1. 从“能对话”到“连续对话”,这条链路到底差在哪
先说结论:ESP32 AI 玩偶从“能对话”到“连续对话”,本质上不是算法问题,而是链路问题。早期我做第一版方案时,玩偶确实能对话——按住按键说话,松开后录音上传,服务端识别再返回语音,整个过程跑得通。但用户体验非常糟糕,每次交互都是一次完整的“请求-响应”周期,你按下按键后要等两三秒才能听到回应,而且一旦服务端处理时间稍长, ESP32 可能已经超时断开。孩子玩了几分钟就失去兴趣,因为这根本不是“对话”,更像是“对讲机”。
真正让我决定重构链路的,是我测试时发现的一个深层次问题:如果用户说话过程中有停顿,比如“我想……听一个故事”,按键式方案把整段话录下来再整体上传,服务端会等用户说完才肯开始识别,用户必须刻意保持持续出声,否则系统会误判为说完。这种体验在成人用语音助手时还能忍,但对儿童玩偶来说几乎是致命的——三岁孩子说话天然带着大量停顿和迟疑。
连续对话的正确做法,是把音频从“离散文件”变成“持续流”。麦克风采集到的 PCM 数据以极小的分片持续推送,服务端边接收边识别,识别出阶段性结果就立刻反馈,必要时还能主动打断播放并抢答。这个过程的实现细节非常多,我踩了不少坑后才跑通,这里把完整的链路重构思路、二进制帧设计、ESP32 端改造和服务端调优全部记录下来,给想入门或正在做同类项目的朋友一个可以直接上手的参考。
这个项目最终的目标很简单:让玩偶能像真人一样,在你说话的时候“听着”,在你说完的瞬间“接话”,甚至在你沉默几秒后主动引导话题。要实现这个效果,至少需要满足下面这些硬指标:
- 音频从 ESP32 到服务端的传输延迟低于 300ms
- 支持全双工传输,即麦克风采集和喇叭播放可以同时进行
- 服务端能实时返回中间识别结果,而不是一次性吐完整文本
- 连接能经受住弱网波动,断线后在 1 秒内自动恢复
- 整条链路内存占用控制在 ESP32 可用堆内存的 30% 以内
看着简单,每一条背后都有坑。下面从头讲。
2. WebSocket 音频链路重构的架构设计
2.1 为什么最终选择 WebSocket 作为音频传输载体
选型时我考虑过三种方案:HTTP 轮询、TCP 长连接、WebSocket。HTTP 方案最简单,ESP32 端用 HTTPClient 库 POST 音频文件,服务端返回识别结果。但它的缺陷太明显——每次请求都有完整的 HTTP 头开销,而且服务端无法主动推送数据,玩偶要“听”服务端说话只能不断轮询,白白浪费电量和网络流量。TCP 长连接可以解决服务端主动推送的问题,但 ESP32 端的 TCP 裸连接处理起来要自己封装协议,还要处理粘包、拆包、心跳保活,工作量不小。
WebSocket 是这几条路里成本最低、收益最高的方案。它是基于 TCP 的标准协议,天然支持双向通信,服务端可以随时把音频帧推给 ESP32,不用玩偶端去“问”。ESP32 生态里 Arduino WebSockets 库和 ESP-IDF 自带的 WebSocket client 组件都很成熟,直接调用就能完成连接握手和帧收发,省掉自己封装底层协议的时间。
但这里要强调一个容易踩坑的点:很多人用 WebSocket 只发字符串或 JSON,这在控制类场景没问题,但音频数据一旦转成 Base64 字符串再来回传输,体积会膨胀约 33%,对 ESP32 这种内存只有几百 KB 的芯片来说,一个 512 字节的音频帧转 Base64 后变成 683 字节,多出来的 171 字节在大流量下就是实实在在的带宽和内存浪费。所以音频传输必须走二进制帧。
2.2 二进制帧设计:给音频数据加上“信封”
WebSocket 协议本身就分文本帧和二进制帧,我用二进制帧承载音频数据,同时设计了一个极简的帧头结构,让接收方能够识别每个音频帧属于“上行采集”还是“下行播放”,以及携带了哪些附加信息。
帧结构如下:
typedef struct { uint8_t magic; // 固定 0xAA,用于帧同步校验 uint8_t type; // 0x01: 上行音频(ESP32→服务端) // 0x02: 下行音频(服务端→ESP32) // 0x03: 控制命令 // 0x04: 心跳 uint8_t seq; // 序列号,用于丢帧检测 uint8_t flags; // bit0: 是否结束标志 // bit1: 是否包含文本消息 uint16_t length; // 负载长度,大端序 // 随后是 length 字节的负载数据 } audio_frame_header_t;帧头只占 6 字节,相比直接裸发 PCM 数据,多出的开销微乎其微,但换来的好处非常明显:
- 帧同步:如果出现断流错位,通过 magic 字节可以把数据流重新对齐
- 类型区分:同一个 WebSocket 连接既传音频又传控制指令,接收方根据 type 字段决定走哪条处理逻辑
- 丢帧检测:seq 连续递增,接收方发现跳跃就能知道中间丢了帧,可以触发重传或降级处理
- 结束标志:连续对话中用户可能随时停止说话,结束标志让服务端知道一个语音段的边界
音频编码我选了 16-bit、16kHz、单声道的 PCM。为什么不直接上用 MP3 或 OPUS?因为 ESP32 端的编解码器资源有限,PCM 虽然原始,但编码解码零开销,16kHz 的采样率对语音识别完全够用,而且 AI 玩偶场景下大部分推理在服务端完成,端侧不需要压缩。实测 16k/16bit/mono 的比特率是 256kbps,在 Wi-Fi 环境下传输完全没问题,延迟远低于压缩编码带来的计算延迟。
2.3 连续对话的状态机设计
链路重构不只是把数据格式从文本换成二进制,更要改变整个交互模式。我用一个状态机来管理对话生命周期:
typedef enum { ST_IDLE, // 空闲,等待唤醒或按键 ST_LISTENING, // 采集麦克风音频并上传 ST_PROCESSING, // 等待服务端响应 ST_SPEAKING, // 播放服务端下发的语音 ST_INTERRUPTED // 被用户语音打断 } dialog_state_t;核心逻辑:默认处于 ST_IDLE,检测到唤醒词后进入 ST_LISTENING,持续采集并上传音频;服务端识别结果出来后,如果用户说话结束,进入 ST_PROCESSING,由大模型生成回复;回复文本合成为音频后,服务端下发音频帧,玩偶进入 ST_SPEAKING 播放;播放过程中如果用户又开口说话,玩偶应立即停声进入 ST_LISTENING,实现全双工打断。
这个状态机最关键的创新点是“边说话边听”。传统“对讲机”方案中播放语音时麦克风是关闭的,因为怕回声干扰识别。但全双工状态下,即便在播放语音,麦克风也可以继续采集,服务端通过对语音做回声消除(AEC)来区分用户声音和玩偶自己的喇叭声。这个后续在音频处理部分会详细展开。
3. ESP32 端音频采集与播放的实操改造
3.1 音频采集侧:I2S 麦克风与 DMA 缓冲的配合
ESP32 采集音频有两条路:内置 ADC 采样模拟麦克风,或者用 I2S 接口接数字麦克风。我强烈建议不要使用内置 ADC——ESP32 的内置 ADC 噪声很大,12-bit 精度实际有效位数只有 9 位左右,用于语音识别会明显影响识别准确率,尤其在安静环境下底噪已经被放大得厉害。正确做法是外接 I2S 数字麦克风,比如 INMP441 或 ICS-43434。
INMP441 是 24-bit 数字输出,采样率支持 8kHz 到 48kHz。我用的是 16kHz 采样率,与语音识别引擎要求匹配。接线很简单:
INMP441 SCK -> ESP32 GPIO5 INMP441 WS -> ESP32 GPIO25 INMP441 SD -> ESP32 GPIO26 INMP441 L/R -> GND (左声道)ESP32 侧用 I2S 驱动采集,配置为内置 DAC 模式禁用,直接读 DMA 缓冲:
i2s_config_t i2s_rx_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 512, .use_apll = false, .tx_desc_auto_clear = false, .fixed_mclk = 0 };dma_buf_count 和 dma_buf_len 的配合是关键。总共 8 个 512 帧的 DMA buffer,按 16kHz 采样的播放时长算,每个 buffer 是 512/16000 = 32ms,8 个 buffer 就是 256ms 的缓存。这个值设太小会导致音频丢帧,设太大延迟增加且内存占用上升。256ms 是一个比较适合 ESP32 的平衡点。
采集线程把 DMA buffer 里的 PCM 数据读出来后,按之前设计的帧结构封装,每帧承载 320 个采样(20ms 音频)比较合适。为什么选 20ms?因为主流的 WebRTC 音频引擎和语音识别引擎都以 10ms/20ms 为处理帧规模,20ms 一帧既能控制单帧数据量,又能保证连续传输的实时性。封装后通过 WebSocket 的 binary 类型帧发送出去:
void audio_send_task(void *param) { int16_t pcm_buf[320]; size_t bytes_read = 0; while (running) { esp_err_t err = i2s_read(I2S_NUM_0, pcm_buf, sizeof(pcm_buf), &bytes_read, portMAX_DELAY); if (err != ESP_OK || bytes_read == 0) continue; // 构造二进制帧 uint8_t frame[6 + 640]; frame[0] = 0xAA; frame[1] = 0x01; // 上行音频 frame[2] = seq++; frame[3] = 0x00; frame[4] = (640 >> 8) & 0xFF; frame[5] = 640 & 0xFF; memcpy(frame + 6, pcm_buf, 640); ws_client.sendBIN(frame, sizeof(frame)); } }这里有一个非常隐晦的性能问题:如果每个音频帧都单独调用一次 sendBIN,Wi-Fi 协议栈会频繁进入发包状态,效率很低,而且 ESP32 的 TCP 发送缓冲区可能被撑爆。更好的做法是攒几帧再发,我在实测中每 100ms 发送 5 帧,延迟增加约 80ms,但网络吞吐稳定性大幅提升,丢包率从 0.8% 降到 0.05% 以下。代价是多了一点缓冲,但对整体对话体验影响很小。
3.2 音频播放侧:从文件播放到流式播放
传统方案的播放逻辑是拿到一段完整 MP3 文件再播放,ESP32 上可以用 Audio 库直接解码。但连续对话要求边收边播,服务端合成一段音频后立即分片下发,ESP32 收到一个分片就开始播放,不等全部数据到齐。这要求改造播放模块。
我选用 MAX98357A I2S 功放 + 小喇叭作为播放设备,I2S 配置为 TX 模式,采样率也是 16kHz/16bit。为了让播放更稳定,我在播放任务中维护了一个环形缓冲队列:
#define PLAY_BUF_SIZE 64 QueueHandle_t play_queue; typedef struct { uint8_t *data; size_t len; } audio_chunk_t; void audio_play_task(void *param) { audio_chunk_t chunk; while (running) { if (xQueueReceive(play_queue, &chunk, pdMS_TO_TICKS(50))) { size_t bytes_written = 0; while (bytes_written < chunk.len) { size_t n = 0; i2s_write(I2S_NUM_1, chunk.data + bytes_written, chunk.len - bytes_written, &n, portMAX_DELAY); bytes_written += n; } free(chunk.data); } } }服务端下发的每个音频帧到达后,WebSocket 事件回调函数中只做一件事:把帧负载拷贝到新的内存块里,送入 play_queue。播放任务从队列中取数据写 I2S,这样网络接收和音频播放完全解耦,网络波动不会直接导致声音卡顿。
不过这带来一个需要小心处理的问题:缓冲区堆积会不断加大延迟。用户说 5 秒的话,服务端回复 10 秒的语音,如果网络一直正常,播放队列始终消费得完;但一旦网络抖动导致服务端下发速度变慢,队列会积压未播放的数据,等网络恢复后播放的已经是几秒前的内容。解决方法是设定一个目标缓冲水位,比如队列累计超过 500ms 的音频时,丢弃部分数据让水位降到 200ms 以内。这个水位控制逻辑在“打断”场景下尤其重要。
3.3 全双工的关键:本地回声消除
这是我在整个项目里最头疼的部分。刚开始做全双工时,玩偶一边播放自己的回复,一边采集麦克风声音,结果服务端把玩偶自己说的话识别成了用户输入,导致对话死循环——玩偶问一句,又自己回答一句,完全乱套。
解决这个问题有几条路:
- 半双工硬切:播放时不传上行音频,虽然简单但会丢失打断能力
- 硬件回声消除:在模拟域用专门芯片(如 FM1188),效果好但增加 BOM 成本和接线复杂度
- 软件 AEC:在 ESP32 端对采集信号做回声消除处理
我在第一版全双工实现中选了软件消除的思路,用 ESP32 的 DSP 库做自适应滤波。具体做法是:维护一个参考缓冲,记录最近播放的音频数据,采集到新音频时,用自适应滤波器估算回声路径,然后把估计的回声从采集信号中减掉。实际实现中发现 ESP32 上做 16kHz 的 NLMS 自适应滤波,CPU 占用约 15%,可以接受,但滤波器收敛速度和噪声环境中不稳定,测试下来效果一般。后来换了一个更务实的方案:把下行参考音频(即播放出去的内容)随上行音频一起上传,在服务端用 WebRTC 的 AEC3 模块做回声消除。因为服务端算力充裕,AEC3 的消除效果远好于 ESP32 本地实现。
这样一来,ESP32 端不需要做复杂 DSP,只需要把“当前正在播放的音频帧”复制一份标记为参考信号,打包在帧头的 flags 字段中。服务端拿到上行音频和参考信号后,用 WebRTC 的 AudioProcessing 模块处理,就能清晰地分离用户语音。
注意:如果不想引入额外的服务端处理,可以采用半双工模式作为 v1 版本,全双工留到功能稳定后再升级。我一开始就是半双工跑通的链路,再逐步加回声消除与打断。
4. 服务端音频流处理的链路优化
4.1 用 Golang 实现音频流中继服务
服务端我选的是 Golang,主要是因为 goroutine 并发模型适合大量 WebSocket 长连接管理,且标准库的github.com/gorilla/websocket很稳定。整体服务端架构分三层:
- WebSocket 接入层:负责管理连接、收发二进制帧
- 音频处理管道:对上行音频做回声消除、VAD(语音活动检测)
- AI 推理层:ASR 识别、LLM 回复、TTS 合成
接入层的核心代码很简洁:
var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, } type Client struct { conn *websocket.Conn send chan []byte } func handleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("升级失败:", err) return } client := &Client{ conn: conn, send: make(chan []byte, 256), } go client.writePump() go client.readPump() }readPump中读取二进制消息后,不是立刻丢给 ASR,而是先进一个 VAD 模块做端点检测。这里我用的是开源的 Silero VAD 模型,ONNX 运行时推理,每 30ms 的音频块计算一次说话概率。VAD 状态输出端点的三个事件:speech_start、speech_end、silence_timeout。每次 speech_end 就触发一次 ASR 请求,把这段时间积累的音频一次性送出去,得到识别文本后交给 LLM。
注意一个吞吐量问题:ESP32 每 100ms 发一批 5 帧音频,每帧约 640 字节,即每秒约 32KB 数据。单个客户端不算大,但如果考虑后续可能有多个玩偶同时在线,就需要对音频数据做缓冲池复用,避免频繁的内存分配。Go 的sync.Pool是首选:
var framePool = sync.Pool{ New: func() interface{} { b := make([]byte, 1024) return &b }, }4.2 低延迟链路调优:Nginx 与 WebSocket 配置
服务端如果直接暴露端口给 ESP32 访问,初期实验没问题,但正式部署时我还是会用 Nginx 做反向代理,这样复用已有的 80/443 端口,还能顺便做 TLS 和负载均衡。
Nginx 支持 WebSocket 需要显式设置 Upgrade 头:
location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout和proxy_send_timeout必须调大,否则默认 60 秒无数据 Nginx 就会掐断连接。我用 3600 秒保证长连接不被误杀。还有proxy_buffering off要开启,否则 Nginx 会缓冲 WebSocket 响应数据,导致服务端推送的音频帧产生额外延迟。
关于心跳机制,WebSocket 有 Ping/Pong 帧,我让 ESP32 每 30 秒发一次 Ping,服务端自动回 Pong。如果连续 3 次 Ping 没有收到 Pong,ESP32 主动重连。在服务端,如果 90 秒没有收到任何帧(包括 Ping),就关闭连接释放资源。
4.3 ASR 与 LLM 的处理策略优化
连续对话对 ASR(语音识别)的实时性要求很高。我一开始是每次 VAD 检测到说话结束才送识别,这会导致响应的第 1 个字出现前有约 500ms 的识别耗时。后来改成流式识别,把 ESP32 上传的音频帧实时送入 ASR 引擎,引擎边接收边出中间结果,一旦识别到完整的语义单元就通知 LLM 预生成回复。
我用的 ASR 是基于 Paraformer 的开源模型,在流式模式下每 200ms 输出一次中间结果。LLM 服务端如果收到 800ms 的静默后用户还没有继续说,就认为一个完整对话轮次结束,立刻生成最终回复。这样整体响应延迟能从“说话结束 + 500ms 识别 + 800ms 推理”压缩到“说话结束 + 200ms 尾包 + 300ms 推理”,用户几乎感觉不到等待。
有一个优化细节:对常见的交互模式,比如“讲个故事”“唱首歌”“今天天气怎么样”,可以在 LLM 前面加一层意图缓存,命中高频问题就直接返回预设回复,不调度大模型。这个缓存命中率在我的测试中有约 20%,能显著降低整体延迟和服务器成本。
5. 常见问题与排查心得
5.1 WebSocket 频繁断连:1006 错误的真相
实测中最常见的问题是连接不稳定,表现为 WebSocket 的 onClose 回调中 code 为 1006,reason 为空。1006 表示连接异常关闭,不是正常握手关闭。
我排查这类问题总结出一套流程:
- 先排除 Nginx 超时:检查
/var/log/nginx/error.log,如果看到 “upstream timed out” 就说明是反向代理把连接掐了,调大proxy_read_timeout - 再查 ESP32 侧内存:ESP32 在堆内存不足时 OpenSSL 或者 TCP 栈可能异常崩溃,打印空闲堆内存看趋势,如果持续下降说明有内存泄漏
- 看 Wi-Fi 信号强度:esp_wifi_get_ap_info 查 RSSI,低于 -70dBm 时 TCP 连接很可能频繁断流,这时要从硬件层面加天线或者调整摆放位置
- 检查防火墙:很多云服务器的安全组默认只放行 80/443,如果用 8080 等非标端口,需要确认安全组规则
5.2 音频播放卡顿问题排障
卡顿的根源一般是播放缓冲区欠载(underrun)。排查顺序:
- 确认服务端下发节奏比采集节奏快还是慢。如果服务端 TTS 合成速度跟不上 ESP32 的消耗速度,会出现周期性卡顿。我的解决方法是服务端合成完一句完整的话后才开始下发,而不是一个字一个字地推
- 确认 ESP32 播放任务优先级。I2S 写操作的等待时间可能导致任务被低优先级拖住,把播放任务优先级设到 5(ESP32 默认优先级 1~24,数值越大越优先)就能缓解
- 检查是 Wi-Fi 丢包还是 WebSocket 层丢帧。我在帧头设计了 seq 字段,接收方检测到 seq 跳跃就打印日志,连续多次跳跃说明网络层有问题
5.3 中断与抢答的“优先级反转”问题
连续对话场景下,用户可能在玩偶播放回复时打断它。这个动作背后隐藏着一个优先级问题:播放任务和采集任务同时运行时,如果采集线程优先级低于播放线程,当播放占用 CPU 时采集线程得不到调度,用户打断的语音先被丢弃,等服务端发现用户已经说话时,已经晚了 500ms 以上。
解决方法是给采集任务一个足够高的优先级,确保 I2S DMA 读操作优先执行,因为采集的数据一旦丢失不可能重来;播放数据即使延迟几百毫秒,对听感的影响远小于打断丢失。在 FreeRTOS 配置中我把采集任务设为 10,播放任务设为 7,网络发送任务设为 6。
5.4 连接重连和状态恢复
网络波动导致 ESP32 和服务器断开后,怎么恢复之前对话状态?我的做法是在 ESP32 端记录当前状态机的阶段和最近 2 秒的音频数据。重连成功后,客户端发一个控制帧,携带上次会话 ID 和断开前状态,服务端根据会话 ID 找回上下文,把未播完的音频继续下发。如果无法恢复上下文,则让 ESP32 端播报“刚才网络不太稳定,我们再聊一次吧”,然后回到 IDLE。
6. 链路重构后的效果与扩展方向
完成这条二进制音频链路重构后,实际体验提升非常明显。之前按键说话模式延迟约 2.8 秒,现在连续对话模式在 Wi-Fi 局域网内延迟降到约 800ms,用户说话结束到玩偶开口间隔约 1.2 秒,这个间隔在孩子能接受的范围之内。全双工打断的成功率从 0%(原方案不支持)提升到了 90% 以上,只要孩子音量足够,玩偶能及时停下自己的话转头听新指令。
从系统资源角度看,ESP32 的空闲堆内存在 64KB,之前运行完整音频播放 + 录音时剩余不到 20KB,重构后稳定在 30KB 以上。这个余量让我可以继续加功能,比如在端侧跑一个关键词检测模型。
展开方向我认为有三个值得做:第一,端侧接 OPUS 编码压缩,适合需要走公网的场景,虽然会占用一些 CPU,但在 4G/低带宽链路下能明显缩短传输时间;第二,服务端接入多模态模型,让玩偶不仅能“听”,还能根据用户说话的语速、音量感知情绪,做出更有温度的回应;第三,把链路抽象成通用模块,以后做智能音箱、穿戴设备时可以直接复用这套二进制帧和状态机。
我在实际开发中最深的感受是:AI 硬件产品体验好不好,常常不取决于模型有多强,而取决于链路处理得细不细。一个 30ms 的缓冲设置差异、一个被忽略的 Nginx 超时配置,都足以毁掉整段对话体验。希望这篇复盘能帮你在自己的 ESP32 AI 项目里少走几步弯路。