Ariver
2026-06-14 e86df221fa8f9d6796fb7fe59020767d7c806fac
Fix X-ASR hold pre-capture
9 files modified
263 ■■■■■ changed files
CHANGELOG.md 17 ●●●●● patch | view | raw | blame | history
privatevoice.src/app.go 114 ●●●●● patch | view | raw | blame | history
privatevoice.src/app_live_caption_test.go 59 ●●●●● patch | view | raw | blame | history
privatevoice.src/build/darwin/Info.plist 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/package-lock.json 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/package.json 2 ●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go 7 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 22 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 34 ●●●●● patch | view | raw | blame | history
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 实验修复
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 + "说话"
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
}
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>
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"
      },
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",
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 {
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
@@ -37,6 +41,7 @@
    engine   *xasrStreamingEngine
    stream   *sherpa.OnlineStream
    lastText string
    hasAcceptedSamples bool
    finished bool
}
@@ -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)
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")