From e86df221fa8f9d6796fb7fe59020767d7c806fac Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 14 Jun 2026 20:57:40 +0800
Subject: [PATCH] Fix X-ASR hold pre-capture

---
 privatevoice.src/frontend/package.json                 |    2 
 privatevoice.src/internal/engine/engine_darwin.go      |   30 +++++-
 privatevoice.src/internal/engine/engine_darwin_test.go |   34 ++++++
 privatevoice.src/app.go                                |  114 ++++++++++++++++++++--
 privatevoice.src/app_live_caption_test.go              |   59 +++++++++++
 privatevoice.src/internal/engine/engine.go             |    7 +
 privatevoice.src/frontend/package-lock.json            |    4 
 CHANGELOG.md                                           |   17 +++
 privatevoice.src/build/darwin/Info.plist               |    4 
 9 files changed, 250 insertions(+), 21 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 237feff..30508e8 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
 # Changelog
 
+## v2.1.31 (2026-06-14)
+
+### X-ASR 实验修复
+
+- **X-ASR 首字保护**:hold 模式在按下热键时立即开始预采集音频,确认不是组合键后再显示浮窗并进入正式录音,降低“你看看”等短句首字丢失概率。
+- **组合键与短按隔离**:预采集阶段检测到组合键或用户过早松开热键时会丢弃音频,不进入识别流程。
+- **X-ASR 开头上下文 padding**:X-ASR streaming session 首次接收音频时补 250ms 前置静音,提升贴近开头的短音节稳定性。
+- **取消路径加固**:Escape 取消会停止实时字幕 session,并阻止同一次按住手势在 debounce 后重新启动录音;引擎不可用时仍允许已有录音释放/取消清理完成。
+- **隔离范围**:首字保护能力仅由 X-ASR streaming engine 启用;SenseVoice、Qwen3-ASR、Moonshine、Parakeet 不启用预采集路径。
+
+### 构建
+
+- build: `20260614.2056`
+- 说明: 本版本为 X-ASR 首字/尾字稳定性实验测试包,不是 App Store 正式上传包。
+
+---
+
 ## v2.1.30 (2026-06-07)
 
 ### X-ASR 实验修复
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 7412d0a..5fd76fb 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -28,8 +28,8 @@
 )
 
 const (
-	appVersion        = "2.1.30"
-	appBuild          = "20260613.2201"
+	appVersion        = "2.1.31"
+	appBuild          = "20260614.2056"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "PrivateVoice Dictation"
 
@@ -66,6 +66,7 @@
 	mu                      sync.Mutex
 	isRecording             bool
 	isStoppingRecording     bool
+	isHoldRecordingPending  bool
 	isFreetalking           bool
 	hotkeyActive            bool
 	hotkeyPressTime         time.Time
@@ -82,6 +83,7 @@
 	liveCaptionCancel       context.CancelFunc
 	liveCaptionSeq          uint64
 	releaseTailCaptureNanos atomic.Int64
+	holdPreCaptureEnabled   atomic.Bool
 }
 
 func RunApp() error {
@@ -281,19 +283,23 @@
 		)
 	}
 
-	if a.eng == nil || a.isRecordingHotkey || a.isStoppingRecording {
+	engineReady := a.eng != nil
+	if 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 stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey, a.isStoppingRecording)
+			logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), engineReady, a.isRecordingHotkey, a.isStoppingRecording)
 		}
 		return
 	}
 
 	// Escape cancels any active recording
 	if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
+		a.isCombination = true
 		a.isFreetalking = false
 		a.isRecording = false
+		a.isHoldRecordingPending = false
 		a.lastStopTime = time.Now()
+		a.stopLiveCaptionLocked()
 		a.recorder.Stop()
 		logger.Info("Recording cancelled (Escape)")
 		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
@@ -304,15 +310,23 @@
 		return
 	}
 
+	if !engineReady && !a.hotkeyActive && !a.isRecording && !a.isFreetalking {
+		if isDown && time.Since(a.lastHotkeyBlocked) > time.Second {
+			a.lastHotkeyBlocked = time.Now()
+			logger.Info("Hotkey ignored: key=%s engineReady=false recordingHotkey=%t stoppingRecording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.isRecordingHotkey, a.isStoppingRecording)
+		}
+		return
+	}
+
 	switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) {
 	case config.HotkeyModeTap:
-		a.pollTapHotkeyLocked(isDown)
+		a.pollTapHotkeyLocked(isDown, engineReady)
 	default:
-		a.pollHoldHotkeyLocked(isDown)
+		a.pollHoldHotkeyLocked(isDown, engineReady)
 	}
 }
 
