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