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/internal/engine/engine_darwin.go      |    6 ++
 privatevoice.src/internal/engine/engine_darwin_test.go |   14 ++++
 privatevoice.src/app.go                                |   95 +++++++++++++++++++++++--------
 privatevoice.src/app_live_caption_test.go              |   54 ++++++++++++++++++
 privatevoice.src/internal/engine/engine.go             |    7 ++
 5 files changed, 151 insertions(+), 25 deletions(-)

diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 73a8aec..7412d0a 100755
--- a/privatevoice.src/app.go
+++ b/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 + "说话"
diff --git a/privatevoice.src/app_live_caption_test.go b/privatevoice.src/app_live_caption_test.go
index 55b8316..b8804c1 100644
--- a/privatevoice.src/app_live_caption_test.go
+++ b/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
+}
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index 3f939c1..c3e564d 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/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 {
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index 3fc59e1..d530c83 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/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)
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
index fdfbf8e..a46bcea 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/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")

--
Gitblit v1.9.3