状态:v0.2 评审修订稿
所属子项目:02-P-NBL
Round:Round 1
日期:2026-06-02
关联文档:
本版根据 2 位架构师、2 位程序员、2 位 QA 对 v0.1 PRD 的评审意见修订。
Round 1 的核心定位保持不变:
不改变用户体验,把现有 SenseVoice 纳入可扩展的 ModelProfile 架构。
本版把以下事项从“建议/待确认”改为确定规则:
| 事项 | Round 1 决策 |
|---|---|
models/state.json |
本轮必须落地最小版读写;它只是索引,不能绕过真实文件校验 |
| 新下载目录 | 新下载的 SenseVoice 统一安装到 models/sensevoice-zh |
| 旧目录 | models/sensevoice 只读兼容,不自动迁移、不删除 |
SelectedModelID |
本轮加入隐藏配置,默认 sensevoice-zh,不暴露 UI |
| Wails 公开 API | 保持旧签名兼容,不要求前端改调用 |
RequiredFiles |
需要支持 allOf / anyOf,不能只用 []string |
| EngineFactory | 只接收已解析、已校验的 ResolvedModel |
| 下载器 | 必须 staging + 校验 + 安全落盘,失败不得破坏已有可用模型 |
当前 VoiceSnap 的离线识别链路基本围绕 SenseVoice 写死:
models/sensevoice。model.int8.onnx 或 model.onnx。tokens.txt 固定在 SenseVoice 目录下。但后续产品规划已经确定要支持:
| 语言版本 | 默认轻量模型 | 高级模型 |
|---|---|---|
| 中文 | SenseVoice | Qwen3-ASR |
| English | Moonshine English | Parakeet |
因此 Round 1 不是接入新模型,而是先把当前单模型架构改成可扩展的模型 Profile 架构,并确保现有中文 SenseVoice 用户体验不退化。
Round 1 完成后,系统内部应从:
写死 SenseVoice 路径和 SenseVoice config
演进为:
ModelRegistry -> ModelResolver -> ResolvedModel -> EngineFactory -> SenseVoice config
对用户来说,Round 1 交付后产品应基本无感:
明确不做:
models/sensevoice 到 models/sensevoice-zh 的自动大文件迁移。manifest.json。作为一个已经安装过 SenseVoice 的用户,我升级到 Round 1 版本后,应用应该继续识别到我的本地模型,并能正常离线识别。
验收点:
models/sensevoice 可以被识别。作为一个新用户,我第一次启动应用时,仍然按当前流程准备 SenseVoice 模型,不需要理解模型 Profile 或语言版本。
验收点:
models/sensevoice-zh。作为后续 Round 2/3/4/5 的研发人员,我可以在不重写主流程的情况下,新增 Moonshine、Parakeet、Qwen3-ASR profile,并通过 EngineFactory 生成不同 backend 的 sherpa-onnx config。
验收点:
Round 1 采用以下职责划分:
ModelRegistry
-> ModelResolver
-> ModelInstaller
-> EngineFactory
-> EngineService
| 模块 | 建议包/文件 | 职责 |
|---|---|---|
ModelRegistry |
internal/model/registry.go |
注册内置模型 profile,按 model id 查询 profile |
LanguageRegistry |
internal/model/registry.go 或独立文件 |
注册最小语言 profile,为 Round 2 做准备,不参与 UI |
ModelResolver |
internal/model/resolver.go |
根据 profile、state、新旧目录解析出可用模型路径 |
ModelValidator |
internal/model/validator.go |
校验 required files,支持 allOf / anyOf |
ModelInstallState |
internal/model/install_state.go |
读写 models/state.json |
ModelInstaller |
internal/model/downloader.go 或新文件 |
下载、解压、staging、校验、安全落盘 |
EngineFactory |
internal/engine/factory.go |
根据 ResolvedModel 生成 sherpa config 并初始化 engine |
EngineService |
services/engine_service.go |
保持 Wails public API 兼容,内部映射到默认模型 |
paths |
internal/paths/paths.go |
只提供基础路径函数,不承载模型业务判断 |
关键原则:
ModelProfile 表示一个可安装、可加载、可校验的识别模型。
建议结构:
type ModelProfile struct {
ID string
DisplayName string
BackendKind string
Tier string
SupportedLanguageIDs []string
RecommendedFor []string
Description string
ApproxSize string
DownloadURLs []string
InstallDirName string
RequiredFiles []RequiredFileRule
ProviderOrder []string
NumThreads int
}
字段说明:
| 字段 | 说明 |
|---|---|
ID |
稳定技术 ID,不随显示名变化 |
DisplayName |
用户可见名称,例如 SenseVoice |
BackendKind |
决定使用哪类 sherpa config builder |
Tier |
default 或 advanced |
SupportedLanguageIDs |
模型理论支持的语言 |
RecommendedFor |
本产品推荐该模型服务的语言 |
InstallDirName |
新安装目录名 |
RequiredFiles |
安装完整性规则 |
ProviderOrder |
例如 macOS 可为 coreml,cpu |
注意:
SupportedLanguageIDs 和 RecommendedFor。RequiredFiles []string 不够表达 SenseVoice 的二选一模型文件,因此 Round 1 必须支持结构化规则。
建议结构:
type RequiredFileRule struct {
Role string
AllOf []string
AnyOf []string
Required bool
}
规则说明:
AllOf 中的路径必须全部存在。AnyOf 中的路径至少存在一个。SenseVoice profile:
id: sensevoice-zh
displayName: SenseVoice
backendKind: sensevoice
tier: default
supportedLanguageIds: zh-CN, zh-Hans, zh-Hant, yue, en, ja, ko
recommendedFor: zh-CN
installDirName: sensevoice-zh
requiredFiles:
- role: model
anyOf:
- model.int8.onnx
- model.onnx
- role: tokens
allOf:
- tokens.txt
providerOrder:
- coreml
- cpu
numThreads: 4
模型文件优先级:
model.int8.onnx 和 model.onnx 时,优先使用 model.int8.onnx。model.onnx 时,允许使用 model.onnx。Round 1 只做最小可编译语言 profile,不做运行时语言切换。
建议结构:
type LanguageProfile struct {
ID string
DisplayName string
UILocale string
SystemMatchers []string
DefaultModelID string
UpgradeModelIDs []string
}
Round 1 至少注册:
id: zh-CN
displayName: 中文
uiLocale: zh-CN
systemMatchers: zh, zh-CN, zh-Hans, zh-Hant
defaultModelId: sensevoice-zh
upgradeModelIds: []
Round 1 不使用它做 UI,也不做系统语言自动切换。
Round 1 必须落地最小版 models/state.json。
文件位置:
models/state.json
路径字段必须存相对 models 根目录的相对路径,避免 App Support 目录变化后绝对路径失效。
建议结构:
{
"schemaVersion": 1,
"selectedModelId": "sensevoice-zh",
"installedModels": {
"sensevoice-zh": {
"installedAt": 1780120000000,
"version": "unknown",
"path": "sensevoice-zh",
"sourceDirKind": "new"
}
}
}
字段说明:
| 字段 | 说明 |
|---|---|
schemaVersion |
状态文件 schema 版本,本轮为 1 |
selectedModelId |
当前选择模型,本轮默认 sensevoice-zh |
installedModels[modelId].path |
相对 models 根目录的路径 |
installedModels[modelId].sourceDirKind |
new 或 legacy |
状态文件原则:
state.json 是索引,不是真相。state.json。state.json 不存在、为空、非法 JSON、model id 未知、路径不存在时,不得导致旧模型不可用。EngineFactory 不直接接收 ModelProfile,也不自己查路径。它只接收 ModelResolver 产出的 ResolvedModel。
建议结构:
type ModelInstallStatus string
const (
ModelNotInstalled ModelInstallStatus = "not_installed"
ModelInstalledValid ModelInstallStatus = "installed_valid"
ModelInstalledIncomplete ModelInstallStatus = "installed_incomplete"
ModelStateInvalidFallbackFound ModelInstallStatus = "state_invalid_fallback_found"
ModelStateInvalidNoFallback ModelInstallStatus = "state_invalid_no_fallback"
)
type ResolvedModel struct {
ModelID string
Profile ModelProfile
RootDir string
SourceDirKind string
Files map[string]string
BackendKind string
ProviderOrder []string
Status ModelInstallStatus
Missing []string
Problems []string
}
Files 示例:
{
"model": "/Users/ar/Library/Application Support/PrivateVoice Input/models/sensevoice-zh/model.int8.onnx",
"tokens": "/Users/ar/Library/Application Support/PrivateVoice Input/models/sensevoice-zh/tokens.txt"
}
进入 engine 初始化的前置条件:
Status == installed_valid
或
Status == state_invalid_fallback_found
其他状态不得进入 engine 初始化。
Round 1 默认当前模型为:
sensevoice-zh
路径候选顺序:
models/state.json 中 sensevoice-zh 记录的路径。models/sensevoice-zh。models/sensevoice。每个候选目录都必须经过 required files 校验。
解析算法:
models/state.json。sensevoice-zh,把其中路径作为第一候选。models/sensevoice-zh。models/sensevoice。state_invalid_fallback_found。not_installed。installed_incomplete,并列出缺失/异常项。state_invalid_no_fallback。优先级规则:
Round 1 不接入新模型,但下载器必须向通用 DownloadModel(profile) 边界靠拢,内部只实现 SenseVoice。
前端现有调用必须继续可用:
DownloadModel(primaryURL string, fallbackURL string) error
Round 1 行为:
DownloadModel(primaryURL, fallbackURL) 内部映射到默认模型 sensevoice-zh。primaryURL / fallbackURL 继续作为下载 URL 权威来源。ModelProfile.DownloadURLs 可以作为内部默认值或后续扩展,但 Round 1 不要求前端改为传 modelID。下载必须使用 staging 流程:
models/.downloads/sensevoice-zh/<run-id>/archive
models/.downloads/sensevoice-zh/<run-id>/extract
models/.staging/sensevoice-zh-<run-id>
models/sensevoice-zh
流程:
models/sensevoice-zh。models/state.json。失败处理:
models/sensevoice。models/sensevoice-zh。目标目录处理:
models/sensevoice-zh 不存在,校验通过后移动 staging 到该目录。models/sensevoice-zh。models/sensevoice-zh 前应先 rename 到 backup 临时名;替换失败时必须恢复 backup。models/sensevoice。现有调用入口必须保留:
func New() (Engine, error)
Round 1 中:
engine.New() 内部使用当前 selected model。SelectedModelID 为空,默认 sensevoice-zh。engine.New() 调用 Resolver 获取 ResolvedModel。建议新增内部入口:
func NewWithModelID(modelID string) (Engine, error)
func NewWithResolvedModel(resolved model.ResolvedModel) (Engine, error)
平台实现应从无参:
newPlatformEngine()
改为接收已解析路径:
newPlatformEngine(resolved model.ResolvedModel)
Round 1 只需要支持:
backendKind = sensevoice
生成现有 sherpa-onnx config:
config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
config.ModelConfig.Tokens = resolved.Files["tokens"]
config.ModelConfig.NumThreads = resolved.Profile.NumThreads
config.ModelConfig.Provider = provider
provider 顺序:
coreml -> cpu
用户可见硬件信息保持现状风格:
SenseVoice · CoreML (Apple Neural Engine)
SenseVoice · CPU
不要向用户展示 sensevoice-zh 这种内部 profile id。
如果 sherpa-onnx 初始化失败:
engine:status 事件返回错误状态。Round 1 在 config.json 中新增隐藏字段:
type Config struct {
// existing fields...
SelectedModelID string `json:"SelectedModelID,omitempty"`
}
兼容规则:
SelectedModelID 时,加载后默认 sensevoice-zh。SelectedModelID 为空时,默认 sensevoice-zh。SelectedModelID 是未知 model id 时,回退 sensevoice-zh,记录日志。ModelDownloadUrl 和 FallbackModelDownloadUrl 本轮继续保留。LanguageMode / LanguageID 的运行时逻辑;这些留到 Round 2。Round 1 不要求前端改 UI,也不要求前端切换到新模型 API。
必须保持以下 Wails public API:
EngineService.ModelExists() bool
EngineService.DownloadModel(primaryURL string, fallbackURL string) error
ConfigService.GetConfig()
语义要求:
ModelExists() 返回“当前 selected/default model 是否完整可用”。ModelExists() 必须返回 false。DownloadModel(primaryURL, fallbackURL) 继续触发现有下载进度事件。ConfigService.GetConfig() 继续返回 ModelDownloadUrl 和 FallbackModelDownloadUrl。必须保持以下事件契约:
model:download-progress
payload:
{
"percent": 42.5,
"downloaded": 123456,
"total": 789012
}
engine:status
状态语义继续支持:
loading
ready
need_model
error
ready 时继续带:
{
"hardwareInfo": "SenseVoice · CoreML (Apple Neural Engine)"
}
前端现有链路必须保持:
App 启动
-> EngineService.ModelExists()
-> need_model
-> OnboardingView
-> EngineService.DownloadModel(primaryURL, fallbackURL)
-> model:download-progress
-> engine:status ready
Round 1 原则上不新增 UI。
允许的 UI 变化:
不允许的 UI 变化:
Round 1 不改变隐私承诺:
允许联网的场景:
不允许联网的场景:
models/sensevoice 且文件完整的用户,升级后不需要重新下载模型。models 目录时,现有 Onboarding 流程仍然可用。models/sensevoice-zh。models/state.json 写入 selectedModelId = sensevoice-zh。state.json 不存在时,可以通过目录探测找到模型。state.json 非法 JSON 时,不影响完整模型加载。state.json 指向不存在目录时,应 fallback 到新目录和旧目录。state.json 指向不完整目录时,应 fallback 到其他完整目录。tokens.txt 时,模型不可用。tokens.txt 时,模型不可用。model.int8.onnx 和 model.onnx 都不存在时,模型不可用。model.int8.onnx 和 model.onnx 时,优先使用 model.int8.onnx。EngineService.ModelExists() 签名不变。EngineService.DownloadModel(primaryURL, fallbackURL) 签名不变。ConfigService.GetConfig() 继续返回旧下载 URL 字段。model:download-progress payload 不变。engine:status 事件语义不变。至少验证:
覆盖:
ModelRegistry 根据 ID 获取 profile。LanguageRegistry 至少包含 zh-CN。allOf 规则。anyOf 规则。model.int8.onnx 和 model.onnx 时选择 int8。model.onnx 时通过。tokens.txt 时失败。not_installed。SelectedModelID 缺失时默认 sensevoice-zh。SelectedModelID 回退 sensevoice-zh。覆盖:
models/sensevoice 目录初始化。models/sensevoice-zh 目录初始化。state.json 指向旧目录时初始化。state.json 损坏但新目录完整时初始化。models/sensevoice-zh 和 state.json。建议补充固定测试数据:
privatevoice.src/testdata/asr/zh_short.wav
建议音频内容:
请帮我测试 SenseVoice 离线输入。
自动化识别验收不强制标点完全一致,至少要求包含关键词:
测试
离线
输入
用户词库 fixture:
{
"replacements": [
{
"from": "sins voice",
"to": "SenseVoice"
}
]
}
历史 fixture:
{
"retentionDays": 0,
"entries": []
}
| 场景 | 前置数据 | 网络 | 重点验收 |
|---|---|---|---|
| 老用户旧目录 | 只有 models/sensevoice,文件完整 |
断网 | 不重新下载,能初始化和识别 |
| 新用户无模型 | 无 models 目录 |
联网 | 下载到 models/sensevoice-zh,校验通过 |
| 新目录优先 | 新旧目录都完整,无 state | 断网 | 使用 models/sensevoice-zh |
| state 指旧目录 | state path=sensevoice,旧目录完整 |
断网 | 使用旧目录且不迁移 |
| state 损坏 | state.json 非法 JSON,新/旧目录完整 |
断网 | fallback 到目录探测 |
| state 指坏目录 | state 指向不完整目录,旧目录完整 | 断网 | fallback 到旧目录 |
| 目录不完整 | 只有 tokens.txt 或只有模型文件 |
断网 | 显示模型不可用,不初始化 engine |
| 两种模型文件 | 同时有 model.int8.onnx 和 model.onnx |
断网 | 使用 int8 |
| 下载失败 | 已有旧模型,下载 URL 失败 | 联网/断网 | 旧模型仍可用,旧目录不被破坏 |
| 解压失败 | 已有旧模型,压缩包损坏 | 联网 | 旧模型仍可用,不写入完整 state |
| 回归主流程 | 模型完整、词库和历史已有数据 | 断网 | 录音、上屏、replacement、历史写入正常 |
| UI 回归 | 任意模型状态 | 任意 | 不出现新模型/语言选择入口 |
| 权限回归 | 麦克风权限未授权/已授权 | 任意 | 权限页、首页、词库页、历史页可用 |
Round 1 发布测试包前,必须完成:
发布记录至少保留:
| 风险 | 影响 | 对策 |
|---|---|---|
| 抽象过度导致 Round 1 变大 | 延误后续 Round | 只实现 sensevoice backend,接口预留但不接新模型 |
| 旧目录兼容遗漏 | 老用户升级后模型失效 | models/sensevoice 是必须兼容路径,纳入自动化和手工测试 |
| state 和真实文件不一致 | 误判模型可用性 | required files 校验优先于 state |
| 下载器覆盖旧模型 | 老用户可用模型被破坏 | staging + 校验 + 安全落盘,禁止删除完整旧目录 |
| Wails API 改签名 | 前端运行时断裂 | Round 1 保持旧 public API |
| EngineFactory 抽象不彻底 | 后续接 Moonshine/Parakeet 返工 | EngineFactory 只接收 ResolvedModel |
| 不完整模型进入初始化 | 崩溃或卡死 | Resolver/Validator 阻止进入 engine |
| 识别时触发联网 | 破坏离线承诺 | 已有模型时禁止冷启动/识别阶段请求远程 |
Round 1 结束时应交付:
ModelProfile / ModelRegistry。LanguageProfile / LanguageRegistry 最小实现。RequiredFileRule,支持 allOf / anyOf。ModelInstallState,读写 models/state.json。ModelResolver,支持 state、新目录、旧目录 fallback。ResolvedModel。sensevoice-zh profile。EngineFactory 初步实现。SelectedModelID 隐藏配置,默认 sensevoice-zh。以下事项不进入 Round 1:
manifest.json。