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