| | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | | "time" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.28" |
| | | appBuild = "20260604.0049" |
| | | appVersion = "2.1.30" |
| | | appBuild = "20260607.1127" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk |
| | | holdActivationDelay = 180 * time.Millisecond |
| | | doneIndicatorHideDelayMs = 250 |
| | | liveCaptionInterval = 120 * time.Millisecond |
| | | liveCaptionMaxBytes = 108 |
| | | ) |
| | | |
| | | // App holds all application state and orchestration logic. |
| | |
| | | lastHotkeyBlocked time.Time |
| | | silenceSince time.Time // when continuous silence started (free-talk only) |
| | | freeTalkStart time.Time // when free-talk mode started |
| | | liveCaptionCancel context.CancelFunc |
| | | liveCaptionSeq uint64 |
| | | } |
| | | |
| | | func RunApp() error { |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusRecording) |
| | | a.startRecordingTimer(overlay.StatusRecording) |
| | | } |
| | | |
| | |
| | | |
| | | if cancel { |
| | | logger.Info("Recording cancelled (combination key)") |
| | | a.stopLiveCaptionLocked() |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | | a.recorder.Stop() |
| | | if a.cfg.SoundFeedback { |
| | |
| | | return |
| | | } |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusFreetalking) |
| | | a.startRecordingTimer(overlay.StatusFreetalking) |
| | | } |
| | | |
| | |
| | | case <-ticker.C: |
| | | a.mu.Lock() |
| | | recording := a.isRecording |
| | | captionActive := a.liveCaptionCancel != nil |
| | | a.mu.Unlock() |
| | | if !recording { |
| | | return |
| | | } |
| | | if captionActive { |
| | | continue |
| | | } |
| | | d := time.Since(start) |
| | | a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) |
| | | } |
| | | } |
| | | }() |
| | | } |
| | | |
| | | func (a *App) startLiveCaptionLocked(status overlay.Status) { |
| | | if a.liveCaptionCancel != nil { |
| | | return |
| | | } |
| | | |
| | | a.engineMu.Lock() |
| | | streamingEng, ok := a.eng.(engine.StreamingEngine) |
| | | if !ok { |
| | | a.engineMu.Unlock() |
| | | return |
| | | } |
| | | |
| | | ctx, cancel := context.WithCancel(a.ctx) |
| | | a.liveCaptionSeq++ |
| | | seq := a.liveCaptionSeq |
| | | a.liveCaptionCancel = cancel |
| | | go a.runLiveCaption(ctx, seq, status, streamingEng, a.engineMu.Unlock) |
| | | } |
| | | |
| | | func (a *App) stopLiveCaptionLocked() { |
| | | if a.liveCaptionCancel == nil { |
| | | return |
| | | } |
| | | a.liveCaptionCancel() |
| | | a.liveCaptionCancel = nil |
| | | a.liveCaptionSeq++ |
| | | } |
| | | |
| | | func (a *App) clearLiveCaption(seq uint64) { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | if a.liveCaptionSeq == seq { |
| | | a.liveCaptionCancel = nil |
| | | } |
| | | } |
| | | |
| | | func (a *App) runLiveCaption(ctx context.Context, seq uint64, status overlay.Status, streamingEng engine.StreamingEngine, releaseEngine func()) { |
| | | defer a.clearLiveCaption(seq) |
| | | defer releaseEngine() |
| | | |
| | | session, err := streamingEng.NewStreamingSession() |
| | | if err != nil { |
| | | logger.Error("Live caption disabled: %v", err) |
| | | return |
| | | } |
| | | defer session.Close() |
| | | |
| | | ticker := time.NewTicker(liveCaptionInterval) |
| | | defer ticker.Stop() |
| | | |
| | | offset := 0 |
| | | lastDisplay := "" |
| | | for { |
| | | select { |
| | | case <-ctx.Done(): |
| | | return |
| | | case <-ticker.C: |
| | | samples, nextOffset := a.recorder.ReadSamplesSince(offset) |
| | | offset = nextOffset |
| | | if len(samples) == 0 { |
| | | continue |
| | | } |
| | | partial, err := session.Accept(samples) |
| | | if err != nil { |
| | | logger.Error("Live caption failed: %v", err) |
| | | return |
| | | } |
| | | display := liveCaptionDisplayText(a.userdict.Apply(textproc.PostProcess(partial))) |
| | | if display == "" || display == lastDisplay { |
| | | continue |
| | | } |
| | | |
| | | a.mu.Lock() |
| | | active := a.isRecording && a.liveCaptionSeq == seq |
| | | a.mu.Unlock() |
| | | if !active { |
| | | return |
| | | } |
| | | |
| | | lastDisplay = display |
| | | a.indicator.SetStatus(status, display) |
| | | } |
| | | } |
| | | } |
| | | |
| | | func liveCaptionDisplayText(text string) string { |
| | | text = strings.TrimSpace(text) |
| | | if text == "" || len([]byte(text)) <= liveCaptionMaxBytes { |
| | | return text |
| | | } |
| | | |
| | | runes := []rune(text) |
| | | for len(runes) > 0 && len([]byte("..."+string(runes))) > liveCaptionMaxBytes { |
| | | runes = runes[1:] |
| | | } |
| | | if len(runes) == 0 { |
| | | return "" |
| | | } |
| | | return "..." + string(runes) |
| | | } |
| | | |
| | | func (a *App) stopTapRecordingLocked() { |
| | |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | |
| | | a.stopLiveCaptionLocked() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | |
| | | a.isFreetalking = false |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | a.stopLiveCaptionLocked() |
| | | // MUST stop device in a separate goroutine: we are inside the audio |
| | | // data callback, and device.Stop() waits for in-flight callbacks to |
| | | // finish — calling it here would deadlock. |
| | |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.engineMu.Lock() |
| | | defer a.engineMu.Unlock() |
| | | |
| | | a.mu.Lock() |
| | | old := a.eng |
| | | a.eng = eng |
| | | a.mu.Unlock() |
| | | a.engineMu.Unlock() |
| | | |
| | | if old != nil && old != eng { |
| | | old.Close() |
| | |
| | | |
| | | // GetEngine returns the current engine (may be nil). |
| | | func (a *App) GetEngine() engine.Engine { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | a.engineMu.Lock() |
| | | defer a.engineMu.Unlock() |
| | | return a.eng |
| | | } |
| | | |