一次从 JSON + Base64 到二进制 PCM、从重复上传播放参考到回传播放游标、从“感觉是网络问题”到三层日志对齐的工程复盘。
本文基于真实项目截至 2026 年 9 月 4 日的实现。协议字节数来自代码,运行指标来自开发日志。不同阶段的测试不是同一环境下的严格 A/B 实验;本文不会把多项改动后的体验改善全部归功于 Base64 的移除,也不会把最新的缓冲策略写成已经通过长期验证。
1. 起点:一个会说话的玩偶,为什么需要重构音频协议
我们的项目是一套 AI 陪伴玩偶系统:设备使用 ESP32-S3,驱动两块 240×240 眼睛屏,同时运行 GIF 表情、麦克风采集、流式播放、Wi-Fi、WebSocket 和 MQTT。板上音频方案为 ES8311 + ES7210,实际使用单麦。
服务端分成两部分:NestJS 负责设备鉴权、用户、智能体、会话和连接管理;FastAPI 负责 AEC、VAD、ASR、LLM、TTS 等语音处理。App 也接入这套服务,但本文的数据主要来自设备端。
早期,系统已经能够完成一问一答。真正暴露问题的,是我们希望设备在播放回复时仍然听见用户,并支持用户打断。
原本依次执行的任务变成同时运行:
麦克风持续采集 → 上传 → 云端处理
云端 TTS 下发 → 接收缓冲 → 扬声器播放
眼睛 GIF 刷新、MQTT 心跳与设备状态上报同时进行
用户描述问题很简单:“前半段很卡”“说话丢字”“有时一段播完,下一段才被识别”。但这些现象可能分别来自采集积压、发送锁竞争、下行抖动、播放欠载和 AEC 参考失配。
我们最初想做的是“增加全双工”,最后发现,必须先重新定义这条实时链路。
2. Base64 不是 WebSocket 的要求
早期音频路径使用 JSON 包装,把 PCM 编码成 Base64,再附带请求标识、序号等字段发送。这种方式适合快速打通接口,也方便打印和调试,但音频每秒持续发送时,代价会被反复支付。
WebSocket 原生支持文本消息和二进制消息,并不要求音频转成 Base64。文本消息与二进制消息的区分由协议 opcode 表达。参见 RFC 6455:Data Frames。
因此,我们把问题拆成两个边界:
- 设备与自有后端之间的协议,由我们决定,没有必要为了 JSON 而编码音频。
- 后端与第三方 ASR/TTS 之间的协议,由提供商接口决定;如果某家接口要求 Base64,就在对应适配层转换。
我们没有把设备上的 JSON 音频封装原样搬到 Nest 再做一遍,而是让自有实时音频路径保持二进制。
2.1 先计算一个确定的开销
设备上传格式为 PCM S16LE、16 kHz、单声道,每帧 100 ms:
16000 samples/s × 2 bytes/sample × 1 channel = 32000 B/s
每 100 ms = 1600 samples = 3200 B
按照 Base64 的编码规则,长度为 n 的数据编码后通常占 4 × ceil(n / 3) 字节。本例中,3200 字节变成 4268 字节。编码规则见 RFC 4648:Base64 Encoding。
| 应用层表示 | 每 100 ms 字节数 | 每秒字节数 |
|---|---|---|
| 原始 MIC PCM | 3200 | 32000 |
| Base64 音频字符串,不含 JSON 外壳 | 4268 | 42680 |
| 当前普通 MIC 二进制帧,36 字节头 | 3236 | 32360 |
| 当前播放期间的二进制帧,64 字节头 | 3264 | 32640 |
普通 MIC 二进制帧相较“仅 Base64 音频字符串”已经少约 24.2%。JSON 字段名、UUID 文本和时间戳还未计入旧方案,因此这里是一个保守的应用层字节数比较。
这不是线上的总流量:WebSocket 头、客户端掩码、TCP/IP,以及使用 WSS 时的 TLS 开销,都需要另算。
也不能把“字节减少 24.2%”写成“CPU 减少 24.2%”。我们没有保留同负载下的 Base64 编码 CPU profile。能够确定的是:设备不再需要为每块 PCM 生成 Base64 字符串和 JSON 文本,链路中的相关解析和临时字符串开销也随之减少。
3. 不是删除所有元数据,而是分开控制消息和音频数据
重构中一个重要判断是:JSON 不是问题本身,把大量 PCM 装进 JSON 才是不必要的工作。
我们保留两类消息:
| 消息类别 | 表示方式 | 项目中的例子 |
|---|---|---|
| 低频控制和业务事件 | JSON 文本消息 | audio_start、audio_start_ack、speech_start、tts_stream_start、tts_stream_end |
| 高频实时音频 | WebSocket 二进制消息 | EYUP/1 上行、EYAU/1 下行 |
audio_start 负责声明采样率、声道、帧时长和协议版本。设备等待 audio_start_ack,再按协商结果上传音频。身份认证仍然在连接握手和后端会话中完成,不需要在每个音频块里重复发送凭证。
但流标识、序号、样本位置不能随意删掉。它们回答的是三个不同问题:
- 这块音频属于哪条流或哪一轮回复?
- 中间是否跳过了应用层音频帧?
- 这块音频位于哪段媒体时间线上?
这些信息尤其影响打断、重连和 AEC。二进制协议的目标是用更紧凑、稳定的表示保留它们,而不是把音频变成无法识别来源的一串字节。
4. EYUP/1:上行到底发送了什么
当前普通麦克风上行使用 36 字节头:
| 偏移 | 长度 | 字段 |
|---|---|---|
| 0 | 4 | Magic:EYUP |
| 4 | 1 | 版本:1 |
| 5 | 1 | 帧类型 |
| 6 | 2 | 头长度 |
| 8 | 16 | Stream UUID |
| 24 | 4 | Sequence |
| 28 | 8 | Sample clock |
| 36 | 可变 | MIC PCM S16LE |
协议头中的多字节整数使用大端序,PCM 样本保持小端序。这两个规则需要明确区分,不能用本地 C 结构体直接拷贝作为跨语言协议。
播放期间,type 3 帧将头扩展到 64 字节:
| 偏移 | 长度 | 扩展字段 |
|---|---|---|
| 36 | 4 | 播放参考序号 |
| 40 | 8 | 播放参考样本游标 |
| 48 | 16 | 对应 TTS 回复的 UUID |
| 64 | 3200 | 仍然只有一份 MIC PCM |
这里必须特别强调:当前 type 3 虽然在代码里仍称为 duplex 帧,但它并不携带 MIC 和 reference 两份 PCM。 它同时描述采集流和播放参考时间线,负载中只有麦克风音频。
另外,当前 MIC sample clock 随发送的音频块推进,不是硬件 ADC 的绝对采样时间戳。上传队列主动丢弃旧块时,仅靠这个字段不能完整恢复采集缺口,还要结合采集侧的丢帧和排队统计。这是当前协议语义的边界,而不是可以忽略的小细节。
5. AEC 参考链路的演化:云端已经有的数据,不必再上传一次
为了支持云端 AEC,我们曾尝试把麦克风 PCM 和播放参考 PCM 都传给云端。
它很直观:两路 16 kHz 单声道音频各占 32 KB/s,组合后原始负载达到 64 KB/s,还会增加发送频率、队列和调度负担。开发过程中的固件注释也记录过一个历史问题:约 6.4 KB 的双路帧超过当时 4096 字节 WebSocket I/O 缓冲,写入被拆分;后来将缓冲调整到 8192 字节。
这是历史排查记录,不是 WebSocket 在 ESP32 上只能达到某个固定帧率的通用结论。更根本的问题是:我们为什么要重新上传云端刚刚生成的 TTS 音频?
最终改成:
Python 生成 TTS PCM ─────→ 缓存对应回复音频
│ │
↓ │
Nest 二进制转发 │
↓ │
ESP 播放缓冲 → I2S │
│ │
└─ 播放游标 + 回复 UUID ───┤
↓
MIC PCM ─────────────────────→ 云端选取参考、对齐并进行 AEC
普通 MIC 帧是 3236 字节,播放期间是 3264 字节。也就是说,参考时间线相较普通上行只增加:
(64 - 36) × 10 = 280 B/s
280 / 32000 = 0.875%
这与“两路 PCM 让上行负载接近翻倍”有本质区别。
但游标也不等于扬声器在空气中实际发声的精确时间。I2S/DMA、Codec、播放队列和声学传播仍存在延迟;云端还需要完成参考对齐。重复上传问题被解决,不代表 AEC 的全部时序问题已经消失。
6. EYAU/1:让回复音频可识别、可排队、可丢弃
下行使用 EYAU/1:28 字节头加 PCM 负载。头包含 magic、版本、flags、头长度、16 字节 request UUID 和 4 字节序号。
当前下行音频为 24 kHz、S16LE、单声道,原始播放速率是 48,000 B/s。Python 通常按最多 4800 字节切块,即每块最多约 100 ms;上游块的尾部可能不足这个长度,所以不能假设每一帧都是完整 100 ms。
设备为什么需要 request UUID?因为用户说新一句话时,旧回复的音频可能还在网络和队列里。
本项目会在新一轮 speech_start 后更新预期回复标识,停止旧回复,并拒绝不属于当前请求的 PCM。相应的 tts_stream_end 和结果消息也要校验请求归属,避免旧请求的结束事件关闭新播放。
这不是音频压缩,而是流的生命周期管理。只把 JSON 改成字节流,却没有旧回复隔离,依然会出现串音、补播旧内容和状态错乱。
7. 接收回调不能直接变成播放器
当前 Nest 对音频的核心操作仍然很简单:
// 示意:不重新编码,不把 PCM 转成 JSON。
deviceSocket.send(data, { binary: true });
ESP 的 WebSocket 回调负责组装完整消息、校验协议头与 request UUID,再复制 PCM 到 PSRAM 并入队。真正的 I2S 写入由独立播放任务处理。
为什么要分开?一块 100 ms 音频按实时速率播放,本来就会占用相应的输出时间。如果网络回调同步等待播放完成,回调本身就会阻塞后续数据和控制消息。
当前实现还不是零拷贝:接收组帧和播放入队之间仍存在一次 PCM 复制。它优先解决所有权和生命周期问题,不应被包装成“彻底没有内存开销”。
上下行队列也采用不同策略:
- 上行实时队列只有 2 帧容量,满时尝试丢弃最旧音频,防止越积越久。这是在持续过载时保鲜,不是无损保证。
- 下行播放队列有 128 个描述符容量,音频数据放 PSRAM,尽量吸收 TTS 的突发输出。随意丢弃一个下行 PCM 块,会直接造成用户听见的缺字。
同样叫“队列满”,上行和下行不能机械地采用同一种处理方式。
8. 二进制改完,为什么仍会卡顿
二进制重构后,开发测试中的卡顿明显缓解,但后续仍然出现欠载。它提醒我们:减少编码和复制开销,不会自动消除公网抖动。
一次 9 月 3 日的 Python 日志显示:
first_pcm=1478ms
bufferedBytes=75582 bufferedMs=1574 leadMs=1200
maxUpstreamGapMs=40
对应设备却报告:
PCM local prebuffer: queued=4 target=14 wait=1200 ms
PCM underrun: count=1 ... queued=0
服务端有约 1.57 秒音频,不等于设备已经有约 1.57 秒音频。
这里还有一个真实的策略问题:原来的自适应目标虽然能提高到 14 或 16 帧,但最大等待时间固定为 1200 ms。时间一到,即使只有 4 帧,也开始播放。目标变大,启动条件却没有真正遵守它。
因此,继续无限增加服务端缓存并不能回答问题。我们需要知道数据究竟在哪一段变慢。
9. 三层日志对齐:完整到达,不代表及时到达
我们增加了三层观测:Python 记录 TTS 供给及下发节奏,Nest 记录收到并转发二进制帧的时间、Socket 缓冲与发送回调耗时,ESP 记录完整帧进入应用层的时间和播放水位。
下面是 2026 年 9 月 3 日 18:53 左右、同一次设备连接内三轮对话的数据。ESP 日志没有打印每个 requestId,我们通过轮次顺序、帧数和字节数交叉匹配 Nest 请求。
| 指标 | 第一轮 | 第二轮 | 第三轮 |
|---|---|---|---|
| 帧数 | 33 | 39 | 87 |
| PCM 字节数 | 152354 | 171910 | 383320 |
| 音频时长,按 48000 B/s 推算 | 3.174 s | 3.581 s | 7.986 s |
| Nest 最大帧间隔 | 110 ms | 110 ms | 111 ms |
| ESP 最大完整帧接收间隔 | 400 ms | 504 ms | 697 ms |
| ESP 首帧距收到流开始事件 | 810 ms | 327 ms | 100 ms |
| ESP 实际预缓冲等待 | 1320 ms | 1020 ms | 630 ms |
| 开播时缓存音频 | 800 ms | 800 ms | 800 ms |
| 欠载次数 | 0 | 0 | 7 |
Nest 三轮的 maxBuffered 均为 0,maxSendCallbackMs 均为 1 ms,汇总时没有未完成的发送回调。至少在这些观测点,没有出现 Node 用户态发送队列明显积压的证据。
更有价值的是字节核对。第三轮 Nest 统计了 385756 字节,设备记录 383320 字节,看起来像少了数据,但两端统计口径不同:
385756 - 87 × 28 = 383320
第一、第二轮也完全一致:
153278 - 33 × 28 = 152354
173002 - 39 × 28 = 171910
Nest 计入了 EYAU 应用协议头,ESP 只统计 PCM。这三轮中,帧数和负载字节数匹配,没有观察到应用层 PCM 丢失;音频最终到齐了,却可能在该播放时还没有到。
第三轮尤为明显:Nest 从首帧到末帧的转发跨度是 6694 ms,ESP 对应接收跨度是 9144 ms,交付过程在下游被拉长了约 2450 ms。这里比较的是各自的时间间隔,不需要假定两台机器墙上时钟完全同步。
9.1 能确定的边界,和还不能确定的根因
发送回调在 1 ms 内完成,不代表远端 ESP 已经收到数据。它不能证明内核、代理或隧道中没有积压。
当前测试经过公网穿透,Nest 看见的是本地代理连接。因此,证据把问题范围缩小到了 Nest 本地写出之后、ESP 完整消息回调之前,候选包括:公网穿透、网络传输、Wi-Fi、设备 TCP 接收和任务调度。
我们不能仅凭这些日志断言“必然是 natapp 限速”,也不能完全排除设备接收调度。更严格的区分需要局域网直连对照、同一音频重放或 TCP 级观测。
9.2 一个必须修正的统计口径
第三轮日志还有:
underruns=7 silence=140 ms
这个 140 ms 表示播放器按每次 20 ms 累计的主动静音样本量,不是完整的墙钟停顿时长。欠载后播放器还可能等待队列,等待时间没有全部计入 silence。
因此,不能宣传为“用户总共只停顿了 140 ms”。第三轮实际从预缓冲结束到播放结束约 8.85 秒,而 PCM 本身约 7.99 秒;差值包含等待及其他播放收尾因素,也不能简单当作精确的纯卡顿时间。
好的诊断不仅要加日志,还要知道每一个数字到底测量了什么。
10. 水位调整:好网络尽快开始,坏网络留出余量
我们把本地预缓冲从描述符数量改为实际 PCM 字节数,计算公式为:
buffered_ms = queued_pcm_bytes × 1000 / 48000
当前策略的基准目标为 800 ms,上限为 1600 ms,软等待 1200 ms,硬等待 3000 ms:
- 达到目标水位,立即开始。
- 已收到流结束,短回复也可以直接开始,不必凑满目标。
- 超过软等待后,达到最低 800 ms 可以开始,即使未达到更高的自适应目标。
- 达到硬超时则兜底开始,并记录
reason=hard_timeout。
这不是绝对的“永远不少于 800 ms”:完整短回复和硬超时都是明确的例外。它是一种延迟与连续性折中,不是任何网络条件下的零欠载保证。
上面的三轮测试验证了第一阶段水位策略:前两轮分别承受了 400 ms、504 ms 的接收抖动而没有欠载;第三轮在较长回复后半段,连续出现约 697、560、409 ms 的接收间隔,最终耗尽缓存。
随后我们增加了根据接收抖动提前抬升目标的规则:
| 接收间隔 | 当前处理 |
|---|---|
| 小于 300 ms | 不因这次间隔抬升目标 |
| 300–449 ms | 标记本轮不稳定,阻止过早降低水位 |
| 450–649 ms | 目标至少提高到 1000 ms |
| 至少 650 ms | 目标至少提高到 1400 ms |
| 实际欠载 | 目标再提高 200 ms,最高 1600 ms |
| 连续三轮无欠载、无上述明显抖动 | 目标降低 100 ms,最低 800 ms |
如果播放尚未开始,新的目标可以作用于当前预缓冲;已经开始播放时,则主要保护后续回复,并不会凭空补出缺失的音频。
这组“接收抖动驱动”的规则已实现并编译通过,但本文所列三轮日志采集于它加入之前。不能用前三轮数据证明它已经解决长回复欠载。
10.1 为什么 800 ms 缓存不等于额外等待 800 ms
Python 会先积累音频,并允许下行保有一定提前量。Nest 日志中,前 10 帧可以在约 4–6 ms 内完成本地转发;如果后续网络足够快,设备也可以快速达到缓存目标。
因此,我们没有增加一段固定 sleep,而是等待实际音频储备。正常网络下仍保持基准目标,不因其他设备或上一次服务器测试自动加大延迟。
不过,坏网络恢复后的水位下降有滞后,也可能影响后续首包等待。它换取的是不频繁来回调整,不能声称对好网络体验“绝对零影响”。是否值得,需要继续比较首包等待的 P50/P95 和每分钟实际停顿时长。
11. 重构后仍需要守住的边界
11.1 二进制不是压缩
本项目当前音频本体仍是 PCM。16 kHz 上行约 32 KB/s,24 kHz 下行约 48 KB/s,持续双向传输仍然有明确带宽成本。二进制去掉的是文本编码膨胀,不会让 PCM 自动变成低码率音频。
11.2 完整消息不一定一次回调就收到
固件按总长度和偏移组装回调片段,完整后才解析 EYAU。协议长度、版本和 PCM 偶数字节校验必须保留。当前最大二进制消息限制是 128 KiB;这只是上限防护,不等于已经具备所有恶意输入和异常分片测试。
11.3 TCP 保序不等于应用语义正确
连接重建、旧回复残留、主动丢弃旧上传块都发生在 TCP 语义之外。sequence、stream UUID 和 request UUID 的职责不能被“TCP 已经可靠”替代。WebSocket 的消息分片也与应用层音频块不是同一个概念,见 RFC 6455:Fragmentation。
11.4 保留排查证据,但不要恢复重复合成链路
当前服务端把每轮进入对话处理的 ASR PCM 和回复 PCM 保存为 WAV,上传 COS,方便回听。实时播放不依赖等文件上传完成,也不再为了兼容播放重新请求一份 MP3。
归档 WAV 可以帮助判断音频内容本身是否有异常,但它不是设备扬声器输出的录音,不能反映网络和本地播放队列造成的停顿。两类证据需要配合使用。归档还需要产品侧明确保留期限和访问权限,不能因方便调试而无限保留用户语音。
12. 最终收获:让链路每一段的行为可解释
这次重构不是把 send_text() 换成 send_bin() 就结束了。
它包含了几次相互关联的判断:
- 用二进制传输持续 PCM,把低频业务控制留给 JSON。
- 保留流标识和时间线,让打断后的旧回复可以被识别和丢弃。
- 不重复上传云端已有的 TTS PCM,用设备播放游标驱动参考选择。
- 让采集、网络收发、播放任务通过有界队列解耦。
- 区分音频最终完整与音频实时可用,以多层日志定位交付抖动。
- 用实际缓存时长决定启动,而不是盲目增加固定等待。
最值得保留的一条工程经验是:
实时音频的正确性不只是“这些字节最终都到了”,还包括“它们在该播放的时刻已经到了”。
二进制协议让设备少做了一些不必要的工作;请求隔离和游标让数据的含义更清楚;诊断和自适应水位则让剩下的问题可以被测量、被解释、被继续优化。
这比一句“改成二进制后就不卡了”,更接近我们真正经历过的工程过程。
附:项目实现与数据依据
本文对应的实现文件:
- 固件
main/app/cloud_client.c:EYUP 编码、EYAU 接收、旧回复过滤、下行接收统计。 - 固件
main/app/pcm_stream_player.cpp:PSRAM 播放队列、实际字节水位、自适应启动与欠载记录。 - Nest
ai_service/src/modules/ws-gateway/audio.gateway.ts:鉴权后连接管理、二进制转发、发送进度诊断。 - Python
py_api/api/ws_audio.py:EYUP 解析、EYAU 组帧、TTS 下发节奏及 WAV 归档。
三轮联合测试的请求标识前缀依次为 dc477d7e、a8bdac19、bbd0f27a,测试时间为 2026-09-03 18:53 左右。本文只保留排查必要指标,不公开账户、设备凭证及用户音频地址。
后续验证需要补充:同一音频的局域网与公网穿透对照、长回复连续多轮测试、真实墙钟欠载时长、首次可听音频延迟分位数,以及新水位策略在网络恢复后的收敛速度。