1. 从一问一答到连续对话这次重构到底在解决什么我手头有个 ESP32 AI 玩偶项目之前已经能实现最简单的“你问一句、它答一句”的对话。但用户拿回家玩两天就受不了说完问题后要等很久回答过程中还不能插嘴偶尔网络抖一下就直接失败。这次重构的目标非常明确把原来的 HTTP 整段音频上传和下载播放换成基于 WebSocket 的二进制音频链路让玩偶具备连续对话能力。文章不聊云里雾里的概念只讲我这次真实改过的方案、踩过的坑以及最后怎么把端到端延迟从 3 秒压到 800ms 左右。如果你也在做 ESP32 语音玩具、桌面机器人或者任何低资源 AI 交互设备这篇应该能帮你少走不少弯路。1.1 “能对话”和“连续对话”的差距在哪先说“能对话”是什么状态。旧版玩偶的逻辑非常简单按下按钮或者检测到语音结束之后录音 3 秒把整段 WAV 通过 HTTP POST 发给服务器服务器等识别完再把合成好的 MP3 下载回来播放。这个链路里玩偶在“录音—等待—播放”三个阶段里只有一个动作在发生用户必须被动等它走完。最难受的是播放合成音频的那几秒系统完全不理新输入哪怕用户喊“停”也没反应。连续对话要解决的本质问题是人机交互里的“全双工”“人说话的一瞬间设备已经知道你在说话人还没说完识别结果已经流式出来人刚停顿设备就开始给响应响应过程中人一旦插嘴设备立刻闭嘴让路。” 这靠 HTTP 整段传输从头到尾都做不到因为 HTTP 天然是“请求-响应”模型客户端必须等服务器把结果全部给完才能播放。真要做得顺畅必须建立一条长连接音频以流的方式持续双向跑服务器边收边识边合成边下发客户端边收边播。另外语音识别本身有“端点检测”VADVoice Activity Detection的概念。旧方案是录完一整段再传覆盖不了一句话说一半被夹断、停顿后再继续的场景。连续对话需要把音频切成 20ms 或 30ms 的小块持续发给识别引擎让引擎自己去判断这句话是不是结束了。哪怕用户说“今天天……嗯明天天气怎么样”识别结果也可以实时修正而不是只拿最后落盘的录音去识别。1.2 为什么一定要动音频链路而不是加个按钮有人会问不做全双工直接加一个“打断按钮”播放时候按一下停止然后重新录音是不是也能假装连续对话我实测过体验上完全不是一回事。按钮打断必须靠手操作玩偶交互场景里用户两只手经常拿着东西或者小朋友压根不知道要按哪里。更关键的是加了按钮也只能解决“停止播放”没法解决“人话还没说完机器就开始抢话”的问题。连续对话的核心是让设备有听觉优先级只要人一开口机器必须立刻降低自己的音量或停止输出而不是等一句话说完再处理。我这次重构的第一步就是把“音频采集”和“音频播放”拆成两条独立的数据总线让它们可以同时工作。ESP32 上写音频采集任务的优先级高于写播放任务这样麦克风输入永远能抢到 CPU 和 DMA 资源。播放线程只在没有检测到人声时正常工作一旦 VAD 判断本地有人声播放增益直接压到 0同时通知服务端发送 interrupt 控制帧。这套机制做下来玩偶才第一次有了“被抢话”的反应。2. 方案定型WebSocket 二进制帧抛弃 JSON 音频链路选型上我见过很多项目直接用 WebSocket 传输 JSON音频做 Base64 编码塞进 JSON 字段里。这种方案写起来最快调试也直观但它不适合低资源设备。Base64 会让 1 字节变 1.33 字节JSON 解析和序列化在 ESP32 上又非常吃内存连续跑几分钟很容易因为堆碎片导致重启。所以这次我直接定了二进制协议所有数据都走 WebSocket Binary Message不再碰 JSON 音频。2.1 为什么选 WebSocket 而不是 HTTP 或 RTSPHTTP 的主要问题前面说了请求响应模式不适合流式。RTSP 又太重ESP32 上要跑 RTSP 客户端还得引入大量 RTP/RTCP 状态机而且大多数公网服务器不会直接开 RTSP 端口。WebSocket 是浏览器时代为长连接设计的协议握手走 HTTP 升级之后就是 TCP 上的二进制流非常干净。它在 ESP32 上客户端库成熟在服务器端哪个语言都有异步支持公网部署也不容易被防火墙误伤。还有一点容易被忽略WebSocket 自带帧边界每个 Binary Message 天然是一个完整的数据块。这样我可以用每个 Message 承载一小段音频或一条控制指令不需要在应用层额外去切 TCP 流。但要注意Message 本身可能很长底层 TCP 还是会拆包所以应用层依然需要缓冲区来拼完整帧这一点我后面会详细说。2.2 二进制帧协议控制帧与音频帧分离协议设计不能只想到“往 socket 里丢裸 PCM”。服务器要同时处理多个客户端、要识别插话、要下发不同格式的音频所以必须在二进制流里约定好帧头。我这个项目最终用的帧结构如下字段长度说明magic2 字节固定 0xAA55用于快速对齐version1 字节协议版本当前固定 0x01type1 字节帧类型区分音频/控制/心跳等seq2 字节帧序号用于丢包检测和排序payload_len2 字节payload 长度最大 65535payload可变音频数据或控制指令type 我定义了 6 种1 表示客户端上行音频帧PCM/Opus2 表示客户端控制帧start/stop/interrupt3 表示服务端下行音频帧4 表示服务端控制帧如识别中间结果、TTS 状态5 表示错误帧6 表示心跳帧。控制帧的 payload 不是音频而是很短的结构体比如 interrupt 帧就只有一个字节的 reason 字段。这个设计看起来平淡无奇但实战中非常管用。magic 让你可以在 ESP32 上快速跳过垃圾数据type 让服务器和客户端都能区分“这是声音”还是“这是一条命令”。seq 虽然很多时候用不上但是在弱网环境下能帮助你发现乱序和丢包不至于把前面的话插到后面来播。2.3 采样率、位深、声道和帧尺寸的取舍音频参数我建议直接定 16kHz、16bit、单声道。原因很简单绝大多数语音识别服务都支持这个规格而且 16kHz 已经覆盖人声主要频段玩偶喇叭听合成语音也够清晰。44.1kHz 全频带音频会让 ESP32 的 I2S 走更高的带宽WiFi 和音频抢信道时更容易卡顿没必要。帧尺寸上我用 20ms 一个包。16kHz、16bit、单声道20ms 就是 320 字节。这个大小非常合适足够小服务器可以快速做 VAD 判断也不至于太小不然每包的网络开销比例会很高。如果你用 I2S 采集到 4800 字节的块到应用层再切成 320 字节的小块发送即可。如果用 Opus 压缩20ms 帧在 32kbps 码率下只有 80 字节左右流量能省很多但代价是 ESP32 上要集成 Opus 编码器费 ROM 和 CPU。我第一版先用裸 PCM 打通链路等一切稳定后再切 Opus。裸 PCM 上行速率大约是 32KB/s对于普通家庭宽带和流量套餐完全不构成压力所以你如果不是做低功耗长续航设备直接裸 PCM 起步是最省事的。3. 服务器端从“一次请求一次响应”改成流式中转服务器的改造是这次重构里工作量最大的一块。原来是一个 HTTP View接收完整录音调用识别 API再调用合成 API返回音频文件。现在要变成常驻内存的 WebSocket 服务每个客户端对应一条双向音频管道。3.1 语音识别与语音合成的接法我用的 Python FastAPI websockets 库。每个客户端连接进来后起两个异步任务一个 receiver task 负责读上行二进制帧一个 sender task 负责发下行音频和控制帧。识别服务我用的是云厂商的流式语音识别接口它接受一个持续输出的音频流然后回调返回识别中间结果和最终结果。合成服务也选了支持流式返回的接口但为了兼容性我写了一个简单的缓冲层如果 TTS 接口一次性返回完整 WAV我就按 20ms 切片分批发送。这里有个关键点识别接口的音频流必须和客户端上行的顺序一致不能因为网络抖动乱序就丢掉中间片段。我在 receiver task 里维护一个队列收到音频帧先校验 seq发现空洞就标记一次丢包但不阻塞后续处理。因为语音识别对短时间十几毫秒丢一包其实容忍度还可以最怕的是顺序错乱所以我的队列只做 FIFO不做排序真乱序了就靠中止重呼来解决。合成音频下发的逻辑更讲究节流。先看客户端是否处于可接收状态。如果服务端还在等识别结果客户端那边播放任务其实是空闲的可以正常收包。一旦开始播 TTS我就按播放节奏发送每发完一包用 asyncio.sleep 控制间隔避免把玩偶的网络缓冲区直接塞满。3.2 全双工转发上行识别、下行合成怎么并行如果只是简单的“上行走识别、下行走合成”那还是半双工。连续对话的容器里要允许用户在 TTS 播放时继续说话服务器要同时处理两类事件一类是新到的识别音频一类是 client 发来的 interrupt 控制帧。我设计了三个事件源incoming_audio、interrupt_event、tts_state。receiver task 每收到一个音频帧就把它推入识别队列同时检查用户音量是否超过阈值一旦检测到人声就触发 interrupt_event。sender task 在播放 TTS 的过程中会监听 interrupt_event一旦触发立即停止当前播放循环把已经合成但还没发出去的所有音频帧清空然后向识别服务发送强制终端的指令重新开启一轮识别。这个逻辑用伪代码写就是这样async def sender_task(ws, tts_audio_queue, interrupt_event): while True: if interrupt_event.is_set(): tts_audio_queue.clear() interrupt_event.clear() continue # 检查当前是否在播放窗口内 if can_send_next(playback_window): audio_chunk await tts_audio_queue.get() await ws.send(build_audio_frame(audio_chunk)) else: await asyncio.sleep(0.01)实际工程里还要加一个“播放许可”令牌令牌被播放状态机控制。用户一旦出声令牌被夺走sender_task 就卡在等待令牌上等于天然实现了“抢话暂停”。3.3 实测中踩过的坑1006、io 断流和后台回收调试过程中我遇到最多的两个问题一个在客户端连接侧一个在服务器进程侧。WebSocket 的 1006 错误含义是连接被异常关闭没有收到正常的 Close 帧。在 ESP32 上表现为连上服务器后跑几十秒网络稍一波动就直接断开客户端打印 close code 1006。这种问题不是协议写错多数是服务端空闲超时或者防火墙把长连接踢了。我最后在服务器 WebSocket accept 时把 ping_interval 设为 10 秒ping_timeout 设为 5 秒客户端也单独做心跳两个方向都有保活1006 才明显减少。另一个报错信息是stream disconnected before completion: failed to send websocket request: io。这个看着像网络问题实际排查下来往往是我服务器进程退出导致 socket 被回收或者服务端 handler 抛异常后连接池没正常关闭。解决方式是所有 websocket 处理函数包一层 try/finallyfinally 里必须关闭连接并清理队列服务器部署用 systemd 或 supervisor 托管进程崩溃后自动拉起避免零点定时任务回收导致连接全断。4. ESP32 客户端让音频真正“流”起来服务端只是管道真正让玩偶自然的是 ESP32 这边的采集、播放、打断和网络协作。我用的板子是 ESP32-S3音频 Codec 是 ES8311如果用经典的 ESP32 INMP441 麦克风 MAX98357 功放也可以核心逻辑一致。4.1 采集和播放怎么不打架I2S、DMA 与双缓冲ESP32 有 I2S 外设但同一个 I2S 端口不能同时做采集和播放。我用两个 I2S 总线I2S0 接麦克风采集I2S1 接功放播放。两个端口各自开 DMADMA buffer 我设置为 4 个 block每个 block 320 字节这样每个中断回调里刚好能拿到 20ms 的数据直接塞进 WebSocket 发送队列。很多新手在这里犯的错误是把采集和播放放在同一个循环里先读麦克风再写喇叭。这样音频链路会互相拖慢播放时采不到音采音时放不了声。正确做法是采集任务用高优先级队列播放任务用低优先级两者完全解耦。我用 FreeRTOS 双任务实现audio_capture_task 负责 I2S 读、塞队列、发送audio_play_task 负责接收网络帧、写 I2S。两个任务之间有独立的队列深度播放队列满就丢弃旧帧绝不阻塞网络接收。播放端还要注意 I2S 的 bit_clock 和 sample_rate 配置一定要和服务器下发音频一致。如果有 resample尽量在服务器端完成不要指望 ESP32 跑算法重采样。我试过在 ESP32 上用 esp-adf 的 resample 组件CPU 占用高还容易出现爆音后面改成服务器统一转成 16k/16bit/mono 再下发。4.2 二进制拆包、粘包和序号校验WebSocket 库本身会保证一个 Binary Message 完整送达但服务器每个 Message 可能包含多个逻辑帧比如我为了省头部开销把两个 20ms 音频块合成一个 Message 发下去。这就需要在 ESP32 端做二次拆包。我在 ESP32 上维护一个接收缓冲区rx_buffer收到 WebSocket Binary Message 时先把整包数据 append 到rx_buffer然后循环解析帧头。解析逻辑大概是size_t pos 0; while (pos 4 rx_buffer.size()) { uint16_t magic (rx_buffer[pos] 8) | rx_buffer[pos 1]; uint8_t version rx_buffer[pos 2]; uint8_t type rx_buffer[pos 3]; // 再读2字节seq、2字节payload_len... if (payload_len remaining) break; // 等待下一包 process_frame(type, seq, payload_ptr, payload_len); pos frame_len; } // 把剩余数据移动到缓冲区头部如果收到的半包卡在缓冲区中间不要清空继续等后面的 Message 拼接。这个“sticky packet”处理是所有 WebSocket 流式传输的必修课。还要注意 seq如果 seq 跳变超过 5说明网络丢了比较多我会主动向服务器发一个控制帧请求重发最后一段或者直接重置播放缓冲区避免一直播旧内容。4.3 打断机制不是关掉播放那么简单玩偶要“被抢话”打断至少要做三件事缺一不可第一本地播放任务要能立刻停止。ESP32 上直接清空 I2S 播放 DMA 的 buffer把播放任务挂起让功放静音。只把任务优先级调低是不够的必须主动调i2s_zero_dma_buffer或类似接口把未播完的数据清掉否则功放还会继续发几毫秒声音听着像“磕巴”了一下。第二向服务器发 interrupt 控制帧。这个帧必须在音频帧前面优先发送告诉服务器“我已经停了”。服务器收到后才会清空 TTS 发送队列重新开始识别。如果客户端不发这帧服务器会以为还在正常播放继续往下发等到用户再说下一句时才反应过来延迟一下就上去了。第三本地也要进入“听”的状态。打断后要立刻重新开启麦克风采集不能再停在播放模式。我通过一个全局状态变量playback_active控制播放任务在循环里检查这个变量一旦被打断就退出循环采集任务则始终运行永远不会被关掉。这样用户话还没说完采集到的音频就已经开始上传了。4.4 从 HTTP 轮询迁移到 WebSocket 长连接时的注意点旧逻辑里每次对话都是“开 socket、发请求、收文件、关 socket”WiFi 连接经常重新建立功耗高且不稳定。改成 WebSocket 长连接后第一次握手在设备启动时完成之后一直保持。这带来几个新问题内存占用、重连时机、服务器状态同步。内存上长连接必须常驻ESP32 的 TLS 连接如果开启需要至少 40KB RAM。我这版用的明文 WebSocket局域网或内网穿透场景省下不少内存公网场景建议至少用带 TLS 的但要把CONFIG_ESP_TLS_USING_MBEDTLS开好并调大任务栈。重连不能放在主循环里硬怼。我用指数退避断线后先 1 秒重连一次失败等 2 秒再失败等 4 秒最多到 30 秒。每次重连成功后要重新发送一次“reset”控制帧让服务器清空旧上下文否则识别引擎会以为上一句话还没说完连续对话状态错乱。5. 连续对话的体验指标与调优链路通了之后具体体验还要靠数据说话。我这里把连续对话的几个关键指标和调优做法列出来。5.1 端到端延迟的计算方法用户说“你好”到玩偶说“我在”这中间到底经过多少毫秒我按下面几个阶段拆解阶段耗时ESP32 采集 20ms 音频约 20ms音频帧发送到服务器10~30ms服务器 VAD 判定语音结束200~400msASR 识别并返回文本100~300msTTS 合成第一个音频块200~500ms音频块下发到 ESP3210~30msESP32 播放缓冲播放50~100ms加起来大约 600ms 到 1.4s。我们实测多数在 800ms 左右。想要压到更低优化点集中在 VAD 判定和 TTS 首包延迟上。VAD 判定不能等用户彻底安静 500ms 才结束我用“连续 250ms 静音”就判定结束同时依赖 ASR 的半句结果提前触发 TTS这里要小心误判但玩偶对话场景下宁快勿慢。播放缓冲也是大头。ESP32 网络环境不稳时我会把播放缓冲开到 5 个包大约 100ms换来防卡顿网络稳定时改成 2 个包延迟能再少 60ms。这个参数做成可配置OTA 下发。5.2 回声消除、降噪和增益调整连续对话最容易出现的问题是玩偶把自己的合成声音录进去形成回声。如果是全双工同时收发必须做回声消除。便宜的方案是硬件上让喇叭和麦克风距离远一点音腔做隔离软件上我接了 ESP-ADF 里的 AEC 组件把参考信号从播放任务引到采集任务能压掉大部分回声效果够用。降噪方面因为家庭环境有风扇、空调声我用 ESP32 的esp_dsp做了一版简单的噪声门低于阈值的帧直接静音高于阈值的正常上传。你不要指望在 ESP32 上跑深度学习降噪单核处理器跑不动。更实际的是调高麦克风增益到中等水平避免把环境底噪放大服务器端如果 ASR 自带降噪就把enable_filter打开效果比端上好。增益调整要配合 AEC 做联动。每次 TTS 开始播放前我把麦克风采集增益降低 6dB播放结束再恢复。这样即使 AEC 没覆盖到所有路径回声也会小很多。缺点是人声靠近时可能被压掉一点音量但这部分靠 AGC 自动补偿即可。5.3 断线重连和状态机设计连续对话的本质是一个有限状态机我最终抽象出四个状态IDLE空闲等待语音激活。LISTENING正在采集发送给识别服务。THINKING识别完成等待 TTS 首包。SPEAKING正在播放合成音频同时允许被 interrupt 打断。状态机跑起来后我发现最乱的是“SPEAKING 中收到新的用户语音”。如果不处理会出现机器一边说、一边又识别出一堆指令上下文全乱。我的做法是在 SPEAKING 状态本地 VAD 检测到人声后状态先切到 LISTENING同时发 interrupt 帧只有收到服务器返回的“interrupt ack”控制帧才允许下一轮识别结果进入 TTS。没收到 ack 之前的识别结果一律丢弃防止串话。断线重连也要挂在状态机里。断线时状态强制回到 IDLE重连成功后不自动恢复之前的会话而是要求用户说新的唤醒词。这样虽然每次断线后多一步唤醒但避免了重连后上下文错乱造成更差的体验。实际测试里只要服务器不崩WiFi 信号正常长连接能稳定跑几小时没问题。6. 通用排查速查表和一点体会折腾完这一整套 WebSocket 二进制音频链路我把遇到的高频问题整理成一张表方便你以后直接查。6.1 高频问题速查表现象大概率原因排查与解决WebSocket 连接几秒后断开close code 1006服务端空闲超时 / 防火墙踢连接 / 心跳没配服务端开启 ping_interval客户端也发心跳确认网络层不做长连接干扰客户端报failed to send websocket request: io服务端进程异常退出或连接被回收看服务端日志handler 要 try/finally 关闭连接用 systemd 托管进程ESP32 上音频播放断断续续播放缓冲太小或网络丢包触发清空增大播放缓冲到 5~10 个包服务端降低发送频率必要时让服务器做前向纠错重发最后一个包输出声音有爆音/沙沙声I2S 配置采样率不匹配或 DMA buffer 太小统一 16k/16bit/monoDMA block 设为 20ms 对齐播放任务栈加大无法打断机器一直说本地 VAD 没接入播放状态机或 interrupt 帧没发采集任务检测到人声后必须立即清 DMA、发 interrupt 帧停止 TTS 队列对话延迟很高总感觉慢半拍VAD 静音等待时间太长或 TTS 首包延迟大缩短静音判定阈值到 250msASR 半句结果提前触发 TTS减小播放缓冲服务器内存持续上涨每个连接的任务/队列没释放连接关闭时 cancel 所有异步任务、清空队列使用连接级上下文管理器长时间不对话后首句识别很慢云端 ASR 连接可能被回收需要冷启动每 5 秒发一个 20ms 静音帧给识别服务保持连接活跃6.2 我的实操体会与后续扩展这次重构给我最深的一个体会是连续对话不是“多了一个流式接口”这么简单它需要采集、网络、服务器转发、识别、合成、播放六层协作任何一层还在用“整段处理”的思路整体体验都不会好。我先花了一个礼拜把二进制帧协议定死再花两天把 WebSocket 长连接打通最后调音效和打断又用了一周。如果你也想做类似项目建议先别急着接复杂 AI 能力先把“16k 音频双向流 心跳 interrupt 控制帧”这条骨架跑通再接 ASR/TTS 会省很多事。后续我打算做三件事一是把上行裸 PCM 换成 Opus压缩 4 倍带宽这样在 4G 或弱网环境更稳二是在服务器端用 WebRTC 的音频前处理器做更稳的 AEC 和降噪端上只做采集和播放把复杂算法都上云三是给状态机加一个“持续多轮记忆”模块用户不需要每次都喊唤醒词一句话结束后的 3 秒内再次说话自动延续上下文。这套重型方案等玩偶的 MCU 升级到双核高主频后还能继续演进但眼下这套 WebSocket 二进制音频链路已经足够让玩偶从“能对话”迈向“连续对话”了。
