From 4e7591ebb1294f611e7b85082be55313dd53820f Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 03 Jun 2026 13:25:19 +0800
Subject: [PATCH] Implement Round 3 Moonshine English model

---
 privatevoice.src/internal/model/profile.go                                |    1 
 privatevoice.src/internal/modelselection/selection_test.go                |   83 ++
 privatevoice.src/internal/engine/engine_darwin.go                         |   44 +
 privatevoice.src/frontend/src/components/settings/GeneralPage.svelte      |   14 
 privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte |   12 
 privatevoice.src/internal/language/language_test.go                       |    3 
 02-P-NBL/round-3/qa-test-plan.md                                          |  208 ++++++
 02-P-NBL/round-3/code-review-current.md                                   |   32 +
 02-P-NBL/round-3/logs/npm-run-build-20260603.log                          |   38 +
 02-P-NBL/round-3/engineering-tasks.md                                     |  336 ++++++++++
 privatevoice.src/internal/engine/engine.go                                |   25 
 privatevoice.src/frontend/src/lib/i18n/zh.json                            |    4 
 02-P-NBL/round-3/qa-handoff-current.md                                    |  100 +++
 privatevoice.src/frontend/src/lib/i18n/en.json                            |    4 
 02-P-NBL/round-3/prd.md                                                   |  349 +++++++++++
 02-P-NBL/round-3/todo-list.md                                             |   60 +
 privatevoice.src/internal/model/registry.go                               |   50 +
 02-P-NBL/round-3/dev-checklist-current.md                                 |   59 +
 privatevoice.src/internal/config/config.go                                |   22 
 privatevoice.src/internal/config/config_test.go                           |   12 
 02-P-NBL/round-3/logs/go-test-all-20260603.log                            |   23 
 privatevoice.src/internal/model/model_test.go                             |   74 ++
 02-P-NBL/round-3/README.md                                                |   63 ++
 privatevoice.src/internal/modelselection/selection.go                     |   56 +
 privatevoice.src/app.go                                                   |   40 
 privatevoice.src/frontend/src/App.svelte                                  |   22 
 privatevoice.src/services/engine_service.go                               |   75 ++
 02-P-NBL/round-3/logs/git-diff-check-20260603.log                         |    0 
 28 files changed, 1,760 insertions(+), 49 deletions(-)

