From 0ffbf1935c9d091cce22a5583275ac0902f7a693 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 03:04:51 +0800
Subject: [PATCH] Add Cantonese language model support

---
 privatevoice.src/internal/model/profile.go                            |    3 
 privatevoice.src/internal/modelselection/selection_test.go            |   40 ++
 privatevoice.src/internal/engine/engine_darwin.go                     |    1 
 privatevoice.src/internal/language/language_test.go                   |   15 
 02-P-NBL/round-6/todo-dev.md                                          |   76 +++
 privatevoice.src/frontend/package.json                                |    2 
 privatevoice.src/internal/engine/engine_windows.go                    |    1 
 privatevoice.src/frontend/src/components/settings/LanguagePage.svelte |   66 ++
 privatevoice.src/frontend/src/lib/i18n/zh.json                        |    1 
 privatevoice.src/frontend/src/lib/i18n/en.json                        |    1 
 privatevoice.src/internal/engine/engine_darwin_test.go                |   34 +
 privatevoice.src/internal/model/registry.go                           |   41 ++
 privatevoice.src/app_live_caption_test.go                             |   17 
 privatevoice.src/scripts/build-windows-preview.sh                     |    8 
 CHANGELOG.md                                                          |   17 
 privatevoice.src/internal/engine/engine_linux.go                      |    1 
 privatevoice.src/build/darwin/Info.plist                              |    4 
 privatevoice.src/internal/model/model_test.go                         |   96 ++++
 CODEGRAPH.md                                                          |   15 
 privatevoice.src/services/engine_service_windows_test.go              |   28 +
 02-P-NBL/round-6/requirements-dev.md                                  |  417 ++++++++++++++++++++
 privatevoice.src/app.go                                               |   16 
 privatevoice.src/build/config.yml                                     |    2 
 privatevoice.src/services/engine_service.go                           |  108 ++++
 privatevoice.src/internal/model/build_capabilities_windows.go         |    3 
 02-P-NBL/round-6/README.md                                            |   28 +
 privatevoice.src/frontend/package-lock.json                           |    4 
 privatevoice.src/services/engine_service_test.go                      |  113 +++++
 28 files changed, 1,116 insertions(+), 42 deletions(-)

diff --git a/02-P-NBL/round-6/README.md b/02-P-NBL/round-6/README.md
new file mode 100644
index 0000000..077492d
--- /dev/null
+++ b/02-P-NBL/round-6/README.md
@@ -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` 作为高级模型。
diff --git a/02-P-NBL/round-6/requirements-dev.md b/02-P-NBL/round-6/requirements-dev.md
new file mode 100644
index 0000000..fd899c0
--- /dev/null
+++ b/02-P-NBL/round-6/requirements-dev.md
@@ -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 模型”进入产品主链路。
diff --git a/02-P-NBL/round-6/todo-dev.md b/02-P-NBL/round-6/todo-dev.md
new file mode 100644
index 0000000..92b08ad
--- /dev/null
+++ b/02-P-NBL/round-6/todo-dev.md
@@ -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`。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d96398..27c5be6 100755
--- a/CHANGELOG.md
+++ b/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)
 
 ### 多平台测试包整理
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index c25f132..359f71b 100644
--- a/CODEGRAPH.md
+++ b/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`
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 3cfd3b4..0a0c428 100755
--- a/privatevoice.src/app.go
+++ b/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)
-		a.replaceEngine(nil)
+		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))
diff --git a/privatevoice.src/app_live_caption_test.go b/privatevoice.src/app_live_caption_test.go
index 321f388..2c1610d 100644
--- a/privatevoice.src/app_live_caption_test.go
+++ b/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)
diff --git a/privatevoice.src/build/config.yml b/privatevoice.src/build/config.yml
index 02f3dcd..6066ae1 100755
--- a/privatevoice.src/build/config.yml
+++ b/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: .
diff --git a/privatevoice.src/build/darwin/Info.plist b/privatevoice.src/build/darwin/Info.plist
index 3ff6829..466def2 100755
--- a/privatevoice.src/build/darwin/Info.plist
+++ b/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>
diff --git a/privatevoice.src/frontend/package-lock.json b/privatevoice.src/frontend/package-lock.json
index 7bd111d..eb0d87d 100755
--- a/privatevoice.src/frontend/package-lock.json
+++ b/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"
       },
diff --git a/privatevoice.src/frontend/package.json b/privatevoice.src/frontend/package.json
index eddd6fb..f831a59 100755
--- a/privatevoice.src/frontend/package.json
+++ b/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",
diff --git a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
index d486394..f989836 100644
--- a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
+++ b/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) {
-        engineStatus.set('loading')
-      } else {
-        engineHardwareInfo.set('')
-      }
+      if (!current?.installed) return
+      engineStatus.set('loading')
       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')
-        await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
+        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 {
-        await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
+        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)
     }
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 7f8aa66..8a20dac 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/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."
   },
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 5d2e8c5..de30cea 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/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,暂不支持此模型。"
   },
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index ddf1ad8..156b48e 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/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"]
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
index 245adc7..55f5f0a 100644
--- a/privatevoice.src/internal/engine/engine_darwin_test.go
+++ b/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{
diff --git a/privatevoice.src/internal/engine/engine_linux.go b/privatevoice.src/internal/engine/engine_linux.go
index f452f33..2d9fde2 100755
--- a/privatevoice.src/internal/engine/engine_linux.go
+++ b/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
diff --git a/privatevoice.src/internal/engine/engine_windows.go b/privatevoice.src/internal/engine/engine_windows.go
index 18d0b24..8ace9c7 100755
--- a/privatevoice.src/internal/engine/engine_windows.go
+++ b/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
diff --git a/privatevoice.src/internal/language/language_test.go b/privatevoice.src/internal/language/language_test.go
index ffe80c3..ac41000 100644
--- a/privatevoice.src/internal/language/language_test.go
+++ b/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)
 	}
 }
diff --git a/privatevoice.src/internal/model/build_capabilities_windows.go b/privatevoice.src/internal/model/build_capabilities_windows.go
index 67de4d4..05e456b 100644
--- a/privatevoice.src/internal/model/build_capabilities_windows.go
+++ b/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
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index d3f025e..b90ea90 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/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 {
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index 0c8871d..8f38d67 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/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
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index 5da545f..641adc4 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/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
 	}
diff --git a/privatevoice.src/internal/modelselection/selection_test.go b/privatevoice.src/internal/modelselection/selection_test.go
index 718d061..fe72cad 100644
--- a/privatevoice.src/internal/modelselection/selection_test.go
+++ b/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
diff --git a/privatevoice.src/scripts/build-windows-preview.sh b/privatevoice.src/scripts/build-windows-preview.sh
index 4fe9bf1..f89f3ca 100755
--- a/privatevoice.src/scripts/build-windows-preview.sh
+++ b/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.
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index ecfb3ac..2a93b00 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/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)
 }
diff --git a/privatevoice.src/services/engine_service_test.go b/privatevoice.src/services/engine_service_test.go
index 95ebd23..9aab675 100644
--- a/privatevoice.src/services/engine_service_test.go
+++ b/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")
+	}
+}
diff --git a/privatevoice.src/services/engine_service_windows_test.go b/privatevoice.src/services/engine_service_windows_test.go
index 9700ba3..b18a37d 100644
--- a/privatevoice.src/services/engine_service_windows_test.go
+++ b/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)
+	}
+}

--
Gitblit v1.9.3