-func (a *App) pollHoldHotkeyLocked(isDown bool) {
+func (a *App) pollHoldHotkeyLocked(isDown bool, engineReady bool) {
 	if isDown {
 		if !a.hotkeyActive {
 			// Key just pressed
@@ -327,20 +341,29 @@
 				a.stopTapRecordingLocked()
 				return
 			}
+			if engineReady && a.shouldStartHoldPreCaptureLocked() {
+				logger.Info("Hotkey action: start hold pre-capture")
+				a.startHoldPreCaptureLocked()
+			}
 		} else {
 			// Key held down - check for combination keys
 			if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
 				a.isCombination = true
 				logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
-				if a.isRecording {
+				if a.isHoldRecordingPending {
+					a.cancelHoldPreCaptureLocked("combination key")
+				} else if a.isRecording {
 					a.stopRecordingLocked(true)
 				}
 			}
 
 			// If held long enough without combo, start hold-to-talk recording.
-			if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond {
+			if engineReady && !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond {
 				logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
 				a.startRecordingLocked()
+			} else if engineReady && a.isHoldRecordingPending && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay {
+				logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
+				a.confirmHoldPreCaptureLocked()
 			}
 		}
 	} else if a.hotkeyActive {
@@ -349,6 +372,11 @@
 		logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination)
 		a.hotkeyActive = false
 
+		if a.isHoldRecordingPending {
+			a.cancelHoldPreCaptureLocked("released before activation")
+			return
+		}
+
 		if a.isRecording {
 			// Hold-to-talk: release stops recording
 			a.stopRecordingLocked(a.isCombination)
@@ -356,7 +384,60 @@
 	}
 }
 
-func (a *App) pollTapHotkeyLocked(isDown bool) {
+func (a *App) shouldStartHoldPreCaptureLocked() bool {
+	return a.holdPreCaptureEnabled.Load() &&
+		!a.isRecording &&
+		!a.isStoppingRecording &&
+		!a.isFreetalking &&
+		time.Since(a.lastStopTime) > 500*time.Millisecond
+}
+
+func (a *App) startHoldPreCaptureLocked() {
+	if a.isRecording || a.isStoppingRecording || a.isFreetalking {
+		return
+	}
+	if err := a.recorder.Start(); err != nil {
+		logger.Error("Failed to start hold pre-capture: %v", err)
+		return
+	}
+	a.isRecording = true
+	a.isHoldRecordingPending = true
+	logger.Info("Hold pre-capture started")
+}
+
+func (a *App) confirmHoldPreCaptureLocked() {
+	if !a.isHoldRecordingPending || !a.isRecording {
+		a.isHoldRecordingPending = false
+		return
+	}
+	a.isHoldRecordingPending = false
+	a.hideGen.Add(1)
+
+	logger.Info("Hold pre-capture confirmed")
+	a.positionIndicator()
+	a.indicator.SetStatus(overlay.StatusRecording, "0:00")
+	a.indicator.Show()
+	if a.cfg.SoundFeedback {
+		sound.PlayStart()
+	}
+	a.startLiveCaptionLocked(overlay.StatusRecording)
+	a.startRecordingTimer(overlay.StatusRecording)
+}
+
+func (a *App) cancelHoldPreCaptureLocked(reason string) {
+	if !a.isHoldRecordingPending {
+		return
+	}
+	a.isHoldRecordingPending = false
+	if a.isRecording {
+		a.isRecording = false
+		a.lastStopTime = time.Now()
+		a.recorder.Stop()
+	}
+	logger.Info("Hold pre-capture cancelled: %s", reason)
+}
+
+func (a *App) pollTapHotkeyLocked(isDown bool, engineReady bool) {
 	if isDown {
 		if !a.hotkeyActive {
 			a.hotkeyActive = true
@@ -402,7 +483,7 @@
 		return
 	}
 
-	if time.Since(a.lastStopTime) > 500*time.Millisecond {
+	if engineReady && time.Since(a.lastStopTime) > 500*time.Millisecond {
 		logger.Info("Hotkey action: start tap recording")
 		a.startTapRecordingLocked()
 	}
@@ -413,6 +494,7 @@
 		return
 	}
 	a.isRecording = true
+	a.isHoldRecordingPending = false
 	a.hideGen.Add(1) // cancel any pending delayed hide
 
 	logger.Info("Recording started")
@@ -437,6 +519,7 @@
 		return
 	}
 	a.isRecording = false
+	a.isHoldRecordingPending = false
 	a.lastStopTime = time.Now()
 
 	if cancel {
@@ -889,6 +972,7 @@
 
 func (a *App) replaceEngine(eng engine.Engine) {
 	a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
+	a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
 
 	a.engineMu.Lock()
 	old := a.eng
@@ -912,6 +996,14 @@
 	return delay
 }
 
+func holdPreCaptureEnabledForEngine(eng engine.Engine) bool {
+	preCaptureEng, ok := eng.(engine.HoldPreCaptureEngine)
+	if !ok {
+		return false
+	}
+	return preCaptureEng.HoldPreCaptureEnabled()
+}
+
 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 b8804c1..7dd3a0a 100644
--- a/privatevoice.src/app_live_caption_test.go
+++ b/privatevoice.src/app_live_caption_test.go
@@ -38,6 +38,56 @@
 	}
 }
 
+func TestHoldPreCaptureUsesEngineCapability(t *testing.T) {
+	a := &App{}
+	a.replaceEngine(holdPreCaptureTestEngine{enabled: true})
+
+	if !a.holdPreCaptureEnabled.Load() {
+		t.Fatal("hold pre-capture should be enabled by engine capability")
+	}
+}
+
+func TestHoldPreCaptureDefaultsToFalse(t *testing.T) {
+	a := &App{}
+	a.replaceEngine(plainTestEngine{})
+
+	if a.holdPreCaptureEnabled.Load() {
+		t.Fatal("hold pre-capture should default to false")
+	}
+}
+
+func TestShouldStartHoldPreCaptureLocked(t *testing.T) {
+	a := &App{}
+	a.holdPreCaptureEnabled.Store(true)
+
+	if !a.shouldStartHoldPreCaptureLocked() {
+		t.Fatal("expected hold pre-capture to start when enabled and idle")
+	}
+
+	a.isRecording = true
+	if a.shouldStartHoldPreCaptureLocked() {
+		t.Fatal("should not pre-capture while already recording")
+	}
+	a.isRecording = false
+
+	a.isStoppingRecording = true
+	if a.shouldStartHoldPreCaptureLocked() {
+		t.Fatal("should not pre-capture while stopping")
+	}
+	a.isStoppingRecording = false
+
+	a.isFreetalking = true
+	if a.shouldStartHoldPreCaptureLocked() {
+		t.Fatal("should not pre-capture while tap recording is active")
+	}
+	a.isFreetalking = false
+
+	a.lastStopTime = time.Now()
+	if a.shouldStartHoldPreCaptureLocked() {
+		t.Fatal("should not pre-capture during post-stop debounce")
+	}
+}
+
 func TestStartRecordingIgnoredWhileStoppingRecording(t *testing.T) {
 	a := &App{isStoppingRecording: true}
 
@@ -72,3 +122,12 @@
 func (e tailCaptureTestEngine) ReleaseTailCaptureDelay() time.Duration {
 	return e.delay
 }
+
+type holdPreCaptureTestEngine struct {
+	plainTestEngine
+	enabled bool
+}
+
+func (e holdPreCaptureTestEngine) HoldPreCaptureEnabled() bool {
+	return e.enabled
+}
diff --git a/privatevoice.src/build/darwin/Info.plist b/privatevoice.src/build/darwin/Info.plist
index a9c5b5a..95314f9 100755
--- a/privatevoice.src/build/darwin/Info.plist
+++ b/privatevoice.src/build/darwin/Info.plist
@@ -17,9 +17,9 @@
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>2.1.30</string>
+    <string>2.1.31</string>
     <key>CFBundleVersion</key>
-    <string>20260613.2201</string>
+    <string>20260614.2056</string>
     <key>LSApplicationCategoryType</key>
     <string>public.app-category.productivity</string>
     <key>ITSAppUsesNonExemptEncryption</key>
diff --git a/privatevoice.src/frontend/package-lock.json b/privatevoice.src/frontend/package-lock.json
index 198db7d..4c533ad 100755
--- a/privatevoice.src/frontend/package-lock.json
+++ b/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "privatevoice-dictation-frontend",
-  "version": "2.1.30",
+  "version": "2.1.31",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "privatevoice-dictation-frontend",
-      "version": "2.1.30",
+      "version": "2.1.31",
       "dependencies": {
         "@wailsio/runtime": "latest"
       },
diff --git a/privatevoice.src/frontend/package.json b/privatevoice.src/frontend/package.json
index f19f9b4..7fb1459 100755
--- a/privatevoice.src/frontend/package.json
+++ b/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
 {
   "name": "privatevoice-dictation-frontend",
   "private": true,
-  "version": "2.1.30",
+  "version": "2.1.31",
   "type": "module",
   "scripts": {
     "dev": "vite dev",
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index c3e564d..e2b918d 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/privatevoice.src/internal/engine/engine.go
@@ -33,6 +33,13 @@
 	ReleaseTailCaptureDelay() time.Duration
 }
 
+// HoldPreCaptureEngine is implemented by engines that should start capturing
+// audio immediately on hold-to-talk key down, before the activation delay has
+// confirmed that the hotkey was not part of a key combination.
+type HoldPreCaptureEngine interface {
+	HoldPreCaptureEnabled() bool
+}
+
 // 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 d530c83..5caceb8 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -16,11 +16,15 @@
 
 const (
 	asrSampleRate          = 16000
+	xasrHeadPaddingSamples = asrSampleRate / 4
 	xasrTailPaddingSamples = asrSampleRate + asrSampleRate/2
 	xasrReleaseTailDelay   = 300 * time.Millisecond
 )
 
-var xasrTailPadding = make([]float32, xasrTailPaddingSamples)
+var (
+	xasrHeadPadding = make([]float32, xasrHeadPaddingSamples)
+	xasrTailPadding = make([]float32, xasrTailPaddingSamples)
+)
 
 type sherpaEngine struct {
 	recognizer *sherpa.OfflineRecognizer
@@ -34,10 +38,11 @@
 }
 
 type xasrStreamingSession struct {
-	engine   *xasrStreamingEngine
-	stream   *sherpa.OnlineStream
-	lastText string
-	finished bool
+	engine             *xasrStreamingEngine
+	stream             *sherpa.OnlineStream
+	lastText           string
+	hasAcceptedSamples bool
+	finished           bool
 }
 
 func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
@@ -235,6 +240,10 @@
 	if s.stream == nil || s.finished {
 		return s.lastText, nil
 	}
+	if !s.hasAcceptedSamples {
+		samples = xasrSamplesWithHeadPadding(samples)
+		s.hasAcceptedSamples = true
+	}
 
 	s.engine.mu.Lock()
 	defer s.engine.mu.Unlock()
@@ -295,6 +304,13 @@
 	return nextText
 }
 
+func xasrSamplesWithHeadPadding(samples []float32) []float32 {
+	padded := make([]float32, 0, len(xasrHeadPadding)+len(samples))
+	padded = append(padded, xasrHeadPadding...)
+	padded = append(padded, samples...)
+	return padded
+}
+
 func (e *sherpaEngine) HardwareInfo() string {
 	return e.hwInfo
 }
@@ -307,6 +323,10 @@
 	return xasrReleaseTailDelay
 }
 
+func (e *xasrStreamingEngine) HoldPreCaptureEnabled() bool {
+	return true
+}
+
 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 a46bcea..f2db4e4 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/privatevoice.src/internal/engine/engine_darwin_test.go
@@ -164,6 +164,28 @@
 	}
 }
 
