资讯动态

ESP32语音玩偶连续对话重构:WebSocket二进制音频链路实战

发布时间:2026/9/12 9:48:17 来源:尧图企业网站定制
接到朋友吐槽电话时我正拿着我们做的 ESP32 AI 玩偶对着麦克风一遍遍喊话。玩偶能听懂、能回答但每轮对话都要先按按键、等“滴”声、说完再盯着它卡顿两三秒才蹦出一句回应。这哪叫对话分明是对讲机。我下决心重构目标很明确让玩偶从“能对话”走向“连续对话”像真人聊天一样我说完它马上接它说着我随时打断。而这一切的核心就是丢掉原来的 HTTP 请求式链路重建一条基于 WebSocket 的二进制音频链路。这篇文章就记录这次重构的完整思路、帧协议设计、ESP32 端实操、服务端配合以及整整两周的排障实录。如果你也在做语音玩具、智能音箱或者 AIoT 语音交互这里面的协议细节和工程坑应该能帮你少走不少弯路。1. 第一版玩偶的“一问一答”困局问题到底出在哪1.1 用户感知层面的断裂感第一版玩偶的原理很简单用户按下按钮ESP32 录音松手后把音频用 HTTP POST 到云端云端依次调用 ASR、大模型、TTS把合成好的音频文件返回玩偶播放。产品经理说这叫“语音问答”但用户拿到手的感觉是“按一下问一句等半天听一句再按一下”。成年人玩两分钟就放下小孩新鲜感也就十分钟。真正致命的不是延迟本身而是交互的“断裂感”用户必须等一个完整的请求-响应循环结束才能进行下一句。提问过程中无法打断等待过程中没有任何反馈玩偶的回话一旦有 200ms 以上的停顿用户就开始怀疑它是不是死机了。连续对话不是让单次响应更快而是让整段交互像流水一样不停顿。这次重构的出发点就是把“每次交互都是一个孤立请求”的模型彻底改成“整场对话是一条持续的流”。1.2 技术链路里的四个瓶颈旧链路的问题在后端压测和真机日志里暴露得很彻底归结起来是四个瓶颈传输格式太“胖”旧实现把音频转成 base64 塞进 JSON。base64 会让数据体积膨胀约 33%ESP32 编码要耗时服务端解码也要耗时两头都在给 CPU 添负担。无法流式处理HTTP 短连接要求完整语音包上传后服务端才开始 ASR。用户说 5 秒的话这 5 秒里服务端什么都干不了只能等。TTS 也要等大模型把整段话生成完才合成首包延迟被无谓拉长。连接建立成本高每一轮对话都要重新 TCP 握手、TLS 握手。虽然单次握手只有几十毫秒但累积到多轮对话中用户体验就是“一句话一条缝”。MCU 端内存压力大在 ESP32 上拼 JSON 字符串需要动态分配内存。音频越长字符串越大堆碎片越严重跑几轮后 malloc 失败导致随机重启。1.3 我给这次重构定的三条原则第一用 WebSocket 长连接代替 HTTP 短连接让“连接”贯穿整个会话。第二音频走二进制帧而不是 JSON 文本从根上消除 base64 和字符串解析的开销。第三服务端全链路流式化流式 ASR、流式大模型、流式 TTS让每个环节都能“边收边出”。后面所有设计和排障都围绕这三条原则展开。这里提前说一句如果你的产品还处于原型验证阶段完全可以在 Arduino 上先跑通 WebSocket 收发但要做到连续对话的量产级稳定性ESP-IDF 几乎是必选项后面我会解释为什么。2. 二进制帧协议设计给音频流画一条“车道”2.1 为什么是二进制帧不是 JSON 文本帧很多人觉得 WebSocket 也能传文本把旧 JSON 直接放进 text frame 不就行了没那么简单。音频是典型的实时流数据每一帧只有几十字节到几百字节的 PCM。如果每帧都套一层 JSON比如{seq:12,audio:base64data}光解析 JSON 键值对在 ESP32 上就要耗费不少 CPU。更要命的是JSON 解析必须等完整包收齐才能开始而二进制帧有定长头部MCU 端拿到头部就能知道整帧长度直接 memcpy 到目标缓冲区连字符串处理函数都不用调。二进制的另一个优势是便于扩展。头部里预留 type 字段想加控制消息、事件消息直接定义新类型不用改解析逻辑。音频数据也可以按固定的“格式描述符 裸 PCM”布局走服务端拿到就能直接喂给 ASR中间没有任何转换。我在实际对比中测过同一段 5 秒语音JSON 文本帧在 ESP32 上编码耗时约 18ms二进制帧几乎可以忽略不计这个差距在连续对话中会持续放大。2.2 帧结构定义与字段说明最终采用的帧结构如下偏移长度(字节)字段说明02magic固定 0x5A5A用于快速校验和对齐21version协议版本当前 0x0131msg_type0x01 音频帧 / 0x02 控制帧 / 0x03 服务端响应42sequence小端序发送方递增序列号62payload_len小端序表示 payload 长度8Npayload业务数据控制帧的 payload 首字节是控制子类型0x10 表示 VAD_START0x11 表示 VAD_END0x12 表示 INTERRUPT用户打断0x13 表示 PING0x14 表示 PONG0x15 表示 ERROR0x16 表示 SESSION_RESUME。音频帧的 payload 前 4 字节固定为格式描述前两字节采样率16000、第三字节位深16、第四字节声道数1第 5 字节起才是裸 PCM 数据。格式描述在会话建立时其实已经协商过但放在每一帧里能让服务端在处理乱序或重连时更鲁棒不用依赖会话状态。2.3 序列号与重连恢复逻辑sequence 序列号是这个协议里最容易被忽略但最重要的一环。ESP32 每发一帧sequence 就加一。服务端不需要对每个音频帧回 ACK那会浪费带宽但服务端会记录收到的最后一个 sequence。设备断线重连后发送 SESSION_RESUME 控制帧服务端对比本地记录与设备上报的 sequence就知道断线期间丢了哪几帧。对实时对话来说历史音频帧丢了就丢了不需要补传但 sequence 能告诉服务端“用户当前说到哪里”避免把上一轮的文本和这一轮的文本搞混。没有这个字段断线重连后经常出现“答非所问”这是纯音频流方案里最隐蔽的坑。3. ESP32 端实操从 IDF 到 WebSocket 客户端的二进制改造3.1 为什么从 Arduino 迁到 ESP-IDF原型阶段用的 Arduino接麦克风、连 WiFi 都很方便两周能跑通 demo。但做连续对话后Arduino 的几层封装开始拖后腿内存池不透明、WiFi 重连策略不灵活、多任务调度粗糙。最典型的是堆碎片跑几十轮对话后 malloc 失败率明显上升设备进入一种“用着用着就死机重启后又正常”的诡异状态。最终我们整体迁移到 ESP-IDF v5.x。它不是为了让代码“看起来更专业”而是三个实际好处一是可以精确配置 lwIP 的 TCP keepalive 和 WiFi modem sleep二是 FreeRTOS 任务优先级、事件组、队列都能直接控制三是 ESP-SR 语音前端在 IDF 里有完善的原生支持。如果大家只是验证协议继续用 Arduino 也不是不行但量产前建议至少把 WiFi 和内存管理部分迁到 IDF。3.2 I2S 采集把 PDM 麦克风接到 DMA 环形缓冲硬件上用的是 INMP441 数字麦克风走 I2S 接口PDM 转 PCM 后输出 16kHz/16bit/单声道。ESP32 的 I2S 驱动里配置接收模式接收数据通过 DMA 写入环形缓冲区CPU 只需要每隔固定时间从缓冲区取一帧。一帧定为 20ms320 字节。20ms 是语音交互的常见帧长既能保证低延迟又不会让 WebSocket 包过于碎片化。我把 DMA 缓冲区设成 8 个 320 字节的 block一个采集任务专负责从环形缓冲取帧、做语音活动检测另一个任务负责 WebSocket 发送和接收。这里有个实测才会碰到的坑播放 TTS 时麦克风照样在采集扬声器的声音会串进来能量阈值一高就可能误判成“用户插话”。所以连续对话必须上 AEC回声消除。ESP-IDF 里可以用 ESP-SR 的 AEC 组件把正在播放的音频作为参考信号和麦克风信号一起做处理处理后的信号再做打断检测误触率能降到可接受范围。第一版没做 AEC玩偶经常自己打断自己场面非常喜感。3.3 WebSocket 客户端接入与二进制发送ESP-IDF 官方组件仓库里有esp_websocket_client支持 ws 和 wss。开发阶段用 ws量产上 wss。需要特别留意的是 TLS 内存wss 握手建立起的 TLS 会话会吃掉不少 RAMCONFIG_MBEDTLS_SSL_IN_CONTENT_LEN和CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN默认只有 16KB如果音频帧或服务端回推的 TTS 块稍大会被 mbedTLS 直接拦截。我把这两个值调到 32KB实测稳定很多。发送二进制帧的关键代码char frame[8 320]; build_frame_header(frame, AUDIO_FRAME, seq, 320); memcpy(frame 8, pcm_data, 320); esp_websocket_client_send_bin(client, frame, sizeof(frame), pdMS_TO_TICKS(100));接收服务端下发的二进制 TTS 音频时在事件回调里判断if (event-event_id WEBSOCKET_EVENT_DATA) { if (event-data_type WS_DATA_TYPE_BIN) { xQueueSend(tts_audio_queue, event-data_ptr, 0); } }重点不要在 WebSocket 事件回调里直接写 I2S 或做任何耗时操作只把数据 push 进队列。我们在第一版犯过这个错回调里直接写 DMA 缓冲导致播放出现周期性 xrun声音一卡一卡的查了三天才发现是任务抢占问题。3.4 任务划分与内存预算最终 ESP32 侧的任务划分任务优先级栈大小职责Audio Capture较高4096从 I2S 环形缓冲取帧、本地 VAD、打断检测WebSocket Task高4096连接维护、收发二进制帧、心跳TTS Player中4096从队列取音频数据、写 I2S 播放Main/OTA低8192业务状态机、OTA 升级等整个 WebSocket TLS 环形缓冲的常驻内存约 90KB在 ESP32 的 320KB RAM 里占接近三分之一但比原先 JSON base64 的动态分配方案省心太多几乎不再出现随机重启。内存预算这件事我建议在项目第一天就画出来因为语音链路涉及采集、网络、播放三个方向任何一个方向多吃了内存另外两个方向就会在关键时刻出问题。4. 服务端全双工链路识别、大模型与 TTS 如何“边收边回”4.1 从“等整段上传”到“边收边识别”服务端我用 Python FastAPI 做 WebSocket 网关也可以用 Gin 或 Node.js思路一致。收到二进制帧后先解析头部按 msg_type 分流。音频帧直接累计转发给流式 ASR。这里的关键是ESP32 的本地 VAD 检测到用户说完了会给服务端发 VAD_END 控制帧服务端收到 VAD_END 后调用 ASR 的 stop/finish拿到完整文本。所谓流式 ASR不是等服务端说“结束”而是由设备端的 VAD 决定语义边界。要注意服务端不能依赖“客户端发完最后一块音频就结束”。WebSocket 本身没有消息边界服务端必须靠协议里的控制帧来判断句子边界。这就是为什么 VAD_START/VAD_END 控制帧必须和音频帧在同一个连接、保证前后顺序。如果这里设计成“音频发完就自然结束”一旦网络抖动导致最后一帧丢失服务端就会一直傻等。4.2 大模型流式输出与 TTS 分段合成的配合拿到完整用户文本后调用大模型的流式接口。设置streamtrue让 token 陆续返回。接下来是最考经验的部分LLM 输出的 token 不是一个完整的句子怎么判断何时送去 TTS我用的策略是按标点切句。出现句号、问号、感叹号时把累积文本作为一个句子交给 TTS句子超过 40 个字还没标点也强制切一刀避免 TTS 等待太久。TTS 接到句子后开始流式合成生成的音频块直接通过 WebSocket 二进制帧推给 ESP32。整个链路的首包延迟构成很清晰用户说完到 VAD_END约 600msASR 出最终文本约 300-500msLLM 首 token约 200-400msLLM 切出第一句并送到 TTS约 100msTTS 首包约 200-400ms合计约 1.4-2.0 秒比旧方案的 2.8-3.5 秒有明显改善。如果后续用更强的本地 VAD 模型把 600ms 压到 300ms还能再低。这里有个反直觉的点新方案虽然多了 VAD 判断时间但因为是流式并行整体反而快了很多——旧方案里 TTS 要等大模型完整输出而大模型生成 200 字可能要 5-8 秒这段等待是用户感知最明显的部分。4.3 会话管理device_id、session_id 与旧连接踢出服务端必须有一个连接管理器key 是 device_id。设备首次连接时服务端下发 session_id断线重连时设备带相同 device_id服务端从 Redis 恢复该设备的大模型上下文、TTS 生成状态。还有一个细节同一设备开两个连接。比如 App 和玩偶同时连或者调试工具也在连状态会互相踩。我在连接管理器里做了一件事——新连接到来时如果该 device_id 已有活跃连接主动给旧连接发一个 CLOSE 帧并关闭。这个“旧连接踢出”的机制可以避免绝大多数状态混乱。服务端还要做背压控制。玩偶播放速度跟不上服务端下发速度时TCP 窗口会自动限流但更保险的是在服务端维护一个“在途字节数”计数器超过阈值就暂停推送 TTS 音频包。否则极端情况下服务端把 3 秒钟的音频在 300ms 内全推给设备设备缓冲区直接爆掉又是新的 1006 断开。5. 稳定性实战断线重连、1006 排查与低功耗取舍5.1 WebSocket 1006 异常断开的完整排查链路实现连续对话后我们遇到最头疼的问题就是客户端日志里反复出现[websocket] onclose, code: 1006。1006 表示连接在没有正常 close frame 的情况下被断开特点是没有错误码、没有原因纯粹“被掐断”。现象是短对话正常长句 TTS 播放时偶发。排查过程和结论值得完整记录下来。第一步查网关代理。我在服务端前面挂了 Nginx如果走 WebSocket 转发proxy_read_timeout和proxy_send_timeout默认 60 秒。这不是断线原因因为服务器到 ESP32 的数据一直没停过Nginx 认为连接活跃。但保险起见我把它调成了 3600 秒排除嫌疑。第二步查服务端 WebSocket 库的 Ping 机制。FastAPI 的 WebSocket 接口基于 UvicornUvicorn 默认每 20 秒发一次 Ping如果 ESP32 没有及时回 Pong就会判定连接死亡并断开。问题就出在这里——TTS 播放时 CPU 忙着跑音频解码WebSocket 任务被抢占Pong 没打出去。解决办法是单独给 WebSocket 心跳逻辑提一个高优先级任务或者把 Uvicorn 的 ping_interval 调大到 60 秒。第三步查 ESP32 的 lwIP TCP keepalive。默认是关闭的长连接一旦经过 NAT 或运营商级设备中间映射很容易被回收。打开CONFIG_LWIP_TCP_KEEPALIVE设置空闲 10 秒、间隔 15 秒探测一次能保证网络路径上的设备不会把连接当僵尸清理掉。第四步查 WiFi 省电策略。ESP32 默认的 modem sleep 会在空闲时让 WiFi 模块打盹丢包后才重传Ping/Pong 在这种模式下经常超时。对连续对话这种场景我把CONFIG_PM_ENABLE打开同时用esp_pm_config禁止 WiFi 进入最低功耗档位只用 modem sleep 的浅睡眠。调试完效果立竿见影一周内再没出现过 1006。5.2 H5 调试能连、打包成 App 连不上的经典问题这个坑很多人问。H5 页面调试 WebSocket 一切正常打包成 Android/iOS App 后偏偏连不上。常见原因有三个。一是明文流量限制。Android 9 开始默认禁止应用使用明文流量ws://会被系统直接拦截。H5 在 Chrome 里调试通常不受这个限制或者已经手动允许所以没问题App 里如果用ws://十有八九是这里断了。解法是生产环境一律wss://调试环境在network_security_config.xml里单独放行。二是鉴权参数不一致。H5 调试时 URL 里带的 token 是浏览器会话里实时生成的App 里可能写死了过期 token服务端握手直接拒绝。连接建立前后要打日志对比两边实际发送的 URL、query 参数和 header。三是客户端 WebSocket 库差异。Android 的 OkHttp 对 Ping/Pong 的处理和浏览器不完全一样某些版本会把服务端回包当成普通数据帧处理。如果服务端和客户端都严格按 RFC 6455 实现一般不会有问题一旦有优先怀疑集成库的版本换一个成熟实现验证。5.3 功耗与连续对话的平衡连续对话意味着玩偶必须一直处于“监听-可能响应”的状态WiFi 要保持长连接功耗比原来“按一下才干活”的模型高不少。实测在 3.7V、1000mAh 电池下连续对话场景的平均电流约 220mA能撑 4 小时左右如果只是被动等待唤醒词平均电流可以降到 50-60mA。几个有效的降功耗手段等待唤醒阶段只跑 WakeNet用 ESP32 的 DSP 指令做语音前端主核不用频繁唤醒。进入对话状态后开启浅层 modem sleepDTIM 调到 3WebSocket 心跳从每 10 秒一次降到每 30 秒一次减少空口唤醒。TTS 播放是 CPU 高负载场景播完立刻把主频从 240MHz 降到 80MHz让出带宽和功耗。关闭不必要的日志输出ESP-IDF 的printf在低功耗模式下反而会拉高瞬时功耗。对玩具类产品我的建议是不要追求纯真无线、连续对话一整天而是“正常对话 2 小时 充电底座”。连续对话真正消耗的不仅是电量还有交互频率带来的网络流量和云端成本产品上要提前想清楚套餐和限流策略。5.4 OTA 升级连续对话版本必须有的“后悔药”协议重构后设备端逻辑变化非常大不可能靠一次烧录就交付必须有 OTA 通道。ESP32 的 OTA 流程分两个ota_0、ota_1分区新固件写入非当前分区校验通过后切换启动分区重启后标记“当前分区可用”。如果新固件启动后 30 秒内没有上报心跳或者连续重启三次失败自动回滚到旧分区。固件体积是另一个问题。原固件约 1.8MBOTA 每次都传全量包不划算。服务端用差分算法生成补丁包实测能压到 800KB 左右设备端用 HTTP 分块下载边下边写 OTA 分区。整个升级过程在 2.4GHz Wi-Fi 下大约 15-20 秒期间对话会中断所以升级最好放在用户不使用时触发并给设备端加一个“升级中”的状态展示。这里我踩过的最深的坑是OTA 升级过程中 WebSocket 连接保持不下线下载分块和对话音频同时在收发lwIP 的 buffer 被占满两个任务互相饿死。最终做法是升级前先主动断开 WebSocket进入“静默升级”状态升级完成再重新连。虽然损失了一点实时性但稳定得多。6. 重构后的实测数据与后续可扩展方向6.1 同一套硬件上的关键指标对比整个重构完成后我在同一块 ESP32 开发板、同一个服务端集群上做了新旧链路对比指标重构前HTTPJSON重构后WebSocket二进制用户说完到玩偶开口首包2.8-3.5 秒1.4-2.0 秒单轮 5 秒语音上行体积约 220KBbase64JSON约 165KB裸 PCM帧头单轮 TCP 连接数每轮 1 次 各自 TLS 握手整个会话 1 次长连接是否支持用户打断不支持支持300ms 内响应断线后恢复用户需重新按键自动重连并恢复上下文连续 50 轮对话内存偶发 malloc 失败无动态分配路径延迟的降低并不只是某个环节变快了而是整个模型从“等完整请求”变成“边生成边传输”。哪怕单个环节只省了几十毫秒累积起来就是 1 秒以上的体验提升。这个体感差异在语音交互里能明显改变用户对产品的评价。6.2 一些对后续迭代有价值的经验讲几个这次重构中沉淀下来的经验供大家直接抄。二进制协议是稳定基石。音频帧、控制帧、服务端响应全走同一套帧结构后续加“播报进度上报”、“电量上报”都只是新增 msg_type不破坏老设备兼容性。序列号哪怕不补传也要留。它不仅是丢包检测工具更是排查问题的关键索引。服务端日志里有了 sequence就能精确定位是采集端卡住还是网络丢包还是服务端处理超时。把 VAD 逻辑放在设备端。服务端做 VAD 需要把音频完整上送既费流量又费时间。设备端用 ESP-SR 或能量阈值先判断“用户有没有在说话、有没有说完”服务端只需要跟着控制帧走链路简洁太多。AEC 比预想中重要。没有回声消除时玩偶自己说话会打断自己这是连续对话上线前必须解决的基础问题。ESP-SR 的 AEC 已经够用不用自研。6.3 下一阶段的三个方向这次重构解决的是“能不能连续对话”的问题但距离“对话得像人”还有距离。下一步我计划做三件事一是在设备端部署更强的关键词唤醒和本地意图识别把“在吗”、“闭嘴”这类高频指令从云端剥离进一步压低延迟二是尝试 Opus 压缩音频上行16kbps 码率下语音质量仍可接受能把上行流量再砍一半三是给服务端加基于 sequence 的自动补传机制在网络抖动时由服务端主动请求缺失帧而不是靠设备重连来纠正。如果你们也在做类似的 ESP32 语音交互产品或者正在从一问一答往连续对话迁移建议先小步验证协议设计用模拟数据跑通收发再接入真实 ASR/TTS。别一上来就把大模型、流式识别全接上链路一长排障会非常痛苦。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价