ESP32 AI玩偶全双工语音链路重构:WebSocket流式对话实战
2026/9/11 21:30:55 网站建设 项目流程

说实话,这个项目最初的起点很简单:我想做一个能放在桌上的 AI 陪伴玩偶,用 ESP32 驱动,能听懂人话、能开口回答。第一版其实二天就"跑通"了——麦克风录一段 16kHz 音频,HTTP 上传,服务端 ASR 识别,丢给大模型,再把 TTS 生成的整段 mp3 拉回来播放。Demo 演示时大家都说"不错,能对话了"。但等你真的把它摆在桌面聊上十分钟,问题就全出来了:说完了要干等两三秒才听到回应;玩偶正在播报时你喊它名字完全没反应;连续聊几轮之后,它像一台反应迟钝的录音机,根本配不上"陪伴"这个词。

于是我把整个音频链路推倒重做,核心思路就一句话:把原来"录音—上传—识别—生成—下载—播放"的串行流程,改成建立在 WebSocket 二进制帧通道上的全双工流式管线。这篇文章会把改完之后的完整方案、二进制协议设计、ESP32 端和服务端的改造细节、实测数据以及我踩过的坑全部写出来。如果你也在做类似的 AI 硬件、语音玩具、桌面机器人,这篇应该能帮你少走不少弯路。

1. "能对话"背后的三块短板:为什么玩偶聊几句就冷场

1.1 原始链路到底是什么样

第一版方案非常典型,很多 ESP32 语音项目都是这么起步的,所以我先把这条链路画出来:

  1. 玩偶端持续录音,本地用能量阈值做简单 VAD(端点检测),检测到人声结束就把整段音频保存下来。
  2. 整段 PCM/WAV 通过 HTTP POST 传给服务端。
  3. 服务端先跑一遍离线 ASR,拿到完整文本。
  4. 文本交给 LLM,等 LLM 生成完整回复。
  5. 回复文本交给 TTS,合成完整音频文件。
  6. 玩偶下载音频,开始播放。

按下说话键到玩偶开口,整条链路是严格串行的。理想状态下每个环节 300ms,加起来也有 1.5 秒;实际跑起来,公共网络、ASR 排队、TTS 合成大段音频,经常干等 3 到 5 秒。我实测过一轮"今天天气怎么样"的完整对话,分段耗时大概是这样:

链路环节耗时
录音结束 + HTTP 上传120ms~400ms
离线 ASR 整段识别300ms~800ms
LLM 完整生成800ms~2500ms
TTS 合成长音频500ms~1500ms
下载 + 播放300ms~600ms
总计2.1s~5.8s

这不是"延迟高"的问题,是体验逻辑本身就是错的。人类聊天不是先录一段话发给对方、等对方把整段回答录好再播给你听,而是边说边听、边听边想、随时可以插嘴。

1.2 三个体验杀手:串行、半双工、无状态

第一版的问题归纳起来就三个:

串行等待放大了每一步的延迟。上一环不结束,下一环就不能开始。ASR 明明已经有了前几秒的文本,却不能先丢给 LLM;LLM 第一个 token 明明已经出来了,TTS 却不能开始合成并回传。所有可以"流式"的东西全被堵在整段式接口后面。

半双工导致无法插话。ESP32 端录音和播放是互斥的:在播放 TTS 音频时,麦克风链路直接关闭。用户想打断、想追问、想喊"停",玩偶一概听不见。真正的对话里,打断能力比首响延迟更重要——你受不了的是一个不能闭嘴的聊天对象。

每次请求都是一次全新会话。第一版为了简单,LLM 上下文全靠服务端拼接历史消息,设备端完全无状态。结果就是用户说"那后天呢?"这种指代性追问,玩偶根本接不住。连续对话不只是流式音频,还要求会话状态在整个链路中持续维护。

1.3 重构目标:到底想要什么样的"连续对话"

