Ariver
2026-06-14 d14fa75079de570cb9c4dbf63f6b5bd65f959b09
Fix X-ASR release tail capture
5 files modified
176 ■■■■ changed files
privatevoice.src/app.go 95 ●●●● patch | view | raw | blame | history
privatevoice.src/app_live_caption_test.go 54 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go 7 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 6 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 14 ●●●●● patch | view | raw | blame | history
privatevoice.src/app.go
@@ -63,23 +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
    liveCaptionCancel context.CancelFunc
    liveCaptionSeq    uint64
    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 {
@@ -279,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
    }
@@ -407,7 +409,7 @@
}
func (a *App) startRecordingLocked() {
    if a.isRecording {
    if a.isRecording || a.isStoppingRecording {
        return
    }
    a.isRecording = true
@@ -450,16 +452,16 @@
    }
    a.stopLiveCaptionLocked()
    hasVoice := a.recorder.HasVoiceActivity()
    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
@@ -625,11 +627,11 @@
    a.lastStopTime = time.Now()
    a.stopLiveCaptionLocked()
    hasVoice := a.recorder.HasVoiceActivity()
    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)
    }()
}
@@ -688,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.
@@ -857,6 +888,8 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
    a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
    a.engineMu.Lock()
    old := a.eng
    a.eng = eng
@@ -867,6 +900,18 @@
    }
}
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 {
    if mode == config.HotkeyModeTap {
        return "点按" + keyName + "说话"
privatevoice.src/app_live_caption_test.go
@@ -3,6 +3,7 @@
import (
    "strings"
    "testing"
    "time"
)
func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
@@ -18,3 +19,56 @@
        t.Fatalf("caption = %q, want leading truncation marker", got)
    }
}
func TestReleaseTailCaptureDelayUsesEngineCapability(t *testing.T) {
    a := &App{}
    a.replaceEngine(tailCaptureTestEngine{delay: 300 * time.Millisecond})
    if got := a.releaseTailCaptureDelay(); got != 300*time.Millisecond {
        t.Fatalf("releaseTailCaptureDelay() = %v, want 300ms", got)
    }
}
func TestReleaseTailCaptureDelayDefaultsToZero(t *testing.T) {
    a := &App{}
    a.replaceEngine(plainTestEngine{})
    if got := a.releaseTailCaptureDelay(); got != 0 {
        t.Fatalf("releaseTailCaptureDelay() = %v, want 0", got)
    }
}
func TestStartRecordingIgnoredWhileStoppingRecording(t *testing.T) {
    a := &App{isStoppingRecording: true}
    a.startRecordingLocked()
    if a.isRecording {
        t.Fatal("startRecordingLocked should not start while stop tail capture is pending")
    }
}
func TestStartTapRecordingIgnoredWhileStoppingRecording(t *testing.T) {
    a := &App{isStoppingRecording: true}
    a.startTapRecordingLocked()
    if a.isRecording || a.isFreetalking {
        t.Fatal("startTapRecordingLocked should not start while stop tail capture is pending")
    }
}
type plainTestEngine struct{}
func (plainTestEngine) Recognize([]float32) (string, error) { return "", nil }
func (plainTestEngine) HardwareInfo() string                { return "test" }
func (plainTestEngine) Close()                              {}
type tailCaptureTestEngine struct {
    plainTestEngine
    delay time.Duration
}
func (e tailCaptureTestEngine) ReleaseTailCaptureDelay() time.Duration {
    return e.delay
}
privatevoice.src/internal/engine/engine.go
@@ -2,6 +2,7 @@
import (
    "fmt"
    "time"
    "voicesnap/internal/config"
    "voicesnap/internal/language"
    "voicesnap/internal/logger"
@@ -26,6 +27,12 @@
    NewStreamingSession() (StreamingSession, error)
}
// ReleaseTailCaptureEngine is implemented by engines that need a short audio
// capture grace period after the user releases the recording hotkey.
type ReleaseTailCaptureEngine interface {
    ReleaseTailCaptureDelay() time.Duration
}
// StreamingSession receives incremental 16kHz mono PCM and returns the current
// best transcript for the active utterance.
type StreamingSession interface {
privatevoice.src/internal/engine/engine_darwin.go
@@ -6,6 +6,7 @@
    "fmt"
    "strings"
    "sync"
    "time"
    "voicesnap/internal/logger"
    "voicesnap/internal/model"
@@ -16,6 +17,7 @@
const (
    asrSampleRate          = 16000
    xasrTailPaddingSamples = asrSampleRate + asrSampleRate/2
    xasrReleaseTailDelay   = 300 * time.Millisecond
)
var xasrTailPadding = make([]float32, xasrTailPaddingSamples)
@@ -301,6 +303,10 @@
    return e.hwInfo
}
func (e *xasrStreamingEngine) ReleaseTailCaptureDelay() time.Duration {
    return xasrReleaseTailDelay
}
func (e *sherpaEngine) Close() {
    if e.recognizer != nil {
        sherpa.DeleteOfflineRecognizer(e.recognizer)
privatevoice.src/internal/engine/engine_darwin_test.go
@@ -6,6 +6,7 @@
    "os"
    "path/filepath"
    "testing"
    "time"
    "voicesnap/internal/model"
)
@@ -163,6 +164,19 @@
    }
}
func TestXASRRequestsReleaseTailCaptureDelay(t *testing.T) {
    got := (&xasrStreamingEngine{}).ReleaseTailCaptureDelay()
    if got != 300*time.Millisecond {
        t.Fatalf("ReleaseTailCaptureDelay() = %v, want 300ms", got)
    }
}
func TestOfflineSherpaDoesNotRequestReleaseTailCapture(t *testing.T) {
    if _, ok := any(&sherpaEngine{}).(ReleaseTailCaptureEngine); ok {
        t.Fatal("offline sherpa engine should not request release tail capture")
    }
}
func TestXASRRealModelSmoke(t *testing.T) {
    if os.Getenv("PRIVATEVOICE_XASR_SMOKE") != "1" {
        t.Skip("set PRIVATEVOICE_XASR_SMOKE=1 to run the real X-ASR model smoke test")