Ariver
2026-06-14 e86df221fa8f9d6796fb7fe59020767d7c806fac
privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
   appVersion        = "2.1.30"
   appBuild          = "20260613.2201"
   appVersion        = "2.1.31"
   appBuild          = "20260614.2056"
   appDisplayVersion = appVersion + " (build " + appBuild + ")"
   appName           = "PrivateVoice Dictation"
@@ -66,6 +66,7 @@
   mu                      sync.Mutex
   isRecording             bool
   isStoppingRecording     bool
   isHoldRecordingPending  bool
   isFreetalking           bool
   hotkeyActive            bool
   hotkeyPressTime         time.Time
@@ -82,6 +83,7 @@
   liveCaptionCancel       context.CancelFunc
   liveCaptionSeq          uint64
   releaseTailCaptureNanos atomic.Int64
   holdPreCaptureEnabled   atomic.Bool
}
func RunApp() error {
@@ -281,19 +283,23 @@
      )
   }
   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, "已取消")
@@ -304,15 +310,23 @@
      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
@@ -327,20 +341,29 @@
            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 {
@@ -349,6 +372,11 @@
      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)
@@ -356,7 +384,60 @@
   }
}
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
@@ -402,7 +483,7 @@
      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()
   }
@@ -413,6 +494,7 @@
      return
   }
   a.isRecording = true
   a.isHoldRecordingPending = false
   a.hideGen.Add(1) // cancel any pending delayed hide
   logger.Info("Recording started")
@@ -437,6 +519,7 @@
      return
   }
   a.isRecording = false
   a.isHoldRecordingPending = false
   a.lastStopTime = time.Now()
   if cancel {
@@ -889,6 +972,7 @@
func (a *App) replaceEngine(eng engine.Engine) {
   a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
   a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
   a.engineMu.Lock()
   old := a.eng
@@ -912,6 +996,14 @@
   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 + "说话"