我在动工之前给自己定了几个可以量化的指标,不然重构很容易做成"换汤不换药":

  • 首响应时间(从用户停顿到玩偶发出第一个音节)压到 1.5 秒以内。
  • 玩偶播放回答期间,麦克风保持开启,用户随时可以打断。
  • 同一轮对话内,ASR、LLM、TTS 三者是并行推进的,而不是串行等待。
  • 链路能连续工作 30 分钟以上,不出现内存溢出、缓冲饥饿、断线假死。
  • 二进制协议是我们自己定的,不依赖第三方语音 SDK,保证玩偶端逻辑完全可控。

有了这几个目标,选型就顺理成章了:长连接必须上,流式必须上,音频必须走二进制。

2. WebSocket 二进制帧协议:为实时音频重画一条数据通道

2.1 为什么是 WebSocket,而不是 MQTT、UDP 或者再坚持 HTTP

这是项目里第一个需要拍板的技术决策。我当时的候选有 HTTP/2 流、MQTT、UDP(带 DTLS)、WebSocket。最终选的 WebSocket,理由很实际:

方案优势在 ESP32 音频场景的问题
HTTP/1.1 轮询/长轮询实现简单服务端无法主动推送,音频下行延迟不可控,频繁建连开销大
MQTT硬件生态好,QoS 可靠broker 中转,多一跳延迟;QoS 重传对实时音频反而造成乱序;维持长连接的心跳开销不小
裸 UDP延迟最低公网环境 NAT 穿透、鉴权、加密全要自己搞,TLS 握手和密钥协商麻烦;ESP32 端代码量和排查成本都高
WebSocket基于 TCP,天然穿透公网;支持全双工;二进制帧和文本帧分离TCP 头部阻塞理论存在,但 16kHz 音频帧流量很小,实测可忽略

我用的服务端是 Golang,标准库加一个gorilla/websocket就能搞定;ESP32 端 Arduino 生态里有WebSockets库,ESP-IDF 也有官方esp_websocket_client。两边都不是从零造轮子,出问题还能翻源码,这是我很看重的。

另外一个容易被忽略的点:WebSocket 可以直接复用 443/80 端口,TLS 握手走的就是 HTTPS 那套,在公司网络、校园网、家庭路由器后面基本不会被拦。这对硬件产品来说太重要了——你永远不知道用户会把设备放在什么网络环境里。

2.2 二进制帧到底比 JSON 省了多少

很多人的第一个疑问是:JSON 不是也能传音频吗?Base64 编码一下不就行了?能跑,但代价很大。

16kHz、16bit、单声道 PCM,一秒是 32000 字节。如果走 WebSocket 文本帧,Base64 编码会把这 32000 字节膨胀成约 42667 字节,多出整整三分之一。在 ESP32 这种内存和带宽都敏感的设备上,每秒钟多传 10KB 数据,WiFi 占用、CPU 拷贝、内存开销全部跟着涨。而且 JSON 本身还要加引号、字段名、花括号,解析的时候还得逐字段字符串匹配。

我们的做法是:控制信令走 JSON 文本帧,音频数据全部走二进制帧。二进制帧的 payload 直接就是裸 PCM(或者 Opus 压缩后的编码帧),没有一点点额外包装。

帧头我设计了 8 字节,定长,不做变长解析:

Byte 偏移长度字段说明
01message_type0x01 音频上行,0x02 音频下行,0x03 控制事件,0x04 握手/鉴权
11codec0x00 PCM_S16LE,0x01 OPUS,预留扩展
2-32sequence16 位递增序号,用于丢包检测和排序
4-52sample_rate采样率,如 16000
6-72payload_len本帧 payload 长度,单位字节

payload 就直接跟在 8 字节头后面。这样接收端memcpy头部,按payload_len切出音频数据,连续写进环形缓冲就行。不做 JSON 解析、不做 Base64 解码、不查表,开销几乎可以忽略。

2.3 控制信令与音频流的隔离设计

