资讯动态

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

发布时间:2026/9/12 16:50:32 来源:尧图企业网站定制
最近我在重构一个ESP32 AI玩偶的音频链路核心目标就一个让它从那种按一下说一句的“对讲机模式”进化成可以随时插话、连续对话的“电话模式”。这个项目折腾了挺久踩了不少坑尤其是把JSON文本链路换成WebSocket二进制音频链路那一部分值得单独拿出来聊一聊。如果你也在做类似的设备端AI交互或者正在纠结ESP32音频传输方案这篇内容应该能帮你省下一大把时间。先说结论从“能对话”到“连续对话”不是改几行代码的事而是整套音频采集、编码传输、服务端编排、播放打断的链路都要重构。核心抓手就是WebSocket加二进制帧再加上一套配合得当的打断机制。下面我按自己的实际改造过程展开讲。1. 从“对讲机”到“打电话”玩偶对话体验的质变1.1 旧方案为什么只能“能对话”最早的版本其实并不复杂玩偶上放一个按钮用户按住说话松手后ESP32把录音文件通过HTTP上传到服务器服务器跑一轮ASR语音识别、大模型回复、TTS语音合成再把生成的音频文件下载回设备播放。这个流程看起来每一步都合理但实际体验很糟心。最大问题是回合感太重。用户必须等“录音上传 → 识别 → 思考 → 合成 → 下载 → 播放”这一整条链路全部走完才能听到回复中间任何一步慢一点等待时间就直奔3秒以上。而且这期间用户完全无法插话想补充一句“不对我是说红色的那件”都没办法只能干等播放完再重新按按钮。另一个问题是HTTP请求本身的割裂感。录音是一段一段的TTS音频也是一整块一整块下载设备端根本不知道服务端什么时候会回复、回复到哪一步了。这导致ESP32上的状态机写得非常别扭一会儿在“等待上传”一会儿在“等待下载”稍微有点网络波动就超时。说白了这套架构本质是“文件传输”不是“对话”。1.2 连续对话到底卡在哪几个环节想把体验从“回合制”变成“实时对话”至少得拆出四个关键能力全双工音频流、流式识别与合成、随时打断、以及低延迟的端到端链路。全双工音频流要求ESP32能一边采集麦克风声音、一边播放扬声器声音同时通过一条长连接和服务器双向传输音频数据。这里HTTP基本没用必须上WebSocket这类全双工协议。流式识别和流式合成则是服务端的活。ASR不能等用户说完一整句再出结果最好边说边出中间结果TTS也不能等大模型把整段话生成完再合成而是要流式生成、流式下发这样首包延迟能大幅压低。随时打断考验的是设备端和服务端的配合。用户一旦开口设备端要能立刻感知到声音告诉服务端“停止当前合成和播放”同时本地播放器要马上清空缓冲区、停止播放让用户的声音立刻能被识别。这里涉及VAD语音活动检测、播放缓冲管理和指令优先级设计。端到端延迟则是贯穿所有环节的指标。每一段音频的采集、封包、传输、处理、回传都有延迟任何一个环节出现长尾都会让对话显得迟钝。后面我会具体算一笔时间账。2. 链路选型为什么必须是WebSocket加二进制2.1 HTTP轮询与WebSocket的本质区别有人会问为什么不用HTTP轮询每隔几百毫秒问一次服务器“有没有新音频”听起来也行啊。但问题在于HTTP轮询的实时性本质上是被动查询服务端有数据也要等客户端来问。为了做到低延迟轮询间隔必须很短而每个HTTP请求都有完整的头部开销、连接建立开销ESP32这种资源受限设备根本扛不住高频轮询。而且双向都要轮询代码复杂度直接翻倍。WebSocket就不一样。它是一条TCP长连接建立后客户端和服务端随时可以互发数据真正的全双工。对于音频流这种高频、小包、持续不断的数据WebSocket的开销远小于HTTP。更重要的是WebSocket原生支持二进制帧opcode0x2音频数据可以直接塞进帧里不用像文本帧那样处理字符编码也没有Base64膨胀问题。2.2 音频数据走JSON的代价我见过不少项目喜欢把所有数据都包成JSON音频也不例外转成Base64字符串塞进JSON字段里。这种方案在调试时确实方便浏览器里能直接看包内容但一到真实设备上就露馅了。首先是体积膨胀。Base64编码会把每3个字节变成4个字符体积膨胀约33%。16kHz、16bit、单声道的PCM裸流每秒就是32000字节转成Base64后约42667字节。ESP32的WiFi上传带宽通常只有几Mbps这部分膨胀在持续传输时非常可观。其次是解析开销。ESP32上解析JSON本身就吃内存和CPUBase64解码还要额外来一轮。设备端Flash和RAM本来就紧张这点开销完全不值得。而且JSON解析一旦遇到格式错误整个帧就要丢弃调试起来非常痛苦。我的建议很直接音频这种二进制数据就用二进制帧传文本类指令再走JSON不迟。2.3 音频参数与编码格式选择PCM vs Opus音频参数的选择直接决定带宽、延迟和音质。我最终定的是16kHz采样率、16bit位深、单声道。为什么是16kHz因为绝大多数ASR引擎包括云端和开源的都是以16kHz作为标准输入采样率。玩偶对话场景也不需要高保真音乐16kHz足够覆盖语音频带8kHz带宽且每帧数据量可控。编码格式我在PCM裸流和Opus之间犹豫了很久。PCM的好处是零编解码延迟、零CPU开销、实现最简单320字节一帧20ms音频打包进WebSocket直接发。坏处是带宽高每秒32KB对WiFi来说其实完全够用唯一怕的是WiFi拥塞时的重传放大。Opus的好处是压缩率高同样的语音质量只需约16~24kbps比PCM省十倍以上而且Opus有完善的丢包补偿。但代价是ESP32上需要跑Opus编码器虽然乐鑫有ESP-ADF集成了但编解码延迟、内存占用、以及帧边界处理都更复杂。实测下来在2.4G WiFi环境还不错的情况下PCM裸流完全够用延迟还更低。如果你要跑4G Cat.1模块或者网络环境很差再考虑上Opus。以下是我的参数配置表参数数值说明采样率16000 Hz匹配ASR引擎标准输入位深16 bit单声道PCM帧长20 ms每帧320字节上行比特率256 kbps32KB/sWiFi下可接受编码格式PCM裸流也可选Opus24kbps3. 二进制帧协议设计自定义协议头是关键3.1 帧头设计与消息类型定义直接用裸PCM流在WebSocket里传也不是不行但一旦要加事件通知、配置同步、打断指令你就需要一套协议来区分“这是音频数据”还是“这是控制指令”。我设计了一个非常轻量的帧头1字节版本号、1字节消息类型、2字节负载长度大端序后面跟实际负载。总共4字节头加上负载一起塞进WebSocket二进制帧。这个开销完全可以忽略。消息类型我定义了这么几类上行音频帧、下行音频帧、VAD开始事件、VAD结束事件、打断指令、心跳包、配置协商。上下行音频帧就是纯粹的PCM数据控制类消息负载可以是空也可以带一个JSON字段说明细节。这里有个小技巧控制消息的负载虽然可以走JSON但负载一般都很短几十个字节JSON解析不会造成压力调试时又能直接打印非常方便。3.2 打断与VAD事件如何编码连续对话里最核心的指令就是“打断”。当设备端检测到用户开始说话而此时设备正在播放TTS音频设备端要做两件事本地立刻停止播放并清空播放缓冲区同时往服务端发一个打断指令让服务端停止当前TTS合成和下行音频流。打断指令的编码要尽量短。我的做法是类型字段设为INTERRUPT负载为空。服务端收到后立刻丢弃当前TTS任务的剩余音频和状态让ASR继续解析新一段用户语音。这里有个关键点打断指令必须在音频流里“插队”。WebSocket本身是按发送顺序送达的如果设备端先发了几帧PCM、再发打断指令服务端可能已经把后面几帧PCM交给TTS播放了。所以设备端一旦检测到说话要立刻停止发送上行音频帧吗不是此时上行音频仍然要发因为那正是用户说话的内容需要交给ASR识别。真正要停的是下行的TTS播放。协议上我的处理方式是下发通道是独立的设备端播放任务收到打断指令后服务端通过新的一条下行消息类型STOP_TTS来通知播放任务停止。而上行通道继续走音频帧。这样职责清晰上行只负责用户语音下行既负责TTS音频也负责停止信号。3.3 心跳与断线重连机制设计音频链路长时间开着必然要处理网络切换、WiFi休眠、服务端重启这些情况。心跳机制必不可少。我的做法是每10秒发一次空负载的HEARTBEAT帧服务端收到后回一个同样的帧。如果设备端连续3次没收到心跳响应就判定连接已死主动重连。重连策略有个容易踩坑的地方不能一断就立刻重连否则在网络抖动时会造成“重连风暴”。我用的是指数退避第1次重连等1秒第2次等2秒第4秒、8秒、16秒最大到30秒。同时保留当前上下文ID重连后如果对话还没结束可以继续发音频。我实际测试过WiFi路由器重启这种场景设备端大概30秒内能自动恢复连接不需要用户干预。另外要注意ESP32的TCP栈在断线后不会立刻感知需要通过心跳超时来触发关闭和重连所以心跳间隔不能设得太长。4. ESP32端采集、播放与打断的实战实现4.1 I2S硬件配置INMP441与MAX98357A玩偶的音频硬件我用的是两颗非常常见的芯片INMP441做麦克风采集MAX98357A做I2S功放输出。这两颗芯片都是I2S接口价格便宜资料多ESP32直接支持调试起来很顺手。I2S配置上有几个细节容易出错。INMP441是单声道麦克风但它的I2S输出格式是24bit左对齐而ESP32的I2S外设需要明确配置成I2S_CHANNEL_FMT_ONLY_LEFT或I2S_CHANNEL_FMT_RIGHT否则采出来的数据会左右声道混叠。MAX98357A配置为标准的I2S格式不需要额外配置声道选择但要注意它的BCLK和LRCLK必须和INMP441共用同一组I2S引脚时采样率要一致。我直接用ESP32的一路I2S外设BCLK和LRCLK共用DIN给MAX98357ADOUT接INMP441这样省引脚配置也简单。DMA缓冲区大小很关键。默认配置下I2S的DMA buffer如果太小比如只有80ms网络一抖动就会导致播放断流。我最终把RX缓冲区设为480msTX缓冲区设为320ms实测下来既能抗网络抖动又不会让延迟大到不可接受。注意ESP32的I2S驱动里DMA buffer是多个描述符链起来的总大小是buffer_count乘以buffer_len两个参数都要调。4.2 双任务架构录音任务与播放任务ESP32跑的是FreeRTOS天然适合用双任务。一个任务负责从I2S RX读数据封装成二进制帧通过WebSocket发送另一个任务负责从WebSocket接收下行帧把PCM数据写入I2S TX播放。两个任务之间不要直接共享数据我用一个消息队列做中转。上行方向录音任务读到的PCM直接发送不需要经过队列因为发送本身是阻塞的能起到流量控制的作用。下行方向播放任务从WebSocket事件回调里拿到PCM数据后放进一个环形缓冲区ring buffer播放任务再从缓冲区读数据写I2S。这样WebSocket回调不会阻塞播放任务又能独立控制节奏。这里有一个需要注意的点WebSocket事件回调运行在ESP-IDF的默认事件任务里优先级不低。如果你在回调里做耗时操作比如直接写I2SDMA等待会影响其他协议栈事件处理甚至触发看门狗。所以回调里只做数据搬运到ring buffer这一步写I2S必须放到独立播放任务里。4.3 本地VAD与打断逻辑端侧VAD我用的是最简单的能量检测。每20ms一帧PCM数据计算帧内采样点的平均幅度RMS。如果连续若干帧超过阈值判定为“用户开始说话”如果连续超过500ms低于阈值判定为“用户说完”。这个简单方案有个致命缺陷扬声器正在播放TTS时麦克风会拾取到扬声器的声音能量检测会把TTS声音误判为用户说话导致自打断。我的处理办法是播放音频期间降低麦克风采集灵敏度也就是把VAD阈值动态调高。但这样仍然会有漏检尤其是TTS音量较大的时候。更稳妥的方案是做简单的回声估计播放任务知道当前播放的音量把最近几十毫秒播放信号的能量作为参考只有当麦克风采集能量明显大于“预估回声能量 固定余量”时才判定为用户说话。这个方案不需要复杂的AEC算法实测在玩偶这种近距离场景里足够用。我还加了一个锁定时间一旦判定用户说话200ms内不再做新的VAD判决避免抖动脉冲误触发。打断动作本身要非常快。播放任务一旦收到来自VAD的“说话开始”信号立刻执行三步清空ring buffer里所有未播数据、把I2S输出音量拉到静音、发送打断指令给服务端。这三步要在50ms内完成否则用户会听到TTS说到一半被硬生生掐断的杂音。实测下来从用户开口到扬声器静音大概100ms左右体感上就是“我刚一说话它就不说了”比较自然。4.4 WebSocket客户端关键配置ESP32端我用的官方esp_websocket_client组件底层是esp-tls。有几个配置项必须调好不然音频流很容易出问题。第一是接收buffer大小。默认的receive buffer可能只有几KB下行音频帧一次发几KB就会触发WS_DATA_NOT_ENOUGH_MEM。我把buffer_size提高到16384字节同时max_reconnect_count设为无限重连。第二是reconnect_timeout_ms默认可能太快我设成2000ms避免网络不好时疯狂重连。第三是task_stackWebSocket组件会创建独立任务处理大帧时需要较大栈空间我设了8192字节否则大负载解析时会栈溢出。还有一点容易被忽略esp_websocket_client发送二进制帧时长度参数必须是实际字节数如果传了0组件会按文本帧处理。我踩过一次这个坑导致服务端死活解析不出音频数据。后来统一封装了一个send_binary(type, payload, len)函数先拼帧头再发确保类型字段是WS_TRANSPORT_BINARY。5. 服务端从ASR到大模型再到TTS的流式编排5.1 WebSocket网关与二进制帧解析服务端我用Go写了WebSocket网关主要是看中它对高并发长连接的支持以及二进制帧处理方便。每个玩偶设备连接上来后网关维护一个Session对象里面包含设备ID、上下行通道、当前ASR任务、TTS任务状态。二进制帧解析其实很简单读4字节头判断类型然后按不同的类型分发。但这里有个工程化的问题多个设备的数据包在服务端是交错到达的不能全局顺序处理。我一开始只开了一个全局goroutine处理所有帧结果一个玩偶的TTS长音频把整条链路堵住了其他玩偶的语音识别全部卡顿。改成每Session一个读goroutine加一个处理goroutine后问题立刻消失。也就是说并发模型必须按连接隔离。5.2 流式ASR与LLM/TTS的衔接流式链路是服务端的重头戏。设备端上行音频帧到达后我直接把PCM数据喂给流式ASR引擎。Google Cloud Speech、Azure Speech、阿里云智能语音交互这些主流的流式ASR都支持二进制音频流输入但要注意格式协商采样率、编码、是否带标点都要预先配置好。ASR识别出中间结果后不能等用户说完才触发大模型。我的策略是维护一个“当前用户话术”ASR每吐出一个稳定片段就追加进去。当VAD结束事件到达时说明用户说完了立刻把完整话术发给大模型。大模型可以用流式输出但音频场景下没有太大必要因为大模型回复通常很短等待时间主要花在首token上。关键优化是不要让大模型把整段回复生成完再给TTS而是一旦大模型开始输出文本就立刻按句子边界切分把文本块发给TTS引擎流式合成合成完一块发一块。这样音频首包可以在用户说完后1秒左右到达设备端。TTS服务我用的流式接口每次返回不定长的音频数据。服务端把这些音频数据按20ms帧切分封装成下行音频帧发给设备。这里注意一个点TTS流式返回的音频采样率可能和ASR不一致比如TTS输出24kHz但设备端I2S播放可以配置成24kHz不过为了统一我在服务端做了重采样到16kHz。这样设备端只需固定16kHz播放省掉不少麻烦。重采样用soxr库质量高但CPU开销有点大后来换成了简单的线性插值对话场景完全够用。5.3 多用户并发与资源占用玩偶如果量产服务端要考虑的不只是单机性能还有资源管理。WebSocket长连接本身很便宜几万个连接在一台8核16G的机器上完全没问题。真正吃资源的是ASR和TTS实例。通常云服务按并发路数计费所以必须严格控制每个Session的并发路数一个Session同一时间只允许一路ASR、一路TTS新任务必须先取消旧任务。另一个容易忽略的是连接空闲超时。很多设备放在那里不动长连接一直开着如果服务端没有空闲检测连接数会被无效连接占满。我的做法是Session如果超过120秒没有收到任何上行帧包括心跳就主动关闭连接让设备端重连。这样既释放资源也验证了设备端断线重连机制的可靠性。服务端还需要处理一种特殊情况设备端发来打断指令后ASR正在进行中间识别。这时不能粗暴取消ASR任务而是要把已经识别到的文本追加进“当前用户话术”因为用户打断TTS后往往会继续补充内容比如“不是那个是左边那个”。正确处理是将打断前的识别结果保留与打断后的新音频一起拼成完整话术再交给大模型。这一步做不好用户会觉得设备“听不懂补充的话”。6. 常见问题与调试实录6.1 WebSocket 1006 断连排查思路WebSocket 1006是一个很令人头疼的关闭码它表示连接被异常关闭没有正常关闭握手。在ESP32上这个码出现的原因五花八门。我遇到最多的是三种WiFi信号弱导致TCP重传超时、服务端主动断开但设备端没收到FIN包、以及ESP32内存不足导致WiFi协议栈崩溃。排查方式我建议分三步走。第一步看服务端日志如果服务端主动关了连接一定会有对应日志第二步在ESP32端打开esp_websocket_client的详细日志它会打印底层TLS/TCP状态能看到是接收超时还是发送失败第三步看系统剩余内存ESP32在长时间运行后如果内存碎片化严重WebSocket收发包都会失败。我用esp_get_free_heap_size()定时打印剩余内存发现最小可用堆从50KB慢慢掉到20KB以下基本可以断定是内存问题。后来换成静态分配缓冲区、减少动态字符串拼接内存稳定在40KB以上1006断连就很少出现了。另外一个隐蔽的坑如果ESP32用的是旧版本IDFesp_websocket_client在收到大帧时可能会因为内存分配失败直接断连。升级到IDF 4.4及以上并按照我前面说的把buffer size调大基本能解决。6.2 音频卡顿与延迟优化音频卡顿几乎都是网络抖动和服务端处理抖动叠加的结果。设备端播放缓冲区设了320ms后单次网络抖动基本能扛住。但如果WiFi本身信号差持续的丢包还是会让音频断断续续。这种场景我加了一个“追帧”机制播放任务检测到ring buffer里积压的数据超过500ms就把播放速度临时加快5%直到积压降到200ms以下。听感上几乎察觉不到变速但能明显减少延迟累积。延迟优化方面我端到端实测的数据是这样的用户说完最后一个字到设备端播放出TTS第一个字节总计约1.2秒。其中录音封包20ms、网络RTT约50ms、ASR尾包处理和VAD确认约300ms、大模型首token约300ms、TTS首包约300ms、下行网络RTT约50ms、播放延迟约200ms。如果你想继续压缩大头在ASR的VAD确认时间。可以把VAD结束判定从“静音500ms”缩短到“静音300ms”但会增加误断句。更高级的做法是交给ASR服务端的端点检测让ASR自己判断说话结束设备端VAD只做辅助。我后来改成设备端检测到静音300ms时发一个“疑似结束”事件服务端结合ASR结果确认是否真的要结束两者一致才算数。这样误断和延迟之间能取得一个更好的平衡。6.3 回声导致的自打断这个是我调试中花时间最多的问题。一开始用简单的能量检测VAD每次TTS一响麦克风就检测到高能量判断为用户说话然后触发打断TTS被自己掐断形成一种“说一句就停”的诡异节奏。我后来做的动态阈值方案算是能用了但还有一个更好的补救手段播放TTS期间把麦克风采集到的信号通过一个高通滤波器滤掉低频回声成分。因为TTS从扬声器出来经过空气传播低频成分衰减比直接电信号小但到达麦克风后会有明显的混响尾巴。高通滤波后直接回声能量大幅降低用户语音因为是近场说话高频成分保持较好检测更准确。滤波器用的是简单的二阶IIR在ESP32上连100个周期都不到几乎不占CPU。另外要提一下硬件的坑。我把扬声器音量调大后回声问题成倍放大后来发现是MAX98357A的功放和INMP441贴得太近机械振动直接传导到麦克风。在PCB布局上扬声器下方加了泡棉减震、麦克风用硅胶套隔离后回声问题改善非常明显。所以如果你也被自打断困扰先检查硬件结构再调软件顺序错了会事倍功半。6.4 实测效果与体验对比改造完成后的体验和旧版本简直天壤之别。旧版本从按下按钮到听到回复平均4秒以上而且完全不能插话。新版本用户正常说话结束约1.2秒开始播放TTS播放过程中任何时候开口设备约100ms内静音并开始收新语音大模型会把打断前后的内容整合理解。我还专门做了连续追问测试问完“今天天气怎么样”紧接着不等播放完就说“那明天呢”玩偶能正确识别出是补充问题并回答明天的天气。这一套链路跑通之后玩偶才算真正有了“对话感”。当然这个过程中还有很多细节可以优化比如多轮记忆、声纹区分、以及更自然的TTS韵律但底层这套WebSocket二进制音频链路已经是稳定基座后续迭代不用再动底层。回到开头那个问题从“能对话”到“连续对话”本质是抛弃文件思维、拥抱流思维。WebSocket加二进制帧只是实现手段真正核心的是打断机制、流式编排和端到端延迟的控制。我个人最大的体会是不要一上来就堆复杂算法先把PCM裸流的全双工链路调通再逐步加VAD、回声处理、编码压缩每一步都有明确的体验提升调试起来心里也有底。

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

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

免费获取报价