| CHANGELOG.md | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/app.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/app_live_caption_test.go | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/build/darwin/Info.plist | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/frontend/package-lock.json | ●●●●● patch | view | raw | blame | history | |
| privatevoice.src/frontend/package.json | ●●●●● 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 |
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")