Ariver
2026-06-14 e86df221fa8f9d6796fb7fe59020767d7c806fac
privatevoice.src/app.go
@@ -3,6 +3,7 @@
import (
   "context"
   "fmt"
   "strings"
   "sync"
   "sync/atomic"
   "time"
@@ -27,16 +28,18 @@
)
const (
   appVersion        = "2.1.20"
   appBuild          = "20260603.1145"
   appVersion        = "2.1.31"
   appBuild          = "20260614.2056"
   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
   doneIndicatorHideDelayMs = 250
   liveCaptionInterval      = 120 * time.Millisecond
   liveCaptionMaxBytes      = 108
)
// App holds all application state and orchestration logic.
@@ -48,6 +51,7 @@
   cfg      *config.Config
   recorder *audio.Recorder
   eng      engine.Engine
   engineMu sync.Mutex
   hk       hotkey.Listener
   paster   input.Paster
   history  *history.Store
@@ -59,21 +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
   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 {
@@ -111,7 +121,7 @@
   // Create services for Wails bindings
   appService := services.NewAppService(app.cfg, appDisplayVersion)
   configService := services.NewConfigService(app.cfg)
   engineService := services.NewEngineService()
   engineService := services.NewEngineService(app.cfg)
   app.engineService = engineService
   hotkeyService := services.NewHotkeyService(app.cfg)
   permissionService := services.NewPermissionService()
@@ -273,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, "已取消")
@@ -296,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
@@ -319,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 {
@@ -341,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)
@@ -348,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
@@ -394,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")
@@ -420,6 +510,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusRecording)
   a.startRecordingTimer(overlay.StatusRecording)
}
@@ -428,10 +519,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 {
@@ -441,16 +534,17 @@
      return
   }
   hasVoice := a.recorder.HasVoiceActivity()
   a.stopLiveCaptionLocked()
   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
@@ -473,6 +567,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusFreetalking)
   a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -490,15 +585,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() {
@@ -509,11 +709,12 @@
   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()
      samples, hasVoice := a.stopRecorderAfterReleaseTailCapture()
      a.recognizeAndPaste(hasVoice, samples)
   }()
}
@@ -555,6 +756,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.
@@ -571,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.
@@ -597,7 +828,17 @@
      return
   }
   text, err := a.eng.Recognize(samples)
   a.engineMu.Lock()
   eng := a.eng
   if eng == nil {
      a.engineMu.Unlock()
      logger.Error("Recognition skipped: engine not ready")
      a.indicator.SetStatus(overlay.StatusError, "引擎未就绪")
      a.delayedHideIf(autoHide, 2000)
      return
   }
   text, err := eng.Recognize(samples)
   a.engineMu.Unlock()
   if err != nil {
      logger.Error("Recognition failed: %v", err)
      a.indicator.SetStatus(overlay.StatusError, "错误")
@@ -687,6 +928,7 @@
   eng, err := engine.New()
   if err != nil {
      logger.Error("Engine initialization failed: %v", err)
      a.replaceEngine(nil)
      status := "need_model"
      if modelExists {
         status = "error"
@@ -701,9 +943,7 @@
      return
   }
   a.mu.Lock()
   a.eng = eng
   a.mu.Unlock()
   a.replaceEngine(eng)
   logger.Info("ASR engine ready: %s", eng.HardwareInfo())
   if a.engineService != nil {
@@ -728,6 +968,40 @@
   a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode))
   a.indicator.Show()
   a.delayedHideIf(autoHide, 2000)
}
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
   a.engineMu.Unlock()
   if old != nil && old != eng {
      old.Close()
   }
}
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 {
@@ -911,9 +1185,7 @@
   if a.recorder != nil {
      a.recorder.Close()
   }
   if a.eng != nil {
      a.eng.Close()
   }
   a.replaceEngine(nil)
   a.wg.Wait()
   logger.Info("Cleanup complete")
}
@@ -939,14 +1211,12 @@
// 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
}
// SetEngine sets the engine after model download.
func (a *App) SetEngine(eng engine.Engine) {
   a.mu.Lock()
   defer a.mu.Unlock()
   a.eng = eng
   a.replaceEngine(eng)
}