ESP32语音交互重构:WebSocket二进制流实现连续对话
我前段时间把一个 ESP32 AI 玩偶的语音对话链路从“按一下、说一句、等半天、播一句”重构成了 WebSocket 二进制音频流实时双向链路。改完以后最直观的变化是孩子跟玩偶聊天时会抢话了会打断它唱歌会连续问三四个问题不等提示音。简单说就是从“能对话”变成了“连续对话”。这篇文章我会把整个重构过程掰开讲清楚为什么第一版 HTTP 整段上传的方案注定走不通WebSocket 二进制帧结构怎么设计ESP32 端 VAD、I2S、断线重连怎么处理服务器端怎么把 ASR、LLM、TTS 串成流式流水线以及我踩过的几个典型坑。如果你也在做 ESP32 语音交互、桌面机器人、智能玩具、带麦克风的 AI 硬件这篇文章应该能帮你少走不少弯路。1. “能对话”和“连续对话”差了什么1.1 先看我第一版是怎么做的第一版的流程很典型网上大部分 ESP32 LLM 语音教程都是这个套路用户按住按键不放ESP32 用 I2S 麦克风录音松开按键后停止录音把整段 WAV 通过 HTTP POST 上传到服务器服务器依次调用 ASR 识别成文字、把文字交给大模型、得到回复文本后再调用 TTS 合成整段 MP3最终一次性返回ESP32 收到后播放。这套流程在我的项目里跑通了但体验非常糟糕。录音 3 秒上传 1 秒ASR 1 到 2 秒LLM 2 到 3 秒TTS 再合成几秒钟的语音从头到尾至少 8 秒起步。而且这 8 秒里用户什么都做不了不能打断不能补充不能抢话。说白了这是一个“对讲机”不是“电话”。小孩子才不管你的链路有多严谨他只知道问完一句之后要傻等半天这种交互完全不像真人聊天。还有更麻烦的HTTP 整段上传天然不适合长语音。如果孩子一口气讲了 30 秒上传的 WAV 文件就有 1MB 左右16kHz 16bit 单声道大概是 32KB/sHTTP 上传慢不说服务器还要等完整文件收到后才能开始 ASR延迟在链路层就焊死了。1.2 为什么一定要换成 WebSocket 和二进制帧我对重构提了几个硬性目标语音边录边传、服务器边收边识别、TTS 边生成边播、用户可以随时打断、单次连接能持续服务多轮对话。能做到这些的协议目前最顺手的就是 WebSocket。WebSocket 和 HTTP 最本质的区别它不是“请求-响应”式而是长连接全双工。建立一次连接后客户端能推数据给服务器服务器也能主动推数据给客户端不需要轮询不需要每次都带 HTTP 头。音频流天然是持续的、双向的这种场景简直就是为 WebSocket 量身定做的。但这里有个细节很多人会忽略WebSocket 的 data frame 分文本帧和二进制帧文本帧必须按 UTF-8 编码传输。音频原始数据是 PCM是纯粹的字节流如果你用文本帧传必须先做 Base64 编码体积膨胀 33%编码解码还要额外消耗 CPU。ESP32 这种 MCU 上每一点浪费都不可接受。所以音频数据必须走二进制帧这是链路重构的基础。另外二进制帧不光是传输音频原始数据你还需要在帧头里塞一些必要信息比如帧类型、序号、边界标志。我后面专门设计了一套很薄的帧结构十几字节的开销换来的是整个链路的可控性和排查问题的便利性非常值得。2. 二进制音频链路整体设计思路2.1 帧结构给音频流加个“信封”WebSocket 本身只保证帧的完整性和顺序性它不管你这帧传的是语音还是事件更不管这段语音是不是一整句话的开始或结束。所以应用层需要一个自己的“信封”让接收方一看到帧头就知道这是音频还是控制事件是句首还是句尾这是第几个包我设计了一套非常精简的帧结构每个 WebSocket 二进制消息作为一个底层帧。偏移长度字段说明04MAGIC固定 0xAE 0x01 0x23 0x45用于快速校验41TYPE0x01 音频0x02 服务端音频回复0x03 事件0x04 心跳51FLAGSbit0 句首bit1 句尾bit2 打断标记64SEQ32 位大端序号发方递增102LENpayload 长度大端最大 6553512NPAYLOAD音频数据或其他内容MAGIC 字段看着多余实际上非常有用。网络调试的时候我只需要用串口打印收到的前 4 个字节就能快速判断这个 WebSocket 连接有没有被乱七八糟的中间层污染。有一次排查 Nginx 反代配置问题就是靠 MAGIC 不对立刻定位到是反代层搞坏了数据。TYPE 和 FLAGS 是整个交互状态机的核心。客户端录到一句话的开头发送的音频帧带 bit0录音停顿超过静音阈值发送带 bit1 的帧服务器看到这个标志就知道“这句话说完了可以开始做 ASR 了”。SEQ 用于做播放队列的排序和丢包检测虽然 WebSocket 在 TCP 之上不会乱序但服务器端多路处理时 seq 依然能帮我们发现问题。2.2 客户端到服务器一次完整对话的消息序列用这张帧结构一次完整的语音问答交互长这样客户端把 I2S 采集到的 PCM 数据每 20ms 切一块每块 640 字节16kHz × 16bit × 0.02s 640B封装成 TYPE0x01 的帧发出去。发送前先发一个 FLAGS 带 bit0 的包表示“我要开始说话了”录音静音持续 1 秒后发一个 FLAGS 带 bit1 的包表示“话说完了”。服务器端收到 bit1 帧后把从 bit0 开始收到的所有音频 payload 拼接起来就得到了一句完整的原始语音。然后进入 ASR → LLM → TTS 流水线。TTS 输出的音频流被切成小块通过 TYPE0x02 的二进制帧推回客户端。客户端收到 TYPE0x02 就丢进播放队列按 SEQ 顺序用 I2S 功放播出来。这里有一个非常关键的细节客户端不需要等服务器把整段 TTS 全部推完才开始处理而是来一个包播一个包。这样用户听到首个语音响应的时间会大幅缩短体感上“对话感”会强很多。2.3 音频格式选型PCM 还是 Opus做流式音频传输第一步要决定的就是编解码方案。我第一版用的是 WAV 整段上传简单但笨重。重构后我优先选了裸 PCM理由是它在 ESP32 上开销最小不用解码直接能喂给 I2S 播放。裸 PCM 16kHz / 16bit / 单声道的码率是 256kbps也就是每秒 32KB。局域网或者近距离 Wi-Fi 环境下没有任何压力。但如果你的玩偶要放到公网环境、走 4G/5G 或者跨地域访问256kbps 长时间传输还是有点浪费带宽尤其是不稳定网络下可能会出现排队积压。这时候可以考虑 Opus。Opus 在 ESP32 上完全可以跑OpenEM 或 libopus 移植都有现成案例。但我个人建议第一版先用裸 PCM 跑通全链路不要一上来就上压缩。因为压缩会引入编解码延迟、内存开销和调试复杂度一旦链路出问题你很难分清是网络问题还是编解码问题。先把 PCM 链路做稳定再按需引入压缩是性价比最高的路线。3. ESP32 端的关键实现细节3.1 硬件组合与 I2S 配置我用的板子是 ESP32-S3-DevKitC麦克风是 INMP441I2S 数字麦克风功放是 MAX98357AI2S 数字功放喇叭是个 3W 的小方喇叭。这套组合是 ESP32 音频 DIY 里最常见的搭配便宜、资料多、调试相对容易。INMP441 的 I2S 配置要注意几点它支持 16kHz 采样率、24bit 数据宽度但我们在读取时只取高 16bit 就够用。MAX98357A 是 16bit DACI2S 配置成标准 Philips 模式采样率必须和采集端保持一致。ESP32 的 I2S 驱动读写是 DMA 缓冲方式我设置的是 4 个 1024 字节的 DMA buffer实测下来对延迟和稳定性的平衡比较好。#include driver/i2s.h #define I2S_MIC_WS 4 #define I2S_MIC_SCK 5 #define I2S_MIC_SD 6 void setup_i2s_mic() { i2s_config_t config { .mode (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate 16000, .bits_per_sample I2S_BITS_PER_SAMPLE_32BIT, .channel_format I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags ESP_INTR_FLAG_LEVEL1, .dma_buf_count 4, .dma_buf_len 1024, .use_apll false, .tx_desc_auto_clear false, .fixed_mclk 0 }; i2s_pin_config_t pin_config { .bck_io_num I2S_MIC_SCK, .ws_io_num I2S_MIC_WS, .data_out_num I2S_PIN_NO_CHANGE, .data_in_num I2S_MIC_SD }; i2s_driver_install(I2S_NUM_0, config, 0, NULL); i2s_set_pin(I2S_NUM_0, pin_config); i2s_set_clk(I2S_NUM_0, 16000, I2S_BITS_PER_SAMPLE_32BIT, I2S_CHANNEL_MONO); }注意 I2S_NUM_0 和 I2S_NUM_1 要分开用。麦克风占一个功放占另一个避免时钟和 DMA 通道互相干扰。这块我曾经图省事共用了一个 I2S 外设结果采集和播放互相卡声音撕裂得一塌糊涂。3.2 VAD 与语音边界判断有了音频流下一个核心问题是ESP32 怎么判断用户“开始说话”和“说完了一句话”。我用的是经典的“能量阈值 静音时长”方案没用复杂的机器学习 VAD因为 ESP32 上跑大一点的 VAD 模型内存吃不消而且实时性很难保证。具体做法每拿到一块 640 字节的 PCM计算它的均方根能量RMS。静音时 RMS 很低说话时 RMS 明显跳高。维护一个简单状态机IDLE沉默持续检测 RMS一旦超过阈值进入 LISTENING 并发送带 bit0 的音频帧LISTENING持续发送音频帧同时统计静音时长如果 RMS 低于阈值持续 1 秒发送带 bit1 的帧并回到 IDLERMS 的计算很简单float cal_rms(const int16_t* data, int len) { int64_t sum 0; for (int i 0; i len; i) { sum (int32_t)data[i] * data[i]; } return sqrt((float)sum / len); }阈值怎么定我在安静环境下实测背景噪音 RMS 大概在 80 到 200说话时通常在 800 到 3000。所以我把阈值先设成 500然后通过串口实时打印 RMS 值按实际环境微调。建议你不要照抄别人的阈值不同麦克风、不同增益、不同环境差别非常大。这里有个很容易踩的坑INMP441 对高频噪声很敏感空调声、风扇声都能让 RMS 达到 500 以上。我的处理方法是做一次简单的底噪估计系统启动后先采集 500ms 环境音算出平均 RMS 作为底噪 base然后阈值取 max(base * 2.5, 400)。这样既不至于环境一变就失灵也不会因为底噪高导致一句话中间被误判为静音。3.3 播放队列与断线重连服务器推回的音频帧是一个个独立的块为了让播放连续ESP32 端需要维护一个播放队列。我实现了一个环形缓冲区把收到的 PCM payload 写进去播放任务从里面读数据喂给 I2S。队列长度设置为 200ms 左右的音频量也就是 6400 字节太短会因网络抖动出现断音太长会增加延迟。断线重连这块值得多说两句。WebSocket 连接在真实 Wi-Fi 环境里不可能永远稳定尤其玩偶可能会被孩子挪来挪去。我的重连策略是用指数退避第一次重连等 2 秒第二次 4 秒依此类推最大 30 秒。重连成功后客户端立刻发一个 TYPE0x03 的 RESET 事件让服务器清空这个会话的音频上下文避免把上一轮的半句话拼到新一轮里。void reconnect_ws() { int delay_sec 2; while (!ws_client.isConnected()) { delay(delay_sec * 1000); ws_client.begin(WS_HOST, WS_PORT, WS_PATH); delay_sec min(delay_sec * 2, 30); } ws_client.sendTXT({\type\:\reset\}); }经验是不要用太短的 500ms 重连Wi-Fi 断开后路由器还没把无线状态切回来太频繁的重连只会让日志刷屏并加剧网络拥堵。真正的连续对话体验很大程度是靠一个稳扎稳打的重连策略撑住的。4. 服务器端流式处理4.1 WebSocket 服务与并发写锁服务器我用的 Golang gorilla/websocket。选择 Go 不是因为别的主要是并发模型太适合处理大量 WebSocket 长连接了。一个连接分配一个读 goroutine 和一个写 goroutine逻辑清晰不容易出死锁。gorilla/websocket 有一个非常关键的注意事项同一个连接不能并发写。如果多个 goroutine 同时调用 WriteMessage会直接 panic 或导致数据交错。所以每个连接必须配一个写锁我习惯把发送逻辑封装成一个方法统一加锁。type Client struct { conn *websocket.Conn send chan []byte mu sync.Mutex } func (c *Client) WriteMessage(msgType int, data []byte) error { c.mu.Lock() defer c.mu.Unlock() return c.conn.WriteMessage(msgType, data) }语音块推回的频率其实挺高TTS 每产生 20ms 的音频就推一帧一秒钟几十帧。写锁竞争不会很大但是不加锁一定会炸这是我在测试阶段亲手踩过的坑。4.2 会话状态管理与会话超时每个 WebSocket 连接对应一个会话。我在服务器端用一个全局 map 维护当前所有活跃会话key 是连接 IDvalue 是一个 Session 结构体里面保存着当前音频累积片段、ASR 引擎实例、LLM 上下文、TTS 播放队列状态等。会话要有超时清理机制。比如用户连着 WebSocket 但 120 秒没有任何音频帧我会主动发一个事件提示并关闭连接否则会有一堆僵尸连接占用内存。清理机制我用的是定时扫描每 30 秒检查一次 last_active_time。会话上下文中LLM 的历史消息列表是多轮对话的关键。我把用户说的话和 AI 的回复都追加进上下文同时限制最大 token 数超过就丢弃最早的对话。否则聊到十几轮以后光上下文就超了模型限制调用直接报错。4.3 ASR / LLM / TTS 流水线怎么串服务器收到带 bit1 的音频帧后会触发流水线拼接好的 PCM → ASR → 文本 → LLM → 回复文本 → TTS → 音频帧推回客户端。这条流水线用同步方式串起来最简单但当 TTS 生成整个回复音频时通常需要几秒钟如果等全部生成完再推客户端感知的延迟又会回到 8 秒的老路。我的做法是只让 ASR 和 LLM 等待完整结果TTS 生成器一旦拿到文本就按句子切分每生成一小块比如 200ms立即通过 WebSocket 推给客户端。这样用户感受到的时间线是说完话 0.5 秒内 ASR 出文本LLM 生成约 1 到 2 秒TTS 第一个音频块开始播放后用户大概在说完话后 2 到 3 秒就开始能听到回复了后面内容边生成边播整体体感非常接近两个人打电话。这一段的实现每个服务商接口各不相同但原理一致。核心思想是“能流式的地方绝不整段等待”。ASR 如果有实时识别模式就用实时识别TTS 如果有流式合成模式就用流式合成这两点对延迟的改善比 LLM 快慢还要明显。5. 从“流式”到“连续对话”的最后一公里5.1 回声消除是最大的拦路虎流式链路跑通后我遇到了一个非常头疼的问题玩偶在播放回复的时候麦克风会把喇叭的声音也录进去于是服务器 ASR 会识别出刚才 AI 自己说的话以为用户又说了新内容然后 AI 又回复麦克风又录到回复内容形成无限自激循环。说白了就是“你对着麦克风放录音它会一直说个不停”。这个问题在学术上叫声学回声解决手段是 AEC声学回声消除。ESP32 上可以用 ESP-ADF 框架里的 AEC 模块它基于 WebRTC 的 AEC 算法做了一些优化不过我实测下来效果和硬件结构关系极大。最实用的方案其实是物理隔离加音量控制。我把喇叭朝下放置用隔音棉挡住喇叭到麦克风的直达路径同时把喇叭音量调到 60% 左右。配合 ESP-ADF 的 AEC在安静的室内环境能做到不误触发。但如果你在声音反射严重的房间里测试还是会漏。所以我的建议是第一版连续对话不要求全双工先做半双工逻辑播放期间不把麦克风数据送入识别链路。这虽然损失了“同时说话”的能力但保住了稳定性。5.2 打断检测连续对话的灵魂连续对话最直观的爽点其实是“能打断”。孩子听到答案不满意可以直接说“不对你再想想”AI 应该立刻停止当前播放并开始听新指令。我的实现方案是在播放 TTS 的过程中麦克风持续采集音频但只做 RMS 检测不做 ASR。如果 RMS 连续 3 个 20ms 帧都超过一个较高阈值比 VAD 阈值更高比如 1600就认为用户在说话此时客户端发送一个 TYPE0x03 的打断事件给服务器服务器收到后丢弃剩余 TTS 消息客户端同时清空播放队列关闭当前播放重新进入监听状态。如果打断成功用户说下一句时客户端携带的 bit0 会被服务器识别为新一轮“用户指令”而不是把前一句 AI 回复的词拼接进上下文。这个逻辑在 Session 里有一个 is_playing 状态位控制实现起来并不复杂但对体验的提升非常大。一个务实的渐进路线是按键对讲 → WebSocket 流式半双工 → 唤醒词启动监听 → 打断检测 → 真正的全双工 AEC。从任何一步跳到下一步都比直接一上来就做全双工要稳得多。6. 常见问题与排查技巧实录6.1 WebSocket 连接 1006 掉线做 WebSocket 音频流之后我最常遇到的错误码就是 onclose 1006。1006 在 WebSocket 协议里表示“连接被异常关闭”既没有 close frame也没有明确的错误说明特别让人抓狂。可能原因排查手段解决方式服务器/gateway 主动断开看服务器日志确认是否有 panic 或连接被清理修复服务端异常关闭前发 close frameNginx 反代超时时间太短用定时 ping/pong 或日志记录连接时长调大 proxy_read_timeout、配置 websocket 升级头网络环境切换Wi-Fi 断连/弱网观察重启前后信号强度增加重连退避机制重连后发 reset 事件长时间空闲被服务端踢掉看服务端空闲超时策略客户端每 30 秒发心跳帧实践中最坑的是1006 这个错误被很多 WebSocket 客户端库统一汇报成“连接疑似被恶意关闭”但实际上绝大多数情况不是恶意而是 Nginx 的超时设置有误。如果你用了 Nginx 反代一定要确认配置了 Upgrade 和 Connection 头并且 proxy_read_timeout 至少 300 秒。6.2 音频断断续续、爆音、识别错乱音频链路的问题往往不是网络问题而是 I2S 配置问题。我遇到过一个典型现象播放声音有“啪”的爆音调试了很久发现是 I2S 的采样率在录音和播放之间不一致导致时钟不同步。此外DMA buffer 太小也会导致音频流坑坑洼洼。buffer 设置 4 个 1024 字节能明显减少刺刺啦啦的声音。如果播放结束时直接切断音频会产生尖锐的 pop 音我后来在播放队列末尾补了一段静音再停止播放效果立刻改善。最后VAD 阈值如果设得太低环境噪声很容易被当成语音送入 ASR然后识别出一堆莫名其妙的文字。建议在串口打印实时 RMS用几组不同环境的录音来确定阈值。切勿凭感觉给一个固定值。6.3 ESP32 内存不够、频繁重启这个问题在纯 PCM 常驻链路里很常见。裸 PCM 一秒 32KB如果 ASR 需要整句音频用户说 10 秒就产生 320KB 数据。ESP32 常规型号的 RAM 通常只有 320KB 左右跑完 Wi-Fi 协议栈、WebSocket 库、I2S DMA 缓冲、播放队列之后剩余内存非常有限很容易 OOM 重启。最有效的办法有两个第一使用 ESP32-S3 PSRAM 版本把音频累积区放到 heap_caps_malloc(MALLOC_CAP_SPIRAM)第二改进协议边收边发客户端只保留当前这一句话的素材服务器收到 bit1 后立刻把整段音频转交给 ASR客户端释放缓冲区。我实际的项目最终两个方法都用上了设备连续运行一整天没有发生过一次 OOM 重启。做完整条链路我最大的感受是“连续对话”本质上不是大模型能力的问题而是音频链路基本功的问题。就算不换更聪明的模型把传输链路从 HTTP 整段改成 WebSocket 二进制流式体感也能从“对讲机”升级成“电话”再加上打断和重连就非常接近自然对话了。如果你也准备改造自己的 ESP32 语音硬件建议就按“对讲机 → 流式半双工 → 打断 → 全双工”这个顺序来每一步都能看到实实在在的进步也方便在出错时精准定位到底哪一层出了问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →