| privatevoice.src/app.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/app_live_caption_test.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/internal/engine/engine.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/internal/engine/engine_darwin.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/internal/engine/engine_darwin_test.go | ●●●●● 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")