Ariver
2026-06-14 d14fa75079de570cb9c4dbf63f6b5bd65f959b09
privatevoice.src/app.go
@@ -3,6 +3,7 @@
import (
   "context"
   "fmt"
   "strings"
   "sync"
   "sync/atomic"
   "time"
@@ -27,8 +28,8 @@
)
const (
   appVersion        = "2.1.28"
   appBuild          = "20260605.0353"
   appVersion        = "2.1.30"
   appBuild          = "20260613.2201"
   appDisplayVersion = appVersion + " (build " + appBuild + ")"
   appName           = "PrivateVoice Dictation"
@@ -37,6 +38,8 @@
   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.
@@ -60,21 +63,25 @@
   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
   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
}
func RunApp() error {
@@ -274,10 +281,10 @@
      )
   }
   if a.eng == nil || a.isRecordingHotkey {
   if a.eng == nil || 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), a.eng != nil, a.isRecordingHotkey, a.isStoppingRecording)
      }
      return
   }
@@ -402,7 +409,7 @@
}
func (a *App) startRecordingLocked() {
   if a.isRecording {
   if a.isRecording || a.isStoppingRecording {
      return
   }
   a.isRecording = true
@@ -421,6 +428,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusRecording)
   a.startRecordingTimer(overlay.StatusRecording)
}
@@ -433,6 +441,7 @@
   if cancel {
      logger.Info("Recording cancelled (combination key)")
      a.stopLiveCaptionLocked()
      a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
      a.recorder.Stop()
      if a.cfg.SoundFeedback {
@@ -442,16 +451,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
@@ -474,6 +484,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusFreetalking)
   a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -491,15 +502,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,11 +626,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)
   }()
}
@@ -556,6 +673,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.
@@ -572,6 +690,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.
@@ -741,17 +888,28 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
   a.engineMu.Lock()
   defer a.engineMu.Unlock()
   a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(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 {
   tailCaptureEng, ok := eng.(engine.ReleaseTailCaptureEngine)
   if !ok {
      return 0
   }
   delay := tailCaptureEng.ReleaseTailCaptureDelay()
   if delay < 0 {
      return 0
   }
   return delay
}
func hotkeyReadyText(keyName, mode string) string {
@@ -961,8 +1119,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
}