播 7 秒就断:Fish Audio 流式 WAV 的哨兵值陷阱
Fish Audio 的流式 WAV 把 RIFF 长度填成哨兵值,嵌入式端按完整文件严格校验,长语音 7 秒必断。对照上游源码才发现:原版测试只用假 WAV,从没调通过。
· 12 次阅读
前情是昨天那篇《让小机器人开口》:字幕 fallback 修好了,AES 内存故障绕过去了,小机器人终于有声音了。但新固件一上真机,长语音只播约 7 秒就断。日志很直接——播到 112640 frames 之后报 ESP_ERR_INVALID_RESPONSE。
另一条路也断了:一条 1224 字的长回复请求,TTS 接口直接返回 HTTP 400,连声音都没有。
先说 7 秒这个数。112640 帧,16-bit 单声道 44.1kHz,算下来正好约 7 秒出头。断得这么整齐,一看就是解析层的检查在掐,而不是随机的网络抖动。
挖开 Fish Audio 的 /v1/tts 返回:它是 chunked 的流式 WAV。流式传输时总长度还没定,RIFF 头和 data 块的长度字段填的是接近 0xFFFFFFFF 的哨兵值——"还没完,先占个位"的意思。
我们这边的 WAV 解码器(muse_tts_wav.c)是按"完整文件"写的严格校验:RIFF size 超过 16MB 上限,直接判 ESP_ERR_INVALID_RESPONSE。它在走到采样率检查之前就已经死了;流结束时 data 长度没归零,又再失败一次。所以长语音必断,7 秒是哨兵值撞上 16MB 上限的位置。
中间走过一段弯路,值得记一笔。本来怀疑过采样率:Fish Audio 返回的是 44.1kHz 单声道 16-bit PCM(它支持 8/16/24/32/44.1kHz,不支持 48kHz),而解码器之前只认 48kHz。改成双认之后真机还是报同样的错——证明采样率不是最先触发的检查。在已被证伪的分支上继续加码,是排障里最常见的沉没成本。
定根因靠的是横向对照,不是盯着自己的代码看:
- 上游底版(一个开源的繁体中文移植)的 WAV 解码器跟我们逐字相同,同样没处理哨兵值;
- 它的测试只用程序合成的假 WAV 文件——长度字段是写死的真实值,从没走过真实的云端流。结论很扎心:原版很可能根本没调通,bug 是从它那继承来的;
- 另一个社区实现 musechan 压根没做 TTS,只搞显示、舵机、电源这些硬件驱动,语音合成是空白,没有参考价值;
- GitHub 上 fish-audio-python 的 #26 issue 症状一模一样:严格播放器遇到流式 WAV 的哨兵长度直接失败。第三方证据闭环。
所以这 20 行容错代码,是原版作者欠的技术债,由我们来还。修法不复杂:识别 RIFF/data 的哨兵长度,data 块按 EOF 结束,同时保留累计字节上限和有效样本安全检查——容错不是放任。
顺带还挖出第二个坑:TTS 模块的发送缓冲只有 2048 字节,长语音的请求发不出去,1224 字那条就是这么 400 的。提到 8192 重新编译。但这一步还没真机复测——不能算根治。别人的配置、自己的推测,都是起点,真机是终审。上次"抄 musechan 的 mbedTLS 配置"就是被真机否掉的(两项加了没用,最后靠关硬件 AES、改软件 AES 绕过去),这个教训得吃一堑。
这一轮的产品决策几经变化:先是恢复官方字幕、关掉 CJK 字库(中文显示方框,省内存),最后拍板连字幕也关掉——只听声音,TTS 用普通话女声。新固件包已经构建好,等真机刷机验收(设备当前还停在无声的旧版)。
方法论一笔:对照开源实现和 issue 交叉验证,是给根因"定罪"的最快路径。看到一个点就停,只能修掉症状;挖到"为什么是它",才能把同一类 bug 一次清干净。