Ariver
2026-07-01 2ba1056c56435f8d98110eca18dba90dc344de66
privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
   appVersion        = "2.1.30"
   appBuild          = "20260613.2201"
   appVersion        = "2.1.39"
   appBuild          = "20260701.2356"
   appDisplayVersion = appVersion + " (build " + appBuild + ")"
   appName           = "PrivateVoice Dictation"
@@ -37,6 +37,8 @@
   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
   slowRecognizeThresholdMs = int64(2000)
   slowPipelineThresholdMs  = int64(2500)
   doneIndicatorHideDelayMs = 250
   liveCaptionInterval      = 120 * time.Millisecond
   liveCaptionMaxBytes      = 108
@@ -66,6 +68,7 @@
   mu                      sync.Mutex
   isRecording             bool
   isStoppingRecording     bool
   isHoldRecordingPending  bool
   isFreetalking           bool
   hotkeyActive            bool
   hotkeyPressTime         time.Time
@@ -82,6 +85,7 @@
   liveCaptionCancel       context.CancelFunc
   liveCaptionSeq          uint64
   releaseTailCaptureNanos atomic.Int64
   holdPreCaptureEnabled   atomic.Bool
}
func RunApp() error {
@@ -281,19 +285,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 +312,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 +343,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 +374,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 +386,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 +485,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 +496,7 @@
      return
   }
   a.isRecording = true
   a.isHoldRecordingPending = false
   a.hideGen.Add(1) // cancel any pending delayed hide
   logger.Info("Recording started")