音频帧必须尽量"薄",但会话控制不能全塞在二进制里。我们的习惯是:高频低延迟的走二进制,低频偶发的走 JSON 文本帧。

比如这些场景走文本帧:

  • 设备上线时发送设备信息、协议版本、鉴权 token。
  • 服务端下发会话 ID、VAD 灵敏度参数。
  • 设备上报音量、电池、WiFi RSSI。
  • 服务端下发"开始说话/停止说话"这类事件通知。

而这些场景走二进制帧:

  • 麦克风采集的 PCM 上行。
  • TTS 合成的音频下行。
  • Opus 编码帧的传输。

为什么分开?因为音频帧频率高,一秒钟 50 个帧(20ms 一帧),如果每个帧都带 JSON 头,光解析就是浪费;控制事件频率低,但对可读性要求高,JSON 一眼能看懂,调试时tcpdump抓个包就能定位问题。两者用 WebSocket 的同一条连接传输,靠message_type区分,互不阻塞。

序列号这个字段是我后来补上的。ESP32 的 WiFi 在 2.4GHz 频段干扰严重时,TCP 重传会导致 WebSocket 帧到达顺序错乱,但 TCP 保证最终有序,所以实际很少乱序。真正有用的是丢包检测:接收端发现 sequence 跳变,就知道中间丢了帧,播放缓冲里补一段静音,而不是让整个链路卡死。

3. ESP32 端改造:麦克风、播放器和一条不会堵车的总线

3.1 I2S 采集链路:从"录整段"到"一帧一帧传"

我用的主控是 ESP32-S3,配合 INMP441 数字麦克风(I2S 接口,MEMS 颗粒)和 MAX98357A I2S 功放直推小喇叭。这个组合几乎是 ESP32 语音项目的标准答案:INMP441 便宜、噪声低,MAX98357A 只需三根数据线(BCLK、LRCLK、DIN),不需要额外 DAC。

关键配置如下:

#include <driver/i2s.h> #include <WiFi.h> #include <WebSocketsClient.h> #define I2S_WS 5 #define I2S_SD 41 #define I2S_BCK 4 #define I2S_PORT I2S_NUM_0 #define SAMPLE_RATE 16000 #define FRAME_MS 20 // 每帧 20ms #define FRAME_SAMPLES (SAMPLE_RATE * FRAME_MS / 1000) // 320 采样点 #define FRAME_BYTES (FRAME_SAMPLES * 2) // 640 字节 PCM16

INMP441 配置为 16kHz、16bit、单声道。为什么选 16kHz 而不是 48kHz?因为 ASR 模型绝大多数按 16kHz 训练,TTS 合成后为了空气感会重采样到 24/44.1k,但上行语音识别 16kHz 就是最优性价比。采样率越高,传输带宽、内存、CPU 全部跟着涨,识别率并不会变好。

采集端我改成了DMA 双缓冲 + FreeRTOS 任务,不再用阻塞式i2s_read一下读一大段。读流程是这样的:

// 采集任务:每 20ms 从 I2S 读 640 字节,加上 8 字节帧头发送 void audioCaptureTask(void* arg) { uint8_t pcmBuf[FRAME_BYTES]; size_t bytesRead = 0; while (true) { esp_err_t err = i2s_read(I2S_PORT, pcmBuf, FRAME_BYTES, &bytesRead, portMAX_DELAY); if (err == ESP_OK && bytesRead == FRAME_BYTES) { uint8_t frame[8 + FRAME_BYTES]; frame[0] = 0x01; // 音频上行 frame[1] = 0x00; // PCM_S16LE frame[2] = (seq >> 8) & 0xFF; // sequence 高字节 frame[3] = seq & 0xFF; frame[4] = (16000 >> 8) & 0xFF; frame[5] = 16000 & 0xFF; frame[6] = (FRAME_BYTES >> 8) & 0xFF; frame[7] = FRAME_BYTES & 0xFF; memcpy(frame + 8, pcmBuf, FRAME_BYTES); ws.sendBIN(frame, 8 + FRAME_BYTES); seq++; } vTaskDelay(pdMS_TO_TICKS(5)); // 让出 CPU } }