+func TestXASRHeadPaddingPreparesInitialContext(t *testing.T) {
+	if xasrHeadPaddingSamples != 4000 {
+		t.Fatalf("head padding samples = %d, want 4000", xasrHeadPaddingSamples)
+	}
+	input := []float32{0.1, -0.2, 0.3}
+	got := xasrSamplesWithHeadPadding(input)
+
+	if len(got) != xasrHeadPaddingSamples+len(input) {
+		t.Fatalf("padded len = %d, want %d", len(got), xasrHeadPaddingSamples+len(input))
+	}
+	for i := 0; i < xasrHeadPaddingSamples; i++ {
+		if got[i] != 0 {
+			t.Fatalf("head padding sample %d = %v, want 0", i, got[i])
+		}
+	}
+	for i, want := range input {
+		if got[xasrHeadPaddingSamples+i] != want {
+			t.Fatalf("payload sample %d = %v, want %v", i, got[xasrHeadPaddingSamples+i], want)
+		}
+	}
+}
+
 func TestXASRRequestsReleaseTailCaptureDelay(t *testing.T) {
 	got := (&xasrStreamingEngine{}).ReleaseTailCaptureDelay()
 	if got != 300*time.Millisecond {
@@ -171,12 +193,24 @@
 	}
 }
 
+func TestXASREnablesHoldPreCapture(t *testing.T) {
+	if !(&xasrStreamingEngine{}).HoldPreCaptureEnabled() {
+		t.Fatal("X-ASR should enable hold pre-capture")
+	}
+}
+
 func TestOfflineSherpaDoesNotRequestReleaseTailCapture(t *testing.T) {
 	if _, ok := any(&sherpaEngine{}).(ReleaseTailCaptureEngine); ok {
 		t.Fatal("offline sherpa engine should not request release tail capture")
 	}
 }
 
+func TestOfflineSherpaDoesNotEnableHoldPreCapture(t *testing.T) {
+	if _, ok := any(&sherpaEngine{}).(HoldPreCaptureEngine); ok {
+		t.Fatal("offline sherpa engine should not enable hold pre-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