WebSocket 二进制音频链路重构让 ESP32 AI 玩偶从“能对话”走向“连续对话”先说结论做 AI 硬件玩具最坑的不是大模型接口调不通而是“能对话”这三个字背后的链路设计。去年我接手了一个 ESP32 AI 玩偶项目最初交付的版本是“问一句、答一句”录音、上传、等待、播放循环往复。用户每次说话都要等 3 到 5 秒的静默期完全没有“对话感”。后来我们花了三周时间重构音频链路把原本基于 HTTP 的短连接改成 WebSocket 长连接把文本协议换成了二进制音频帧最终让整只玩偶从“一问一答”进化到“边说边听、能随时打断”体验完全上了一个台阶。这篇文章把这次重构中踩过的坑、拆过的流程、定过的帧格式和调试经验完整记录下来。适合手里正好在调 ESP32 语音设备、或者准备做类似 AI 硬件的朋友参考。里面会有一些代码段和协议设计但不会堆太多底层源码重点是讲清楚每步为什么这么做以及哪些地方最容易翻车。1. 为什么从“能对话”到“连续对话”必须重构音频链路很多开发者第一次做语音硬件第一版都是这么干的按钮触发录音录完一段 WAV 或 MP3 上传到服务器然后服务器调用语音识别和对话模型再把回复音频返回设备播放完等用户下一次按键。这版跑通很容易但一旦真拿在手里用几天你就会发现根本不是“对话”而是“对讲机”加“电话留言”。尤其在 ESP32 这种资源极其有限的单片机上问题会被放大得非常明显。1.1 原来的架构卡在哪里老架构的核心瓶颈主要有四个。第一个是连接建立的成本。每次交互都要走一轮 HTTP 请求包括 TCP 握手、HTTP 头传输、可能的 TLS 握手。ESP32 本身网络栈性能一般Wi-Fi 环境稍有波动三次握手加 TLS 就要耗时 200 到 500 毫秒。人耳对这个延迟极其敏感一旦超过 500 毫秒就会觉得“这设备反应真慢”。第二个是头部开销。HTTP 是以文本为主的协议哪怕只上传 100 字节的音频数据也要背负几百字节的头。音频帧本身才多大一帧 20ms 的 16kHz 单声道 PCM就是 640 字节。头部已经接近音频数据的量级了纯属浪费带宽。第三个是上下行割裂。原有方案无法在播放回复的同时继续采集用户声音因为没有一条稳定双向的通道。想实现“用户中途插话打断”这种功能前端只能斗胆开多个 HTTP 请求或者不断轮询代码写起来无比拧巴还极不稳定。第四个是弱网下的劣化。HTTP 短连接遇到弱网丢包要么重传要么超时音频流一旦断裂用户听感就是断断续续毫无连续可言。其实这些问题的根源就是一句话用短请求对话式协议去承载实时双向音频流方向就错了。实时音频需要的是全双工、低开销、低延迟的长连接通道WebSocket 恰好就是最贴合这个需求的协议。1.2 WebSocket 二进制帧到底解决了什么WebSocket 本身不是一个新协议2011 年就是 RFC 6455 了很多开发者早已在网页聊天、消息推送里用过。但你把它用到嵌入式音频链路上会发现它有几个被低估的特性。第一是长连接复用。一次握手之后ESP32 与服务器之间保持一条 TCP 链路后续所有音频帧都在这条链路上跑不再重复握手延迟大幅下降。第二是二进制帧的低开销。WebSocket 帧头最小只有 2 字节和 HTTP 动辄几百字节的头相比基本可以忽略不计。这对于高频发送音频帧的场景收益极其明显。第三是天然的双向能力。服务器可以随时下行推送音频给 ESP32ESP32 也可以随时上行发送用户音频。做打断、做流式播放、做多轮对话都变成非常自然的操作。第四是分帧边界。WebSocket 每个消息天然带有边界服务端不用像解析 TCP 字节流那样再去处理“粘包”和“分包”。只要双方约定好帧内部格式解析逻辑可以做得非常干净。所以当时我评估完就决定链路协议从 HTTP文本格式整体切换到 WebSocket二进制帧。后面证明这个方向完全正确只是细节上踩了一些坑。2. 整体方案与链路设计这一节把重构后的整体设计思路讲明白。你会看到我为什么选择“客户端主动模式半双工平滑切换”的方案也会看到完整的数据流和帧格式这部分是整个重构的地基。2.1 链路拓扑与角色分工整个系统由三个角色组成。第一层是 ESP32 设备端。它负责三件事采集麦克风数据、播放服务器下发的音频、以及运行一套轻量的状态机来管理“用户正在说话”和“设备正在播放”两个状态。第二层是 WebSocket 网关。它运行在一台具备公网或局域网 IP 的服务器上负责与 ESP32 保持长连接、解析二进制帧、把上行音频流转给语音识别模块把下行 TTS 音频流转回设备。为什么叫网关而不直接叫大模型服务因为语音识别、对话大模型、语音合成这些模块通常各自独立部署有的还是 HTTP 接口需要一个中间层做协议转换。第三层是 AI 服务后端一般拆成两段流式语音识别端和对话语音合成端。网关拿到用户音频后先送流式识别等识别文本完整后再调用对话大模型拿到回复文本最后调用语音合成生成音频流一路下行回设备。我建议把网关和大模型服务拆开千万别把大模型 SDK 全都塞进 WebSocket 服务里。一是避免一个模型超时拖死整个连接二是方便单独扩容。实测下来网关进程占用非常稳定哪怕语音识别偶尔卡顿WebSocket 连接也不受影响设备端不会掉线。2.2 二进制帧格式设计WebSocket 本身只负责消息边界不关心业务内容。所以我们在消息体里设计了一套极简二进制帧。每个 WebSocket 消息体用头部 4 字节标记类型和长度后面紧跟负载数据。格式如下Byte 0 : 帧类型0x01: 音频上行, 0x02: 音频下行, 0x03: 控制帧, 0x04: 心跳 Byte 1 : 编码格式标志0x01: PCM16, 0x02: Opus Byte 2~3 : 负载长度大端序uint16最多 65535 字节 Byte 4~N : 负载数据音频或控制指令有人可能会问直接用 JSON 文本包一层 base64 不就行了开发省事调试直观。确实省事但代价很大base64 会增加约 33% 的体积JSON 解析在 ESP32 上既费内存又费 CPU而且一旦音频数据量大内存拷贝次数会显著增加。对微控制器来说二进制是最友好、最可控的形式我把这套帧结构直接写进协议文档双方按字节对齐解析出错的概率反而比文本低。控制帧的负载数据我设计成简单的 keyvalue 文本比如eventtts_start、eventtts_end、eventinterrupt。控制这块用文本是为了调试清晰音频部分用二进制是为了性能这样两种需求都兼顾了。2.3 半双工还是全双工——初期最纠结的决策很多人一听“连续对话”第一反应就是全双工觉得双向音频必须实时并行。但实际上我们一开始也用全双工做过实验结果翻车了。问题出在设备端。ESP32 的 I2S 外设虽然有独立收发 DMA 通道理论上可以同时录音和播放但实际用起来回声消除是个巨大的坑。如果设备同时开着麦克风和喇叭麦克风会采到喇叭的声音如果不做 AEC远端语音识别就会把设备自己的播报识别成用户的话整个对话立刻被自己打断。所以在第一版重构中我选的是半双工快速切换方案同一时间只有一路音频在“工作”要么上行要么下行但切换速度要足够快。具体实现是设备默认处于“聆听”状态持续上传麦克风采集的音频当服务器下发了 TTS 音频帧设备先发送一条tts_start控制帧同时停掉上行采集切换到播放模式播放完毕后再发一条tts_end控制帧重新回到聆听状态。这套机制让用户几乎感觉不到切换因为切换只发生在本地不做网络往返速度极快实测从“停录”到“开播”的时间能控制在几十毫秒以内。如果后续要做全双工通常需要外接支持 AEC 的音频芯片或者用像乐鑫 ESP-SR 这类带回声消除的 SDK那就可以在软件层解决一部分问题。但以我们的资源半双工快速切换是这个阶段最稳、成本最低的解法。3. 核心实现细节从 ESP32 到服务器的完整音轨光有协议还不够真正让每条音频帧能跑起来涉及一堆很琐碎但决定成败的细节。我把设备和服务器两个端的关键实现拆开讲顺带把编解码参数的选择逻辑说清楚。3.1 ESP32 端I2S 采集与播放ESP32 的音频采集我用的 I2S 外设配合一个数字 MEMS 麦克风比如常见的 INMP441。初始化时核心代码如下i2s_config_t i2s_config { .mode (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate 16000, .bits_per_sample I2S_BITS_PER_SAMPLE_16BIT, .channel_format I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags ESP_INTR_FLAG_LEVEL1, .dma_buf_count 8, .dma_buf_len 1024, };这里解释几个关键参数。采样率选 16000Hz不是拍脑袋定的。当前主流的语音识别模型比如 Whisper 系列和许多中文 ASR 模型原生采样率就是 16kHz直接用这个速率可以省掉重采样这一步。16kHz 的采样率对人声识别完全够用因为人说话的主要能量集中在 300Hz 到 3400Hz 之间。位深选 16bit这是绝大多数 ASR 和 TTS 引擎默认接受的格式兼容性最好。单声道是必然因为单个麦克风采集单声道省带宽也避免双声道数据对齐的问题。DMA 缓冲区的配置这里特别重要。dma_buf_count8、dma_buf_len1024的搭配实测下来能保证在 Wi-Fi 发送偶发卡顿时也不会丢数据。如果缓冲区开太小网络抖动一小会儿就丢帧开太大又会导致音频采集延迟飙升。8 组 1024 点也就是大约 512ms 的缓冲量既不浪费内存又能容忍短时网络波动。采集端还有一个细节原本 I2S 数据到手后需要把 32bit 容器中的 16bit 有效数据提取出来。INMP441 的数据是左对齐的实际有效位在高 16 位所以需要右移 16 位再转成 int16_t然后打包上传。很多教程没提这个直接裸传 32bit 数据导致服务器端识别效果极差。int16_t pcm_data[1024]; for (int i 0; i bytes_read / 4; i) { int32_t raw buffer[i]; pcm_data[i] (int16_t)(raw 16); }播放端的 I2S 配置和采集类似只是模式改成了I2S_MODE_TX直接给 I2S 写 int16_t 数组就行。3.2 编解码选型为什么不直接传 PCM音频采集上来是裸 PCM16kHz、16bit、单声道每秒的数据量是 32000 字节也就是约 31.25KB/s。如果产品只在局域网环境运行直接传 PCM 完全可行因为不涉及跨公网带宽。但我们的场景里ESP32 需要通过公网连接云服务器每秒 31.25KB 的上行带宽在很多家用宽带的弱上传链路下已经有点吃紧遇到丢包重传整个链路会明显变卡。所以在正式方案里设备的音频编码用的是 Opus。Opus 在 16kHz 采样下非常适合语音以 16kbps 码率设置为例每秒只产生 2KB 数据比裸 PCM 节省了 94% 的带宽同时语音质量依然可以接受。对 ESP32 来说Opus 编码的开销并不算大乐鑫的 ESP-ADF 框架里已经集成了 Opus 编解码器PAF 平台也可以直接用。服务器下行给设备的 TTS 音频我直接用 16kHz 16bit 的单声道 PCM。为什么下行不压缩因为 TTS 是服务器生成的带宽可控播放端如果直接拿 PCM可以省掉解码环节。ESP32 的 CPU 主频有限解码 Opus 虽然不是大事但理论上能省则省播放流程越短延迟越低。而且压缩再解压总有额外延迟下行音频走 PCM让链路延迟控制在最紧凑的范围。如果你在公网环境下播放的 TTS 音频比较长比如一段 30 秒以上的长回复那建议下行也做 Opus 压缩否则 ESP32 的 RAM 会被待播放音频占掉太多可能导致系统其他任务内存不足。我们的回复通常控制在 10 秒以内PCM 数据量约 320KB用 ESP32 的 PSRAM 缓存完全够用。3.3 服务器端WebSocket 网关与 ASR/LLM/TTS 的对接服务器端我用的 Python 的websockets库搭建网关轻量且异步支持好。核心思路是给每个 WebSocket 连接维护一个独立的会话对象音频帧进来之后按序送入流式语音识别管道。这里有个容易被忽视的问题流式语音识别通常要求数据按时间顺序到达但 WebSocket 消息本身不保证应用层顺序完全可靠尤其是你的 Wi-Fi 网络出现重传时音频帧可能乱序。所以我在网关侧给音频帧加了一个序号字段虽然当前帧格式里没有体现但建议在负载前增加 2 字节的 sequence number接收端检查到乱序时直接丢弃错序帧避免把语音特征算错。服务器端伪代码大概是async def handle_audio(websocket, session): async for message in websocket: if isinstance(message, bytes): frame parse_binary_frame(message) if frame.type 0x01: # 上行音频 await session.asr.feed(frame.payload) elif isinstance(message, str): handle_control_message(session, message)流式识别的结果会逐步返回识别文本稳定后网关再把完整文本交给大模型。这里要注意当下游大模型响应很慢时不要让 WebSocket 连接等待太久应该先把“收到文本”的确认帧回给设备让设备保持低延迟感知回复音频生成后再下行。这样可以避免用户在设备端感受到“服务器没反应”的空白期。另外WebSocket 网关必须做心跳保活。ESP32 在 NAT 环境下长时间不发包路由器可能会回收映射表导致服务器以为连接还在但设备侧已经失联。我每 30 秒由服务器发送一个 PING 帧设备收到后回 PONG连续两次没回就直接断开设备端检测到断开后主动重连。这个方法简单粗暴但非常有效实测一整天待机不掉线。4. 连续对话的关键优化流式、打断与缓冲策略链路通了只是第一步真正让“连续”这两个字成立的是下面三个优化点。它们直接决定了用户拿到玩偶之后的第一感觉到底是像个玩具还是像个“活的”对话伙伴。4.1 从“等说完再识别”到“边说边识别”传统按文件识别的做法是用户说完一句话上传整个音频文件服务器一次性返回识别文本。这个流程在语音交互硬件上体验极差因为用户必须等自己说完了服务器才开始工作延迟等于“说话时长 识别时间 大模型时间 TTS 时间”。重构后我接的是流式语音识别设备边上传音频帧服务器边识别用户话还没说完识别结果已经出了一部分。当识别到短语边界或静音过长的位置网关就直接判定一句话结束无需等待整段录音上传完毕。这里有一个关键调节参数静音判定阈值。太灵敏用户中间停顿一下就被截断太迟钝对话节奏就慢。我最终的经验值是当识别引擎连续 600ms 未检测到有效语音且当前已有完整识别文本就判定当前轮次结束。这里面 600ms 是经验值实际需要根据你产品的目标用户微调如果是儿童玩偶建议放宽到 800ms因为孩子说话停顿明显更多。4.2 打断机制这是“连续感”的灵魂我做用户调研的时候好几个人不约而同提到一个场景玩偶在播报一个很长的故事用户想喊停怎么办第一版架构完全做不到因为播放和采集是分开的无法打断。重构后的二进制长连接天然支持打断。打断机制的实现逻辑并不复杂。设备保持在播放 TTS 音频时麦克风实际上也在工作只是音频帧不上传。我改成了播放过程中以 100ms 间隔持续监听环境音量如果发现音量超过一个预设阈值就立即停止播放清空播放缓冲区然后向服务器发送一条eventinterrupt控制帧。服务器收到打断帧后立刻丢弃未发送完的 TTS 音频并通知大模型停止当前生成。整个过程要求设备和服务器两头都做“快速放弃”不能有任何“发完再停”的犹疑。实测下来从用户开口说“停”到设备完全静音、再次进入聆听状态控制在 300ms 以内人耳感知就是“一说就停”。这 300ms 的拆分大致是音量检测 50ms缓冲清空 100ms控制帧上行 80ms服务器响应 70ms。ESP32 的实时性要抓好务必把音量检测放到中断级或高优先级任务里不要用低优先级的轮询任务否则会明显感觉到延迟。4.3 网络抖动与音频缓冲策略公网 Wi-Fi 环境下网络抖动是不可避免的随时可能出现 100ms 甚至 500ms 的丢包或乱序。直接播放收到的每一帧遇到网络卡顿用户听到的就是“卡碟”效果。我们的做法是设备端维护一个 jitter buffer抖动缓冲。下行 TTS 音频到达后先不直接播放而是进入一个环形缓冲区只有当缓冲区里的音频量达到 80ms 左右才开始播放。这 80ms 的缓冲会带来一点点延迟但它换来的是当网络出现短时抖动时播放不会立即断音而是靠缓冲撑过去。这个数值必须在产品测试阶段反复调。80ms 是我测下来比较均衡的折中如果你的网络质量很好可以降到 40ms 压缩延迟如果是跨省公网建议提到 120ms 更安全。另外服务器下行时的发送节奏也要控制。如果服务器一次性把整段 10 秒 TTS 都丢给客户端ESP32 的 RAM 会瞬间被塞满网络闪断时还会丢失大量数据。正确的做法是服务器以块为单位每块 200ms 的音频发送一次两块之间间隔 150ms 左右形成一个“慢发快收”的节奏让设备端缓冲区始终有数据但不满溢。这样即使某一块发丢了设备端还有缓冲可以兜底重传也来得及。5. 调试这个链路时我吃过的苦和收获的实战经验这一节把我调试过程中遇到过的典型问题、排查思路和最终解决方案列个清单。这部分最值钱因为全都是文档里不会写的实战细节遇到相似症状可以直接按图索骥。5.1 问题症状与排查过程实录第一个头疼的问题是 ESP32 只有上行音频服务器收到的全是噪声。一开始我以为是麦克风坏了后来用串口把 I2S 原始 DMA 数据打印出来发现数据非零但全像高频噪声。排查之后才发现是 32bit 容器未做移位处理有效 16bit 数据还在高半区音频引擎读到低半区的填充位全是无效数据。解决后噪声立刻消失了。第二个问题是 WebSocket 连接总是几十秒后断开。排查时我先看服务器日志发现并不是服务器主动断开而是 TCP 层收到 RST。开启tcpdump抓包又发现ESP32 在空闲时不发任何包NAT 映射超时后连接就被静默丢弃。解决办法就是前面说的服务器每 30 秒发一次 PING 心跳设备端必须及时回 PONG。别指望底层 TCP keepalive 单独解决NAT 层的超时很多时候比 keepalive 时间短。第三个问题是偶发的音频首帧延迟很大经常听到“啵”的一声爆破音。这个出现在播放侧。I2S 刚开始播放时DAC 输出会有一个突刺原因往往是 DMA 缓冲区从空到非空的瞬间内部有未初始化的数据。解决办法是播放前先向 DMA 缓冲区写几 ms 的静音数据让 I2S 先初始化输出然后再喂真实音频。这个细节很多参考代码不会讲但你做到了音质体验会立刻上一个档次。第四个问题是当服务器在下行大音频时上行麦克风采集会出现咔哒声。这个属于中断优先级和 CPU 调度打架。ESP32 的 I2S 收发共用中断如果你在播放过程中频繁开关 DMA采集侧会受到干扰。我们的解决办法是播放和采集两个 DMA 通道都保持常开打断时不是停掉 DMA而是停止向播放 DMA 写新数据这样既保留了下一次采集的同步性也避免了 I2S 重新初始化带来的咔哒声。5.2 常见问题速查表我把调试过程中最重要的问题浓缩成一张表方便你开发到那一步时直接对照。症状可能原因排查方式解决建议服务器端全噪声I2S 有效位未提取串口打印原始 uint32 数据右移 16 位再转 int16_tWebSocket 频繁断线NAT 映射超时抓包看 RST 来源服务端 30s PING客户端回 PONG播放时有爆破音DAC 未初始化直接喂数据听音判断是否在首帧播放前先写几句话静音播放时采集咔哒声DMA 反复启停观察中断频率保持 DMA 常开停止写数据即可下行音频有撕裂感jitter buffer 过浅播放中观察空缓冲次数增加到 80~120ms打断后 TTS 还在播未及时清空本地缓冲打断后仍有残余音频一次清空环形缓冲再发控制帧公网延迟高未使用二进制帧计算单帧开销改用二进制帧降低体积内存不够下行 PCM 过大查看剩余 RAM或改用 Opus 压缩下行音频5.3 几个真正让体验起飞的小技巧调试完稳定运行之后我们再回头抠了一些用户体验细节。这几个小点看起来不起眼但对“连续对话”的主观感受提升极大。第一个是播放 TTS 前发送一个“设备端准备开始播放”的控制帧。这样即使 TTS 音频生成的延迟稍长设备也会提前播放一段很短的提示音比如“嗯”的一声让整个交互节奏不突兀用户不会觉得自己被晾在一边。第二个是把音量检测和 WebSocket 发送任务拆分。刚开始我们直接在 WebSocket 回调里做了音量检测结果网络一慢整个回调任务被阻塞导致打断反应迟钝。后来把音量检测放到单独的 FreeRTOS 任务优先级设为中等偏上WebSocket 发帧只管发两件事解耦后打断响应稳定在 300ms 以内。第三个是 ESP32 的 Wi-Fi 省电模式必须关掉至少在连续对话期间。默认的 Wi-Fi modem sleep 会带来几十毫秒的唤醒延迟对话场景下完全不可接受。直接用esp_wifi_set_ps(WIFI_PS_NONE)关掉省电延迟会立刻减小。代价是功耗上升但对于插电的桌面玩偶来说完全不是问题如果是电池供电的产品就需要做精细化的功耗状态切换这里就不展开了。6. 后续还能怎么扩展这次重构解决了主干路径的稳定性但“连续对话”本身还有不少可以继续深化和扩展的空间。我在项目收尾后整理出一个扩展清单有些已经在计划内有些则是给接手的团队留下的 roadmap。首先是全双工 智能打断的完整版。当前的半双工方案能应付 95% 的对话场景但人总有同时在听和说的需求比如设备在放音乐时用户想直接说话。如果要做这层建议直接上带硬件 AEC 的音频前端或者集成乐鑫的 ESP-SR 回声消除库。注意软件 AEC 对 CPU 占用比较高可行的前提是你的主控是 ESP32-S3 或者 ESP32-P4 这类带较强 DSP 能力的芯片。其次是音频链路的加密与鉴权。现在的二进制 WebSocket 链路默认跑在明文 TCP 上局域网玩一玩没问题但如果产品要做成联网发售的形态建议至少要加 TLS能有效防止不合规的中间人窃听和恶意控制帧注入。同时设备入网时要有 token 鉴权不允许陌生设备直接连上网关。TLS 的握手会增加几十毫秒但有硬件加速的芯片压力不大。第三是云侧的多设备协同。目前网关只管单个设备的音频链路如果产品系列里有多个 ESP32 设备比如一个桌面玩偶配一个随身挂件需要考虑同一用户的多个 WebSocket 连接如何共享会话状态。比如玩偶上问一句“我昨晚订的快递到哪了”挂件也想知道答案这就需要把会话信息从连接层剥离出来放到一个独立的 session store 里比如用 Redis。这个扩展在我下一次重构里大概率会做到时候可以再出一篇完整的分享。回归到核心这次链路重构最大的收获是我意识到做硬件语音交互产品最不能妥协的就是链路设计的底层逻辑。短连接做交互长连接做流文本做控制二进制做音频这句话听着简单但真正落地需要把这些原则贯穿到每一层实现里。如果你正准备做 ESP32 语音交互产品我建议你在动手前先把这一层链路协议想清楚不要以“先跑通”的心态开始因为后面改起来改的不是代码是整个系统的骨架。