如果你玩过 ESP32 语音助手,大概率会遇到这个尴尬:按一下按钮,录完音,等上好几秒,喇叭里才慢悠悠吐出回答。这种“一问一答”的交互,与其说是对话,不如说是对讲机。尤其做 AI 玩偶、桌面机器人这类产品,用户期待的是像和人聊天一样的自然对话节奏,而不是每次都要等那个明显的“录音中…处理中…”停顿。
我这次重构 ESP32 AI 玩偶的音频链路,目标就是把它从“能对话”升级成“连续对话”。核心思路很直接:放弃原来“录音上传——等待返回——播放”的 HTTP 轮询式方案,换成一条基于 WebSocket 的二进制音频长连接,让音频数据像流水一样持续双向流动。这篇文章就完整记录这次重构的设计思路、帧协议、ESP32 端实现、服务端流式处理,以及踩过的坑。
1. 链路重构的整体思路:为什么原来那套方案走不下去
1.1 旧方案的问题不在功能,在交互节奏
先说旧方案长什么样。之前的实现是典型的“按讲式”:用户按下玩偶身上的按键,ESP32 开始录音,录满 3 秒或者松手后,把整段音频通过 HTTP POST 发给服务端。服务端调用 ASR 识别成文字,再丢给大模型生成回答,然后 TTS 合成完整音频文件,把整个音频文件返回给 ESP32。ESP32 收到后从头播放,播完算一次交互结束。
这个流程在功能层面完全能跑通,但实际用起来问题很大。
延迟非常高。整个链路里“录音完才处理”是最大的瓶颈。按我的实际测试,一段 3 秒录音,上传、ASR、LLM 推理、TTS 合成、下载播放串行下来,第一次响应往往要 5 到 8 秒。孩子对着玩偶说一句话,玩偶沉默半分钟,这在产品体验上是不可接受的。
还有内存问题。ESP32 的 SRAM 总共也就 512KB 左右,WiFi 协议栈、扬声器缓冲、系统任务占掉一部分后,可用内存非常紧张。旧方案要把整段录音暂存在内存里,或者用 SD 卡做中转,播放时又要把整个 TTS 音频文件下载到本地缓存,稍微长一点的回答就爆内存。最后不得不做各种妥协:限制回答长度、降低采样率、压缩音频质量。
最关键的问题,是没有“打断”和“插话”机制。用户在玩偶说话的时候继续说话,系统毫无反应,只能等它说完。这不是“对话”,这是“对讲机”。小孩子玩的时候经常出现一个人对着玩偶自言自语,玩偶在喋喋不休地讲,两边完全不在一个频道上。
1.2 连续对话的技术本质:双工流式化
“连续对话”和“能对话”之间的差别,拆开来看其实就四个点:全双工连接、音频边采边发、服务端边收边处理、随时可打断。
全双工连接是基础。HTTP 是半双工的请求-响应模式,客户端请求一次,服务端返回一次,连接就结束了或者闲置了。WebSocket 则是真正的全双工长连接,建立一次连接后,客户端和服务端可以随时互相发数据,不需要反复握手。对音频这种持续产生的数据流来说,WebSocket 天然就是最合适的载体。
边采边发是交互延迟的关键。旧方案是“采完一整段再发”,新方案是“每采集 30 到 100 毫秒的音频就立即通过 WebSocket 发出去”。这样服务端在用户还没说完的时候就已经开始做语音识别了,等用户说完最后一个字,前面的文字基本已经识别出来了。这就像视频直播和录像上传的区别,一个是流式实时,一个是归档处理。
服务端边收边处理意味着要接流式接口。ASR 要用支持流式的接口,实时吐出识别文本,而不是等整个音频传完再统一识别。大模型要支持流式输出,LLM 生成第一个 token 就开始处理,不用等整段回答生成完。TTS 要支持流式返回,合成一部分就下发一部分,ESP32 边收边播。
可打断是最难做但也最影响体验的部分。用户说话过程中,玩偶需要实时检测到“有人在说话”,然后立即停止当前播放,清空 TTS 缓冲,同时告诉服务端“我被打断了,重新听”。这个逻辑在协议设计和任务调度上都要提前规划。
1.3 为什么必须用二进制帧而不是文本帧
WebSocket 本身支持两种数据帧:文本帧(Text Frame)和二进制帧(Binary Frame)。很多人一看“WebSocket 能发文本”,就直接用 JSON 文本传输数据。音频数据本质是字节流,如果要塞进 JSON 文本,唯一的办法是 Base64 编码。Base64 会把音频数据膨胀 33%,这对带宽影响还在其次,更麻烦的是编解码会消耗 ESP32 宝贵的 CPU 周期和内存。
还有一个容易被忽略的问题:Base64 编码的字符串是一次性生成一次性解析的,服务端必须等完整的 Base64 字符串到达才能解码。但音频是流式数据,你不可能等“一整段录音”全到了再解码,那就又回到了旧的请求-响应模式。用二进制帧可以直接把原始的 PCM 或 Opus 编码数据放在 payload 里,不需要任何额外编码转换,每组音频数据到了就能立刻处理。
我这次的结论就是:语音交互要走 WebSocket 二进制帧,音频数据直接裸传,控制信令用独立的帧类型承载,而不是把所有东西都塞进 JSON 字符串里。
2. 二进制音频帧协议设计:消息编排与状态机
2.1 帧结构:一个简单可扩展的二进制头部
我设计帧协议的原则是简单优先。添加了足够的控制信息,但不搞复杂的嵌套结构。最终定下来的是定长头部加变长负载。头部用固定 16 字节,方便解析,也方便对齐处理。
typedef struct { uint8_t magic[2]; // 固定为 'W' 'A' uint8_t version; // 协议版本号,固定 0x01 uint8_t type; // 帧类型:音频上行/音频下行/控制/心跳 uint16_t session_id; // 会话ID uint16_t seq; // 序列号,用于乱序检测和调试 uint32_t timestamp; // 时间戳,单位 ms uint32_t payload_len; // 负载长度 } __attribute__((packed)) audio_frame_header_t;注意这里我用__attribute__((packed))强制结构体紧凑排列,避免编译器对齐导致头部实际长度和计算的不一致。头部总共 2 + 1 + 1 + 2 + 2 + 4 + 4 = 16 字节,这个定长设计让解析代码写起来非常干净。
帧类型我用枚举值定义,音频上行使 0x01,音频下行是 0x02,控制事件是 0x03,心跳是 0x04。控制事件下面再细分 event 子类型,比如开始说话、停止说话、打断、音量调整等。这一层细分用 payload 里的第一个字节表示,不在头部再加字段,保持头部稳定。
协议里所有的多字节字段统一用大端序(网络字节序)。ESP32 本身是小端处理器,x86 服务器也是小端,但你不能假设未来的设备全是小端,统一转大端可以有效避免跨平台问题。
2.2 为什么加 session_id 和 seq 两个字段
session_id 是连接级标识。ESP32 每次重启、每次重新连接 WebSocket,都生成一个新的 session_id。服务端用它来区分会话上下文,尤其是做断线重连后恢复上下文时,session_id 能帮服务端判断:是同一个会话被临时中断了,还是用户重新发起了一个全新会话。
seq 序列号是我调试过程中受益最大但一开始差点没加的设计。每发一帧音频序号加一,服务端可以检测到有没有丢帧、乱序。WiFi 环境丢包不可完全避免,WebSocket 协议本身有重传机制能保证不乱序,但 WiFi 信号不稳导致连接中断时,seq 能帮助判断最后一帧成功发到哪了,方便重连后从正确的位置恢复。我还用它做链路质量统计,如果连续多帧序号跳跃过大,基本能断定是网络出现了严重的丢包或卡顿。
在实际调试时,我发现一个很实用的技巧:wer 日志里把 seq 打出来,播放端如果出现杂音或卡顿,先看 seq 是否连续。如果不连续,问题在网络或服务端;如果连续但声音还是卡,那就是播放缓冲或编解码的问题。这个定位思路帮我把故障排查时间缩短了一大半。
2.3 控制事件与会话状态机
连续对话需要有清晰的状态定义。我设计了四个状态:IDLE(空闲监听)、LISTENING(用户说话中)、THINKING(语音识别完成,等待 LLM 生成)、SPEAKING(TTS 播放中)。状态迁移由控制事件驱动。
IDLE 状态下,ESP32 一直保持静音检测。检测到声音能量超过阈值,就发送 START_TALK 事件给服务端,同时进入 LISTENING 状态,开始持续发送音频帧。服务端收到 START_TALK 后,开始流式 ASR。用户停顿超过一定时间,ESP32 发送 STOP_TALK 事件,服务端结束本轮 ASR,把完整文本送入 LLM,状态切到 THINKING。LLM 返回文本后,TTS 开始合成,服务端下发音频帧给 ESP32,ESP32 进入 SPEAKING 状态播放。
打断在这里非常关键。SPEAKING 期间,如果 ESP32 检测到麦克风采集到超过阈值的音频(通常是用户说话了),要立即执行三步操作:清空本地播放缓冲以快速停止声音、发送 INTERRUPT 事件给服务端、切换状态回 LISTENING 并开始上传音频。服务端收到 INTERRUPT 后,立即停止 TTS 合成和音频下发,清空与上一轮相关的语音合成任务,然后准备接收新一轮的音频输入。
这个状态机不复杂,但把“连续对话”从一句口号变成了可实现的逻辑。没有状态机的时候,我的代码里到处都是 if-else 的嵌套,一个状态没处理好,就会出现在“已经停止播放了但还在收音频”或者“已经关麦了但服务端还在等文本”这种尴尬局面。
3. ESP32 端实现:从采集到播放的完整链路
3.1 硬件选型与内存分配
我这台 AI 玩偶用的主控是 ESP32-S3,麦克风是 INMP441(I2S 数字接口的 MEMS 麦克风),功放是 MAX98357A I2S 功放模块,直接驱动一个 3W 的小喇叭。这个组合在玩偶类项目里很常见,硬件成本低、接线简单、驱动代码不用操心模拟信号处理,比较省事。
ESP32-S3 的内存分配需要精打细算。我用的 ESP-IDF 开发框架,WiFi 协议栈本身会吃掉不少内存,再加上 TLS 握手的资源消耗,可用堆内存大概在 300KB 到 380KB 之间。我的分配策略是:
- I2S DMA 环形缓冲区:配置 4 块每块 1024 字节,用于麦克风连续采集,总共 4KB
- 音频发送缓冲:一个 xQueue 队列,队列项是声音数据块的指针,队列深度 8,每块 3200 字节(对应 16kHz 采样率 100 毫秒的数据量),最大占用 25.6KB
- 播放 DMA 缓冲:同样的 4 块每块 1024 字节结构
- TTS 播放队列:队列深度 8,每块 4096 字节,最大占用 32KB
这样整个音频链路的内存占用加起来不到 70KB,给 WiFi 栈和系统任务留出了充足的空间。在使用 ESP32 做音频类项目时,我的经验是“先用内存预算倒推设计方案,再写第一行代码”,不然做着做着就会发现内存不够用了,被迫返工。
3.2 I2S 音频采集与发送:不要在中断回调里发网络包
先解释一下 I2S。I2S 是芯片间传输数字音频数据的接口标准,ESP32 的 I2S 外设负责把麦克风传来的数字信号持续搬运到内存里,搬运过程由 DMA 控制器完成,不占 CPU。这个设计非常适合流式采集:设置好 DMA 环形缓冲区后,音频数据会自动填满 buffer,应用层只需要定期把 buffer 里的数据取走。
我的采集代码核心逻辑如下:
// 配置 I2S 接收(麦克风) i2s_std_config_t rx_config = { .clk_cfg = { .sample_rate_hz = 16000, .clk_src = I2S_CLK_SRC_DEFAULT, }, .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .dout = I2S_GPIO_UNUSED, .din = GPIO_NUM_6, }, }; // 采集任务:循环读取、拷贝、入队 void audio_capture_task(void *arg) { int16_t *buffer = heap_caps_malloc(3200, MALLOC_CAP_DMA); while (1) { size_t bytes_read = 0; // 阻塞读取 100ms 的音频数据 i2s_channel_read(rx_chan, buffer, 3200, &bytes_read, portMAX_DELAY); if (bytes_read == 3200) { // 入队给网络发送任务处理 xQueueSend(audio_tx_queue, &buffer, 0); } } }有个细节很多人第一次做会踩坑。INMP441 虽然是单声道麦克风,但 I2S 协议的数据格式是立体声的,也就是说 16kHz 采样率下配置成 STEREO 模式,DMA 每秒产生的是 64000 字节而不是 32000 字节。左声道是有效音频数据,右声道通常是 0 或者无效数据。所以要么在采集后做一次左右声道分离,把左声道数据提取出来,要么干脆按立体声数据量读,然后只取一半。我在协议上传时只发左声道的数据,这样带宽立减一半。
发送的核心原则是:不要在 DMA 回调或采集循环里直接调用 WebSocket 发送函数。网络发送是阻塞操作,尤其是 WiFi 信号不好时,一个发送调用可能卡几十甚至几百毫秒。如果阻塞了采集循环,DMA 缓冲区会溢出,音频数据直接丢弃,声音就会出现“咔哒咔哒”的断裂声。正确做法是采集任务只负责把数据放进队列,单独开一个网络发送任务从队列里取数据、组帧、发送:
void network_tx_task(void *arg) { ws_send_buffer_t frame; audio_chunk_t chunk; while (1) { if (xQueueReceive(audio_tx_queue, &chunk, pdMS_TO_TICKS(200))) { // 组装帧头 build_frame_header(&frame.header, AUDIO_UP, session_id, seq++, chunk.len); memcpy(frame.payload, chunk.data, chunk.len); // 通过 WebSocket 发送二进制帧 esp_websocket_client_send_bin(ws_client, frame.raw, frame.total_len, pdMS_TO_TICKS(100)); } } }这样采集和网络发送之间由队列解耦,即使网络暂时卡顿,也只是队列积压,不会直接导致音频采集丢失。配合 FreeRTOS 的任务优先级设置,采集任务优先于网络发送任务,保证音频采集永不停顿。
3.3 QoS 策略:采样率、音频编码与直播式发送
音频编码选择是我认真对比过的一个决定。最简单的方案是用裸 PCM 数据发送,ESP32 采出来是什么就发什么,服务端收到就能直接用,不用任何解码。16kHz 采样率、16bit 深度、单声道,码率是 256kbps。在局域网环境下完全没有问题,公网环境也勉强能跑,但加上 TTS 下行的带宽占用,WiFi 的稳定性会受到一定影响。
更优的方案是用 Opus 编码压缩。Opus 在 16kHz 语音场景下,24kbps 的码率就能获得不错的音质,带宽占用只有 PCM 的十分之一。但 Opus 编码需要 CPU 算力,ESP32-S3 执行 Opus 编码 20ms 一帧的数据,大约需要 2 到 3ms 的 CPU 时间,这个开销可以接受。问题是 ESP-IDF 本身不自带 Opus 库,需要自己移植,我当时用着裸 PCM 已经达到了不错的效果,所以先没有引入 Opus 编码。
对 100ms 一个包的数据包大小,我用的是 3200 字节的负载。太小了,比如 20ms 一包,网络包数量暴增,WiFi 传输效率下降,CPU 打断频率也变高;太大了,比如 500ms 一包,端到端延迟会大步增加,交互卡顿感会很强烈。最终在延迟和传输效率之间平衡,确定了 100ms 一个包,实测效果比较好。
发送时的 QoS 策略有一个关键点:重传那些实时性已经过期的音频数据其实没有价值。WebSocket 基于 TCP,协议层会自动重传丢失的数据包。但对于语音实时链路来说,已经丢失的 100ms 音频,即使重传成功,到达时也已经晚了。所以在发送缓冲优先策略上,我做了轻微调整:如果 TTS 或者系统事件需要快速处理,网络发送任务会优先发送控制帧,音频帧可以适当丢帧。
3.4 播放实现:边收边播的低延迟 TTS 通道
TTS 下行音频的播放同样用 DMA 缓冲加队列的方式实现。WebSocket 收到音频下行帧后,先把 payload 拷贝到播放队列,播放任务从队列取数据,通过 I2S 发送到 MAX98357A 功放,驱动喇叭发声。
播放延迟的优化点在于 DMA 缓冲区的深度设置。缓冲区太深,声音输出会滞后于实际收到的时间;太浅,网络稍有抖动就会因为 buffer 掏空而出现不连续的声音。我测试下来,4 块每块 1024 字节的 DMA 缓冲是比较合适的平衡点,对应的音频延迟大概是 128 毫秒左右。网络稳定时,这个深度能保证播放的连续性;突发抖动时,播放中断的概率也降低到了可以接受的范围。
打断播放的实现比听起来简单得多。I2S 驱动提供了i2s_channel_disable接口,调用后 DMA 立刻停止输出。我实现的具体步骤是:
void playback_interrupt(void) { i2s_channel_disable(tx_chan); // 立即停止 I2S 输出 xQueueReset(play_queue); // 清空音频播放队列 i2s_channel_disable(rx_chan); // 同时关掉麦克风采集 send_ws_control_event(EVENT_INTERRUPT, NULL, 0); // 通知服务端 vTaskDelay(pdMS_TO_TICKS(20)); // 等待音频尾音衰减 i2s_channel_enable(rx_chan); // 开麦,准备采集新语音 }这里有个细节:关闭 I2S 发送通道前,最好让 DMA 输出完当前正在播放的那一块数据,否则会听到一个刺耳的“啪”声。我通过先调用i2s_channel_disable再等 20ms 的方式,让残留在缓冲里的尾音自然衰减,实测爆音明显减轻。
4. 服务端流式处理:ASR、LLM、TTS 的管道协作
4.1 Python 服务端:一个管道式处理框架
服务端我用的 Python 加 FastAPI 框架,WebSocket 接入用websockets库,部署在局域网内的一台 Linux 小主机上。整体架构是三条流式管道:音频输入管道、文本推理管道、音频输出管道。
async def handle_audio_ws(websocket: WebSocket): await websocket.accept() session_id = str(uuid.uuid4()) # 输入管道:接收 ESP32 音频帧 audio_queue = asyncio.Queue() # 输出管道:发送 TTS 音频和事件到 ESP32 output_queue = asyncio.Queue() # 启动三个异步任务:接收、处理、发送 recv_task = asyncio.create_task(ws_receiver(websocket, audio_queue)) process_task = asyncio.create_task(audio_processor(session_id, audio_queue, output_queue)) send_task = asyncio.create_task(ws_sender(websocket, output_queue)) await asyncio.gather(recv_task, process_task, send_task)ws_receiver收到音频帧后放进audio_queue,audio_processor从队列里取音频帧,进行 VAD 静音检测、流式 ASR、LLM 调用、TTS 合成,最后把生成好的音频块放进output_queue,ws_sender负责把音频块和事件帧发送回 ESP32。三个任务互不阻塞,音频数据的流动是真正的流式。
服务端最关键的是 ASR 选型。我调研过几个方案,支持流式识别是底线。讯飞、阿里云、腾讯云的语音识别服务都有流式接口,开源的 Sherpa-ONNX 也支持流式识别。本地小模型最大的好处是延迟低且不会产生云端费用。我用的是 Vosk 的流式模式,在局域网小主机上识别速度和准确率都还能接受。
4.2 VAD 到底放在哪端:解决“什么时候说完了”这个难题
判断用户说完话是连续对话里最微妙、最容易做错的一环。放客户端和服务端各有利弊,我最后选了“客户端简单预判 + 服务端最终确认”的组合方案。
ESP32 端做能量检测,每采集一帧音频就算一次 RMS 音量。音量高于阈值就认为有人说话,连续 600 毫秒音量低于阈值就认为一句话说完了。这个检测逻辑消耗极低,ESP32 做完全没压力。它的作用不是精确判断,而是给服务端一个粗粒度的“我说完了”信号。
服务端做精确 VAD。语音识别系统(比如 Vosk)本身会对音频流做端点检测,当识别器认为用户说完一句话时,会给出最终的识别结果。服务端把 ASR 的结束信号和客户端的能量检测信号做融合:任何一个信号到达都发一个“可能的结束”事件,只有两个信号都确认了,才真正结束当前对话轮次。
这个双重确认机制防止了一个比较尴尬的问题:如果用户说话声音比较轻,麦克风采集到的能量偏低,客户端的能量检测可能漏报;反过来如果环境噪声较大,客户端可能误报用户说完了,但 ASR 还在耐心等。两个信号交叉验证后,实现对不同说话习惯和不同噪音环境的兼容性。
4.3 流式 ASR 到 LLM 到 TTS 的三级流水线:先托底,再优化
服务端管道我第一次实现时是完全串行的:等 ASR 出全部文本,再调 LLM,等 LLM 输出全部回答,再调 TTS 合成整段音频。结果端到端延迟比旧方案好不了太多。后来把管道改成真正的流式:
ASR 识别出部分文本后,立刻把已识别的文本喂给 LLM 的流式接口。LLM 的输出不是一下全部生成,而是逐步生成 token。每生成一个 token,服务端就把它送进 TTS 引擎,TTS 合成出一个音频块,马上塞进 output_queue 发送给 ESP32。
这样做的效果是,用户通常听到的第一个字节比完整生成快了不知道多少倍。LLM 生成 300 字左右的回答,完整生成大约要 4 到 6 秒,但流式管道下,1 到 1.5 秒就开始听到声音了。这在对话体验上的提升比任何参数调优都来得明显。
不过这个三级流水线有一个要注意的地方:ASR 识别中途出错怎么办。比如用户说“今天天气怎么样”,ASR 前几个词错识别成“今天请”,直接喂给 LLM 会导致整个回答偏离主题。所以我的实现里,LLM 的输入用的不是 ASR 的瞬时文本,而是 ASR 的“稳定文本”。稳定文本是 ASR 在识别过程中不再变化的文本片段。Vosk 这类流式识别器通常会给一个 partial result 和一个 final result,我只有 final result 的累积版本才喂给 LLM,partial result 只用于显示或调试。
5. 重构前后性能对比与优化空间
5.1 延迟拆解:从首字延迟到完整回复
重构后实测数据如下(局域网环境,ESP32-S3 连接 5GHz WiFi,服务端为 Linux 小主机,16kHz 单声道 PCM 音频):
| 阶段 | 旧方案(HTTP 录音上传) | 新方案(WebSocket 二进制流) |
|---|---|---|
| 用户说完到服务端开始 ASR | 3 到 5 秒(等录音结束) | 0.5 到 1 秒(边录边收) |
| ASR 识别完成 | 0.3 到 0.8 秒 | 0.2 到 0.5 秒 |
| LLM 首 token | 0.5 到 2 秒 | 0.3 到 1 秒 |
| 首字音频听到 | 需要 LLM 完整生成再加 TTS,5 到 10 秒 | 1 到 2 秒 |
| 完整回答播放完成 | 10 到 15 秒 | 4 到 8 秒 |
旧方案里最浪费时间的等待用户录音完成,在新方案里被完全消除了。用户说“你好,小智”,服务端在用户说出“你”字的时候就已经开始处理音频了,等用户说完最后一个字,识别结果几乎同步出来了。
5.2 Base64 vs 二进制帧的实际资源对比
我实际测了一组数据传输对比。同样传输 10 秒音频:
- PCM 原始数据:16kHz × 16bit × 1 声道 × 10 秒 = 320KB
- Base64 后文本形式:320KB × 4 / 3 ≈ 427KB,额外多 33% 传输量
- 换成 JSON 包装带协议字段,还得再加几十 KB 的 JSON 结构开销
ESP32 上 Base64 编码 320KB 数据大概消耗 300 到 500ms 的 CPU 时间,对于实时性要求高的音频流,这个开销完全没必要。二进制帧的解析也只是简单内存拷贝加上结构体指针偏移,几乎不消耗 CPU。无论从带宽、内存还是耗时,二进制都是更优的解法。
5.3 还可以继续优化的方向
目前用的裸 PCM 传输,在局域网环境表现很好。如果要在公网跑,带宽贵、网络抖动大,建议上 Opus 编码。ESP32-S3 做 Opus 编码压力不大,代码侵入也不算很复杂,网上有人用它做过语音对讲机方案。把编码器换成 Opus 后,上行码率能从 256kbps 降到 24kbps,这对弱网环境的稳定性提升非常明显。
另一个可以做的优化是 WebSocket 连接断开的快速重连和会话恢复。当前我的实现里,断线重连后是全新会话,用户需要重新说一遍意图。理想状态是重连后通过 session_id 恢复上下文,让对话能无缝衔接。后面的版本我准备把这一块加上。
6. 常见问题与排查技巧实录
6.1 WebSocket 断连、收不到数据、连接不稳定
实际使用中最常见的问题是 WebSocket 连接间歇性断开,错误码是 1006(异常关闭),日志里经常出现stream disconnected before completion或failed to send websocket request这类报错。
1006 表示连接在未正常关闭的情况下断开了,通常是网络层问题。排查路径分三步:第一步确认 WiFi 信号强度,ESP32 在信号弱的地方很容易出现 TCP 连接半开;第二步检查服务端的 WebSocket 空闲超时设置,如果服务端把空闲连接当死链收了,需要在客户端加心跳保活机制;第三步检查防火墙或 NAT 超时,TCP 长连接长时间无数据会被中间设备断开。
我在客户端加了两个保险:每 10 秒发送一个心跳帧,顺便验证连接的健康状况;断线后采用指数退避重连策略,第一次重连等 1 秒,第二次等 2 秒,最多等 30 秒。重连成功后通过 session_id 恢复会话。
6.2 播放声音出现杂音、断断续续、爆音
音频播放质量差,先分析是采集问题还是播放问题。一个简单有效的定位方法是:在服务端录制收到的音频,回放确认接收链路声音是否正常;再在 ESP32 端播放固定测试音,确认播放链路是否正常。两边单独测都没问题,那就是链路中间的缓冲或时序问题。
常见原因有这几个:I2S DMA 缓冲区太小导致播放中断;麦克风采集和 I2S 播放共用了同一组 DMA 通道导致互相干扰(ESP32 的 I2S0 和 I2S1 要区分开);WiFi 的功耗管理导致 CPU 频率波动,影响了编解码和音频任务响应;电源纹波太大,特别是在 USB 供电的情况下,电流波动会通过功放放大成噪音。
解决方法是:I2S 缓冲区深度加到 4 块每块 1024 字节;关闭 WiFi 的 modem sleep 或者调整功耗管理策略;电源端加 100uF 电解电容和 0.1uF 陶瓷电容滤波;音频任务优先级调高,确保播放任务不被其他任务抢占。
6.3 回声问题:玩偶自己说的话被自己听见
连续对话比按键对话更容易暴露回声问题。玩偶在播放 TTS 回答时,喇叭外放的声音很大,麦克风如果灵敏度也高,就会把自己说的话收进去。这时如果还开着能量检测做打断,就会形成自激:播着播着,“听到”自己的声音,误判用户插话,突然打断,然后重新识别,识别不出来,再播,再被打断——无限循环。
解决思路分三层。硬件层上,麦克风尽量远离喇叭,如果条件允许可以用指向性麦克风或者麦克风减震支架。算法层上,在做 VAD 检测时,跳过“本设备正在播放”的时间段,也就是说,播放期间检测到的声音直接忽略,不触发打断。方案层上,如果播放和采集真正并行,可以用 WebRTC 的回声消除算法。ESP32 上跑 WebRTC AEC 有点吃力,但如果是 ESP32-S3 这种双核 240MHz 的处理器,专门抽一个核来做还是能跑起来的。我用的是硬件层和算法层的组合方案,播放时短暂静音检测,效果明显改善。
6.4 内存不足导致连接失败或系统重启
音频任务经常出现内存不足,尤其是 WebSocket 握手阶段。因为 TLS 握手需要分配大量内存,如果系统在握手期间内存碎片化严重或者可用内存不足,握手就会失败。
排查时把内存监控打开,实时观察最大空闲块和最小剩余内存。我遇到过一次系统反复重启的问题,原因是打印日志时不小心把音频数据当字符串输出了,导致串口被疯狂刷屏,同时内存被日志缓冲耗尽。加日志要克制,音频流式项目里最容易犯的就是日志刷屏。
修复方法是调整内存分配策略。把音频队列、网络缓冲都显式分配到指定的内存堆上,比如 DMA 相关缓冲用MALLOC_CAP_DMA,普通数据块用MALLOC_CAP_SPIRAM,把系统默认堆留给 WiFi 栈和 TLS 库。这样即使音频任务跑得再猛,也不会把 WiFi 栈的内存挤爆。
6.5 协议扩展:后续加入本地唤醒词和离线命令
做完整条链路后,我发现这套二进制帧协议扩展起来很方便。加唤醒词功能只需要在 ESP32 端跑一个本地语音唤醒模型,检测到唤醒词后发送WAKE_UP事件,服务端就知道新的一轮对话要开始了。离线命令,比如“暂停”“继续”“关机”,可以在 ESP32 端本地做简单关键词识别,识别命中后直接执行对应动作,不用发送到服务端等待响应,减少不必要的网络延迟。
这套协议的状态机和帧设计也方便扩展自定义事件。比如玩偶的电机动作、表情 LED 灯效,都可以作为独立的帧类型在链路上传输,语音和动作联动就变得很自然。播到特定文字时做个眨眼、摆手的动作,在儿童玩偶场景里是非常有吸引力的体验。
7. 结束语
完整跑通这套链路后,我最大的感受不是延迟数字变好看了,而是玩偶“活”了。以前的交互是:孩子说一句话,玩偶沉默 5 秒,说一段话,然后沉默。现在变成了:孩子说话,玩偶马上有回应,孩子中途插话,玩偶马上闭嘴听。这种体验上的差距,远大于任何参数指标给人的体感提升。
最后分享一个实际操作中悟到的小技巧:调试 WebSocket 长连接时,别只盯着 ESP32 的日志。在服务端打一条带时间戳的收发日志,同步抓包看 WebSocket 帧,两边时间戳一对比,整个链路的延迟和丢帧问题基本一目了然。我以前花了一整天排查一个“听着像卡顿”的问题,最后发现是播放端 DMA buffer 深度不够,而不是网络问题。这种问题如果不做双侧对比,真的很容易迷路。
后续我打算把 Opus 编码和断线会话恢复补上,让这套链路在公网环境下也能稳定跑。也准备把 VAD 和打断策略做成可配置参数,方便别人根据自己玩偶的场景调优。如果你也在做 ESP32 语音交互设备,欢迎按这套方案快速复现,再根据实际效果反复调整。