i2s_read配合portMAX_DELAY,读不到数据时任务会挂起,不会死占 CPU。DMA 会自动把麦克风数据搬运到内存,CPU 只需要每 20ms 来取一次成品。这一段的重点是:不要让录音、WiFi 发送、UI 刷新三个任务抢同一个 CPU 核。ESP32-S3 虽然是双核,但 Arduino 默认所有任务可能都跑在一个核上。我显式用xTaskCreatePinnedToCore把采集任务固定到 core 0,把 WebSocket 接收/播放任务固定到 core 1,两边互不干扰。

3.2 播放缓冲:怎样避免"说着说着卡壳"

下行音频的坑比上行多。TTS 服务端是流式合成的,合成一段发一段,网络有抖动,ESP32 端如果收到一帧播一帧,播放就一顿一顿的,像磁带卡带。

解决方案是加一个播放 FIFO。我用了一个 64KB 的环形缓冲区,收到下行音频帧就写进去,I2S 播放任务按 20ms 粒度从里面读数据。

static uint8_t playFifo[64 * 1024]; // 环形缓冲 static int playHead = 0, playTail = 0; // WebSocket 事件回调:收到下行音频帧 void onWebSocketEvent(WStype_t type, uint8_t* payload, size_t length) { if (type == WStype_BIN && length > 8 && payload[0] == 0x02) { uint16_t plen = (payload[6] << 8) | payload[7]; fifoWrite(playFifo, payload + 8, plen); // 只管写入 } } // 播放任务 void audioPlaybackTask(void* arg) { uint8_t pcmBuf[FRAME_BYTES]; while (true) { size_t avail = fifoAvailable(); if (avail >= FRAME_BYTES) { fifoRead(pcmBuf, FRAME_BYTES); size_t written = 0; i2s_write(I2S_PORT, pcmBuf, FRAME_BYTES, &written, portMAX_DELAY); } else { // 缓冲不足:补静音,避免 I2S 下溢 int16_t silent[FRAME_SAMPLES] = {0}; i2s_write(I2S_PORT, silent, FRAME_BYTES, &written, portMAX_DELAY); } } }

这里有个关键经验:预填充(pre-buffer)。我会在进入 SPEAKING 状态后,先等播放 FIFO 积累到 80~120ms 的音频再开始播放。代价是多 100ms 左右的首响,换来的是播放过程不断断续续。80ms 是人类感知不到的,而网络抖动 50ms 是常态。这个预填充量我是调参调出来的:填得太少,WiFi 抖动一来就卡;填得太多,打断时尾巴太长。最终 100ms 在两种体验之间最平衡。

下行音频帧之间是有间隔的(TTS 合成需要时间),所以 FIFO 在播完当前数据、下一帧还没到的时候会短暂"饥饿"。这时我选择补静音而不是停下来等——停下来会让 I2S 下溢,喇叭会咔哒一声,补静音至少听起来是平滑的。

3.3 连接管理与断线自愈:onclose 1006 是最常遇到的敌人

ESP32 的 WiFi 链路并不像 PC 上那么稳定。路由器重启、AP 切换、休眠唤醒,都可能导致 TCP 连接静默死亡。我在调试时最常看到的错误就是热词里那个onclose code: 1006——正常关闭码是 1000,1006 表示连接异常断开,对端没有发送 close 帧。

处理策略是三层:

  1. 应用层心跳:每 15 秒发一个 JSON 文本帧{"type":"ping"},服务端回{"type":"pong"}。两分钟没收到 pong,判断链路假死,主动断开重连。
  2. 指数退避重连:断开后第一次 1 秒重连,之后 2 秒、4 秒、8 秒……最大 30 秒,避免服务端恢复时一堆设备同时撞上来。
  3. 状态恢复:重连后设备重新发{"type":"hello","device_id":"...","token":"..."},服务端返回新的会话 ID,一切重新开始。这对连续对话是个损失(正说着话断了就断了),但至少设备不会变成一块砖。

