ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ESP32 AI玩偶语音交互重构:基于WebSocket的全双工音频流方案

ESP32 AI玩偶语音交互重构:基于WebSocket的全双工音频流方案 去年我做了个 ESP32 AI 玩偶就是那种能用语音聊天的小玩具。第一版跑通以后演示效果勉强能看按住按钮说话松手后录音上传服务端识别、调大模型、合成语音再把音频拉回来播放。整条链路走完大概要 2 到 3 秒。放展台上配讲解员话术还算过得去但拿回家给小孩用两天不到就露馅了——孩子跑来问我为什么每次都要按着按钮说话为什么回答之前总要等那么久。那一刻我意识到我做的不是“对话”是“对讲机”。于是有了这次重构。我把原来“录音—上传—等待—播放”的串行路径改成基于 WebSocket 的二进制音频全双工链路用户开口的同时音频帧就在实时上传服务端边识别、边生成回复、边合成语音并流式推回。玩偶不需要按钮也没有明显等待它终于从“能对话”变成了“连续对话”。这篇文章就把我这次重构的完整思路写出来为什么选 WebSocket 而不是 HTTP为什么音频数据要用二进制帧而不是 JSONESP32 端和服务端各自怎么配合以及调试中踩过的那些坑。准备做同类 AI 硬件玩具的朋友可以直接参考这套方案。1. 项目背景与重构动机当“对讲机式”交互不再满足需求1.1 从“能对话”到“连续对话”到底差在哪对这个项目来说“能对话”和“连续对话”是完全不同的两个产品状态。“能对话”是指设备完成一轮完整问答。按按钮说话设备把这句话当成一个独立任务处理完再放一段语音出来。整个过程就像用对讲机你按住说话松手等回复回复结束后话筒回到你手里再来一轮。这种模式优点是实现简单先录音、再上传、再处理是一条单向流水线缺点是每轮之间有明显通信间隙用户必须时刻等待体验非常割裂。“连续对话”则要求设备具备全双工语音交互能力用户不需要按任何按钮直接说“你好最近有什么新闻”设备不仅能听到还能在你说话的同时就开始解析。等到你说完它马上给出回应回应过程中你随时可以插话打断。这更像人和人之间自然的交谈。这次重构的核心目标就是打破对讲机式交互的串行瓶颈。我需要让音频数据像水一样流动起来输入端麦克风不断产生音频流输出端喇叭不断播放回复音频中间的识别、理解、生成、合成全部以流式方式并发处理。1.2 重构前的技术瓶颈到底卡在哪里旧方案全链路串行。用户说完一句话ESP32 先把整段语音存进内存然后通过 HTTP POST 以 JSON 形式把 Base64 编码的音频上传到服务端。服务端拿到完整音频后调一次 ASR 拿到文本再调大模型拿回复接着调 TTS 合成音频最后把音频文件用 HTTP GET 交回给 ESP32 播放。整个过程中任何一步慢了用户就能感受到明显停顿。实测下来旧方案端到端延迟在 2.5 秒到 4 秒之间。网络好时勉强到 2 秒网络稍微波动就奔着 4 秒去了。这个数字放到智能音箱上用户早就暴躁了。而且 Base64 本身会对二进制音频产生 33% 的数据膨胀同样一段 PCM 数据编码后要多传三分之一ESP32 端解码 Base64 还需要额外 CPU 时间。对于本身资源就紧张的 MCU 来说这完全是在浪费算力。更关键的是HTTP 是半双工请求-响应协议。服务端没办法在用户说话过程中把中间结果推给客户端也没办法在语音播报的同时继续接收新的语音输入。要支持打断、支持边说边听就必须换一个真正的全双工通道。我最终选择了 WebSocket。2. 链路选型与技术方案设计为什么是 WebSocket 二进制2.1 先说说为什么没继续用 HTTP有人会问HTTP 也支持流式上传比如用 chunked 编码把音频分段传上去服务端边收边识别这不也能做到流式吗理论上可以但实际体验有几个绕不开的问题。第一HTTP 的服务端主动推送能力很弱。识别过程中服务端想告诉客户端“我已经听到你在说话了”或“你刚才那句话我识别成了什么”HTTP 做起来非常别扭。要么用轮询客户端隔几百毫秒就查一次状态要么用 SSE 或者长期连接这就等于自己再造一套半双工通道。多个请求来回配合状态管理很容易乱。第二轮询会产生额外网络开销。ESP32 本身 WiFi 资源宝贵每次 HTTP 轮询都要重新建连、握手、传输、断开。一批请求下来白白消耗大量功耗和带宽。对电池供电的玩偶来说这个开销不能忍。第三HTTP 请求天然以“一个请求、一个响应”为边界。识别一段 3 秒的语音如果采用一次性上传服务端必须等语音完整到达才开始处理这 3 秒采集时间就白白浪费了如果分段上传又会面临连接中断的复杂处理。与其在这些边界问题上绕来绕去不如直接用 WebSocket 这种为长连接而生的全双工协议。2.2 WebSocket 的优势天然的全双工长连接WebSocket 建立连接后客户端和服务端可以随时互相发送数据没有请求-响应配对的约束。对语音交互场景来说这意味着音频上行和音频下行可以同时进行ESP32 一边上传用户的语音帧一边接收服务端推送的 TTS 音频帧。插话打断也变得简单——用户开口时ESP32 向服务端发一个 CONTROL_INTERRUPT 帧服务端收到后立刻停掉当前 TTS 任务转入手动识别新语音整个过程不需要重新建连。数据形态上WebSocket 原生支持二进制帧。PCM 采样数据、Opus 压缩数据都是二进制可以直接塞进帧里不需要像 JSON 那样做文本序列化。PCM 采样点本身就是 16 位有符号整数用二进制补码表示直接拼进 payload 里就是最自然的数据形态。省掉 Base64 编码和解码两步后20ms 音频帧的收发耗时可以从毫秒级降到微秒级传输体积也缩小了一大截。虽然 WebSocket 本身有帧头开销但相比 JSON 里的引号、键名、Base64 膨胀这点开销微不足道。在设备端ESP-IDF 自带的 esp_websocket_client 组件已经封装好 WebSocket 握手、帧收发和 ping/pong 心跳直接用就行。服务端在 Python 里用 websockets 库或者 FastAPI 的 WebSocket 接口几行代码就能搭起异步接收循环。生态成熟不需要自己造轮子。2.3 二进制帧协议设计先定好边界再动手写代码选定 WebSocket 之后下一步是设计应用层的二进制帧协议。WebSocket 虽然保证了消息边界但底层还是 TCPTCP 是字节流。服务端一次 read 可能只读到半个帧也可能一次收到三个帧。所以应用层必须以自己的帧头长度字段为准从缓冲区里一次次切帧。我设计了一个固定 12 字节帧头 可变负载的协议。协议字节布局如下偏移长度字段说明02magic固定 0xA55A用于快速校验和自同步21version协议版本号当前为 0x0131type帧类型见下表44sequence自增序列号检测丢帧或乱序84length负载长度字节0 表示纯控制帧12?payload音频数据或控制字段帧类型定义如下type类型名方向负载内容0x01HELLOC→S设备标识ASCII0x02AUDIO_STARTC→S采样率、位深、声道数等参数0x03AUDIO_CHUNKC→S一帧 PCM/Opus 数据0x04AUDIO_ENDC→S无VAD 静音触发0x05TTS_STARTS→C文本标识或音频格式0x06TTS_CHUNKS→C一帧 TTS 音频0x07TTS_ENDS→C无播放完毕0x08CONTROL_INTERRUPTC→S打断标识0x09ERROR双向错误码加提示文本设计时我特意加了两点magic 和 sequence。magic 的作用是在数据错位时快速找回帧边界一旦某个位置读不到 magic就丢弃一个字节重新同步。sequence 用于统计端到端延迟也能在极端情况下发现丢帧。实际调试中我发现没有 sequence 你很难判断声音卡顿是网络丢帧还是播放缓冲不足加了它两个原因可以立刻区分开。注意协议字段我统一用大端字节序网络字节序ESP32 和 Python 两边约定一致省去后面的字节序纠纷。3. ESP32 端实现采集、编码、发送三件事同时开工3.1 I2S 麦克风采集与音频格式对齐ESP32 端首先要解决音频采集问题。我用的是一颗 I2S 接口的 MEMS 麦克风通过 ESP32 的 I2S 外设读取数据。I2S 是音频设备的标准数字接口ESP32 原生支持读取效率比模拟 ADC 高很多。为了让流式识别更顺畅音频格式我统一对齐到 16kHz、16bit、单声道。这是绝大多数 ASR 服务默认的输入格式服务端不需要转码。I2S 配置好之后数据会进入 DMA 双缓冲采集任务可以高效地取走数据。采样点数按 20ms 一帧来计算16000 × 0.02 320 个采样点每个采样点 2 字节所以每帧负载是 640 字节。这里有一个很容易踩的坑ESP32 的 I2S 支持多种时钟配置麦克风模块的 SCK/WS 引脚不能接错否则数据全是杂音。而且 MEMS 麦克风需要稳定供电电压板上 LDO 电流不够的话采样会出现周期性爆音。最初我没在意调试了很久才发现是电源问题。3.2 WebSocket 客户端接入与二进制发送连接部分我用 ESP-IDF 的 esp_websocket_client 组件。初始化时把 URI 指向服务端比如 ws://192.168.1.100:9000/voice然后启动客户端并等待连接成功。之后采集任务每取到 20ms 的音频数据就封装成 AUDIO_CHUNK 帧发送出去。#include esp_websocket_client.h #define FRAME_HEADER_LEN 12 #define AUDIO_FRAME_BYTES 640 // 20ms 16kHz/16bit/mono static uint16_t g_seq 0; void build_frame(uint8_t type, const uint8_t *payload, uint32_t len, uint8_t *out, uint32_t *out_len) { uint8_t *p out; *p 0xA5; // magic high *p 0x5A; // magic low *p 0x01; // version *p type; // type *p (g_seq 24) 0xFF; // sequence *p (g_seq 16) 0xFF; *p (g_seq 8) 0xFF; *p g_seq 0xFF; g_seq; *p (len 24) 0xFF; // payload length *p (len 16) 0xFF; *p (len 8) 0xFF; *p len 0xFF; if (payload len 0) { memcpy(p, payload, len); } *out_len FRAME_HEADER_LEN len; } void audio_task(void *arg) { esp_websocket_client_config_t ws_cfg { .uri ws://192.168.1.100:9000/voice, .reconnect_timeout_ms 5000, }; esp_websocket_client_handle_t ws esp_websocket_client_init(ws_cfg); esp_websocket_client_start(ws); while (!esp_websocket_client_is_connected(ws)) { vTaskDelay(pdMS_TO_TICKS(200)); } uint8_t *pcm heap_caps_malloc(AUDIO_FRAME_BYTES, MALLOC_CAP_DMA); if (!pcm) { vTaskDelete(NULL); return; } uint8_t frame[FRAME_HEADER_LEN AUDIO_FRAME_BYTES]; while (1) { size_t bytes_read 0; esp_err_t ret i2s_read(I2S_NUM_0, pcm, AUDIO_FRAME_BYTES, bytes_read, portMAX_DELAY); if (ret ! ESP_OK || bytes_read AUDIO_FRAME_BYTES) { continue; } uint32_t frame_len 0; build_frame(0x03, pcm, AUDIO_FRAME_BYTES, frame, frame_len); // AUDIO_CHUNK esp_websocket_client_send_bin(ws, frame, frame_len, pdMS_TO_TICKS(200)); } }实际开发中我建议把采集和发送拆成两个独立任务中间用队列缓冲。采集任务只管把 20ms 的 PCM 帧放到队列发送任务从队列取帧并发送。这样即使 WiFi 暂时拥塞音频数据也不会丢等网络恢复后可以继续发送。缺点是增加内存占用但对 640 字节的小帧来说队列深度 50 也就 32KBESP32 还能接受。3.3 接收端TTS 音频流的缓存与播放下行方向ESP32 要接收服务端推送的 TTS_CHUNK 帧并交给 I2S DAC 或外部音频功放播放。播放任务和录音任务是两个独立通道不能在同一个 WebSocket 回调里直接操作 I2S否则在中断上下文里做太多事会导致采集丢帧。我的做法是收到 TTS_CHUNK 后把音频数据拷贝到一个环形缓冲队列播放任务不断从队列取出数据写入 I2S。当收到 TTS_END 时在队列尾部打一个结束标志播放任务播完最后一帧就停止。如果用户插入打断设备会清空整个播放队列同时发送 CONTROL_INTERRUPT 给服务端。播放缓冲的大小要仔细调。缓冲太小网络抖动时会产生爆音或卡顿缓冲太大又会让用户感觉回复来得很慢。我实测下来预缓冲 120ms 的音频是比较平衡的值也就是先缓存 6 帧 20ms 的 TTS 音频再开始播放之后边收边播既能抗住轻微抖动又不至于延迟过高。4. 服务端实现异步流式处理管道三段并行4.1 Python 异步接收与帧解析服务端的作用远不止转发音频帧。整个服务端我拆成了三条并行的流水线音频输入识别、大模型生成回复、语音合成输出。三者之间用异步队列串起来任何一段都可以独立工作。我选择 Python 的 websockets 库来实现异步 WebSocket 服务。入口是一个async def voice_socket(websocket)协程代码逻辑是循环接收客户端消息把收到的字节流累积到缓冲区按协议头部长度切帧再分发到对应处理器。核心解析代码大概是这样的import asyncio import struct import websockets MAGIC 0xA55A async def voice_socket(websocket): buffer bytearray() try: async for message in websocket: if not isinstance(message, (bytes, bytearray)): continue buffer.extend(message) while len(buffer) 12: magic struct.unpack_from(H, buffer, 0)[0] if magic ! MAGIC: buffer.pop(0) continue version, ftype struct.unpack_from(BB, buffer, 2) seq struct.unpack_from(I, buffer, 4)[0] length struct.unpack_from(I, buffer, 8)[0] if len(buffer) 12 length: break payload bytes(buffer[12:12 length]) del buffer[:12 length] await dispatch_frame(websocket, ftype, seq, payload) except websockets.exceptions.ConnectionClosed as exc: print(fconnection closed: code{exc.code} reason{exc.reason})注意帧头解析和协议表保持一致先读 magic再读 version 和 type然后是 4 字节 sequence 和 4 字节 length。实际项目里最好把协议定义做成一个共享头文件或独立模块C 端和 Python 端同时引用避免手写不一致。4.2 VAD 断句与流式识别怎么判断用户说完了服务端持续收到 AUDIO_CHUNK 帧但并不是每帧都触发识别而是先做 VAD语音活动检测。VAD 的作用是判断用户当前有没有在说话。算法上最简单的做法就是算音频能量。帧能量超过阈值认为有人在说话连续多帧能量低于阈值说明停顿了。停顿超过 600ms 就触发一次断句把这一段时间内收到的音频送入 ASR。ESP32 端也会做 VAD但服务端做一步更稳妥因为有些环境噪声在设备端会被误判服务端可以用更大模型做判断。流式 ASR 接入我用的是兼容三方协议的方式把监听到的音频按 100ms 切片送入识别服务边送边拿中间识别结果。用户停下来后ASR 返回最终文本我立刻转发给大模型生成回复。这里有一个小技巧不要在拿到完整文本后再开始生成回复。ASR 的中间结果可以先出来配合“边说边识别”能提前几百毫秒让大模型开始处理最终端到端时延能再降一截。4.3 TTS 分段合成与下行推送先让第一句话响起来LLM 生成回复文本是流式的大模型不是一次性吐完整段话而是逐步输出 token。我拿到文本后按句子边界和标点切分比如遇到句号、问号、感叹号或换行符就切成一个片段。每个片段立即送入 TTS 引擎合成语音合成好的音频直接封装成 TTS_CHUNK 帧推给 ESP32。这样做可以让用户只等第一句回复的时间而不是等整段话生成完毕。举个例子玩偶回答“今天天气不错适合出去走走记得带伞”如果是整段文本生成完再 TTS用户可能要等 2 秒如果按句子切分并流式合成第一句“今天天气不错”可能 500ms 就到了。TTS 的音频格式我同样统一为 16kHz、16bit、单声道 PCM和上行保持一致。服务端合成后不需要转码直接打包发送极大减少下行处理耗时。推送时用 asyncio.create_task 把发送包放到独立协程避免 TTS 合成阻塞主循环。服务端完整链路大致是audio task 收帧 → ASR task 识别 → llm task 生成文本 → tts task 合成音频 → send task 推给客户端。每一段都是队列生产者或消费者模型互不阻塞。5. 全链路调优与实测效果5.1 延迟数据对比HTTP 旧链路 vs WebSocket 二进制新链路重构完成后我专门做了几组对比测试。旧链路指按按钮录音 3 秒、上传 JSON、服务端处理、下载音频播放新链路指用户自然说话、VAD 断句、流式识别加流式 TTS。测试网络为局域网 WiFiRTT 约 5ms。指标旧链路HTTP新链路WebSocket 二进制首包延迟从开口到第一声回复约 2.5s约 500ms正常回答延迟说完到回复完整结束34s0.81.2s打断响应时间不支持约 200ms单次数据包体膨胀33%Base64几乎为 0连接开销每次请求重建长连接一次新链路在流畅度上的提升非常直观。500ms 的首包延迟在语音交互里已经接近真人的反应速度。你可以明显感觉到玩偶在“听你说话”而不是“等你说完再去处理”。5.2 影响体验的隐藏因素不止延迟还有节奏延迟数字漂亮不代表体验一定好。调试中发现几个隐藏因素比单纯降低延迟影响更大。第一个是 VAD 断句时间。如果静音超时设得太长比如 1 秒用户说完话后要傻等 1 秒才触发识别即使后续处理再快体验也跟不上。我最终把静音超时设在 600ms相当于人说话时正常的换气停顿既能准确断句又不会在句中说一半被打断。第二个是 TTS 预缓冲策略。前面提到的 120ms 预缓冲是为了兼顾防抖和速度。如果播放队列为空就开始播放一旦网络抖动扬声器会先响几个字再卡住比延迟 500ms 更难受。预缓冲相当于给播放一个“稳定起步”的窗口。第三个是麦克风自动增益AGC。玩偶经常放在桌面上人说话距离忽远忽近。没有 AGC 时VAD 会把人声当作噪声漏掉或者把孩子喊叫当成普通音量整体压低。ESP32 平台可以在 I2S 后处理阶段做简单能量归一化服务端 VAD 前也可以再叠加一层自动增益效果会好很多。实测下来一个 5 岁小朋友在 1 米外喊“小智小智”玩偶能在 400ms 内给出回应几乎感觉不到网络链路存在。这个结果让我确信这次重构的方向完全正确。6. 实战中踩过的坑与解决方案6.1 连接总是莫名断开WebSocket 1006 与心跳之谜重构初期我遇到过 WebSocket 连接在设备待机几分钟后必然断开的现象。客户端日志里只看到一行stream disconnected before completion: failed to send websocket request: io error。服务端这边抛出的异常是 websockets.exceptions.ConnectionClosedErrorcode 为 1006。1006 意味着连接异常关闭没有收到正常的 close 帧。最典型的原因是 TCP 连接被中间设备NAT 路由器、运营商网关静默回收。设备长时间没有数据发送网关就把连接状态删了。解决办法有两个一是 WebSocket 协议自带 ping/pong 心跳客户端或服务端周期性发 ping对方回 pong连接就不会被判定为空闲二是业务层在音频帧间隙插入自定义心跳帧比如每 10 秒发一个空负载的 AUDIO_CHUNK 帧。我最终在 ESP32 端启用了 esp_websocket_client 的心跳配置把 ping 间隔设为 30 秒。同时服务端加了连接空闲回收逻辑超过 90 秒没有收到任何数据就主动关闭连接。这个组合下设备连续挂机一晚没有再断开。6.2 粘包与半包一个 while 循环解决的心病另一个高频问题是音频卡顿伴随偶发的声音撕裂。抓包后发现服务端有时一次会收到多个 WebSocket 消息的拼包有时一条消息只读到一半。这正是 TCP 字节流的粘包和半包现象。WebSocket 协议本身保留了消息边界但服务端多条消息可能同时到达接收循环如果只按消息读不做应用层组装就会出现解析错位。解决方法是把收到的每条 message 持续追加到同一个 buffer然后在一个 while 循环里按帧长度取帧。前面给出的 voice_socket 代码就是这么写的。注意处理两个边界buffer 长度不足一个帧头时不要取帧头声明长度超过当前 buffer 剩余长度时也不要取先等下一个 message。我还在解析层加了 magic 自同步。万一帧头错位连续读不到正确的 magic就丢弃一个字节继续找。这套机制在初期调试非常有用因为最初 ESP32 端发送帧头时我犯过一个字节序错误服务端愣是靠 magic 自同步把系统拉活了日志里全是 resync 提示一眼定位到问题。6.3 ESP32 内存与任务调度的平衡音频处理在 ESP32 上最大的敌人是内存不够用。ESP32 的可用堆内存本来就紧张WiFi 协议栈、TLS、WebSocket 各占一部分。我一开始给录音队列和播放队列各开了 200 帧的深度直接导致 PSRAM 不够、系统反复重启。后来把队列压到 50 帧同时使用heap_caps_malloc(MALLOC_CAP_DMA)为 I2S DMA 分配内存问题才缓解。任务优先级上采集任务我给到中高优先级因为 I2S 数据如果不及时取走DMA 缓冲满了就会丢数据WebSocket 接收回调放在低优先级网络数据晚几十毫秒不致命但音频采集丢一帧就是永久性损失。播放任务优先级介于两者之间保证播放不卡顿。实际分配建议是采集优先级 10、播放 8、WebSocket 接收 5。6.4 H5 能连接打包成 App 连不上不只是证书问题有个朋友拿我的代码做了个 H5 页面调试一切正常但用工具打包成 Android App 后WebSocket 死活连不上。这是因为 Android 9 开始默认禁止明文流量ws://这种非加密 WebSocket 会被系统直接拦截。解决办法有三个一是换成wss://加密链路最推荐二是在 AndroidManifest 里配置android:usesCleartextTraffictrue仅适用于内网调试三是在网络安全配置里单独放开某个域名的明文 WebSocket。iOS 平台的 ATS 也有类似限制建议直接上 wss。顺手提一句如果玩偶有 OTA 升级需求升级流程和音频链路不要共用同一个 WebSocket 连接。升级需要可靠的文件传输偶尔断线重传即可音频链路需要实时低延迟。混在一起会让升级过程中出现大量音频缓冲冲突。我在实际项目里升级走单独的 HTTPS 连接OTA 过程中临时关闭 WebSocket 音频服务升级完成后自动重新建立两个需求互不干扰。最后说一点个人体会。这次重构里最让我头疼的其实不是 WebSocket 的二进制帧怎么设计也不是 ESP32 的 DMA 缓冲怎么配置而是把“对讲机”思维改过来。早先我总想着“先把话说完再开始处理”后来才真正理解连续对话的核心是让数据流动采集、识别、生成、合成始终在并行任何一段都不需要等前一段完全结束。如果你也准备给自己的 ESP32 设备做语音交互我强烈建议一上来就按全双工流式架构设计。协议部分参考我上面的帧结构代码可以直接抄调试时先从局域网环境开始把 VAD、心跳、粘包这些问题一个个解决再换复杂网络。只有当你体验到边说边听、边说边回复的那种顺畅感时才会明白之前按下按钮的交互方式真的已经过时了。
返回列表