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