还有一个内存上的坑:WebSocketsClient库在 TLS 模式下握手和加密会吃不少 RAM。ESP32-S3 几百 KB 的 SRAM 看着不小,但 WiFi 协议栈、TLS、I2S DMA 缓冲、播放 FIFO 一叠加就紧张。我的建议是:模块选型要买带 PSRAM 的版本,编译时开启 PSRAM,把大数组(比如播放缓冲)放到堆上而不是全局变量里。全局变量占的是内部 SRAM,堆则可以落到 PSRAM。实测开了 PSRAM 之后,编译后的 free heap 从 60KB 左右涨到 200KB 以上,项目后面的扩展空间就大了。

4. 服务端语音网关:流式 ASR、流式 TTS 与全双工管线

4.1 网关整体结构:一个入口,三路流水

服务端我起了个名字叫"语音网关",用 Golang 写。它本质上是一个 WebSocket 服务端,负责把设备传来的音频流转发给 ASR,把 LLM 的文本转成语音再回传给设备。整体结构分成四块:

ESP32 玩偶 │ WebSocket 二进制帧上行(PCM) ▼ 语音网关(Golang + gorilla/websocket) │ ├──→ 流式 ASR(识别文本流) │ │ │ ▼ │ 对话状态管理器(维护多轮上下文) │ │ │ ▼ │ LLM 流式生成(增量 token) │ │ │ ▼ │ 流式 TTS(合成音频流) │ │ └── WebSocket 二进制帧下行(PCM)→ ESP32

网关入口部分代码大概长这样:

var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } func handleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade error:", err) return } sess := newSession(conn) // 每个连接一个会话对象 go sess.writeLoop() // 下行写循环(单 goroutine 串行写,防止并发写连接) sess.readLoop() // 上行读循环 }

writeLoop是这里容易写错的地方。WebSocket 连接不允许两个 goroutine 同时WriteMessage,会直接concurrent write to websocket connectionpanic。所以下行所有消息(音频帧、控制事件)都通过一个 channel 塞给writeLoop,由它一个 goroutine 统一写。这个模式我在整个服务端一直沿用,后来接多个设备也没出过并发问题。

4.2 从"传整段"到"流式":ASR 和 LLM 的接入方式变了

老链路里 ASR 是一个"给我完整音频,我还你完整文本"的黑盒。而连续对话要求 ASR 边收音频边出文本。我用的方案是流式 ASR + 部分结果(partial result)机制

具体做法:ESP32 端每 20ms 发一帧过来,网关把这些 PCM 直接转给本地部署的流式 ASR 服务(我用的是 sherpa-onnx 的 streaming Paraformer 模型,支持 WebSocket 协议,16kHz 单声道,可以把每个音频 chunk 发过去)。ASR 会返回两种结果:

  • is_final=false的 partial text:中间识别结果,比如"今天天气""今天天气怎么"
  • is_final=true的 final text:端点检测确认后的完整句子,比如"今天天气怎么样"

这两者怎么用,是流式对话体验好坏的胜负手。

我的策略是:final text 才是真正触发 LLM 的信号,partial text 只做展示和打断判断,不喂给 LLM。为什么?因为 LLM 对"今天天气""今天天气怎么"这种中间结果会立刻生成一堆内容,如果 ASR 后面又修正成"今天天气怎么样",LLM 上下文里就混入了错误文本,回复质量直接崩。

但 partial text 也有用:当它和当前正在播放的 TTS 内容比对发现明显是用户在说话、而且不是"嗯""啊"这种语气词时,就立刻触发打断(见第 5 节)。