@@ -437,6 +521,7 @@
      return
   }
   a.isRecording = false
   a.isHoldRecordingPending = false
   a.lastStopTime = time.Now()
   if cancel {
@@ -455,8 +540,11 @@
   a.isStoppingRecording = true
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
   go func() {
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture()
      a.recognizeAndPaste(hasVoice, samples)
      perfID := newPerfTraceID()
      pipelineStart := time.Now()
      logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
      a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
   }()
}
@@ -631,8 +719,11 @@
   logger.Info("Tap recording stopped")
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
   go func() {
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture()
      a.recognizeAndPaste(hasVoice, samples)
      perfID := newPerfTraceID()
      pipelineStart := time.Now()
      logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
      a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
   }()
}
@@ -684,21 +775,43 @@
// silenceAutoStop performs the actual device stop and ASR after a silence timeout.
// Runs in its own goroutine to avoid deadlocking the audio callback thread.
func (a *App) silenceAutoStop() {
   perfID := newPerfTraceID()
   pipelineStart := time.Now()
   logger.Info("PERF pipeline_start id=%d mode=silence_auto_stop", perfID)
   voiceStart := time.Now()
   hasVoice := a.recorder.HasVoiceActivity()
   voiceElapsed := time.Since(voiceStart)
   stopStart := time.Now()
   samples := a.recorder.StopAndGetSamples()
   stopElapsed := time.Since(stopStart)
   logger.Info(
      "PERF audio_stop_done id=%d mode=silence_auto_stop stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d",
      perfID,
      perfDurationMS(stopElapsed),
      perfDurationMS(voiceElapsed),
      len(samples),
      perfAudioDurationMS(samples),
      hasVoice,
      perfSinceMS(pipelineStart),
   )
   logger.Info("Free talk stopped (silence auto-stop)")
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
   a.recognizeAndPaste(hasVoice, samples)
   a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
}
func (a *App) waitForReleaseTailCapture() {
func (a *App) waitForReleaseTailCapture(perfID int64) {
   delay := a.releaseTailCaptureDelay()
   if delay <= 0 {
      logger.Info("PERF release_tail_wait_done id=%d configured_ms=0 elapsed_ms=0", perfID)
      return
   }
   logger.Info("Release tail capture: waiting %dms before stopping audio", delay.Milliseconds())
   start := time.Now()
   time.Sleep(delay)
   logger.Info("PERF release_tail_wait_done id=%d configured_ms=%d elapsed_ms=%d", perfID, delay.Milliseconds(), perfSinceMS(start))
}
func (a *App) releaseTailCaptureDelay() time.Duration {
@@ -709,101 +822,261 @@
   return time.Duration(nanos)
}
func (a *App) stopRecorderAfterReleaseTailCapture() ([]float32, bool) {
   a.waitForReleaseTailCapture()
func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
   a.waitForReleaseTailCapture(perfID)
   stopStart := time.Now()
   samples := a.recorder.StopAndGetSamples()
   stopElapsed := time.Since(stopStart)
   voiceStart := time.Now()
   hasVoice := a.recorder.HasVoiceActivity()
   voiceElapsed := time.Since(voiceStart)
   a.mu.Lock()
   a.isStoppingRecording = false
   a.mu.Unlock()
   logger.Info(
      "PERF audio_stop_done id=%d stop_ms=%d voice_check_ms=%d samples=%d audio_ms=%d has_voice=%t since_start_ms=%d",
      perfID,
      perfDurationMS(stopElapsed),
      perfDurationMS(voiceElapsed),
      len(samples),
      perfAudioDurationMS(samples),
      hasVoice,
      perfSinceMS(pipelineStart),
   )
   return samples, hasVoice
}
// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
// Shared by both hold and tap modes.
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) {
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) {
   // Snapshot config values under lock to avoid data races
   a.mu.Lock()
   soundFeedback := a.cfg.SoundFeedback
   hotkeyVK := a.cfg.HotkeyVK
   autoHide := a.cfg.AutoHide
   copyToClipboard := a.cfg.CopyToClipboard
   selectedModelID := a.cfg.SelectedModelID
   languageID := a.cfg.LanguageID
   a.mu.Unlock()
   logger.Info(
      "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
      perfID,
      hasVoice,
      len(samples),
      perfAudioDurationMS(samples),
      copyToClipboard,
      perfSinceMS(pipelineStart),
   )
   if !hasVoice {
      logger.Info("No voice activity detected")
      logger.Info("PERF pipeline_done id=%d result=no_voice total_ms=%d", perfID, perfSinceMS(pipelineStart))
      a.indicator.SetStatus(overlay.StatusNoVoice, "无语音")
      a.delayedHideIf(autoHide, 1500)
      return
   }
   if len(samples) == 0 {
      logger.Info("PERF pipeline_done id=%d result=no_samples total_ms=%d", perfID, perfSinceMS(pipelineStart))
      a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
      a.delayedHideIf(autoHide, 1500)
      return
   }
   engineLockStart := time.Now()
   a.engineMu.Lock()
   engineLockElapsed := time.Since(engineLockStart)
   eng := a.eng
   if eng == nil {
      a.engineMu.Unlock()
      logger.Error("Recognition skipped: engine not ready")
      logger.Info("PERF pipeline_done id=%d result=engine_not_ready engine_lock_ms=%d total_ms=%d", perfID, perfDurationMS(engineLockElapsed), perfSinceMS(pipelineStart))
      a.indicator.SetStatus(overlay.StatusError, "引擎未就绪")
      a.delayedHideIf(autoHide, 2000)
      return
   }
   recognizeStart := time.Now()
   text, err := eng.Recognize(samples)
   recognizeElapsed := time.Since(recognizeStart)
   recognizeElapsedMS := perfDurationMS(recognizeElapsed)
   a.engineMu.Unlock()
   logger.Info(
      "PERF recognize_done id=%d engine_lock_wait_ms=%d recognize_ms=%d samples=%d audio_ms=%d since_start_ms=%d",
      perfID,
      perfDurationMS(engineLockElapsed),
      recognizeElapsedMS,
      len(samples),
      perfAudioDurationMS(samples),
      perfSinceMS(pipelineStart),
   )
   if err != nil {
      logger.Error("Recognition failed: %v", err)
      logger.Info("PERF pipeline_done id=%d result=recognize_error total_ms=%d", perfID, perfSinceMS(pipelineStart))
      a.indicator.SetStatus(overlay.StatusError, "错误")
      a.delayedHideIf(autoHide, 2000)
      return
   }
   postProcessStart := time.Now()
   text = textproc.PostProcess(text)
   text = a.userdict.Apply(text)
   postProcessElapsed := time.Since(postProcessStart)
   logger.Info(
      "PERF text_postprocess_done id=%d postprocess_ms=%d text_runes=%d since_start_ms=%d",
      perfID,
      perfDurationMS(postProcessElapsed),
      len([]rune(text)),
      perfSinceMS(pipelineStart),
   )
   if text == "" {
      logger.Info("PERF pipeline_done id=%d result=empty_text total_ms=%d", perfID, perfSinceMS(pipelineStart))
      a.indicator.SetStatus(overlay.StatusNoContent, "无内容")
      a.delayedHideIf(autoHide, 1500)
      return
   }
   logger.Info("Recognized: %s", text)
   historyStart := time.Now()
   a.history.Add(text)
   logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
   a.waitForHotkeyRelease(hotkeyVK)
   hotkeyWaitStart := time.Now()
   polls, released := a.waitForHotkeyRelease(hotkeyVK)
   logger.Info(
      "PERF hotkey_release_wait_done id=%d wait_ms=%d polls=%d released=%t since_start_ms=%d",
      perfID,
      perfSinceMS(hotkeyWaitStart),
      polls,
      released,
      perfSinceMS(pipelineStart),
   )
   pasteStart := time.Now()
   if err := a.paster.Paste(text, copyToClipboard); err != nil {
      pasteElapsed := time.Since(pasteStart)
      logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
      logger.Error("Paste failed, trying fallback: %v", err)
      typeStart := time.Now()
      if err := a.paster.TypeText(text); err != nil {
         logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
         logger.Error("Fallback type also failed: %v", err)
         a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
         a.delayedHideIf(autoHide, 2500)
         return
      }
      logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
   } else {
      logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
   }
   a.indicator.SetStatus(overlay.StatusDone, "完成")
   if soundFeedback {
      sound.PlayDone()
   }
   totalMS := perfSinceMS(pipelineStart)
   logger.Info("PERF pipeline_done id=%d result=success total_ms=%d", perfID, totalMS)
   logSlowRecognitionIfNeeded(perfID, eng, selectedModelID, languageID, samples, recognizeElapsedMS, totalMS)
   a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
}
func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) {
   reason := slowRecognitionReason(recognizeMS, totalMS)
   if reason == "" {
      return
   }
   engineInfo := ""
   if eng != nil {
      engineInfo = eng.HardwareInfo()
   }
   load, hasLoad := currentSystemLoadAverage()
   if hasLoad {
      logger.Info(
         "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load1=%.2f load5=%.2f load15=%.2f",
         perfID,
         reason,
         modelID,
         languageID,
         engineInfo,
         perfAudioDurationMS(samples),
         len(samples),
         recognizeMS,
         totalMS,
         load[0],
         load[1],
         load[2],
      )
      return
   }
   logger.Info(
      "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load_unavailable=true",
      perfID,
      reason,
      modelID,
      languageID,
      engineInfo,
      perfAudioDurationMS(samples),
      len(samples),
      recognizeMS,
      totalMS,
   )
}
func slowRecognitionReason(recognizeMS, totalMS int64) string {
   recognizeSlow := recognizeMS > slowRecognizeThresholdMs
   totalSlow := totalMS > slowPipelineThresholdMs
   switch {
   case recognizeSlow && totalSlow:
      return "recognize,total"
   case recognizeSlow:
      return "recognize"
   case totalSlow:
      return "total"
   default:
      return ""
   }
}
// waitForHotkeyRelease polls until the hotkey is released (max 500ms),
// then waits an additional 50ms settling delay.
func (a *App) waitForHotkeyRelease(hotkeyVK int) {
func (a *App) waitForHotkeyRelease(hotkeyVK int) (int, bool) {
   polls := 0
   released := false
   for i := 0; i < 50; i++ {
      polls = i + 1
      if !a.hk.IsKeyDown(hotkeyVK) {
         released = true
         break
      }
      time.Sleep(10 * time.Millisecond)
   }
   time.Sleep(50 * time.Millisecond)
   return polls, released
}
func newPerfTraceID() int64 {
   return time.Now().UnixNano()
}
func perfSinceMS(start time.Time) int64 {
   return perfDurationMS(time.Since(start))
}
func perfDurationMS(duration time.Duration) int64 {
   return duration.Milliseconds()
}
func perfAudioDurationMS(samples []float32) int64 {
   return int64(len(samples)) * 1000 / 16000
}
// delayedHide hides the indicator after a delay if AutoHide is enabled.
@@ -889,6 +1162,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
@@ -901,6 +1175,9 @@
}
func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration {
   if eng == nil {
      return 0
   }
   tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine)
   if !ok {
      return 0
@@ -912,6 +1189,17 @@
   return delay
}
func holdPreCaptureEnabledForEngine(eng engine.Engine) bool {
   if eng == nil {
      return false
   }
   preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine)
   if !ok {
      return true
   }
   return preCaptureEng.HoldPreCaptureEnabled()
}
func hotkeyReadyText(keyName, mode string) string {
   if mode == config.HotkeyModeTap {
      return "点按" + keyName + "说话"