WebSocket 二进制音频链路重构:让 ESP32 AI 玩偶从“能对话”走向“连续对话”

15 阅读20分钟

一次从 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 PCM320032000
Base64 音频字符串,不含 JSON 外壳426842680
当前普通 MIC 二进制帧,36 字节头323632360
当前播放期间的二进制帧,64 字节头326432640

普通 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_startaudio_start_ackspeech_starttts_stream_starttts_stream_end
高频实时音频WebSocket 二进制消息EYUP/1 上行、EYAU/1 下行

audio_start 负责声明采样率、声道、帧时长和协议版本。设备等待 audio_start_ack,再按协商结果上传音频。身份认证仍然在连接握手和后端会话中完成,不需要在每个音频块里重复发送凭证。

但流标识、序号、样本位置不能随意删掉。它们回答的是三个不同问题:

  • 这块音频属于哪条流或哪一轮回复?
  • 中间是否跳过了应用层音频帧?
  • 这块音频位于哪段媒体时间线上?

这些信息尤其影响打断、重连和 AEC。二进制协议的目标是用更紧凑、稳定的表示保留它们,而不是把音频变成无法识别来源的一串字节。

4. EYUP/1:上行到底发送了什么

当前普通麦克风上行使用 36 字节头:

偏移长度字段
04Magic:EYUP
41版本:1
51帧类型
62头长度
816Stream UUID
244Sequence
288Sample clock
36可变MIC PCM S16LE

协议头中的多字节整数使用大端序,PCM 样本保持小端序。这两个规则需要明确区分,不能用本地 C 结构体直接拷贝作为跨语言协议。

播放期间,type 3 帧将头扩展到 64 字节:

偏移长度扩展字段
364播放参考序号
408播放参考样本游标
4816对应 TTS 回复的 UUID
643200仍然只有一份 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 请求。

指标第一轮第二轮第三轮
帧数333987
PCM 字节数152354171910383320
音频时长,按 48000 B/s 推算3.174 s3.581 s7.986 s
Nest 最大帧间隔110 ms110 ms111 ms
ESP 最大完整帧接收间隔400 ms504 ms697 ms
ESP 首帧距收到流开始事件810 ms327 ms100 ms
ESP 实际预缓冲等待1320 ms1020 ms630 ms
开播时缓存音频800 ms800 ms800 ms
欠载次数007

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:

  1. 达到目标水位,立即开始。
  2. 已收到流结束,短回复也可以直接开始,不必凑满目标。
  3. 超过软等待后,达到最低 800 ms 可以开始,即使未达到更高的自适应目标。
  4. 达到硬超时则兜底开始,并记录 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() 就结束了。

它包含了几次相互关联的判断:

  1. 用二进制传输持续 PCM,把低频业务控制留给 JSON。
  2. 保留流标识和时间线,让打断后的旧回复可以被识别和丢弃。
  3. 不重复上传云端已有的 TTS PCM,用设备播放游标驱动参考选择。
  4. 让采集、网络收发、播放任务通过有界队列解耦。
  5. 区分音频最终完整与音频实时可用,以多层日志定位交付抖动。
  6. 用实际缓存时长决定启动,而不是盲目增加固定等待。

最值得保留的一条工程经验是:

实时音频的正确性不只是“这些字节最终都到了”,还包括“它们在该播放的时刻已经到了”。

二进制协议让设备少做了一些不必要的工作;请求隔离和游标让数据的含义更清楚;诊断和自适应水位则让剩下的问题可以被测量、被解释、被继续优化。

这比一句“改成二进制后就不卡了”,更接近我们真正经历过的工程过程。


附:项目实现与数据依据

本文对应的实现文件:

  • 固件 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 归档。

三轮联合测试的请求标识前缀依次为 dc477d7ea8bdac19bbd0f27a,测试时间为 2026-09-03 18:53 左右。本文只保留排查必要指标,不公开账户、设备凭证及用户音频地址。

后续验证需要补充:同一音频的局域网与公网穿透对照、长回复连续多轮测试、真实墙钟欠载时长、首次可听音频延迟分位数,以及新水位策略在网络恢复后的收敛速度。