免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

ESP32 AI玩偶全双工语音链路重构:WebSocket流式对话实战

ESP32 AI玩偶全双工语音链路重构:WebSocket流式对话实战 说实话这个项目最初的起点很简单我想做一个能放在桌上的 AI 陪伴玩偶用 ESP32 驱动能听懂人话、能开口回答。第一版其实二天就跑通了——麦克风录一段 16kHz 音频HTTP 上传服务端 ASR 识别丢给大模型再把 TTS 生成的整段 mp3 拉回来播放。Demo 演示时大家都说不错能对话了。但等你真的把它摆在桌面聊上十分钟问题就全出来了说完了要干等两三秒才听到回应玩偶正在播报时你喊它名字完全没反应连续聊几轮之后它像一台反应迟钝的录音机根本配不上陪伴这个词。于是我把整个音频链路推倒重做核心思路就一句话把原来录音—上传—识别—生成—下载—播放的串行流程改成建立在 WebSocket 二进制帧通道上的全双工流式管线。这篇文章会把改完之后的完整方案、二进制协议设计、ESP32 端和服务端的改造细节、实测数据以及我踩过的坑全部写出来。如果你也在做类似的 AI 硬件、语音玩具、桌面机器人这篇应该能帮你少走不少弯路。1. 能对话背后的三块短板为什么玩偶聊几句就冷场1.1 原始链路到底是什么样第一版方案非常典型很多 ESP32 语音项目都是这么起步的所以我先把这条链路画出来玩偶端持续录音本地用能量阈值做简单 VAD端点检测检测到人声结束就把整段音频保存下来。整段 PCM/WAV 通过 HTTP POST 传给服务端。服务端先跑一遍离线 ASR拿到完整文本。文本交给 LLM等 LLM 生成完整回复。回复文本交给 TTS合成完整音频文件。玩偶下载音频开始播放。按下说话键到玩偶开口整条链路是严格串行的。理想状态下每个环节 300ms加起来也有 1.5 秒实际跑起来公共网络、ASR 排队、TTS 合成大段音频经常干等 3 到 5 秒。我实测过一轮今天天气怎么样的完整对话分段耗时大概是这样链路环节耗时录音结束 HTTP 上传120ms~400ms离线 ASR 整段识别300ms~800msLLM 完整生成800ms~2500msTTS 合成长音频500ms~1500ms下载 播放300ms~600ms总计2.1s~5.8s这不是延迟高的问题是体验逻辑本身就是错的。人类聊天不是先录一段话发给对方、等对方把整段回答录好再播给你听而是边说边听、边听边想、随时可以插嘴。1.2 三个体验杀手串行、半双工、无状态第一版的问题归纳起来就三个串行等待放大了每一步的延迟。上一环不结束下一环就不能开始。ASR 明明已经有了前几秒的文本却不能先丢给 LLMLLM 第一个 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_S16LE0x01 OPUS预留扩展2-32sequence16 位递增序号用于丢包检测和排序4-52sample_rate采样率如 160006-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 字节 PCM16INMP441 配置为 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——正常关闭码是 10001006 表示连接异常断开对端没有发送 close 帧。处理策略是三层应用层心跳每 15 秒发一个 JSON 文本帧{type:ping}服务端回{type:pong}。两分钟没收到 pong判断链路假死主动断开重连。指数退避重连断开后第一次 1 秒重连之后 2 秒、4 秒、8 秒……最大 30 秒避免服务端恢复时一堆设备同时撞上来。状态恢复重连后设备重新发{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_finalfalse的 partial text中间识别结果比如今天天气今天天气怎么is_finaltrue的 final text端点检测确认后的完整句子比如今天天气怎么样这两者怎么用是流式对话体验好坏的胜负手。我的策略是final text 才是真正触发 LLM 的信号partial text 只做展示和打断判断不喂给 LLM。为什么因为 LLM 对今天天气今天天气怎么这种中间结果会立刻生成一堆内容如果 ASR 后面又修正成今天天气怎么样LLM 上下文里就混入了错误文本回复质量直接崩。但 partial text 也有用当它和当前正在播放的 TTS 内容比对发现明显是用户在说话、而且不是嗯啊这种语气词时就立刻触发打断见第 5 节。LLM 接入相对简单OpenAI 兼容接口的streamtrue模式返回增量 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凑满 20ms640 字节就封装成一帧下发。这样设备端永远稳定地每 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 → SPEAKINGTTS 首帧到达开始播放。SPEAKING → IDLE播放 FIFO 为空且没有新的用户输入。任意状态 → 打断触发用户声音出现且有内容立即中断当前播放回到 LISTENING。5.2 打断Barge-In到底怎么实现才不误伤打断是连续对话里最微妙的部分。实现不好会面临两个极端太灵敏玩偶自己播报的声音都能把自己打断自激太迟钝用户喊破喉咙也叫不停。我最终用的是三条件判定本地 VAD 检测到上行音频能量超过阈值。这个阈值要比正常说话高 6dB 左右过滤掉环境底噪。流式 ASR 的 partial text 非空且不是常见语气词。比如嗯啊呃这类不算一旦出现实词停等一下不对或者用户的完整问题才认为真的有人在说话。上行音频的功率明显高于下行播放剩余量。这一步是为了防自激——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 给后来者的最小落地清单梳理一遍如果让我回到项目第一天重新做我会把下面这几件事的顺序排好先把协议定下来帧头、消息类型、序列号、协议版本这些是地基后面想改代价极大。ESP32 端优先做采集—发送和接收—播放两条独立任务中间用 FIFO 解耦不要在任何回调里做耗时操作。服务端下行所有数据收敛到一个writeLoopgoroutine避免并发写 WebSocket 的 panic。打断机制从第一天就设计进状态机不要等首响优化完再补——它和状态机耦合太深后期加会牵扯 ASR、TTS、播放缓冲所有模块。内存敏感的设备开发板一定选带 PSRAM 的型号并且从一开始就把大数组放堆上。长时间稳定性测试至少连续跑 1 小时半小时以内的测试发现不了时钟漂移和 FIFO 水位问题。这套链路跑通之后玩偶的交互体验变化是质变的。以前你对着它说话感觉是在操作一台设备现在更像是在和一个人通话——你说话时它安静听它说话时你可以随时插嘴话题可以连续几轮不断线。如果把这段工程经验抽象一下本质上就是一句话AI 硬件交互要走向接近真人瓶颈从来不在单点识别或生成的准确率而在整条链路的实时性和协同方式。后续我还想往这条链路上加本地唤醒词、声纹识别以及基于 Opus 的窄带压缩来降低上行流量方向上已经清晰了关键的地基这次算打稳了。
返回列表