Ariver
2026-06-29 9d4f977026bbb4516f8cc97f715c0dc28bf2a13f
privatevoice.src/app.go
@@ -3,6 +3,7 @@
import (
   "context"
   "fmt"
   "strings"
   "sync"
   "sync/atomic"
   "time"
@@ -27,16 +28,19 @@
)
const (
   appVersion        = "2.1.21"
   appBuild          = "20260603.1327"
   appVersion        = "2.1.38"
   appBuild          = "20260629.2014"
   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
   defaultReleaseTailDelay  = 300 * time.Millisecond
   doneIndicatorHideDelayMs = 250
   liveCaptionInterval      = 120 * time.Millisecond
   liveCaptionMaxBytes      = 108
)
// App holds all application state and orchestration logic.
@@ -60,21 +64,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
   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 {
@@ -274,19 +284,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, "已取消")
@@ -297,15 +311,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
@@ -320,20 +342,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 {
@@ -342,6 +373,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)
@@ -349,7 +385,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
@@ -395,17 +484,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")
@@ -421,6 +511,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusRecording)
   a.startRecordingTimer(overlay.StatusRecording)
}
@@ -429,10 +520,12 @@
      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 {
@@ -442,16 +535,20 @@
      return
   }
   hasVoice := a.recorder.HasVoiceActivity()
   a.stopLiveCaptionLocked()
   a.isStoppingRecording = true
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
   go func() {
      samples := a.recorder.StopAndGetSamples()
      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)
   }()
}
func (a *App) startTapRecordingLocked() {
   if a.isRecording || a.isFreetalking {
   if a.isRecording || a.isStoppingRecording || a.isFreetalking {
      return
   }
   a.isFreetalking = true
@@ -474,6 +571,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusFreetalking)
   a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -491,15 +589,120 @@
         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() {
@@ -510,12 +713,16 @@
   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()
      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)
   }()
}
@@ -556,6 +763,7 @@
      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.
@@ -566,17 +774,85 @@
// 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(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 {
   nanos := a.releaseTailCaptureNanos.Load()
   if nanos <= 0 {
      return 0
   }
   return time.Duration(nanos)
}
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
@@ -585,78 +861,156 @@
   copyToClipboard := a.cfg.CopyToClipboard
   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)
   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),
      perfDurationMS(recognizeElapsed),
      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()
   }
   logger.Info("PERF pipeline_done id=%d result=success total_ms=%d", perfID, perfSinceMS(pipelineStart))
   a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
}
// 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.
@@ -741,17 +1095,43 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
   a.engineMu.Lock()
   defer a.engineMu.Unlock()
   a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
   a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
   a.mu.Lock()
   a.engineMu.Lock()
   old := a.eng
   a.eng = eng
   a.mu.Unlock()
   a.engineMu.Unlock()
   if old != nil && old != eng {
      old.Close()
   }
}
func releaseTailCaptureDelayForEngine(eng engine.Engine) time.Duration {
   if eng == nil {
      return 0
   }
   tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine)
   if !ok {
      return defaultReleaseTailDelay
   }
   delay := tailCaptureEng.ReleaseTailCaptureDelay()
   if delay < 0 {
      return 0
   }
   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 {
@@ -961,8 +1341,8 @@
// 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
}