LLM 接入相对简单,OpenAI 兼容接口的stream=true模式返回增量 token。网关把 final text 拼上历史上下文发出去,然后把 token 流逐个转发给 TTS。这一步的关键是:LLM 不需要等完整回复生成完,第一个 token 出来就可以让 TTS 开始准备。但 TTS 是按句子合成的,所以网关要做一个简单的缓冲切句:把 LLM 输出的文本按逗号、句号、问号切分成短句,每凑齐一个短句就丢给 TTS 合成。

4.3 流式 TTS 回传:按句合成、按帧发送

TTS 我用的是支持流式输出的引擎(Piper TTS 本地跑,16kHz 单声道,输出 WAV PCM),合成结果以 chunk 形式返回。网关收到一段 PCM,就直接打成二进制帧下发,不用等整句结束。

这里我趟过一个坑:TTS 引擎输出的 chunk 大小不可控,可能几毫秒一个,也可能几百毫秒一个。如果原样转发,ESP32 端播放缓冲的压力会非常大。我的做法是服务端做一次"定帧":把 TTS 输出的 PCM 读进一个 buffer,凑满 20ms(640 字节)就封装成一帧下发。这样设备端永远稳定地每 20ms 收到一帧音频,节奏完全均匀。

为了减少下行音频帧的 WebSocket 头开销和 ESP32 中断频率,我后来甚至做了个小优化:把 5 个 20ms 帧拼在一个 WebSocket 帧里发送(100ms 一个包),设备端解包后写进播放 FIFO。实测效果明显,因为 WebSocket 帧头虽然只有 2~14 字节,但 TCP/IP 包在 WiFi 上的发送开销远大于这几十字节,每秒钟少发 40 个包,路由器和我自己的网络稳定度都好了很多。

5. "连续对话"的状态机与打断机制:从一问一答到边听边说

5.1 会话状态机:四个状态管住整个会话

连续对话不是"永远开着麦克风"就行。链路里每个环节能做什么、不能做什么,必须用一个清晰的状态机管起来。我最终用的是四个状态,外加一个打断标志:

状态含义什么在运行能不能接收新语音
IDLE空闲,等待唤醒或人声麦克风采集 + VAD
LISTENING正在听用户说话采集 + 上传 + 流式 ASR
THINKING已拿到用户完整问题,等待 LLM/TTSASR 关闭,LLM/TTS 处理中能(可插话,触发打断)
SPEAKING玩偶正在播放回答播放 FIFO + I2S 输出能(这是关键改动)

和前一个版本最大的区别是在 SPEAKING 状态:上行采集、VAD、ASR 全部保持运行。所以说这是真正的"边听边说"。老版方案播放期间直接i2s_stop关掉了麦克风,那才是"能对话"到"连续对话"最大的鸿沟。

状态迁移逻辑:

  • IDLE → LISTENING:本地 VAD 检测到语音开始(能量超过阈值且持续 150ms)。
  • LISTENING → THINKING:流式 ASR 返回 final text,本地 VAD 检测到语音结束。
  • THINKING → SPEAKING:TTS 首帧到达,开始播放。
  • SPEAKING → IDLE:播放 FIFO 为空,且没有新的用户输入。
  • 任意状态 → 打断触发:用户声音出现且有内容,立即中断当前播放,回到 LISTENING。

5.2 打断(Barge-In)到底怎么实现才不误伤

打断是"连续对话"里最微妙的部分。实现不好会面临两个极端:太灵敏,玩偶自己播报的声音都能把自己打断(自激);太迟钝,用户喊破喉咙也叫不停。

我最终用的是三条件判定:

  1. 本地 VAD 检测到上行音频能量超过阈值。这个阈值要比正常说话高 6dB 左右,过滤掉环境底噪。
  2. 流式 ASR 的 partial text 非空,且不是常见语气词。比如"嗯""啊""呃"这类不算,一旦出现实词("停""等一下""不对"或者用户的完整问题),才认为真的有人在说话。
  3. 上行音频的功率明显高于下行播放剩余量。这一步是为了防自激——MAX98357A 功放的喇叭距离 INMP441 麦克风很近,白菜价的 MEMS 麦克风隔音能力有限,玩偶自己播报的声音会串进麦克风。