diff --git a/02-P-NBL/round-3/README.md b/02-P-NBL/round-3/README.md
new file mode 100644
index 0000000..cb560d5
--- /dev/null
+++ b/02-P-NBL/round-3/README.md
@@ -0,0 +1,63 @@
+# Round 3:Moonshine English 默认模型
+
+状态:v0.1 启动稿
+所属子项目:`02-P-NBL`
+Round:Round 3
+基线版本:`v2.1.20-build20260603.1145`
+基线 commit:`f66a79670c444010a59b8513c9f61257ebc7d9b6`
+工作分支:`codex/round3-moonshine-english`
+日期:2026-06-03
+
+## 1. 本轮目标
+
+Round 3 的目标是让 English 语言版本真正进入英文离线识别路径:
+
+```text
+English effective language -> moonshine-en -> sherpa-onnx Moonshine v1 -> English offline recognition
+```
+
+中文路径保持不变:
+
+```text
+Chinese effective language -> sensevoice-zh -> SenseVoice
+```
+
+## 2. 本轮文档
+
+- [`prd.md`](prd.md):产品需求和验收边界
+- [`engineering-tasks.md`](engineering-tasks.md):研发任务拆解
+- [`qa-test-plan.md`](qa-test-plan.md):QA 测试计划
+- [`todo-list.md`](todo-list.md):本轮执行 Todo
+
+## 3. 关键技术修正
+
+总分期计划里的 Moonshine 文件名和 sherpa 配置字段需要修正。
+
+当前项目使用 `github.com/k2-fsa/sherpa-onnx-go-macos v1.12.24`,该版本的 Moonshine 配置是 v1 四文件:
+
+```text
+preprocess.onnx
+encode.int8.onnx
+uncached_decode.int8.onnx
+cached_decode.int8.onnx
+tokens.txt
+```
+
+对应 Go 字段:
+
+```go
+config.ModelConfig.Moonshine.Preprocessor
+config.ModelConfig.Moonshine.Encoder
+config.ModelConfig.Moonshine.UncachedDecoder
+config.ModelConfig.Moonshine.CachedDecoder
+config.ModelConfig.Tokens
+```
+
+不要按旧文档里的 `encoder_model.ort` / `decoder_model_merged.ort` / `MergedDecoder` 实现 Round 3。
+
+## 4. 资料依据
+
+- sherpa-onnx Moonshine v1 模型文档:<https://k2-fsa.github.io/sherpa/onnx/moonshine/models.html>
+- sherpa-onnx Moonshine Go API 示例入口:<https://k2-fsa.github.io/sherpa/onnx/moonshine/go.html>
+- k2-fsa GitHub release asset:<https://github.com/k2-fsa/sherpa-onnx/releases/tag/asr-models>
+
diff --git a/02-P-NBL/round-3/code-review-current.md b/02-P-NBL/round-3/code-review-current.md
new file mode 100644
index 0000000..ee004ca
--- /dev/null
+++ b/02-P-NBL/round-3/code-review-current.md
@@ -0,0 +1,32 @@
+# Round 3 等效代码复审记录
+
+状态:通过,未发现阻断问题
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+工作分支:`codex/round3-moonshine-english`
+
+## 1. 复审范围
+
+重点复审:
+
+- `moonshine-en` profile 是否使用正确 required files。
+- English language 默认模型是否切换到 `moonshine-en`。
+- 旧配置 `SelectedModelID=sensevoice-zh` 是否不会锁死 English 默认模型。
+- `engine.New()` 是否按 current model 初始化。
+- macOS Moonshine sherpa config 是否使用 v1 四文件。
+- Onboarding 是否不再写死 SenseVoice URL。
+- 语言切换后是否同步 current model。
+- 引擎重载失败是否清空旧引擎,避免继续误用旧模型。
+- SenseVoice 中文路径是否保留。
+
+## 2. 复审结论
+
+未发现阻断问题。
+
+## 3. 已知非阻断项
+
+- 旧 `EngineService.DownloadModel(primaryURL, fallbackURL)` API 仍保留,用于兼容;Round 3 前端已不再调用。
+- Linux/Windows engine 文件仍是 SenseVoice 路径;本项目当前 Round 3 交付目标为 macOS。
+- `npm run build` 仍有既有 Svelte a11y warning,不是本轮新增。
+
diff --git a/02-P-NBL/round-3/dev-checklist-current.md b/02-P-NBL/round-3/dev-checklist-current.md
new file mode 100644
index 0000000..1b890a0
--- /dev/null
+++ b/02-P-NBL/round-3/dev-checklist-current.md
@@ -0,0 +1,59 @@
+# Round 3 研发自查记录
+
+状态:开发自查通过,待 QA
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+工作分支:`codex/round3-moonshine-english`
+基线 commit:`f66a79670c444010a59b8513c9f61257ebc7d9b6`
+
+## 1. 变更范围
+
+本次已完成 Round 3 核心开发:
+
+- 新增 `moonshine-en` model profile。
+- English language profile 默认模型改为 `moonshine-en`。
+- 新增 `ModelSelectionMode=auto|manual`,旧配置缺字段时按 `auto` 处理。
+- 新增 `internal/modelselection` 包,集中解析 current model。
+- `engine.New()` 改为按 current model 初始化。
+- macOS engine 新增 sherpa-onnx Moonshine v1 backend builder。
+- `EngineService` 新增 current model 状态、下载和重载 API。
+- Onboarding 改为下载 current model profile,不再由前端传 SenseVoice URL。
+- 设置页语言切换后会同步 current model,缺模型时进入 onboarding。
+- 引擎替换增加互斥保护,避免重载和识别并发关闭。
+- 前端文案去掉 Round 2 的“English 仍保持 SenseVoice”描述。
+
+## 2. 明确未做
+
+- 未接入 Parakeet。
+- 未接入 Qwen3-ASR。
+- 未接入 Moonshine v2。
+- 未做模型选择页。
+- 未做模型删除 UI。
+- 未做正式签名和 notarization。
+
+## 3. 自查命令
+
+| 命令 | 结果 | 日志 |
+|---|---|---|
+| `npm run build` | PASS | [`logs/npm-run-build-20260603.log`](logs/npm-run-build-20260603.log) |
+| `go test ./... -count=1` | PASS | [`logs/go-test-all-20260603.log`](logs/go-test-all-20260603.log) |
+| `git diff --check` | PASS | [`logs/git-diff-check-20260603.log`](logs/git-diff-check-20260603.log) |
+
+## 4. 等效代码复审
+
+复审记录:[`code-review-current.md`](code-review-current.md)
+
+## 5. 已知提示
+
+`npm run build` 仍有既有 Svelte a11y warning:
+
+- `UpdateDialog.svelte` 的 dialog tabindex warning。
+- `UpdateDialog.svelte` 的 div interaction warning。
+- `ToggleSwitch.svelte` 的 aria-label warning。
+
+这些 warning 不是本轮新增功能引入,当前不阻断 Round 3 QA。
+
+## 6. 研发结论
+
+Round 3 开发自查通过,可以进入 QA。
diff --git a/02-P-NBL/round-3/engineering-tasks.md b/02-P-NBL/round-3/engineering-tasks.md
new file mode 100644
index 0000000..b324cfb
--- /dev/null
+++ b/02-P-NBL/round-3/engineering-tasks.md
@@ -0,0 +1,336 @@
+# Round 3 研发任务拆解:Moonshine English 默认模型
+
+状态:v0.1 研发拆解稿
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+
+## 1. 研发目标
+
+Round 3 的研发目标是:
+
+```text
+English effective language 默认使用 moonshine-en,并通过 sherpa-onnx Moonshine v1 完成英文离线识别。
+```
+
+本轮必须保持中文 SenseVoice 路径不退化。
+
+## 2. 交付边界
+
+### 2.1 必须交付
+
+- `moonshine-en` model profile。
+- `BackendMoonshine` backend kind。
+- English language profile 的 `DefaultModelID=moonshine-en`。
+- 当前模型解析器:effective language + model selection mode。
+- Moonshine required files 校验。
+- Moonshine 下载、解压、安装、状态记录。
+- Moonshine sherpa config builder。
+- Engine factory 支持 SenseVoice 和 Moonshine 分发。
+- Onboarding 改为下载 current model profile。
+- 语言切换后同步 current model 状态。
+- 单元测试覆盖 profile、resolver、下载校验、backend 分发。
+- 开发自查、QA handoff。
+
+### 2.2 明确不交付
+
+- Parakeet。
+- Qwen3-ASR。
+- Moonshine v2。
+- 模型选择页。
+- 模型删除 UI。
+- 高级模型切换 UI。
+- 30+ 语言列表。
+- 正式签名和 notarization。
+
+## 3. 推荐任务顺序
+
+```text
+T0 代码现状确认
+T1 Moonshine profile
+T2 默认模型解析器
+T3 Engine factory/backend builder
+T4 Onboarding 下载 current model
+T5 语言切换后的模型同步
+T6 前端文案与状态展示
+T7 单元测试
+T8 开发自查
+T9 QA handoff
+```
+
+## 4. 任务明细
+
+### T0:代码现状确认
+
+负责人:后端 + 前端
+类型:准备任务
+
+重点文件:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/model/downloader.go`
+- `privatevoice.src/internal/model/install_state.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/internal/language/resolver.go`
+- `privatevoice.src/internal/engine/engine.go`
+- `privatevoice.src/internal/engine/engine_darwin.go`
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/services/config_service.go`
+- `privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte`
+- `privatevoice.src/frontend/src/App.svelte`
+
+完成标准:
+
+- 明确哪些 API 仍写死 SenseVoice。
+- 明确 config 默认值和旧配置兼容策略。
+- 明确前端 onboarding 当前调用路径。
+
+### T1:Moonshine profile
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/model/model_test.go`
+
+工作内容:
+
+- 新增常量:
+  - `MoonshineModelID = "moonshine-en"`
+  - `BackendMoonshine = "moonshine"`
+- 新增 `moonshine-en` profile。
+- English language profile 的 `DefaultModelID` 改为 `moonshine-en`。
+- 配置 required files:
+  - `preprocess.onnx`
+  - `encode.int8.onnx`
+  - `uncached_decode.int8.onnx`
+  - `cached_decode.int8.onnx`
+  - `tokens.txt`
+- `ProviderOrder` 使用 `cpu`。
+- `DownloadURLs` 使用官方 base-en-int8 URL。
+
+完成标准:
+
+- `GetModelProfile("moonshine-en")` 可返回完整 profile。
+- `ListModelProfiles()` 顺序稳定。
+- `NormalizeModelID("moonshine-en")` 返回自身。
+- Moonshine 缺任意 required file 时校验失败。
+
+### T2:默认模型解析器
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/internal/language/resolver.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/model/selection.go`
+- `privatevoice.src/internal/model/selection_test.go`
+
+工作内容:
+
+- 新增配置字段:
+  - `ModelSelectionMode string`
+- 允许值:
+  - `auto`
+  - `manual`
+- 旧配置缺字段时默认 `auto`。
+- 新增 current model resolver:
+
+```text
+ResolveCurrentModel(cfg, detector)
+```
+
+- 规则:
+  - auto 模式按 effective language 的 `DefaultModelID`。
+  - manual 模式按 `SelectedModelID`。
+  - manual 非法时 fallback 到 effective language 默认模型并记录日志。
+
+完成标准:
+
+- English fresh config 解析为 `moonshine-en`。
+- Chinese fresh config 解析为 `sensevoice-zh`。
+- 旧 Round 2 config 没有 `ModelSelectionMode` 时不会锁死在 SenseVoice。
+- 单元测试覆盖 auto/manual/非法值。
+
+### T3:Engine factory/backend builder
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/engine/engine.go`
+- `privatevoice.src/internal/engine/engine_darwin.go`
+- `privatevoice.src/internal/engine/engine_test.go`
+
+工作内容:
+
+- `NewWithResolvedModel` 不再只允许 `BackendSenseVoice`。
+- 根据 `resolved.BackendKind` 分发到:
+  - `buildSenseVoiceConfig`
+  - `buildMoonshineConfig`
+- SenseVoice builder 保持现有逻辑。
+- Moonshine builder 设置:
+
+```go
+config.ModelConfig.Moonshine.Preprocessor = resolved.Files["preprocessor"]
+config.ModelConfig.Moonshine.Encoder = resolved.Files["encoder"]
+config.ModelConfig.Moonshine.UncachedDecoder = resolved.Files["uncached_decoder"]
+config.ModelConfig.Moonshine.CachedDecoder = resolved.Files["cached_decoder"]
+config.ModelConfig.Tokens = resolved.Files["tokens"]
+config.ModelConfig.Provider = "cpu"
+```
+
+- `HardwareInfo()` 对 Moonshine 显示可区分信息,例如 `Moonshine English · CPU`。
+
+完成标准:
+
+- SenseVoice 初始化路径不变。
+- Moonshine 初始化可执行。
+- 未知 backend 返回明确错误。
+- 初始化失败日志包含 model id、backend、provider。
+
+### T4:Onboarding 下载 current model
+
+负责人:后端 + 前端
+建议文件:
+
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte`
+- `privatevoice.src/frontend/src/App.svelte`
+
+工作内容:
+
+- 新增 `EngineService.GetCurrentModelStatus()` 或等价 API。
+- 新增 `EngineService.DownloadCurrentModel()`。
+- `ModelExists()` 检查 current model。
+- 前端 onboarding 调用 `DownloadCurrentModel()`,不再传 config URL。
+- 下载进度事件沿用 `model:download-progress`。
+- 下载完成后调用 init callback 重新初始化。
+
+完成标准:
+
+- English fresh user 下载 Moonshine。
+- Chinese fresh user 下载 SenseVoice。
+- 不再由前端决定模型 URL。
+- 下载失败可重试。
+
+### T5:语言切换后的模型同步
+
+负责人:后端 + 前端
+建议文件:
+
+- `privatevoice.src/services/config_service.go`
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/App.svelte`
+
+工作内容:
+
+- `SetLanguageAuto()` / `SetLanguageManual()` 后,前端刷新 current model 状态。
+- 如果当前模型未安装,进入 need_model/onboarding。
+- 如果当前模型已安装,重新初始化引擎。
+- 切换失败时显示错误,不删除旧模型。
+
+完成标准:
+
+- 中文切 English:如 Moonshine 未安装,提示下载。
+- English 切中文:如 SenseVoice 已安装,恢复 SenseVoice。
+- 重启后按同样规则解析 current model。
+
+### T6:前端文案与状态展示
+
+负责人:前端
+建议文件:
+
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+- `privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte`
+
+工作内容:
+
+- Onboarding 文案避免写死“同步中文模型”。
+- English UI 下文案应明确正在准备 offline speech model。
+- 中文 UI 下文案仍自然。
+- 状态展示不需要新增模型管理页。
+
+完成标准:
+
+- English onboarding 没有中文文案。
+- 中文 onboarding 没有英文突兀文案。
+- 下载 MB 进度显示正常。
+
+### T7:单元测试
+
+负责人:后端 + 前端
+
+后端至少覆盖:
+
+- Moonshine profile 查询。
+- Moonshine required files 校验。
+- English default model 为 `moonshine-en`。
+- Chinese default model 为 `sensevoice-zh`。
+- old config 缺 `ModelSelectionMode` fallback。
+- current model resolver。
+- unknown backend error。
+- SenseVoice builder 不退化。
+
+前端至少覆盖:
+
+- build 通过。
+- onboarding 不再调用旧 URL 参数 API。
+- 语言切换后会刷新 engine/model 状态。
+
+建议命令:
+
+```bash
+cd privatevoice.src
+go test ./...
+
+cd privatevoice.src/frontend
+npm run build
+```
+
+### T8:开发自查
+
+负责人:研发
+
+必须执行:
+
+```bash
+cd privatevoice.src/frontend
+npm run build
+
+cd privatevoice.src
+go test ./... -count=1
+
+git diff --check
+```
+
+自查结论必须记录:
+
+- 变更范围。
+- 测试命令和结果。
+- 未覆盖风险。
+- 是否进入 QA。
+
+### T9:QA handoff
+
+负责人:研发 + QA
+
+QA 移交必须包含:
+
+- commit。
+- 变更范围。
+- 测试命令。
+- 当前模型下载 URL。
+- 英文新用户测试方式。
+- 中文老用户回归方式。
+- 已知风险。
+
diff --git a/02-P-NBL/round-3/logs/git-diff-check-20260603.log b/02-P-NBL/round-3/logs/git-diff-check-20260603.log
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/02-P-NBL/round-3/logs/git-diff-check-20260603.log
diff --git a/02-P-NBL/round-3/logs/go-test-all-20260603.log b/02-P-NBL/round-3/logs/go-test-all-20260603.log
new file mode 100644
index 0000000..7549feb
--- /dev/null
+++ b/02-P-NBL/round-3/logs/go-test-all-20260603.log
@@ -0,0 +1,23 @@
+?   	voicesnap	[no test files]
+?   	voicesnap/internal/audio	[no test files]
+ok  	voicesnap/internal/config	2.459s
+ok  	voicesnap/internal/correctioncsv	1.831s
+?   	voicesnap/internal/dock	[no test files]
+?   	voicesnap/internal/engine	[no test files]
+ok  	voicesnap/internal/history	0.742s
+?   	voicesnap/internal/hotkey	[no test files]
+?   	voicesnap/internal/input	[no test files]
+ok  	voicesnap/internal/language	3.108s
+?   	voicesnap/internal/logger	[no test files]
+ok  	voicesnap/internal/model	5.008s
+ok  	voicesnap/internal/modelselection	3.548s
+?   	voicesnap/internal/overlay	[no test files]
+?   	voicesnap/internal/paths	[no test files]
+?   	voicesnap/internal/permissions	[no test files]
+?   	voicesnap/internal/singleinstance	[no test files]
+?   	voicesnap/internal/sound	[no test files]
+?   	voicesnap/internal/startup	[no test files]
+?   	voicesnap/internal/textproc	[no test files]
+?   	voicesnap/internal/updater	[no test files]
+ok  	voicesnap/internal/userdict	4.076s
+?   	voicesnap/services	[no test files]
diff --git a/02-P-NBL/round-3/logs/npm-run-build-20260603.log b/02-P-NBL/round-3/logs/npm-run-build-20260603.log
new file mode 100644
index 0000000..e898e03
--- /dev/null
+++ b/02-P-NBL/round-3/logs/npm-run-build-20260603.log
@@ -0,0 +1,38 @@
+
+> privatevoice-input-frontend@2.1.20 build
+> vite build
+
+vite v6.4.1 building for production...
+transforming...
+1:23:43 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:28:0 Elements with the 'dialog' interactive role must have a tabindex value
+https://svelte.dev/e/a11y_interactive_supports_focus
+26: 
+27: <!-- svelte-ignore a11y_click_events_have_key_events -->
+28: <div class="overlay" onclick={onclose} role="dialog">
+          ^
+29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
+30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+1:23:43 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:30:2 Non-interactive element `<div>` should not be assigned mouse or keyboard event listeners
+https://svelte.dev/e/a11y_no_noninteractive_element_interactions
+28: <div class="overlay" onclick={onclose} role="dialog">
+29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
+30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+            ^
+31:     <h2 class="title">{t('update.title')}</h2>
+32:     <p class="version">{t('update.version', { version: info.version })}</p>
+1:23:44 PM [vite-plugin-svelte] src/components/shared/ToggleSwitch.svelte:17:0 Buttons and links should either contain text or have an `aria-label`, `aria-labelledby` or `title` attribute
+https://svelte.dev/e/a11y_consider_explicit_label
+15: </script>
+16: 
+17: <button
+           ^
+18:   class="toggle"
+19:   class:active={checked}
+✓ 163 modules transformed.
+rendering chunks...
+computing gzip size...
+dist/index.html                      0.41 kB │ gzip:  0.28 kB
+dist/assets/app-icon-Bs-pS5hx.png  400.98 kB
+dist/assets/index-p3GQqwqB.css      33.39 kB │ gzip:  5.73 kB
+dist/assets/index-CWMyDHKt.js      137.34 kB │ gzip: 45.94 kB
+✓ built in 2.74s
diff --git a/02-P-NBL/round-3/prd.md b/02-P-NBL/round-3/prd.md
new file mode 100644
index 0000000..1cc2af0
--- /dev/null
+++ b/02-P-NBL/round-3/prd.md
@@ -0,0 +1,349 @@
+# Round 3 PRD:Moonshine English 默认模型
+
+状态:v0.1 启动稿
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+基线版本:`v2.1.20-build20260603.1145`
+基线 commit:`f66a79670c444010a59b8513c9f61257ebc7d9b6`
+关联文档:
+
+- [`../product-plan.md`](../product-plan.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+- [`../round-2/prd.md`](../round-2/prd.md)
+
+## 1. 背景
+
+Round 1 已完成模型 profile 基础设施,Round 2 已完成语言检测和语言版本设置。当前状态是:
+
+- 中文和 English UI 可以根据 effective language 切换。
+- `LanguageProfile` 已经有 `zh-CN` 和 `en`。
+- `en` 语言版本仍临时使用 `sensevoice-zh`。
+- 引擎初始化仍只支持 `sensevoice` backend。
+- Onboarding 下载仍依赖 config 里的 SenseVoice URL。
+
+Round 3 要把 English 语言版本接到真正的英文默认模型 Moonshine English。
+
+## 2. 本轮目标
+
+Round 3 完成后:
+
+| 场景 | effective language | 默认模型 | 期望结果 |
+|---|---|---|---|
+| 中文系统新用户 | `zh-CN` | `sensevoice-zh` | 继续中文 UI + SenseVoice |
+| English 系统新用户 | `en` | `moonshine-en` | English UI + Moonshine English |
+| 其他系统语言新用户 | `en` | `moonshine-en` | English UI + Moonshine English |
+| 老中文用户升级 | `zh-CN` | `sensevoice-zh` | 不重新下载、不丢数据 |
+| 用户手动切到 English | `en` | `moonshine-en` | 未安装则提示/下载 Moonshine |
+| 用户手动切回中文 | `zh-CN` | `sensevoice-zh` | 使用已有 SenseVoice |
+
+核心目标:
+
+- 新增 `moonshine-en` model profile。
+- English 语言版本默认模型改为 `moonshine-en`。
+- 新增 sherpa-onnx Moonshine backend builder。
+- Onboarding 按“当前应使用的模型 profile”下载模型,而不是写死 SenseVoice URL。
+- Moonshine 下载失败、初始化失败、缺文件时,不破坏 SenseVoice、用户词库、历史记录和配置。
+
+## 3. 本轮非目标
+
+明确不做:
+
+- 不接入 Parakeet。
+- 不接入 Qwen3-ASR。
+- 不接入 Moonshine Voice framework。
+- 不接 Moonshine v2 多语言模型。
+- 不做 realtime partial transcript。
+- 不做模型选择页。
+- 不做模型删除、清理和回滚 UI。
+- 不做 30+ 语言列表。
+- 不做粤语独立语言版本。
+- 不做欧洲语言第一优选模型决策。
+- 不改变用户词库、history、replacement 规则格式。
+- 不做正式签名和 notarization。
+
+## 4. 模型选择决策
+
+Round 3 选择:
+
+```text
+model id: moonshine-en
+actual package: sherpa-onnx-moonshine-base-en-int8
+backend kind: moonshine
+language: English only
+```
+
+选择 `base-en-int8`,不是 `tiny-en-int8`,原因:
+
+- GitHub release asset 显示 `base-en-int8.tar.bz2` 约 `250807309` bytes,约 239 MiB。
+- sherpa 官方文档列出的解压文件总量约 560448 blocks,仍在用户可接受的 300MB 级别附近。
+- 英文用户分支需要更稳的默认准确率,base 比 tiny 更适合作为第一默认模型。
+- `tiny-en-int8` 可作为工程备用候选,但本轮不暴露为独立 UI 选项。
+
+资料依据:
+
+- Moonshine v1 官方文档明确该页模型只支持 English:<https://k2-fsa.github.io/sherpa/onnx/moonshine/models.html>
+- 官方模型下载 URL:<https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-moonshine-base-en-int8.tar.bz2>
+
+## 5. Moonshine Profile
+
+新增 profile:
+
+```text
+ID: moonshine-en
+DisplayName: Moonshine English
+BackendKind: moonshine
+Tier: default
+SupportedLanguageIDs: en
+RecommendedFor: en
+Description: 轻量英文离线模型,适合英文听写和英文日常输入。
+ApproxSize: 约 239 MiB 下载包,解压后约 270-280 MiB
+InstallDirName: moonshine-en
+ProviderOrder: cpu
+NumThreads: 4
+```
+
+下载 URL:
+
+```text
+primary:
+https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-moonshine-base-en-int8.tar.bz2
+
+fallback:
+https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-moonshine-tiny-en-int8.tar.bz2
+```
+
+说明:
+
+- fallback URL 只用于下载源不可用时保证开发/QA 能继续验证,不表示最终产品会静默降级到 tiny。
+- 如果最终产品不接受 tiny 作为降级包,研发应把 fallback 留空或换成自维护 mirror。
+
+Required files:
+
+| Role | Required file |
+|---|---|
+| `preprocessor` | `preprocess.onnx` |
+| `encoder` | `encode.int8.onnx` |
+| `uncached_decoder` | `uncached_decode.int8.onnx` |
+| `cached_decoder` | `cached_decode.int8.onnx` |
+| `tokens` | `tokens.txt` |
+
+## 6. sherpa-onnx 配置
+
+当前项目锁定:
+
+```text
+github.com/k2-fsa/sherpa-onnx-go v1.12.24
+github.com/k2-fsa/sherpa-onnx-go-macos v1.12.24
+```
+
+该版本 Moonshine 是 v1 四文件配置:
+
+```go
+config.ModelConfig.Moonshine.Preprocessor = resolved.Files["preprocessor"]
+config.ModelConfig.Moonshine.Encoder = resolved.Files["encoder"]
+config.ModelConfig.Moonshine.UncachedDecoder = resolved.Files["uncached_decoder"]
+config.ModelConfig.Moonshine.CachedDecoder = resolved.Files["cached_decoder"]
+config.ModelConfig.Tokens = resolved.Files["tokens"]
+config.ModelConfig.NumThreads = resolved.Profile.NumThreads
+config.ModelConfig.Provider = "cpu"
+config.DecodingMethod = "greedy_search"
+```
+
+保留:
+
+```go
+config.FeatConfig.SampleRate = 16000
+config.FeatConfig.FeatureDim = 80
+```
+
+不要使用:
+
+```text
+encoder_model.ort
+decoder_model_merged.ort
+MergedDecoder
+```
+
+这些不是当前项目依赖版本对应的 Moonshine v1 接入形态。
+
+## 7. 默认模型解析规则
+
+Round 3 必须修正“当前模型”的定义。
+
+当前问题:
+
+```text
+config.Default().SelectedModelID = sensevoice-zh
+engine.New() 直接读取 SelectedModelID
+```
+
+这会导致 English 新用户即使 effective language 是 `en`,仍然被锁到 SenseVoice。
+
+Round 3 目标规则:
+
+```text
+CurrentModel = ResolveCurrentModel(Config, EffectiveLanguage)
+```
+
+建议引入:
+
+```text
+ModelSelectionMode = auto | manual
+```
+
+默认:
+
+```text
+ModelSelectionMode = auto
+```
+
+解析规则:
+
+| 条件 | 当前模型 |
+|---|---|
+| `ModelSelectionMode=auto` 且 effective language=`zh-CN` | `sensevoice-zh` |
+| `ModelSelectionMode=auto` 且 effective language=`en` | `moonshine-en` |
+| `ModelSelectionMode=manual` 且 `SelectedModelID` 合法 | `SelectedModelID` |
+| `ModelSelectionMode=manual` 但模型非法 | 回退到 effective language 默认模型,并记录日志 |
+| 旧配置缺少 `ModelSelectionMode` | 视为 `auto` |
+
+说明:
+
+- Round 3 没有模型选择 UI,因此普通用户不会进入 `manual`。
+- 老用户配置里已有的 `SelectedModelID=sensevoice-zh` 应视为历史默认值,不应阻止 English 默认 Moonshine 生效。
+- 后续 Round 4/5 做模型选择页时,再由 UI 把 `ModelSelectionMode` 设为 `manual`。
+
+## 8. 用户故事
+
+### 8.1 English 新用户
+
+作为 English macOS 用户,我第一次启动 App 时,应看到 English UI,并自动准备 Moonshine English。
+
+验收点:
+
+- effective language=`en`。
+- current model=`moonshine-en`。
+- 如果 Moonshine 未安装,进入 onboarding 下载 Moonshine。
+- 下载完成后引擎显示 `Moonshine English · CPU` 或等价信息。
+- 按 R-Alt 说英文,可以离线识别并写入 history。
+
+### 8.2 中文新用户
+
+作为中文 macOS 用户,我第一次启动 App 时,应保持 Round 2 体验。
+
+验收点:
+
+- effective language=`zh-CN`。
+- current model=`sensevoice-zh`。
+- 不下载 Moonshine。
+- SenseVoice 可正常离线识别。
+
+### 8.3 老中文用户升级
+
+作为已有中文用户,我升级到 Round 3 后,不应被要求下载 Moonshine。
+
+验收点:
+
+- 旧 SenseVoice 目录可继续使用。
+- `userdict.json` 不变。
+- `history.json` 不变。
+- replacement 规则继续生效。
+- 热键和历史写入不退化。
+
+### 8.4 用户手动切到 English
+
+作为用户,我在设置页把语言版本切到 English 后,App 应切换到英文产品路径。
+
+验收点:
+
+- UI 立即切到 English。
+- current model 解析为 `moonshine-en`。
+- 如果 Moonshine 未安装,显示模型准备/下载状态。
+- 如果 Moonshine 已安装,重新初始化 Moonshine 引擎。
+- 切换失败时,不能删除或覆盖 SenseVoice。
+
+### 8.5 用户手动切回中文
+
+作为用户,我可以从 English 切回中文。
+
+验收点:
+
+- UI 立即切回中文。
+- current model 解析为 `sensevoice-zh`。
+- 若 SenseVoice 已安装,直接恢复中文识别。
+- Moonshine 模型保留,不自动删除。
+
+## 9. Onboarding 与下载规则
+
+Round 3 onboarding 不应再让前端从 `Config.ModelDownloadUrl` 和 `FallbackModelDownloadUrl` 读取 SenseVoice URL。
+
+目标:
+
+```text
+frontend -> EngineService.DownloadCurrentModel()
+backend -> ResolveCurrentModel()
+backend -> profile.DownloadURLs
+backend -> DownloadProfile(profile, urls, modelsRoot)
+```
+
+要求:
+
+- `ModelExists()` 检查 current model,不是固定 SenseVoice。
+- `DownloadCurrentModel()` 下载 current model。
+- 下载完成后重新初始化 current model。
+- 下载失败只影响目标模型,不删除已有模型。
+- 下载进度事件继续复用 `model:download-progress`。
+
+保留兼容:
+
+- 可以保留旧 `DownloadModel(primaryURL, fallbackURL)` API,但 Round 3 前端不应再调用它。
+- `ModelDownloadUrl` / `FallbackModelDownloadUrl` 可暂时留在 config 中,不在本轮强删。
+
+## 10. 失败处理
+
+### 10.1 下载失败
+
+期望:
+
+- 显示下载失败。
+- 不删除 `models/sensevoice`、`models/sensevoice-zh`。
+- 不删除已有 `models/moonshine-en` 完整目录。
+- 不把 incomplete staging 目录作为可用模型。
+- 用户可重试。
+
+### 10.2 缺文件
+
+如果 `moonshine-en` 缺少任意 required file:
+
+- `ValidateModelDir` 返回 incomplete。
+- `ModelExists()` 返回 false。
+- `engine.New()` 不应崩溃。
+- UI 进入 need_model 或错误状态。
+
+### 10.3 初始化失败
+
+如果 Moonshine sherpa 初始化失败:
+
+- 状态为 `error` 或 `need_model`,取决于模型是否完整存在。
+- 记录 backend、provider、model id、root dir。
+- 不修改用户词库、历史和 SenseVoice 目录。
+
+## 11. 验收标准
+
+Round 3 功能性验收 PASS 必须满足:
+
+- English fresh user 默认 Moonshine。
+- Chinese fresh user 默认 SenseVoice。
+- 老中文用户升级不触发 Moonshine 下载。
+- Moonshine 可下载、校验、加载。
+- 英文语音可离线识别,并写入 `history.json`。
+- 中文 SenseVoice 回归通过。
+- 语言切换会同步 current model。
+- Moonshine 下载失败不破坏已有 SenseVoice。
+- Moonshine 缺文件不被判定为可用。
+- `go test ./...` 通过。
+- `npm run build` 通过。
+- QA 报告记录版本、build、commit、测试包、日志和关键证据。
+
diff --git a/02-P-NBL/round-3/qa-handoff-current.md b/02-P-NBL/round-3/qa-handoff-current.md
new file mode 100644
index 0000000..6a954a0
--- /dev/null
+++ b/02-P-NBL/round-3/qa-handoff-current.md
@@ -0,0 +1,100 @@
+# Round 3 QA Handoff:Moonshine English 默认模型
+
+状态:待 QA
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+工作分支:`codex/round3-moonshine-english`
+基线 commit:`f66a79670c444010a59b8513c9f61257ebc7d9b6`
+
+## 1. QA 输入文档
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+- [`dev-checklist-current.md`](dev-checklist-current.md)
+
+## 2. 本轮核心断言
+
+QA 需要验证:
+
+- English effective language 默认 current model 是 `moonshine-en`。
+- 中文 effective language 默认 current model 仍是 `sensevoice-zh`。
+- English 新用户 onboarding 下载 Moonshine。
+- 中文新用户 onboarding 下载/使用 SenseVoice。
+- 老中文用户升级不触发 Moonshine 下载。
+- Moonshine 缺文件不能被判定为可用。
+- Moonshine 下载或初始化失败不破坏 SenseVoice、userdict、history。
+- 语言切换后 current model 同步,缺模型时进入 onboarding,已安装时重载引擎。
+
+## 3. Moonshine 模型信息
+
+本轮模型:
+
+```text
+model id: moonshine-en
+package: sherpa-onnx-moonshine-base-en-int8.tar.bz2
+backend: moonshine
+provider: cpu
+```
+
+下载 URL:
+
+```text
+https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-moonshine-base-en-int8.tar.bz2
+```
+
+Required files:
+
+```text
+preprocess.onnx
+encode.int8.onnx
+uncached_decode.int8.onnx
+cached_decode.int8.onnx
+tokens.txt
+```
+
+## 4. 建议 QA 顺序
+
+1. 自动化/半自动检查:
+   - `go test ./... -count=1`
+   - `npm run build`
+   - `git diff --check`
+2. English fresh user:
+   - 干净 App Support。
+   - effective language=`en`。
+   - 验证 onboarding 下载 `moonshine-en`。
+   - 验证 engine ready 显示 Moonshine。
+3. 中文老用户升级:
+   - 使用已有 SenseVoice、userdict、history。
+   - 验证不下载 Moonshine。
+   - 验证中文识别和 replacement。
+4. 语言切换:
+   - 中文切 English。
+   - English 切中文。
+   - 验证 current model 同步和缺模型状态。
+5. 故障保护:
+   - Moonshine 缺文件。
+   - Moonshine 下载失败。
+   - SenseVoice 回归。
+
+## 5. 研发自查结论
+
+已通过:
+
+- `npm run build`
+- `go test ./... -count=1`
+- `git diff --check`
+
+日志位于:
+
+- [`logs/npm-run-build-20260603.log`](logs/npm-run-build-20260603.log)
+- [`logs/go-test-all-20260603.log`](logs/go-test-all-20260603.log)
+- [`logs/git-diff-check-20260603.log`](logs/git-diff-check-20260603.log)
+
+## 6. QA 注意事项
+
+- 目前尚未做正式 `.dmg` 测试包;这是源码开发自查完成后的 QA handoff。
+- 如果 QA 通过,后续按发布规则递增 patch 版本、生成新 build 编号、打 `.dmg`、生成 manifest、验证后打 tag。
+- `npm ci` 安装依赖时提示 6 个现有 npm audit 风险,本轮没有自动升级依赖,避免扩大变更面。
+
diff --git a/02-P-NBL/round-3/qa-test-plan.md b/02-P-NBL/round-3/qa-test-plan.md
new file mode 100644
index 0000000..f27d614
--- /dev/null
+++ b/02-P-NBL/round-3/qa-test-plan.md
@@ -0,0 +1,208 @@
+# Round 3 QA 测试计划:Moonshine English 默认模型
+
+状态:v0.1 QA 计划稿
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+
+## 1. QA 目标
+
+Round 3 的 QA 目标是证明:
+
+```text
+English 语言版本默认使用 Moonshine English,中文 SenseVoice 路径不退化,且模型下载/初始化失败不会破坏用户数据。
+```
+
+重点验证:
+
+- English fresh user 默认 Moonshine。
+- Chinese fresh user 默认 SenseVoice。
+- 老中文用户升级不被强制下载 Moonshine。
+- Moonshine 可下载、校验、加载、离线识别。
+- 语言切换会带动 current model 同步。
+- Moonshine 失败不破坏 SenseVoice、userdict、history、replacement。
+
+## 2. 测试范围
+
+### 2.1 本轮必须测
+
+- `moonshine-en` profile。
+- Moonshine required files 校验。
+- English language default model。
+- current model resolver。
+- onboarding 下载 current model。
+- Moonshine engine 初始化。
+- 英文离线识别和 history 写入。
+- 中文 SenseVoice 回归。
+- 语言切换后的模型同步。
+- 下载失败保护。
+- 缺文件保护。
+- 老用户升级保护。
+
+### 2.2 本轮不测
+
+- Parakeet。
+- Qwen3-ASR。
+- Moonshine v2。
+- realtime partial transcript。
+- 模型选择页。
+- 模型删除 UI。
+- 30+ 语言列表。
+- Developer ID 签名和 notarization。
+
+## 3. QA 进入标准
+
+研发移交 QA 前必须提供:
+
+- 可运行构建或分支。
+- 变更范围说明。
+- `go test ./... -count=1` 结果。
+- `npm run build` 结果。
+- current model resolver 说明。
+- Moonshine 下载 URL。
+- 已知风险说明。
+
+## 4. 测试环境
+
+| 项目 | 要求 |
+|---|---|
+| OS | macOS |
+| 芯片 | Apple Silicon 必测;Intel 如仍支持则抽测 |
+| 网络 | 联网、断网 |
+| 语言 | `zh-CN`、`en`、其他语言 fallback |
+| 用户状态 | 老中文用户、新中文用户、新 English 用户、缺文件用户 |
+| 模型状态 | SenseVoice 已安装、Moonshine 未安装、Moonshine 已安装、Moonshine 缺文件 |
+
+系统语言不建议反复真实修改 macOS 全局设置作为唯一手段。自动化和半自动 QA 可以用临时用户数据目录、config 手动语言设置、mock detector 或启动环境配合验证。
+
+## 5. 测试矩阵
+
+| ID | 场景 | 前置条件 | 期望结果 |
+|---|---|---|---|
+| R3-QA-001 | Moonshine profile | 查询 `moonshine-en` | profile 存在,backend=`moonshine` |
+| R3-QA-002 | Moonshine required files valid | 五个 required files 完整 | 校验通过 |
+| R3-QA-003 | Moonshine required files missing | 删除任意一个 required file | 校验失败 |
+| R3-QA-004 | English default model | effective language=`en`,auto model | current model=`moonshine-en` |
+| R3-QA-005 | Chinese default model | effective language=`zh-CN`,auto model | current model=`sensevoice-zh` |
+| R3-QA-006 | 旧配置兼容 | 缺 `ModelSelectionMode` | 默认 auto,不锁死旧 `SelectedModelID` |
+| R3-QA-007 | English fresh onboarding | 干净 App Support + English | 下载 Moonshine |
+| R3-QA-008 | Chinese fresh onboarding | 干净 App Support + 中文 | 下载/使用 SenseVoice |
+| R3-QA-009 | 老中文用户升级 | 已有 SenseVoice/userdict/history | 不下载 Moonshine,数据不丢 |
+| R3-QA-010 | Moonshine 加载 | Moonshine 完整安装 | engine ready,硬件信息可区分 Moonshine |
+| R3-QA-011 | 英文离线识别 | Moonshine ready + 断网 | 英文语音可识别 |
+| R3-QA-012 | history 写入 | 英文识别完成 | `history.json` 新增英文记录 |
+| R3-QA-013 | replacement 不退化 | 中文 replacement 已配置 | 中文回归仍生效 |
+| R3-QA-014 | 中文回归 | SenseVoice ready | 中文离线识别可用 |
+| R3-QA-015 | 中文切 English | SenseVoice installed,Moonshine missing | 进入 Moonshine 下载/need_model |
+| R3-QA-016 | English 切中文 | Moonshine installed,SenseVoice installed | 切回 SenseVoice |
+| R3-QA-017 | Moonshine 下载失败 | 阻断网络或坏 URL | 不删除 SenseVoice,不写入有效 Moonshine 状态 |
+| R3-QA-018 | Moonshine 初始化失败 | 模型完整但强制 provider/路径异常 | 状态 error,数据不丢 |
+| R3-QA-019 | staging 清理 | 下载失败后 | `.staging` 不被当成可用模型 |
+| R3-QA-020 | UI 文案 | English onboarding | 没有中文模型写死文案 |
+| R3-QA-021 | 重启持久化 | English + Moonshine installed 后重启 | 仍为 Moonshine ready |
+| R3-QA-022 | 其他系统语言 fallback | detector=`fr-FR` | effective=`en`,current model=`moonshine-en` |
+
+## 6. 自动化测试建议
+
+后端优先覆盖:
+
+- `GetModelProfile("moonshine-en")`。
+- `ValidateModelDir(moonshine-en)`。
+- `ResolveCurrentModel`。
+- `ModelSelectionMode` normalize。
+- Engine backend dispatch。
+- Unknown backend error。
+- Download source validation。
+
+建议命令:
+
+```bash
+cd privatevoice.src
+go test ./...
+```
+
+前端至少执行:
+
+```bash
+cd privatevoice.src/frontend
+npm run build
+```
+
+## 7. 手工测试步骤
+
+### 7.1 English 新用户路径
+
+1. 备份真实 App Support。
+2. 使用临时干净 App Support 或临时 HOME。
+3. 模拟 English effective language。
+4. 启动 App。
+5. 检查 UI 是 English。
+6. 检查 onboarding 下载 Moonshine。
+7. 下载完成后检查 engine ready。
+8. 断网。
+9. 按 R-Alt 说一段英文。
+10. 检查识别结果和 `history.json`。
+
+通过标准:
+
+- 不下载 SenseVoice。
+- Moonshine 模型目录为 `models/moonshine-en`。
+- 英文识别结果写入 history。
+- 断网时不访问网络也能识别。
+
+### 7.2 中文老用户升级路径
+
+1. 使用真实或复制的老用户数据,包含 SenseVoice、userdict、history。
+2. 启动 Round 3 App。
+3. 检查 current model 为 `sensevoice-zh`。
+4. 做一次中文识别。
+5. 检查 replacement 和 history。
+
+通过标准:
+
+- 不触发 Moonshine 下载。
+- 不改写或删除 SenseVoice。
+- 用户词库和历史保留。
+
+### 7.3 语言切换路径
+
+1. 中文状态下打开设置页。
+2. 切到 English。
+3. 检查 UI 立即变英文。
+4. 检查 current model 变为 Moonshine。
+5. 如果未安装,下载 Moonshine。
+6. 切回中文。
+7. 检查 current model 恢复 SenseVoice。
+
+通过标准:
+
+- 切换语言时模型跟随 effective language。
+- 两个模型都保留。
+- 切换失败不破坏原模型。
+
+### 7.4 下载失败保护
+
+1. 准备已安装 SenseVoice。
+2. 让 English current model 指向 Moonshine。
+3. 模拟 Moonshine 下载失败。
+4. 检查 SenseVoice 目录和 state。
+5. 切回中文并识别。
+
+通过标准:
+
+- Moonshine 失败不会破坏中文路径。
+- staging/incomplete 目录不被判定为 installed。
+
+## 8. QA 结论标准
+
+可标记 Round 3 功能性 QA PASS 的条件:
+
+- R3-QA-001 到 R3-QA-022 全部通过,或失败项有明确非阻断说明。
+- Round 2 语言设置路径未回归。
+- SenseVoice 中文核心路径未回归。
+- QA 报告记录版本、build、commit、测试包、日志和关键证据。
+
diff --git a/02-P-NBL/round-3/todo-list.md b/02-P-NBL/round-3/todo-list.md
new file mode 100644
index 0000000..fb7a34d
--- /dev/null
+++ b/02-P-NBL/round-3/todo-list.md
@@ -0,0 +1,60 @@
+# Round 3 Todo List:Moonshine English 默认模型
+
+状态:进行中
+所属子项目:`02-P-NBL`
+Round:Round 3
+日期:2026-06-03
+
+## 1. 文档与启动
+
+- [x] 创建 Round 3 worktree:`/Users/ar/Projects/VoiceSnap-2.1.1-Round3`
+- [x] 基于 `v2.1.20-build20260603.1145` 创建分支:`codex/round3-moonshine-english`
+- [x] 读取项目规则和全局交付/QA/发布规则
+- [x] 核准 sherpa-onnx Moonshine v1 文件结构
+- [x] 创建 Round 3 PRD
+- [x] 创建 Round 3 研发任务拆解
+- [x] 创建 Round 3 QA 测试计划
+
+## 2. 研发实现
+
+- [x] 新增 `moonshine-en` profile
+- [x] English language profile 默认模型改为 `moonshine-en`
+- [x] 新增 `ModelSelectionMode`
+- [x] 实现 current model resolver
+- [x] 改造 `engine.New()` 使用 current model resolver
+- [x] 新增 Moonshine backend builder
+- [x] 保持 SenseVoice backend 不退化
+- [x] 改造 `EngineService.ModelExists()` 检查 current model
+- [x] 新增 `EngineService.DownloadCurrentModel()`
+- [x] 前端 onboarding 改为调用 `DownloadCurrentModel()`
+- [x] 语言切换后刷新 current model 状态
+- [x] Onboarding 文案去除 SenseVoice 写死表达
+
+## 3. 开发自查
+
+- [x] `go test ./... -count=1`
+- [x] `npm run build`
+- [x] `git diff --check`
+- [x] 研发自查报告
+- [x] QA handoff
+
+## 4. QA
+
+- [ ] 自动化/半自动 QA
+- [ ] English 新用户 Moonshine 下载和识别
+- [ ] 中文老用户 SenseVoice 回归
+- [ ] 语言切换模型同步
+- [ ] 断网英文识别
+- [ ] 下载失败保护
+- [ ] 缺文件保护
+- [ ] QA 报告
+
+## 5. 交付
+
+- [ ] 用户验收候选包
+- [ ] 用户验收
+- [ ] 测试包版本递增
+- [ ] 生成 `.dmg`
+- [ ] 生成 `RELEASE_MANIFEST.md`
+- [ ] 验证 `.dmg`
+- [ ] tag
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 8a39b1e..e642b13 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -48,6 +48,7 @@
 	cfg      *config.Config
 	recorder *audio.Recorder
 	eng      engine.Engine
+	engineMu sync.Mutex
 	hk       hotkey.Listener
 	paster   input.Paster
 	history  *history.Store
@@ -597,7 +598,17 @@
 		return
 	}
 
-	text, err := a.eng.Recognize(samples)
+	a.engineMu.Lock()
+	eng := a.eng
+	if eng == nil {
+		a.engineMu.Unlock()
+		logger.Error("Recognition skipped: engine not ready")
+		a.indicator.SetStatus(overlay.StatusError, "引擎未就绪")
+		a.delayedHideIf(autoHide, 2000)
+		return
+	}
+	text, err := eng.Recognize(samples)
+	a.engineMu.Unlock()
 	if err != nil {
 		logger.Error("Recognition failed: %v", err)
 		a.indicator.SetStatus(overlay.StatusError, "错误")
@@ -687,6 +698,7 @@
 	eng, err := engine.New()
 	if err != nil {
 		logger.Error("Engine initialization failed: %v", err)
+		a.replaceEngine(nil)
 		status := "need_model"
 		if modelExists {
 			status = "error"
@@ -701,9 +713,7 @@
 		return
 	}
 
-	a.mu.Lock()
-	a.eng = eng
-	a.mu.Unlock()
+	a.replaceEngine(eng)
 
 	logger.Info("ASR engine ready: %s", eng.HardwareInfo())
 	if a.engineService != nil {
@@ -728,6 +738,20 @@
 	a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode))
 	a.indicator.Show()
 	a.delayedHideIf(autoHide, 2000)
+}
+
+func (a *App) replaceEngine(eng engine.Engine) {
+	a.engineMu.Lock()
+	defer a.engineMu.Unlock()
+
+	a.mu.Lock()
+	old := a.eng
+	a.eng = eng
+	a.mu.Unlock()
+
+	if old != nil && old != eng {
+		old.Close()
+	}
 }
 
 func hotkeyReadyText(keyName, mode string) string {
@@ -911,9 +935,7 @@
 	if a.recorder != nil {
 		a.recorder.Close()
 	}
-	if a.eng != nil {
-		a.eng.Close()
-	}
+	a.replaceEngine(nil)
 	a.wg.Wait()
 	logger.Info("Cleanup complete")
 }
@@ -946,7 +968,5 @@
 
 // SetEngine sets the engine after model download.
 func (a *App) SetEngine(eng engine.Engine) {
-	a.mu.Lock()
-	defer a.mu.Unlock()
-	a.eng = eng
+	a.replaceEngine(eng)
 }
diff --git a/privatevoice.src/frontend/src/App.svelte b/privatevoice.src/frontend/src/App.svelte
index e6dc993..d0bfc4e 100755
--- a/privatevoice.src/frontend/src/App.svelte
+++ b/privatevoice.src/frontend/src/App.svelte
@@ -84,11 +84,7 @@
       }
     })
 
-    Call.ByName('voicesnap/services.EngineService.ModelExists').then((exists: any) => {
-      if (!exists) {
-        engineStatus.set('need_model')
-      }
-    })
+    refreshCurrentModelAvailability()
 
     // Load actual hotkey name from backend (store default is generic "Ctrl")
     Call.ByName('voicesnap/services.HotkeyService.GetHotkeyName').then((name: any) => {
@@ -110,6 +106,22 @@
     setLocale(locale)
     languageReady = true
   }
+
+  async function refreshCurrentModelAvailability() {
+    try {
+      const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
+      if (!current?.installed) {
+        engineStatus.set('need_model')
+      }
+    } catch {
+      try {
+        const exists: any = await Call.ByName('voicesnap/services.EngineService.ModelExists')
+        if (!exists) {
+          engineStatus.set('need_model')
+        }
+      } catch {}
+    }
+  }
 </script>
 
 {#if isIndicatorRoute}
diff --git a/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
index 566b118..5116ef5 100755
--- a/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
+++ b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -42,15 +42,9 @@
 
   async function triggerDownload() {
     try {
-      const config: any = await Call.ByName('voicesnap/services.ConfigService.GetConfig')
-      if (config) {
-        await Call.ByName('voicesnap/services.EngineService.DownloadModel',
-          config.ModelDownloadUrl,
-          config.FallbackModelDownloadUrl
-        )
-        statusText = t('onboarding.optimizing')
-        detailText = t('onboarding.extracting')
-      }
+      await Call.ByName('voicesnap/services.EngineService.DownloadCurrentModel')
+      statusText = t('onboarding.optimizing')
+      detailText = t('onboarding.extracting')
     } catch (err: any) {
       statusText = t('onboarding.failed')
       detailText = err?.message || String(err)
diff --git a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index 9762ac3..50d099b 100755
--- a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -109,6 +109,19 @@
     setLocale(locale)
   }
 
+  async function syncEngineForCurrentModel() {
+    try {
+      const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
+      if (current?.installed) {
+        engineStatus.set('loading')
+      } else {
+        engineHardwareInfo.set('')
+        engineStatus.set('need_model')
+      }
+      await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel')
+    } catch {}
+  }
+
   async function onAutoHideChange(checked: boolean) {
     autoHide.set(checked)
     try {
@@ -155,6 +168,7 @@
         ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto')
         : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
       applyLanguageSettings(settings)
+      await syncEngineForCurrentModel()
     } catch {
       languageModeVal = prevMode
       languageIDVal = prevID
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 1b2d78b..e0c9ba9 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -49,8 +49,8 @@
     "languageChinese": "Chinese",
     "languageEnglish": "English",
     "languageAutoDesc": "Follow the system language, currently choosing only between Chinese and English",
-    "languageChineseDesc": "Use the Chinese interface. The offline model remains SenseVoice in this round",
-    "languageEnglishDesc": "Use the English interface. The offline model remains SenseVoice in this round",
+    "languageChineseDesc": "Use the Chinese interface with SenseVoice as the default offline model",
+    "languageEnglishDesc": "Use the English interface with Moonshine English as the default offline model",
     "autoHide": "Auto-hide Indicator",
     "autoHideDesc": "Automatically hide the floating indicator after completion",
     "soundFeedback": "Sound Feedback",
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 6d60438..207bdd3 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -49,8 +49,8 @@
     "languageChinese": "中文",
     "languageEnglish": "English",
     "languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择",
-    "languageChineseDesc": "使用中文界面,模型仍保持当前离线 SenseVoice",
-    "languageEnglishDesc": "Use English interface. The offline model remains SenseVoice in this round",
+    "languageChineseDesc": "使用中文界面,并默认使用 SenseVoice 离线模型",
+    "languageEnglishDesc": "使用 English 界面,并默认使用 Moonshine English 离线模型",
     "autoHide": "自动隐藏指示器",
     "autoHideDesc": "操作完成后自动隐藏浮动指示器",
     "soundFeedback": "提示音",
diff --git a/privatevoice.src/internal/config/config.go b/privatevoice.src/internal/config/config.go
index 473bf25..e3e15c8 100755
--- a/privatevoice.src/internal/config/config.go
+++ b/privatevoice.src/internal/config/config.go
@@ -9,10 +9,12 @@
 )
 
 const (
-	HotkeyModeTap      = "tap"
-	HotkeyModeHold     = "hold"
-	LanguageModeAuto   = "auto"
-	LanguageModeManual = "manual"
+	HotkeyModeTap            = "tap"
+	HotkeyModeHold           = "hold"
+	LanguageModeAuto         = "auto"
+	LanguageModeManual       = "manual"
+	ModelSelectionModeAuto   = "auto"
+	ModelSelectionModeManual = "manual"
 )
 
 // Config holds the application configuration, compatible with the WPF version's config.json.
@@ -27,6 +29,7 @@
 	IndicatorX               int    `json:"IndicatorX,omitempty"`
 	IndicatorY               int    `json:"IndicatorY,omitempty"`
 	SelectedModelID          string `json:"SelectedModelID,omitempty"`
+	ModelSelectionMode       string `json:"ModelSelectionMode,omitempty"`
 	LanguageMode             string `json:"LanguageMode,omitempty"`
 	LanguageID               string `json:"LanguageID,omitempty"`
 	ModelDownloadUrl         string `json:"ModelDownloadUrl"`
@@ -43,6 +46,7 @@
 		HideDockIcon:             true,
 		CopyToClipboard:          true,
 		SelectedModelID:          model.DefaultModelID,
+		ModelSelectionMode:       ModelSelectionModeAuto,
 		LanguageMode:             LanguageModeAuto,
 		LanguageID:               model.DefaultLanguageID,
 		ModelDownloadUrl:         "http://www.maikami.com/voicesnap/sensevoice.zip",
@@ -73,6 +77,7 @@
 	}
 	cfg.HotkeyMode = NormalizeHotkeyMode(cfg.HotkeyMode)
 	cfg.LanguageMode = NormalizeLanguageMode(cfg.LanguageMode)
+	cfg.ModelSelectionMode = NormalizeModelSelectionMode(cfg.ModelSelectionMode)
 	languageID := cfg.LanguageID
 	cfg.LanguageID = model.NormalizeLanguageID(cfg.LanguageID)
 	if languageID != "" && languageID != cfg.LanguageID {
@@ -95,6 +100,15 @@
 	}
 }
 
+func NormalizeModelSelectionMode(mode string) string {
+	switch mode {
+	case ModelSelectionModeAuto, ModelSelectionModeManual:
+		return mode
+	default:
+		return ModelSelectionModeAuto
+	}
+}
+
 func NormalizeHotkeyMode(mode string) string {
 	switch mode {
 	case HotkeyModeTap, HotkeyModeHold:
diff --git a/privatevoice.src/internal/config/config_test.go b/privatevoice.src/internal/config/config_test.go
index 1d7ee7a..c04af51 100644
--- a/privatevoice.src/internal/config/config_test.go
+++ b/privatevoice.src/internal/config/config_test.go
@@ -17,6 +17,9 @@
 	if cfg.SelectedModelID != model.DefaultModelID {
 		t.Fatalf("selected model = %q, want %q", cfg.SelectedModelID, model.DefaultModelID)
 	}
+	if cfg.ModelSelectionMode != ModelSelectionModeAuto {
+		t.Fatalf("model selection mode = %q, want %q", cfg.ModelSelectionMode, ModelSelectionModeAuto)
+	}
 }
 
 func TestNormalizeLanguageMode(t *testing.T) {
@@ -27,3 +30,12 @@
 		t.Fatalf("bad mode normalized to %q, want %q", got, LanguageModeAuto)
 	}
 }
+
+func TestNormalizeModelSelectionMode(t *testing.T) {
+	if got := NormalizeModelSelectionMode(ModelSelectionModeManual); got != ModelSelectionModeManual {
+		t.Fatalf("manual normalized to %q", got)
+	}
+	if got := NormalizeModelSelectionMode("bad"); got != ModelSelectionModeAuto {
+		t.Fatalf("bad mode normalized to %q, want %q", got, ModelSelectionModeAuto)
+	}
+}
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index 8e2c23c..8fe031d 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/privatevoice.src/internal/engine/engine.go
@@ -3,8 +3,10 @@
 import (
 	"fmt"
 	"voicesnap/internal/config"
+	"voicesnap/internal/language"
 	"voicesnap/internal/logger"
 	"voicesnap/internal/model"
+	"voicesnap/internal/modelselection"
 	"voicesnap/internal/paths"
 )
 
@@ -60,9 +62,10 @@
 	cfg, err := config.Load()
 	if err != nil {
 		logger.Error("Failed to load config for model selection: %v", err)
-		return NewWithModelID(model.DefaultModelID)
+		cfg = config.Default()
 	}
-	return NewWithModelID(cfg.SelectedModelID)
+	current := modelselection.Resolve(cfg, language.NewSystemDetector())
+	return NewWithModelID(current.ModelID)
 }
 
 func NewWithModelID(modelID string) (Engine, error) {
@@ -77,18 +80,28 @@
 	if !resolved.IsUsable() {
 		return nil, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems)
 	}
-	if resolved.BackendKind != model.BackendSenseVoice {
+	if !isSupportedBackend(resolved.BackendKind) {
 		return nil, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
 	}
 
-	logger.Info("Loading model %s from %s", resolved.ModelID, resolved.RootDir)
+	logger.Info("Loading model %s backend=%s from %s", resolved.ModelID, resolved.BackendKind, resolved.RootDir)
 	return newPlatformEngine(resolved)
+}
+
+func isSupportedBackend(backend string) bool {
+	switch backend {
+	case model.BackendSenseVoice, model.BackendMoonshine:
+		return true
+	default:
+		return false
+	}
 }
 
 func resolveCurrentModel() (model.ResolvedModel, error) {
 	cfg, err := config.Load()
 	if err != nil {
-		return model.ResolveModel(model.DefaultModelID)
+		cfg = config.Default()
 	}
-	return model.ResolveModel(model.NormalizeModelID(cfg.SelectedModelID))
+	current := modelselection.Resolve(cfg, language.NewSystemDetector())
+	return model.ResolveModel(current.ModelID)
 }
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index 7a7f9c1..7e5b70f 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -17,22 +17,21 @@
 }
 
 func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
+	if !isSupportedBackend(resolved.BackendKind) {
+		return nil, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
+	}
+
 	providers := darwinProviders(resolved.ProviderOrder)
 
 	for _, p := range providers {
-		config := sherpa.OfflineRecognizerConfig{}
-		config.FeatConfig.SampleRate = 16000
-		config.FeatConfig.FeatureDim = 80
-		config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
-		config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
-		config.ModelConfig.Tokens = resolved.Files["tokens"]
-		config.ModelConfig.NumThreads = resolved.Profile.NumThreads
-		config.ModelConfig.Provider = p.provider
-		config.DecodingMethod = "greedy_search"
+		config, err := offlineConfigForResolvedModel(resolved, p.provider)
+		if err != nil {
+			return nil, err
+		}
 
 		recognizer := sherpa.NewOfflineRecognizer(&config)
 		if recognizer != nil {
-			info := fmt.Sprintf("SenseVoice · %s", p.name)
+			info := fmt.Sprintf("%s · %s", resolved.Profile.DisplayName, p.name)
 			logger.Info("Engine initialized: %s", info)
 			return &sherpaEngine{
 				recognizer: recognizer,
@@ -45,6 +44,31 @@
 	return nil, fmt.Errorf("failed to initialize sherpa-onnx with any provider")
 }
 
+func offlineConfigForResolvedModel(resolved model.ResolvedModel, provider string) (sherpa.OfflineRecognizerConfig, error) {
+	config := sherpa.OfflineRecognizerConfig{}
+	config.FeatConfig.SampleRate = 16000
+	config.FeatConfig.FeatureDim = 80
+	config.ModelConfig.Tokens = resolved.Files["tokens"]
+	config.ModelConfig.NumThreads = resolved.Profile.NumThreads
+	config.ModelConfig.Provider = provider
+	config.DecodingMethod = "greedy_search"
+
+	switch resolved.BackendKind {
+	case model.BackendSenseVoice:
+		config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
+		config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
+	case model.BackendMoonshine:
+		config.ModelConfig.Moonshine.Preprocessor = resolved.Files["preprocessor"]
+		config.ModelConfig.Moonshine.Encoder = resolved.Files["encoder"]
+		config.ModelConfig.Moonshine.UncachedDecoder = resolved.Files["uncached_decoder"]
+		config.ModelConfig.Moonshine.CachedDecoder = resolved.Files["cached_decoder"]
+	default:
+		return sherpa.OfflineRecognizerConfig{}, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
+	}
+
+	return config, nil
+}
+
 func darwinProviders(providerOrder []string) []struct {
 	name     string
 	provider string
diff --git a/privatevoice.src/internal/language/language_test.go b/privatevoice.src/internal/language/language_test.go
index 0bf3b79..ca4458c 100644
--- a/privatevoice.src/internal/language/language_test.go
+++ b/privatevoice.src/internal/language/language_test.go
@@ -39,6 +39,9 @@
 	if settings.UILocale != "en" {
 		t.Fatalf("ui locale = %q, want en", settings.UILocale)
 	}
+	if settings.DefaultModelID != model.MoonshineModelID {
+		t.Fatalf("default model = %q, want %q", settings.DefaultModelID, model.MoonshineModelID)
+	}
 }
 
 func TestResolveManualIgnoresDetector(t *testing.T) {
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index 90a325e..b5273c2 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/privatevoice.src/internal/model/model_test.go
@@ -23,6 +23,32 @@
 	}
 }
 
+func TestRegistryReturnsMoonshineEnglish(t *testing.T) {
+	profile, err := GetModelProfile(MoonshineModelID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if profile.BackendKind != BackendMoonshine {
+		t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendMoonshine)
+	}
+	if profile.InstallDirName != "moonshine-en" {
+		t.Fatalf("install dir = %q, want moonshine-en", profile.InstallDirName)
+	}
+	if len(profile.DownloadURLs) == 0 {
+		t.Fatal("expected moonshine download URL")
+	}
+}
+
+func TestEnglishLanguageDefaultsToMoonshine(t *testing.T) {
+	profile, err := GetLanguageProfile(EnglishLanguageID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if profile.DefaultModelID != MoonshineModelID {
+		t.Fatalf("English default model = %q, want %q", profile.DefaultModelID, MoonshineModelID)
+	}
+}
+
 func TestValidateSenseVoiceAnyOfAndAllOf(t *testing.T) {
 	dir := t.TempDir()
 	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
@@ -70,6 +96,42 @@
 	result := ValidateModelDir(DefaultModelProfile(), dir)
 	if result.Valid {
 		t.Fatal("expected zero-byte model file to fail validation")
+	}
+}
+
+func TestValidateMoonshineRequiredFiles(t *testing.T) {
+	profile, err := GetModelProfile(MoonshineModelID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	dir := t.TempDir()
+	createValidMoonshine(t, dir)
+
+	result := ValidateModelDir(profile, dir)
+	if !result.Valid {
+		t.Fatalf("expected valid moonshine dir, missing=%v problems=%v", result.Missing, result.Problems)
+	}
+	for _, role := range []string{"preprocessor", "encoder", "uncached_decoder", "cached_decoder", "tokens"} {
+		if result.Files[role] == "" {
+			t.Fatalf("missing resolved file role %q", role)
+		}
+	}
+}
+
+func TestValidateMoonshineRejectsMissingFile(t *testing.T) {
+	profile, err := GetModelProfile(MoonshineModelID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	dir := t.TempDir()
+	createValidMoonshine(t, dir)
+	if err := os.Remove(filepath.Join(dir, "cached_decode.int8.onnx")); err != nil {
+		t.Fatal(err)
+	}
+
+	result := ValidateModelDir(profile, dir)
+	if result.Valid {
+		t.Fatal("expected missing cached decoder to fail validation")
 	}
 }
 
@@ -354,6 +416,18 @@
 	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "model")
 }
 
+func createValidMoonshine(t *testing.T, dir string) {
+	t.Helper()
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		t.Fatal(err)
+	}
+	writeTestFile(t, filepath.Join(dir, "preprocess.onnx"), "preprocessor")
+	writeTestFile(t, filepath.Join(dir, "encode.int8.onnx"), "encoder")
+	writeTestFile(t, filepath.Join(dir, "uncached_decode.int8.onnx"), "uncached")
+	writeTestFile(t, filepath.Join(dir, "cached_decode.int8.onnx"), "cached")
+	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+}
+
 func tarArchive(t *testing.T, files map[string]string) []byte {
 	t.Helper()
 	var buf bytes.Buffer
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index 21e2e53..2e0dc4e 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/privatevoice.src/internal/model/profile.go
@@ -2,6 +2,7 @@
 
 const (
 	DefaultModelID    = "sensevoice-zh"
+	MoonshineModelID  = "moonshine-en"
 	DefaultLanguageID = "zh-CN"
 	EnglishLanguageID = "en"
 )
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index c9db604..8740133 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/privatevoice.src/internal/model/registry.go
@@ -7,6 +7,7 @@
 
 const (
 	BackendSenseVoice = "sensevoice"
+	BackendMoonshine  = "moonshine"
 )
 
 var modelProfiles = map[string]ModelProfile{
@@ -26,6 +27,10 @@
 		},
 		RecommendedFor: []string{"zh-CN"},
 		Description:    "轻量中文模型,适合中文和中英混输。",
+		DownloadURLs: []string{
+			"http://www.maikami.com/voicesnap/sensevoice.zip",
+			"https://modelscope.cn/models/sherpa-onnx/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2",
+		},
 		InstallDirName: "sensevoice-zh",
 		LegacyDirNames: []string{"sensevoice"},
 		RequiredFiles: []RequiredFileRule{
@@ -41,6 +46,49 @@
 			},
 		},
 		ProviderOrder: []string{"coreml", "cpu"},
+		NumThreads:    4,
+	},
+	MoonshineModelID: {
+		ID:                   MoonshineModelID,
+		DisplayName:          "Moonshine English",
+		BackendKind:          BackendMoonshine,
+		Tier:                 "default",
+		SupportedLanguageIDs: []string{EnglishLanguageID},
+		RecommendedFor:       []string{EnglishLanguageID},
+		Description:          "轻量英文离线模型,适合英文听写和英文日常输入。",
+		ApproxSize:           "约 239 MiB 下载包,解压后约 270-280 MiB",
+		DownloadURLs: []string{
+			"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-moonshine-base-en-int8.tar.bz2",
+		},
+		InstallDirName: "moonshine-en",
+		RequiredFiles: []RequiredFileRule{
+			{
+				Role:     "preprocessor",
+				AllOf:    []string{"preprocess.onnx"},
+				Required: true,
+			},
+			{
+				Role:     "encoder",
+				AllOf:    []string{"encode.int8.onnx"},
+				Required: true,
+			},
+			{
+				Role:     "uncached_decoder",
+				AllOf:    []string{"uncached_decode.int8.onnx"},
+				Required: true,
+			},
+			{
+				Role:     "cached_decoder",
+				AllOf:    []string{"cached_decode.int8.onnx"},
+				Required: true,
+			},
+			{
+				Role:     "tokens",
+				AllOf:    []string{"tokens.txt"},
+				Required: true,
+			},
+		},
+		ProviderOrder: []string{"cpu"},
 		NumThreads:    4,
 	},
 }
@@ -60,7 +108,7 @@
 		NativeName:     "English",
 		UILocale:       "en",
 		SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
-		DefaultModelID: DefaultModelID,
+		DefaultModelID: MoonshineModelID,
 	},
 }
 
diff --git a/privatevoice.src/internal/modelselection/selection.go b/privatevoice.src/internal/modelselection/selection.go
new file mode 100644
index 0000000..0347f37
--- /dev/null
+++ b/privatevoice.src/internal/modelselection/selection.go
@@ -0,0 +1,56 @@
+package modelselection
+
+import (
+	"voicesnap/internal/config"
+	"voicesnap/internal/language"
+	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
+)
+
+type CurrentModel struct {
+	ModelID          string
+	Profile          model.ModelProfile
+	SelectionMode    string
+	LanguageSettings language.LanguageSettings
+	FallbackReason   string
+}
+
+func Resolve(cfg *config.Config, detector language.SystemLocaleDetector) CurrentModel {
+	if cfg == nil {
+		cfg = config.Default()
+	}
+
+	settings := language.Resolve(cfg.LanguageMode, cfg.LanguageID, detector)
+	selectionMode := config.NormalizeModelSelectionMode(cfg.ModelSelectionMode)
+	modelID := settings.DefaultModelID
+	fallbackReason := ""
+
+	if selectionMode == config.ModelSelectionModeManual {
+		if profile, err := model.GetModelProfile(cfg.SelectedModelID); err == nil {
+			return CurrentModel{
+				ModelID:          profile.ID,
+				Profile:          profile,
+				SelectionMode:    selectionMode,
+				LanguageSettings: settings,
+			}
+		}
+		fallbackReason = "invalid manual model selection"
+		logger.Info("Invalid manual SelectedModelID %q, falling back to language default %q", cfg.SelectedModelID, modelID)
+	}
+
+	profile, err := model.GetModelProfile(modelID)
+	if err != nil {
+		fallbackReason = "invalid language default model"
+		logger.Info("Invalid default model %q for language %q, falling back to %q", modelID, settings.EffectiveLanguageID, model.DefaultModelID)
+		profile = model.DefaultModelProfile()
+		modelID = profile.ID
+	}
+
+	return CurrentModel{
+		ModelID:          modelID,
+		Profile:          profile,
+		SelectionMode:    selectionMode,
+		LanguageSettings: settings,
+		FallbackReason:   fallbackReason,
+	}
+}
diff --git a/privatevoice.src/internal/modelselection/selection_test.go b/privatevoice.src/internal/modelselection/selection_test.go
new file mode 100644
index 0000000..47daac6
--- /dev/null
+++ b/privatevoice.src/internal/modelselection/selection_test.go
@@ -0,0 +1,83 @@
+package modelselection
+
+import (
+	"testing"
+
+	"voicesnap/internal/config"
+	"voicesnap/internal/model"
+)
+
+type fakeDetector struct {
+	locale string
+}
+
+func (f fakeDetector) Detect() string {
+	return f.locale
+}
+
+func TestResolveAutoUsesEnglishDefaultModel(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeAuto
+	cfg.ModelSelectionMode = config.ModelSelectionModeAuto
+
+	current := Resolve(cfg, fakeDetector{locale: "en-US"})
+	if current.ModelID != model.MoonshineModelID {
+		t.Fatalf("model id = %q, want %q", current.ModelID, model.MoonshineModelID)
+	}
+	if current.Profile.BackendKind != model.BackendMoonshine {
+		t.Fatalf("backend = %q, want %q", current.Profile.BackendKind, model.BackendMoonshine)
+	}
+}
+
+func TestResolveAutoUsesChineseDefaultModel(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeAuto
+	cfg.ModelSelectionMode = config.ModelSelectionModeAuto
+
+	current := Resolve(cfg, fakeDetector{locale: "zh-CN"})
+	if current.ModelID != model.DefaultModelID {
+		t.Fatalf("model id = %q, want %q", current.ModelID, model.DefaultModelID)
+	}
+}
+
+func TestResolveOldConfigMissingSelectionModeUsesAuto(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeAuto
+	cfg.ModelSelectionMode = ""
+	cfg.SelectedModelID = model.DefaultModelID
+
+	current := Resolve(cfg, fakeDetector{locale: "en-US"})
+	if current.SelectionMode != config.ModelSelectionModeAuto {
+		t.Fatalf("selection mode = %q, want auto", current.SelectionMode)
+	}
+	if current.ModelID != model.MoonshineModelID {
+		t.Fatalf("old selected model locked current model to %q, want %q", current.ModelID, model.MoonshineModelID)
+	}
+}
+
+func TestResolveManualUsesSelectedModel(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeAuto
+	cfg.ModelSelectionMode = config.ModelSelectionModeManual
+	cfg.SelectedModelID = model.DefaultModelID
+
+	current := Resolve(cfg, fakeDetector{locale: "en-US"})
+	if current.ModelID != model.DefaultModelID {
+		t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.DefaultModelID)
+	}
+}
+
+func TestResolveInvalidManualFallsBackToLanguageDefault(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeAuto
+	cfg.ModelSelectionMode = config.ModelSelectionModeManual
+	cfg.SelectedModelID = "bad-model"
+
+	current := Resolve(cfg, fakeDetector{locale: "en-US"})
+	if current.ModelID != model.MoonshineModelID {
+		t.Fatalf("model id = %q, want %q", current.ModelID, model.MoonshineModelID)
+	}
+	if current.FallbackReason == "" {
+		t.Fatal("expected fallback reason")
+	}
+}
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index 305353d..f44fe18 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/privatevoice.src/services/engine_service.go
@@ -1,9 +1,13 @@
 package services
 
 import (
+	"fmt"
 	"sync"
+	"voicesnap/internal/config"
 	"voicesnap/internal/engine"
+	"voicesnap/internal/language"
 	"voicesnap/internal/model"
+	"voicesnap/internal/modelselection"
 	"voicesnap/internal/paths"
 
 	"github.com/wailsapp/wails/v3/pkg/application"
@@ -38,6 +42,36 @@
 	}
 }
 
+func (s *EngineService) GetCurrentModelStatus() map[string]interface{} {
+	current := s.currentModel()
+	resolved, err := model.ResolveModel(current.ModelID)
+	installed := err == nil && resolved.IsUsable()
+	status := model.ModelNotInstalled
+	var missing []string
+	var problems []string
+	if err == nil {
+		status = resolved.Status
+		missing = resolved.Missing
+		problems = resolved.Problems
+	}
+
+	return map[string]interface{}{
+		"modelID":             current.ModelID,
+		"displayName":         current.Profile.DisplayName,
+		"backendKind":         current.Profile.BackendKind,
+		"languageID":          current.LanguageSettings.EffectiveLanguageID,
+		"selectionMode":       current.SelectionMode,
+		"installed":           installed,
+		"installStatus":       status,
+		"missing":             missing,
+		"problems":            problems,
+		"fallbackReason":      current.FallbackReason,
+		"downloadSize":        current.Profile.ApproxSize,
+		"supportedLanguages":  current.Profile.SupportedLanguageIDs,
+		"recommendedLanguage": current.Profile.RecommendedFor,
+	}
+}
+
 func (s *EngineService) SetStatus(status, hardwareInfo, lastError string) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -67,6 +101,39 @@
 	return nil
 }
 
+func (s *EngineService) DownloadCurrentModel() error {
+	current := s.currentModel()
+	if len(current.Profile.DownloadURLs) == 0 {
+		return fmt.Errorf("no download URL configured for model %s", current.ModelID)
+	}
+
+	err := model.DownloadProfile(current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
+		if s.app != nil {
+			s.app.Event.Emit("model:download-progress", map[string]interface{}{
+				"percent":    percent,
+				"downloaded": downloaded,
+				"total":      total,
+				"modelID":    current.ModelID,
+				"modelName":  current.Profile.DisplayName,
+			})
+		}
+	})
+	if err != nil {
+		return err
+	}
+
+	if s.initCallback != nil {
+		go s.initCallback()
+	}
+	return nil
+}
+
+func (s *EngineService) ReloadCurrentModel() {
+	if s.initCallback != nil {
+		go s.initCallback()
+	}
+}
+
 // SetInitCallback sets the callback to re-initialize the engine after model download.
 func (s *EngineService) SetInitCallback(cb func()) {
 	s.initCallback = cb
@@ -76,3 +143,11 @@
 func (s *EngineService) SetApp(app *application.App) {
 	s.app = app
 }
+
+func (s *EngineService) currentModel() modelselection.CurrentModel {
+	cfg, err := config.Load()
+	if err != nil {
+		cfg = config.Default()
+	}
+	return modelselection.Resolve(cfg, language.NewSystemDetector())
+}

--
Gitblit v1.9.3