ESP32 WebSocket二进制音频链路实现连续对话交互 最近在把一款基于 ESP32-S3 的 AI 玩偶从“按键说话、等待回答”的交互方式改造成“直接对它讲、它随时接话”的连续对话形态。折腾完最大的感受是真正卡住体验的不是 AI 能力而是音频链路。原来那种“录一段完整音频、HTTP 上传、再拿回一整段 MP3”的方案demo 能跑但一放到连续对话场景就各种别扭。这篇就完整复盘一下我把 WebSocket 二进制音频链路搬到 ESP32 上的过程和思路为什么抛弃 HTTP 短连接、音频帧协议怎么设计、ESP32 端怎么采集和推流、服务端怎么桥接 ASR/TTS以及我实测中踩过的坑。内容偏实战适合正在做 ESP32 语音助手、AI 硬件玩具或者正在用 WebSocket 传音频流的朋友参考直接抄作业也可以。1. 为什么“能对话”和“连续对话”之间隔着一道墙1.1 最初的一问一答链路长什么样很多 AI 语音硬件的第一个版本都长这样设备端按下按键开始录音录音存到内存 buffer松开按键后把整段音频通过 HTTP POST 扔给服务器。服务器拿到完整文件先调 ASR语音识别转成文本再让大模型生成回复文本接着用 TTS 合成一整段音频文件最后把文件 URL 返回给设备设备下载完再播放。这套流程在逻辑上是通顺的最开始我也觉得“能对话”了。但实际跑起来用户玩两分钟就会发现不舒服。问题出在哪一句话这套架构天然是批处理模式不适合交互型场景。1.2 连续对话场景下五个让人崩溃的痛点第一是延迟叠加。录音要录完一整句录音结束才上传。上传之后服务器要“先 ASR、再 LLM、再 TTS”而且三个环节是串行的。TTS 还得等到整段文本生成完才能合成音频首字延迟经常到 5-10 秒。对玩偶这种需要“即时响应感”的产品来说这个延迟基本等于劝退。第二是交互割裂。用户必须“说完一句话、停下来、等结果”不能打断不能插话。AI 说话的时候用户再开口设备压根不采集因为播放和录制在代码里是互斥的。想做一个“能随时被用户打断”的体验旧架构完全无从下手。第三是连接开销。每一次 HTTP 请求都是一次完整的握手。ESP32 这种资源受限设备做 TLS 握手要消耗几百毫秒和不少内存。高频交互时每次请求都重新握手光连接损耗就占了一大截。第四是状态盲区。服务器在拿到完整录音之前完全不知道用户是不是已经说完了。它没法判断“这句话说了一半停顿”和“这句话结束了”的区别也就没法做到真正的流式识别和即时响应。第五是半双工的宿命。旧方案里“采集-上传-等待-播放”是一个严格串行的流水线。想要做到像对讲机那样边听边说或者像电话那样全双工这个模型根本做不到必须换架构。1.3 为什么最终锁定了 WebSocket 二进制链路我对比过几套方案。MQTT 要额外搭 broker音频负载虽然能传但消息大小、实时性和二进制帧支持都不如 WebSocket 顺手。UDP 传输实时性最好但丢包和 NAT 穿透问题一堆不适合做产品。RTSP 那种流媒体协议在嵌入式端太笨重。WebSocket 是最合适的中间点。它在一条 TCP 连接上同时支持上行和下行天然全双工。连接建立之后不需要每次重新握手TLS 握手成本摊到整个会话周期里。而且 WebSocket 原生支持二进制帧可以直接承载 PCM 裸流不需要像文本帧那样做 Base64 编码省掉了大概 33% 的传输膨胀。更关键的是它能把“录音-识别-生成-播放”从四个串行环节改造成一条同时流动的管道。用户说话的同时音频片段不断通过 WebSocket 上行推到服务器服务器识别出文本、生成回复后TTS 音频又通过同一条连接的二进制帧实时下行推回 ESP32。这种模式下首字延迟可以从原来的 5 秒级别压到几百毫秒级别。所以我把重构的核心放在了 WebSocket 二进制音频链路上。这个“二进制”三个字特别重要很多人用 WebSocket 传音频时习惯性地用 JSON 包一层再 Base64那其实又把问题绕回去了。2. WebSocket 二进制音频链路的核心设计2.1 音频编码参数怎么定PCM16、16kHz、单声道音频参数的选择决定了整条链路的码率和实时性表现。我最终定的是PCM 编码、16kHz 采样率、16bit 位深、单声道。为什么是这几个参数16kHz 采样率是绝大多数 ASR 服务不管是云端还是本地部署的输入标准低于 8kHz 识别率明显下降高于 16kHz 对识别准确率的提升很有限但码率会翻倍。16bit 位深是音频处理链路的默认格式做 VAD语音活动检测、回声消除这些处理都基于 16bit 整型。单声道是语音场景的标配双声道对识别没有帮助纯属浪费带宽。码率算一下就很清晰了每秒数据量 16000 × 2字节 × 1声道 32000 字节/秒约 31.25KB/s如果按 20ms 切一帧每帧音频数据量 32000 × 0.02 640 字节这个数据量对 WiFi 来说毫无压力ESP32 的 WiFi 吞吐量动辄几 MB/s31KB/s 连零头都不到。连续对话场景真正的瓶颈从来不是带宽而是延迟和抖动。2.2 为什么不能用 JSON 文本帧传音频见过很多开发者第一次用 WebSocket 传音频习惯性地把音频数据先转 Base64然后塞进 JSON 里发出去{ type: audio, data: UklGRiIAAABXQVZFZm10IBAAAAABAAEAgD4AAIAAAABAAgA... }功能上能跑但实际上有四个问题。一是体积膨胀。Base64 会把每 3 个字节变成 4 个字符膨胀 33%。之前算过一帧 640 字节Base64 之后就变成约 854 字节。连续对话场景下这些额外字节每一帧都在传输累积起来很可观。二是编解码开销。ESP32 的 CPU 主频虽然不低但跑 Base64 编解码加 JSON 序列化/反序列化对实时音频链路来说就是白白烧掉 CPU 周期。ESP32 同时要做 I2S 采集、VAD 检测、网络传输能省一点是一点。三是解析不确定性。服务器解析 JSON 时无法保证每条 WebSocket 消息正好对应一帧音频。如果客户端把多帧数据合并成一次发送服务器还得自己做拆分——这就是在应用层重新发明一套“分包协议”纯属自找麻烦。四是处理延迟。JSON 需要完整收到一个文本消息之后才能解析流式场景下如果服务器要边收边处理JSON 解析反而成为瓶颈。正确做法是直接使用 WebSocket 的二进制帧Binary Message把 PCM 数据原样放进二进制帧的 payload 里发送。一个 WebSocket 二进制消息 一帧音频天然的边界服务器收到就能直接处理不用解析任何文本结构。2.3 音频帧协议设计一个“快递面单”的故事虽然 WebSocket 自带消息边界但链路里传的不只有音频数据还有控制信令比如“开始说话”“停止说话”“AI 开始回复”“播放完成”。如果不设计一个统一的帧格式服务端就得靠消息类型来猜流式场景下容易出乱子。我给音频流设计了一个轻量帧头类似快递面单把必要信息写在前面后面挂 payload| magic(2B) | version(1B) | type(1B) | seq(4B) | timestamp(4B) | payload_len(4B) | payload |各字段含义magic0x5A5A固定魔数用于校验帧边界。如果服务端收到一帧数据的开头不是这个值说明链路错位直接丢弃并告警。version0x01协议版本号。后续协议升级时可以平滑兼容旧设备。type消息类型。我保留了 0x01 上行音频、0x02 下行音频、0x03 控制消息、0x04 二进制响应事件。seq4B从设备启动开始递增的序号。断线重连后服务端根据 seq 判断是否出现了音频丢失可以决定要不要让用户重说一遍。timestamp4B采集时刻的毫秒时间戳服务端可以用它计算链路端到端延迟。payload_len4Bpayload 字节长度大端序。虽然 WebSocket 本身能知道长度但显式写出来方便做协议解析和日志打印。整个帧头 16 字节。对 640 字节的 PCM payload 来说头部开销只有 2.5%完全可以接受。为什么大端序因为网络字节序约定俗成就是大端ESP32 的芯片不管是 Xtensa 还是 RISC-V 内核都支持字节序转换指令成本可以忽略。3. ESP32 端实战从录音上传到实时推流3.1 硬件与引脚I2S 麦克风加功放喇叭我用的开发板是 ESP32-S3-DevKitC配了一个INMP441 I2S 数字麦克风和MAX98357A I2S 功放模块外接一个 3W 小喇叭。选 INMP441 的原因是 I2S 数字输出信号质量稳定不涉及模拟信号调理电路。MAX98357A 也是 I2S 输入数字信号直接进功放省掉了 DAC 芯片。接线很简单INMP441 的 SCK、WS、SD 分别接 ESP32-S3 的 GPIO 4、5、6L/R 接地表示左声道。MAX98357A 的 BCLK、LRC、DIN 分别接 GPIO 15、16、17SD 模式引脚接高电平使能。两组 I2S 外设用的是不同的引脚组可以并行工作采集和播放互不干扰。需要注意的是 INMP441 的供电电压建议用 3.3V别接到 5V 上。我之前有一块板子就是因为 5V 供电直接把麦克风烧了教训惨痛。3.2 建立 WebSocket 长连接并发送二进制帧ESP-IDF 自带的esp_websocket_client组件天然支持二进制帧发送不需要额外引入第三方库。初始化客户端并注册事件回调#include esp_websocket_client.h static esp_websocket_client_handle_t ws_client; static void websocket_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { esp_websocket_event_data_t *data (esp_websocket_event_data_t *)event_data; switch (event_id) { case WEBSOCKET_EVENT_CONNECTED: ESP_LOGI(TAG, websocket connected); break; case WEBSOCKET_EVENT_DISCONNECTED: ESP_LOGI(TAG, websocket disconnected); /* 触发自动重连逻辑 */ break; case WEBSOCKET_EVENT_DATA: /*>static void audio_capture_task(void *arg) { uint8_t *frame heap_caps_malloc(FRAME_SIZE AUDIO_FRAME_HEADER_LEN, MALLOC_CAP_DMA); if (!frame) { ESP_LOGE(TAG, no mem); vTaskDelete(NULL); } size_t bytes_read 0; uint32_t seq 0; int64_t timestamp_ms; while (1) { if (session_state STATE_SPEAKING) { timestamp_ms esp_timer_get_time() / 1000; /* 从 I2S 读 20ms 音频 */ i2s_channel_read(tx_chan, frame AUDIO_FRAME_HEADER_LEN, FRAME_SIZE, bytes_read, pdMS_TO_TICKS(50)); if (bytes_read FRAME_SIZE) { fill_audio_frame_header(frame, AUDIO_TYPE_UP, seq, timestamp_ms); esp_websocket_client_send_bin(ws_client, frame, AUDIO_FRAME_HEADER_LEN FRAME_SIZE, pdMS_TO_TICKS(20)); } } else { vTaskDelay(pdMS_TO_TICKS(20)); } } }esp_websocket_client_send_bin的最后一个参数是超时时间。ESP32 的 TCP 栈在 WiFi 信号差时可能出现发送阻塞所以超时设置要合理不能设成无限等待。我实测下来 20ms 超时比较合适一次发送失败就丢掉这一帧语音识别本身有容错能力丢几帧 20ms 的音频影响不大。3.3 本地 VAD让设备知道“你什么时候开始说话”连续对话的核心是让设备自己判断“用户开始说话了”和“用户说完了”这就要做VAD语音活动检测。一种方案是用 DNN 模型做 VAD比如 Silero VAD识别准确率很高但 ESP32 的算力跑这类模型比较吃力内存和 CPU 都有压力。另一种方案是传统能量检测简单高效对嵌入式场景足够用。我采用的是RMS 能量加静音超时的组合方案。每一帧音频进来后计算 RMS 值公式是RMS sqrt( sum(x[i]^2) / N )RMS 超过阈值就判定这一帧有语音低于阈值就判定为静音。当连续检测到 N 帧静音比如持续 600ms就认为用户这句话说完了。整套状态机分成四个状态IDLE空闲不采集不上行等待唤醒。LISTEN聆听设备处于“听”状态开始采集音频计算 VAD上行推流。PROCESSING处理中检测到用户停顿停止上行等待服务器返回回复。此时麦克风仍保持采集如果再次检测到语音立即回到 LISTEN 状态实现打断。PLAYING播放中TTS 音频下行播放的同时麦克风继续监听。如果检测到用户说话立刻压低播放音量并回到 LISTEN。这个状态机是“连续对话”和“一问一答”的本质区别。一问一答里“播放”和“采集”互斥连续对话里它们必须共存。3.4 双工控制播放和采集怎么不打架这里有一个容易被忽略的硬件问题喇叭外放时麦克风会把声音采进去形成回声。如果不处理服务器会把回声当作语音识别导致 AI 自己打断自己。我用了几个策略叠加。第一播放时对采集音频做简单的能量门限。如果一帧音频 RMS 低于阈值直接丢弃不上行从源头减少误触发。第二把麦克风物理位置尽量靠近用户嘴巴方向远离喇叭。第三播放音量控制在 60%-70%不要太响。实在要追求更激进的打断效果可以后续接 AEC回声消除算法但 ESP32 跑 AEC 要选择合适的库我目前用前两种策略已经能保证体验不混乱了。4. 服务端与云端 ASR/TTS 的桥接实践4.1 用 Golang 搭一个音频流网关服务端我用的是 Golanggorilla/websocket这个库就很好用。网关的核心职责是维护设备连接、解析二进制帧、把上行音频转给 ASR、把 TTS 下行的音频帧推回设备。package main import ( encoding/binary log net/http github.com/gorilla/websocket ) const ( frameHeaderLen 16 audioTypeUp 0x01 audioTypeDown 0x02 ) var upgrader websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } type AudioFrame struct { Seq uint32 Timestamp uint32 Payload []byte } func parseAudioFrame(payload []byte) (*AudioFrame, error) { if len(payload) frameHeaderLen { return nil, fmt.Errorf(frame too short) } if payload[0] ! 0x5A || payload[1] ! 0x5A { return nil, fmt.Errorf(bad magic) } frame : AudioFrame{ Seq: binary.BigEndian.Uint32(payload[8:12]), Timestamp: binary.BigEndian.Uint32(payload[12:16]), Payload: payload[frameHeaderLen:], } return frame, nil } func handleWS(w http.ResponseWriter, r *http.Request) { conn, err : upgrader.Upgrade(w, r, nil) if err ! nil { log.Println(upgrade failed:, err) return } defer conn.Close() asrStream : newASRClient() defer asrStream.Close() for { msgType, payload, err : conn.ReadMessage() if err ! nil { log.Println(read error:, err) break } if msgType ! websocket.BinaryMessage { continue } frame, err : parseAudioFrame(payload) if err ! nil { log.Println(parse frame:, err) continue } /* 根据帧头 type 分发 */ switch payload[3] { case audioTypeUp: asrStream.WriteAudio(frame.Payload) case audioTypeDown: /* 下行音频交给播放调度器 */ } } }网关设计的关键点有两个一是每次从 WebSocket 读到的二进制消息就是完整的一帧直接解析帧头就能定位到 payload二是上行音频转给 ASR 时要保持顺序不能乱序否则识别结果会错乱。Golang 的 WebSocket 库在单 goroutine 内连续 ReadMessage 本身就保证顺序所以我把 ASR 写入也放在同一个 goroutine 里天然有序。4.2 音频流进 ASR、文本回流的时序配合云端 ASR 我用的是流式识别服务支持边收音频边出识别中间结果。流程上ESP32 上行音频被网关解析后直接喂给 ASR 的流式接口。ASR 产出的中间结果不断累积当检测到用户停顿静音超过阈值时服务端组合出完整的用户 query然后交给大模型生成回复。这里有一个时序细节很关键ASR 判定“一句话结束”的时间点必须和 ESP32 的 VAD 判定保持大致同步。如果服务器只依赖 ASR 的端点检测而设备端已经在用户停顿后关闭了上行就可能丢尾音。我的方案是两个层级共同作用设备端 VAD 检测到静音后发送一个控制帧type0x03告诉服务器“我说完了”服务器同时参考 ASR 的端点检测结果先到先触发。这个双保险机制实测下来非常稳。TTS 环节我用的 TTS 服务支持流式合成也就是生成一段推一段。服务端收到 TTS 的音频分片后同样封装成 16 字节帧头类型置为 0x02逐个通过 WebSocket 下行推给 ESP32。ESP32 收到后解析出 PCM payload直接写入 I2S 播放。整条链路从用户说完话到设备播放出第一个字实测大约 300-500ms体感上已经非常接近真人对话了。4.3 断线重连与状态恢复怎么优雅处理 1006WebSocket 断开是物联网场景的常态WiFi 漫游、路由器 NAT 超时、设备休眠唤醒都可能导致连接中断。ESP32 的esp_websocket_client自带重连机制但只做到“重连”还不够还要做到“重连后状态一致”。最容易踩的坑就是WebSocket 1006 错误。1006 表示连接异常关闭也就是 TCP 连接在未收到 Close 帧的情况下直接断开。出现 1006 的原因通常是链路超时比如路由器把空闲连接回收了。但 WebSocket 连接空闲时ESP32 这边不发数据服务端也没数据可发这条连接可能 60 秒就被 NAT 清了。解决办法是定期发送 WebSocket Ping 帧让链路保持活跃。ESP-IDF 的esp_websocket_client不支持自动 Ping需要在任务里手动实现static void ws_ping_task(void *arg) { while (1) { vTaskDelay(pdMS_TO_TICKS(15000)); esp_websocket_client_send_ping(ws_client); } }15 秒一次 Ping实测下来连接非常稳定几天不掉线。服务端收到 Ping 后会自动回 PongTCP 层维持活跃NAT 也不会回收。重连后的状态恢复我在服务端做了一个简单的会话上下文管理。设备重连后服务端根据设备 ID 找到之前的会话状态把 ASR 的中间结果残留情况告诉设备。如果用户在断线期间说了半句话服务端会丢弃这部分不完整的音频数据然后通知设备“可以重新开始说了”。设备收到这个消息后清空本地 VAD 状态回到 LISTEN 状态等待用户。5. 连续对话链路调试实录五个高频坑5.1 问题速查表先睹为快整个重构过程中我踩过的坑不少整理成一个速查表遇到问题可以先对着查症状直接原因解决方案WebSocket 连接 1006 异常断开NAT 超时回收空闲连接或 WiFi 休眠导致 TCP 断开15 秒发送一次 Ping 帧保活关闭 WiFi 省电模式音频卡顿、声音断断续续下行音频发送过快ESP32 来不及写入 I2S或服务端发送缓冲区溢出ESP32 侧加一个播放缓冲区预填 120ms服务端每次发送后检查 TCP 写缓冲首字延迟高还是 5 秒多服务端等 TTS 全部合成完才下发改成流式 TTS生成一个分片就下发一个分片有回声、AI 自己打断自己麦克风采到喇叭外放的声音播放时对采集音频做能量门限调整麦克风和喇叭的物理位置降低播放音量设备休眠唤醒后连不上服务器休眠期间 TCP 连接被服务端默默回收唤醒后没有立即重连唤醒后主动检查连接状态断开则手动触发esp_websocket_client_start5.2 三个印象最深的故障现场第一个是音频卡顿问题。最开始我把整段 TTS 音频一次性通过 WebSocket 发给 ESP32想着设备内存够就一次全收。结果 ESP32 一边接收一边播放I2S 的 DMA 缓冲经常被读空声音就像卡碟一样。后来改成设备端维护一个播放队列收到音频帧先塞队列队列长度达到 120ms 预填量后开始播放。实测下来即便服务端发送节奏有波动声音也能保持连续。第二个是 WSS 还是 WS 的选择问题。内网调试用ws://没问题但一旦要跨网络访问或者浏览器端调试就必须用wss://。ESP32 使用wss://时需要在esp_websocket_client_config_t里配置证书或者设置跳过证书校验测试环境才这么干。生产环境必须使用证书校验否则存在安全问题。第三个是本地说完了但服务器还认为用户在说话。设备端 VAD 用的是能量阈值如果环境噪音大比如旁边开着电视噪音能量会持续超过阈值导致设备一直处于 SPEAKING 状态服务器永远等不到用户停顿。后来我在服务端对 ASR 的端点检测结果增加了超时兜底设备持续上行超过 15 秒没有检测到静音服务器强制触发一次“用户说完了”逻辑。同时设备端 VAD 的阈值设计成自适应模式根据环境底噪动态调整。5.3 调试工具和排查技巧调试这类实时音频链路光看日志效率太低。我推荐几个工具组合。Wireshark 抓包是最直观的。过滤 WebSocket 流量能看到每一帧二进制消息的大小、发送时间、间隔用统计学视角定位是采集抖动、网络抖动还是服务端处理抖动。我曾经用 Wireshark 发现一个 200ms 的规律性延迟最后定位到是服务端每收 10 帧就做一次批量转发导致的改完立刻好转。日志打点必须带时间戳和序号。我在 ESP32 端给每一帧都打了 seq 和相对时间戳服务端也打印收到帧的 seq。对比两边的日志就能算出端到端延迟和丢帧率。比如 ESP32 发了 seq1000服务端收到时如果 seq997说明中间丢了 3 帧可以倒查是 WiFi 传输问题还是服务端丢包。还有一个技巧是在服务端做一个简单的音频回显接口。设备连上后发一段测试音频服务端原样返回设备播放出来。如果回显的声音正常说明链路没有问题如果不正常问题一定出在设备采集或者播放环节。这个办法能快速二分定位问题边界。我自己实际用下来的体会是连续对话链路的稳定性提升是一场持久战。WiFi 环境千差万别家里、办公室、商场每个地方的路由器行为都不一样。我最后的底牌是在设备端做了多级兜底WebSocket 断线自动重连、VAD 超时强制复位、播放缓冲自适应调整再加上服务端的会话状态恢复。这套组合拳下来虽然不能说 100% 完美但用户日常使用基本感知不到断线或者卡顿了。如果你也在做类似的 ESP32 语音硬件项目我建议从一开始就把音频链路设计成流式 WebSocket 二进制帧的模式别再走 HTTP 录音上传的弯路。调试的时候记住四个字先通后优。先把链路跑通延迟、丢帧这些指标再一项项优化。经历这一轮重构给我最大的启发是很多交互体验问题答案往往不在上层应用逻辑而在底层数据传输链路里。