| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.30" |
| | | appBuild = "20260613.2201" |
| | | appVersion = "2.1.31" |
| | | appBuild = "20260614.2056" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | mu sync.Mutex |
| | | isRecording bool |
| | | isStoppingRecording bool |
| | | isHoldRecordingPending bool |
| | | isFreetalking bool |
| | | hotkeyActive bool |
| | | hotkeyPressTime time.Time |
| | |
| | | liveCaptionCancel context.CancelFunc |
| | | liveCaptionSeq uint64 |
| | | releaseTailCaptureNanos atomic.Int64 |
| | | holdPreCaptureEnabled atomic.Bool |
| | | } |
| | | |
| | | func RunApp() error { |
| | |
| | | ) |
| | | } |
| | | |
| | | if a.eng == nil || a.isRecordingHotkey || a.isStoppingRecording { |
| | | 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 stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey, a.isStoppingRecording) |
| | | 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() |
| | | } |
| | |
| | | return |
| | | } |
| | | a.isRecording = true |
| | | a.isHoldRecordingPending = false |
| | | a.hideGen.Add(1) // cancel any pending delayed hide |
| | | |
| | | logger.Info("Recording started") |
| | |
| | | return |
| | | } |
| | | a.isRecording = false |
| | | a.isHoldRecordingPending = false |
| | | a.lastStopTime = time.Now() |
| | | |
| | | if cancel { |
| | |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng))) |
| | | a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng)) |
| | | |
| | | a.engineMu.Lock() |
| | | old := a.eng |
| | |
| | | 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 mode == config.HotkeyModeTap { |
| | | return "点按" + keyName + "说话" |