孩子房间里的AI玩偶,之前的状态是:问一句“今天天气怎么样”,它要卡三四秒才开始说话,说完就彻底闭嘴,想追问一句还得重新喊唤醒词。朋友来家里看到说“这玩偶能对话啊”,但我自己心里清楚,这种“能对话”和真正的连续对话之间,隔着一条完整的链路重构。后来我把音频传输从HTTP整段上传换成了WebSocket二进制帧,配合流式语音识别和流式合成,玩偶才终于从“问一句答一句”变成了可以被打断、可以连续追问的状态。这篇就把重构过程中最关键的几个部分拆开讲:二进制音频帧怎么设计、服务端和ESP32侧各改了什么、实测延迟数据如何、有哪些参数不调必翻车。适合手里有ESP32 AI硬件、正在把对话体验往“真人语感”方向做的开发者参考。
1. 先梳理旧链路:为什么“一问一答”模式拖垮了玩偶的体验
1.1 原来的音频上传链路是怎么跑的
大多数ESP32 AI玩具项目的第一版链路都是这个套路:设备端通过I2S接口从麦克风采集PCM音频,等用户说完一整句话后,把整段WAV数据通过HTTP POST上传到云端服务器,服务器依次调用ASR(语音识别)拿到文本、调用LLM生成回复、再调用TTS合成语音,最后返回一个完整的音频文件,设备下载完开始播放。
这套逻辑在Demo演示时完全够用,网络好的情况下,单轮对话2到3秒能响。但问题在于:用户一旦想连续聊,体验就会迅速恶化。因为每一轮对话都是“采集一整段→上传→等待识别→等待生成→等待合成→下载→播放”的串行链路,任何一个环节的网络抖动都会直接叠加到用户的等待时间上。更难受的是,这轮对话没播完之前,麦克风通道是没法继续采集的,用户想说下一句只能干等。
我在实际项目里还遇到过更隐蔽的问题:ESP32的内存有限,采集10秒的16kHz、16bit单声道PCM音频,大约要320KB的缓冲区。这个空间在ESP32-S3上还能勉强接受,但一旦把TTS返回的MP3或WAV也缓存进内存,内存压力就立刻上来了,经常出现采集到一半缓冲区被挤爆的情况。
1.2 旧架构下“不够连续”的三个具体原因
第一,整段音频上传导致首包延迟高。用户说完一句话,设备端要等静音检测确认“话说完了”,才开始上传,上传又要花一个RTT,服务端必须等整个文件收完才能开始ASR。这就像寄快递,必须把整个包裹打包好、送到站点,快递公司才能开始分拣,完全没有“边写边寄”的通道。
第二,HTTP请求-响应模型是半双工的。服务端只能被动等设备请求,设备不发起请求,服务端永远不能主动推送数据。这就导致VAD事件、识别中间结果、TTS流式音频块这些本该实时下发的内容,全部要等设备轮询或者干脆等整轮结束。可连续对话最需要的恰恰是服务端的主动推送。
第三,数据格式太浪费。之前我在JSON消息体里塞base64编码的音频,同样一段音频,base64编码体积膨胀约33%,ESP32解析的时候又要做内存拷贝,ArduinoJson处理几十KB的帧时还容易触发内存碎片导致重启。音频边界还得靠业务逻辑猜,服务端经常分不清这一帧到底是“中间内容”还是“结束标志”。
老链路的对比感受可以看这个表格:
| 维度 | HTTP短连接(旧) | WebSocket文本帧(中间态) | WebSocket二进制帧(重构后) |
|---|---|---|---|
| 连接开销 | 每次请求都要建连 | 长连接,低开销 | 长连接,低开销 |
| 数据方向 | 半双工 | 全双工 | 全双工 |
| 音频编码 | base64膨胀 | 可读但解析慢 | 原生二进制,零膨胀 |
| 帧边界 | 依赖HTTP body长度 | 依赖文本分隔符 | 依赖定长帧头 |
| 适合连续对话 | 非常吃力 | 能跑但不经济 | 最适合 |
当时我把链路从HTTP换到WebSocket文本帧时,感觉已经好很多了,至少服务端能主动推送了。但音频用文本帧传输依然别扭,真正质的改变是后面把音频全部改成二进制帧之后才发生的。
2. 二进制音频帧:字段设计与编解码实现
2.1 为什么不用JSON直接传音频
很多开发者一听WebSocket就习惯性地继续用JSON传所有东西,包括音频。在网页前端场景下这么做问题不大,但在ESP32这种内存和算力都受限的设备上,代价非常明显。
首先,JSON里音频通常用base64表示,同样数据体积膨胀33%,意味着WiFi传输时间也同步增加三分之一。其次,ESP32上解析JSON要消耗额外的CPU周期和内存,ArduinoJson处理几KB级别的小消息还行,一旦碰到包含几十KB音频数据的消息,动态内存分配很容易导致堆碎片,跑一段时间后设备就会莫名重启。最麻烦的是帧边界问题,一个JSON里塞一大段音频后,如果中间出现特殊字符,解析器很容易把帧边界搞错,调试起来非常痛苦。
二进制帧则完全不同。定长帧头让粘包、半包处理变得极其简单,载荷区直接放原始PCM或OPUS编码数据,收发两端只需要做内存拷贝,不需要任何字符串解析。对ESP32来说,这几乎是零成本的。
2.2 帧头结构定义
我最终定的帧头是16字节定长,所有多字节字段统一使用大端序(网络字节序),这样和设备、服务端的CPU架构无关,避免大小端转换的坑。
| 偏移 | 长度(字节) | 字段名 | 说明 |
|---|---|---|---|
| 0 | 2 | magic | 固定0xA5 0xA5,用于快速校验 |
| 2 | 1 | version | 协议版本,当前为0x01 |
| 3 | 1 | type | 帧类型 |
| 4 | 2 | seq | 序列号,递增,用于丢帧统计 |
| 6 | 2 | flags | 位标志(0x01=最后一包) |
| 8 | 4 | timestamp | 相对时间戳,单位毫秒 |
| 12 | 2 | codec | 音频编码:0x00=PCM16,0x01=OPUS |
| 14 | 2 | payload_len | 载荷区长度 |
帧类型我定义了下面这些,覆盖上行、下行和事件:
| 类型值 | 名称 | 方向 | 作用 |
|---|---|---|---|
| 0x01 | AUDIO_UP | 设备→服务端 | 上行音频块 |
| 0x02 | VAD_EVENT | 双向 | 语音事件,如start、end、energy |
| 0x03 | INTERRUPT | 设备→服务端 | 用户打断指令 |
| 0x04 | AUDIO_DOWN | 服务端→设备 | 下行音频块 |
| 0x05 | TTS_META | 服务端→设备 | TTS状态与文本信息 |
| 0x06 | HEARTBEAT | 双向 | 心跳帧 |
| 0x07 | ERROR | 双向 | 错误上报 |
这个协议把事件和音频数据分开,好处是设备端收到底层WebSocket消息时,先读前16字节拿到type,就能立刻判断该走哪条处理路径,不需要把整个载荷读完再判断。比如收到INTERRUPT帧,ESP32就能立刻停止当前播放,而不是等整段音频载荷解析完才发现这是个控制指令。
2.3 ESP32侧发送与接收的伪码实现
发送一帧音频的流程,用Arduino框架下的代码示意大概是这样的:
void sendAudioFrame(uint8_t* pcmData, uint16_t len, uint16_t seq) { uint8_t header[HEADER_LEN]; // 16字节 header[0] = 0xA5; header[1] = 0xA5; header[2] = 0x01; // version header[3] = 0x01; // type: AUDIO_UP header[4] = (seq >> 8) & 0xFF; // seq big-endian header[5] = seq & 0xFF; header[6] = 0x00; header[7] = 0x00; // flags uint32_t ts = millis(); header[8] = (ts >> 24) & 0xFF; header[9] = (ts >> 16) & 0xFF; header[10] = (ts >> 8) & 0xFF; header[11] = ts & 0xFF; header[12] = 0x00; // codec: PCM16 header[13] = 0x00; header[14] = (len >> 8) & 0xFF; header[15] = len & 0xFF; wsClient.binary(header, HEADER_LEN); wsClient.binary(pcmData, len); }注意上面是“分两条WebSocket消息发送”,实际生产环境我建议把头和载荷拼到一个缓冲区里一次发送,否则部分WebSocket库会把头和载荷拆成两个独立的帧,服务端收到后还得自己缓存做重组,徒增复杂度。
接收端解析时要重点处理粘包问题。WebSocket本身已经提供了消息边界,理论上一次性收到的就是完整帧,但ESP32的WebSocket库在接收大数据时可能回调多次,所以服务端发下来的音频块如果超过设备的接收缓冲区,就会触发分片回调。我采用的方式是:先收16字节头,根据payload_len再收对应长度的载荷,内部维护一个接收状态机,保证无论底层如何分片,业务层拿到的永远是完整帧。
3. 服务端改造:从“一次性响应”到“流式转发”的长连接架构
3.1 服务端会话模型设计
服务端是整个重构里改动最大的部分。我最初用Flask处理HTTP请求,重构后直接用Go重写了WebSocket服务端,用Gorilla WebSocket库管理长连接。之所以选Go,是因为它的goroutine模型天然适合“一个连接多个并发任务”的场景。
核心模型是这样的:每个玩偶设备从WebSocket升级建立连接后,服务端立即创建一个Session对象,Session内部维护三个子任务协程——ASR流式识别协程、LLM流式生成协程、TTS流式合成协程,以及三个任务之间的数据管道。
用伪码展示核心结构:
type Session struct { conn *websocket.Conn sendMu sync.Mutex asr *ASRClient llm *LLMClient tts *TTSClient ctx context.Context cancel context.CancelFunc } // 读取循环:从WebSocket读出二进制帧,按类型分发 func (s *Session) readLoop() { for { msgType, data, err := s.conn.ReadMessage() if err != nil { s.cancel() return } if msgType != websocket.BinaryMessage { continue } header := parseHeader(data) switch header.Type { case FrameAudioUp: s.asr.Feed(header.Payload) case FrameVadEvent: s.asr.HandleVAD(header.Payload) case FrameInterrupt: s.tts.Interrupt() case FrameHeartbeat: s.sendPacket(header.Seq, FrameHeartbeat, nil) } } }关键的一点是:s.sendMu锁必须加在写入动作上,因为多个协程(ASR结果返回、TTS音频块返回)会同时尝试向WebSocket写数据,Gorilla WebSocket库本身不允许并发写,不加锁会出现“concurrent write to websocket connection”的panic。
3.2 流式ASR到LLM到TTS的管道设计
连续对话的核心在服务端,不只是把数据换成二进制,而是要把原先“完整ASR→完整LLM→完整TTS”的串行流程,改成三层管道各自流式工作。
我采取的方案是:
- ASR层:设备端边说话边传音频块,ASR服务流式返回识别结果。用户还没说完,服务端已经拿到部分文本。当VAD_EVENT的end事件到达时,ASR输出完整文本。
- LLM层:不等ASR完全结束,就把已识别的文本前缀送进LLM,让LLM提前开始生成首token。注意这里要控制好节奏——如果用户还在说话,LLM只能基于不完整文本做生成,容易答非所问。我最后采用的策略是ASR输出稳定的
中间结果时只做预热,VAD end之后才真正把完整文本提交给LLM。 - TTS层:LLM的回复文本进入TTS后,TTS按句子切分,合成第一句话的音频后立刻下发,后续句子边合成边下发。这样用户听到首音的时间大幅缩短,而不是等整个回复全部合成完。
一个典型的时间线是这样的:用户说完“今天天气怎么样”,设备端VAD触发end事件时,服务端同时收到完整文本并提交LLM;LLM大约400ms返回第一段回复文本;TTS合成第一个音频块大约200ms,然后立即下发;设备端收到首包后启动播放。整条链路下,“说完话到听到首音”可以控制在1秒左右,相比之前整段合成的2到3秒,体验质的飞跃。
3.3 多设备会话管理与掉线处理
当多个玩偶同时在线时,服务端需要一张Session表来管理所有连接:
var sessionMgr = struct { sync.RWMutex sessions map[string]*Session }{sessions: make(map[string]*Session)}每台设备通过JWT或Token鉴权,握手成功后以device_id为键注册到Session表。设备断线时,要触发Session的cancel函数,停止所有ASR、LLM、TTS协程,避免协程泄漏。我最初没做这个清理,设备频繁掉线重连后,服务端协程数一路飙升,最后把内存打爆了。
掉线时还有一点容易被忽略:TTS协程可能已经合成了部分音频,这些音频块需要清空,否则下一条指令到达时,设备会先播放上一轮残留的音频再响应新指令,用户感知就是“玩偶突然自言自语”。
4. ESP32客户端改造:打断检测、缓冲调度与断线重连
4.1 状态机重构
ESP32客户端侧的代码结构,我从原来的“采集—上传—等待—播放”的线性流程,改成了一个五状态状态机:IDLE、LISTENING、PROCESSING、PLAYING、INTERRUPTED。
- 空闲转监听:用户按下唤醒按钮,或本地检测到唤醒词后进入LISTENING。
- 监听转处理:本地VAD判定用户说完一句话(静音超过450ms),停止采集,进入PROCESSING,等待服务端返回。
- 处理转播放:收到TTS下行音频首包,启动播放,进入PLAYING。
- 播放转监听:播放队列为空,回到LISTENING,等待用户下一句话。
- 播放中打断:播放状态下麦克风持续采集,若检测到语音能量超过阈值,发送INTERRUPT帧给服务端,同时转INTERRUPTED并快速清空播放队列,然后回到LISTENING。
这个状态机的核心价值是让设备的一言一行都有明确状态,而不是靠一堆散落的if-else判断“现在在干嘛”。我在重构前就是因为播放和采集逻辑混在一起,经常出现“播放到一半,麦克风缓冲区的老数据被当成新语音上传”的诡异问题。
4.2 本地VAD与打断检测实现
ESP32上做VAD,不推荐直接上复杂的神经网络模型,资源不够。基于能量的轻量VAD在安静环境下已经足够好用,我实现的方式非常朴素:对16kHz、16bit单声道音频,按20ms一帧计算RMS均方根能量,然后和动态底噪阈值比较。
float computeRMS(const int16_t* samples, size_t n) { int64_t sum = 0; for (size_t i = 0; i < n; i++) { sum += (int32_t)samples[i] * samples[i]; } return sqrt((float)sum / n); } void updateNoiseFloor(float rms) { // 平时持续更新底噪,只在非语音状态下更新,防止语音拉高阈值 noiseFloor = noiseFloor * 0.98f + rms * 0.02f; } bool isSpeech(float rms) { return rms > noiseFloor * 3.0f; }底噪阈值有个细节要特别注意:只在非语音状态下更新底噪,否则用户说话的声音会把底噪拉高,导致后续的语音判定失灵。另外,家用电风扇、空调的噪音频率比较稳定,能量不高,阈值乘3基本能过滤掉;但如果是靠近鱼缸水泵这种持续低频噪声,建议先做一次200Hz以下的高通滤波再算能量。
打断检测和普通VAD是同一个能量算法,但切换时机不同。播放状态下,麦克风依然保持采集,每20ms算一次RMS。如果RMS高于语音阈值的1.5倍,我判定用户想说话,立刻发送INTERRUPT帧,然后清空播放队列。
实际测试中,打断检测最怕THP(touch-to-talk)和TTS混音的情况,就是玩偶一边播放一边自己发声,麦克风把喇叭的声音又采进去了。这个要去掉回声,最简单的方案是播放时降低喇叭音量,或者用带回声抵消的音频前端芯片。在不加硬件的条件下,只能通过“播放结束后60ms内不触发打断”的窗口来缓解。
4.3 播放缓冲与抖动控制
下行音频到达ESP32后,不能立刻播放,也不能等全部到齐再播。立刻播放会卡顿,因为网络抖动可能导致下一包迟迟不来;等全部到齐又回到老链路的老路,首音延迟太大。
我做了一个简单的自适应抖动缓冲区:维护一个音频块队列,记录每个块的时间戳。收到首个音频块后,等待额外的40~80ms作为缓冲积累,再启动I2S播放。播放过程中,每消费一个块,检查队列长度:
- 队列为空且无新块到达超过200ms,判定卡顿,上报ERROR帧。
- 队列长度超过500ms数据量,说明网络突发积压,主动丢弃中间的音频块只保留最新块,避免延迟越来越大。
ESP32的I2S播放是DMA驱动的,我的做法是维护一个“用户态队列”加“DMA半满/全满中断”两级缓冲。TTS音频块到达后台任务后写入队列,I2S DMA中断回调里从队列取数据填入DMA缓冲区。这样播放和网络接收完全解耦,互不阻塞。
4.4 断线重连与心跳保活
ESP32的WiFi本身就是不稳定的存在,WebSocket长连接一定要设计心跳和重连机制,否则用户玩着玩着玩偶就“哑巴”了。
我采用的是应用层心跳:每30秒发送一个HEARTBEAT帧,服务端收到后原样回一个HEARTBEAT帧。如果客户端连续2个心跳周期(60秒)没收到回复,判定连接已死,主动关闭WebSocket并进入重连流程。
重连采用指数退避加随机抖动,避免多个设备同时重连造成服务端瞬间压力高峰:
- 第一次重连等待1秒
- 第二次2秒
- 第三次4秒
- 第四次8秒
- 上限30秒,之后保持30秒间隔持续尝试
- 每次等待时间加上0到1秒的随机抖动
重连过程中还要注意:ESP32重新连接WiFi和重新建立WebSocket连接期间,要确保音频采集和播放线程已暂停,否则会出现一边重连一边采集上传,数据全丢的无效操作。
4.5 关于1006异常关闭的处理
很多用WebSocket的开发者都遇到过“onclose code 1006”的问题,热搜里也频繁出现。1006的定义是“连接异常关闭”,意味着TCP连接断开时,客户端没有收到服务端的Close帧。
我遇到的情况主要有两种:一是ESP32在WiFi睡眠或漫游时TCP连接被底层断开;二是服务端进程崩溃或主动kill了连接,但没有发送Close帧。
排查1006的思路建议按这个顺序来:
- 查看服务端日志,确认服务端是否看到了正常关闭流程。
- 在ESP32侧打印WiFi的RSSI信号强度,看是否在断连前有信号大幅波动。
- 如果用Wireshark抓包,看TCP层是FIN还是RST。RST通常意味着对端异常,FIN四元组正常则是正常关闭。
经验之谈:ESP32上经常发生1006是因为模组的WiFi栈在省电模式下会自动断开TCP连接,而应用层还以为连接活着。所以玩偶项目里我直接禁用了WiFi Modem Sleep,虽然功耗高一点,但连接稳定性明显改善。
5. 延迟实测、参数调优与踩坑记录
5.1 端到端延迟拆分表
重构完成后,我在家里网络环境(WiFi信号中等,延迟约20ms)下做了多轮实测,统计“用户说完到听到首音”的延迟分布:
| 环节 | 重构前(毫秒) | 重构后(毫秒) | 说明 |
|---|---|---|---|
| 本地VAD判定结束 | 300 | 30 | 重构前要等整段录音结束 |
| 音频上传/首包处理 | 500 | 200 | 二进制帧减小了传输量 |
| ASR完整识别 | 400 | 250 | 流式识别提前出中间结果 |
| LLM首token | 400 | 400 | 依赖模型性能,变化不大 |
| TTS首包合成 | 600 | 200 | 按句流式合成,大大缩短 |
| 设备启动播放 | 50 | 50 | 基本不变 |
| 合计 | 约2650 | 约1130 | 首音延迟压缩一半以上 |
注意表格里的数字是典型场景,不包含服务端排队和网络拥塞的极端情况。如果网络RTT超过80ms,整个链路延迟会线性上升。所以玩偶要放得离路由器近一点,或者用2.4G频段而不是5G频段,ESP32的5G信号穿墙能力很差。
5.2 关键参数调优记录
- OPUS编码码率:32kbps,采样率16kHz。PCM裸流是256kbps,OPUS压缩到32kbps后,不仅传输更快,ESP32解码OPUS的CPU占用也就在10%左右。注意ESP32的IDF框架自带OPUS解码库,直接用就行,不需要额外移植。
- 播放缓冲积累量:首包到达后等待80ms再启动播放。这个值太小容易在弱网下卡顿,太大又会让首音延迟明显。80ms是我在中等信号强度下试出来的折中值。
- 心跳间隔:30秒。太频繁会无谓浪费流量,太久又不能在掉线后及时感知。
- WebSocket消息大小:单帧载荷不超过4KB。OPUS编码20ms音频一帧大约80字节,我每个上行包放200ms的音频数据,约800字节,保证一个包就能承载。下行TTS音频块也控制在20ms一包,大约80字节,避免单个帧太大导致ESP32接收缓冲区溢出。
5.3 调试技巧:用时间戳和seq定位卡顿
连续对话系统一旦卡顿,最先要判断的是“卡在哪一段”。我开发时在ESP32侧的日志里,每个关键节点都打了时间戳:
- 收到TTS_START帧的毫秒时间
- 收到第一个AUDIO_DOWN帧的毫秒时间
- I2S回调里真正开始播放第一个样本的毫秒时间
- 每个下行音频块的实际播放时间戳
对比这四个时间点,就能快速区分卡顿是发生在“服务端还没下发”“网络还没传到”还是“本地播放队列饿死”。另外,每帧都有seq序号,我定期统计接收到的seq是否连续,如果出现空洞,基本可以判定是网络丢包,优先检查WiFi信号和路由器拥塞,而不是去查服务端逻辑。
5.4 三个容易翻车的细节
第一,不要在一条WebSocket消息里传超大音频。我一开始图省事,把TTS合成的完整音频一次性塞进一个二进制帧里,结果ESP32的WebSocket库在接收时疯狂报内存不足。后来改成按20ms一个OPUS包下发,问题立刻消失。
第二,不要用全局锁保护音频队列。ESP32的Arduino环境里,如果播放线程和网络接收线程同时操作一个std::queue,用互斥锁确实能保证正确性,但在高频操作下锁开销很大,容易导致I2S DMA缓冲区饿死。改成FreeRTOS的Queue或者环形缓冲区,性能好得多。
第三,不要忽略设备时间戳的同步问题。ESP32没有RTC电池时,重启后时间会丢失回到1970年。服务端下发TTS_META帧时如果带的是服务器时间戳,和设备本地的毫秒戳做差值毫无意义。我最后的做法是:所有音视频同步只用相对时间戳,从设备连接建立那一秒开始计数,服务端转发的所有时间戳都换算成相对值。
收尾:一点体会
整个重构下来,我最深的感受是:玩偶类AI硬件想做出“陪伴感”,瓶颈往往不在模型智商,而在音频链路的实时性。模型再聪明,如果设备四秒才开口、说话不能打断,用户依然会觉得“这是个玩具”;而一旦把首音延迟压到一秒左右、支持随时打断追问,哪怕是同一个模型,体验立刻就不一样了。
如果让我再重来一次,我会在一开始就把帧协议设计成二进制格式,而不是先跑通JSON再说。后续加打断控制、加流式TTS,全都是因为底层协议预留好了控制帧和事件帧,才没把架构推倒重来。最后分享一个小技巧:日志里把TTS_START帧到达时间和I2S播放器真正发声的时间差值打出来,差值超过200ms的时候,一定有一个环节在悄悄偷走用户的耐心。这个数值,比看任何仪表盘都直观。