| | |
| | | import ( |
| | | "context" |
| | | "fmt" |
| | | "strings" |
| | | "sync" |
| | | "sync/atomic" |
| | | "time" |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.20" |
| | | appBuild = "20260603.1145" |
| | | appVersion = "2.1.31" |
| | | appBuild = "20260614.2056" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Input" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | | silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity) |
| | | silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk |
| | | 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. |
| | |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | |
| | | indicator overlay.Overlay |
| | | |
| | | // State |
| | | mu sync.Mutex |
| | | isRecording bool |
| | | isFreetalking bool |
| | | hotkeyActive bool |
| | | hotkeyPressTime time.Time |
| | | isCombination bool |
| | | tapStopOnPress bool |
| | | isRecordingHotkey bool |
| | | hideGen atomic.Uint64 |
| | | lastStopTime time.Time |
| | | hotkeyPollSeen bool |
| | | lastHotkeyDown bool |
| | | lastHotkeyBlocked time.Time |
| | | silenceSince time.Time // when continuous silence started (free-talk only) |
| | | freeTalkStart time.Time // when free-talk mode started |
| | | mu sync.Mutex |
| | | isRecording bool |
| | | isStoppingRecording bool |
| | | isHoldRecordingPending bool |
| | | isFreetalking bool |
| | | hotkeyActive bool |
| | | hotkeyPressTime time.Time |
| | | isCombination bool |
| | | tapStopOnPress bool |
| | | isRecordingHotkey bool |
| | | hideGen atomic.Uint64 |
| | | lastStopTime time.Time |
| | | hotkeyPollSeen bool |
| | | lastHotkeyDown bool |
| | | 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 |
| | | releaseTailCaptureNanos atomic.Int64 |
| | | holdPreCaptureEnabled atomic.Bool |
| | | } |
| | | |
| | | func RunApp() error { |
| | |
| | | // Create services for Wails bindings |
| | | appService := services.NewAppService(app.cfg, appDisplayVersion) |
| | | configService := services.NewConfigService(app.cfg) |
| | | engineService := services.NewEngineService() |
| | | engineService := services.NewEngineService(app.cfg) |
| | | app.engineService = engineService |
| | | hotkeyService := services.NewHotkeyService(app.cfg) |
| | | permissionService := services.NewPermissionService() |
| | |
| | | ) |
| | | } |
| | | |
| | | if a.eng == nil || a.isRecordingHotkey { |
| | | engineReady := a.eng != nil |
| | | if a.isRecordingHotkey || a.isStoppingRecording { |
| | | if isDown && time.Since(a.lastHotkeyBlocked) > time.Second { |
| | | a.lastHotkeyBlocked = time.Now() |
| | | logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey) |
| | | logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), engineReady, a.isRecordingHotkey, a.isStoppingRecording) |
| | | } |
| | | return |
| | | } |
| | | |
| | | // Escape cancels any active recording |
| | | if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) { |
| | | a.isCombination = true |
| | | a.isFreetalking = false |
| | | a.isRecording = false |
| | | a.isHoldRecordingPending = false |
| | | a.lastStopTime = time.Now() |
| | | a.stopLiveCaptionLocked() |
| | | a.recorder.Stop() |
| | | logger.Info("Recording cancelled (Escape)") |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | |
| | | return |
| | | } |
| | | |
| | | if !engineReady && !a.hotkeyActive && !a.isRecording && !a.isFreetalking { |
| | | if isDown && time.Since(a.lastHotkeyBlocked) > time.Second { |
| | | a.lastHotkeyBlocked = time.Now() |
| | | logger.Info("Hotkey ignored: key=%s engineReady=false recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.isRecordingHotkey, a.isStoppingRecording) |
| | | } |
| | | return |
| | | } |
| | | |
| | | switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) { |
| | | case config.HotkeyModeTap: |
| | | a.pollTapHotkeyLocked(isDown) |
| | | a.pollTapHotkeyLocked(isDown, engineReady) |
| | | default: |
| | | a.pollHoldHotkeyLocked(isDown) |
| | | a.pollHoldHotkeyLocked(isDown, engineReady) |
| | | } |
| | | } |
| | | |
| | | func (a *App) pollHoldHotkeyLocked(isDown bool) { |
| | | func (a *App) pollHoldHotkeyLocked(isDown bool, engineReady bool) { |
| | | if isDown { |
| | | if !a.hotkeyActive { |
| | | // Key just pressed |
| | |
| | | a.stopTapRecordingLocked() |
| | | return |
| | | } |
| | | if engineReady && a.shouldStartHoldPreCaptureLocked() { |
| | | logger.Info("Hotkey action: start hold pre-capture") |
| | | a.startHoldPreCaptureLocked() |
| | | } |
| | | } else { |
| | | // Key held down - check for combination keys |
| | | if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) { |
| | | a.isCombination = true |
| | | logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK)) |
| | | if a.isRecording { |
| | | if a.isHoldRecordingPending { |
| | | a.cancelHoldPreCaptureLocked("combination key") |
| | | } else if a.isRecording { |
| | | a.stopRecordingLocked(true) |
| | | } |
| | | } |
| | | |
| | | // If held long enough without combo, start hold-to-talk recording. |
| | | if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond { |
| | | if engineReady && !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond { |
| | | logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds()) |
| | | a.startRecordingLocked() |
| | | } else if engineReady && a.isHoldRecordingPending && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay { |
| | | logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds()) |
| | | a.confirmHoldPreCaptureLocked() |
| | | } |
| | | } |
| | | } else if a.hotkeyActive { |
| | |
| | | logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination) |
| | | a.hotkeyActive = false |
| | | |
| | | if a.isHoldRecordingPending { |
| | | a.cancelHoldPreCaptureLocked("released before activation") |
| | | return |
| | | } |
| | | |
| | | if a.isRecording { |
| | | // Hold-to-talk: release stops recording |
| | | a.stopRecordingLocked(a.isCombination) |
| | |
| | | } |
| | | } |
| | | |
| | | func (a *App) pollTapHotkeyLocked(isDown bool) { |
| | | func (a *App) shouldStartHoldPreCaptureLocked() bool { |
| | | return a.holdPreCaptureEnabled.Load() && |
| | | !a.isRecording && |
| | | !a.isStoppingRecording && |
| | | !a.isFreetalking && |
| | | time.Since(a.lastStopTime) > 500*time.Millisecond |
| | | } |
| | | |
| | | func (a *App) startHoldPreCaptureLocked() { |
| | | if a.isRecording || a.isStoppingRecording || a.isFreetalking { |
| | | return |
| | | } |
| | | if err := a.recorder.Start(); err != nil { |
| | | logger.Error("Failed to start hold pre-capture: %v", err) |
| | | return |
| | | } |
| | | a.isRecording = true |
| | | a.isHoldRecordingPending = true |
| | | logger.Info("Hold pre-capture started") |
| | | } |
| | | |
| | | func (a *App) confirmHoldPreCaptureLocked() { |
| | | if !a.isHoldRecordingPending || !a.isRecording { |
| | | a.isHoldRecordingPending = false |
| | | return |
| | | } |
| | | a.isHoldRecordingPending = false |
| | | a.hideGen.Add(1) |
| | | |
| | | logger.Info("Hold pre-capture confirmed") |
| | | a.positionIndicator() |
| | | a.indicator.SetStatus(overlay.StatusRecording, "0:00") |
| | | a.indicator.Show() |
| | | if a.cfg.SoundFeedback { |
| | | sound.PlayStart() |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusRecording) |
| | | a.startRecordingTimer(overlay.StatusRecording) |
| | | } |
| | | |
| | | func (a *App) cancelHoldPreCaptureLocked(reason string) { |
| | | if !a.isHoldRecordingPending { |
| | | return |
| | | } |
| | | a.isHoldRecordingPending = false |
| | | if a.isRecording { |
| | | a.isRecording = false |
| | | a.lastStopTime = time.Now() |
| | | a.recorder.Stop() |
| | | } |
| | | logger.Info("Hold pre-capture cancelled: %s", reason) |
| | | } |
| | | |
| | | func (a *App) pollTapHotkeyLocked(isDown bool, engineReady bool) { |
| | | if isDown { |
| | | if !a.hotkeyActive { |
| | | a.hotkeyActive = true |
| | |
| | | return |
| | | } |
| | | |
| | | if time.Since(a.lastStopTime) > 500*time.Millisecond { |
| | | if engineReady && time.Since(a.lastStopTime) > 500*time.Millisecond { |
| | | logger.Info("Hotkey action: start tap recording") |
| | | a.startTapRecordingLocked() |
| | | } |
| | | } |
| | | |
| | | func (a *App) startRecordingLocked() { |
| | | if a.isRecording { |
| | | if a.isRecording || a.isStoppingRecording { |
| | | return |
| | | } |
| | | a.isRecording = true |
| | | a.isHoldRecordingPending = false |
| | | a.hideGen.Add(1) // cancel any pending delayed hide |
| | | |
| | | logger.Info("Recording started") |
| | |
| | | a.isRecording = false |
| | | return |
| | | } |
| | | a.startLiveCaptionLocked(overlay.StatusRecording) |
| | | a.startRecordingTimer(overlay.StatusRecording) |
| | | } |
| | | |
| | |
| | | return |
| | | } |
| | | a.isRecording = false |
| | | a.isHoldRecordingPending = false |
| | | a.lastStopTime = time.Now() |
| | | |
| | | if cancel { |
| | | logger.Info("Recording cancelled (combination key)") |
| | | a.stopLiveCaptionLocked() |
| | | a.indicator.SetStatus(overlay.StatusCancelled, "已取消") |
| | | a.recorder.Stop() |
| | | if a.cfg.SoundFeedback { |
| | |
| | | return |
| | | } |
| | | |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | a.stopLiveCaptionLocked() |
| | | a.isStoppingRecording = true |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | | samples := a.recorder.StopAndGetSamples() |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | }() |
| | | } |
| | | |
| | | func (a *App) startTapRecordingLocked() { |
| | | if a.isRecording || a.isFreetalking { |
| | | if a.isRecording || a.isStoppingRecording || a.isFreetalking { |
| | | return |
| | | } |
| | | a.isFreetalking = true |
| | |
| | | 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() |
| | | |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | a.stopLiveCaptionLocked() |
| | | a.isStoppingRecording = true |
| | | logger.Info("Tap recording stopped") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | go func() { |
| | | samples := a.recorder.StopAndGetSamples() |
| | | samples, hasVoice := a.stopRecorderAfterReleaseTailCapture() |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | }() |
| | | } |
| | |
| | | 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. |
| | |
| | | logger.Info("Free talk stopped (silence auto-stop)") |
| | | a.indicator.SetStatus(overlay.StatusProcessing, "识别中") |
| | | a.recognizeAndPaste(hasVoice, samples) |
| | | } |
| | | |
| | | func (a *App) waitForReleaseTailCapture() { |
| | | delay := a.releaseTailCaptureDelay() |
| | | if delay <= 0 { |
| | | return |
| | | } |
| | | logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds()) |
| | | time.Sleep(delay) |
| | | } |
| | | |
| | | func (a *App) releaseTailCaptureDelay() time.Duration { |
| | | nanos := a.releaseTailCaptureNanos.Load() |
| | | if nanos <= 0 { |
| | | return 0 |
| | | } |
| | | return time.Duration(nanos) |
| | | } |
| | | |
| | | func (a *App) stopRecorderAfterReleaseTailCapture() ([]float32, bool) { |
| | | a.waitForReleaseTailCapture() |
| | | samples := a.recorder.StopAndGetSamples() |
| | | hasVoice := a.recorder.HasVoiceActivity() |
| | | |
| | | a.mu.Lock() |
| | | a.isStoppingRecording = false |
| | | a.mu.Unlock() |
| | | |
| | | return samples, hasVoice |
| | | } |
| | | |
| | | // recognizeAndPaste runs ASR on the recorded samples and pastes the result. |
| | |
| | | return |
| | | } |
| | | |
| | | text, err := a.eng.Recognize(samples) |
| | | a.engineMu.Lock() |
| | | eng := a.eng |
| | | if eng == nil { |
| | | a.engineMu.Unlock() |
| | | logger.Error("Recognition skipped: engine not ready") |
| | | a.indicator.SetStatus(overlay.StatusError, "引擎未就绪") |
| | | a.delayedHideIf(autoHide, 2000) |
| | | return |
| | | } |
| | | text, err := eng.Recognize(samples) |
| | | a.engineMu.Unlock() |
| | | if err != nil { |
| | | logger.Error("Recognition failed: %v", err) |
| | | a.indicator.SetStatus(overlay.StatusError, "错误") |
| | |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | a.replaceEngine(nil) |
| | | status := "need_model" |
| | | if modelExists { |
| | | status = "error" |
| | |
| | | return |
| | | } |
| | | |
| | | a.mu.Lock() |
| | | a.eng = eng |
| | | a.mu.Unlock() |
| | | a.replaceEngine(eng) |
| | | |
| | | logger.Info("ASR engine ready: %s", eng.HardwareInfo()) |
| | | if a.engineService != nil { |
| | |
| | | a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode)) |
| | | a.indicator.Show() |
| | | a.delayedHideIf(autoHide, 2000) |
| | | } |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng))) |
| | | a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng)) |
| | | |
| | | a.engineMu.Lock() |
| | | old := a.eng |
| | | a.eng = eng |
| | | a.engineMu.Unlock() |
| | | |
| | | if old != nil && old != eng { |
| | | old.Close() |
| | | } |
| | | } |
| | | |
| | | func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration { |
| | | tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine) |
| | | if !ok { |
| | | return 0 |
| | | } |
| | | delay := tailCaptureEng.ReleaseTailCaptureDelay() |
| | | if delay < 0 { |
| | | return 0 |
| | | } |
| | | return delay |
| | | } |
| | | |
| | | func holdPreCaptureEnabledForEngine(eng engine.Engine) bool { |
| | | preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine) |
| | | if !ok { |
| | | return false |
| | | } |
| | | return preCaptureEng.HoldPreCaptureEnabled() |
| | | } |
| | | |
| | | func hotkeyReadyText(keyName, mode string) string { |
| | |
| | | if a.recorder != nil { |
| | | a.recorder.Close() |
| | | } |
| | | if a.eng != nil { |
| | | a.eng.Close() |
| | | } |
| | | a.replaceEngine(nil) |
| | | a.wg.Wait() |
| | | logger.Info("Cleanup complete") |
| | | } |
| | |
| | | |
| | | // 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 |
| | | } |
| | | |
| | | // SetEngine sets the engine after model download. |
| | | func (a *App) SetEngine(eng engine.Engine) { |
| | | a.mu.Lock() |
| | | defer a.mu.Unlock() |
| | | a.eng = eng |
| | | a.replaceEngine(eng) |
| | | } |