Ariver
2026-07-02 0ffbf1935c9d091cce22a5583275ac0902f7a693
Add Cantonese language model support
25 files modified
3 files added
1150 ■■■■■ changed files
02-P-NBL/round-6/README.md 28 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-6/requirements-dev.md 417 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-6/todo-dev.md 76 ●●●●● patch | view | raw | blame | history
CHANGELOG.md 17 ●●●●● patch | view | raw | blame | history
CODEGRAPH.md 15 ●●●●● patch | view | raw | blame | history
privatevoice.src/app.go 14 ●●●● patch | view | raw | blame | history
privatevoice.src/app_live_caption_test.go 17 ●●●●● patch | view | raw | blame | history
privatevoice.src/build/config.yml 2 ●●● patch | view | raw | blame | history
privatevoice.src/build/darwin/Info.plist 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/package-lock.json 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/package.json 2 ●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte 60 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/en.json 1 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/zh.json 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_darwin_test.go 34 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_linux.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/engine/engine_windows.go 1 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/language/language_test.go 15 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/build_capabilities_windows.go 3 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 96 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/profile.go 3 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/registry.go 41 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/modelselection/selection_test.go 40 ●●●●● patch | view | raw | blame | history
privatevoice.src/scripts/build-windows-preview.sh 8 ●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 108 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service_test.go 113 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service_windows_test.go 28 ●●●●● patch | view | raw | blame | history
02-P-NBL/round-6/README.md
New file
@@ -0,0 +1,28 @@
# 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` 作为高级模型。
02-P-NBL/round-6/requirements-dev.md
New file
@@ -0,0 +1,417 @@
# 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 模型”进入产品主链路。
02-P-NBL/round-6/todo-dev.md
New file
@@ -0,0 +1,76 @@
# 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.md
@@ -1,5 +1,22 @@
# 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)
### 多平台测试包整理
CODEGRAPH.md
@@ -3,7 +3,7 @@
## 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
@@ -11,6 +11,17 @@
- `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
@@ -26,6 +37,8 @@
- 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`
privatevoice.src/app.go
@@ -28,8 +28,8 @@
)
const (
    appVersion        = "2.1.40"
    appBuild          = "20260702.0143"
    appVersion        = "2.2.0"
    appBuild          = "20260702.0303"
    appDisplayVersion = appVersion + " (build " + appBuild + ")"
    appName           = "PrivateVoice Dictation"
@@ -1118,7 +1118,11 @@
    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"
@@ -1160,6 +1164,12 @@
    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))
privatevoice.src/app_live_caption_test.go
@@ -114,6 +114,23 @@
    }
}
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)
privatevoice.src/build/config.yml
@@ -7,7 +7,7 @@
  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: .
privatevoice.src/build/darwin/Info.plist
@@ -17,9 +17,9 @@
    <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>
privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
  "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"
      },
privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
{
  "name": "privatevoice-dictation-frontend",
  "private": true,
  "version": "2.1.38",
  "version": "2.2.0",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -3,7 +3,7 @@
  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
@@ -36,6 +36,7 @@
  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 })
@@ -101,21 +102,37 @@
  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 []
    }
  }
@@ -159,6 +176,7 @@
      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 || ''
    }
  }
@@ -278,9 +296,23 @@
    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()
@@ -307,16 +339,28 @@
  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)
@@ -327,6 +371,8 @@
    } catch {
      languageModeVal = prevMode
      languageIDVal = prevID
      effectiveLanguageIDVal = prevEffectiveID
      pendingLanguageID = prevPendingID
      languageMode.set(prevMode)
      languageID.set(prevID)
    }
privatevoice.src/frontend/src/lib/i18n/en.json
@@ -92,6 +92,7 @@
    "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."
  },
privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -92,6 +92,7 @@
    "japaneseDesc": "基于 ReazonSpeech 的日语专项离线模型。",
    "koreanDesc": "韩语专项离线模型,适合韩语听写。",
    "qwen3Desc": "更高质量的中文离线模型,适合中文长句、技术词和中英混输。",
    "sensevoiceYueDesc": "粤语专项离线模型,基于 SenseVoice 并使用粤语数据增强。",
    "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。",
    "windowsPreviewUnsupported": "Windows 预览版当前仅支持 SenseVoice,暂不支持此模型。"
  },
privatevoice.src/internal/engine/engine_darwin.go
@@ -114,6 +114,7 @@
    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"]
privatevoice.src/internal/engine/engine_darwin_test.go
@@ -93,6 +93,40 @@
    }
}
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{
privatevoice.src/internal/engine/engine_linux.go
@@ -23,6 +23,7 @@
    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
privatevoice.src/internal/engine/engine_windows.go
@@ -32,6 +32,7 @@
        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
privatevoice.src/internal/language/language_test.go
@@ -19,6 +19,9 @@
    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,
@@ -75,16 +78,16 @@
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)
    }
}
privatevoice.src/internal/model/build_capabilities_windows.go
@@ -3,7 +3,8 @@
package model
func modelUnsupportedReasonInCurrentBuild(modelID string) string {
    if modelID == DefaultModelID {
    profile, err := GetModelProfile(modelID)
    if err == nil && profile.BackendKind == BackendSenseVoice {
        return ""
    }
    return UnsupportedReasonWindowsPreview
privatevoice.src/internal/model/model_test.go
@@ -114,6 +114,42 @@
    }
}
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 {
@@ -178,6 +214,31 @@
    }
}
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")
@@ -228,6 +289,41 @@
    }
}
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 {
privatevoice.src/internal/model/profile.go
@@ -9,7 +9,9 @@
    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"
@@ -29,6 +31,7 @@
    RecommendedFor       []string
    Description          string
    ApproxSize           string
    LanguageParam        string
    DownloadURLs         []string
    InstallDirName       string
    LegacyDirNames       []string
privatevoice.src/internal/model/registry.go
@@ -16,6 +16,7 @@
var languageProfileOrder = []string{
    DefaultLanguageID,
    CantoneseLanguageID,
    EnglishLanguageID,
    FrenchLanguageID,
    GermanLanguageID,
@@ -355,6 +356,35 @@
        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{
@@ -366,6 +396,14 @@
        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,
@@ -507,6 +545,9 @@
}
func NormalizeLanguageID(id string) string {
    if id == "yue" {
        return CantoneseLanguageID
    }
    if _, err := GetLanguageProfile(id); err != nil {
        return DefaultLanguageID
    }
privatevoice.src/internal/modelselection/selection_test.go
@@ -40,6 +40,46 @@
    }
}
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
privatevoice.src/scripts/build-windows-preview.sh
@@ -4,8 +4,8 @@
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
@@ -66,8 +66,8 @@
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.
privatevoice.src/services/engine_service.go
@@ -65,6 +65,20 @@
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
@@ -105,6 +119,25 @@
    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 {
@@ -141,6 +174,56 @@
    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 {
@@ -369,32 +452,37 @@
}
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)
}
privatevoice.src/services/engine_service_test.go
@@ -1,6 +1,11 @@
package services
import "testing"
import (
    "testing"
    "voicesnap/internal/config"
    "voicesnap/internal/model"
)
func TestEngineServiceDownloadStatusTracksCancelAndFinish(t *testing.T) {
    service := NewEngineService(nil)
@@ -53,3 +58,109 @@
        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")
    }
}
privatevoice.src/services/engine_service_windows_test.go
@@ -41,3 +41,31 @@
        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)
    }
}