ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于WebSocket的ESP32全双工音频流设计:从能对话到连续对话

基于WebSocket的ESP32全双工音频流设计:从能对话到连续对话 做 ESP32 AI 玩偶的开发者应该都有同感一开始做的“能对话”其实只是“能一问一答”。按下按钮录一段音上传识别等大模型回复再合成一段音频放出来整个过程像在对讲机里聊天。这次我重新设计了整个音频链路用 WebSocket 二进制帧把 ESP32 的采播链路改造成真正的全双工流式管道让玩偶从“能对话”变成了“连续对话”——用户随时开口随时可以被响应也可以随时打断。这篇文章把这次重构的思路、协议设计、ESP32 端改造、服务端接续方案和实际踩坑记录完整写出来希望能给做同类智能硬件、AI 玩具的工程师一点参考。1. 先讲清楚为什么旧链路做不了连续对话1.1 一问一答式交互是怎么卡住的大多数 ESP32 AI 玩偶的初版方案都是这种模式ESP32 通过 I2S 接一个麦克风用户按下某个按键或喊完唤醒词后开始录音录完一段完整语音要么存成 WAV 文件要么直接用 HTTP multipart 上传到服务器。服务器把音频送到 ASR 识别识别文本交给 LLMLLM 返回回答文本再交给 TTS 合成一整段音频文件最后 ESP32 下载这个音频文件播放。这套链路看起来没什么问题但实际体验非常“钝”。我实测过一次从用户停止说话到玩偶开口中间经常要等 3 到 5 秒。如果用户话说得长一点录音文件会更大HTTP 上传需要更久TTS 合成整段音频也需要更久后面所有环节全被最长的那段延迟拖着走。更难受的是用户一旦发现自己说错了或者不想听玩偶继续讲没有任何办法打断只能等它播完。这种体验放在“AI 玩具”上用户新鲜感一过就不愿意碰了。问题的本质是链路结构错了。HTTP 长连接不适合承载实时音频流服务端必须先把整段音频收完才能做识别TTS 也必须先合成完整文件才能传输播放。整个系统是“先录完整段再处理完整段再播放完整段”的离线流水线而不是“边说边传、边收边播”的实时管线。要做连续对话第一件事就是把这个管线拆掉换成流式处理。1.2 半双工与全双工的本质差异从通信模式上看旧链路是典型的半双工同一时间只有一端在“说话”。ESP32 上传音频时服务端回不了话服务端播放 TTS 时ESP32 又没法把麦克风数据传上来。这样带来的不仅是延迟还有交互上的“死板”AI 无法在用户还没说完时就判断意图也无法在播放中途被用户打断后立即切换内容。连续对话要求的是全双工上行音频流和下行音频流同时跑而且两端可以基于时间戳和事件自由切换说话角色。WebSocket 天然是长连接全双工通道尤其适合这种场景。建立一次连接后ESP32 可以持续把麦克风采集到的 16 kHz 音频帧通过二进制消息推到服务端服务端也能随时把 TTS 流式生成的音频帧推回来。用户说话时不需要等待AI 回复时用户也可以插话所有数据都走在同一条连接上省掉了 HTTP 频繁握手和文件传输的开销。这听上去像是把通信协议换掉就行但真正改起来涉及音频帧格式、编码选型、ESP32 内存管理、播放队列、服务端状态机和断线重连一环扣一环。下面我把这次重构的完整方案拆开讲。2. 链路重构的总体思路与协议选型2.1 为什么最终选 WebSocket 二进制通道我在方案选型时对比过三条路HTTP/2 的流式接口、RTSP 音视频流、WebSocket。HTTP/2 虽然在服务端可以做 server push但 ESP32 上的 HTTP/2 客户端库相对笨重TLS 握手内存开销大而且流式上传音频时服务端很难舒服地把下行音频和控制事件混在同一连接里推送。RTSP 本身更适合摄像头这种视频监控场景信令复杂要处理 SDP、RTP 打包之类的问题对 AI 玩偶这种低功耗小设备来说太重了。最后选 WebSocket 是基于两个实际理由。第一它是一根长连接而且天然全双工服务端可以直接向 ESP32 推音频不需要 ESP32 轮询。第二WebSocket 的二进制消息可以直接承载 PCM 或 Opus 裸流不需要像 JSON 那样做 Base64 编码。可能有人觉得 Base64 就是多花点字节而已但在 ESP32 上影响不小Base64 会让音频体积膨胀约 33%意味着同样一段 16 kHz 单声道 PCM16 数据每秒要从 32 KB 变成约 42 KB。WiFi 上传带宽有限内存拷贝还多一轮划不来。开发的时候可能会有疑问WebSocket 和 TCP 裸连接有什么区别直接吞掉 WebSocket 协议栈用 TCP 自定义协议不是更省内存吗确实更省但代价是完全失去兼容性。WebSocket 客户端库在 ESP32 上已经很成熟而且服务端各种框架Node.js、Go、Python都有现成支持。更重要的是WebSocket 自带基于 HTTP 的升级握手和 Ping/Pong 心跳机制我可以在不额外设计 socket 层协议的情况下把精力全部放在音频帧结构上。2.2 音频帧格式设计信令和音频要分开WebSocket 本身区分文本帧和二进制帧这个特性一定要用好。我的设计原则是所有控制信令走文本帧所有音频数据走二进制帧。不要把音频塞进 JSON 里再发也不要把控制命令用二进制自定义结构体搞得很复杂。文本帧可读性强调试时抓包一眼就能看懂信令二进制帧则尽量精简只放解码必需的信息。帧头我设计成一个固定 12 字节的结构2 字节魔数0xAA55用于快速校验1 字节版本号1 字节消息类型4 字节序列号4 字节时间戳。后面接一帧音频负载。消息类型区分上行录音数据、下行播放数据、VAD 事件、打断事件、服务端状态通知等。这里的序列号不是可选的它是整个链路能实现“连续对话”的基石音频每一帧按发送顺序递增编号服务端用它识别乱序和丢帧客户端重连后用最后确认的序号对齐状态。这里有一个容易踩坑的地方不要把二进制音频帧直接当成“一次性大块数据”发送。ESP32 的内存有限一次发送 64 KB 数据会导致 RAM 暴增、WiFi 发送卡顿。我在实际设计中把每帧音频控制在 320 字节左右也就是 16 kHz、16 bit、单声道下 10 ms 的数据长度16000 * 2 * 0.01 320 字节。10 ms 一帧是比较平衡的选择网络包不大播放端可以做低延迟缓冲服务端 ASR 对 10 ms 的音频块也很友好。2.3 编码选型上行 PCM16下行 Opus 的折中方案音频编码是这次重构里一个需要认真权衡的点。最开始我想全链路都用 Opus这样带宽最低、质量最好但 ESP32 上跑 Opus 编码需要引入编解码库CPU 占用高不说播放端还要处理解码和重采样内存和代码复杂度都上去了。麦克风采集到的原始数据是 PCM16如果直接编码成 Opus上行链路会引入几十毫秒的编码延迟对服务端 ASR 来说PCM16 反而更方便因为大部分流式识别接口都提供原始 PCM 输入。我最后采用的方案是上行不压缩直接传 PCM16下行用 Opus 压缩服务端把 TTS 的 WAV/PCM 数据编码成 Opus 再推给 ESP32ESP32 用优化过的解码器解成 PCM 后送 I2S 播放。为什么这么分配因为上行是 ESP32 采集的原始音频采样率固定 16 kHz数据量每秒 32 KBWiFi 上传完全扛得住没必要增加 MCU 端的编码压力下行是服务端 TTS 合成的音频采样率可能是 24 kHz 或更高如果不用 Opus音频文件会很大播放延迟也会变长。用 Opus 可以把下行码率压到每秒 16 到 24 kbps对 Wi-Fi 传输和 ESP32 播放缓冲都很友好。这里补充一个关键细节下行音频采样率尽量保持 24 kHz 或者 16 kHz不要直接使用 TTS 服务的 48 kHz 原始输出。ESP32 的硬件 I2S 可以配置主时钟和位时钟但如果你在代码里频繁重采样CPU 占用率会明显升高。我在服务端把 TTS 输出统一重采样到 24 kHz再编码为 OpusESP32 端解码后直接用 24 kHz 播省去端上重采样的麻烦。3. ESP32 端改造从录音到边采边发的管线3.1 I2S 采集与环形缓冲别让 WiFi 堵住麦克风ESP32 的录音通常走 I2S 接口接数字麦克风我这次用的是 INMP441一条 I2S 总线同时接麦克风和一个 I2S 功放模块 MAX98357A。这里第一个坑就是I2S 总线的 RX 和 TX 如果共用一条数据线必须分时处理否则采集和播放会互相干扰。我的做法是把采集和播放放在两个不同的 I2S 端口上一个管麦克风一个管功放代码清晰信号也不会串。采集端不能直接用阻塞式读取 I2S 然后立刻 WebSocket 发送因为 WiFi 发送可能会因为网络拥塞而阻塞几十毫秒这期间麦克风数据的 DMA buffer 会溢出。我设计了两个角色一个采集任务专门从 I2S DMA 里读数据写入一个预分配的环形缓冲区RingBuffer另一个发送任务从环形缓冲区里取数据按 10 ms 一帧打包成 WebSocket 二进制消息发送。环形缓冲区的容量我设置为 12 KB大约能缓存 300 ms 的音频。这样即使 WiFi 偶发卡顿麦克风数据也不会立刻丢失。这个设计中有一个值得注意的参数环形缓冲区的大小。设置太大内存被录音缓存占掉播放端和协议栈没内存用设置太小WiFi 稍微抖动一下就开始丢帧。我测试下来在不使用 PSRAM 的前提下12 KB 是比较平衡的值。如果你的模块有 PSRAM可以放大到 32 KB但要注意环形缓冲区的读写指针必须用 volatile 声明避免编译器优化导致的不一致。3.2 WebSocket 发送队列与流控ESP32 端的 WebSocket 客户端我用的 ESP-IDF 自带的 esp_websocket_client 组件。这个库支持二进制消息和异步事件回调但它的底层发送是阻塞式的如果你在采集任务里直接调用 send一旦网络速度跟不上整个采集流程会被拖死。我的处理方式是单独创建一个发送队列发送任务从队列里取音频帧然后调用 WebSocket API 发送。采集任务只负责入队不等待发送结果。这个队列的容量也要控制。如果队列太长用户停止说话后还会有大量音频帧积压在队列里慢慢上传服务端 VAD 会一直收到数据导致“说完话后很久才响应”的怪问题。我把队列长度限制在 50 帧也就是 500 ms 的音频量。队列满了之后新的音频帧直接丢弃优先保证实时性。对实时音频来说丢 100 ms 的音频远比重演 500 ms 的旧数据更符合连续对话的体验。流控方面还需要一个 ACK 机制。服务端收到一帧音频后不需要逐帧确认那样太浪费带宽。我采用“每 10 帧确认一次”的批次 ACK服务器收到第 10、20、30……帧时回一个 JSON 信令携带已确认的最大序号。ESP32 端收到 ACK 后更新一个 last_acked_seq 变量本地队列清理时只清理序号小于等于 last_acked_seq 的帧。这样既知道服务端消费到了哪里又不增加太多额外流量。3.3 播放端低延迟队列与打断机制下行播放是最容易被人忽略、但影响体验最大的地方。TTS 流式返回后服务端把音频切成多个小帧连续推给 ESP32ESP32 收到后不能直接塞给 I2S 就完事因为网络抖动会导致音频数据到达不均匀播放端如果按到达的节奏播放声音会一顿一顿的。我在播放端做了一个抖动缓冲Jitter Buffer先缓存约 120 ms 的音频再按固定的节拍把数据送入 I2S。120 ms 这个值测试下来比较适中太低容易因为网络波动卡顿太高会导致 AI 回复前明显多等一段时间交互不够“快”。播放任务还需要处理打断。用户在使用过程中随时可能说话想在 AI 还没播完的时候就插嘴。这时候 ESP32 端的麦克风数据仍在持续上传服务端通过 VAD 检测到用户新的语音后会向下发送一个“打断”文本帧。ESP32 收到打断指令后要做三件事清空播放队列里的剩余音频、清空解码器内部缓存、立刻把 I2S 输出静音。注意不要直接关闭 I2S否则功放会发出刺啦声最保险的做法是向 I2S 持续写入静音 PCM 数据。还有一个我踩过的坑播放过程中的回声问题。玩偶在播放回复时麦克风会把扬声器声音也采进去结果是下一轮对话的识别文本里混着之前 AI 回复的内容ASR 结果乱七八糟。硬件上很难彻底解决我用了一个比较取巧的方案当 ESP32 正在播放下行音频时上行麦克风数据仍然正常发送但播放结束后发送一个文本帧通知服务端“本地刚播完”服务端利用 VAD 时间戳丢弃播放结束前后 300 ms 的录音数据。这个方案只做一次时间对齐不需要复杂的 AEC 算法实测在安静环境下效果已经足够好。4. 服务端接续与状态同步4.1 网关状态机从连接建立到打断复位服务端我用了 Node.js 加 ws 库来做网关每个 ESP32 设备一条 WebSocket 连接。网关里定义了一个简单的状态机CONNECTED、LISTENING、PROCESSING、REPLYING、INTERRUPTED。设备刚连上时是 CONNECTED收到用户音频流后转为 LISTENINGVAD 检测到完整一句话后进入 PROCESSINGLLM 返回首个 token 并开始下发 TTS 时进入 REPLYING播放过程中收到新的 VAD 事件则回到 PROCESSING 并发送打断信号一轮对话结束后回到 LISTENING继续等下一句。这个状态机的关键点在于“信号与音频分离”。音频帧继续走二进制通道状态切换通过文本帧通知 ESP32。比如服务端从 PROCESSING 切换到 REPLYING 时会先发一条包含会话 ID 和回复起始序号的通知然后才开始推 TTS 音频帧。ESP32 端只需要关注状态通知不需要解析音频内容。这样的好处是后续如果要接多个大模型供应商只需要网关内部调整 ASR/TTS/LLM 的调度ESP32 端的协议完全不用动。4.2 音频流与 ASR/TTS/LLM 的串联网关收到二进制音频帧后按照帧头的序列号重新组包喂给流式 ASR。我这边接的是基于大模型的流式语音识别支持按 10 ms 或 20 ms 的粒度持续输入音频。ASR 返回的是“准实时”文本不是等用户说完才给一个完整句子而是边输入边吐临时识别结果。VAD 检测到用户停顿超过 600 ms就认为这句话说完了把当前识别结果固化下来拼上对话历史送给 LLM。LLM 返回后需要把回复文本逐句推给 TTS。这里我强调一个细节不要等 LLM 全部生成完再开始 TTS而是让 LLM 以流式 token 的方式输出网关先积累一个完整短句就立刻发给 TTS 合成这句的音频并马上推给 ESP32。这样用户听到第一个字的延迟可以从“整段回复生成完”缩短到“第一个短句生成完”。实测首句播报延迟能压到 0.8 到 1.5 秒左右这个数字在“连续对话”里用户基本感知不到等待。TTS 输出的音频我会统一压成 Opus然后切成 20 ms 的帧每个帧携带递增的下行音频序号通过 WebSocket 二进制消息连续推送。ESP32 端收到后先把 Opus 解码为 PCM放进抖动缓冲再按节奏播放。整个过程连续且可中断服务器不需要知道 ESP32 当前具体播到了哪一帧只需要知道它的 ACK 序号。4.3 断线重连与序号同步1006 之后怎么办连续对话最怕就是用户刚说到一半WebSocket 连接断了。我调试中遇到的典型报错是浏览器控制台或者服务端日志里的[websocket] onclose, code: 1006, reason: , reconnect: true还有stream disconnected before completion: failed to send websocket request: io。这两种情况本质都是连接被异常关闭服务端没有收到正常关闭帧客户端侧的 TCP 连接已经断了。1006 是 WebSocket 协议里“非正常关闭”的通用码不一定代表服务器主动断开更多是网络中间层超时、路由器把空闲连接清掉了或者 ESP32 的 WiFi 短暂掉线。我的应对有三层第一ESP32 客户端开启 WebSocket 库自带的重连能力并用指数退避1 秒、2 秒、4 秒……上限 30 秒避免服务端被重连风暴打挂。第二客户端在发送音频帧时记录最后发送的 seq重连后立刻把 last_seq 通过文本帧发给服务端。服务端根据这个序号判断如果客户端掉线前已经上传了 200 帧音频但只确认到 150 帧那么服务端会丢弃 150 帧之前的所有缓存只保留 150 到 200 帧之间的数据用于 ASR 补识别。第三服务端给每个会话设置一个 15 秒的窗口如果 15 秒内没有收到任何音频帧或心跳就主动释放连接资源防止僵尸连接越堆越多。这里要特别提醒一下重连后不要盲目从断点重传音频。实时音频的价值在于“当下”过了几秒钟的旧语音对 ASR 已经没有任何意义重传只会让服务端误判用户还在说话还可能造成识别结果混乱。所以重连后的正确做法是客户端通知服务端“我回来了”服务端清空与旧音频相关的缓存会话继续监听新的音频流。只有那些已经进入 LLM 上下文、但还没有最终播报的文本回复才需要通过文本信令做一次状态补偿。5. 踩坑记录与性能调优5.1 高频问题速查表这次重构里遇到的典型问题我整理成了一张速查表后面调试可以直接对照排查。现象根因排查思路与解决设备频繁断线服务端看到 code 1006网络空闲超时或 WiFi 信号弱开启 WebSocket Ping/Pong间隔 20 到 30 秒同时检查 ESP32 天线布局和供电电流播放声音一顿一顿抖动缓冲太小或下行音频帧不均匀把播放缓冲从 60 ms 调到 120 ms排查服务端 TTS 是否有大片静音数据空转识别文本混入上一轮播放内容麦克风采到扬声器声音播放结束后发送文本帧通知服务端服务端丢弃 300 ms 录音后续再考虑 AECfailed to send websocket request: io客户端 TCP 写阻塞发送缓冲区满检查是否有大帧发送确保发送任务不阻塞采集增大 socket 发送超时时间用户说完了但迟迟不响应环形缓冲太大积压了旧音频限制发送队列长度允许丢帧确保 VAD 事件和音频帧的顺序不错乱ESP32 内存不足导致重启WebSocket 接收缓冲 播放缓冲占太多 RAM启用 PSRAM 存放环形缓冲和播放队列调低库的接收缓冲到 4 KB 左右这些问题的共同点是很多“看起来像网络问题”的现象其实是协议和缓冲策略设计不合理。比如音频积压导致的假死经常被误判成服务器响应慢实际上只要把发送队列限制住问题立刻消失。5.2 从“能对话”到“连续对话”的实测数据变化我用同一个 ESP32 设备、同一个服务端只切换旧链路和新链路做了一组简单对比。旧链路从按下按键到开始播音平均耗时 3.8 秒用户说完话后必须等待AI 播放期间无法打断一轮对话结束后要再次按按键才能说话。新链路下用户自然说话后VAD 识别到停顿开始处理首句回复大约在 1.1 秒后开始播放AI 播放过程中用户随时说话20 到 30 毫秒内服务端就能检测到新语音并下发打断指令ESP32 清空播放队列后开始接收下一轮回复。延迟指标背后还有一个容易被忽略的数字WebSocket 音频帧的实时吞吐。16 kHz、16 bit 单声道音频每秒产生 32 KB 数据如果按 10 ms 一帧拆分每秒要发 100 帧。在室内 WiFi 环境下ESP32 的上行发送间隔抖动控制在 5 ms 左右下行 TTS 音频帧的到达间隔因为加了服务端缓冲基本能稳定在 20 ms 上下。这个数据说明WebSocket 二进制链路完全撑得住轻量 AI 设备的实时对话瓶颈通常不在协议而在应用层缓冲和状态机设计。5.3 几个立竿见影的优化项第一把 ESP32 的 CPU 频率固定到 240 MHz。开发板默认可能跑到 160 MHzI2S 采样、Opus 解码、WebSocket 加密传输同时进行时偶尔会出现播放卡顿。固定高频率后播放线程和发送线程的调度余量明显提升。第二关闭 WiFi 的省电模式或者至少把 modem sleep 的间隔调大。默认省电模式下WiFi 模块会周期性睡眠音频帧传输延迟会突然飙高而连续对话对延迟极其敏感。如果电池供电可以做一个折中设备空闲 3 秒以上再开启省电检测到 VAD 有声音时立刻切到高性能模式。第三善用 ESP32 的 PSRAM。音频环形缓冲区、播放抖动缓冲、Opus 解码器的中间数据都放到 PSRAM内部 SRAM 留给 WiFi 协议栈和实时任务栈。实测内存余量从不到 10 KB 提高到 80 KB 以上系统稳定性提升明显。第四服务端做统一时钟参考。不要相信 ESP32 本地的毫秒时间戳因为设备重启后时间可能漂移。网关收到连接后下发一个服务器起始时间后续音频帧的时间戳全部以这个起始时间为基准递增。这样即使设备在会话中途重启服务端也能通过时间戳判断音频流的新旧避免把旧数据当成新语音处理。最后再分享一个我在实际开发里坚持的做法在 WebSocket 文本信令里保留一个 debug 字段专门用来携带服务端的当前状态、队列长度、音频帧计数。ESP32 端把所有 debug 信息周期性地打印到串口方便现场调试。很多时候你以为的“网络抖动”其实是服务端在某个状态里卡住了或者 ASR 的临时结果一直没有固化。有了这条透明的 debug 通道很多问题几分钟就能定位而不是靠猜。这次重构给我最大的体会是让 AI 玩偶实现连续对话关键不在模型有多强也不在硬件算力有多高而在于把实时音频链路当成一条“水管”来设计——采集端持续进水传输端按帧输送服务端按状态接力播放端按节奏出水。中间任何一个环节用“攒一大块再处理”的老思路体验就会立刻退回“能对话”的水平。如果你也在做类似的 WebSocket 音频链路我建议先画清楚帧格式和状态机再动手写代码这套架构一旦跑通后面换模型、换 TTS、换硬件都快很多。
返回列表