Ariver
2026-06-03 4e7591ebb1294f611e7b85082be55313dd53820f
Implement Round 3 Moonshine English model
15 files modified
13 files added
1809 ■■■■■ changed files
02-P-NBL/round-3/README.md 63 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/code-review-current.md 32 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/dev-checklist-current.md 59 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/engineering-tasks.md 336 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/logs/git-diff-check-20260603.log patch | view | raw | blame | history
02-P-NBL/round-3/logs/go-test-all-20260603.log 23 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/logs/npm-run-build-20260603.log 38 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/prd.md 349 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/qa-handoff-current.md 100 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/qa-test-plan.md 208 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/todo-list.md 60 ●●●●● patch | view | raw | blame | history
privatevoice.src/app.go 40 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/App.svelte 22 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte 12 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte 14 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/en.json 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/zh.json 4 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/config/config.go 22 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/config/config_test.go 12 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine.go 25 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 44 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/language/language_test.go 3 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 74 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/profile.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/registry.go 50 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection.go 56 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection_test.go 83 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 75 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-3/README.md
New file
@@ -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>
02-P-NBL/round-3/code-review-current.md
New file
@@ -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,不是本轮新增。
02-P-NBL/round-3/dev-checklist-current.md
New file
@@ -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。
02-P-NBL/round-3/engineering-tasks.md
New file
@@ -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。
- 英文新用户测试方式。
- 中文老用户回归方式。
- 已知风险。
02-P-NBL/round-3/logs/git-diff-check-20260603.log
02-P-NBL/round-3/logs/go-test-all-20260603.log
New file
@@ -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]
02-P-NBL/round-3/logs/npm-run-build-20260603.log
New file
@@ -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
02-P-NBL/round-3/prd.md
New file
@@ -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、测试包、日志和关键证据。
02-P-NBL/round-3/qa-handoff-current.md
New file
@@ -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 风险,本轮没有自动升级依赖,避免扩大变更面。
02-P-NBL/round-3/qa-test-plan.md
New file
@@ -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、测试包、日志和关键证据。
02-P-NBL/round-3/todo-list.md
New file
@@ -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
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)
}
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}
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)
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
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",
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": "提示音",
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:
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)
    }
}
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)
}
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
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) {
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
privatevoice.src/internal/model/profile.go
@@ -2,6 +2,7 @@
const (
    DefaultModelID    = "sensevoice-zh"
    MoonshineModelID  = "moonshine-en"
    DefaultLanguageID = "zh-CN"
    EnglishLanguageID = "en"
)
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,
    },
}
privatevoice.src/internal/modelselection/selection.go
New file
@@ -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,
    }
}
privatevoice.src/internal/modelselection/selection_test.go
New file
@@ -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")
    }
}
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())
}