火种说|VoxWeft 踩坑实录:一个端侧同传项目的开发日志
在端侧AI生态里,真正让技术落地的,不是宏大的叙事,而是一个个具体的开发者——学生、独立开发者,或者是行业里“懂技术也懂痛点”的人。他们用面壁智能的端侧模型,在自己熟悉的领域里,做出了令人惊喜的尝试。
这一期的火种是一位独立开发者。他站在 VoxCPM2 的肩膀上,以这款 2B 参数多语言 TTS 模型为语音生成核心,在 Apple Silicon 上搭起了一套本地运行的实时同传系统——VoxWeft。
换句话说,一台苹果电脑就能在本地完成从“听到”到“说出”的全过程。所有语音识别、翻译和语音合成,都在你自己的设备上运行。
VoxWeft 将麦克风输入、语音分段、ASR、翻译、流式 TTS 和播放串成一条完整链路。其中,VoxCPM2 支持 30 种语言、48kHz 输出、语音克隆和语音设计,并通过 MLX 以 4-bit 量化运行。在 M3 Ultra 上,流式 TTS 预热后的首次出声延迟可低至 124–168 毫秒;而在 M2 开发机上,经过系统资源优化,从语音分段提交到翻译语音出声,端到端延迟约为 2.5 秒。
本文含视频,请前往微信公众号原文观看。
现在,VoxWeft 的代码、开发日志和相关配置都已经开源。
但今天我们不只讲 VoxWeft 有多厉害,我们一起来翻开他写下的那份 DEVLOG。
01.
先讲一个没人发现的事
2026 年 9 月,VoxWeft 在展览现场运行时,藏着一个始终没有被发现的问题。
直到项目开源后的代码审查,开发者才发现:ASR 桥接层一直在把 WAV 文件头当作音频数据解码。
每段音频都以 RIFF WAV 格式传入,但桥接层直接将整个文件按 int16 解析,导致 44 字节的文件头变成了 22 个音频采样点,振幅最高达到 ±0.98。相当于每段 ASR 输入的开头,都混入了约 1.4 毫秒的高振幅噪声。这个问题影响了展览期间所有语言的 ASR 输入。
奇怪的是,语音识别依然能够正常工作。开发者在日志中解释,模型对这类短暂噪声具有一定的鲁棒性,正因如此,问题始终没有引起注意。直到开源后的代码审查,它才被真正发现。
02.
一份“反直觉”的开发日志
真正让这份开发日志有价值的,是它的写法。
开发者给自己定了一条规矩:每条记录都按“症状→根本原因→修复→代价”写。 更特别的是,他把“被证伪的假设”和“尚未解释的现象”当成正式内容来记录。日志里专门有一节叫“Open - measured but not yet explained”(已观测到但尚未解释的现象)。
用他的话说:记录走不通的路和记录修复方案同等重要,因为这能防止后来者重复走入同样的死胡同。
03.
加了一把锁,整个系统反而不动了
最初,为了避免 TTS(语音合成)和 ASR(语音识别)同时推理产生冲突,开发者想了一个很直觉的办法:加一把锁,让它们排队执行。
结果,系统反而卡死了。桥接进程活着,CPU 约 50%,/health 正常返回,/ready 一直超时,日志零输出。TTS 和 ASR 都没了响应。
问题出在流式 TTS 处理器是一个 async generator。它获取锁,yield 一个音频块,然后在客户端读取时仍然持有锁挂起。ASR 请求到来,永远等在同一把锁上。事件循环没东西可跑。
“这个 bug 被误诊了一天,”开发者表示,“因为死锁恰好被一个在流式会话期间到达的请求触发,我们以为是‘某段中文音频有毒’。”
修复方案:直接删掉锁。 单线程执行器已经提供了互斥,await executor.submit(...) 在块之间释放控制权。
代价:这个问题被误诊了整整一天。由于死锁恰好在处理某段中文音频时发生,开发者一度以为是音频本身触发了异常。
04.
两个线程同时构造同一个 MLX 模型
症状:进程消失,退出码-9。launchd 重启,下一个预热请求到来时再次消失。
原因是 VoxWeft 启动时会在后台预热模型,但这时 uvicorn 已经开始接受请求。如果真实请求在预热期间到达,两条路径同时进入懒加载代码,并发构造了两个 MLX 模型实例。MLX 在这里就不是线程安全的,经不起两个线程同时构造模型,进程会直接消失,退出码 -9。
原来的防护是 asyncio.Lock。但 asyncio.Lock 是 per-event-loop 的,跨线程完全不起作用。
修复也很直接:换成 threading.Lock,预热和请求走同一套 ensure*_locked() 逻辑,确保任何时候都只有一个线程能创建模型。
05.
VoxCPM2 的 temperature
不是采样温度
做 TTS 的人有个直觉:调低 temperature 减少波动。
在 VoxCPM2 上,t = 0.01 时,生成永远不发出停止信号,一直跑到 80 秒上限,80% 的帧是静音。
根因:VoxCPM2 的 temperature 不是 softmax 采样温度,它缩放的是 flow-matching ODE 的初始噪声(z = randn(...) * temperature)。趋近于零时,CFM 没有随机性可用,不收敛。
修复:保持官方默认值 1.0。
日志里有一句值得每个做模型调参的人记住的话:“这个旋钮看起来像是治抖动的,但只会让事情更糟。 ”
06.
7 个样本差点得出一个错误结论
NLLB-1.3B 在短片段上输出重复。有人提出:模型太小了,换 3.3B 就好了。
第一次测试:7 个人工编写的样本,1 个重复。 结论:“不是模型大小的问题,是解码参数的问题。”
看起来很合理。但样本太小了。
第二次测试:28 条真实 ASR 生产音频片段。1.3B 和 3.3B 都是 0/28 重复。
人们看到的重复输出,实际是另一个 bug——配置排序错误导致错误语言的文本被喂进了翻译器。
“7 个样本的测试只能告诉你:一个假设不是明显错误的。仅此而已。”
07.
删掉一行“冗余”代码,demo 崩了
stopFlag = false。看起来像是残留代码。删掉。
结果:播放器任务立即退出,生成计数器每次按键推进 2-3 而不是 1。demo 炸了。
原来那个 false 是重置。删掉它,标志位保留上一次的 true,任务一看“已经停了”直接退出。
“如果某个标志重置看起来是冗余的,先找到谁在读它,再删。”
08.
把踩过的坑,留在身后
这些条目都是开发者在 M2 / M3 Ultra 上真实遇到的,这也是这份日志最稀缺的品质。
当你看到“一个温度参数差点毁掉整个 TTS 生成”,你会想起自己也被某个“看起来应该有效”的直觉坑过;当你看到“7 个样本得出错误结论”,你会想起那些在数据不足时依然自信的评审会;当你看到“删掉一行看似冗余的代码导致 demo 崩溃”,你会想起自己的 git revert 记录。
云端模型跟用户的距离只有一个聊天窗口,但端侧模型要在真实世界里闯过重重关卡。
在开发的过程中,作者也发现继承自参考实现的 warmup_patches=2 反而引入了 470 毫秒额外延迟,并且造成了它本要消除的那个爆音。
“我们继承了一个默认值,为之写了一套未经测量的机制文档。测量只花了 20 分钟。” 他想说的是:很多“最佳实践”从未被验证过,很多“众所周知”的坑,踩下去才发现方向反了。
而这些,只有真正动手做过的人才知道。开发者选择把它们一关一关记录下来再开源,让后来者不必再走一遍同样的死胡同。
这大概就是火种的意义——不是自己跑得多快,而是把自己踩过的坑,变成后来者的路标。
此外,开源项目里还有一份 AGENT-SETUP.md。用户让 Agent 把项目 clone 下来后,照着它就能一步步启动项目:一个正确的用例,LLM 都能写出来;但失败条件,只能从实测中得出。
相关链接
VoxWeft 开源项目:
https://github.com/HenryZ838978/VoxWeft
开发者踩坑实录:
https://github.com/HenryZ838978/VoxWeft/blob/main/docs/DEVLOG.md
用 AGENT 快速开始:
https://github.com/HenryZ838978/VoxWeft/blob/main/docs/AGENT-SETUP.md
VoxCPM2:
https://huggingface.co/openbmb/VoxCPM2