三个条件同时满足才触发打断。触发后的动作是:本地立即清空播放 FIFO、调用i2s_zero_dma_buffer停掉播放、向服务端发送{"type":"interrupt","at":1234}。服务端收到后清空 TTS 合成队列,但保留会话上下文,然后等新一轮的 final text。

打断的端到端延迟要求很高。我实测从用户开口到喇叭静音,大约 120~220ms。这个延迟如果超过 300ms,用户会明显感觉"它还在抢话";低于 100ms 又会误伤,因为自己播放的回声还没衰减完。目前 120~220ms 在误伤率和响应速度之间算比较甜点的区间。

5.3 时钟漂移与长时间运行:两个不显眼但致命的坑

连续对话跑 30 分钟以上,我遇到了两个只在长时间运行后才暴露的问题,这里重点说。

时钟漂移。服务端 TTS 的采样时钟和 ESP32 的 I2S 播放时钟并不是同一个晶振,频率存在微小偏差。早期我用完播放 FIFO 就不管了,结果跑 20 分钟后发现播放越来越卡——原因是服务端持续以 16000Hz 的"名义速率"发音频,但 ESP32 实际按 15995Hz 播放,每秒钟多积累了 5 个采样,播放 FIFO 逐渐被撑满,最后溢出丢帧。

解决方法是自适应播放速率:播放任务每次从 FIFO 读取时,根据 FIFO 水位动态跳过或补插一个采样,让水位稳定在中间。具体实现有点繁琐,简化的思路是:

