Implement Round 3 Moonshine English model
15 files modified
13 files added
| New file |
| | |
| | | # 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> |
| | | |
| New file |
| | |
| | | # 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,不是本轮新增。 |
| | | |
| New file |
| | |
| | | # 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。 |
| New file |
| | |
| | | # 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。 |
| | | - 英文新用户测试方式。 |
| | | - 中文老用户回归方式。 |
| | | - 已知风险。 |
| | | |
| New file |
| | |
| | | ? 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] |
| New file |
| | |
| | | |
| | | > 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 |
| New file |
| | |
| | | # 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、测试包、日志和关键证据。 |
| | | |
| New file |
| | |
| | | # 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 风险,本轮没有自动升级依赖,避免扩大变更面。 |
| | | |
| New file |
| | |
| | | # 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、测试包、日志和关键证据。 |
| | | |
| New file |
| | |
| | | # 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 |
| | |
| | | cfg *config.Config |
| | | recorder *audio.Recorder |
| | | eng engine.Engine |
| | | engineMu sync.Mutex |
| | | hk hotkey.Listener |
| | | paster input.Paster |
| | | history *history.Store |
| | |
| | | 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, "错误") |
| | |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | a.replaceEngine(nil) |
| | | status := "need_model" |
| | | if modelExists { |
| | | status = "error" |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | if a.recorder != nil { |
| | | a.recorder.Close() |
| | | } |
| | | if a.eng != nil { |
| | | a.eng.Close() |
| | | } |
| | | a.replaceEngine(nil) |
| | | a.wg.Wait() |
| | | logger.Info("Cleanup complete") |
| | | } |
| | |
| | | |
| | | // 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) |
| | | } |
| | |
| | | } |
| | | }) |
| | | |
| | | 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) => { |
| | |
| | | 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} |
| | |
| | | |
| | | 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 |
| | | ) |
| | | 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) |
| | |
| | | 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 { |
| | |
| | | ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto') |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | |
| | | "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", |
| | |
| | | "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": "提示音", |
| | |
| | | HotkeyModeHold = "hold" |
| | | LanguageModeAuto = "auto" |
| | | LanguageModeManual = "manual" |
| | | ModelSelectionModeAuto = "auto" |
| | | ModelSelectionModeManual = "manual" |
| | | ) |
| | | |
| | | // Config holds the application configuration, compatible with the WPF version's config.json. |
| | |
| | | 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"` |
| | |
| | | HideDockIcon: true, |
| | | CopyToClipboard: true, |
| | | SelectedModelID: model.DefaultModelID, |
| | | ModelSelectionMode: ModelSelectionModeAuto, |
| | | LanguageMode: LanguageModeAuto, |
| | | LanguageID: model.DefaultLanguageID, |
| | | ModelDownloadUrl: "http://www.maikami.com/voicesnap/sensevoice.zip", |
| | |
| | | } |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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: |
| | |
| | | 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) { |
| | |
| | | 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) |
| | | } |
| | | } |
| | |
| | | import ( |
| | | "fmt" |
| | | "voicesnap/internal/config" |
| | | "voicesnap/internal/language" |
| | | "voicesnap/internal/logger" |
| | | "voicesnap/internal/model" |
| | | "voicesnap/internal/modelselection" |
| | | "voicesnap/internal/paths" |
| | | ) |
| | | |
| | |
| | | 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) { |
| | |
| | | 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) |
| | | } |
| | |
| | | } |
| | | |
| | | 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, |
| | |
| | | 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 |
| | |
| | | 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) { |
| | |
| | | } |
| | | } |
| | | |
| | | 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") |
| | |
| | | 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") |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | const ( |
| | | DefaultModelID = "sensevoice-zh" |
| | | MoonshineModelID = "moonshine-en" |
| | | DefaultLanguageID = "zh-CN" |
| | | EnglishLanguageID = "en" |
| | | ) |
| | |
| | | |
| | | const ( |
| | | BackendSenseVoice = "sensevoice" |
| | | BackendMoonshine = "moonshine" |
| | | ) |
| | | |
| | | var modelProfiles = map[string]ModelProfile{ |
| | |
| | | }, |
| | | 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{ |
| | |
| | | }, |
| | | }, |
| | | 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, |
| | | }, |
| | | } |
| | |
| | | NativeName: "English", |
| | | UILocale: "en", |
| | | SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"}, |
| | | DefaultModelID: DefaultModelID, |
| | | DefaultModelID: MoonshineModelID, |
| | | }, |
| | | } |
| | | |
| New file |
| | |
| | | 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, |
| | | } |
| | | } |
| New file |
| | |
| | | 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") |
| | | } |
| | | } |
| | |
| | | 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" |
| | |
| | | } |
| | | } |
| | | |
| | | 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() |
| | |
| | | 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 |
| | |
| | | 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()) |
| | | } |