Ariver
2026-06-14 d14fa75079de570cb9c4dbf63f6b5bd65f959b09
Fix X-ASR release tail capture
5 files modified
142 ■■■■■ changed files
privatevoice.src/app.go 61 ●●●● 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
@@ -65,6 +65,7 @@
    // State
    mu                sync.Mutex
    isRecording       bool
    isStoppingRecording     bool
    isFreetalking     bool
    hotkeyActive      bool
    hotkeyPressTime   time.Time
@@ -80,6 +81,7 @@
    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")