Ariver
2026-06-13 859c14d783dc31fe68550b114fc8adeea09dab2a
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          = "20260604.0049"
   appVersion        = "2.1.30"
   appBuild          = "20260607.1127"
   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.
@@ -75,6 +78,8 @@
   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
}
func RunApp() error {
@@ -421,6 +426,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusRecording)
   a.startRecordingTimer(overlay.StatusRecording)
}
@@ -433,6 +439,7 @@
   if cancel {
      logger.Info("Recording cancelled (combination key)")
      a.stopLiveCaptionLocked()
      a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
      a.recorder.Stop()
      if a.cfg.SoundFeedback {
@@ -442,6 +449,7 @@
      return
   }
   a.stopLiveCaptionLocked()
   hasVoice := a.recorder.HasVoiceActivity()
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
   go func() {
@@ -474,6 +482,7 @@
      a.isRecording = false
      return
   }
   a.startLiveCaptionLocked(overlay.StatusFreetalking)
   a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -491,15 +500,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,6 +624,7 @@
   a.isRecording = false
   a.lastStopTime = time.Now()
   a.stopLiveCaptionLocked()
   hasVoice := a.recorder.HasVoiceActivity()
   logger.Info("Tap recording stopped")
   a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
@@ -556,6 +671,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.
@@ -742,12 +858,9 @@
func (a *App) replaceEngine(eng engine.Engine) {
   a.engineMu.Lock()
   defer a.engineMu.Unlock()
   a.mu.Lock()
   old := a.eng
   a.eng = eng
   a.mu.Unlock()
   a.engineMu.Unlock()
   if old != nil && old != eng {
      old.Close()
@@ -961,8 +1074,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
}