| 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
@@ -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")