Add Cantonese language model support
25 files modified
3 files added
| New file |
| | |
| | | # Round 6:粤语语言版本 |
| | | |
| | | 状态:v0.1 需求开发文档 |
| | | 所属子项目:`02-P-NBL` |
| | | Round:Round 6 |
| | | 日期:2026-07-02 |
| | | |
| | | ## 1. 本轮目标 |
| | | |
| | | Round 6 的目标是在当前 PrivateVoice 主版本中新增 `粤语` 语言版本: |
| | | |
| | | ```text |
| | | 用户选择粤语 -> 自动下载并激活 SenseVoice Yue 2025-09-09 -> 使用粤语离线识别 |
| | | ``` |
| | | |
| | | 本轮不先拆独立 App 包。官网营销可以使用“PrivateVoice 粤语版”表达,但技术交付优先复用当前 App、当前更新链路和当前模型管理能力。 |
| | | |
| | | ## 2. 本轮文档 |
| | | |
| | | - [`requirements-dev.md`](requirements-dev.md):产品决议、交互、研发边界、QA 验收和风险 |
| | | |
| | | ## 3. 关键决议 |
| | | |
| | | - 粤语作为独立语言版本进入语言选择,不作为中文下的普通模型。 |
| | | - 默认模型使用 `SenseVoice Yue 2025-09-09`。 |
| | | - 用户选择粤语后,如果模型未安装,App 引导下载并在安装校验通过后自动激活。 |
| | | - `Qwen3-ASR`、`X-ASR` 不作为粤语默认模型展示。 |
| | | - 只有后续准确率或商业验证要求更高时,才评估 `Fun-ASR-Nano GGUF` 作为高级模型。 |
| New file |
| | |
| | | # Round 6 需求开发文档:粤语语言版本 |
| | | |
| | | 状态:v0.1 决议稿 |
| | | 所属子项目:`02-P-NBL` |
| | | Round:Round 6 |
| | | 日期:2026-07-02 |
| | | 决策来源:2026-07-02 架构讨论 |
| | | 目标交付版本:`2.2.0` |
| | | |
| | | ## 1. 背景 |
| | | |
| | | PrivateVoice 已从最初的中文 / English 双语言模型路线,扩展到多语种语言 profile 和模型 profile 架构。当前产品不再以 Mac App Store 为主要发布渠道,后续将以独立站下载和销售为主。 |
| | | |
| | | 在粤语能力上,公开资料显示 `sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09` 是基于 2024 SenseVoice 多语种模型、使用 21.8k 小时粤语数据 fine-tune 的新版模型,支持中文、粤语、英文、日文、韩文。该模型仍使用 sherpa-onnx / ONNX 路线,适合优先接入现有 PrivateVoice 主版本。 |
| | | |
| | | 资料依据: |
| | | |
| | | - sherpa-onnx SenseVoice 预训练模型文档:<https://k2-fsa.github.io/sherpa/onnx/sense-voice/pretrained.html> |
| | | - Hugging Face 模型页:<https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09> |
| | | - Fun-ASR-Nano 备选路线:<https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-2512> |
| | | - FunASR llama.cpp runtime 备选路线:<https://www.funasr.com/en/llama-cpp.html> |
| | | |
| | | ## 2. 产品决议 |
| | | |
| | | 本轮选择: |
| | | |
| | | ```text |
| | | 在当前 PrivateVoice 主版本中新增“粤语”语言选项。 |
| | | 用户选择粤语后,自动下载并激活 SenseVoice Yue 2025-09-09。 |
| | | 不先拆独立 App 包。 |
| | | 开发完成并通过验收后的 App 版本号记录为 2.2.0。 |
| | | ``` |
| | | |
| | | 产品表达: |
| | | |
| | | - App 内:新增 `粤语` 语言版本。 |
| | | - 官网营销:可以建立“PrivateVoice 粤语版”落地页或下载入口。 |
| | | - 技术交付:仍使用同一个 App、同一个代码主干、同一套模型下载和更新链路。 |
| | | |
| | | ## 3. 本轮目标 |
| | | |
| | | Round 6 完成后: |
| | | |
| | | | 场景 | 期望结果 | |
| | | |---|---| |
| | | | 用户打开语言设置 | 可以看到 `粤语` 选项,位置靠近 `中文` | |
| | | | 用户选择粤语,模型未安装 | App 提示或引导下载粤语离线模型 | |
| | | | 用户确认下载 | 下载 `SenseVoice Yue 2025-09-09`,显示进度 | |
| | | | 下载和校验成功 | 自动切换并激活粤语识别 | |
| | | | 用户再次启动 App | 仍保持粤语语言和粤语模型,除非用户切换 | |
| | | | 用户切回中文 | 恢复中文默认模型,不删除粤语模型 | |
| | | | 下载失败 | 不破坏原语言、原模型和用户数据 | |
| | | | 初始化失败 | 原可用模型继续可用,并给出失败提示 | |
| | | |
| | | 核心目标: |
| | | |
| | | - 新增粤语语言 profile。 |
| | | - 新增 SenseVoice Yue 2025-09-09 模型 profile。 |
| | | - 粤语语言选择触发默认模型下载和激活。 |
| | | - 粤语识别固定使用 `yue` 语言参数,不默认使用 `auto`。 |
| | | - 模型列表和 UI 文案避免误导用户以为 Qwen3-ASR / X-ASR 是粤语专项模型。 |
| | | |
| | | ## 4. 本轮非目标 |
| | | |
| | | 明确不做: |
| | | |
| | | - 不新建独立 App 包、独立 bundle id 或独立更新通道。 |
| | | - 不把粤语做成中文下的一个普通模型选项。 |
| | | - 不把 Qwen3-ASR 作为粤语默认模型。 |
| | | - 不在粤语页面默认展示 X-ASR。 |
| | | - 不接入 Fun-ASR-Nano GGUF 作为本轮默认模型。 |
| | | - 不做多模型准确率正式 benchmark,除非 QA 发现 SenseVoice Yue 不达标。 |
| | | - 不内置模型到 DMG,默认仍采用按需下载。 |
| | | - 不承诺本轮完成完整粤语营销页、支付页或授权体系。 |
| | | |
| | | ## 5. 功能层次 |
| | | |
| | | ```text |
| | | 粤语语言版本(主功能) |
| | | ├── 语言选择中新增“粤语” |
| | | ├── 粤语默认模型自动下载 |
| | | ├── 粤语模型安装校验 |
| | | ├── 粤语模型自动激活 |
| | | ├── 粤语识别后处理 |
| | | └── 下载/初始化失败保护 |
| | | |
| | | 官网“粤语版”营销表达(非本轮 App 功能) |
| | | └── 同一个 App 的独立落地页或下载入口 |
| | | |
| | | Fun-ASR-Nano 高准确率模型(后续评估) |
| | | └── 仅当 SenseVoice Yue 真实 QA 不达标时进入 spike |
| | | ``` |
| | | |
| | | ## 6. 用户故事 |
| | | |
| | | ### 6.1 粤语用户首次选择粤语 |
| | | |
| | | 作为粤语用户,我想在语言设置里直接选择 `粤语`,以便 App 使用适合粤语的离线识别模型,而不是让我在模型列表里猜哪个模型支持粤语。 |
| | | |
| | | 前置: |
| | | |
| | | - App 已安装。 |
| | | - 用户当前使用中文或其他语言。 |
| | | - SenseVoice Yue 尚未安装。 |
| | | |
| | | 流程: |
| | | |
| | | 1. 用户进入 `输入` 或 `语言` 设置页。 |
| | | 2. 用户点击 `粤语`。 |
| | | 3. App 显示粤语离线模型下载提示或 inline 下载状态。 |
| | | 4. 用户确认下载,或 App 按产品设定自动下载。 |
| | | 5. 下载进度可见。 |
| | | 6. 下载完成后自动校验。 |
| | | 7. 校验通过后自动激活粤语识别。 |
| | | |
| | | 期望: |
| | | |
| | | - 当前语言变为 `粤语`。 |
| | | - 当前模型变为 `SenseVoice Yue`。 |
| | | - Engine ready。 |
| | | - 用户可以立即用粤语听写。 |
| | | |
| | | ### 6.2 粤语用户重启后继续使用粤语 |
| | | |
| | | 作为已选择粤语的用户,我希望重启 App 后仍然是粤语输入,以便不用每次重新选择。 |
| | | |
| | | 期望: |
| | | |
| | | - 语言选择持久化。 |
| | | - 模型选择或默认模型解析稳定。 |
| | | - 若模型文件仍完整,App 直接加载粤语模型。 |
| | | - 若模型缺失或损坏,显示可恢复的重新下载状态。 |
| | | |
| | | ### 6.3 用户从粤语切回中文 |
| | | |
| | | 作为中粤双语用户,我想从粤语切回中文,以便使用普通中文识别模型。 |
| | | |
| | | 期望: |
| | | |
| | | - 切回中文后默认模型恢复为中文默认模型。 |
| | | - 粤语模型文件保留。 |
| | | - 后续再切回粤语时不重复下载。 |
| | | |
| | | ### 6.4 下载失败保护 |
| | | |
| | | 作为用户,我希望粤语模型下载失败时,原来可用的输入能力不被破坏。 |
| | | |
| | | 期望: |
| | | |
| | | - 原语言和原模型继续可用。 |
| | | - App 不写入损坏模型状态。 |
| | | - UI 明确显示失败原因或重试入口。 |
| | | |
| | | ## 7. 页面和交互要求 |
| | | |
| | | ### 7.1 语言选项 |
| | | |
| | | 语言选择中新增 `粤语`,建议顺序: |
| | | |
| | | ```text |
| | | 自动 / 中文 / 粤语 / English / Français / Deutsch / Español / Italiano / Português / 日本語 / 한국어 |
| | | ``` |
| | | |
| | | 如果后续已拆出独立 `语言` 页签,则 `粤语` 作为独立语言行出现,位置紧跟 `中文`。 |
| | | |
| | | ### 7.2 选择粤语时的下载交互 |
| | | |
| | | 推荐交互: |
| | | |
| | | ```text |
| | | 用户点击“粤语” |
| | | -> UI 显示:需要下载粤语离线模型,约 226 MB |
| | | -> 用户确认下载 |
| | | -> 显示下载进度 |
| | | -> 下载完成并校验 |
| | | -> 自动激活 SenseVoice Yue |
| | | ``` |
| | | |
| | | 可接受的轻量版本: |
| | | |
| | | ```text |
| | | 用户点击“粤语” |
| | | -> App 直接开始下载 |
| | | -> 语言按钮保持选中 |
| | | -> 状态显示“正在下载粤语离线模型” |
| | | ``` |
| | | |
| | | 选择哪种交互由实现前最终确认。若无进一步产品确认,默认采用“首次下载前提示确认”,避免用户误触发 200MB+ 下载。 |
| | | |
| | | ### 7.3 模型列表展示 |
| | | |
| | | 当当前语言为 `粤语`: |
| | | |
| | | - 默认展示 `SenseVoice Yue`。 |
| | | - 可展示普通 `SenseVoice` 作为备用,但必须明确不是粤语专项。 |
| | | - 不默认展示 `Qwen3-ASR`,除非后续验证其对粤语有明确优势。 |
| | | - 不展示 `X-ASR zh-en 960ms`,避免误导。 |
| | | |
| | | 示例文案: |
| | | |
| | | ```text |
| | | SenseVoice Yue |
| | | 粤语专项离线模型,基于 SenseVoice 并使用粤语数据增强。 |
| | | ``` |
| | | |
| | | 状态文案: |
| | | |
| | | ```text |
| | | 未安装 · 约 226 MB |
| | | 正在下载 42% |
| | | 已安装 |
| | | 当前 |
| | | ``` |
| | | |
| | | ### 7.4 首启和无模型路径 |
| | | |
| | | 如果用户首次启动时系统 locale 匹配不到粤语,不自动进入粤语。粤语应由用户手动选择。 |
| | | |
| | | 如果未来官网“粤语版”下载入口需要默认粤语,可以通过首次启动配置或发行渠道参数实现,但不在本轮做独立包。 |
| | | |
| | | ## 8. 研发约束 |
| | | |
| | | ### 8.1 LanguageProfile |
| | | |
| | | 新增粤语语言 profile: |
| | | |
| | | ```text |
| | | LanguageID: yue-HK 或 yue |
| | | DisplayName: Cantonese |
| | | NativeName: 粤语 |
| | | UILocale: zh |
| | | DefaultModelID: sensevoice-yue-2025-09-09 |
| | | SystemMatchers: yue, yue-HK, zh-HK 可考虑但不默认强行匹配 |
| | | ``` |
| | | |
| | | 建议: |
| | | |
| | | - 内部 ID 优先使用 `yue-HK`,产品显示为 `粤语`。 |
| | | - `zh-HK` 是否自动映射到粤语需谨慎。香港用户不一定都希望默认粤语;本轮可先不自动将 `zh-HK` 归为粤语,避免误判。 |
| | | |
| | | ### 8.2 ModelProfile |
| | | |
| | | 新增模型 profile: |
| | | |
| | | ```text |
| | | ModelID: sensevoice-yue-2025-09-09 |
| | | DisplayName: SenseVoice Yue |
| | | BackendKind: sensevoice |
| | | Tier: default |
| | | SupportedLanguageIDs: yue-HK |
| | | RecommendedFor: yue-HK |
| | | LanguageParam: yue |
| | | Description: 粤语专项离线模型,基于 SenseVoice 并使用粤语数据增强。 |
| | | ApproxSize: 约 226 MB 模型文件;下载包大小以实际 HTTP HEAD 为准 |
| | | InstallDirName: sensevoice-yue-2025-09-09 |
| | | ``` |
| | | |
| | | 下载 URL: |
| | | |
| | | ```text |
| | | https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2 |
| | | ``` |
| | | |
| | | Required files: |
| | | |
| | | ```text |
| | | model.int8.onnx |
| | | tokens.txt |
| | | ``` |
| | | |
| | | ### 8.3 Engine 配置 |
| | | |
| | | 粤语模型加载时: |
| | | |
| | | ```text |
| | | sense_voice.model = model.int8.onnx |
| | | sense_voice.language = yue |
| | | sense_voice.use_itn = 按现有 SenseVoice 路线决定 |
| | | tokens = tokens.txt |
| | | ``` |
| | | |
| | | 关键要求: |
| | | |
| | | - 粤语语言 profile 的默认模型必须解析到 SenseVoice Yue。 |
| | | - 用户选择粤语后,不应继续使用普通中文 SenseVoice。 |
| | | - 下载成功后应触发 engine reload。 |
| | | - 初始化失败时,保留原模型可用性。 |
| | | |
| | | ### 8.4 后处理 |
| | | |
| | | 由于 2025-09-09 模型文档明确不支持 punctuation,本轮需要保守后处理: |
| | | |
| | | - 中文 / 粤语句末标点补全。 |
| | | - 常见英文混输空格保护。 |
| | | - 常见粤语助词不做激进改写。 |
| | | - 不做同音字大规模纠错。 |
| | | - 不做繁简强制转换,除非后续有明确产品决策。 |
| | | |
| | | ### 8.5 配置和回退 |
| | | |
| | | 切换粤语时: |
| | | |
| | | - 如果下载未完成,不应把不可用模型写成最终可用状态。 |
| | | - 如果下载成功但初始化失败,应保留原语言/模型,或明确提示用户当前粤语模型不可用。 |
| | | - 如果用户已经手动选择其他模型,切换语言时应按现有“语言默认模型优先”规则处理,并记录 fallback reason。 |
| | | |
| | | ## 9. QA 测试计划 |
| | | |
| | | ### 9.1 自动化测试 |
| | | |
| | | 后端单元测试: |
| | | |
| | | - `NormalizeLanguageID("yue-HK")` |
| | | - `GetLanguageProfile("yue-HK")` |
| | | - 粤语语言默认模型为 `sensevoice-yue-2025-09-09` |
| | | - `GetModelProfile("sensevoice-yue-2025-09-09")` |
| | | - required files 完整时校验通过 |
| | | - 缺少 `model.int8.onnx` 时校验失败 |
| | | - 缺少 `tokens.txt` 时校验失败 |
| | | - 当前语言切换到粤语后,模型 resolver 返回 SenseVoice Yue |
| | | - 手动模型不支持粤语时,回退到粤语默认模型并保留 fallback reason |
| | | |
| | | 前端构建: |
| | | |
| | | ```bash |
| | | cd privatevoice.src/frontend |
| | | npm run build |
| | | ``` |
| | | |
| | | 后端测试: |
| | | |
| | | ```bash |
| | | cd privatevoice.src |
| | | go test ./... -count=1 |
| | | ``` |
| | | |
| | | ### 9.2 手工 QA |
| | | |
| | | | ID | 场景 | 前置条件 | 期望结果 | |
| | | |---|---|---|---| |
| | | | R6-QA-001 | 语言列表显示 | 打开输入/语言设置 | `粤语` 出现在 `中文` 后 | |
| | | | R6-QA-002 | 选择粤语未安装 | 模型未安装 | 显示下载提示或开始下载 | |
| | | | R6-QA-003 | 下载进度 | 联网下载 | 进度可见,不能重复触发 | |
| | | | R6-QA-004 | 下载完成 | 下载成功 | 模型校验通过并自动激活 | |
| | | | R6-QA-005 | 粤语短句识别 | SenseVoice Yue ready | 粤语短句可识别 | |
| | | | R6-QA-006 | 粤英混输 | SenseVoice Yue ready | 英文产品名/技术词不被明显破坏 | |
| | | | R6-QA-007 | 普通话混入 | SenseVoice Yue ready | 普通话片段不导致崩溃或空结果 | |
| | | | R6-QA-008 | 切回中文 | 两模型均安装 | 中文默认模型可用 | |
| | | | R6-QA-009 | 再切回粤语 | 粤语模型已安装 | 不重复下载,直接加载 | |
| | | | R6-QA-010 | 下载失败 | 断网/中断下载 | 原模型可继续使用 | |
| | | | R6-QA-011 | 坏包/缺文件 | 删除 required file | App 显示未安装或需修复 | |
| | | | R6-QA-012 | 重启持久化 | 已选择粤语 | 重启后仍为粤语 | |
| | | | R6-QA-013 | 旧用户配置 | 旧配置无 yue | 正常回退,不崩溃 | |
| | | | R6-QA-014 | English 回归 | 切到 English | Moonshine/Parakeet 不受影响 | |
| | | | R6-QA-015 | 中文回归 | 切到中文 | SenseVoice/Qwen3-ASR 不受影响 | |
| | | |
| | | ### 9.3 真实语料 QA |
| | | |
| | | 每类至少 10 条,优先使用真实香港粤语用户录音: |
| | | |
| | | - 日常短句。 |
| | | - 长句口述。 |
| | | - 粤英混输。 |
| | | - 人名、地名、品牌名。 |
| | | - 常见粤语助词。 |
| | | - 安静环境。 |
| | | - 轻噪声环境。 |
| | | - 普通话混入。 |
| | | - 静音和无语音。 |
| | | - 连续切换语言后识别。 |
| | | |
| | | ## 10. 验收标准 |
| | | |
| | | Round 6 通过条件: |
| | | |
| | | - 语言列表新增 `粤语`,位置和文案符合要求。 |
| | | - 选择粤语后,默认模型解析到 `SenseVoice Yue`。 |
| | | - 未安装时可下载,下载进度可见。 |
| | | - 下载完成后 required files 校验通过。 |
| | | - 模型自动激活,engine ready。 |
| | | - 粤语短句和粤英混输真实 QA 通过最低可用标准。 |
| | | - 下载失败、初始化失败不破坏原语言、原模型、词库、历史。 |
| | | - 中文和 English 现有流程回归通过。 |
| | | - 不把 Qwen3-ASR / X-ASR 误作为粤语默认模型。 |
| | | - QA 报告记录版本、build、模型文件、测试设备、测试语料摘要和失败样本。 |
| | | - 开发完成、QA 通过并进入交付时,App 版本号、`CHANGELOG.md`、release manifest 和安装包命名均记录为 `2.2.0`,build 编号继续使用 `YYYYMMDD.HHMM`。 |
| | | |
| | | ## 11. 风险和待确认 |
| | | |
| | | | 风险 | 说明 | 处理 | |
| | | |---|---|---| |
| | | | 模型许可 | 需要确认模型权重商业使用、归属和 attribution 要求 | 发布前做 license 审核 | |
| | | | 标点能力 | 2025-09-09 文档写明不支持 punctuation | 做保守后处理,不承诺复杂标点 | |
| | | | 自动匹配 zh-HK | `zh-HK` 用户不一定都想用粤语 | 本轮先手动选择,不强制自动映射 | |
| | | | 下载体验 | 200MB+ 模型在弱网下可能失败 | 使用现有断点/重试能力,QA 覆盖弱网 | |
| | | | 准确率 | 粤语真实口音、噪声、领域词可能不足 | 建立真实语料 QA 和失败样本池 | |
| | | | 独立营销名 | “粤语版”可能被用户理解为独立 App | 官网文案说明为同一 App 的粤语语言版本 | |
| | | |
| | | ## 12. 后续路线 |
| | | |
| | | 如果 SenseVoice Yue 真实 QA 不达标,再进入技术 spike: |
| | | |
| | | ```text |
| | | Fun-ASR-Nano 2512 GGUF / llama.cpp runtime |
| | | ``` |
| | | |
| | | 评估维度: |
| | | |
| | | - 粤语 CER/WER。 |
| | | - 粤英混输准确率。 |
| | | - 长句上下文能力。 |
| | | - 延迟。 |
| | | - 内存占用。 |
| | | - 包体大小。 |
| | | - macOS 签名和分发复杂度。 |
| | | |
| | | 只有 Fun-ASR-Nano 明显优于 SenseVoice Yue,才考虑作为“高准确率模型”或“Pro 模型”进入产品主链路。 |
| New file |
| | |
| | | # Round 6 开发 Todo:粤语语言版本 |
| | | |
| | | 状态:完成 |
| | | 目标版本:`2.2.0` |
| | | 需求来源:`requirements-dev.md` |
| | | |
| | | ## 范围 |
| | | |
| | | 本轮只实现同一 App 内的粤语语言版本: |
| | | |
| | | - 新增 `粤语` 语言选项。 |
| | | - 新增 `SenseVoice Yue 2025-09-09` 模型 profile。 |
| | | - 粤语默认模型解析、下载校验和 engine 加载参数正确。 |
| | | - 粤语场景不误展示或误用 Qwen3-ASR / X-ASR。 |
| | | - 补自动化测试、开发自测、代码审核和 QA 回归记录。 |
| | | |
| | | 不做: |
| | | |
| | | - 不做独立 App 包。 |
| | | - 不做独立官网销售页。 |
| | | - 不做授权 / 支付 / 自动更新。 |
| | | - 不做 Fun-ASR-Nano 接入。 |
| | | - 不改变中文、English、Qwen3-ASR、Moonshine、Parakeet、X-ASR 既有行为,除非为粤语路由所必需。 |
| | | |
| | | ## Todo |
| | | |
| | | | ID | 事项 | 状态 | 验收信号 | |
| | | |---|---|---|---| |
| | | | R6-T01 | 定位现有语言 profile、模型 profile、下载校验、engine 参数入口 | 完成 | 涉及 `internal/model`、`internal/modelselection`、`internal/engine`、`services`、`LanguagePage.svelte` | |
| | | | R6-T02 | 新增 `粤语` 语言 profile,位置紧跟 `中文` | 完成 | 语言列表包含 `yue-HK` / `粤语`,默认模型为 SenseVoice Yue | |
| | | | R6-T03 | 新增 `SenseVoice Yue 2025-09-09` 模型 profile | 完成 | URL、安装目录、required files、size、文案已接入 | |
| | | | R6-T04 | 粤语 engine 加载固定使用 `sense_voice.language = yue` | 完成 | `internal/engine` 配置测试覆盖 | |
| | | | R6-T05 | 前端输入/模型列表展示粤语状态,不误导展示 Qwen3-ASR / X-ASR | 完成 | service 层测试覆盖粤语只展示 SenseVoice Yue;前端构建通过 | |
| | | | R6-T06 | 补后端单元测试 | 完成 | `go test ./... -count=1` 通过 | |
| | | | R6-T07 | 补前端构建验证 | 完成 | `npm --prefix frontend run build` 通过,存在既有 Svelte a11y warning | |
| | | | R6-T08 | 两位代码审核员复核 | 完成 | Ptolemy、Helmholtz 最终结论均为 APPROVE | |
| | | | R6-T09 | QA 回归测试 | 完成 | Nietzsche 最终 QA Recheck:PASS | |
| | | | R6-T10 | 修复审核/QA 问题并记录证据 | 完成 | 已修 pending 语言预览、旧 engine 保留、Windows 文案/测试、版本元数据;测试证据见下方 | |
| | | |
| | | ## 验收证据 |
| | | |
| | | ### 代码审核 |
| | | |
| | | - Ptolemy:最终 `APPROVE`。重点复核 pending 语言不持久化、下载成功后才切换、共享模型不误标 `Current`。 |
| | | - Helmholtz:最终 `APPROVE`。重点复核 Windows 文案、Windows SenseVoice Yue 支持、pending 已安装模型和 X-ASR 共享模型边界。 |
| | | |
| | | ### QA 回归 |
| | | |
| | | - Nietzsche:最终 `PASS`。 |
| | | - 覆盖范围: |
| | | - 未安装目标语言只预览,不持久化、不 reload。 |
| | | - pending 下下载未安装模型,下载成功后按目标语言持久化。 |
| | | - pending 下使用已安装模型,按目标语言持久化。 |
| | | - 当前中文 + X-ASR 预览 English 时,X-ASR 不再误标 `Current`。 |
| | | - Windows preview 下 SenseVoice Yue 被标记为 supported。 |
| | | |
| | | ### 自动化验证 |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/PrivateVoice/privatevoice.src |
| | | go test ./services -count=1 |
| | | npm --prefix frontend run build |
| | | go test ./... -count=1 |
| | | GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go test -exec=/usr/bin/true ./... |
| | | git -C /Users/ar/Projects/PrivateVoice diff --check |
| | | ``` |
| | | |
| | | 结果:全部通过。存在既有 macOS linker warning 和既有 Svelte a11y warning,未作为本轮阻塞。 |
| | | |
| | | ## Progress Log |
| | | |
| | | - 2026-07-02:完成 Round 6 粤语语言版本开发,版本元数据更新为 `2.2.0` / `20260702.0303`。 |
| | | - 2026-07-02:新增 `yue-HK` 语言 profile、`SenseVoice Yue` 模型 profile,并在 SenseVoice engine 配置中传入 `language=yue`。 |
| | | - 2026-07-02:根据代码审核修复语言切换安全语义:未安装目标语言只在输入页预览,下载/选择成功后才持久化语言并 reload;失败或取消不影响旧语言和旧 engine。 |
| | | - 2026-07-02:根据代码审核修复 Windows preview 文案和支持断言,SenseVoice backend 包括中文与粤语,非 SenseVoice 模型仍禁用。 |
| | | - 2026-07-02:两位代码审核员最终 `APPROVE`,QA 最终 `PASS`。 |
| | |
| | | # Changelog |
| | | |
| | | ## v2.2.0 (2026-07-02) |
| | | |
| | | ### 粤语语言版本 |
| | | |
| | | - **新增粤语语言选项**:输入配置页语言列表新增 `粤语`,位置紧跟 `中文`。 |
| | | - **新增 SenseVoice Yue 模型 profile**:接入 `sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09`,作为粤语默认离线模型。 |
| | | - **固定粤语识别语言参数**:粤语模型加载时显式设置 SenseVoice `language=yue`,避免走默认自动识别。 |
| | | - **收敛粤语模型列表**:粤语语言下只展示粤语默认模型,不把 Qwen3-ASR / X-ASR 作为粤语专项模型展示。 |
| | | - **补充回归测试**:覆盖粤语语言 profile、模型校验、模型选择、engine 配置、前端构建和 Windows 交叉编译。 |
| | | |
| | | ### 构建 |
| | | |
| | | - build: `20260702.0303` |
| | | - 说明: 本版本为 Round 6 粤语语言版本真实用户测试候选包基线,已完成代码审核和 QA 回归。 |
| | | |
| | | --- |
| | | |
| | | ## v2.1.40 (2026-07-02) |
| | | |
| | | ### 多平台测试包整理 |
| | |
| | | ## Project |
| | | |
| | | - Product: Wails v3 / Go desktop dictation app. |
| | | - Current branch purpose: cross-platform local package handoff based on `v2.1.40-build20260702.0143`. |
| | | - Current branch purpose: Round 6 Cantonese language version real-user test package based on `v2.2.0-build20260702.0303`. |
| | | - Protected macOS behavior: existing macOS App Store / local DMG build lines are not changed by this branch. |
| | | |
| | | ## Core Entrypoints |
| | |
| | | - `privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard. |
| | | - `privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration. |
| | | - `privatevoice.src/embed.go`: embeds frontend assets and app/tray icons. |
| | | |
| | | ## Language And Model Registry |
| | | |
| | | - `privatevoice.src/internal/model/profile.go`: model and language IDs, model profile shape, install status types. |
| | | - `privatevoice.src/internal/model/registry.go`: language profile order, model profiles, language profiles, and normalization. |
| | | - `privatevoice.src/internal/modelselection/selection.go`: resolves current language and model, including manual-model fallback. |
| | | - Round 6 Cantonese path: |
| | | - Language ID: `yue-HK`, displayed as `粤语`. |
| | | - Default model: `sensevoice-yue-2025-09-09`. |
| | | - Backend: SenseVoice with `LanguageParam="yue"`. |
| | | - The Cantonese language profile intentionally has no upgrade models, so Qwen3-ASR and X-ASR are not shown as Cantonese-specific options. |
| | | |
| | | ## Platform Modules |
| | | |
| | |
| | | - ASR engine: |
| | | - macOS: `privatevoice.src/internal/engine/engine_darwin.go` |
| | | - Windows: `privatevoice.src/internal/engine/engine_windows.go` |
| | | - Linux: `privatevoice.src/internal/engine/engine_linux.go` |
| | | - SenseVoice profiles may set `Profile.LanguageParam`; Cantonese uses `yue`. |
| | | - Overlay: |
| | | - macOS: `privatevoice.src/internal/overlay/overlay_darwin.go` |
| | | - Windows: `privatevoice.src/internal/overlay/overlay_windows.go` |
| | |
| | | ) |
| | | |
| | | const ( |
| | | appVersion = "2.1.40" |
| | | appBuild = "20260702.0143" |
| | | appVersion = "2.2.0" |
| | | appBuild = "20260702.0303" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "PrivateVoice Dictation" |
| | | |
| | |
| | | eng, err := engine.New() |
| | | if err != nil { |
| | | logger.Error("Engine initialization failed: %v", err) |
| | | if a.hasEngine() { |
| | | logger.Info("Keeping existing ASR engine after reload failure") |
| | | } else { |
| | | a.replaceEngine(nil) |
| | | } |
| | | status := "need_model" |
| | | if modelExists { |
| | | status = "error" |
| | |
| | | a.delayedHideIf(autoHide, 2000) |
| | | } |
| | | |
| | | func (a *App) hasEngine() bool { |
| | | a.engineMu.Lock() |
| | | defer a.engineMu.Unlock() |
| | | return a.eng != nil |
| | | } |
| | | |
| | | func (a *App) replaceEngine(eng engine.Engine) { |
| | | a.releaseTailCaptureNanos.Store(int64(releaseTailCaptureDelayForEngine(eng))) |
| | | a.holdPreCaptureEnabled.Store(holdPreCaptureEnabledForEngine(eng)) |
| | |
| | | } |
| | | } |
| | | |
| | | func TestHasEngineReflectsCurrentEngine(t *testing.T) { |
| | | a := &App{} |
| | | if a.hasEngine() { |
| | | t.Fatal("new app should not report an engine") |
| | | } |
| | | |
| | | a.replaceEngine(plainTestEngine{}) |
| | | if !a.hasEngine() { |
| | | t.Fatal("app should report an engine after replaceEngine") |
| | | } |
| | | |
| | | a.replaceEngine(nil) |
| | | if a.hasEngine() { |
| | | t.Fatal("app should not report an engine after clearing") |
| | | } |
| | | } |
| | | |
| | | func TestShouldStartHoldPreCaptureLocked(t *testing.T) { |
| | | a := &App{} |
| | | a.holdPreCaptureEnabled.Store(true) |
| | |
| | | description: "Private offline voice input tool" |
| | | copyright: "(c) 2026, PrivateVoice" |
| | | comments: "Private. Offline." |
| | | version: "2.1.28" |
| | | version: "2.2.0" |
| | | |
| | | dev_mode: |
| | | root_path: . |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>2.1.40</string> |
| | | <string>2.2.0</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260702.0143</string> |
| | | <string>20260702.0303</string> |
| | | <key>ITSAppUsesNonExemptEncryption</key> |
| | | <false/> |
| | | <key>LSApplicationCategoryType</key> |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "version": "2.1.38", |
| | | "version": "2.2.0", |
| | | "lockfileVersion": 3, |
| | | "requires": true, |
| | | "packages": { |
| | | "": { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "version": "2.1.38", |
| | | "version": "2.2.0", |
| | | "dependencies": { |
| | | "@wailsio/runtime": "latest" |
| | | }, |
| | |
| | | { |
| | | "name": "privatevoice-dictation-frontend", |
| | | "private": true, |
| | | "version": "2.1.38", |
| | | "version": "2.2.0", |
| | | "type": "module", |
| | | "scripts": { |
| | | "dev": "vite dev", |
| | |
| | | import { t, setLocale } from '../../lib/i18n' |
| | | import { languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config' |
| | | import type { UILocale } from '../../lib/stores/config' |
| | | import { engineStatus, engineHardwareInfo } from '../../lib/stores/app' |
| | | import { engineStatus } from '../../lib/stores/app' |
| | | |
| | | interface ModelOption { |
| | | modelID: string |
| | |
| | | let modelCancellingID = $state('') |
| | | let modelProgress = $state(0) |
| | | let modelError = $state('') |
| | | let pendingLanguageID = $state('') |
| | | |
| | | const unsub1 = languageMode.subscribe(v => { languageModeVal = v }) |
| | | const unsub2 = languageID.subscribe(v => { languageIDVal = v }) |
| | |
| | | async function syncEngineForCurrentModel() { |
| | | try { |
| | | const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus') |
| | | if (current?.installed) { |
| | | if (!current?.installed) return |
| | | engineStatus.set('loading') |
| | | } else { |
| | | engineHardwareInfo.set('') |
| | | } |
| | | await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel') |
| | | } catch {} |
| | | } |
| | | |
| | | async function loadModelOptions() { |
| | | if (pendingLanguageID) { |
| | | await loadModelOptionsForLanguage(pendingLanguageID) |
| | | return |
| | | } |
| | | await loadCurrentModelOptions() |
| | | } |
| | | |
| | | async function loadCurrentModelOptions() { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions') |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | } catch { |
| | | modelOptions = [] |
| | | } |
| | | } |
| | | |
| | | async function loadModelOptionsForLanguage(selection: string): Promise<ModelOption[]> { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptionsForLanguage', selection) |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | return modelOptions |
| | | } catch { |
| | | modelOptions = [] |
| | | return [] |
| | | } |
| | | } |
| | | |
| | |
| | | case 'zipformer-ja-reazonspeech': return t('models.japaneseDesc') |
| | | case 'zipformer-ko': return t('models.koreanDesc') |
| | | case 'qwen3-asr-0.6b': return t('models.qwen3Desc') |
| | | case 'sensevoice-yue-2025-09-09': return t('models.sensevoiceYueDesc') |
| | | default: return option.description || '' |
| | | } |
| | | } |
| | |
| | | try { |
| | | if (option.installed) { |
| | | engineStatus.set('loading') |
| | | if (pendingLanguageID) { |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModelForLanguage', pendingLanguageID, option.modelID) |
| | | pendingLanguageID = '' |
| | | const settings: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(settings) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID) |
| | | } |
| | | } else { |
| | | if (pendingLanguageID) { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByIDForLanguage', pendingLanguageID, option.modelID) |
| | | pendingLanguageID = '' |
| | | const settings: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(settings) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID) |
| | | } |
| | | engineStatus.set('loading') |
| | | } |
| | | await loadModelOptions() |
| | |
| | | async function onLanguageChange(selection: string) { |
| | | const prevMode = languageModeVal |
| | | const prevID = languageIDVal |
| | | const prevEffectiveID = effectiveLanguageIDVal |
| | | const prevPendingID = pendingLanguageID |
| | | if (selection === 'auto') { |
| | | pendingLanguageID = '' |
| | | languageModeVal = 'auto' |
| | | languageMode.set('auto') |
| | | } else { |
| | | languageModeVal = 'manual' |
| | | languageIDVal = selection |
| | | languageMode.set('manual') |
| | | languageID.set(selection) |
| | | } |
| | | try { |
| | | if (selection !== 'auto') { |
| | | const options = await loadModelOptionsForLanguage(selection) |
| | | const defaultInstalled = options.some(option => option.isDefault && option.installed) |
| | | if (!defaultInstalled) { |
| | | pendingLanguageID = selection |
| | | effectiveLanguageIDVal = selection |
| | | await syncModelDownloadStatus() |
| | | return |
| | | } |
| | | } |
| | | pendingLanguageID = '' |
| | | const settings: any = selection === 'auto' |
| | | ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto') |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | | effectiveLanguageIDVal = prevEffectiveID |
| | | pendingLanguageID = prevPendingID |
| | | languageMode.set(prevMode) |
| | | languageID.set(prevID) |
| | | } |
| | |
| | | "japaneseDesc": "Dedicated Japanese offline model based on ReazonSpeech.", |
| | | "koreanDesc": "Dedicated Korean offline model for Korean dictation.", |
| | | "qwen3Desc": "Higher quality Chinese offline model for long Chinese dictation, technical terms, and mixed Chinese-English input.", |
| | | "sensevoiceYueDesc": "Dedicated Cantonese offline model based on SenseVoice with Cantonese-enhanced data.", |
| | | "qwen3RequiresMacOS14": "Qwen3-ASR requires macOS 14 or later. This compatibility build does not support downloading it.", |
| | | "windowsPreviewUnsupported": "The Windows preview currently supports SenseVoice only. This model is not available yet." |
| | | }, |
| | |
| | | "japaneseDesc": "基于 ReazonSpeech 的日语专项离线模型。", |
| | | "koreanDesc": "韩语专项离线模型,适合韩语听写。", |
| | | "qwen3Desc": "更高质量的中文离线模型,适合中文长句、技术词和中英混输。", |
| | | "sensevoiceYueDesc": "粤语专项离线模型,基于 SenseVoice 并使用粤语数据增强。", |
| | | "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。", |
| | | "windowsPreviewUnsupported": "Windows 预览版当前仅支持 SenseVoice,暂不支持此模型。" |
| | | }, |
| | |
| | | switch resolved.BackendKind { |
| | | case model.BackendSenseVoice: |
| | | config.ModelConfig.SenseVoice.Model = resolved.Files["model"] |
| | | config.ModelConfig.SenseVoice.Language = resolved.Profile.LanguageParam |
| | | config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1 |
| | | case model.BackendMoonshine: |
| | | config.ModelConfig.Moonshine.Preprocessor = resolved.Files["preprocessor"] |
| | |
| | | } |
| | | } |
| | | |
| | | func TestOfflineConfigForSenseVoiceYueSetsLanguageParam(t *testing.T) { |
| | | root := t.TempDir() |
| | | files := map[string]string{ |
| | | "model": filepath.Join(root, "model.int8.onnx"), |
| | | "tokens": filepath.Join(root, "tokens.txt"), |
| | | } |
| | | resolved := model.ResolvedModel{ |
| | | ModelID: model.SenseVoiceYueModelID, |
| | | BackendKind: model.BackendSenseVoice, |
| | | Profile: model.ModelProfile{ |
| | | DisplayName: "SenseVoice Yue", |
| | | BackendKind: model.BackendSenseVoice, |
| | | LanguageParam: "yue", |
| | | ProviderOrder: []string{"cpu"}, |
| | | NumThreads: 4, |
| | | }, |
| | | Files: files, |
| | | } |
| | | |
| | | config, err := offlineConfigForResolvedModel(resolved, "cpu") |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | if config.ModelConfig.SenseVoice.Model != files["model"] { |
| | | t.Fatalf("model = %q, want %q", config.ModelConfig.SenseVoice.Model, files["model"]) |
| | | } |
| | | if config.ModelConfig.SenseVoice.Language != "yue" { |
| | | t.Fatalf("language = %q, want yue", config.ModelConfig.SenseVoice.Language) |
| | | } |
| | | if config.ModelConfig.Tokens != files["tokens"] { |
| | | t.Fatalf("tokens = %q, want %q", config.ModelConfig.Tokens, files["tokens"]) |
| | | } |
| | | } |
| | | |
| | | func TestOnlineConfigForXASRUsesStreamingTransducer(t *testing.T) { |
| | | root := t.TempDir() |
| | | files := map[string]string{ |
| | |
| | | config.FeatConfig.SampleRate = 16000 |
| | | config.FeatConfig.FeatureDim = 80 |
| | | config.ModelConfig.SenseVoice.Model = resolved.Files["model"] |
| | | config.ModelConfig.SenseVoice.Language = resolved.Profile.LanguageParam |
| | | config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1 |
| | | config.ModelConfig.Tokens = resolved.Files["tokens"] |
| | | config.ModelConfig.NumThreads = resolved.Profile.NumThreads |
| | |
| | | config.FeatConfig.SampleRate = 16000 |
| | | config.FeatConfig.FeatureDim = 80 |
| | | config.ModelConfig.SenseVoice.Model = resolved.Files["model"] |
| | | config.ModelConfig.SenseVoice.Language = resolved.Profile.LanguageParam |
| | | config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1 |
| | | config.ModelConfig.Tokens = resolved.Files["tokens"] |
| | | config.ModelConfig.NumThreads = resolved.Profile.NumThreads |
| | |
| | | tests := map[string]string{ |
| | | "zh-CN": model.DefaultLanguageID, |
| | | "zh_Hans_CN": model.DefaultLanguageID, |
| | | "zh-HK": model.DefaultLanguageID, |
| | | "yue-HK": model.CantoneseLanguageID, |
| | | "yue": model.CantoneseLanguageID, |
| | | "en-US": model.EnglishLanguageID, |
| | | "en_GB.UTF-8": model.EnglishLanguageID, |
| | | "fr-FR": model.FrenchLanguageID, |
| | |
| | | |
| | | func TestResolveOptionsAreStable(t *testing.T) { |
| | | settings := Resolve(config.LanguageModeAuto, model.DefaultLanguageID, fakeDetector{locale: "zh-CN"}) |
| | | if len(settings.Options) != 9 { |
| | | t.Fatalf("options len = %d, want 9", len(settings.Options)) |
| | | if len(settings.Options) != 10 { |
| | | t.Fatalf("options len = %d, want 10", len(settings.Options)) |
| | | } |
| | | if settings.Options[0].ID != model.DefaultLanguageID { |
| | | t.Fatalf("first option = %q, want %q", settings.Options[0].ID, model.DefaultLanguageID) |
| | | } |
| | | if settings.Options[1].ID != model.EnglishLanguageID { |
| | | t.Fatalf("second option = %q, want %q", settings.Options[1].ID, model.EnglishLanguageID) |
| | | if settings.Options[1].ID != model.CantoneseLanguageID { |
| | | t.Fatalf("second option = %q, want %q", settings.Options[1].ID, model.CantoneseLanguageID) |
| | | } |
| | | if settings.Options[2].ID != model.FrenchLanguageID { |
| | | t.Fatalf("third option = %q, want %q", settings.Options[2].ID, model.FrenchLanguageID) |
| | | if settings.Options[2].ID != model.EnglishLanguageID { |
| | | t.Fatalf("third option = %q, want %q", settings.Options[2].ID, model.EnglishLanguageID) |
| | | } |
| | | } |
| | |
| | | package model |
| | | |
| | | func modelUnsupportedReasonInCurrentBuild(modelID string) string { |
| | | if modelID == DefaultModelID { |
| | | profile, err := GetModelProfile(modelID) |
| | | if err == nil && profile.BackendKind == BackendSenseVoice { |
| | | return "" |
| | | } |
| | | return UnsupportedReasonWindowsPreview |
| | |
| | | } |
| | | } |
| | | |
| | | func TestRegistryReturnsCantoneseSenseVoiceYueDefault(t *testing.T) { |
| | | languageProfile, err := GetLanguageProfile(CantoneseLanguageID) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | if languageProfile.NativeName != "粤语" { |
| | | t.Fatalf("Cantonese native name = %q, want 粤语", languageProfile.NativeName) |
| | | } |
| | | if languageProfile.DefaultModelID != SenseVoiceYueModelID { |
| | | t.Fatalf("Cantonese default model = %q, want %q", languageProfile.DefaultModelID, SenseVoiceYueModelID) |
| | | } |
| | | if len(languageProfile.UpgradeModelIDs) != 0 { |
| | | t.Fatalf("Cantonese upgrade models = %v, want none", languageProfile.UpgradeModelIDs) |
| | | } |
| | | |
| | | profile, err := GetModelProfile(SenseVoiceYueModelID) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | if profile.BackendKind != BackendSenseVoice { |
| | | t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendSenseVoice) |
| | | } |
| | | if profile.LanguageParam != "yue" { |
| | | t.Fatalf("language param = %q, want yue", profile.LanguageParam) |
| | | } |
| | | if profile.InstallDirName != "sensevoice-yue-2025-09-09" { |
| | | t.Fatalf("install dir = %q, want sensevoice-yue-2025-09-09", profile.InstallDirName) |
| | | } |
| | | if !stringSliceContains(profile.SupportedLanguageIDs, CantoneseLanguageID) { |
| | | t.Fatalf("supported languages = %v, want %s", profile.SupportedLanguageIDs, CantoneseLanguageID) |
| | | } |
| | | if len(profile.DownloadURLs) == 0 { |
| | | t.Fatal("expected SenseVoice Yue download URL") |
| | | } |
| | | } |
| | | |
| | | func TestRegistryReturnsQwen3ASRChinese(t *testing.T) { |
| | | profile, err := GetModelProfile(Qwen3ASRModelID) |
| | | if err != nil { |
| | |
| | | } |
| | | } |
| | | |
| | | func TestLanguageProfileOrderPlacesCantoneseAfterChinese(t *testing.T) { |
| | | profiles := ListLanguageProfiles() |
| | | if len(profiles) < 3 { |
| | | t.Fatalf("language profiles len = %d, want at least 3", len(profiles)) |
| | | } |
| | | if profiles[0].ID != DefaultLanguageID { |
| | | t.Fatalf("first language = %q, want %q", profiles[0].ID, DefaultLanguageID) |
| | | } |
| | | if profiles[1].ID != CantoneseLanguageID { |
| | | t.Fatalf("second language = %q, want %q", profiles[1].ID, CantoneseLanguageID) |
| | | } |
| | | if profiles[2].ID != EnglishLanguageID { |
| | | t.Fatalf("third language = %q, want %q", profiles[2].ID, EnglishLanguageID) |
| | | } |
| | | } |
| | | |
| | | func TestNormalizeLanguageIDAcceptsCantoneseAlias(t *testing.T) { |
| | | if got := NormalizeLanguageID("yue"); got != CantoneseLanguageID { |
| | | t.Fatalf("NormalizeLanguageID(yue) = %q, want %q", got, CantoneseLanguageID) |
| | | } |
| | | if got := NormalizeLanguageID(CantoneseLanguageID); got != CantoneseLanguageID { |
| | | t.Fatalf("NormalizeLanguageID(%s) = %q", CantoneseLanguageID, got) |
| | | } |
| | | } |
| | | |
| | | func TestValidateSenseVoiceAnyOfAndAllOf(t *testing.T) { |
| | | dir := t.TempDir() |
| | | writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens") |
| | |
| | | } |
| | | } |
| | | |
| | | func TestValidateSenseVoiceYueRequiredFiles(t *testing.T) { |
| | | profile, err := GetModelProfile(SenseVoiceYueModelID) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | dir := t.TempDir() |
| | | createValidSenseVoice(t, dir) |
| | | |
| | | result := ValidateModelDir(profile, dir) |
| | | if !result.Valid { |
| | | t.Fatalf("expected valid SenseVoice Yue dir, missing=%v problems=%v", result.Missing, result.Problems) |
| | | } |
| | | if got := filepath.Base(result.Files["model"]); got != "model.int8.onnx" { |
| | | t.Fatalf("model file = %q, want model.int8.onnx", got) |
| | | } |
| | | if result.Files["tokens"] == "" { |
| | | t.Fatal("missing resolved tokens file") |
| | | } |
| | | } |
| | | |
| | | func TestValidateSenseVoiceYueRejectsFp32OnlyModel(t *testing.T) { |
| | | profile, err := GetModelProfile(SenseVoiceYueModelID) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | dir := t.TempDir() |
| | | writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens") |
| | | writeTestFile(t, filepath.Join(dir, "model.onnx"), "fp32") |
| | | |
| | | result := ValidateModelDir(profile, dir) |
| | | if result.Valid { |
| | | t.Fatal("expected SenseVoice Yue to require model.int8.onnx") |
| | | } |
| | | } |
| | | |
| | | func TestValidateMoonshineRequiredFiles(t *testing.T) { |
| | | profile, err := GetModelProfile(MoonshineModelID) |
| | | if err != nil { |
| | |
| | | KoreanModelID = "zipformer-ko" |
| | | Qwen3ASRModelID = "qwen3-asr-0.6b" |
| | | XASRZhEn960ModelID = "x-asr-zh-en-960ms" |
| | | SenseVoiceYueModelID = "sensevoice-yue-2025-09-09" |
| | | DefaultLanguageID = "zh-CN" |
| | | CantoneseLanguageID = "yue-HK" |
| | | EnglishLanguageID = "en" |
| | | FrenchLanguageID = "fr" |
| | | GermanLanguageID = "de" |
| | |
| | | RecommendedFor []string |
| | | Description string |
| | | ApproxSize string |
| | | LanguageParam string |
| | | DownloadURLs []string |
| | | InstallDirName string |
| | | LegacyDirNames []string |
| | |
| | | |
| | | var languageProfileOrder = []string{ |
| | | DefaultLanguageID, |
| | | CantoneseLanguageID, |
| | | EnglishLanguageID, |
| | | FrenchLanguageID, |
| | | GermanLanguageID, |
| | |
| | | ProviderOrder: []string{"cpu"}, |
| | | NumThreads: 1, |
| | | }, |
| | | SenseVoiceYueModelID: { |
| | | ID: SenseVoiceYueModelID, |
| | | DisplayName: "SenseVoice Yue", |
| | | BackendKind: BackendSenseVoice, |
| | | Tier: "default", |
| | | SupportedLanguageIDs: []string{CantoneseLanguageID}, |
| | | RecommendedFor: []string{CantoneseLanguageID}, |
| | | Description: "粤语专项离线模型,基于 SenseVoice 并使用粤语数据增强。", |
| | | ApproxSize: "约 226 MiB 模型文件;下载包大小以实际 HTTP HEAD 为准", |
| | | LanguageParam: "yue", |
| | | DownloadURLs: []string{ |
| | | "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2", |
| | | }, |
| | | InstallDirName: "sensevoice-yue-2025-09-09", |
| | | RequiredFiles: []RequiredFileRule{ |
| | | { |
| | | Role: "model", |
| | | AllOf: []string{"model.int8.onnx"}, |
| | | Required: true, |
| | | }, |
| | | { |
| | | Role: "tokens", |
| | | AllOf: []string{"tokens.txt"}, |
| | | Required: true, |
| | | }, |
| | | }, |
| | | ProviderOrder: []string{"coreml", "cpu"}, |
| | | NumThreads: 4, |
| | | }, |
| | | } |
| | | |
| | | var languageProfiles = map[string]LanguageProfile{ |
| | |
| | | SystemMatchers: []string{"zh", "zh-CN", "zh-Hans", "zh-Hant"}, |
| | | DefaultModelID: DefaultModelID, |
| | | UpgradeModelIDs: []string{Qwen3ASRModelID, XASRZhEn960ModelID}, |
| | | }, |
| | | CantoneseLanguageID: { |
| | | ID: CantoneseLanguageID, |
| | | DisplayName: "Cantonese", |
| | | NativeName: "粤语", |
| | | UILocale: "zh", |
| | | SystemMatchers: []string{"yue", "yue-HK"}, |
| | | DefaultModelID: SenseVoiceYueModelID, |
| | | }, |
| | | EnglishLanguageID: { |
| | | ID: EnglishLanguageID, |
| | |
| | | } |
| | | |
| | | func NormalizeLanguageID(id string) string { |
| | | if id == "yue" { |
| | | return CantoneseLanguageID |
| | | } |
| | | if _, err := GetLanguageProfile(id); err != nil { |
| | | return DefaultLanguageID |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | func TestResolveManualCantoneseUsesSenseVoiceYueDefault(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.CantoneseLanguageID |
| | | cfg.ModelSelectionMode = config.ModelSelectionModeAuto |
| | | |
| | | current := Resolve(cfg, fakeDetector{locale: "en-US"}) |
| | | if current.LanguageSettings.EffectiveLanguageID != model.CantoneseLanguageID { |
| | | t.Fatalf("effective language = %q, want %q", current.LanguageSettings.EffectiveLanguageID, model.CantoneseLanguageID) |
| | | } |
| | | if current.ModelID != model.SenseVoiceYueModelID { |
| | | t.Fatalf("model id = %q, want %q", current.ModelID, model.SenseVoiceYueModelID) |
| | | } |
| | | if current.Profile.BackendKind != model.BackendSenseVoice { |
| | | t.Fatalf("backend = %q, want %q", current.Profile.BackendKind, model.BackendSenseVoice) |
| | | } |
| | | } |
| | | |
| | | func TestResolveManualCantoneseRejectsChineseUpgradeModels(t *testing.T) { |
| | | tests := []string{ |
| | | model.Qwen3ASRModelID, |
| | | model.XASRZhEn960ModelID, |
| | | } |
| | | for _, modelID := range tests { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.CantoneseLanguageID |
| | | cfg.ModelSelectionMode = config.ModelSelectionModeManual |
| | | cfg.SelectedModelID = modelID |
| | | |
| | | current := Resolve(cfg, fakeDetector{locale: "en-US"}) |
| | | if current.ModelID != model.SenseVoiceYueModelID { |
| | | t.Fatalf("%s resolved model id = %q, want %q", modelID, current.ModelID, model.SenseVoiceYueModelID) |
| | | } |
| | | if current.FallbackReason != "manual model not available for language" { |
| | | t.Fatalf("%s fallback reason = %q, want language incompatibility", modelID, current.FallbackReason) |
| | | } |
| | | } |
| | | } |
| | | |
| | | func TestResolveAutoUsesEuropeanDefaultModel(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeAuto |
| | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | cd "$ROOT_DIR" |
| | | |
| | | APP_VERSION="2.1.40" |
| | | APP_BUILD="20260702.0143" |
| | | APP_VERSION="2.2.0" |
| | | APP_BUILD="20260702.0303" |
| | | ARCH="${1:-amd64}" |
| | | |
| | | if [[ "$ARCH" != "amd64" ]]; then |
| | |
| | | |
| | | Scope: |
| | | - Technical preview based on PrivateVoice Dictation ${APP_VERSION}. |
| | | - The first Windows preview supports SenseVoice Chinese recognition only. |
| | | - Other models are visible but disabled in this preview build. |
| | | - This Windows preview supports SenseVoice-based recognition, including Chinese and Cantonese. |
| | | - Non-SenseVoice models are visible but disabled in this preview build. |
| | | |
| | | How to run: |
| | | 1. Extract the whole folder. |
| | |
| | | |
| | | func (s *EngineService) ListModelOptions() []map[string]interface{} { |
| | | current := s.currentModel() |
| | | return s.listModelOptionsForCurrent(current) |
| | | } |
| | | |
| | | func (s *EngineService) ListModelOptionsForLanguage(languageID string) []map[string]interface{} { |
| | | current := s.currentModel() |
| | | activeLanguageID := current.LanguageSettings.EffectiveLanguageID |
| | | current.LanguageSettings = language.Resolve(config.LanguageModeManual, model.NormalizeLanguageID(languageID), nil) |
| | | if current.LanguageSettings.EffectiveLanguageID != activeLanguageID { |
| | | current.ModelID = "" |
| | | } |
| | | return s.listModelOptionsForCurrent(current) |
| | | } |
| | | |
| | | func (s *EngineService) listModelOptionsForCurrent(current modelselection.CurrentModel) []map[string]interface{} { |
| | | languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID) |
| | | if err != nil { |
| | | return nil |
| | |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) SelectModelForLanguage(languageID, modelID string) error { |
| | | languageID = model.NormalizeLanguageID(languageID) |
| | | profile, languageProfile, err := s.allowedModelProfileForLanguage(languageID, modelID) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | resolved, err := model.ResolveModel(profile.ID) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | if !resolved.IsUsable() { |
| | | return fmt.Errorf("model %s is not installed", profile.ID) |
| | | } |
| | | |
| | | s.persistLanguageModelSelection(languageID, profile, languageProfile) |
| | | s.ReloadCurrentModel() |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) DownloadModelByID(modelID string) error { |
| | | profile, err := s.allowedModelProfile(modelID) |
| | | if err != nil { |
| | |
| | | config.Save(s.cfg) |
| | | s.ReloadCurrentModel() |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) DownloadModelByIDForLanguage(languageID, modelID string) error { |
| | | languageID = model.NormalizeLanguageID(languageID) |
| | | profile, languageProfile, err := s.allowedModelProfileForLanguage(languageID, modelID) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | if len(profile.DownloadURLs) == 0 { |
| | | return fmt.Errorf("no download URL configured for model %s", profile.ID) |
| | | } |
| | | |
| | | ctx, finish, err := s.beginModelDownload(profile.ID) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | defer finish() |
| | | |
| | | err = model.DownloadProfileWithContext(ctx, profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) { |
| | | s.updateModelDownloadProgress(profile.ID, percent, downloaded, total) |
| | | if s.app != nil { |
| | | s.app.Event.Emit("model:download-progress", map[string]interface{}{ |
| | | "percent": percent, |
| | | "downloaded": downloaded, |
| | | "total": total, |
| | | "modelID": profile.ID, |
| | | "modelName": profile.DisplayName, |
| | | }) |
| | | } |
| | | }) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | s.persistLanguageModelSelection(languageID, profile, languageProfile) |
| | | s.ReloadCurrentModel() |
| | | return nil |
| | | } |
| | | |
| | | func (s *EngineService) persistLanguageModelSelection(languageID string, profile model.ModelProfile, languageProfile model.LanguageProfile) { |
| | | s.cfg.LanguageMode = config.LanguageModeManual |
| | | s.cfg.LanguageID = languageID |
| | | if profile.ID == languageProfile.DefaultModelID { |
| | | s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto |
| | | s.cfg.SelectedModelID = "" |
| | | } else { |
| | | s.cfg.ModelSelectionMode = config.ModelSelectionModeManual |
| | | s.cfg.SelectedModelID = profile.ID |
| | | } |
| | | config.Save(s.cfg) |
| | | } |
| | | |
| | | func (s *EngineService) CancelModelDownload(modelID string) bool { |
| | |
| | | } |
| | | |
| | | func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) { |
| | | current := s.currentModel() |
| | | profile, _, err := s.allowedModelProfileForLanguage(current.LanguageSettings.EffectiveLanguageID, modelID) |
| | | return profile, err |
| | | } |
| | | |
| | | func (s *EngineService) allowedModelProfileForLanguage(languageID, modelID string) (model.ModelProfile, model.LanguageProfile, error) { |
| | | profile, err := model.GetModelProfile(modelID) |
| | | if err != nil { |
| | | return model.ModelProfile{}, err |
| | | return model.ModelProfile{}, model.LanguageProfile{}, err |
| | | } |
| | | switch model.ModelUnsupportedReason(profile.ID) { |
| | | case "": |
| | | case model.UnsupportedReasonRequiresMacOS14: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID) |
| | | return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID) |
| | | case model.UnsupportedReasonWindowsPreview: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID) |
| | | return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID) |
| | | default: |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID) |
| | | return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID) |
| | | } |
| | | |
| | | current := s.currentModel() |
| | | languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID) |
| | | languageProfile, err := model.GetLanguageProfile(model.NormalizeLanguageID(languageID)) |
| | | if err != nil { |
| | | return model.ModelProfile{}, err |
| | | return model.ModelProfile{}, model.LanguageProfile{}, err |
| | | } |
| | | if profile.ID == languageProfile.DefaultModelID { |
| | | return profile, nil |
| | | return profile, languageProfile, nil |
| | | } |
| | | for _, id := range languageProfile.UpgradeModelIDs { |
| | | if profile.ID == id { |
| | | return profile, nil |
| | | return profile, languageProfile, nil |
| | | } |
| | | } |
| | | return model.ModelProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, current.LanguageSettings.EffectiveLanguageID) |
| | | return model.ModelProfile{}, model.LanguageProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, languageProfile.ID) |
| | | } |
| | |
| | | package services |
| | | |
| | | import "testing" |
| | | import ( |
| | | "testing" |
| | | |
| | | "voicesnap/internal/config" |
| | | "voicesnap/internal/model" |
| | | ) |
| | | |
| | | func TestEngineServiceDownloadStatusTracksCancelAndFinish(t *testing.T) { |
| | | service := NewEngineService(nil) |
| | |
| | | t.Fatalf("modelID after finish = %v, want empty", got) |
| | | } |
| | | } |
| | | |
| | | func TestListModelOptionsForCantoneseOnlyOffersSenseVoiceYue(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.CantoneseLanguageID |
| | | cfg.ModelSelectionMode = config.ModelSelectionModeAuto |
| | | |
| | | service := NewEngineService(cfg) |
| | | options := service.ListModelOptions() |
| | | if len(options) != 1 { |
| | | t.Fatalf("options len = %d, want 1: %#v", len(options), options) |
| | | } |
| | | if got := options[0]["modelID"]; got != model.SenseVoiceYueModelID { |
| | | t.Fatalf("modelID = %v, want %s", got, model.SenseVoiceYueModelID) |
| | | } |
| | | if got := options[0]["isDefault"]; got != true { |
| | | t.Fatalf("isDefault = %v, want true", got) |
| | | } |
| | | } |
| | | |
| | | func TestListModelOptionsForLanguagePreviewsCantoneseWithoutPersisting(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.DefaultLanguageID |
| | | service := NewEngineService(cfg) |
| | | |
| | | options := service.ListModelOptionsForLanguage(model.CantoneseLanguageID) |
| | | if len(options) != 1 { |
| | | t.Fatalf("options len = %d, want 1: %#v", len(options), options) |
| | | } |
| | | if got := options[0]["modelID"]; got != model.SenseVoiceYueModelID { |
| | | t.Fatalf("modelID = %v, want %s", got, model.SenseVoiceYueModelID) |
| | | } |
| | | if got := options[0]["languageID"]; got != model.CantoneseLanguageID { |
| | | t.Fatalf("languageID = %v, want %s", got, model.CantoneseLanguageID) |
| | | } |
| | | if cfg.LanguageID != model.DefaultLanguageID { |
| | | t.Fatalf("cfg.LanguageID = %q, want unchanged %q", cfg.LanguageID, model.DefaultLanguageID) |
| | | } |
| | | } |
| | | |
| | | func TestListModelOptionsForLanguageDoesNotMarkSharedCurrentModelForPendingLanguage(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.DefaultLanguageID |
| | | cfg.ModelSelectionMode = config.ModelSelectionModeManual |
| | | cfg.SelectedModelID = model.XASRZhEn960ModelID |
| | | service := NewEngineService(cfg) |
| | | |
| | | options := service.ListModelOptionsForLanguage(model.EnglishLanguageID) |
| | | var found bool |
| | | for _, option := range options { |
| | | if option["modelID"] != model.XASRZhEn960ModelID { |
| | | continue |
| | | } |
| | | found = true |
| | | if got := option["isCurrent"]; got != false { |
| | | t.Fatalf("isCurrent = %v, want false for pending language shared model", got) |
| | | } |
| | | } |
| | | if !found { |
| | | t.Fatalf("expected %s in English options: %#v", model.XASRZhEn960ModelID, options) |
| | | } |
| | | } |
| | | |
| | | func TestAllowedModelProfileForLanguageAcceptsCantoneseWithoutPersisting(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.DefaultLanguageID |
| | | service := NewEngineService(cfg) |
| | | |
| | | profile, languageProfile, err := service.allowedModelProfileForLanguage(model.CantoneseLanguageID, model.SenseVoiceYueModelID) |
| | | if err != nil { |
| | | t.Fatalf("allowedModelProfileForLanguage returned error: %v", err) |
| | | } |
| | | if profile.ID != model.SenseVoiceYueModelID { |
| | | t.Fatalf("profile id = %q, want %q", profile.ID, model.SenseVoiceYueModelID) |
| | | } |
| | | if languageProfile.ID != model.CantoneseLanguageID { |
| | | t.Fatalf("language profile id = %q, want %q", languageProfile.ID, model.CantoneseLanguageID) |
| | | } |
| | | if _, err := service.allowedModelProfile(model.SenseVoiceYueModelID); err == nil { |
| | | t.Fatal("expected current Chinese language to reject SenseVoice Yue") |
| | | } |
| | | } |
| | | |
| | | func TestAllowedModelProfileForLanguageAcceptsTargetLanguageUpgradeWithoutPersisting(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.DefaultLanguageID |
| | | service := NewEngineService(cfg) |
| | | |
| | | profile, languageProfile, err := service.allowedModelProfileForLanguage(model.EnglishLanguageID, model.ParakeetModelID) |
| | | if err != nil { |
| | | t.Fatalf("allowedModelProfileForLanguage returned error: %v", err) |
| | | } |
| | | if profile.ID != model.ParakeetModelID { |
| | | t.Fatalf("profile id = %q, want %q", profile.ID, model.ParakeetModelID) |
| | | } |
| | | if languageProfile.ID != model.EnglishLanguageID { |
| | | t.Fatalf("language profile id = %q, want %q", languageProfile.ID, model.EnglishLanguageID) |
| | | } |
| | | if _, err := service.allowedModelProfile(model.ParakeetModelID); err == nil { |
| | | t.Fatal("expected current Chinese language to reject Parakeet") |
| | | } |
| | | } |
| | |
| | | t.Fatalf("error = %q, want Windows preview reason", err.Error()) |
| | | } |
| | | } |
| | | |
| | | func TestWindowsPreviewSupportsSenseVoiceYue(t *testing.T) { |
| | | cfg := config.Default() |
| | | cfg.LanguageMode = config.LanguageModeManual |
| | | cfg.LanguageID = model.CantoneseLanguageID |
| | | service := NewEngineService(cfg) |
| | | profile, err := model.GetModelProfile(model.SenseVoiceYueModelID) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | |
| | | status := service.modelStatusMap(profile, modelselection.CurrentModel{ |
| | | ModelID: model.SenseVoiceYueModelID, |
| | | Profile: profile, |
| | | SelectionMode: config.ModelSelectionModeAuto, |
| | | LanguageSettings: language.Resolve(config.LanguageModeManual, model.CantoneseLanguageID, nil), |
| | | }) |
| | | |
| | | if got := status["supportedInBuild"]; got != true { |
| | | t.Fatalf("supportedInBuild = %v, want true", got) |
| | | } |
| | | if got := status["unsupportedReason"]; got != "" { |
| | | t.Fatalf("unsupportedReason = %v, want empty", got) |
| | | } |
| | | if _, err := service.allowedModelProfile(model.SenseVoiceYueModelID); err != nil { |
| | | t.Fatalf("allowedModelProfile returned error: %v", err) |
| | | } |
| | | } |