ESP32语音助手全双工改造:WebSocket流式音频实现连续对话与打断 📅 发布时间:2026/9/8 12:50:00 👁 浏览次数: 家里那台 ESP32 AI 玩偶最初做出来的时候身边朋友的反应是挺新鲜的但真正拿回家用两天评价就变成了不太聪明。原因不在大模型而在链路你按一下它听你说完它想它回答的时候你插不上嘴说错了只能等它播完才能重来。这就是典型的能对话——每一次交互都是一次完整且笨拙的请求-响应跟打电话那种连续对话差的不是一星半点。所以年前我下决心把音频链路整个推翻从 HTTP 一次性上传改成 WebSocket 二进制流式传输让这只玩偶真正具备随时听、随时插话、边说边处理的能力。这篇就把重构过程中的方案选型、帧协议设计、ESP32 采集链路、服务端流式处理、打断状态机和踩过的坑一次性写清楚给正在做 ESP32 语音助手或者 AI 硬件对话的家伙们一个能直接落地的参考。1. 旧链路为什么只能一句一答半双工架构的三个致命瓶颈1.1 旧方案的完整数据流与体验痛点旧链路走的是最常规的录音上传模式。硬件上是一块 ESP32 加 INMP441 数字麦克风录音过程用 I2S 采集 PCM按 16kHz、16bit、单声道存进内存缓冲区录音结束时把整段数据拼成 WAV通过 HTTP POST 丢给服务端。服务端拿到完整音频文件后依次做 ASR、调大模型、合成 TTS最后把生成的 MP3/WAV 文件作为响应返回ESP32 收到后开始播放。这套链路的问题在用户体验上是灾难性的。第一录音有明确的开始和结束概念用户必须说完话后等 VAD 超时我最初设了 1.5 秒静音才能触发上传这个等待本身就是割裂感。第二服务端要等整个音频文件收完才开始 ASR201ms 的录音就要完整上传完TCP 慢启动加上 TLS 握手的开销首字延迟轻松超过两秒。第三播放阶段是完全封闭的单行道ESP32 从收到音频到播完整个过程不采集麦克风用户想打断只能干等更不用提说错话之后要重新按按钮的挫败感。1.2 这次重构给自己定的四个硬指标在做重构之前我先列了一份明确的验收标准免得改到一半方向跑偏。核心就四条连接只在首次建立后续所有上行音频、下行音频、控制信令都走同一条 WebSocket 连接。音频边采边发20ms 一个包服务端边说边处理不等整段录音结束。任意播放状态下用户开口即可打断从你说到它停的时间不超过 300ms。从用户说完话到玩偶开始出声端到端延迟压进 1 秒以内理想情况 800ms 左右。这四个指标每条都直指旧架构的痛点。说实话前两步连接复用和流式上行实现起来并不难真正的硬骨头在第三点和第四点——打断机制和全双工状态切换这就不是简单把 HTTP 换成 WebSocket 就能解决的问题了需要从协议到代码做一次系统性重构。2. 音频传输方案选型为什么最终锁定 WebSocket 二进制帧2.1 四种候选方案的对比与淘汰理由在决定用 WebSocket 之前我把可能的方案都过了一遍包括 HTTP 分块上传/轮询、RTSP/RTP、MQTT以及最终采用的 WebSocket 二进制帧。表格里是当时做的对比方案全双工能力嵌入式资源开销流式音频支持结论HTTP 轮询/分块不支持需轮询模拟连接反复建立开销大只能单向无法推流淘汰RTSP/RTP支持但信令复杂状态机复杂ESP32 跑起来吃力专业流媒体协议但对 AI 对话场景过重淘汰MQTT基于主题推送有 QoS轻量但消息大小和吞吐受限适合传感器小消息不适合持续音频流淘汰WebSocket 二进制帧天然全双工一条连接双向收发ESP32 上有现成库资源可控二进制帧直接承载 PCM/Opus采用WebSocket 胜出的关键原因是它建立在 TCP 之上天生就是双向全双工管道一条连接既能上行发送音频又能下行接收 TTS 结果还有独立的控制信令通道。它不像 RTSP 那样有复杂的信令交互也不像 MQTT 那样对大流量支持有限。而且对 ESP32 来说ArduinoWebsockets 和 ESP-IDF 自带的 WebSocket 客户端都能很稳定地跑二分帧收发不需要额外引入重量级协议栈。2.2 二进制帧协议设计magic、类型、序列号与分片策略很多人在 ESP32 上做 WebSocket 传输习惯性地用文本帧把音频先转 Base64 再塞进 JSON 里。这在原型阶段没问题一旦到了真实对话场景就扛不住Base64 让数据膨胀 33%ESP32 上的 JSON 解析又吃内存又费 CPU音频帧一多整个系统就卡。所以重构后的协议必须直接使用 WebSocket 的 binary framepayload 就是裸音频数据。我定了一个非常精简的 8 字节帧头所有消息统一走这个结构Byte 0-1magic 0xAAAA用来快速判断帧有效性过滤掉握手阶段误收的文本数据。Byte 2帧类型0x01 上行音频、0x02 下行音频、0x03 控制信令、0x04 文本事件。Byte 3版本号目前固定 0x01。Byte 4-5序列号小端序用于服务端排序和丢包检测。Byte 6-7payload 长度小端序最大 65535 字节。控制信令里预先定义了几个关键 opcode0xE1 表示用户开始说话0xE2 表示一句话结束end of speech0xE3 表示打断请求0xE4 表示唤醒成功。这些信令在后面的状态机里会频繁用到。音频分片方面我选择了 20ms 一帧。原因是 16kHz、16bit、单声道的 PCM每秒产生 32000 字节20ms 正好是 640 字节加上 8 字节帧头也才 648 字节这在 WiFi 环境下单帧传输非常轻量即使偶发丢包也只有 20ms 的音频受损服务端通过序列号能感知到并且做插值或静音填充不至于整句崩掉。2.3 为什么不能用文本帧和 JSON 传音频再展开说说这个容易被忽略的设计点。文本帧在 WebSocket 里虽然也能传数据但它的语义和二进制帧有本质区别。文本帧要求 payload 必须是 UTF-8 编码的字符串这就意味着底层要经过一次字符编解码ESP32 上的 MCU 处理这种转换是额外开销。更关键的是一旦把 PCM 用 Base64 编码放进 JSON每一帧数据里会有大量引号、逗号、结构体字段名这些冗余字段对嵌入式设备来说每一字节都是浪费。我实际对比过两种方案的性能用文本帧传同样 20ms 音频帧体从 640 字节膨胀到约 900 字节ESP32 上编码耗时增加约 2ms服务端解码又要额外消耗。而二进制帧从 I2S 拿到数据后可以零拷贝直接封装进 WebSocket 帧发送在 ESP32 这种资源紧张的环境里省下的每一毫秒都是实打实的体验提升。更别说 JSON 解析在嵌入式端的内存峰值了在一段 10 秒的连续对话里光解析开销就能让系统明显卡顿。3. ESP32 端链路搭建从 I2S 采音到二进制帧上行的完整实现3.1 音频采集硬件与驱动配置ESP32 端我用的硬件组合是 INMP441 数字麦克风 MAX98357A I2S 功放这两颗芯片都是 I2S 接口配合 ESP32 的 I2S 外设非常顺。INMP441 是 24bit 输出的数字 MEMS 麦实际使用时我取高 16 位作为有效数据右对齐格式采样率配置成 16000Hz。功放则直接接一个 3W 小喇叭同样走 I2S 输出。关键的配置细节是 DMA 缓冲区和采样格式。我的 I2S 配置里dma_buf_count设为 8dma_buf_len设为 256这样底层 DMA 可以持续往内存写数据不会因为 CPU 偶尔被 WiFi 任务抢占而丢音频。ESP32 的 I2S 读取函数是阻塞式的我在一个独立的 FreeRTOS task 里循环读取每次读到一个完整的 20ms 音频块就立即触发发送这样采集和发送之间不存在中间缓存拷贝的额外延迟。有个容易踩的坑是 INMP441 的 L/R 引脚。INMP441 默认输出在 BCLK 和 WS 的特定极性下如果 L/R 接 GND 则数据在 WS 为低电平时有效接 VDD 则相反。我就因为 L/R 接法不对导致采回来的声音一直是高频噪声排查了半天才发现极性反了。3.2 帧封装与发送节奏20ms 一包的背后逻辑音频采集到之后我是这样封装发送的从 DMA 缓冲区读出一块数据先判断是否满 640 字节没满就继续读满了就填上前面说的 8 字节帧头类型设为 0x01序列号自增然后调用 WebSocket 客户端的sendBinary方法直接发送。整个过程没有任何 JSON 处理也没有 Base64 转码CPU 占用非常低。发送节奏的控制是另一个容易出问题的地方。很多人直接用delay(20)来控制周期看起来差不多但实际会累积时钟漂移时间一长音频帧之间的间隔就乱了服务端做 VAD 时会出现误判。我采用的是微秒级时间戳循环在采集 task 里记录每帧的绝对时间用于比对而不是靠delay的固定周期。实测下来长时间运行时帧间隔的抖动可以控制在几毫秒以内服务端收到的音频流非常平滑。这里还要提一下 TCP_NODELAY 的配置。WebSocket 底层是 TCP而 TCP 默认开启 Nagle 算法会把小包合并后发送这对于 20ms 一帧的音频流是致命的。我直接在 ESP32 连接建立后把底层 socket 的TCP_NODELAY标志位打开强制每个帧都立刻发送否则典型的症状是前几帧间隔正常后面突然卡一下抓包一看全是 Nagle 在攒数据。3.3 下行播放的缓冲设计对抗网络抖动下行方向同样需要精心设计。服务端回传的 TTS 音频也是二进制帧20ms 一包或者 40ms 一包ESP32 收到后不能直接往 I2S 里写因为网络传输有抖动帧与帧之间的到达间隔不可能完全均匀。如果收到一帧播一帧播放声音就会一顿一顿的。我的做法是在 ESP32 端维护一个 jitter buffer抖动缓冲本质就是一个循环队列先收进一定量的音频再启动播放。播放初始延迟设了 200ms也就是说收到 10 帧音频之后才开始往 I2S 输出后面的帧如果稍微晚到只要不超过这个缓冲余量听感上就是连续的。打断的时候则需要一键清空这个缓冲让播放立即停止这属于状态机部分的内容下一节详细展开。下行帧的处理任务和上行采集任务是分开的两个 FreeRTOS task优先级上播放 task 略高于采集 task原因是音频如果播放不及时会直接表现为卡顿而采集端有 DMA 缓冲可以容忍短时间延迟。两个 task 之间通过一个共享的状态变量协调配合互斥锁保护避免同时操作 I2S 外设。这样设计之后实测连续对话场景 CPU 占用大约 35% 左右还有余量做一些 LED 灯效之类的展示。4. 服务端如何把一条 WS 连接复用成双向语音管道4.1 服务端框架选型与双通道处理模型服务端我用的 FastAPI 加 WebSocket 接口有现成的websocket依赖异步模型对 IO 密集型的音频转发非常合适。核心思路是一条 WebSocket 连接建立后服务端不做任何请求-响应配对逻辑而是把它当成一个双向管道上行数据进 ASR下行数据出 TTS两路互不干扰。具体实现上我在服务端为每条 WS 连接创建了两个异步任务一个audio_receiver任务是上行处理循环源源不断从 WebSocket 接收二进制帧解析出 PCM 数据另一个audio_sender任务是下行发送循环从 TTS 队列里取音频帧然后通过同一个 WebSocket 推给设备。两者通过 asyncio 的Queue通信上行来的 VAD 事件和文本事件也会各自分流到对应的处理流程里。这种双通道模型的好处是彻底拆掉了传统请求-响应的时间耦合。服务端不会因为 SS_ASR 还没返回就阻塞下行 TTS 的发送——只要 TTS 有数据就可以直接推给设备两者并行进行这为后面的打断机制打下了基础。注意顺序问题只在 TTS 合成时依赖 LLM 的输出顺序所以 TTS 内部必须要维护好语义顺序不然会出现音频比文本提前或者错乱的情况。4.2 流式 ASR/LLM/TTS 的级联从等完整录音到边说边处理重构的另一半在服务端算法链路。旧方案是整段音频识别新方案要改成流式。ASR 部分我接的是流式识别接口ESP32 每发来一帧 PCM服务端就往识别器里喂一块识别器内部边积累边输出中间结果和最终结果。VAD 判断由服务端结合音频能量做一句话说完了静音超过默认阈值就触发最终的 ASR 结果。LLM 部分用的是大模型的流式输出能力模型开始生成回答后不是等 complete 之后才处理而是迭代取 token一旦拿到足够形成一句完整的自然语言片段就立刻送到 TTS。TTS 也选支持流式合成的引擎一边合成一边把音频帧发回给设备。这个级联过程是整条链路延迟降低的核心从录音完 → ASR完 → LLM完 → TTS完的串行变成了边说边识别、边生成边合成、边合成边下发的流水线。实测下来从用户说完最后一个字到玩偶开始发出第一个音节这个首包延迟能控制在 800ms 以内相比旧方案动辄两秒以上的等待体验提升非常明显。这个数字基本等于 ASR 判断 VAD 截止的 300ms 加上 LLM 首 token 时间 200ms 加上 TTS 首帧合成 300ms每一步都有优化空间但当前这个数值已经足够让用户觉得这家伙反应挺快。4.3 回传帧的发送节奏与背压控制服务端往 ESP32 推音频帧同样有节奏问题。如果 TTS 合成比音频播放快太多服务端一股脑把几百帧全塞给设备ESP32 的内存缓冲会爆掉。我的方案是在服务端对下行发送做了简单的背压控制设备每收到一帧或每播放完一帧客户端会发一个 ACK或通过暂停发送的指令服务端维护一个滑动窗口窗口满时暂停 TTS 帧的推流等窗口释放再继续。这里要注意不要对 WS 底层做额外的流控WebSocket 本身不提供应用层流控所以窗口逻辑必须在自己的应用代码里处理。我用的是下行令牌桶TTS 队列里取到音频帧时先检查窗口计数窗口有余量才发送否则挂起该异步任务。这样即使 TTS 合成速度远超播放速度ESP32 端的内存占用也保持在一个稳定的水位。5. 连续对话的真正难点打断、状态机与会话管理5.1 状态机设计IDLE/LISTENING/PROCESSING/SPEAKING 的流转所有能对话和连续对话的差别最终都落在状态机的设计上。我定义了一个四态模型。播放阶段彻底改变了老方案里播放就是终点的思路而是把它变成整个对话循环里的一个普通环节。IDLE设备待机麦克风低功耗监测等待唤醒词或按键唤醒。LISTENING用户正在说话音频持续上行VAD 在服务端检测端点。PROCESSING一句话已结束LLM 正在生成回答此时设备仍在采集麦克风用于打断监听。SPEAKINGTTS 音频正在下行播放同时设备持续做能量检测一旦发现用户开口就触发打断。关键转折点在三个地方LISTENING 状态下 VAD 检测到静音超时发送 0xE2 结束帧进入 PROCESSINGPROCESSING 状态下 LLM 第一段回答生成完毕进入 SPEAKINGSPEAKING 状态下检测到用户语音发送 0xE3 打断帧清空播放缓冲立即回到 LISTENING。每个状态之间的切换都必须有明确的触发事件和动作不允许有歧义或停滞。5.2 打断机制的完整链路与缓冲清空策略打断是连续对话体验里最重要的一环也是最容易实现得粗糙的部分。很多人以为打断就是检测到用户说话就停止播放实际操作上远不止这些。完整的打断链路是这样工作的ESP32 在 SPEAKING 状态下持续读取麦克风数据计算 RMS 能量如果连续 150ms 能量超过阈值判定用户有说话意图。ESP32 立即往服务端发一个 0xE3 打断帧同时本地清空 jitter bufferI2S 停止输出。服务端收到打断帧后立刻取消当前正在执行的 LLM 生成任务通知 TTS 停止合成并丢弃后续未发送的帧。服务端返回确认信号设备进入 LISTENING 状态用户接下来的话作为新的一轮输入。这个链路里最重要的参数是语气词误打断和反应延时的平衡。能量阈值设太低会把环境噪声误判成说话设太高又会导致用户喊两声都打断不了。我实测下来阈值设在 RMS 800 左右、持续时间 150ms 是一个相对稳的组合能在安静室内环境下做到又快又准。如果家里环境嘈杂建议引入额外的本地 VAD 模型ESP32-S3 上跑一个轻量级语音活动检测是完全可行的。打断帧之后时间敏感的逻辑是上一轮 TTS 帧已经发出但设备还没来得及播放的情况。这部分靠的是设备端的缓冲清空策略。我在设备端定义了一个播放线程它每次从 jitter buffer 取数据前先检查一个全局interrupt_flag如果标志位被置位就放弃当前所有缓冲内容并且立即停止输出。这样打断延迟可以控制在 50ms 以内听感上就是你一说它马上就停而不是还在啰嗦半秒钟才反应。5.3 多轮会话上下文的裁剪与过期策略连续对话的另一个隐藏成本是上下文管理。旧链路每次请求都是无状态的服务端不需要维护任何对话上下文现在改成连续对话后每一轮 ASR 的文本都要拼到大模型的 prompt 里时间一长 context 窗口必然超限。我采用的策略是滑动窗口保留最近 10 轮对话用户 助手各算一轮超出部分直接裁剪同时给每条上下文记录时间戳超过 10 分钟的早轮消息自动过期。这样可以保证大模型的 prompt 长度稳定在可控范围内不会因为上下文无限膨胀导致 TTS 或者 LLM 的响应时间线性增加。这里还要注意一个细节每次打断发生时上一轮未完成的问题和回答都必须从上下文里移除或者标记为未完成否则大模型会认为被打断的话依然有效导致下一轮回答驴唇不对马嘴。我是在服务端记录每条用户消息的处理状态打断时置为 invalidLLM 组装 prompt 时则不把这些无效内容带进去。这样处理之后连续对话的上下文一致性才真正立得住。6. 实测数据与踩坑记录从抓包到稳定运行的最后一公里6.1 网络链路延迟的实测拆解设备端和服务端联调之后我做的第一件事就是搭 Wireshark 抓包把整条链路的延迟拆开逐段分析。我选了三个时间点做统计标定用户说完话到服务端收到最后一帧音频记为 T1服务端发出第一个 TTS 帧到设备端开始播放记为 T2设备开始播放到完整播完第一句话记为 T3然后对比框架里的预期。局域网环境下T1 基本等于语音采集帧间隔加上 VAD 判断时间约 300ms 左右T2 约 500ms由 ASR 句末识别 100ms、LLM 首 token 200ms、TTS 首帧合成 200ms 组成T3 取决于句子长度通常 2 到 5 秒。这里最关键的指标其实是从说完到听到第一个音的 T1T2实测平均值大约 800ms最佳情况能压到 650ms。跨公网部署时因为增加了网络往返我标定到 1.1 秒左右依然在可接受范围内。这个数值比旧方案单次 HTTP 全链路动辄 3 秒以上的延迟进步是质变的。用户感知上最大的差异不是快了多少毫秒而是我说完它立刻有反应这种连续性——旧方案里那种明显的录完了、上传中、等待中的割裂感彻底消失了。6.2 五个让链路不稳定的大坑重构过程中踩了不少坑挑五个最具代表性的写下来每一个都花了我不少时间排查。第一个坑是 Nagle 算法加延迟 ACK 的组合拳。最开始直接用 ArduinoWebsockets 库没开 TCP_NODELAY音频帧发得倒挺勤但抓包发现服务端收帧的节奏忽快忽慢尤其是几个小帧会被 TCP 层合并到服务端才能一次性吐出好几个包直接打乱 VAD 的静音判断。排查过程极其绕一开始我以为是 WS 库的问题后来用 Wireshark 过滤 TCP 流看到 PSH 标志位和 ACK 的节奏才意识到是 Nagle 在作祟。解决办法就是在建立 socket 后立刻设置TCP_NODELAY如果是 ArduinoWebsockets 库需要在底层连接对象上设置。第二个坑是 INMP441 的极性匹配问题。前文提到过L/R 引脚接错会导致采集回来全是噪声。这不算难排查但如果你是第一次用这颗芯片很可能会像我一样在硬件上下功夫半天最后发现只是电平问题。建议拿到模块后先看 datasheet确认 L/R 的接法和 WS 极性的关系或者在代码里先配置成默认模式再用示波器看 WS 信号。第三个坑是 ESP32 播放时采样率不准确导致的音调异常。INMP441 采集端和 MAX98357A 播放端的时钟源在 ESP32 内部同一路 APLL 驱动时没问题但如果一个走了 APLL 另一个走了内部 PLL两个时钟源之间有微小偏差长时间播放会出现越播越快或者越播越慢的漂移。解决方法是统一使用同一个时钟源或者在播放端设计周期性的缓冲水位校准每隔几秒如果水位偏低说明播放比采集快就丢弃一个音频帧反之则插入一个静音帧。听起来有点粗糙但在嵌入式音频里这是标准做法。第四个坑是服务端发送节奏过快导致 ESP32 内存被打满。最初 TTS 合成完成后我把所有帧一次性调用 send 循环发出去结果 ESP32 的接收队列很快爆掉后续帧全部被 WebSocket 库丢弃播放出来的声音出现一卡一卡的现象。后来按背压控制的思路做了滑动窗口这个问题才根治。建议做这类设备端 AI 对话时服务端一定不要能发多快发多快一定要结合设备端的处理能力做拥塞控制。第五个坑是长时间运行后 WebSocket 连接老化。玩偶连续运行几个小时WiFi 偶尔会有短暂的网络抖动WebSocket 连接可能在不经意间断开而且断开后设备端不一定能立刻感知到——要等到下一次发送数据时才发现写失败了。所以设备端必须实现心跳和自动重连机制。我让 ESP32 每 30 秒发送一个 0xE4 心跳帧服务端连续 3 个心跳没收到就主动断开连接设备端收到断开事件后重新走 WiFi 重连和 WS 建连流程整个过程保持在 2 秒以内用户几乎无感知。第五个坑的延伸是 ESP-IDF 和 Arduino 框架的选择。如果只用 Arduino 框架下的 WebSocket 库长时间运行的稳定性是个隐患内存碎片和 task 栈溢出都会缓慢积累。我后来把关键链路迁到了 ESP-IDF 原生组件上稳定性明显提升。如果你已经用 Arduino 跑通了建议至少把 WebSocket 收发放到独立 task 并调大栈空间别在 loop 主循环里干等阻塞。6.3 最终的体验从呆板应答到像在打电话重构完整个链路后我拿给家里人连续用了两天感受确实是天壤之别。有两点特别直观一是家里人现在可以一边干活一边跟玩偶说话不用专门停下来等着它回应二是一旦说错了话直接打断重说玩偶会立刻停下听新的内容而不是像以前那样非要把错的话播完。这种体验上的转变说到底就是二进制音频链路把对话从串行任务变成了并行流水线。实测下来首包延迟从旧方案的 2500ms~3000ms压到了新方案的 650ms~1100ms单次交互的录音等待播放总时长从平均 6 秒缩短到 3 秒上下上下文连续性从单轮无记忆升级为10 轮滑动窗口记忆打断状态清理。最后分享一个个人体会做这类嵌入式 AI 对话设备最容易犯的错是只盯着算法和模型忽略了音频链路的实时性设计。实际上大模型本身的响应速度已经有了很大提升反而是音频采集、传输、播放这条链路上的每一个小延迟才是决定设备聪不聪明的关键。如果想让这个项目继续往上走下一步可以考虑在 ESP32 端做本地 VAD 和回声消除把服务端 VAD 的压力接过去这样既能进一步降低触发延迟也能提升嘈杂环境下的鲁棒性。核心思路就是把能下沉的计算尽量下沉把链路上每 100 毫秒的延迟都抠出来连续对话的体验就是这么一点一点抠出来的。