跳到正文
博客园· 张善友·· 2 小时前AI 评分34

speech-to-speech-dotnet 用一条命令搞定模型下载与启动

一周时间,把「还得东拼西凑」改成「一条命令开聊」 - 张善友

AI 导读

speech-to-speech-dotnet 作者用一周时间把「东拼西凑」的模型准备流程改成一条命令:新增固定 commit 的模型清单 tools/models/models.tsv 和跨平台下载脚本,按 SHA-256 与字节数逐文件校验并支持断点续传。

正文

上周发的那篇《有人用纯 C# 写了个语音识别引擎,README 比代码还好看》,底下有条评论我看了好几遍:

感觉还不是开箱即用啊,还得东拼西凑。

这条评论说的是 FunAsrNet,但我看完脸上有点挂不住——因为我自己的 speech-to-speech-dotnet 也好不到哪去。

先交代一下背景。这个项目是把 huggingface/speech-to-speech 用 C# 做的行为等价重写:Silero VAD 切话、本地 STT 转写、GGUF 小模型生成、Qwen3-TTS 出声,整条语音智能体管线跑在自己机器上,不要 API key,对外讲 OpenAI Realtime 协议,写好的 Realtime 应用把地址指过来就能用。

管线是通的,协议覆盖 26/26,移植差异 75 条逐笔登记在账。但你要真跑起来,得先去 Hugging Face 上把五份模型权重一份一份找齐:VAD 一个仓库、Whisper 一个仓库、GGUF 一个仓库、TTS 二十几个文件、Smart Turn 又一个仓库。README 里我当年写的是「不下载,只指路」。

speech-to-speech-dotnet 架构图

说得客气是指路,说得直接点,就是让读者自己东拼西凑。

那条评论之后我花了一周把这件事了结了。这篇文章记一下过程,也顺带说说改完之后是什么体验。

先把「凑」这件事变成一张清单

模型来源分散是客观事实,消不掉,但可以不让用户感知到。

做法是在仓库里放一份钉死的清单 tools/models/models.tsv,每行四列:SHA-256、字节数、相对路径、带固定 commit 的下载地址。注意是固定 commit,运行时永远不解析 main 或 master——上游哪天换个权重,你的管线行为不会跟着漂移。

然后两个下载脚本,download-models.ps1 和 download-models.sh,干三件事:按清单下载、逐文件校验哈希和大小、断点续传。依赖只有 curl 和一个 sha256 工具,不需要 Python,不需要 Hugging Face CLI,不需要任何 key。Windows 上还修了个老坑:PowerShell 里 curl 默认是 Invoke-WebRequest 的别名,得显式解析 curl.exe,不然参数全是错的。

想省流量可以按组下:

bash ./download-models.sh --groups vad,sense-voice

这部分里跨平台下载器的基础是社区 PR #1(@vovilaljn)贡献的,致谢。

local 一条命令,但坑在 listen 之前

原来的 local 命令号称一条命令开聊,实际有个阴间行为:缺模型的时候服务照样起,客户端也照样连,就是永远收不到事件。看起来像客户端 bug,其实是服务端在连接构建期悄悄拒绝了。这笔账我在差异账本里登记为 E003,登记完就一直没修。

现在 local 在开始监听之前会先算一笔账:你选了哪些本地后端、每个后端需要哪组模型、哪些文件缺失或哈希不对,然后只拉这几组,校验全部通过才绑端口。自定义模型路径始终归用户自己管,缺了就在监听前明确报错,绝不静默拿默认值顶替。

另外一个细节:模型文件几个 GB,每次启动都重新哈希一遍不现实。校验通过的文件会把元数据记进 models/.model-verification-cache.json,下次启动对得上就直接跳过。这个缓存也是这轮加的。

Docker 不再要宿主机伺候

原来的 compose 文件有个我自己都不好意思的设计:LLM 指向 host.docker.internal:8080,也就是你得在宿主机上另外跑一个 llama.cpp 服务,容器才能干活。这叫什么容器化。

现在的形态:entrypoint 在启动服务前先跑下载器,五组默认模型(约 6.84 GB,全量清单 45 个文件 7.1 GB)落进 named volume,校验过了才 exec 启动 serve,下载失败容器直接非零退出,不起一个缺模型的服务。LLM 也改回容器内的本地 GGUF。首次启动等下载,之后重启复用卷,已验证的文件不重复拉。

docker compose up --build

就这一条。

serve 补票,只差一行

上面这些做完后还剩一个逻辑漏洞:local 会准备模型,单独跑 serve 还是老样子。修法小得可笑,Program.cs 里一行:

if (line.Command is "local" or "serve")

复用同一套准备流程,行为完全一致。改完记得把账本 E003 的口径翻过来——这种「代码改了、文档忘了」的事我自己抓到过别人好几次,这回轮到自己头上,README 的已知差异表、differences.jsonl、单元测试计数(现在 713 例)一起同步掉。

顺手把默认 STT 换了

既然都在动模型清单,顺带解决另一个心病:默认的 whisper-base 识别中文只能说勉强。这轮把默认 STT 切成 SenseVoice(sherpa-onnx 的多语种导出,int8 约 228.5 MiB),中文质量好一截,Paraformer 也一并收进清单。想换回 Whisper 加一句 --stt whisper 就行。

这里多说一句许可:SenseVoice 的权重受 FunASR Model License 约束,不是 Apache-2.0,商用之前自己核对。清单里每个模型许可各不相同,下载不代表授权,README 里这句丑话我保留了。

国内网络的最后一步

清单里 45 个文件,大头都在 Hugging Face 上。国内网络环境里「一键下载」能不能成立,取决于这一步。

做法是不改清单,加一个环境变量:

S2S_MODEL_MIRROR=https://hf-mirror.com bash ./download-models.sh

设了之后,所有 huggingface.co 的地址先走镜像,镜像拉不动自动回落源站,断点续传两边都保留,ModelScope 的地址不受影响。镜像地址完全是用户侧配置,清单本身的权威来源不变。

改完之后

现在的上手路径是:

git clone https://github.com/geffzhang/speech-to-speech-dotnet.git
cd speech-to-speech-dotnet
dotnet build SpeechToSpeech.slnx
dotnet run --project src/SpeechToSpeech.Cli -- local

local 自己下模型、自己校验、自己起服务、自己开麦克风。第一条命令到开口说话之间,没有任何一步需要你去浏览器里找权重。

还没做到的也交代一下:NuGet 不打算发——这个项目是行为等价重写,定位不是对外包面,账本里记了;Qwen3-TTS 在 CPU 上是整句合成,被打断那一轮还是会合成完,这笔 D001 的账还挂着;真人麦克风的听感没系统测过。另外刚发现下载脚本的帮助里镜像说明打印了两遍,下轮修。

回到开头那条评论。「东拼西凑」这个事实没有消失——45 个文件、四个上游组织、两家托管平台,依然分散。区别只是这些分散现在由一张清单和两个脚本管着,用户不用知道了。

基础设施的价值就是这样:做好了,没人知道它存在。

感谢那位留言的读者。话糙,但指的方向是对的。

相关链接

来源:博客园 · cnblogs.com