if (fifoAvailable() > HIGH_WATER_MARK) { // 缓冲太多了:每次播放多读 1 个采样并丢弃,等效播放速率提高 0.1% fifoRead(pcmBuf, FRAME_BYTES + 2); // 只写 FRAME_BYTES 到 I2S,丢掉最后 2 字节 } else if (fifoAvailable() < LOW_WATER_MARK) { // 缓冲太少了:每次播放补 1 个采样(复制上一采样的值),等效播放速率降低 fifoRead(pcmBuf, FRAME_BYTES - 2); // 写成 FRAME_BYTES,多出的部分用上一次的值补 }

用这种"水位反馈"的方式,每天只做几次微调,人耳根本听不出音调变化,但 FIFO 能稳稳维持在目标水位。

长时间 ASR 上下文堆积。连续对话 30 分钟,ASR 和 LLM 上下文里可能积累了十几轮对话。我把对话轮次限制在最近 10 轮,超过就丢最老的;LLM 的max_tokens也设了上限,防止一次生成过长导致 TTS 队列爆掉。设备端同样维护一个"最近 5 条 TTS 帧序列号"窗口,防止断线重连后旧帧和新帧混在一起。

6. 实测数据、踩坑清单与最终体验

6.1 重构前后的延迟对比

全部改造完之后,我用同一句话"帮我讲个关于星星的故事"做了 20 次实测(家里 200M 宽带,手机热点备用)。

指标旧版整段式链路新版流式链路
用户停口 → 玩偶发出第一个音节2.1s~5.8s0.9s~1.4s
完整回答播完(80 字左右)需等全部合成,约 2s 后开始播边说边合成,整体时间缩短约 30%
用户插话 → 玩偶闭嘴不支持120ms~220ms
连续对话 30 分钟是否稳定偶尔内存溢出重启稳定,播放 FIFO 水位受控
上行带宽占用(PCM)上传整段文件,峰值大每 20ms 640B,约 32KB/s 恒定
下行带宽占用(Base64 JSON vs 二进制)约 42KB/s约 32KB/s

首响从两三秒压到 1 秒左右,这不是"快了一点点",而是从"能忍"跨到了"自然"的门槛。有人觉得 1 秒还是长,但注意"连续对话"的体验重点不在于首响多快,而在于整体节奏是否像人。现在的链路里,TTS 还在说上一句的尾巴时,ASR 已经识别到用户新的问题并开始处理了,整个对话是重叠推进的——这才是"连续"。

6.2 踩过的坑:从"能跑"到"能长时间跑"的排查实录

这里写几个我印象最深的坑,按排查链路来写,希望能帮你复现排查思路而不是只拿走一个答案。

坑一:播放时断时续,WiFi 信号满格也卡。

一开始我以为是带宽不够,后来抓包发现是 ESP32 端 WebSocket 接收回调里做了太多事——收到音频帧后直接在里面i2s_write,播放和网络处理耦合在一起。WiFi 一抖动,TCP 缓冲堆积,回调积压,播放就等。解决办法就是前面说的:回调里只做入 FIFO,播放任务单独跑,网络处理和播放彻底解耦。排查思路很简单:看是"数据没到"还是"数据到了没播"。在回调里加计数器,如果计数在涨但喇叭没声,问题就在播放端;如果计数不再涨,问题在网络或对端。

坑二:TTS 下行一段时间后声音越来越小,最后断断续续。

这就触发了前面说的时钟漂移。一开始我怀疑是功放过热,后来看 FIFO 水位才发现是服务端发得比设备播得快,FIFO 慢慢溢出,丢帧后声音细节丢失,听起来就像"声音变小"。加上自适应播放速率之后解决。这个坑的隐蔽性在于:半小时以内根本看不出来,只有长时间跑才会暴露。

坑三:喊一声"停"没反应,但拍手却能打断。

这是防自激阈值调太高了。本地 VAD 能量阈值我最初设得偏高,导致平时音量偏轻的用户喊停也触发不了打断。后来改成"能量阈值 + ASR partial text 联合判定",即使能量没达到阈值,只要 ASR 识别出实词也允许打断,解决了误伤和漏触发之间的平衡。这里给后来者一个建议:打断判定一定不要只用能量,一定要结合语义信号,否则体验会非常拧巴。

坑四:OTA 升级后设备一直连不上网关。

最后发现是协议版本号没验证。服务端升级协议后老固件还在用旧帧头,message_type 对不上。后来我在握手 JSON 里强制加"protocol_version":2,不匹配就直接拒绝并返回最新版本号,设备提示用户升级。这是个小问题,但排查了整整一晚上,写在这里提醒各位:任何自定义协议从第一天就要有版本号,越早加越省钱。

6.3 给后来者的最小落地清单

梳理一遍,如果让我回到项目第一天重新做,我会把下面这几件事的顺序排好:

  1. 先把协议定下来:帧头、消息类型、序列号、协议版本,这些是地基,后面想改代价极大。
  2. ESP32 端优先做"采集—发送"和"接收—播放"两条独立任务,中间用 FIFO 解耦,不要在任何回调里做耗时操作。
  3. 服务端下行所有数据收敛到一个writeLoopgoroutine,避免并发写 WebSocket 的 panic。
  4. 打断机制从第一天就设计进状态机,不要等首响优化完再补——它和状态机耦合太深,后期加会牵扯 ASR、TTS、播放缓冲所有模块。
  5. 内存敏感的设备,开发板一定选带 PSRAM 的型号,并且从一开始就把大数组放堆上。
  6. 长时间稳定性测试至少连续跑 1 小时,半小时以内的测试发现不了时钟漂移和 FIFO 水位问题。

这套链路跑通之后,玩偶的交互体验变化是质变的。以前你对着它说话,感觉是在"操作一台设备";现在更像是在和一个人通话——你说话时它安静听,它说话时你可以随时插嘴,话题可以连续几轮不断线。如果把这段工程经验抽象一下,本质上就是一句话:AI 硬件交互要走向"接近真人",瓶颈从来不在单点识别或生成的准确率,而在整条链路的实时性和协同方式。后续我还想往这条链路上加本地唤醒词、声纹识别,以及基于 Opus 的窄带压缩来降低上行流量,方向上已经清晰了,关键的地基这次算打稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询