Ariver
2026-06-23 7eb0b4196ce15c8bfbb0514c54b51cf019c78d24
privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
   appVersion        = "2.1.30"
   appBuild          = "20260607.1127"
   appVersion        = "2.1.34"
   appBuild          = "20260623.1312"
   appDisplayVersion = appVersion + " (build " + appBuild + ")"
   appName           = "PrivateVoice Dictation"
@@ -63,23 +63,27 @@
   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
   liveCaptionCancel context.CancelFunc
   liveCaptionSeq    uint64
   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 {
@@ -279,19 +283,23 @@
      )
   }
   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, "已取消")
@@ -302,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
@@ -325,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 {
@@ -347,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)
@@ -354,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
@@ -400,17 +483,18 @@
      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")
@@ -435,6 +519,7 @@
      return
   }
   a.isRecording = false
   a.isHoldRecordingPending = false
   a.lastStopTime = time.Now()
   if cancel {
@@ -450,16 +535,16 @@
   }
   a.stopLiveCaptionLocked()
   hasVoice := a.recorder.HasVoiceActivity()
   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
@@ -625,11 +710,11 @@
   a.lastStopTime = time.Now()
   a.stopLiveCaptionLocked()
   hasVoice := a.recorder.HasVoiceActivity()
   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)
   }()
}
@@ -688,6 +773,35 @@
   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.
@@ -857,6 +971,9 @@
}
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
@@ -867,6 +984,26 @@
   }
}
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 mode == config.HotkeyModeTap {
      return "点按" + keyName + "说话"