前两天有个朋友问我:你们那个 ESP32 AI 玩偶,为什么每次问完一句话都要转圈等半天,能不能像人一样,我说上半句它就能接下半句,我说到一半它还能自己停下来?这个问题一下就戳中了我一直在做的重构。老版本的玩偶用的是“按键对讲”式交互:按住说话、松开识别、转圈等待、播放回答,整个链路是半双工的,体验很像对讲机。这次我和团队一起把音频链路整体换成了 WebSocket 二进制直连,让 ESP32 端持续推流音频,服务端流式识别和合成,再通过二进制帧把音频一块一块推回给玩偶,真正做到全双工的连续对话体验。
文章会从协议设计、ESP32 端采集上行、服务端流式处理、打断控制、状态机、稳定性和踩坑排查几个方面展开,每一步都有可直接落地的代码、参数和思路,适合正在做低成本 AI 硬件原型、想把“能对话”升级成“连续对话”的开发者参考。
1. 这个重构到底在解决什么问题
1.1 老版“按键通话”模式的三个硬伤
先说说我为什么要动手重构。老版本的架构很简单:玩偶上有一个触摸按键,用户按住说话,ESP32 录一段音频,松开按键后,这段音频通过 HTTP POST 上传到服务器,服务器做 ASR 识别、再丢给 LLM 生成回复、TTS 合成语音,最后把 MP3 文件地址返回,ESP32 下载并播放。听着好像没什么问题,但实际用起来有三个让我抓狂的硬伤。
第一个是交互节奏太慢。一次完整的问答应答链路里,音频上传少则几百毫秒,服务器识别和生成回复少则两三秒,再算上播放 MP3 文件前要等整个文件下载完,用户从松开按键到听到回答,经常要等 4 到 6 秒。成人等得起,小朋友等不起,等待超过两秒就开始乱按按键,整个对话就乱掉了。
第二个是用户不能打断。玩偶正在“说话”的时候,用户没办法插话,因为链路是半双工的:要么在录音上传,要么在下载播放,两个方向不会同时跑。可真实对话里,打断和被抢话是最自然不过的事情。用户都觉得,它说错了,我就该能立刻喊停,让它重听我的指令。
第三个是“一次一问”缺乏上下文连续感。老版每次对话都是全新会话,玩偶不记得上一句,也不理解用户会在两句话之间停顿很久,更不会在你还没把话说完时就“等着你”。所以体验是纯问答,不是对话。
1.2 连续对话需要什么样的音频链路
连续对话和按键问答的差异,核心在于全双工和流式处理。全双工的意思是上行音频和下行音频可以同时跑;流式处理的意思是客户端一边采集数据一边发送,服务端一边接收一边识别,识别结果不再需要等整段音频全部传完,TTS 合成出的语音也按帧发送,而不是等整段合成完再一次性下发。
要做到这一点,需要满足几个硬条件:
- 端侧有持续采集音频的能力,而不是按一次录一段;
- 上行链路是长连接,可以一直推流,而不是每次单独建一个 HTTP 请求;
- 服务端能对同一音频流做增量识别,能够判断什么时候是静音、什么时候是停顿;
- 下行链路也能流式传输音频,播放器要有局部缓冲能力,不能每来一个音频包就立刻播放,否则网络抖动会让语音一顿一顿;
- 用户可以在播放中继续上行说话,服务端需要用“打断”信号把下行语音切掉,并切回识别状态。
相比传统 HTTP 上传,WebSocket 几乎是天然为这种场景准备的:它天然支持全双工,TCP 连接建立后双向都可以随时发数据,不需要反复握手;同时它支持二进制帧,我们可以把音频数据、控制命令、序列号、时间戳都封装在一个自定义帧里。这个消息设计,是整个重构的地基,也是我认为最值得仔细讲的部分。
2. WebSocket 二进制音频链路的协议设计
2.1 为什么选 WebSocket 而不是 RTMP 或 HTTP/2
我评估过三个方案:RTMP、WebSocket、HTTP/2 的流式上传加 SSE 下行。
RTMP 是成熟的流媒体协议,做音视频直播很稳,但对 ESP32 这种资源受限设备来说太重了,协议栈复杂,握手繁琐,而且很多服务端组件并不是为低延迟“对话式”场景设计的。HTTP/2 的流式能力很强,服务端推流也很方便,但 ESP32 端要支持 HTTP/2 需要加密和 HPACK 头压缩,底层网络栈的开销明显比 WebSocket 大。
WebSocket 胜在简单直接:基于 TCP 的长连接客户端和服务端实现都成熟,ESP32 的 ESP-IDF 里自带 esp_websocket_client 组件,服务端用 Go 的 gorilla/websocket 或者 nhooyr.io/websocket 都行。它还有现成的 Ping/Pong 控制帧,配合应用层的心跳,能让我非常清晰地判断连接是不是还活着。最终我选 WebSocket,主路线是 ws:// 明文,等部署到生产环境再考虑 wss://。
2.2 二进制帧格式:一次顶一个协议
音频数据如果用 WebSocket 的文本帧传,要先做 Base64 编码,体积膨胀 33%,加解码开销,完全没有必要。我直接用二进制帧,而且自定义了一个很精简的头部结构。WebSocket 的 frame payload 本身就带长度信息,所以我只需要在 payload 最前面加一小段“头部”,用来表达消息类型、序列号和时间戳。
我这里定义每帧 payload 的结构如下:
- 第 0 字节:消息类型(opcode),1 字节无符号整数;
- 第 1~4 字节:序列号 seq,4 字节大端无符号整数,用来做丢包检测和重排;
- 第 5~8 字节:时间戳 ts,4 字节大端无符号整数,单位是采样数,表示这一段音频在采样时间轴上的位置;
- 从第 9 字节开始:真正的业务数据,音频帧或控制命令。
消息类型我留了 4 种,后面如果要扩展还可以继续加:
| opcode | 方向 | 含义 |
|---|---|---|
| 0x01 | 客户端 → 服务端 | 上行音频帧,携带 PCM 数据 |
| 0x02 | 服务端 → 客户端 | 下行音频帧,携带 TTS 音频数据 |
| 0x03 | 双向 | 文本/元数据,比如识别中间结果、设备信息 |
| 0x04 | 双向 | 控制命令,如打断、心跳、重连协商 |
这种结构的好处是,一条 WebSocket 连接上既能传音频数据,又能传控制指令,还不会互相干扰。服务端解码时只要判断 opcode,就可以决定把 payload 交给音频处理管线还是控制命令处理管线。
2.3 心跳、序列号与音轨对齐
网络设备千奇百怪,尤其是家用的路由器,动不动就会把长时间空闲的 TCP 连接断开。WebSocket 在 TCP 之上,TCP 被断开时,应用层如果一直不收发数据,就可能很久之后才发现连接已经死了,或者干脆出现半开连接。所以我在帧设计里加了两个机制。
第一个是应用层心跳。每隔 5 秒,客户端发一个 opcode 为 0x04 的控制帧,控制帧内部用一个子字段表示心跳请求,服务端收到后回复心跳响应。如果连续三次心跳没有收到响应,客户端就认为链路已经失效,主动断开重连。注意这里心跳的间隔不能太短,太短会增加无线模块的唤醒次数,抬高 ESP32 的功耗;也不能太长,太长则无法及时感知链路异常。实测 5 秒比较合适。
第二个是序列号。在弱 WiFi 环境下,就算 TCP 能保证数据不重复不乱序,但重传导致的延迟抖动依然会让音频播放不连贯。有了 seq,客户端可以判断自己收到的音频帧是否有空洞;有了 ts,播放器可以计算 jitter buffer 的基准点。音频传输不要只看瞬时到达的速度,更重要的是播放端能否维持稳定的节奏。
3. ESP32 侧:音频采集、编码与上行发送
3.1 硬件选型与麦克风/喇叭的接法
我用的核心板是 ESP32-S3-WROOM-1 模组,配 8MB PSRAM,板子是 ESP32-S3-DevKitC-1。为什么选 S3?因为它有更充足的 RAM,有原生 USB 烧录调试口,AI 推理和 DSP 指令也比老 ESP32 强不少,做音频缓冲时不用太抠内存。
麦克风用的是 INMP441 数字 MEMS 麦克风,走 I2S 接口。注意 INMP441 是 PDM 输出的,只是它内部集成了数字接口,直接用 I2S 协议去读就行。喇叭功放用 MAX98357A 这种 D 类功放模块,同样走 I2S。这样设计最大的好处是:麦克风采集和喇叭播放共用一条 I2S 总线,不用额外占用模拟引脚,抗干扰能力也比板载模拟麦克风好太多。
接线我列一下,方便直接抄:
| 外设引脚 | ESP32-S3 GPIO | 说明 |
|---|---|---|
| INMP441 SCK | GPIO4 | I2S BCLK 位时钟 |
| INMP441 WS | GPIO5 | I2S LRCLK 左右声道时钟 |
| INMP441 SD | GPIO6 | 数据输出 |
| MAX98357A BCLK | GPIO4 | 与麦克风共享位时钟 |
| MAX98357A LRC | GPIO5 | 与麦克风共享 LRCLK |
| MAX98357A DIN | GPIO7 | 数据输入 |
| 两者电源 | 3.3V / GND | 注意共地 |
这里有个很关键的坑:MAX98357A 和 INMP441 最好用同一个 3.3V 电源域,并且功放的电源要加 100uF 电解电容和 0.1uF 陶瓷电容并联去耦。如果不加,喇叭播放大动态音频时,电源电压会被拉低,INMP441 的输出就会带上明显的“电源噪声”,语音识别率直线下降。这个问题我在 7.2 节里还会细说。
3.2 用 I2S + DMA 连续采样 16bit PCM
在 ESP-IDF 里配置 I2S 非常简单,但要注意版本差异。ESP-IDF 5.x 之后 I2S 驱动 API 和旧版 4.x 完全不兼容,我这里按 5.x 的新驱动写。配置成标准 TDM 模式,采样率 16000,位深 16bit,单声道,内置 DMA 缓冲。
#include "driver/i2s_std.h" i2s_chan_handle_t rx_chan = NULL; i2s_chan_handle_t tx_chan = NULL; i2s_chan_config_t chan_cfg = { .id = I2S_NUM_0, .role = I2S_ROLE_MASTER, .dma_desc_num = 8, .dma_frame_num = 240, .auto_clear = true, }; i2s_new_channel(&chan_cfg, &tx_chan, &rx_chan); i2s_std_config_t std_cfg = { .clk_cfg = { .sample_rate_hz = 16000, .clk_src = I2S_CLK_SRC_DEFAULT, }, .slot_cfg = { .slot_mode = I2S_SLOT_MODE_MONO, .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, .slot_bit_width = I2S_SLOT_BIT_WIDTH_16BIT, .slot_mask = I2S_STD_SLOT_LEFT, }, .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .dout = GPIO_NUM_7, .din = GPIO_NUM_6, }, }; i2s_channel_init_std_mode(rx_chan, &std_cfg); i2s_channel_init_std_mode(tx_chan, &std_cfg); i2s_channel_enable(rx_chan); i2s_channel_enable(tx_chan);dma_frame_num 我配成 240,240 × 16bit × 2(双声道占位) = 960 字节一个 DMA 描述符,8 个描述符就是 7680 字节的环形缓冲。这个数值不是随便拍的:DMA 帧数太小会导致中断频繁,CPU 占用高;太大则采集延迟变大。实测 240 帧、16000Hz 下,每个 DMA 块对应约 30ms 音频,反应速度在玩具场景里完全可以接受。
采集循环只需要不停调用 i2s_channel_read,读到一定长度的 PCM 数据就攒进一个发送队列:
int16_t pcm_buf[320]; // 20ms @ 16kHz size_t bytes_read = 0; esp_err_t ret = i2s_channel_read(rx_chan, pcm_buf, sizeof(pcm_buf), &bytes_read, 100 / portTICK_PERIOD_MS); if (ret == ESP_OK && bytes_read == sizeof(pcm_buf)) { // 送到编码或发送任务 xQueueSend(audio_queue, pcm_buf, 0); }一次读 320 个采样,正好是 20ms 的音频快。为什么选 20ms?因为很多 ASR 服务商对音频分帧的最佳长度就是 20ms 到 40ms,20ms 的包大小适中,即使加头部,整帧 WebSocket 消息也只有 650 字节左右,无线链路一次就能发完,不至于被底层 TCP 拆成多个小包造成无效重传。
3.3 本地 VAD:什么时候该把音频发出去
如果 ESP32 采集到的所有音频都无脑推给服务端,服务端会被大量静音数据淹没,ASR 也容易在无人说话时产生幻觉识别。更可怕的是,全双工链路里下行播放的喇叭声音会被麦克风采进来,形成自激干扰。所以本地必须有一个 VAD(语音活动检测),只把有效语音推上行。
我用的方案是先做能量阈值检测,再做静音尾判定。具体来说,对每一段 20ms 的音频,计算 RMS 值:
uint32_t sum = 0; for (int i = 0; i < 320; i++) { int32_t v = pcm_buf[i]; sum += (uint32_t)(v * v) >> 4; } uint32_t rms = (uint32_t)sqrt(sum / 320);如果 RMS 大于某个阈值(我习惯用 300 到 800 之间,具体数值要靠板子实测,因为麦克风增益差异很大),就认为有人声开始,进入“说话中”状态,持续把音频推给服务器。当连续若干帧 RMS 都低于阈值,比如 30 帧也就是 600ms 静音,就判定说话结束,给服务端发一个“语音结束”控制帧。
这里我的经验是,静音尾巴不要设太短,否则会觉得“抢话”,用户停顿半秒想一下措辞,也会被判定为说话结束。也不要设太长,否则回答延迟会变高。600ms 到 1000ms 是常见区间。另外,如果 ESP32-S3 内存富余,可以考虑用 ESP-SR 里的 WakeNet 做唤醒词检测,把“小贝小贝”唤醒和 VAD 结合,体验会更自然。我在原型里是先做成 WakeNet 唤醒 + 能量 VAD,效果已经很接近真实对话了。
3.4 把 PCM 包成二进制帧并走 WebSocket 上行
发送逻辑我就直接基于 esp_websocket_client 实现。连接建立后,单独开一个“音频发送任务”,从队列里拿 PCM 数据,拼装成二进制帧再发送。
typedef struct __attribute__((packed)) { uint8_t op; uint32_t seq; uint32_t ts; } audio_pkt_head_t; static uint32_t g_seq = 0; static uint32_t g_ts = 0; void send_audio_pcm(int16_t *pcm, size_t sample_count) { size_t payload_len = sizeof(audio_pkt_head_t) + sample_count * sizeof(int16_t); uint8_t *buf = heap_caps_malloc(payload_len, MALLOC_CAP_SPIRAM); audio_pkt_head_t *hdr = (audio_pkt_head_t *)buf; hdr->op = 0x01; hdr->seq = htonl(g_seq++); hdr->ts = htonl(g_ts); memcpy(buf + sizeof(audio_pkt_head_t), pcm, sample_count * sizeof(int16_t)); esp_websocket_client_send_bin(g_websocket, (char *)buf, payload_len, pdMS_TO_TICKS(100)); heap_caps_free(buf); g_ts += sample_count; }这里要特别注意字节序问题。C 语言在 ESP32 上是小端,而网络传输约定用大端,所以 seq 和 ts 都做了一次 htonl 转换。如果你在服务器端忘了用大端解析,时间戳就会变成一个巨大的错误数字。
同时,我建议发送缓冲区不要直接在任务栈上开大数组。ESP32 的任务栈一般只有 4KB 到 8KB,一个 650 字节的帧还好,但如果你把多个帧拼在一起发,就可能爆栈。我用 heap_caps_malloc 分配 PSRAM 内存,发完马上释放,既灵活又不占栈空间。
一个更重要的经验是:音频发送任务必须保证低延迟,不能被其他任务抢占太久。我把它设置成高优先级,并绑定到一个专用核心上运行:
xTaskCreatePinnedToCore(send_task, "audio_send", 4096, NULL, 5, NULL, 1);核心 0 用来跑 WiFi 协议栈和系统任务,核心 1 跑音频收发,避免任务迁移导致的不确定延迟。实测对比后,同样的代码,绑定核心后首包延迟能低 10ms 到 30ms 左右,别看这个数字不大,对端到端低延迟很关键。
4. 服务端:把二进制流变成“能听、能想、能说”的闭环
4.1 服务端框架与连接管理
服务端我用 Go 写,WebSocket 库用的 gorilla/websocket。选 Go 而不是 Node 或 Python,是因为它天然的并发模型非常适合维护大量长连接,而且 gorilla/websocket 的接口对二进制消息处理非常直接。
每个 ESP32 设备连上来后,我会在服务端维护一个 DeviceSession 结构体,里面把这条连接绑定的相关处理器都串起来:
type DeviceSession struct { Conn *websocket.Conn rwmu sync.Mutex asr ASREngine llm LLMClient tts TTSClient buffer *AudioBuffer state AtomicState }连接读循环很简单,通过判断 opcode 分发:0x01 的音频包送给 ASR 缓冲;0x04 的控制包触发打断或心跳等动作。写循环单独用一个 channel 保证多 goroutine 不会并发写同一个 WebSocket,因为 gorilla/websocket 的同一时刻只允许一个 goroutine 写连接,并发写会把底层帧写坏。
我在读循环里获取音频帧的代码示例如下:
mt, payload, err := conn.ReadMessage() if mt != websocket.BinaryMessage || len(payload) < 9 { return fmt.Errorf("invalid packet") } op := payload[0] seq := binary.BigEndian.Uint32(payload[1:5]) ts := binary.BigEndian.Uint32(payload[5:9]) data := payload[9:] switch op { case 0x01: session.buffer.Push(seq, ts, data) case 0x04: handleControl(session, data) }序列号在这里不只是好看,它还能帮我过滤重复帧。由于 TCP 本身不会重复,主要是 Wi-Fi 重传可能导致乱序到达,再加上 ESP32 端如果因为网络卡顿重发了上一帧,服务端需要对 seq 做一次简单比较,小于当前已处理序列号的帧直接丢弃,避免音频内容出现重复片段。
4.2 流式 ASR 会话:识别什么时候已经说完
上行音频到达服务端后,我先累积到一个会话级缓冲里,再按照 ASR 引擎要求的切片大小送识别。用的流式 ASR 模型内部会持续更新“中间结果”,不断返回 它 目前听到的部分文字。
关键点在于“结束判定”。我在设计里遵循了这样的规则:
- 当 ESP32 端本地 VAD 判定说话结束,会发一个“end of speech”控制帧,这是我的第一重结束信号;
- 服务端 ASR 自身也会根据静音检测产生一个 vad_end 事件;
- 两个信号只要有一个触发,ASR 就做一次 final 判定,把完整文字交给下一环。
如果只听 ESP32 一个信号,万一本地上行网络丢了几帧,会导致结束信号丢失,服务端就一直傻等着。所以服务端 ASR 的静音检测作为兜底很重要。双保险机制,代码上一点都不复杂,但能避免大部分“它怎么还不回答我”的诡异问题。
这里还有一个细节:ASR 是流式的,中间结果可能每 200ms 变化一次。想让玩偶有“正在认真听”的感觉,可以每拿到一个中间结果就通过 0x03 文本帧返回给 ESP32,ESP32 再在屏幕上显示或让耳朵灯闪烁。这个功能对最终体验提升不是致命的,但加了之后,用户会明显觉得玩偶“活”了。
4.3 LLM 流式输出到 TTS,再到下行音频帧
ASR 给出完整文本后,我会带着会话上下文请求 LLM。为了让响应延迟尽可能低,LLM 必须用流式输出接口,也就是一边生成文字一边吐出 token,不能等全部生成完再返回。
LLM 流式输出的每个 token 片段被送进一个“增量 TTS 合成器”。这里有个很现实的问题:TTS 引擎往往要求至少是完整的句子或合理短语才能合成自然语音,如果每个 token 都立刻合成,音频会支离破碎。我采用的折中方案是:按标点符号和语气词切句,当前面累积的文字出现逗号、句号、问号、感叹号时,就把这一段提交 TTS,一旦合成出第一段音频,立刻通过 WebSocket 下行推给设备,不用等整个回答结束。
这样做用户感知到的首包延迟,通常能压到 300~800ms,远比整段合成完再下发快了。而且因为 LLM 是边生成边提交 TTS,整个下行的音频流是连续的,播放端不会出现“说一句停半句”的割裂感。
下行音频帧格式我复用了 0x02 二进制帧,数据区域放 TTS 引擎输出的 PCM 编码。注意 TTS 输出采样率一定要统一设置成 16000Hz 16bit 单声道,和 ESP32 采集端一致,否则 ESP32 播放时要么变速,要么需要做重采样。我最初没统一,TTS 默认输出 24000Hz,结果播放速度变快了 1.5 倍,非常滑稽。
4.4 下行播放与打断(Barge-in)控制
下行播放最核心的问题是打断。用户正在听玩偶说话,突然发现这不是自己想要的,开口插了一句话。这时候会发生两件事:
第一,ESP32 本地要识别到用户开口,迅速停止播放当前下行音频,同时往服务端发一个 0x04 打断控制帧。把扬声器“闭嘴”的动作放在设备端,而不是等服务端指令,这是降低打断延迟的关键。本地判断到用户开口到喇叭静音,延迟可以控制在 20ms 以内;如果等服务端处理再回传控制帧,至少要多出 200ms 的网络往返,用户体验会感觉“它好像顿了一下才闭嘴”。
第二,服务端收到打断帧后,要立刻做三件事:停止当前 TTS 合成与下发、清空下行发送队列、重置当前 ASR 会话准备接收新的语音。Go 代码里大概是这样一个流程:
case ctrlBargeIn: tts.Cancel() session.sendQueue.Clear() session.state.Set(stateListening) asr.Reset()这里有一个容易漏掉的细节:清空下行发送队列的时候,当前正在写连接的 goroutine 可能已经拿到了一个音频帧。所以写循环必须也监听一个 cancel channel,当打断发生时,即使拿取帧成功,也要在发送前检查会话状态,是中断状态就直接丢弃。
5. 连续对话的状态机与关键时序
5.1 用一个状态机统一“听、想、说、被抢话”
全双工音频链路最怕状态混乱。如果服务端还按照“收到一段音频 → 处理 → 回复”这种线性思维,设备端一旦同时发生上行和下行,整个逻辑当场就会卡死。我把整个会话抽象成一个状态机,这是重构里性价比最高的一步。
状态定义如下:
| 状态 | 含义 | 备注 |
|---|---|---|
| IDLE | 空闲 | 等待唤醒词或本地 VAD 触发 |
| LISTENING | 倾听中 | ESP32 上行音频流入 ASR |
| THINKING | 思考中 | ASR 结束,LLM 生成回复 |
| SPEAKING | 播放中 | TTS 音频下行播放 |
| INTERRUPTED | 被打断 | 用户插话,清空下行 |
| WAIT_RECOVER | 恢复等待 | 结束语处理,回到 LISTENING |
状态机的变化规则:
- IDLE → LISTENING:唤醒词或本地 VAD 触发;
- LISTENING → THINKING:语音结束,ASR 输出最终文本;
- THINKING → SPEAKING:TTS 开始产生下行音频帧;
- SPEAKING → INTERRUPTED:收到 0x04 打断控制帧或检测到新的上行语音能量;
- INTERRUPTED → LISTENING:清空下行队列、重置 ASR 后立刻进入;
- LISTENING → IDLE:连续长时间无人说话,超过 10 秒后释放链路,但不是断开 WebSocket。
这个状态机在服务端和 ESP32 端各维护一份,只不过 ESP32 端的状态不需要有 THINKING,因为它在等下行音频时只需要表现为“待播放”。两端通过心跳帧里的状态字段同步,出现不一致时以服务端为准。
5.2 关键时序估算:从“发声”到“回应”
做流式架构之后,有个好处是各个环节的延迟可以并行叠加,而不是串行相加。我拿实测数据列一份典型链路耗时:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| ESP32 采集并打包上送 | 20ms | 基于 20ms 一个音频块 |
| WiFi 上行传输 | 50~100ms | 取决于路由器,同一局域网更快 |
| ASR 语音结束判定 | 300~600ms | 包含静音尾判定 |
| LLM 首个 token 输出 | 500~1500ms | 取决于模型和服务端性能 |
| TTS 合成首个音频块 | 200~600ms | 流式合成 |
| 下行传输 + 抖动缓冲 | 100~200ms | 包含足够预读量 |
| 理论总延迟 | 1.2~3s | 用户实际感觉是“说完后不到 2 秒开始回应” |
实际体验中,用户从停止说话到听到玩偶开口,大约在 1 秒到 2 秒之间,这比老版按键模式动不动 4 秒已经强了很多。再往下压延迟,主要瓶颈在 LLM 的首 token 输出,如果你用本地小模型,延迟能更快,但要牺牲回复质量;如果接云上大模型,网络往返是硬成本,只能靠流式接口尽量把首包提前。
这里我强烈建议,端到端延迟统计一定要拆开测,不要只记住一个总数字。我在服务端给每个会话都加了分层耗时日志,ASR 结束时间、LLM 首 token 时间、TTS 首块时间、下行首包时间分别记录,问题出在哪一环,看日志一眼就能定位。
5.3 如何让玩具“闭嘴”更干脆:丢弃与清缓存
很多人做打断功能,只在上层逻辑里喊一句“停止播放”,结果还是要等当前音频缓冲播完才静音,体验非常拖沓。在 ESP32 端,要让喇叭立刻闭嘴,必须连同 I2S 的 DMA 缓冲一起清掉。
我在代码里做了以下几步:
void audio_playback_stop_now(void) { // 停止写入新的音频数据 playback_task_suspend(); // 清空软件队列 xQueueReset(play_queue); // 清空 DMA 缓冲,防止旧音频继续播放 i2s_channel_disable(tx_chan); i2s_channel_enable(tx_chan); }i2s_channel_disable 再 enable 看起来粗暴,却是最有效的“软复位”方式。清完之后,从用户开口到喇叭静音,整体能做到 30ms 以内,耳朵基本听不出有残余声音。如果只清软件队列,喇叭还会把 DMA 里已经搬进去的音频继续放完,通常会产生 50ms 到 100ms 的“残留尾巴”,在打断场景里会显得特别拖。
下行播放端还需要处理一个“新旧音频黏连”的问题。当用户说“我要的不是这个”,新 TTS 的音频包已经开始进入 play_queue,但旧音频可能还没清干净。我的经验是:打断发生时,不仅要把队列里数据全清掉,还要在状态机切到新的 SPEAKING 之前,强制间隔一个 50ms 的“静音帧”,防止新老语音在前面拼接处产生爆音。
6. 稳定性、延迟与内存的工程细节
6.1 WiFi 不稳导致 1006 的那一晚
在 WebSocket 错误码里,1006 是个非常特殊的码,因为按规定,客户端和服务端都不该主动发这个码。1006 只表示“连接异常关闭”,也就是说底层连接挂了,但双方都没有收到关闭帧。我遇到过不止一次:ESP32 端打印出 onclose code 1006,同时服务端也莫名其妙地发现连接中断,两个进程都没有任何主动 close 的日志。
印象最深的一次,是同一个原型机在家里路由器信号弱的角落测试,稳定运行了 5 分钟,然后就出现 1006。我一开始怀疑是服务端空闲连接被路由器杀了,但加长应用层心跳也没用;后来抓 WiFi 日志才发现,是 ESP32 的 WiFi 射频在持续上传大流量时,和 2.4G 频段上蓝牙或者其他设备的干扰打架,导致 TCP 重传超时,底层连接被内核直接断开,WebSocket 层来不及发 close 帧。
解决思路有三个:一是开启 WiFi 的 TCP keepalive,让底层协议栈尽快发现死连接;二是应用层心跳间隔缩短到 5 秒,同时在收到心跳超时后,代码里主动 close 并重连;三是 WiFi 工作模式固定为 STA,关闭 BT 共存,在某些型号上能减少射频干扰。这一套组合下来,我的设备已经连续跑了几十个小时没再出现 1006。
6.2 内存优化与 DMA 缓冲预算
ESP32-S3 有 512KB SRAM,但其中很大一部分可能被 WiFi 协议栈、TLS 和音频缓冲占掉。如果再用老的静态缓冲做法,很容易莫名奇妙死机。我的内存预算大致是这样的:
| 用途 | 内存占用 | 说明 |
|---|---|---|
| ESP-IDF 基础 + WiFi | 120KB | 包含协议栈和驱动 |
| WebSocket 客户端 | 15KB | 缓冲和任务栈 |
| I2S DMA 缓冲 | 8KB | 双通道 8 描述符 |
| 音频发送队列 | 24KB | 最多 36 帧,每帧 650B |
| 下行播放队列 | 48KB | 最多 72 帧,每帧 650B |
| 其他任务和堆余量 | 剩余 | 用于系统抖动 |
我把大块的环形缓冲放在 PSRAM 里,但要注意 DMA 不能直接访问 PSRAM,所以 DMA 描述符本身必须留在内部 RAM,而承载数据的 buffer 放 PSRAM 是可以的,只要先通过 i2s_channel_read 读到内部 RAM 小缓冲区,再拷贝到 PSRAM 大队列里。这个做法能显著提高内存余量,代价是多一次拷贝,但在 20ms 的音频块尺度下,一次 memcpy 性能损失可以忽略。
还有一个让我踩过的坑:在 ESP-IDF 项目里同时启用 BLE 和 WiFi,会把内部 RAM 占用推高很多。如果你不需要蓝牙,编译时把 BLE 功能关掉,内存会宽裕不少。
6.3 OTA 升级和日志:给现场玩偶留一条后路
玩偶是实体产品,烧录一次程序容易,但发给用户之后再想改逻辑,没有 OTA 简直是一场灾难。ESP32 的 OTA 升级是一个经典操作,我把 WebSocket 音频链路和 OTA 分成两个独立逻辑:平时走 WebSocket 长连接,升级时服务端下发一条 0x04 控制帧,内容是“进入 OTA 模式”,ESP32 收到后停止音频链路,然后从 OTA 服务器拉取固件。
OTA 分区表需要提前分好,我的 partition 大致是这样:
- nvs:0x9000,存放配置;
- otadata:0x2000,OTA 状态;
- app0:0x200000,主应用;
- app1:0x200000,OTA 备份。
OTA 升级过程中,HTTP 下载固件会占满网络带宽,和 WebSocket 音频链路完全冲突。所以设计上必须严格串行:先断开 WebSocket,再做 OTA,升级完重启后重新连。否则音频帧和固件数据在同一个网口抢带宽,可能导致固件下载超时失败。
日志方面,ESP32 的串口日志在设备端调试时好使,但设备一旦装进玩偶外壳,串口就没了。我做的方案是:把关键日志封装成 0x03 文本帧回传,服务端按设备 ID 存一份滚动日志文件。这样就算外壳完全封闭,我依然能看到设备运行时的每一次状态切换、心跳超时和错误码。遇到用户反馈问题,我直接查服务端日志就能还原现场。
7. 踩坑记录与问题速查表
7.1 必看!排查速查表
我把实际排查过程中遇到频率最高的问题和对应解法整理成了一张表,直接按图索骥就行。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| WebSocket 连不上,报 1006 | 网络不稳定、TCP 半开连接 | 抓包看 TCP 重传,看心跳是否超时 | 缩短心跳到 5s,应用层主动重连,开启 TCP keepalive |
| 语音识别文字断断续续 | 音频帧丢失或乱序 | 服务端打印 seq,看是否有空洞 | 增加播放端 jitter buffer,服务端用 seq 去重和重排序 |
| 玩偶播放声音变快/变调 | TTS 采样率和 I2S 播放采样率不一致 | 打印 TTS 音频采样率 | 服务端强制 TTS 输出 16000Hz 16bit 单声道 |
| 打断时喇叭还有残余声音 | DMA 缓冲没有清理 | 听感判断,或示波器看音频波形 | i2s_channel_disable/enable 清 DMA |
| 播放有滋滋声,且只在播放大音量时出现 | 电源纹波大,功放与麦克风共享不干净电源 | 用示波器看功放供电纹波 | 加电解电容去耦,电源分路供电 |
| 长时间运行后内存不足崩溃 | 任务栈太小或静态缓冲占用过大 | 打开 heap debugging,查看任务栈高水位 | 任务栈加大,数据缓冲移到 PSRAM |
| ESP32 突然重启,但日志没异常 | 看门狗超时,任务卡在 I2S 读操作 | 打开 Task WDT,查看卡住的任务 | 给 I2S 读操作加超时,任务循环里喂狗 |
| TTS 首包延迟高,但之后正常 | TTS 合成器被阻塞,比如申请了串行锁 | 看服务端并发日志,有没有锁等待 | TTS 引擎实例化多个 goroutine,或做请求队列隔离 |
| 玩偶一段时间不响应语音 | 本地 VAD 阈值过高或麦克风积灰 | 查看上行流量统计,看是否有音频发送 | 调低阈值,添加 AGC 自动增益,定期清理麦克风 |
这张表里的问题我基本都实际遇到过,其中 1006 和 DMA 残余声音是最影响体验的两个,建议大家在开发阶段就做好可视化日志,否则现场排查会非常痛苦。
7.2 还踩过的几个小坑
最后把我一直想单独拎出来说的几个“非典型坑”分享给大家,这些问题是官方文档通常不会讲清楚的。
第一个是麦克风和功放共地的问题。我把 INMP441 和 MAX98357A 接到同一个 3.3V 电源上,用了杜邦线连接,一开始没有任何去耦电容。结果一旦喇叭输出大音量,麦克风采到的音频就会被电源纹波污染。这种污染不是简单的底噪,而是带有明显包络的哼声,ASR 在播放大音量时几乎无法识别。后来我把电源线加粗,在功放电源引脚旁并联了 100uF 和 0.1uF 电容,问题大幅缓解。做音频硬件,电源洁净度永远要排在第一位。
第二个是 ESP32 WebSocket 客户端发送大帧时的经验。esp_websocket_client_send_bin 有一个超时参数,如果你发送较长的音频帧,在 WiFi 信号差的时候可能因为底层阻塞而超时,返回失败。我一开始没检查返回值,结果部分音频帧在弱信号时被静默丢弃,表现是玩偶听不清长句。后来我在发送失败时保留帧并重试一次,重试还是失败就把会话状态重置,效果稳定很多。
第三个是关于语音识别结束信号的“双保险”。早期我只依赖服务端 ASR 的静音判定,当用户说话声音很小或者背景安静时,识别结束非常快;但当环境里有一点风扇声,静音判定就永远不触发,玩偶就会一直“听”,不回答。加上 ESP32 本地 VAD 的结束信号后,问题基本消失。两端各自独立判断,谁先触发结束都可以,这比单一来源可靠得多。
7.3 下一步想做的事
这次重构把 WebSocket 二进制音频链路打通之后,我至少已经解决了从“能对话”到“连续对话”的全部核心问题:全双工传输、流式识别、流式合成、打断控制、状态机、稳定性优化。现在玩偶在安静家庭环境里,已经能比较自然地实现多轮对话,用户说话时可以随时插话,玩偶也会断句式地合成回复,不用再等待转圈。
下一步我想做两个方向:一是把音频编码从裸 PCM 换成 Opus。PCM 在 16kHz 16bit 单声道时是 32KB/s 的流量,对一般 WiFi 来说完全没压力,但如果以后要做电池供电和蜂窝网络版本,流量和功耗都是问题。Opus 在 12kbps 到 24kbps 就能保持不错的语音质量,只是 ESP32 侧的编码库和内存开销还要调优。二是在本地加入更丰富的唤醒交互,比如让玩偶通过检测用户音量来调整自己回答状态,甚至能够识别用户情绪。这些后续再单独写文章分享。
最后分享我实际的体会:从“能对话”走向“连续对话”,技术难点其实不在某一个点上,而在于把全双工链路、流式处理、打断控制、状态机这些环节耐心地串起来。每一个环节都不是无解的难点,但它们彼此耦合,一旦某个点没有处理好,整体体验就会回到对讲机时代。我在这个项目里最大的感悟是:设计协议时多做一层的序列号和时间戳,实现音视频链路时永远校验每一个返回值,调试交互时一定要把自己当成三岁小朋友,把他们毫无逻辑的打断和抢话当成正常输入。这三件事做到位,你的 AI 玩偶大概率就已经跑在真正的“连续对话”赛道上了。