From 5fbe9789faf05ce99bbff6ec4cd0b905519acbd0 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 03 Jul 2026 05:18:07 +0800
Subject: [PATCH] Freeze R221 2.2.2 source baseline
---
C1.source/privatevoice.src/frontend/package.json | 2
C1.source/privatevoice.src/internal/logger/logger.go | 43 +
C1.source/privatevoice.src/internal/hotkey/hotkey.go | 2
.gitignore | 5
C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte | 5
C1.source/privatevoice.src/scripts/build-windows-preview.sh | 4
C1.source/privatevoice.src/build/config.yml | 2
C1.source/privatevoice.src/scripts/benchmark-recognition.sh | 21
C1.source/privatevoice.src/services/diagnostics_service.go | 196 +++++++
C1.source/privatevoice.src/frontend/package-lock.json | 4
K2.项目管理/restore-notes.md | 6
K2.项目管理/R221-1-开发自查.md | 101 ++++
C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte | 22
C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go | 2
C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go | 2
C1.source/privatevoice.src/internal/engine/engine_darwin.go | 58 ++
C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go | 41 +
C1.source/privatevoice.src/internal/engine/engine_windows.go | 28 +
C1.source/privatevoice.src/internal/engine/engine_linux.go | 28 +
K2.项目管理/R221-3-QA-evidence.md | 96 +++
C1.source/privatevoice.src/app_live_caption_test.go | 101 ++++
C1.source/privatevoice.src/app.go | 260 ++++++++--
K2.项目管理/迁移资产清单.md | 5
C1.source/privatevoice.src/internal/audio/recorder_test.go | 21
C1.source/privatevoice.src/frontend/src/App.svelte | 40
C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte | 17
CHANGELOG.md | 16
C1.source/privatevoice.src/cmd/benchmark-recognition/main.go | 115 ++++
K2.项目管理/session-topics/00-Topics.md | 4
C1.source/privatevoice.src/internal/audio/recorder.go | 41 +
CODEGRAPH.md | 20
README.md | 2
C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte | 9
K2.项目管理/handoffs/2026-07-02-迁移交接.md | 7
C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 22
C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte | 16
K1.业务知识/成果地图.md | 1
AGENTS.md | 25
C1.source/privatevoice.src/internal/overlay/overlay_windows.go | 21
K2.项目管理/新窗口接手指南.md | 18
C1.source/privatevoice.src/internal/engine/engine.go | 14
C1.source/privatevoice.src/build/darwin/Info.plist | 4
K2.项目管理/接收验证记录.md | 2
C1.source/privatevoice.src/internal/overlay/overlay_darwin.go | 35 +
44 files changed, 1,343 insertions(+), 141 deletions(-)
diff --git a/.gitignore b/.gitignore
index 1b99ff6..0da0a00 100755
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# OS/editor noise
.DS_Store
+._*
*.swp
*.swo
@@ -57,6 +58,10 @@
/.codex/
/.worktrees/
+# Recovery input snapshots are not active source roots.
+/C1.source/privatevoice-2.2.1-recovered.src/
+/C1.source/privatevoice-2.2.1-recovered.src.RECOVERY.md
+
# Release packages, certificates, logs, and upload artifacts are local outputs.
/Release/
diff --git a/AGENTS.md b/AGENTS.md
index 54d697b..6dc822f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,26 +7,29 @@
如果当前工作目录在 `03-O` 内,先回到项目根入口读取:
-1. `/Users/ar/Projects/PrivateVoice2/AGENTS.md`
-2. `/Users/ar/Projects/PrivateVoice2/PROJECT.md`
-3. `/Users/ar/Projects/PrivateVoice2/INDEX.md`
-4. `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md`
-5. `/Users/ar/Projects/PrivateVoice2/03-O/CODEGRAPH.md`
+1. `/Users/ar/Projects/PrivateVoice/AGENTS.md`
+2. `/Users/ar/Projects/PrivateVoice/PROJECT.md`
+3. `/Users/ar/Projects/PrivateVoice/INDEX.md`
+4. `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`
+5. `/Users/ar/Projects/PrivateVoice/03-O/CODEGRAPH.md`
旧 `K2.项目管理/legacy/` 资料只作证据附件,不能作为活跃任务来源。
## 路径边界
-- 项目根:`/Users/ar/Projects/PrivateVoice2`
-- Git 根:`/Users/ar/Projects/PrivateVoice2/03-O`
-- 源码根:`/Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src`
-- 构建/发布候选包:`/Users/ar/Projects/PrivateVoice2/03-O/C2.builds`
+- 项目根:`/Users/ar/Projects/PrivateVoice`
+- Git 根:`/Users/ar/Projects/PrivateVoice/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`
+- 构建/发布候选包:`/Users/ar/Projects/PrivateVoice/03-O/C2.builds`
- 构建与 QA 脚本:源码内 `C1.source/privatevoice.src/scripts/`,`C3.tools/` 只放索引和迁移工具说明。
-- 会话记忆目录:`/Users/ar/Projects/PrivateVoice2/.codex-sessionhistory/`
+- 会话记忆目录:`/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/`
+- 常规会话主题汇编目录:`/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。`K2.项目管理/session-topics/` 只保存旧项目迁移压缩摘要,不得作为新项目常规主题汇编落点。
## 工作规则
-- 统一活跃台账是 `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md`。
+- 统一活跃台账是 `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`。
- `CODEGRAPH.md` 是人工维护工程地图;自动索引或缓存不能替代它。
+- `codebase-memory` 状态为 `enabled`;标准刷新命令是 `codebase-memory-mcp cli index_repository '{"repo_path":"/Users/ar/Projects/PrivateVoice/03-O"}'`,共享产物为 `.codebase-memory/graph.db.zst` 和 `.codebase-memory/artifact.json`。
+- 每次源码冻结、版本冻结、正式可回滚测试包或发布包前必须刷新 `.codebase-memory/`;日常 Coder 中间态变更不强制刷新。
- 发布、QA、签名、打包和版本冻结必须遵守项目根入口声明的全局规则路由。
- 标准验证命令必须保持原始可执行形式,不在命令本身追加输出截断管道。
diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 0a0c428..9acb5f6 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -3,6 +3,7 @@
import (
"context"
"fmt"
+ "runtime"
"strings"
"sync"
"sync/atomic"
@@ -17,6 +18,8 @@
"voicesnap/internal/input"
"voicesnap/internal/language"
"voicesnap/internal/logger"
+ "voicesnap/internal/model"
+ "voicesnap/internal/modelselection"
"voicesnap/internal/overlay"
"voicesnap/internal/sound"
"voicesnap/internal/textproc"
@@ -28,8 +31,8 @@
)
const (
- appVersion = "2.2.0"
- appBuild = "20260702.0303"
+ appVersion = "2.2.2"
+ appBuild = "20260703.0335"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
@@ -50,14 +53,18 @@
cancel context.CancelFunc
wg sync.WaitGroup
- cfg *config.Config
- recorder *audio.Recorder
- eng engine.Engine
- engineMu sync.Mutex
- hk hotkey.Listener
- paster input.Paster
- history *history.Store
- userdict *userdict.Store
+ cfg *config.Config
+ recorder *audio.Recorder
+ eng engine.Engine
+ engineMu sync.Mutex
+ engineInitMu sync.Mutex
+ engineInitSeq atomic.Uint64
+ engineMeta engineMetadata
+ engineFactory engineFactoryFunc
+ hk hotkey.Listener
+ paster input.Paster
+ history *history.Store
+ userdict *userdict.Store
engineService *services.EngineService
wailsApp *application.App
@@ -86,6 +93,24 @@
liveCaptionSeq uint64
releaseTailCaptureNanos atomic.Int64
holdPreCaptureEnabled atomic.Bool
+}
+
+type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
+
+type engineMetadata struct {
+ InitID uint64
+ ResolvedModelID string
+ ConfigSelectedModelID string
+ SelectionMode string
+ LanguageMode string
+ ConfigLanguageID string
+ EffectiveLanguageID string
+ BackendKind string
+ Provider string
+ LanguageParam string
+ FallbackReason string
+ NumThreads int
+ HardwareInfo string
}
func RunApp() error {
@@ -132,6 +157,7 @@
historyService := services.NewHistoryService(app.history)
userDictService := services.NewUserDictService(app.userdict)
correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
+ diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
// Create Wails application
wailsApp := application.New(application.Options{
@@ -148,6 +174,7 @@
application.NewService(historyService),
application.NewService(userDictService),
application.NewService(correctionCSVService),
+ application.NewService(diagnosticsService),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
@@ -588,9 +615,20 @@
case <-a.ctx.Done():
return
case <-ticker.C:
+ d := time.Since(start)
a.mu.Lock()
recording := a.isRecording
captionActive := a.liveCaptionCancel != nil
+ if recording && d >= audio.MaxRecordingDuration {
+ logger.Error("Recording reached max duration; auto-stopping max_seconds=%d", int(audio.MaxRecordingDuration/time.Second))
+ if a.isFreetalking {
+ a.stopTapRecordingLocked()
+ } else {
+ a.stopRecordingLocked(false)
+ }
+ a.mu.Unlock()
+ return
+ }
a.mu.Unlock()
if !recording {
return
@@ -598,7 +636,6 @@
if captionActive {
continue
}
- d := time.Since(start)
a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60))
}
}
@@ -860,8 +897,6 @@
hotkeyVK := a.cfg.HotkeyVK
autoHide := a.cfg.AutoHide
copyToClipboard := a.cfg.CopyToClipboard
- selectedModelID := a.cfg.SelectedModelID
- languageID := a.cfg.LanguageID
a.mu.Unlock()
logger.Info(
@@ -893,6 +928,7 @@
a.engineMu.Lock()
engineLockElapsed := time.Since(engineLockStart)
eng := a.eng
+ engMeta := a.engineMeta
if eng == nil {
a.engineMu.Unlock()
logger.Error("Recognition skipped: engine not ready")
@@ -982,52 +1018,84 @@
}
totalMS := perfSinceMS(pipelineStart)
logger.Info("PERF pipeline_done id=%d result=success total_ms=%d", perfID, totalMS)
- logSlowRecognitionIfNeeded(perfID, eng, selectedModelID, languageID, samples, recognizeElapsedMS, totalMS)
+ logSlowRecognitionIfNeeded(perfID, engMeta, samples, recognizeElapsedMS, totalMS)
a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
}
-func logSlowRecognitionIfNeeded(perfID int64, eng engine.Engine, modelID, languageID string, samples []float32, recognizeMS, totalMS int64) {
+func logSlowRecognitionIfNeeded(perfID int64, meta engineMetadata, samples []float32, recognizeMS, totalMS int64) {
reason := slowRecognitionReason(recognizeMS, totalMS)
if reason == "" {
return
}
- engineInfo := ""
- if eng != nil {
- engineInfo = eng.HardwareInfo()
+ audioMS := perfAudioDurationMS(samples)
+ rtf := 0.0
+ if audioMS > 0 {
+ rtf = float64(recognizeMS) / float64(audioMS)
}
-
+ numCPU := runtime.NumCPU()
load, hasLoad := currentSystemLoadAverage()
if hasLoad {
+ loadPerCore := 0.0
+ if numCPU > 0 {
+ loadPerCore = load[0] / float64(numCPU)
+ }
logger.Info(
- "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load1=%.2f load5=%.2f load15=%.2f",
+ "PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load1=%.2f load5=%.2f load15=%.2f load1_per_core=%.2f cpu_count=%d",
perfID,
reason,
- modelID,
- languageID,
- engineInfo,
- perfAudioDurationMS(samples),
+ meta.ResolvedModelID,
+ meta.ResolvedModelID,
+ meta.ConfigSelectedModelID,
+ meta.SelectionMode,
+ meta.LanguageMode,
+ meta.ConfigLanguageID,
+ meta.EffectiveLanguageID,
+ meta.BackendKind,
+ meta.Provider,
+ meta.LanguageParam,
+ meta.FallbackReason,
+ meta.NumThreads,
+ meta.HardwareInfo,
+ meta.InitID,
+ audioMS,
len(samples),
recognizeMS,
totalMS,
+ rtf,
load[0],
load[1],
load[2],
+ loadPerCore,
+ numCPU,
)
return
}
logger.Info(
- "PERF slow_recognition id=%d reason=%s model_id=%q language_id=%q engine=%q audio_ms=%d samples=%d recognize_ms=%d total_ms=%d load_unavailable=true",
+ "PERF slow_recognition id=%d reason=%s model_id=%q resolved_model_id=%q cfg_selected_model_id=%q selection_mode=%q language_mode=%q config_language_id=%q effective_language_id=%q backend=%q provider=%q language_param=%q fallback_reason=%q num_threads=%d engine=%q init_id=%d audio_ms=%d samples=%d recognize_ms=%d total_ms=%d rtf=%.3f load_unavailable=true cpu_count=%d",
perfID,
reason,
- modelID,
- languageID,
- engineInfo,
- perfAudioDurationMS(samples),
+ meta.ResolvedModelID,
+ meta.ResolvedModelID,
+ meta.ConfigSelectedModelID,
+ meta.SelectionMode,
+ meta.LanguageMode,
+ meta.ConfigLanguageID,
+ meta.EffectiveLanguageID,
+ meta.BackendKind,
+ meta.Provider,
+ meta.LanguageParam,
+ meta.FallbackReason,
+ meta.NumThreads,
+ meta.HardwareInfo,
+ meta.InitID,
+ audioMS,
len(samples),
recognizeMS,
totalMS,
+ rtf,
+ numCPU,
)
}
@@ -1106,18 +1174,22 @@
}
func (a *App) initEngine() {
- logger.Info("Initializing ASR engine...")
- if a.engineService != nil {
- a.engineService.SetStatus("loading", "", "")
- }
- a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
- "status": "loading",
- })
+ initID := a.engineInitSeq.Add(1)
- modelExists := engine.ModelExists()
- eng, err := engine.New()
+ a.engineInitMu.Lock()
+ defer a.engineInitMu.Unlock()
+
+ if initID != a.engineInitSeq.Load() {
+ logger.Info("Skipping superseded ASR engine init: init_id=%d current_init_id=%d", initID, a.engineInitSeq.Load())
+ return
+ }
+
+ logger.Info("Initializing ASR engine... init_id=%d", initID)
+ a.setEngineStatus("loading", "", "")
+
+ eng, meta, modelExists, err := a.createEngineForInit(initID)
if err != nil {
- logger.Error("Engine initialization failed: %v", err)
+ logger.Error("Engine initialization failed: init_id=%d model_id=%q error=%v", initID, meta.ResolvedModelID, err)
if a.hasEngine() {
logger.Info("Keeping existing ASR engine after reload failure")
} else {
@@ -1127,26 +1199,24 @@
if modelExists {
status = "error"
}
- if a.engineService != nil {
- a.engineService.SetStatus(status, "", err.Error())
- }
- a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
- "status": status,
- "error": err.Error(),
- })
+ a.setEngineStatus(status, "", err.Error())
return
}
- a.replaceEngine(eng)
-
- logger.Info("ASR engine ready: %s", eng.HardwareInfo())
- if a.engineService != nil {
- a.engineService.SetStatus("ready", eng.HardwareInfo(), "")
+ if initID != a.engineInitSeq.Load() {
+ logger.Info("Discarding stale ASR engine init: init_id=%d current_init_id=%d model_id=%q", initID, a.engineInitSeq.Load(), meta.ResolvedModelID)
+ eng.Close()
+ return
}
- a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
- "status": "ready",
- "hardwareInfo": eng.HardwareInfo(),
- })
+
+ a.replaceEngineWithMetadata(eng, meta)
+
+ logger.Info("ASR engine ready: init_id=%d model_id=%q backend=%s provider=%s hardware=%s", initID, meta.ResolvedModelID, meta.BackendKind, meta.Provider, eng.HardwareInfo())
+ a.setEngineStatus("ready", eng.HardwareInfo(), "")
+
+ if a.indicator == nil {
+ return
+ }
// Show indicator briefly — snapshot config under lock
a.mu.Lock()
@@ -1164,6 +1234,77 @@
a.delayedHideIf(autoHide, 2000)
}
+func (a *App) createEngineForInit(initID uint64) (engine.Engine, engineMetadata, bool, error) {
+ if a.engineFactory != nil {
+ return a.engineFactory(initID)
+ }
+
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Error("Failed to load config for engine init: %v", err)
+ cfg = a.cfg
+ if cfg == nil {
+ cfg = config.Default()
+ }
+ }
+
+ current := modelselection.Resolve(cfg, language.NewSystemDetector())
+ meta := engineMetadata{
+ InitID: initID,
+ ResolvedModelID: current.ModelID,
+ ConfigSelectedModelID: cfg.SelectedModelID,
+ SelectionMode: current.SelectionMode,
+ LanguageMode: config.NormalizeLanguageMode(cfg.LanguageMode),
+ ConfigLanguageID: cfg.LanguageID,
+ EffectiveLanguageID: current.LanguageSettings.EffectiveLanguageID,
+ FallbackReason: current.FallbackReason,
+ }
+
+ resolved, err := model.ResolveModel(model.NormalizeModelID(current.ModelID))
+ if err != nil {
+ return nil, meta, false, err
+ }
+ meta.ResolvedModelID = resolved.ModelID
+ meta.BackendKind = resolved.BackendKind
+ meta.LanguageParam = resolved.Profile.LanguageParam
+ meta.NumThreads = resolved.Profile.NumThreads
+
+ modelExists := resolved.IsUsable()
+ if !modelExists {
+ return nil, meta, false, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems)
+ }
+
+ eng, err := engine.NewWithResolvedModel(resolved)
+ if err != nil {
+ return nil, meta, modelExists, err
+ }
+ meta.HardwareInfo = eng.HardwareInfo()
+ if providerEng, ok := eng.(engine.ProviderEngine); ok {
+ meta.Provider = providerEng.Provider()
+ }
+
+ return eng, meta, modelExists, nil
+}
+
+func (a *App) setEngineStatus(status, hardwareInfo, errText string) {
+ if a.engineService != nil {
+ a.engineService.SetStatus(status, hardwareInfo, errText)
+ }
+ if a.wailsApp == nil {
+ return
+ }
+ payload := map[string]interface{}{
+ "status": status,
+ }
+ if hardwareInfo != "" {
+ payload["hardwareInfo"] = hardwareInfo
+ }
+ if errText != "" {
+ payload["error"] = errText
+ }
+ a.wailsApp.Event.Emit("engine:status", payload)
+}
+
func (a *App) hasEngine() bool {
a.engineMu.Lock()
defer a.engineMu.Unlock()
@@ -1171,12 +1312,17 @@
}
func (a *App) replaceEngine(eng engine.Engine) {
+ a.replaceEngineWithMetadata(eng, engineMetadata{})
+}
+
+func (a *App) replaceEngineWithMetadata(eng engine.Engine, meta engineMetadata) {
a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng)))
a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng))
a.engineMu.Lock()
old := a.eng
a.eng = eng
+ a.engineMeta = meta
a.engineMu.Unlock()
if old != nil && old != eng {
@@ -1391,9 +1537,13 @@
if a.recorder != nil {
a.recorder.Close()
}
+ if a.hk != nil {
+ a.hk.Close()
+ }
a.replaceEngine(nil)
a.wg.Wait()
logger.Info("Cleanup complete")
+ logger.Close()
}
// SetRecordingHotkey is called by the hotkey service when entering hotkey recording mode.
diff --git a/C1.source/privatevoice.src/app_live_caption_test.go b/C1.source/privatevoice.src/app_live_caption_test.go
index 2c1610d..accb99c 100644
--- a/C1.source/privatevoice.src/app_live_caption_test.go
+++ b/C1.source/privatevoice.src/app_live_caption_test.go
@@ -2,8 +2,11 @@
import (
"strings"
+ "sync/atomic"
"testing"
"time"
+
+ "voicesnap/internal/engine"
)
func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
@@ -131,6 +134,95 @@
}
}
+func TestInitEngineSerializesAndDiscardsStaleEngine(t *testing.T) {
+ first := &closeTrackingTestEngine{name: "first"}
+ second := &closeTrackingTestEngine{name: "second"}
+ firstStarted := make(chan struct{})
+ allowFirst := make(chan struct{})
+ calls := atomic.Int32{}
+
+ a := &App{
+ engineFactory: func(initID uint64) (engine.Engine, engineMetadata, bool, error) {
+ call := calls.Add(1)
+ switch call {
+ case 1:
+ close(firstStarted)
+ <-allowFirst
+ return first, engineMetadata{InitID: initID, ResolvedModelID: "first", HardwareInfo: "first"}, true, nil
+ case 2:
+ return second, engineMetadata{InitID: initID, ResolvedModelID: "second", HardwareInfo: "second"}, true, nil
+ default:
+ t.Fatalf("unexpected engine factory call %d", call)
+ return nil, engineMetadata{}, false, nil
+ }
+ },
+ }
+
+ doneFirst := make(chan struct{})
+ go func() {
+ a.initEngine()
+ close(doneFirst)
+ }()
+
+ select {
+ case <-firstStarted:
+ case <-time.After(time.Second):
+ t.Fatal("first engine init did not start")
+ }
+
+ doneSecond := make(chan struct{})
+ go func() {
+ a.initEngine()
+ close(doneSecond)
+ }()
+
+ waitForCondition(t, time.Second, func() bool {
+ return a.engineInitSeq.Load() == 2
+ })
+ close(allowFirst)
+
+ select {
+ case <-doneFirst:
+ case <-time.After(time.Second):
+ t.Fatal("first engine init did not finish")
+ }
+ select {
+ case <-doneSecond:
+ case <-time.After(time.Second):
+ t.Fatal("second engine init did not finish")
+ }
+
+ a.engineMu.Lock()
+ gotEngine := a.eng
+ gotMeta := a.engineMeta
+ a.engineMu.Unlock()
+
+ if gotEngine != second {
+ t.Fatalf("final engine = %#v, want second", gotEngine)
+ }
+ if !first.closed.Load() {
+ t.Fatal("stale first engine should be closed")
+ }
+ if second.closed.Load() {
+ t.Fatal("current second engine should remain open")
+ }
+ if gotMeta.ResolvedModelID != "second" || gotMeta.InitID != 2 {
+ t.Fatalf("engine metadata = %+v, want second init metadata", gotMeta)
+ }
+}
+
+func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if fn() {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("condition was not met before timeout")
+}
+
func TestShouldStartHoldPreCaptureLocked(t *testing.T) {
a := &App{}
a.holdPreCaptureEnabled.Store(true)
@@ -189,6 +281,15 @@
func (plainTestEngine) HardwareInfo() string { return "test" }
func (plainTestEngine) Close() {}
+type closeTrackingTestEngine struct {
+ plainTestEngine
+ name string
+ closed atomic.Bool
+}
+
+func (e *closeTrackingTestEngine) HardwareInfo() string { return e.name }
+func (e *closeTrackingTestEngine) Close() { e.closed.Store(true) }
+
type tailCaptureTestEngine struct {
plainTestEngine
delay time.Duration
diff --git a/C1.source/privatevoice.src/build/config.yml b/C1.source/privatevoice.src/build/config.yml
index 6066ae1..ca2015b 100755
--- a/C1.source/privatevoice.src/build/config.yml
+++ b/C1.source/privatevoice.src/build/config.yml
@@ -7,7 +7,7 @@
description: "Private offline voice input tool"
copyright: "(c) 2026, PrivateVoice"
comments: "Private. Offline."
- version: "2.2.0"
+ version: "2.2.2"
dev_mode:
root_path: .
diff --git a/C1.source/privatevoice.src/build/darwin/Info.plist b/C1.source/privatevoice.src/build/darwin/Info.plist
index 466def2..73b4aaa 100755
--- a/C1.source/privatevoice.src/build/darwin/Info.plist
+++ b/C1.source/privatevoice.src/build/darwin/Info.plist
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>2.2.0</string>
+ <string>2.2.2</string>
<key>CFBundleVersion</key>
- <string>20260702.0303</string>
+ <string>20260703.0335</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
diff --git a/C1.source/privatevoice.src/cmd/benchmark-recognition/main.go b/C1.source/privatevoice.src/cmd/benchmark-recognition/main.go
new file mode 100644
index 0000000..28d7a17
--- /dev/null
+++ b/C1.source/privatevoice.src/cmd/benchmark-recognition/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "encoding/binary"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+ "voicesnap/internal/engine"
+)
+
+func main() {
+ wavPath := flag.String("wav", "", "16kHz mono PCM16 WAV file")
+ runs := flag.Int("runs", 3, "number of recognition runs")
+ flag.Parse()
+
+ if *wavPath == "" {
+ fmt.Fprintln(os.Stderr, "missing -wav")
+ os.Exit(2)
+ }
+ if *runs < 1 {
+ *runs = 1
+ }
+
+ samples, sampleRate, err := readPCM16WAV(*wavPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "read wav: %v\n", err)
+ os.Exit(1)
+ }
+ if sampleRate != 16000 {
+ fmt.Fprintf(os.Stderr, "unsupported sample rate %d: provide 16kHz mono PCM16 WAV\n", sampleRate)
+ os.Exit(1)
+ }
+
+ eng, err := engine.New()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "init engine: %v\n", err)
+ os.Exit(1)
+ }
+ defer eng.Close()
+
+ audioMS := int64(len(samples)) * 1000 / 16000
+ fmt.Printf("engine=%q samples=%d audio_ms=%d runs=%d\n", eng.HardwareInfo(), len(samples), audioMS, *runs)
+ var total int64
+ for i := 1; i <= *runs; i++ {
+ start := time.Now()
+ text, err := eng.Recognize(samples)
+ elapsedMS := time.Since(start).Milliseconds()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "run=%d error=%v\n", i, err)
+ os.Exit(1)
+ }
+ total += elapsedMS
+ fmt.Printf("run=%d recognize_ms=%d rtf=%.3f text_runes=%d\n", i, elapsedMS, float64(elapsedMS)/float64(audioMS), len([]rune(text)))
+ }
+ fmt.Printf("avg_recognize_ms=%d\n", total/int64(*runs))
+}
+
+func readPCM16WAV(path string) ([]float32, int, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, 0, err
+ }
+ if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
+ return nil, 0, fmt.Errorf("not a RIFF/WAVE file")
+ }
+
+ var audioFormat, channels, bitsPerSample uint16
+ var sampleRate uint32
+ var pcm []byte
+ for offset := 12; offset+8 <= len(data); {
+ chunkID := string(data[offset : offset+4])
+ chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
+ chunkStart := offset + 8
+ chunkEnd := chunkStart + chunkSize
+ if chunkEnd > len(data) {
+ return nil, 0, fmt.Errorf("truncated %s chunk", chunkID)
+ }
+
+ switch chunkID {
+ case "fmt ":
+ if chunkSize < 16 {
+ return nil, 0, fmt.Errorf("fmt chunk too small")
+ }
+ audioFormat = binary.LittleEndian.Uint16(data[chunkStart : chunkStart+2])
+ channels = binary.LittleEndian.Uint16(data[chunkStart+2 : chunkStart+4])
+ sampleRate = binary.LittleEndian.Uint32(data[chunkStart+4 : chunkStart+8])
+ bitsPerSample = binary.LittleEndian.Uint16(data[chunkStart+14 : chunkStart+16])
+ case "data":
+ pcm = data[chunkStart:chunkEnd]
+ }
+
+ offset = chunkEnd
+ if offset%2 == 1 {
+ offset++
+ }
+ }
+
+ if audioFormat != 1 || channels != 1 || bitsPerSample != 16 {
+ return nil, 0, fmt.Errorf("unsupported WAV format: audio_format=%d channels=%d bits_per_sample=%d; want PCM16 mono", audioFormat, channels, bitsPerSample)
+ }
+ if len(pcm) == 0 {
+ return nil, 0, fmt.Errorf("missing data chunk")
+ }
+ if len(pcm)%2 != 0 {
+ return nil, 0, fmt.Errorf("odd PCM data length")
+ }
+
+ samples := make([]float32, len(pcm)/2)
+ for i := range samples {
+ v := int16(binary.LittleEndian.Uint16(pcm[i*2 : i*2+2]))
+ samples[i] = float32(v) / 32768.0
+ }
+ return samples, int(sampleRate), nil
+}
diff --git a/C1.source/privatevoice.src/frontend/package-lock.json b/C1.source/privatevoice.src/frontend/package-lock.json
index eb0d87d..b860444 100755
--- a/C1.source/privatevoice.src/frontend/package-lock.json
+++ b/C1.source/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "privatevoice-dictation-frontend",
- "version": "2.2.0",
+ "version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "privatevoice-dictation-frontend",
- "version": "2.2.0",
+ "version": "2.2.2",
"dependencies": {
"@wailsio/runtime": "latest"
},
diff --git a/C1.source/privatevoice.src/frontend/package.json b/C1.source/privatevoice.src/frontend/package.json
index f831a59..6207e6e 100755
--- a/C1.source/privatevoice.src/frontend/package.json
+++ b/C1.source/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "privatevoice-dictation-frontend",
"private": true,
- "version": "2.2.0",
+ "version": "2.2.2",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/C1.source/privatevoice.src/frontend/src/App.svelte b/C1.source/privatevoice.src/frontend/src/App.svelte
index 8406d60..e91d231 100755
--- a/C1.source/privatevoice.src/frontend/src/App.svelte
+++ b/C1.source/privatevoice.src/frontend/src/App.svelte
@@ -19,24 +19,26 @@
let showOnboarding = $state(false)
onMount(() => {
+ const offEvents: Array<() => void> = []
+
Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings').then((settings: any) => {
applyLanguageSettings(settings)
}).catch(() => {
languageReady = true
})
- Events.On('indicator:show', (ev: any) => {
+ offEvents.push(Events.On('indicator:show', (ev: any) => {
indicatorVisible.set(true)
if (ev?.data?.status) {
indicatorStatus.set(ev.data.status)
}
- })
+ }))
- Events.On('indicator:hide', () => {
+ offEvents.push(Events.On('indicator:hide', () => {
indicatorVisible.set(false)
- })
+ }))
- Events.On('indicator:status', (ev: any) => {
+ offEvents.push(Events.On('indicator:status', (ev: any) => {
const data = ev?.data
if (data?.status) {
indicatorStatus.set(data.status)
@@ -44,13 +46,13 @@
if (data?.hotkeyName) {
hotkeyName.set(data.hotkeyName)
}
- })
+ }))
- Events.On('indicator:volume', (ev: any) => {
+ offEvents.push(Events.On('indicator:volume', (ev: any) => {
indicatorVolume.set(ev?.data ?? 0)
- })
+ }))
- Events.On('engine:status', (ev: any) => {
+ offEvents.push(Events.On('engine:status', (ev: any) => {
const data = ev?.data
if (data?.status) {
engineStatus.set(data.status as any)
@@ -61,23 +63,24 @@
if (data?.hardwareInfo) {
engineHardwareInfo.set(data.hardwareInfo)
}
- })
+ }))
- Events.On('device:changed', (ev: any) => {
+ offEvents.push(Events.On('device:changed', (ev: any) => {
if (ev?.data?.name) {
deviceName.set(ev.data.name)
}
- })
+ }))
- Events.On('update:available', (ev: any) => {
+ offEvents.push(Events.On('update:available', (ev: any) => {
const data = ev?.data as UpdateInfo
updateAvailable.set(data)
showUpdateDialog = true
- })
+ }))
- window.addEventListener('hashchange', () => {
+ const onHashChange = () => {
isIndicatorRoute = window.location.hash === '#/indicator'
- })
+ }
+ window.addEventListener('hashchange', onHashChange)
// Query engine status on mount (events emitted before frontend load are lost)
Call.ByName('voicesnap/services.EngineService.GetStatus').then((data: any) => {
@@ -100,6 +103,11 @@
hotkeyName.set(name)
}
})
+
+ return () => {
+ for (const off of offEvents) off()
+ window.removeEventListener('hashchange', onHashChange)
+ }
})
function applyLanguageSettings(settings: any) {
diff --git a/C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte b/C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
index 2b3a727..c97ab1f 100755
--- a/C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -14,7 +14,7 @@
let currentModelID = $state('')
onMount(() => {
- Events.On('model:download-progress', (ev: any) => {
+ const offDownloadProgress = Events.On('model:download-progress', (ev: any) => {
const data = ev?.data
if (data?.percent != null) {
if (data?.modelID) {
@@ -31,7 +31,7 @@
}
})
- Events.On('engine:status', (ev: any) => {
+ const offEngineStatus = Events.On('engine:status', (ev: any) => {
const data = ev?.data
if (data?.status === 'ready') {
downloadActive = false
@@ -56,6 +56,11 @@
})
triggerDownload()
+
+ return () => {
+ offDownloadProgress()
+ offEngineStatus()
+ }
})
async function triggerDownload() {
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index d54ca49..7b48d66 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { onDestroy } from 'svelte'
import { Call } from '@wailsio/runtime'
import ToggleSwitch from '../shared/ToggleSwitch.svelte'
import { t } from '../../lib/i18n'
@@ -26,6 +27,7 @@
let hintText = $state('')
let devices = $state<InputDevice[]>([])
let showDeviceDropdown = $state(false)
+ let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null
const unsub1 = autoHide.subscribe(v => { autoHideVal = v })
const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v })
@@ -36,6 +38,11 @@
const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
+
+ onDestroy(() => {
+ unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9()
+ clearHotkeyCapture()
+ })
// Load actual device name from backend on mount
async function loadDeviceName() {
@@ -160,6 +167,7 @@
}
async function startRecordingHotkey() {
+ clearHotkeyCapture()
isRecording = true
hintText = t('hotkeys.pressAnyKey')
@@ -191,13 +199,25 @@
} catch {}
})()
- document.removeEventListener('keydown', onKey)
+ clearHotkeyCapture()
}
}
+ activeKeyHandler = onKey
document.addEventListener('keydown', onKey)
}
+ function clearHotkeyCapture() {
+ if (activeKeyHandler) {
+ document.removeEventListener('keydown', activeKeyHandler)
+ activeKeyHandler = null
+ }
+ if (isRecording) {
+ isRecording = false
+ void Call.ByName('voicesnap/services.HotkeyService.StopRecordingHotkey').catch(() => {})
+ }
+ }
+
function mapKeyToVK(e: KeyboardEvent): number {
switch (e.key) {
case 'Control': return e.location === 1 ? 0xA2 : e.location === 2 ? 0xA3 : 0x11
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
index acb3aaa..4efaa6a 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { onDestroy } from 'svelte'
import { Call } from '@wailsio/runtime'
import { t } from '../../lib/i18n'
import { hotkeyVK } from '../../lib/stores/config'
@@ -7,10 +8,17 @@
let currentKeyName = $state('Ctrl')
let isRecording = $state(false)
let hintText = $state('')
+ let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null
const unsub = hotkeyName.subscribe(k => { currentKeyName = k })
+ onDestroy(() => {
+ unsub()
+ clearHotkeyCapture()
+ })
+
async function startRecordingHotkey() {
+ clearHotkeyCapture()
isRecording = true
hintText = t('hotkeys.pressAnyKey')
@@ -44,13 +52,25 @@
} catch {}
})()
- document.removeEventListener('keydown', onKey)
+ clearHotkeyCapture()
}
}
+ activeKeyHandler = onKey
document.addEventListener('keydown', onKey)
}
+ function clearHotkeyCapture() {
+ if (activeKeyHandler) {
+ document.removeEventListener('keydown', activeKeyHandler)
+ activeKeyHandler = null
+ }
+ if (isRecording) {
+ isRecording = false
+ void Call.ByName('voicesnap/services.HotkeyService.StopRecordingHotkey').catch(() => {})
+ }
+ }
+
function mapKeyToVK(e: KeyboardEvent): number {
// Map browser key codes to Windows VK codes
switch (e.key) {
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
index f989836..91efb8d 100644
--- a/C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { onDestroy } from 'svelte'
import { Call, Events } from '@wailsio/runtime'
import { t, setLocale } from '../../lib/i18n'
import { languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config'
@@ -42,7 +43,7 @@
const unsub2 = languageID.subscribe(v => { languageIDVal = v })
const unsub3 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v })
- Events.On('model:download-progress', (ev: any) => {
+ const offDownloadProgress = Events.On('model:download-progress', (ev: any) => {
const data = ev?.data
if (data?.modelID && typeof data.percent === 'number') {
modelBusyID = data.modelID
@@ -54,7 +55,7 @@
}
})
- Events.On('model:download-cancelled', (ev: any) => {
+ const offDownloadCancelled = Events.On('model:download-cancelled', (ev: any) => {
const data = ev?.data
if (data?.modelID) {
modelBusyID = data.modelID
@@ -63,7 +64,7 @@
}
})
- Events.On('model:download-finished', (ev: any) => {
+ const offDownloadFinished = Events.On('model:download-finished', (ev: any) => {
const data = ev?.data
if (!data?.modelID || data.modelID === modelBusyID) {
clearModelBusy()
@@ -71,6 +72,15 @@
}
})
+ onDestroy(() => {
+ unsub1()
+ unsub2()
+ unsub3()
+ offDownloadProgress()
+ offDownloadCancelled()
+ offDownloadFinished()
+ })
+
async function loadSettings() {
try {
const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
index e2b9d09..e074eb2 100644
--- a/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
@@ -14,11 +14,18 @@
let statuses = $state<PermissionStatus[]>([])
let loading = $state(true)
+ let refreshTimeout: number | null = null
onMount(() => {
refresh()
const timer = window.setInterval(refresh, 2500)
- return () => window.clearInterval(timer)
+ return () => {
+ window.clearInterval(timer)
+ if (refreshTimeout !== null) {
+ window.clearTimeout(refreshTimeout)
+ refreshTimeout = null
+ }
+ }
})
async function refresh() {
@@ -40,7 +47,13 @@
await Call.ByName('voicesnap/services.PermissionService.OpenSettings', id)
} catch {}
}
- window.setTimeout(refresh, 800)
+ if (refreshTimeout !== null) {
+ window.clearTimeout(refreshTimeout)
+ }
+ refreshTimeout = window.setTimeout(() => {
+ refreshTimeout = null
+ refresh()
+ }, 800)
}
function sortStatuses(items: PermissionStatus[]) {
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
index ce2ee58..e563508 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { onDestroy } from 'svelte'
import { currentPage } from '../../lib/stores/app'
import { t } from '../../lib/i18n'
import GeneralPage from './GeneralPage.svelte'
@@ -21,6 +22,10 @@
let page = $state('general')
const unsubPage = currentPage.subscribe(p => { page = p })
+ onDestroy(() => {
+ unsubPage()
+ })
+
function navigate(id: string) {
currentPage.set(id)
}
diff --git a/C1.source/privatevoice.src/internal/audio/recorder.go b/C1.source/privatevoice.src/internal/audio/recorder.go
index 5842248..9cf8b92 100755
--- a/C1.source/privatevoice.src/internal/audio/recorder.go
+++ b/C1.source/privatevoice.src/internal/audio/recorder.go
@@ -10,11 +10,17 @@
)
const (
- sampleRate = 16000
- channels = 1
- bitsPerSample = 16
- silenceThreshold = 0.05
+ sampleRate = 16000
+ channels = 1
+ bitsPerSample = 16
+ silenceThreshold = 0.05
+ maxRecordingSeconds = 15 * 60
+ maxPCMBufferBytes = sampleRate * channels * (bitsPerSample / 8) * maxRecordingSeconds
+ MaxRecordingDuration = time.Duration(maxRecordingSeconds) * time.Second
+ maxPendingDeviceStops = 2
)
+
+var pendingDeviceStops = make(chan struct{}, maxPendingDeviceStops)
// InputDevice represents an audio input device.
type InputDevice struct {
@@ -37,6 +43,7 @@
// State
isRecording bool
+ bufferLimitHit bool
maxVolume float64
currentVolume float64
volumeCallback func(float64)
@@ -106,6 +113,7 @@
}
r.pcmBuf = nil
+ r.bufferLimitHit = false
r.maxVolume = 0
r.currentVolume = 0
@@ -249,8 +257,18 @@
if device == nil {
return
}
+ select {
+ case pendingDeviceStops <- struct{}{}:
+ case <-time.After(100 * time.Millisecond):
+ logger.Error("Audio device stop queue saturated; stopping synchronously")
+ stopAndUninitDevice(device)
+ return
+ }
done := make(chan struct{})
go func() {
+ defer func() {
+ <-pendingDeviceStops
+ }()
stopAndUninitDevice(device)
close(done)
}()
@@ -296,6 +314,21 @@
}
// Append raw PCM data
+ if len(r.pcmBuf) >= maxPCMBufferBytes {
+ if !r.bufferLimitHit {
+ logger.Error("Recording buffer limit reached; dropping additional audio max_seconds=%d", maxRecordingSeconds)
+ r.bufferLimitHit = true
+ }
+ r.mu.Unlock()
+ return
+ }
+ if len(r.pcmBuf)+len(input) > maxPCMBufferBytes {
+ input = input[:maxPCMBufferBytes-len(r.pcmBuf)]
+ if !r.bufferLimitHit {
+ logger.Error("Recording buffer limit reached; truncating audio max_seconds=%d", maxRecordingSeconds)
+ r.bufferLimitHit = true
+ }
+ }
r.pcmBuf = append(r.pcmBuf, input...)
// Calculate RMS volume
diff --git a/C1.source/privatevoice.src/internal/audio/recorder_test.go b/C1.source/privatevoice.src/internal/audio/recorder_test.go
index 79d10cc..092b043 100644
--- a/C1.source/privatevoice.src/internal/audio/recorder_test.go
+++ b/C1.source/privatevoice.src/internal/audio/recorder_test.go
@@ -36,6 +36,27 @@
}
}
+func TestOnDataCapsRecordingBuffer(t *testing.T) {
+ r := &Recorder{
+ isRecording: true,
+ pcmBuf: make([]byte, maxPCMBufferBytes-2),
+ }
+
+ r.onData(pcm16LE(1, 2))
+
+ if got := len(r.pcmBuf); got != maxPCMBufferBytes {
+ t.Fatalf("pcm buffer length = %d, want capped %d", got, maxPCMBufferBytes)
+ }
+ if !r.bufferLimitHit {
+ t.Fatal("bufferLimitHit should be set after truncating at cap")
+ }
+
+ r.onData(pcm16LE(3))
+ if got := len(r.pcmBuf); got != maxPCMBufferBytes {
+ t.Fatalf("pcm buffer length after cap = %d, want %d", got, maxPCMBufferBytes)
+ }
+}
+
func pcm16LE(values ...int16) []byte {
buf := make([]byte, len(values)*2)
for i, value := range values {
diff --git a/C1.source/privatevoice.src/internal/engine/engine.go b/C1.source/privatevoice.src/internal/engine/engine.go
index aed8e7f..037efc5 100755
--- a/C1.source/privatevoice.src/internal/engine/engine.go
+++ b/C1.source/privatevoice.src/internal/engine/engine.go
@@ -21,6 +21,12 @@
Close()
}
+// ProviderEngine is implemented by engines that can report the concrete
+// sherpa/onnxruntime provider selected at initialization time.
+type ProviderEngine interface {
+ Provider() string
+}
+
// StreamingEngine is implemented by engines that can expose partial results
// while audio is still being captured.
type StreamingEngine interface {
@@ -116,6 +122,14 @@
return newPlatformEngine(resolved)
}
+func audioDurationMS(samples []float32) int64 {
+ return int64(len(samples)) * 1000 / 16000
+}
+
+func durationMS(duration time.Duration) int64 {
+ return duration.Milliseconds()
+}
+
func isSupportedBackend(backend string) bool {
switch backend {
case model.BackendSenseVoice, model.BackendMoonshine, model.BackendTransducer, model.BackendNemoTransducer, model.BackendQwen3ASR, model.BackendXASRStreaming:
diff --git a/C1.source/privatevoice.src/internal/engine/engine_darwin.go b/C1.source/privatevoice.src/internal/engine/engine_darwin.go
index 156b48e..0d44ab5 100755
--- a/C1.source/privatevoice.src/internal/engine/engine_darwin.go
+++ b/C1.source/privatevoice.src/internal/engine/engine_darwin.go
@@ -29,11 +29,13 @@
type sherpaEngine struct {
recognizer *sherpa.OfflineRecognizer
backendKind string
+ provider string
hwInfo string
}
type xasrStreamingEngine struct {
recognizer *sherpa.OnlineRecognizer
+ provider string
hwInfo string
mu sync.Mutex
}
@@ -69,6 +71,7 @@
return &sherpaEngine{
recognizer: recognizer,
backendKind: resolved.BackendKind,
+ provider: p.provider,
hwInfo: info,
}, nil
}
@@ -93,6 +96,7 @@
logger.Info("Engine initialized: %s", info)
return &xasrStreamingEngine{
recognizer: recognizer,
+ provider: p.provider,
hwInfo: info,
}, nil
}
@@ -196,28 +200,72 @@
}
func (e *sherpaEngine) Recognize(samples []float32) (string, error) {
+ totalStart := time.Now()
+ streamStart := time.Now()
stream := sherpa.NewOfflineStream(e.recognizer)
+ streamMS := durationMS(time.Since(streamStart))
defer sherpa.DeleteOfflineStream(stream)
+ acceptStart := time.Now()
stream.AcceptWaveform(asrSampleRate, samples)
+ acceptMS := durationMS(time.Since(acceptStart))
+ decodeStart := time.Now()
e.recognizer.Decode(stream)
+ decodeMS := durationMS(time.Since(decodeStart))
+ resultStart := time.Now()
result := stream.GetResult()
+ resultMS := durationMS(time.Since(resultStart))
+ logger.Info(
+ "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d",
+ e.backendKind,
+ e.provider,
+ e.hwInfo,
+ len(samples),
+ audioDurationMS(samples),
+ streamMS,
+ acceptMS,
+ decodeMS,
+ resultMS,
+ durationMS(time.Since(totalStart)),
+ )
return result.Text, nil
}
func (e *xasrStreamingEngine) Recognize(samples []float32) (string, error) {
+ totalStart := time.Now()
+ sessionStart := time.Now()
session, err := e.NewStreamingSession()
if err != nil {
return "", err
}
+ sessionMS := durationMS(time.Since(sessionStart))
defer session.Close()
+ acceptStart := time.Now()
if _, err := session.Accept(samples); err != nil {
return "", err
}
- return session.Finish()
+ acceptMS := durationMS(time.Since(acceptStart))
+ finishStart := time.Now()
+ text, err := session.Finish()
+ finishMS := durationMS(time.Since(finishStart))
+ if err == nil {
+ logger.Info(
+ "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_session_ms=%d accept_ms=%d finish_ms=%d total_ms=%d",
+ model.BackendXASRStreaming,
+ e.provider,
+ e.hwInfo,
+ len(samples),
+ audioDurationMS(samples),
+ sessionMS,
+ acceptMS,
+ finishMS,
+ durationMS(time.Since(totalStart)),
+ )
+ }
+ return text, err
}
func (e *xasrStreamingEngine) NewStreamingSession() (StreamingSession, error) {
@@ -316,6 +364,10 @@
return e.hwInfo
}
+func (e *sherpaEngine) Provider() string {
+ return e.provider
+}
+
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
if e.backendKind == model.BackendSenseVoice {
return senseVoiceReleaseTailDelay
@@ -327,6 +379,10 @@
return e.hwInfo
}
+func (e *xasrStreamingEngine) Provider() string {
+ return e.provider
+}
+
func (e *xasrStreamingEngine) ReleaseTailCaptureDelay() time.Duration {
return xasrReleaseTailDelay
}
diff --git a/C1.source/privatevoice.src/internal/engine/engine_linux.go b/C1.source/privatevoice.src/internal/engine/engine_linux.go
index 2d9fde2..1539bf5 100755
--- a/C1.source/privatevoice.src/internal/engine/engine_linux.go
+++ b/C1.source/privatevoice.src/internal/engine/engine_linux.go
@@ -14,6 +14,7 @@
type sherpaEngine struct {
recognizer *sherpa.OfflineRecognizer
+ provider string
hwInfo string
}
@@ -36,6 +37,7 @@
logger.Info("Engine initialized: %s", info)
return &sherpaEngine{
recognizer: recognizer,
+ provider: "cpu",
hwInfo: info,
}, nil
}
@@ -44,13 +46,35 @@
}
func (e *sherpaEngine) Recognize(samples []float32) (string, error) {
+ totalStart := time.Now()
+ streamStart := time.Now()
stream := sherpa.NewOfflineStream(e.recognizer)
+ streamMS := durationMS(time.Since(streamStart))
defer sherpa.DeleteOfflineStream(stream)
+ acceptStart := time.Now()
stream.AcceptWaveform(16000, samples)
+ acceptMS := durationMS(time.Since(acceptStart))
+ decodeStart := time.Now()
e.recognizer.Decode(stream)
+ decodeMS := durationMS(time.Since(decodeStart))
+ resultStart := time.Now()
result := stream.GetResult()
+ resultMS := durationMS(time.Since(resultStart))
+ logger.Info(
+ "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d",
+ model.BackendSenseVoice,
+ e.provider,
+ e.hwInfo,
+ len(samples),
+ audioDurationMS(samples),
+ streamMS,
+ acceptMS,
+ decodeMS,
+ resultMS,
+ durationMS(time.Since(totalStart)),
+ )
return result.Text, nil
}
@@ -59,6 +83,10 @@
return e.hwInfo
}
+func (e *sherpaEngine) Provider() string {
+ return e.provider
+}
+
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
return senseVoiceReleaseTailDelay
}
diff --git a/C1.source/privatevoice.src/internal/engine/engine_windows.go b/C1.source/privatevoice.src/internal/engine/engine_windows.go
index 8ace9c7..17bfee2 100755
--- a/C1.source/privatevoice.src/internal/engine/engine_windows.go
+++ b/C1.source/privatevoice.src/internal/engine/engine_windows.go
@@ -14,6 +14,7 @@
type sherpaEngine struct {
recognizer *sherpa.OfflineRecognizer
+ provider string
hwInfo string
}
@@ -45,6 +46,7 @@
logger.Info("Engine initialized: %s", info)
return &sherpaEngine{
recognizer: recognizer,
+ provider: p.provider,
hwInfo: info,
}, nil
}
@@ -55,13 +57,35 @@
}
func (e *sherpaEngine) Recognize(samples []float32) (string, error) {
+ totalStart := time.Now()
+ streamStart := time.Now()
stream := sherpa.NewOfflineStream(e.recognizer)
+ streamMS := durationMS(time.Since(streamStart))
defer sherpa.DeleteOfflineStream(stream)
+ acceptStart := time.Now()
stream.AcceptWaveform(16000, samples)
+ acceptMS := durationMS(time.Since(acceptStart))
+ decodeStart := time.Now()
e.recognizer.Decode(stream)
+ decodeMS := durationMS(time.Since(decodeStart))
+ resultStart := time.Now()
result := stream.GetResult()
+ resultMS := durationMS(time.Since(resultStart))
+ logger.Info(
+ "PERF engine_recognize_detail backend=%q provider=%q engine=%q samples=%d audio_ms=%d create_stream_ms=%d accept_ms=%d decode_ms=%d result_ms=%d total_ms=%d",
+ model.BackendSenseVoice,
+ e.provider,
+ e.hwInfo,
+ len(samples),
+ audioDurationMS(samples),
+ streamMS,
+ acceptMS,
+ decodeMS,
+ resultMS,
+ durationMS(time.Since(totalStart)),
+ )
return result.Text, nil
}
@@ -70,6 +94,10 @@
return e.hwInfo
}
+func (e *sherpaEngine) Provider() string {
+ return e.provider
+}
+
func (e *sherpaEngine) ReleaseTailCaptureDelay() time.Duration {
return senseVoiceReleaseTailDelay
}
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey.go b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
index 8cf54bf..32f6269 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
@@ -13,6 +13,8 @@
IsAnyOtherKeyPressed(excludeVK int) bool
// IsAnyOtherKeyPressedSince returns true if another key was pressed after since.
IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool
+ // Close releases listener resources.
+ Close()
}
// New creates a new platform-specific hotkey listener.
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
index b9582fe..8143070 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
@@ -12,8 +12,10 @@
static volatile double g_lastDownAt[128];
static volatile int g_monitorStarted = 0;
static volatile int g_monitorLaunching = 0;
+static volatile int g_monitorStopping = 0;
static CFMachPortRef g_eventTap = NULL;
static CFRunLoopSourceRef g_eventTapSource = NULL;
+static CFRunLoopRef g_eventTapRunLoop = NULL;
static volatile unsigned long long g_monitorStartAttempts = 0;
static volatile unsigned long long g_eventTapCreateFailures = 0;
static volatile unsigned long long g_eventTapDisabledCount = 0;
@@ -138,8 +140,26 @@
CGEventTapEnable(g_eventTap, true);
g_monitorStarted = 1;
g_monitorLaunching = 0;
+ g_eventTapRunLoop = CFRunLoopGetCurrent();
+ if (g_eventTapRunLoop != NULL) CFRetain(g_eventTapRunLoop);
CFRunLoopRun();
+
+ if (g_eventTap != NULL) CGEventTapEnable(g_eventTap, false);
+ if (g_eventTapSource != NULL) {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), g_eventTapSource, kCFRunLoopCommonModes);
+ CFRelease(g_eventTapSource);
+ g_eventTapSource = NULL;
+ }
+ if (g_eventTap != NULL) {
+ CFRelease(g_eventTap);
+ g_eventTap = NULL;
+ }
+ if (g_eventTapRunLoop != NULL) {
+ CFRelease(g_eventTapRunLoop);
+ g_eventTapRunLoop = NULL;
+ }
g_monitorStarted = 0;
+ g_monitorStopping = 0;
}
static int ensureAccessibility(void) {
@@ -154,6 +174,12 @@
reason:@"PrivateVoice Dictation global hotkey listener"];
}
+static void endHotkeyActivity(void) {
+ if (g_activityToken == nil) return;
+ [[NSProcessInfo processInfo] endActivity:g_activityToken];
+ g_activityToken = nil;
+}
+
static void* eventTapThreadMain(void* arg) {
(void)arg;
@autoreleasepool {
@@ -164,7 +190,7 @@
}
static void startKeyMonitor(void) {
- if (g_monitorStarted || g_monitorLaunching) return;
+ if (g_monitorStarted || g_monitorLaunching || g_monitorStopping) return;
g_monitorLaunching = 1;
pthread_t thread;
int err = pthread_create(&thread, NULL, eventTapThreadMain, NULL);
@@ -174,6 +200,15 @@
return;
}
pthread_detach(thread);
+}
+
+static void stopKeyMonitor(void) {
+ g_monitorStopping = 1;
+ if (g_eventTap != NULL) CGEventTapEnable(g_eventTap, false);
+ if (g_eventTapRunLoop != NULL) {
+ CFRunLoopStop(g_eventTapRunLoop);
+ }
+ endHotkeyActivity();
}
// isModifierDown polls the current system modifier flags via CGEventSource.
@@ -522,6 +557,10 @@
return false
}
+func (l *darwinListener) Close() {
+ C.stopKeyMonitor()
+}
+
func (l *darwinListener) wasPressedSince(vk int, since time.Time) bool {
if since.IsZero() {
return l.IsKeyDown(vk)
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go
index e0f3f76..a3cdf05 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_linux.go
@@ -96,3 +96,5 @@
func (l *linuxListener) IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool {
return l.IsAnyOtherKeyPressed(excludeVK)
}
+
+func (l *linuxListener) Close() {}
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go
index 71d0d9e..8e13692 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go
@@ -91,6 +91,8 @@
return l.IsAnyOtherKeyPressed(excludeVK)
}
+func (l *windowsListener) Close() {}
+
func contains(slice []int, val int) bool {
for _, v := range slice {
if v == val {
diff --git a/C1.source/privatevoice.src/internal/logger/logger.go b/C1.source/privatevoice.src/internal/logger/logger.go
index 6997408..2041c29 100755
--- a/C1.source/privatevoice.src/internal/logger/logger.go
+++ b/C1.source/privatevoice.src/internal/logger/logger.go
@@ -14,23 +14,66 @@
logFile *os.File
)
+const (
+ maxLogBytes = 10 * 1024 * 1024
+ maxLogBackups = 3
+)
+
// Init initializes the file logger in the persistent app data directory.
func Init() {
+ mu.Lock()
+ if logFile != nil {
+ logFile.Close()
+ logFile = nil
+ }
+ mu.Unlock()
+
dir := paths.AppSupportDir()
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create log dir: %v\n", err)
return
}
path := filepath.Join(dir, "app.log")
+ if err := rotateIfNeeded(path); err != nil {
+ fmt.Fprintf(os.Stderr, "failed to rotate log file: %v\n", err)
+ }
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err)
return
}
+ mu.Lock()
+ defer mu.Unlock()
logFile = f
}
+func rotateIfNeeded(path string) error {
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if info.Size() < maxLogBytes {
+ return nil
+ }
+
+ oldest := fmt.Sprintf("%s.%d", path, maxLogBackups)
+ if err := os.Remove(oldest); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ for i := maxLogBackups - 1; i >= 1; i-- {
+ from := fmt.Sprintf("%s.%d", path, i)
+ to := fmt.Sprintf("%s.%d", path, i+1)
+ if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ return os.Rename(path, path+".1")
+}
+
func write(level, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
ts := time.Now().Format("2006-01-02 15:04:05")
diff --git a/C1.source/privatevoice.src/internal/overlay/overlay_darwin.go b/C1.source/privatevoice.src/internal/overlay/overlay_darwin.go
index a3aecb7..b364993 100755
--- a/C1.source/privatevoice.src/internal/overlay/overlay_darwin.go
+++ b/C1.source/privatevoice.src/internal/overlay/overlay_darwin.go
@@ -151,6 +151,7 @@
static NSWindow* g_window = nil;
static NSWindow* g_dragWindow = nil;
+static int g_windowsOrderedOut = 1;
static float desktopTopY(void) {
NSScreen* mainScreen = [NSScreen mainScreen];
@@ -180,6 +181,17 @@
if (g_dragWindow) {
[g_dragWindow setFrameOrigin:dragOriginForVisualOrigin(visualOrigin)];
}
+}
+
+static void orderOverlayWindowsOutOnce(void) {
+ if (g_windowsOrderedOut) return;
+ if (g_window) [g_window orderOut:nil];
+ if (g_dragWindow) [g_dragWindow orderOut:nil];
+ g_windowsOrderedOut = 1;
+}
+
+static void markOverlayWindowsVisible(void) {
+ g_windowsOrderedOut = 0;
}
static void storeDragPositionFromVisualOrigin(NSPoint visualOrigin) {
@@ -669,8 +681,7 @@
[g_window setAlphaValue:eased];
if (g_state.fadeProgress <= 0 && g_state.fadeTarget == 0) {
- [g_window orderOut:nil];
- if (g_dragWindow) [g_dragWindow orderOut:nil];
+ orderOverlayWindowsOutOnce();
return;
}
@@ -683,6 +694,7 @@
applyOverlayWindowPolicy(g_dragWindow);
[g_dragWindow orderFrontRegardless];
}
+ markOverlayWindowsVisible();
}
// Reposition
@@ -724,6 +736,7 @@
g_window = window;
g_view = view;
+ g_windowsOrderedOut = 1;
PrivateVoiceOverlayWindow* dragWindow = [[PrivateVoiceOverlayWindow alloc]
initWithContentRect:NSMakeRect(0, 0, DRAG_W, DRAG_H)
@@ -789,6 +802,7 @@
applyOverlayWindowPolicy(g_dragWindow);
[g_dragWindow orderFrontRegardless];
}
+ markOverlayWindowsVisible();
}
static void overlayShow(void) {
@@ -882,6 +896,7 @@
import "C"
import (
+ "sync"
"time"
"unsafe"
)
@@ -894,6 +909,7 @@
// darwinOverlay uses a native NSWindow + Core Graphics to render
// the floating indicator, matching the Windows GDI+ implementation.
type darwinOverlay struct {
+ mu sync.Mutex
dragCb func(int, int)
created bool
done chan struct{}
@@ -906,12 +922,17 @@
}
func (o *darwinOverlay) ensureCreated() {
- if !o.created {
- C.overlayCreate()
- o.created = true
- time.Sleep(50 * time.Millisecond)
- go o.pollDrag()
+ o.mu.Lock()
+ if o.created {
+ o.mu.Unlock()
+ return
}
+ o.created = true
+ o.mu.Unlock()
+
+ C.overlayCreate()
+ time.Sleep(50 * time.Millisecond)
+ go o.pollDrag()
}
func (o *darwinOverlay) Show() {
diff --git a/C1.source/privatevoice.src/internal/overlay/overlay_windows.go b/C1.source/privatevoice.src/internal/overlay/overlay_windows.go
index b19d38e..3cbaa3f 100755
--- a/C1.source/privatevoice.src/internal/overlay/overlay_windows.go
+++ b/C1.source/privatevoice.src/internal/overlay/overlay_windows.go
@@ -5,6 +5,7 @@
import (
"math"
"runtime"
+ "sync"
"syscall"
"time"
"unsafe"
@@ -257,12 +258,13 @@
}
type winOverlay struct {
- showCh chan bool
- statusCh chan statusCmd
- volCh chan float64
- posCh chan [2]int
- closeCh chan struct{}
- done chan struct{}
+ showCh chan bool
+ statusCh chan statusCmd
+ volCh chan float64
+ posCh chan [2]int
+ closeCh chan struct{}
+ done chan struct{}
+ closeOnce sync.Once
barHeights [nBars]float64
animTime float64
@@ -307,7 +309,12 @@
func (o *winOverlay) GetPosition() (int, int) { return o.posX, o.posY }
func (o *winOverlay) Size() (int, int) { return capW, capH }
func (o *winOverlay) OnDragged(fn func(x, y int)) { o.dragCb = fn }
-func (o *winOverlay) Close() { close(o.closeCh); <-o.done }
+func (o *winOverlay) Close() {
+ o.closeOnce.Do(func() {
+ close(o.closeCh)
+ })
+ <-o.done
+}
func trySend(ch chan bool, v bool) {
select {
diff --git a/C1.source/privatevoice.src/scripts/benchmark-recognition.sh b/C1.source/privatevoice.src/scripts/benchmark-recognition.sh
new file mode 100755
index 0000000..bcfe28c
--- /dev/null
+++ b/C1.source/privatevoice.src/scripts/benchmark-recognition.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WAV_PATH="${1:-"$ROOT_DIR/testdata/perf/baseline.wav"}"
+RUNS="${2:-3}"
+
+if [[ ! -f "$WAV_PATH" ]]; then
+ cat >&2 <<EOF
+Missing WAV fixture: $WAV_PATH
+
+Usage:
+ scripts/benchmark-recognition.sh /absolute/path/to/baseline.wav 5
+
+The WAV must be 16kHz mono PCM16. Keep the same fixture across versions for A/B performance comparison.
+EOF
+ exit 2
+fi
+
+cd "$ROOT_DIR"
+go run ./cmd/benchmark-recognition --wav "$WAV_PATH" --runs "$RUNS"
diff --git a/C1.source/privatevoice.src/scripts/build-windows-preview.sh b/C1.source/privatevoice.src/scripts/build-windows-preview.sh
index f89f3ca..1c8d1a4 100755
--- a/C1.source/privatevoice.src/scripts/build-windows-preview.sh
+++ b/C1.source/privatevoice.src/scripts/build-windows-preview.sh
@@ -4,8 +4,8 @@
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
-APP_VERSION="2.2.0"
-APP_BUILD="20260702.0303"
+APP_VERSION="2.2.2"
+APP_BUILD="20260703.0335"
ARCH="${1:-amd64}"
if [[ "$ARCH" != "amd64" ]]; then
diff --git a/C1.source/privatevoice.src/services/diagnostics_service.go b/C1.source/privatevoice.src/services/diagnostics_service.go
new file mode 100644
index 0000000..15f79f0
--- /dev/null
+++ b/C1.source/privatevoice.src/services/diagnostics_service.go
@@ -0,0 +1,196 @@
+package services
+
+import (
+ "archive/zip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+ "voicesnap/internal/config"
+ "voicesnap/internal/model"
+ "voicesnap/internal/modelselection"
+ "voicesnap/internal/paths"
+)
+
+type DiagnosticsService struct {
+ cfg *config.Config
+ version string
+}
+
+func NewDiagnosticsService(cfg *config.Config, version string) *DiagnosticsService {
+ if cfg == nil {
+ cfg = config.Default()
+ }
+ return &DiagnosticsService{cfg: cfg, version: version}
+}
+
+func (s *DiagnosticsService) GetRuntimeStats() map[string]interface{} {
+ var mem runtime.MemStats
+ runtime.ReadMemStats(&mem)
+ return map[string]interface{}{
+ "version": s.version,
+ "goos": runtime.GOOS,
+ "goarch": runtime.GOARCH,
+ "go_version": runtime.Version(),
+ "goroutines": runtime.NumGoroutine(),
+ "cpu_count": runtime.NumCPU(),
+ "heap_alloc_mb": bytesToMiB(mem.HeapAlloc),
+ "heap_sys_mb": bytesToMiB(mem.HeapSys),
+ "stack_sys_mb": bytesToMiB(mem.StackSys),
+ "next_gc_mb": bytesToMiB(mem.NextGC),
+ "num_gc": mem.NumGC,
+ "data_dir": paths.AppSupportDir(),
+ "models_dir": paths.ModelsRoot(),
+ }
+}
+
+func (s *DiagnosticsService) ExportDiagnostics() (string, error) {
+ dir := filepath.Join(paths.AppSupportDir(), "diagnostics")
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", err
+ }
+ outPath := filepath.Join(dir, "privatevoice-diagnostics-"+time.Now().Format("20060102-150405")+".zip")
+
+ out, err := os.Create(outPath)
+ if err != nil {
+ return "", err
+ }
+ defer out.Close()
+
+ zw := zip.NewWriter(out)
+ if err := s.addJSON(zw, "runtime.json", s.GetRuntimeStats()); err != nil {
+ zw.Close()
+ return "", err
+ }
+ if err := s.addJSON(zw, "config.redacted.json", redactedConfig(s.cfg)); err != nil {
+ zw.Close()
+ return "", err
+ }
+ if err := s.addJSON(zw, "models.json", modelDiagnostics(s.cfg)); err != nil {
+ zw.Close()
+ return "", err
+ }
+ if err := addLogFiles(zw); err != nil {
+ zw.Close()
+ return "", err
+ }
+ if err := zw.Close(); err != nil {
+ return "", err
+ }
+ return outPath, nil
+}
+
+func (s *DiagnosticsService) addJSON(zw *zip.Writer, name string, value interface{}) error {
+ data, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return err
+ }
+ w, err := zw.Create(name)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(append(data, '\n'))
+ return err
+}
+
+func addLogFiles(zw *zip.Writer) error {
+ for _, name := range []string{"app.log", "app.log.1", "app.log.2", "app.log.3"} {
+ path := filepath.Join(paths.AppSupportDir(), name)
+ if err := addFileIfExists(zw, path, filepath.Join("logs", name)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func addFileIfExists(zw *zip.Writer, path, zipName string) error {
+ in, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer in.Close()
+
+ info, err := in.Stat()
+ if err != nil {
+ return err
+ }
+ header, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+ header.Name = zipName
+ header.Method = zip.Deflate
+ w, err := zw.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, in)
+ return err
+}
+
+func redactedConfig(cfg *config.Config) map[string]interface{} {
+ if cfg == nil {
+ cfg = config.Default()
+ }
+ return map[string]interface{}{
+ "HotkeyVK": cfg.HotkeyVK,
+ "HotkeyMode": cfg.HotkeyMode,
+ "AutoHide": cfg.AutoHide,
+ "SoundFeedback": cfg.SoundFeedback,
+ "HideDockIcon": cfg.HideDockIcon,
+ "CopyToClipboard": cfg.CopyToClipboard,
+ "DeviceNameSet": cfg.DeviceName != "",
+ "SelectedModelID": cfg.SelectedModelID,
+ "ModelSelectionMode": cfg.ModelSelectionMode,
+ "LanguageMode": cfg.LanguageMode,
+ "LanguageID": cfg.LanguageID,
+ }
+}
+
+func modelDiagnostics(cfg *config.Config) map[string]interface{} {
+ current := modelselection.Resolve(cfg, nil)
+ items := make([]map[string]interface{}, 0)
+ for _, profile := range model.ListModelProfiles() {
+ resolved, err := model.ResolveModel(profile.ID)
+ item := map[string]interface{}{
+ "model_id": profile.ID,
+ "display_name": profile.DisplayName,
+ "backend": profile.BackendKind,
+ "tier": profile.Tier,
+ "num_threads": profile.NumThreads,
+ "current": profile.ID == current.ModelID,
+ "supported": model.IsModelSupportedInCurrentBuild(profile.ID),
+ "download_size": profile.ApproxSize,
+ }
+ if err != nil {
+ item["status"] = "resolve_error"
+ item["error"] = err.Error()
+ } else {
+ item["status"] = string(resolved.Status)
+ item["usable"] = resolved.IsUsable()
+ item["source_dir_kind"] = resolved.SourceDirKind
+ item["missing"] = resolved.Missing
+ item["problems"] = resolved.Problems
+ }
+ items = append(items, item)
+ }
+ return map[string]interface{}{
+ "current_model_id": current.ModelID,
+ "selection_mode": current.SelectionMode,
+ "effective_language_id": current.LanguageSettings.EffectiveLanguageID,
+ "fallback_reason": current.FallbackReason,
+ "has_any_usable_model": model.HasAnyUsableModel(),
+ "supported_build_note": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+ "models": items,
+ }
+}
+
+func bytesToMiB(value uint64) float64 {
+ return float64(value) / 1024.0 / 1024.0
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27c5be6..e0b338e 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## v2.2.2 (2026-07-03)
+
+### 性能与稳定性恢复
+
+- **恢复 2.2.1 本地体验包中的性能修复**:合入慢识别诊断、流水线耗时记录、识别 benchmark 工具和诊断服务,用于定位模型、录音、识别、粘贴和 UI 生命周期耗时。
+- **强化引擎生命周期保护**:补入引擎重载串行化、释放保护和跨平台占位实现,降低频繁切换语言 / 模型或长时间运行时的状态竞争风险。
+- **补入录音、热键、overlay 和事件订阅清理**:收敛 recorder、hotkey、overlay 与前端事件订阅的生命周期泄漏风险,改善长时间真实使用稳定性。
+- **保留正式版本口径**:本版本恢复的是 `2.2.1 build 20260702.1737` 的修复内容,但正式候选版本升为 `2.2.2`,避免与旧 macOS arm64 local 体验包混淆。
+
+### 构建
+
+- build: `20260703.0335`
+- 说明: 本版本为 2.2.1 性能 / 稳定性修复恢复后的正式候选源码目标;进入四包冻结前仍需代码审核、QA 和发布门禁。
+
+---
+
## v2.2.0 (2026-07-02)
### 粤语语言版本
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index 91cfda1..57ef3e1 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -3,11 +3,27 @@
## Project
- Product: Wails v3 / Go desktop dictation app.
-- Git root: `/Users/ar/Projects/PrivateVoice2/03-O`.
-- Source root: `/Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src`.
+- Git root: `/Users/ar/Projects/PrivateVoice/03-O`.
+- Source root: `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`.
- Current branch purpose: Round 6 Cantonese language version real-user test package based on `v2.2.0-build20260702.0303`.
- Protected behavior: existing macOS App Store, local DMG, Monterey compatibility, Windows preview, model download safety, and paste/clipboard protection must not be changed without a scoped task and QA plan.
+## Automatic Codebase Memory
+
+- Status: `enabled`.
+- Tool: `codebase-memory-mcp` 0.8.1.
+- Current project name after path unification: `Users-ar-Projects-PrivateVoice-03-O`.
+- Existing artifact note: `.codebase-memory/artifact.json` may still record legacy project name `Users-ar-Projects-PrivateVoice2-03-O` until the graph is regenerated.
+- Shared artifact: `.codebase-memory/graph.db.zst`; metadata: `.codebase-memory/artifact.json`.
+- Scope: `/Users/ar/Projects/PrivateVoice/03-O`.
+- Refresh command: `codebase-memory-mcp cli index_repository '{"repo_path":"/Users/ar/Projects/PrivateVoice/03-O"}'`.
+- Current `indexed_at`, commit, node count, and edge count are recorded in `.codebase-memory/artifact.json`.
+- Excluded by indexer: `.git`, `K3.运营与发布资料/legacy-运营资料/PrivateVoice-26.0604上架`, `K3.运营与发布资料/legacy-运营资料/运营文档`, `C1.source/privatevoice.src/frontend/dist`, `C1.source/privatevoice.src/frontend/node_modules`.
+- Query example after regenerating graph from Git root: `codebase-memory-mcp cli search_graph '{"project":"Users-ar-Projects-PrivateVoice-03-O","query":"model registry","limit":5}'`.
+- This automatic graph is an exploration aid only; it does not replace source reading, tests, QA evidence, or this `CODEGRAPH.md`.
+- Refresh rule: regenerate `.codebase-memory/` before each source freeze, version freeze, formal rollback-capable test package, or release package; daily Coder work-in-progress changes do not force a refresh.
+- Freeze record requirement: record the refresh command, `.codebase-memory/artifact.json` path, indexed commit, freeze commit, and whether the two commits match or differ only by `.codebase-memory/` artifact updates.
+
## Core Entrypoints
- `C1.source/privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard.
diff --git "a/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md" "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md"
index fdd06c7..0c95173 100644
--- "a/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md"
+++ "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md"
@@ -4,6 +4,7 @@
- 当前源码根:`03-O/C1.source/privatevoice.src`
- 工程地图:`03-O/CODEGRAPH.md`
+- 自动代码知识图谱:`03-O/.codebase-memory/`
- 主要技术栈:Go、Wails v3、Svelte 5、Vite、sherpa-onnx、malgo。
## 产品成果
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-1-\345\274\200\345\217\221\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..5974c02
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,101 @@
+# R221-1 开发自查
+
+## 任务
+
+- 任务 ID:`R221-1`
+- Review Gate ID:`R221-2-GATE-20260703.0335`
+- 目标:把归档 `2.2.1 build 20260702.1737` 中的性能与稳定性修复合入当前活跃源码根,并按 Owner 已确认口径升为 `2.2.2` 新 build。
+- 不做范围:不生成四包、不创建 release tag、不把归档 arm64 local DMG 改称当前正式候选、不跳过代码审核 / QA / 发布门禁。
+
+## 起点
+
+- 当前 Git 根:`/Users/ar/Projects/PrivateVoice/03-O`
+- 当前活跃源码根:`03-O/C1.source/privatevoice.src`
+- 恢复快照:`03-O/C1.source/privatevoice-2.2.1-recovered.src`
+- 归档仓库:`/Users/ar/Downloads/0702codex-move/02-project2/26.0702-Archive/PrivateVoice`
+- 归档修复 commit:`ada0f669729d7a30b0112fe0af34a65cdfde2a32`
+- 对比基线:`0ffbf1935c9d091cce22a5583275ac0902f7a693`
+
+## 已执行
+
+- 将归档 commit 中 `privatevoice.src/` 下的变更文件复制到当前活跃源码根。
+- 新增 / 恢复:
+ - `C1.source/privatevoice.src/cmd/benchmark-recognition/main.go`
+ - `C1.source/privatevoice.src/scripts/benchmark-recognition.sh`
+ - `C1.source/privatevoice.src/services/diagnostics_service.go`
+- 更新性能 / 稳定性相关代码:
+ - App 识别流水线、慢识别日志、诊断服务、benchmark 入口。
+ - Recorder、engine、hotkey、overlay、logger、前端事件订阅和设置页生命周期处理。
+- 将版本元数据从归档体验包的 `2.2.1 / 20260702.1737` 改为正式候选目标 `2.2.2 / 20260703.0335`:
+ - `C1.source/privatevoice.src/app.go`
+ - `C1.source/privatevoice.src/build/config.yml`
+ - `C1.source/privatevoice.src/build/darwin/Info.plist`
+ - `C1.source/privatevoice.src/frontend/package.json`
+ - `C1.source/privatevoice.src/frontend/package-lock.json`
+ - `C1.source/privatevoice.src/scripts/build-windows-preview.sh`
+- 更新 `CHANGELOG.md`,新增 `v2.2.2 (2026-07-03)` 记录。
+
+## 已验证
+
+通过:
+
+```bash
+git -C '03-O' diff --check
+npm --prefix '03-O/C1.source/privatevoice.src/frontend' run build
+gofmt -w <changed-go-files>
+go test ./...
+```
+
+前端 build 通过,仍有既有 Svelte a11y warning:
+
+- `src/components/update/UpdateDialog.svelte`
+- `src/components/shared/ToggleSwitch.svelte`
+
+Go 门禁已在 R221-1A 后续补跑通过;`go test ./...` 输出存在 macOS 链接 warning:当前 Go / CLT 在 macOS 26 环境生成的临时 object 标记高于 `macOS-11.0` 链接目标,且 `@rpath/libonnxruntime.1.24.4.dylib` 标记最低 macOS 14.0。该 warning 未导致测试失败,但代码审核 / 发布前需评估目标系统兼容口径。
+
+## R221-1A 补修记录
+
+- Review Gate ID:`R221-2-GATE-20260703.0335`
+- 处理范围:只处理 R221-2 `CHANGES_REQUESTED` 阻断项;未生成安装包、未创建 tag、未进入 QA、未扩展官网 / Windows 正式版 / Monterey backlog。
+
+已处理:
+
+- 已回退默认 UI fallback 从中文改为英文的产品外变更:
+ - `C1.source/privatevoice.src/frontend/src/App.svelte`
+ - `C1.source/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte`
+ - `C1.source/privatevoice.src/frontend/src/lib/i18n/index.ts`
+- 已将新增文件纳入待审 diff / 后续提交范围:使用 `git add -N -- ...` 标记 intent-to-add,复核状态为 ` A`:
+ - `C1.source/privatevoice.src/cmd/benchmark-recognition/main.go`
+ - `C1.source/privatevoice.src/scripts/benchmark-recognition.sh`
+ - `C1.source/privatevoice.src/services/diagnostics_service.go`
+
+本轮验证:
+
+```bash
+git diff --check
+gofmt -w <changed-go-files>
+go test ./...
+```
+
+结果:
+
+- `git diff --check`:PASS。
+- `gofmt -w <changed-go-files>`:PASS,已覆盖当前待审 diff 中的 17 个 `.go` 变更文件。
+- `go test ./...`:PASS。
+- 环境:`go version go1.26.4 darwin/arm64`;Apple clang `17.0.0`,Command Line Tools 路径 `/Library/Developer/CommandLineTools`。
+
+剩余风险:
+
+- `go test ./...` 输出 macOS 链接 warning:当前 Go / CLT 生成物标记高于 `macOS-11.0` 链接目标,且 `@rpath/libonnxruntime.1.24.4.dylib` 标记最低 macOS 14.0;未导致测试失败,但需代码审核 / 发布流程评估对 Monterey 或更低版本兼容口径的影响。
+
+## 风险点
+
+- 本轮恢复的是历史修复 commit 的源码内容,尚未完成代码审核。
+- Go 编译 / 测试已在本环境补跑通过,但存在 macOS 链接 warning,需在代码审核 / 发布前判断是否影响目标系统兼容承诺。
+- `2.2.2` 四包、manifest、SHA256、tag、codebase-memory 刷新均未执行,必须在 R221-2 / R221-3 PASS 后由发布流程完成。
+
+## 交接
+
+- 当前 R221-1A 结论:新增文件纳入 diff、UI fallback 回退、`gofmt` 与 `go test ./...` 均已完成;可针对同一 Review Gate ID `R221-2-GATE-20260703.0335` 请求复审。
+- 下一步:PMO 重新派发 R221-2 复审;R221-2 PASS 前不得进入 QA / 发布冻结。
+- 代码审核重点:恢复 diff 是否完整、版本口径是否正确、性能诊断和生命周期修复是否引入回归、Windows preview 构建脚本是否仍符合规则。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-3-QA-evidence.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-3-QA-evidence.md"
new file mode 100644
index 0000000..f447037
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R221-3-QA-evidence.md"
@@ -0,0 +1,96 @@
+# R221-3 QA Evidence
+
+## 结论
+
+- 任务 ID:`R221-3`
+- Review Gate ID:`R221-2-GATE-20260703.0335`
+- QA 结论:`PASS`
+- 时间:`2026-07-03 05:10:25 +0800`
+- 范围:验证恢复后的当前源码与 `2.2.2 build 20260703.0335` 候选构建路径。
+- 不做范围:不修改源码、不创建 tag、不生成正式发布包、不把本地 QA evidence 当作发布候选。
+
+QA 允许 PMO 解锁 `R221-4` 发布冻结流程。该结论不等于 Windows 正式版通过、不等于 Windows 真机 QA 通过、不等于 Monterey 兼容通过、不等于 notarized 官网正式发布通过。
+
+## 环境
+
+- 项目根:`/Users/ar/Projects/PrivateVoice`
+- Git 根:`/Users/ar/Projects/PrivateVoice/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`
+- 分支:`codex/x-asr-experiment-2.1.29`
+- HEAD:`abb469859662c285fac758c042812e0da943728f`
+- macOS:`26.1 (25B78)`
+- Go:`go version go1.26.4 darwin/arm64`
+- Node:`v23.11.1`
+- npm:`10.9.2`
+
+## 工作区核对
+
+- `git status --short --branch`:当前分支 ahead 1,存在 R221 待审源码 / 文档 diff。
+- `git diff --cached --name-status`:无输出,当前无 staged diff。
+- `git diff --stat`:43 files changed,包含 R221 恢复源码、版本元数据、前端生命周期清理、Windows preview 脚本、CODEGRAPH / 管理文档等。
+- 新增源码文件已在 diff 范围:`cmd/benchmark-recognition/main.go`、`scripts/benchmark-recognition.sh`、`services/diagnostics_service.go`。
+
+## 标准源码门禁
+
+| 项 | 命令 | 结果 | 备注 |
+| --- | --- | --- | --- |
+| 空白检查 | `git diff --check` | PASS | 无输出 |
+| gofmt 状态 | `git diff --name-only -- C1.source/privatevoice.src | grep -E '\.go$' | xargs gofmt -l` | PASS | 无输出,表示当前变更 Go 文件格式干净 |
+| Go 测试 | `go test ./...` | PASS with warning | 复现 macOS 链接 warning,未导致测试失败 |
+| 前端构建 | `npm --prefix frontend run build` | PASS with warning | 复现既有 Svelte a11y warning:`UpdateDialog.svelte`、`ToggleSwitch.svelte` |
+
+## 核心路径核对
+
+| 路径 | Evidence | 结论 |
+| --- | --- | --- |
+| 核心语音输入 / 录音 / 识别流水线 | `go test ./...`;`go test ./internal/model ./internal/modelselection ./internal/language ./internal/engine ./internal/audio ./services`;`go test . -run 'TestSlowRecognitionReason|TestRecognize|TestHotkey|TestLiveCaption|TestWaitForHotkeyRelease|TestReload|TestOverlay|TestVersion'` | 自动化层 PASS;未在本机做真实麦克风、全局热键、粘贴上屏手动测试 |
+| 设置 / 热键配置 | 前端 build PASS;diff 核对 `GeneralPage.svelte`、`HotkeysPage.svelte` 增加 `onDestroy` 和 `clearHotkeyCapture()` | 代码级生命周期清理路径通过;未做真实窗口交互测试 |
+| 语言 / 模型选择 | `go test ./internal/model ./internal/modelselection ./internal/language ./internal/engine ./internal/audio ./services` PASS;`go test ./internal/model -run 'Test.*Yue|Test.*Cantonese|Test.*Language'` PASS | 自动化层覆盖语言 / 模型解析 |
+| 粤语 `yue-HK` | `rg` 核对 `CantoneseLanguageID = "yue-HK"`、`SenseVoiceYueModelID = "sensevoice-yue-2025-09-09"`、`LanguageParam: "yue"`;`internal/engine` 测试 PASS | 粤语注册与 SenseVoice `yue` 参数路径通过 |
+| 前端生命周期风险 | diff 核对 `App.svelte` 释放 Wails event off hooks 和 `hashchange`;`LanguagePage.svelte` 释放模型下载事件;`PermissionsPage.svelte` 释放 interval / timeout;`SettingsWindow.svelte` 释放 store subscription | 生命周期清理路径通过构建与代码核对;未做长时间打开 / 反复开关窗口的视觉 smoke |
+
+## Windows Preview 适配回归
+
+| 项 | 命令 / 证据 | 结果 |
+| --- | --- | --- |
+| Windows cross smoke | `GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go test -exec=/usr/bin/true ./...` | NOT COVERED:本机缺 `x86_64-w64-mingw32-gcc`,命令失败于工具链,不是源码测试失败 |
+| 编译器检查 | `command -v x86_64-w64-mingw32-gcc` | 未找到 |
+| Windows build 脚本语法 | `bash -n scripts/build-windows-preview.sh` | PASS |
+| Windows evidence verifier 语法 | `bash -n scripts/verify-windows-qa-evidence.sh` | PASS |
+| 版本 / build | `scripts/build-windows-preview.sh` 内 `APP_VERSION="2.2.2"`、`APP_BUILD="20260703.0335"` | PASS |
+| preview 口径 | `README-WINDOWS-PREVIEW.txt` 模板声明 unsigned preview、不是安装器、需 Windows 10/11 QA | PASS |
+| evidence 要求 | `verify-windows-qa-evidence.sh` 要求 `qa-results.tsv`、`app.log`、`qa-target-dictation-text.txt`、`system-info.txt`,成功时输出 `WINDOWS_QA_EVIDENCE_PASS` | PASS |
+
+QA 判断:Windows preview 构建路径与 evidence 规则可进入 `R221-4` 发布冻结环节,但发布 / 环境窗口必须补齐 MinGW 工具链并实际生成 / 验证 Windows preview ZIP。不能把 Windows preview 说成正式 Windows 版。
+
+## 版本与构建路径
+
+- `app.go`:`appVersion = "2.2.2"`,`appBuild = "20260703.0335"`。
+- `build/config.yml`:`version: "2.2.2"`。
+- `build/darwin/Info.plist`:`CFBundleShortVersionString = 2.2.2`,`CFBundleVersion = 20260703.0335`,`LSMinimumSystemVersion = 14.0`。
+- `frontend/package.json` / `package-lock.json`:`2.2.2`。
+- `scripts/build-windows-preview.sh`:`2.2.2 / 20260703.0335`。
+- `CHANGELOG.md`:已有 `v2.2.2 (2026-07-03)`,明确恢复 `2.2.1` 内容但正式候选升为 `2.2.2`。
+- `bash -n scripts/build-local-macos.sh`、`bash -n scripts/build-local-macos-universal.sh`、`bash -n scripts/build-release-macos.sh`:PASS。
+
+## macOS Warning / Monterey 判断
+
+`go test ./...` 输出 macOS 链接 warning:
+
+- Go / CLT 临时 object 标记为 macOS 26.0,高于链接目标 macOS 11.0。
+- `@rpath/libonnxruntime.1.24.4.dylib` 最低 macOS 14.0。
+
+QA 判断:
+
+- 不阻塞当前主线 `2.2.2`,因为当前 mainline macOS 包口径为 `LSMinimumSystemVersion = 14.0`,且 Go 测试已通过。
+- 不得据此声称 Monterey 兼容。若 Owner 需要覆盖 macOS 12 / Monterey,必须单独升级 Monterey QA / 构建任务,至少验证 `build-local-macos-monterey.sh`、对应依赖最低系统和真机安装启动。
+
+## 未覆盖风险
+
+- 未做真实麦克风录音、全局热键、Notepad / 输入框粘贴、overlay 视觉稳定性、菜单栏 / Dock 图标策略的手动 smoke。
+- 未做 Windows 10/11 真机 QA,未收回 `WINDOWS_QA_EVIDENCE_PASS` evidence zip。
+- 未跑 Windows cross smoke,原因是本机缺 `x86_64-w64-mingw32-gcc`。
+- 未做 Monterey 验证。
+- 未做 notarization、Developer ID 官网发布链路、下载页 / 授权 / 更新 / 回滚链路验证。
+
+这些风险不阻塞 `R221-4` 进入发布冻结流程,但必须在对应发布 / 平台任务中继续关闭;其中 Windows ZIP 生成必须先补齐 MinGW 工具链。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/handoffs/2026-07-02-\350\277\201\347\247\273\344\272\244\346\216\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/handoffs/2026-07-02-\350\277\201\347\247\273\344\272\244\346\216\245.md"
index 8d3f358..98e4029 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/handoffs/2026-07-02-\350\277\201\347\247\273\344\272\244\346\216\245.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/handoffs/2026-07-02-\350\277\201\347\247\273\344\272\244\346\216\245.md"
@@ -3,14 +3,15 @@
## 来源
- 源项目:`/Users/ar/Projects/PrivateVoice`
-- 新项目:`/Users/ar/Projects/PrivateVoice2`
+- 当前项目:`/Users/ar/Projects/PrivateVoice`
+- 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。
- 源分支:`codex/x-asr-experiment-2.1.29`
- 固定点:`0ffbf1935c9d091cce22a5583275ac0902f7a693`
- Tag:`v2.2.0-build20260702.0303-real-user-test`
## 当前目标
-让新 Codex 窗口可以只读 `PrivateVoice2` 文件继续工作,不依赖旧项目长会话。
+让新 Codex 窗口可以只读 `/Users/ar/Projects/PrivateVoice` 文件继续工作,不依赖旧项目长会话。
## 已完成
@@ -44,4 +45,4 @@
## Reactivation Prompt
-我们从这份 handoff 继续。请先读取本文件,再检查 `/Users/ar/Projects/PrivateVoice2/AGENTS.md`、`PROJECT.md`、`INDEX.md`、`02-P/进度地图.md`、`03-O/CODEGRAPH.md` 和当前 Git 状态,核对哪些结论仍然成立,然后从“下一步”继续推进;不要假设旧 session 上下文仍然可用。
+我们从这份 handoff 继续。请先读取本文件,再检查 `/Users/ar/Projects/PrivateVoice/AGENTS.md`、`PROJECT.md`、`INDEX.md`、`02-P/进度地图.md`、`03-O/CODEGRAPH.md` 和当前 Git 状态,核对哪些结论仍然成立,然后从“下一步”继续推进;不要假设旧 session 上下文仍然可用。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/restore-notes.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/restore-notes.md"
index c786e03..62d9045 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/restore-notes.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/restore-notes.md"
@@ -2,14 +2,14 @@
## 源项目恢复
-本次迁移没有修改、移动、删除或归档 `/Users/ar/Projects/PrivateVoice` 内的任何材料。需要恢复旧项目材料时,直接回到源路径读取。
+本次迁移不再使用 `PrivateVoice2` 作为当前路径。当前项目根为 `/Users/ar/Projects/PrivateVoice`;旧项目材料已作为历史证据保留在本项目结构和 legacy 区内。
## 新项目结构性移动
新项目内发生的结构性移动记录在 `moved-assets.jsonl`。如果要恢复旧仓库布局,可在 `03-O` 中用 Git 历史查看迁移前路径:
```bash
-cd /Users/ar/Projects/PrivateVoice2/03-O
+cd /Users/ar/Projects/PrivateVoice/03-O
git log --oneline --follow -- C1.source/privatevoice.src/main.go
```
@@ -18,5 +18,5 @@
`C2.builds` 和 `K3.运营与发布资料/legacy-运营资料` 中的本地二进制/素材文件默认不提交 Git。若丢失,可从源项目重新复制:
```bash
-rsync -a /Users/ar/Projects/PrivateVoice/Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/ /Users/ar/Projects/PrivateVoice2/03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/
+rsync -a /Users/ar/Projects/PrivateVoice/Release/PrivateVoice-Dictation-2.2.0-build20260702.0303/ /Users/ar/Projects/PrivateVoice/03-O/C2.builds/PrivateVoice-Dictation-2.2.0-build20260702.0303/
```
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/session-topics/00-Topics.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/session-topics/00-Topics.md"
index 706baa5..8525305 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/session-topics/00-Topics.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/session-topics/00-Topics.md"
@@ -1,6 +1,8 @@
# 会话主题索引
-旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。
+本目录是旧项目瘦身迁移时生成的压缩主题摘要 / 接手知识包,不是 PrivateVoice 的常规会话主题汇编目录。
+
+旧项目原始 session 正文不迁入新项目正文。本目录只保留对接手仍有价值的主题摘要和来源索引。PrivateVoice 未来常规会话主题汇编必须写入 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/00-Topics.md` 和 `/Users/ar/Projects/PrivateVoice/.codex-sessionhistory/_topics/`。
| 主题 | 文档 | 时间范围 | 说明 |
| --- | --- | --- | --- |
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225.md"
index 1447f1d..28f0f48 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225.md"
@@ -2,7 +2,7 @@
## 验证目标
-验证 `PrivateVoice2` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。
+验证当前 `PrivateVoice` 迁移包是否可接手。该结论不等同产品 QA PASS、正式发布 PASS 或官网销售链路 PASS。
## 结果
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md"
index 22d180d..bf65f20 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md"
@@ -2,17 +2,17 @@
## 接手目标
-从 `PrivateVoice2` 文件系统接手项目,不依赖旧长会话上下文。
+从 `/Users/ar/Projects/PrivateVoice` 文件系统接手项目,不依赖旧长会话上下文。`PrivateVoice2` 只是迁移期间旧命名,不再作为当前路径。
## 必读顺序
-1. `/Users/ar/Projects/PrivateVoice2/AGENTS.md`
-2. `/Users/ar/Projects/PrivateVoice2/PROJECT.md`
-3. `/Users/ar/Projects/PrivateVoice2/INDEX.md`
-4. `/Users/ar/Projects/PrivateVoice2/02-P/进度地图.md`
-5. `/Users/ar/Projects/PrivateVoice2/02-P/事件地图.md`
-6. `/Users/ar/Projects/PrivateVoice2/03-O/CODEGRAPH.md`
-7. `/Users/ar/Projects/PrivateVoice2/03-O/K2.项目管理/迁移资产清单.md`
+1. `/Users/ar/Projects/PrivateVoice/AGENTS.md`
+2. `/Users/ar/Projects/PrivateVoice/PROJECT.md`
+3. `/Users/ar/Projects/PrivateVoice/INDEX.md`
+4. `/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`
+5. `/Users/ar/Projects/PrivateVoice/02-P/事件地图.md`
+6. `/Users/ar/Projects/PrivateVoice/03-O/CODEGRAPH.md`
+7. `/Users/ar/Projects/PrivateVoice/03-O/K2.项目管理/迁移资产清单.md`
## 当前事实快照
@@ -25,7 +25,7 @@
## 接手自检命令
```bash
-cd /Users/ar/Projects/PrivateVoice2/03-O
+cd /Users/ar/Projects/PrivateVoice/03-O
git status --short --branch
git diff --check
cd C1.source/privatevoice.src
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md"
index 2272114..c2e7370 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md"
@@ -2,8 +2,9 @@
## 来源与目标
-- 来源项目:`/Users/ar/Projects/PrivateVoice`
-- 新项目:`/Users/ar/Projects/PrivateVoice2`
+- 来源项目:旧 PrivateVoice 项目材料
+- 当前项目:`/Users/ar/Projects/PrivateVoice`
+- 历史命名:`PrivateVoice2` 是迁移期间旧命名,不再作为当前路径。
- 迁移时间:2026-07-02
- 基线:`0ffbf1935c9d091cce22a5583275ac0902f7a693` / `v2.2.0-build20260702.0303-real-user-test`
diff --git a/README.md b/README.md
index a044eac..0eff77e 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@
## 开发入口
```bash
-cd /Users/ar/Projects/PrivateVoice2/03-O/C1.source/privatevoice.src
+cd /Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src
npm --prefix frontend run build
go test ./...
```
--
Gitblit v1.9.3