From d14fa75079de570cb9c4dbf63f6b5bd65f959b09 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 14 Jun 2026 20:14:34 +0800
Subject: [PATCH] Fix X-ASR release tail capture
---
privatevoice.src/app.go | 256 +++++++++++++++++++++++++++++++++++++++++++-------
1 files changed, 217 insertions(+), 39 deletions(-)
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 8a39b1e..7412d0a 100755
--- a/privatevoice.src/app.go
+++ b/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.30"
+ appBuild = "20260613.2201"
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,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 {
@@ -111,7 +119,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,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
}
@@ -401,7 +409,7 @@
}
func (a *App) startRecordingLocked() {
- if a.isRecording {
+ if a.isRecording || a.isStoppingRecording {
return
}
a.isRecording = true
@@ -420,6 +428,7 @@
a.isRecording = false
return
}
+ a.startLiveCaptionLocked(overlay.StatusRecording)
a.startRecordingTimer(overlay.StatusRecording)
}
@@ -432,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 {
@@ -441,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
@@ -473,6 +484,7 @@
a.isRecording = false
return
}
+ a.startLiveCaptionLocked(overlay.StatusFreetalking)
a.startRecordingTimer(overlay.StatusFreetalking)
}
@@ -490,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() {
@@ -509,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)
}()
}
@@ -555,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.
@@ -571,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.
@@ -597,7 +745,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 +845,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 +860,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 +885,31 @@
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.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 hotkeyReadyText(keyName, mode string) string {
@@ -911,9 +1093,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 +1119,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)
}
--
Gitblit v1.9.3