资讯详情

资讯详情

ESP32玩偶音频链路重构:从HTTP到WebSocket实现连续对话

1. 从“能对话”到“连续对话”一次音频链路的底层重构先聊聊我最近一直在折腾的一个项目给手里的 ESP32 AI 玩偶做了一次音频链路的整体重构。玩偶本身早就能“对话”了——按下按钮录一句话上传到服务器等 ASR 识别、LLM 回复、TTS 合成再把音频拿回来播放。这套流程跑通很容易网上现成的例程一大把。但真正用起来你会发现这种“按下说话、松手等待、听完再按”的回合制交互跟市面上那些智能音箱的体验差了不止一个量级。用户要的是“连续对话”——我这边说着话它可以随时打断它那边讲着答案我可以直接插嘴整个过程中语音链路始终保持在线而不是每次都重新建立连接。我这次重构的核心就是把原来基于 HTTP 短连接 JSON 文本的音频上传下载链路彻底换成WebSocket 长连接 二进制音频帧的方案。这个改动听起来不复杂真正落地的时候涉及的细节非常多协议设计、音频编码格式选型、ESP32 端的内存与实时性权衡、服务端的流式处理编排、异常断连的恢复策略每一块都有不少坑。这篇博文就把我完整的思路、踩坑记录和最终方案整理出来给正在做 ESP32 语音交互、或者准备在自己项目里用 WebSocket 传音频的朋友一个参考。不管你是刚开始接触嵌入式语音还是已经在做类似的产品原型这篇文章里的协议设计思路、参数计算方法和问题排查经验应该都能帮上忙。2. 为什么必须重建链路旧方案的瓶颈在哪2.1 原来“请求-响应”模式的问题根源先说清楚旧方案到底卡在哪。早期玩偶的交互流程是这样的ESP32 录音结束后把音频数据 base64 编码塞进 JSON通过 HTTP POST 发给服务器服务器跑完识别、生成回复、合成语音返回另一个 JSON里面同样是 base64 编码的音频ESP32 拿到数据解码播放。这套链路单独看每一步都没问题但放到真实场景里体验瓶颈非常明显。第一是延迟叠加。每次交互都要重新建立 TCP 连接握手、HTTP 头携带、服务器处理完再响应光网络往返就有好几次。实测在普通 WiFi 环境下从按下录音键到听到回复最顺利也要 2 秒以上网络稍有波动就奔着 3 秒去了。第二是交互形态受限。HTTP 的请求-响应模型天然是“一问一答”的服务端没办法在识别到一半、或者还没收到完整音频时就开始处理更谈不上主动推送。第三是资源浪费。每次上传下载都要重新分配缓冲区、建立连接、释放连接ESP32 这种资源紧张的芯片能明显感觉到卡顿和内存碎片。WebSocket 长连接天然就是为这种场景设计的。它只需要建立一次连接之后服务器和客户端可以随时互发数据。这给我们带来的最关键能力是双向同时传输。用户的语音可以持续上行同时服务器合成的语音可以持续下行两边不用排队等待。这正是“连续对话”的基础。2.2 为什么音频必须用二进制帧确定了用 WebSocket下一个问题是用什么格式传数据。很多初学者第一反应是继续用 JSON把音频数据 base64 编码后放到 JSON 字段里。这个方案能用但非常不值得。一个 5 秒的 16kHz 16bit 单声道 PCM 音频裸数据大小是 16000 × 2 × 5 160000 字节base64 编码后膨胀成约 213440 字节再加上 JSON 的键值对、引号、大括号实际传输量还要更多。而如果用二进制帧直接发裸 PCM 或压缩后的 Opus 数据5 秒的 Opus 音频通常只有几千字节。在 ESP32 这种 WiFi 吞吐能力有限实际应用通常只有几 Mbps的设备上这个体积差距直接决定了你是“流畅对话”还是“一直转圈等待”。更重要的是WebSocket 协议本身支持二进制帧Binary Frame数据不会被任何文本编码污染可以直接以字节流的形式送达应用层。这样我们可以自定义帧头把音频数据、控制指令、文本信息全部封装在同一个连接里通过帧类型字段区分。整个设计一下就干净了。下图是我的核心思路用 WebSocket 承载一个自定义的轻量二进制协议音频帧走实时通道文本控制帧走信令通道彻底解决“连续对话”的传输问题。3. 音频链路整体设计方案协议、编码与帧结构3.1 音频编码选型Opus 还是 PCM这是重构时最早需要拍板的问题。方案主要有两个直接传 PCM 裸数据或者用 Opus 压缩后再传。PCM 的好处是零编码延迟、零 CPU 开销ESP32 端采集到数据就能直接塞进 WebSocket 帧发出去不占用宝贵的计算资源。坏处是体积大。16kHz 采样率、16bit 位宽、单声道数据速率就是 16k × 2 32kB/s。如果对话持续时间长这个数据量对上行带宽和服务器存储都是压力。Opus 的好处是压缩率惊人特别适合语音。用 Opus 编码 16kHz 单声道语音码率只要 16-24kbps 就能获得很不错的音质对比 PCM 的 256kbps体积缩小到原来的十分之一甚至更少。坏处是编码需要 CPU 开销ESP32 上跑 Opus 编码器会占用一部分 CPU 和内存资源。不过实测下来使用 ESP32-S3 的 240MHz 主频跑 16kHz 的 Opus 编码CPU 占用约 15%-20%完全可以接受。我的最终选择是上行用 Opus下行也优先 Opus。原因很直接减少 WiFi 信道占用。ESP32 的 Wi-Fi 在同时收发时本身性能就会下降音频数据量越小链路越稳定整体延迟反而更低。这里有一个很多教程没强调的点延迟不只是编解码时间更关键的是传输时间。数据量大了WiFi 分包多、重传概率高任何一次 TCP 重传都可能给交互增加几百毫秒延迟。如果不想引入 Opus 依赖PCM 方案也能跑通只是要注意服务器的下行也尽量分块发送避免 ESP32 内存扛不住一次性的大数据块。我是建议直接上 Opus省心很多。ESP-IDF 和 Arduino 环境都有现成的 Opus 移植库编译进去也不复杂。3.2 帧结构与协议定义设计音频链路时我们没有直接用现成的 WebRTC 或者 SIP 这些语音传输协议因为对 ESP32 来说它们太重了。我们只需要一个简单的、足够表达“音频上行”和“音频下行”的协议栈。我定义了一套非常轻量级的二进制帧协议帧头固定 12 字节载荷是音频数据| Byte 0-3 | Byte 4-7 | Byte 8-9 | Byte 10-11 | Byte 12... | | Magic Number | Sequence Number| Frame Type | Payload Len | Payload | | 0xA1 0xB2 | 自增序号 | 0x01等 | 小端 uint16 | 音频帧 |这里解释一下几个关键字段的作用Magic Number用于对端快速识别这是一个有效帧。如果乱序或者解包错误可以直接丢弃避免把垃圾数据当成音频播放出来。Sequence Number自增序号一来用于检测丢包二来在服务端做音频拼接时保证顺序。虽然 WebSocket 本身基于 TCP 保证了传输顺序但应用层的多路复用比如同时传音频和文本会破坏顺序这个序号就是保险。Frame Type区分当前帧是音频数据还是控制命令。我们定义了 0x01 表示音频帧上行、下行共用0x02 表示控制命令0x03 表示心跳。控制命令里又细分了“开始说话”“停止说话”“打断当前播放”“服务端开始合成回复”等子类型。Payload Length小端无符号 16 位整数表示后面跟着的音频数据的字节数。为什么用 uint16因为 Opus 一帧最大也就几百字节而 PCM 如果是 20ms、16kHz、16bit 单声道也就 640 字节远远小于 65535完全够用还能省两个字节。设计任何协议时都要这样精打细算嵌入式系统上没有浪费的余地。这里要特别说一个部分帧头的长度字段设置多大、音频帧每帧时长多少毫秒这两个参数决定了整个系统的实时性和冗余性。下面给出我的计算过程。3.3 帧长与延迟预算的计算先定音频帧时长。我在 ESP32 端设置为每次从麦克风读取 20ms 的音频数据16kHz 采样率下就是 320 个采样点。选 20ms 是基于两个考量Opus 编码器推荐帧长是 20ms这个档位在延迟和压缩率之间最均衡。10ms 帧压缩率会下降60ms 帧延迟又太高。20ms 的音频数据量小打包进 WebSocket 二进制帧后网络传输时间可以忽略不计。而如果等到积累了 200ms 音频再一次性发送用户说第一个字到服务器听到之间的等待时间就会多出 180ms这在实时对话里是能被感知到的卡顿。音频数据产生速率上行 16000 采样率 × 2 字节 / 20ms 1600 字节每帧。经过 Opus 编码后典型码率 24kbps也就是每帧约 60 字节24kbps × 20ms / 8 60 字节。加上 12 字节帧头一帧总大小约 72 字节。这个尺寸对于 WebSocket 帧来说非常轻巧WiFi 网络可以轻松承载。服务端下行音频播放帧我们也定为 20ms 一个 Opus 帧。ESP32 端需要一个播放缓冲区来平滑网络抖动。我实测下来600ms 的播放缓冲是“流畅”和“延迟”之间比较好的平衡点。600ms 意味着缓冲区里有 30 个 20ms 的音频帧。太高会让人觉得回复迟钝太低一遇到网络波动就容易出现“卡顿”或者“撕裂”。这个参数我改了好几次才定下来你可以根据自己的网络环境做调整。注意600ms 缓冲区不是指播放延迟 600ms而是指网络数据到达的抖动容限。服务端在生成第一帧音频后就可以立刻下推此时 ESP32 端边收边放整体感受依然很快。如果服务端迟迟不推缓冲区空了才会感到停顿。4. ESP32 端实操采集、编码、发送与播放4.1 麦克风采集与 I2S 配置ESP32 的音频采集一般走 I2SInter-IC Sound接口。我用的是 INMP441 这款常见的数字麦克风通过 I2S 接口直接输出数字音频省去模拟前端的电路设计对 DIY 项目非常友好。关键配置参数如下采样率sample_rate16000 Hz语音识别场景的常用采样率。位深bits_per_sample16 bit足够的动态范围。声道数channels1单声道节省带宽和内存。ESP-IDF 中配置 I2S 接收的代码大致是i2s_config_t i2s_config { .mode 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 64, };这里有几个值得注意的细节。.dma_buf_count和.dma_buf_len代表 DMA直接内存访问缓冲区的数量和长度。缓冲太大音频数据从麦克风到 CPU 的延迟变高缓冲太小高负载下容易导致 DMA 溢出丢失音频数据。我实测 8 个缓冲区 × 64 帧是一个稳定值能保证采集不丢数据。另外INMP441 是数字麦克风用的是 I2S 的 PDM 模式还是标准模式要仔细看数据手册。有些型号是 PDM 输出需要 ESP32 内部做抽取滤波这在配置上略有不同。我用的这款是标准 I2S 输出省了这一步。4.2 WebSocket 客户端与二进制帧发送ESP32 端 WebSocket 客户端的选择取决于你用哪个开发框架。Arduino 环境下可以用WebSocketsClient库ESP-IDF 环境下则直接使用官方组件esp_websocket_client。我这次用的是 ESP-IDF 环境原生组件的稳定性和内存管理都更可控。连接服务器的代码非常简单esp_websocket_client_config_t ws_cfg { .uri ws://your_server_ip:8080/ws/audio, .buffer_size 4096, .reconnect_timeout_ms 5000, }; esp_websocket_client_init(ws_cfg); esp_websocket_client_start(ws_cfg);关键在发送二进制帧。库里发送文本消息有现成接口但发送二进制帧需要明确指定opaque标志esp_websocket_client_send_bin(ws_client, frame_data, frame_len, pdTRUE);这里的pdTRUE表示这个数据是二进制帧而非文本帧。如果你在 Arduino 环境下用WebSocketsClient发送二进制帧则调用webSocket.sendBIN(server_id, data, len);我一开始不小心用了发送文本帧的接口发音频数据结果服务器收到的数据被解码为字符串十六进制的音频字节被强制转成了 UTF-8数据面目全非。这是很容易踩的一个坑务必确认发送 API 的二进制语义。发送的线程逻辑用 FreeRTOS 的一个任务来驱动每次读取 20ms PCM 数据进行 Opus 编码然后组装成上面定义的帧格式最后发送。整个链路从采集到发送的延迟需要控制在 40ms 以内实测最终约 30ms符合预期。4.3 下行音频的接收与播放下行方向的实现比上行要复杂一些核心原因是“实时性”和“抗抖动”不可兼得。服务器合成的音频以 Opus 帧格式持续下推ESP32 的 WebSocket 事件回调里收到二进制数据后需要尽快解码并写入音频输出。我使用的播放链路是WebSocket 回调 → 按帧解析 → 解码 PCM → 写入播放缓冲区 → I2S 播放。这里要重点说一下播放缓冲区的设计。不用环形队列而是用一个固定大小的 FIFO 缓冲区容量可以撑住 600ms 的音频数据。为什么不用简单粗暴的“收到一帧播一帧”因为网络延迟是波动的服务器发出的音频帧到达 ESP32 的时间并不均匀。如果每收到一帧就立刻播放音频流会断断续续像“卡碟”。缓冲区的作用是吸收抖动平滑输出。缓存管理和播放节奏控制的逻辑void audio_task(void *arg) { while (1) { int available esp_audio_fifo_get_available(playback_fifo); if (available 0) { // 从FIFO中取出一帧解码后的PCM数据写入I2S i2s_write(I2S_NUM_0, pcm_data, pcm_len, bytes_written, portMAX_DELAY); } else { // 缓冲区空了播放静音 i2s_write(I2S_NUM_0, silence, 320, bytes_written, portMAX_DELAY); } vTaskDelay(10 / portTICK_PERIOD_MS); } }这个循环里有一个重要细节当缓冲区数据不足时播放静音而不是让 I2S 总线直接停顿。直接停掉 I2S 会导致输出设备扬声器出现明显的咔哒声或爆音而播放静音填充则能保持平稳输出。这个细节我在调音时花了好长时间才注意到。如果遇到底层链路因为异常而断开的情况需要处理 WebSocket 断线事件。默认情况下esp_websocket_client会自动重连但重连后应用层不能继续沿用旧的会话状态。我实现的方式是在重连成功后向服务器发一个控制帧告知“音频同步点重置”双方清空缓冲区重新开始会话。这样即使电波环境不好断了线恢复后也能迅速回到对话状态而不是卡死。5. 服务端流式处理ASR、LLM、TTS 的编排5.1 服务端整体架构与选型服务端是整个链路的“大脑”。ESP32 只能做音频采集、编码和播放识别、理解、合成这些大事都必须在服务器完成。我用 Go 语言实现了一个 WebSocket 网关服务后面接了 ASR、LLM大语言模型、TTS 三个模块。之所以选 Go是因为它的并发模型非常适合处理大量长连接每个玩偶维持一个 TCP 连接goroutine 的数量可以非常可观资源占用却很小。服务端接收到 ESP32 的二进制帧时通过帧头的 Frame Type 分派处理。如果是音频帧把它转交给 ASR 引擎进行流式识别如果是控制帧按照控制指令进行状态切换。要说明的是完整的服务端代码量非常大尤其是流式识别与流式合成的拼接逻辑。这里分享几个核心节点的设计思路5.2 上行路径从 ESP32 到 ASR 的流式识别流式识别Streaming ASR是连续对话的关键。传统做法是等用户说完整句话再上传一份完整的音频文件去识别这会产生很大的“等待感”。流式识别的核心在于ESP32 每发送一帧 20ms 的音频服务端就把它喂给 ASR 引擎ASR 引擎持续输出“中间结果”。当检测到用户停顿超过某个阈值我设置的是 600ms服务端就认为一句话说完了得到完整识别文本随即触发 LLM。使用 WebSocket 做这一步的好处非常明显数据是流式到达的服务端不必等用户说完才拿到全部音频而是可以边收边识别。识别进程与 WebSocket 接收进程是两个独立的 goroutine通过 channel 传递音频帧数据天然就是流式处理的理想模型。这里有一个体验上的细节当 ASR 识别出一句完整的话之后我们需要立刻把这个状态反馈给 ESP32比如让它熄灭“聆听”指示灯表示进入“思考”状态。这个反馈可以封装成一个控制帧Frame Type 0x02发送下来。ESP32 收到控制帧后再切换指示灯状态整个过程流畅自然不会出现用户已经说完了但玩偶还在傻等的情况。5.3 下行路径LLM 回复与流式 TTS 合成下行路径的处理是“连续对话”体验的另一半。当 ASR 识别出用户完整的一句话后服务端会把这个文本发给 LLMLLM 流式输出回复文本。注意这里我没有等 LLM 输出完整文本后再去 TTS因为大模型生成一段回复往往需要 1-3 秒如果完整等完再合成语音用户会觉得玩偶反应太慢。我采用的“分段 TTS”策略是当 LLM 输出的一段文本达到句号、感叹号、问号等断句点或者累积长度超过一定阈值时立即把这段文本送入 TTS 合成合成完的音频帧立刻通过 WebSocket 推给 ESP32。这样用户听到玩偶开始说话的时间可以提前很多心理感知上玩偶的“反应速度”变快了。下行音频帧的推流代码我用 Go 的 WebSocket 库实现func pushAudioFrame(conn *websocket.Conn, frame *AudioFrame) error { data : make([]byte, 12 len(frame.Payload)) // 填充帧头 binary.BigEndian.PutUint32(data[0:4], frame.Sequence) data[4] frame.Type // 拷贝音频载荷 copy(data[12:], frame.Payload) return conn.WriteMessage(websocket.BinaryMessage, data) }ESP32 端收到这个二进制 WebSocket 消息后按帧头解析、解码、播放。由于下行音频帧与上行音频帧使用的是同一个 WebSocket 连接天然实现了全双工通信——用户在上行说话的同时玩偶可以在下行播放回复音频不用等对方“说完”再“说”。5.4 “打断”功能的实现连续对话和传统一问一答最大的区别就是“打断”。用户听玩偶说话说到一半突然想插话玩偶必须能瞬间停止播放并且开始聆听用户新的输入。这个功能在 WebSocket 链路下实现起来很直观ESP32 始终开麦克风采集即使在播放回复时也不例外。在播放过程中如果采集到的音频幅度超过某个阈值声音足够大ESP32 判断用户正在说话立刻执行打断暂停 I2S 播放清空播放缓冲区向服务端发送一个控制帧内容为“打断”重新开始上行发送音频帧。服务端收到“打断”控制帧后立刻中断当前 TTS 合成任务停止继续下发音频帧并通知 LLM 当前对话上下文需要更新。这个机制实现起来不复杂但要注意 VAD 阈值要调好。阈值太高用户说话打断了没反应阈值太低环境噪音一响就误触发打断。我实测下来用 INMP441 在普通家庭环境阈值设为原始音频样本绝对值的平均值超过约 80016bit 采样满量程 32767比较合适。当然这个值强烈依赖麦克风灵敏度和环境需要实测微调。6. 实测数据与问题排查把坑填完再发布6.1 链路重构后的性能对比改造完成后我对整个系统做了多轮实测用同一段 WiFi 网络环境对比了旧方案和新方案的延迟。需要说明的是以下数据来自我自己的测试场景ESP32-S3 开发板 家里 100M 宽带路由器 部署在云服务器的服务端不同网络环境下数字会有差异但相对趋势是一致的。指标旧方案HTTP JSON新方案WebSocket 二进制连接建立耗时每次交互约 150-300ms仅首次约 50ms之后 0ms长连接保持上行音频传输耗时5秒音频约 1.2 秒含 base64 和 HTTP 开销约 0.3 秒边采集边发送首句回复延迟用户说完到听到回复约 2.5-3.5 秒约 1.0-1.5 秒连续对话支持不支持必须逐轮请求支持全双工 打断单次交互上行数据量5秒音频约 350KB含 base64 膨胀和 HTTP 头约 15KBOpus 压缩后CPU 占用ESP32-S3约 40%大量时间在等待网络约 30%一部分被 Opus 编码占用最让我满意的是首句回复延迟从 3 秒级别降到了 1 秒级别。这个差距在人机交互中非常关键——研究表明超过 2 秒的延迟会让人明显感到“卡”而 1 秒左右基本达到了智能音箱的体验水平。6.2 常见问题与排查技巧实录这部分把我实操过程中踩过的最有代表性的坑整理成一张速查表方便大家对照排查现象可能原因排查方法与解决方案WebSocket 连接总是断开错误码 1006服务器没回心跳包或者 ESP32 内存不足导致连接被系统回收在 WebSocket 协议上自行加应用层心跳每 10 秒发一个 0x03 控制帧并在服务器端回复同时检查 ESP32 的剩余堆内存确保最低不低于 40KB听到的声音有“撕裂感”或“卡顿”播放缓冲区太小或者网络抖动过大增大播放 FIFO 缓冲区到 600ms 以上同时在服务器端降低音频推送频率比如每 40ms 推一帧而不是每 20ms 推一帧发出的音频服务器识别出的文字断断续续上行音频帧没有携带正确的 Sequence Number或者服务端处理顺序错乱检查帧头解析的字节序特别是 Go 服务端和 ESP32 的 C 代码要统一字节序我都用大端另外确认 ESP32 的 I2S 没有溢出丢数据玩偶播放回复时无法被打断麦克风采集停止在 VAD 检测的逻辑里或者是打断控制帧没有被服务端处理确保播放过程中 I2S 接收仍然开启打断控制帧要使用独立的 Frame Type服务端要识别并立刻中断 TTS 任务ESP32 内存不足运行几小时后崩溃WebSocket 接收缓冲区或者解码缓冲区泄漏用heap_caps_get_free_size(MALLOC_CAP_8BIT)监控堆内存确认没有在循环里反复申请释放大块内存Opus 编码器句柄和解码器句柄只初始化一次WiFi 信号强但 WebSocket 数据吞吐很低ESP32 的 TCP 发送缓冲区设置过小或者天线靠近金属外壳导致实际吞吐下降在esp_websocket_client_config_t中设置buffer_size为 4096 以上确认 ESP32 的天线区域不要被外壳遮挡我还要单独说一个很多初学者都会忽略的问题WiFi 省电模式。ESP32 默认开了 WiFi modem sleep 功能这会导致 WebSocket 的数据包延迟显著增加。实测同样的代码关闭 modem sleep 之后音频帧从采集到 ESP32 发出延迟从平均 80ms 降到 30ms 左右。在连续语音这种低延迟场景里这个差异非常明显。配置方式是一行代码esp_wifi_set_ps(WIFI_PS_NONE);代价是功耗增大对于插电使用的玩偶可以接受如果是电池供电场景需要根据产品定位权衡。6.3 一段现场调试实录分享一个真实调试过程。有一次我改了下行音频逻辑后发现玩偶说话断断续续用户说前几个字能听到后面的内容全丢了。排查了很久最后发现是服务端 TTS 模块在生成完语音后一次性把整段音频切成了几百个 20ms 的帧全部塞给了 WebSocket 网关网关在极短的时间内把这几百帧连续推给了 ESP32。ESP32 的 Wi-Fi 吞吐受限于 TCP 窗口和路由器转发能力大量数据包涌进来时发生拥塞丢包重传严重而我的播放 FIFO 缓冲区又比较小最终导致播完队列就没后续数据了。解决方法是给服务端下行加一个节流器Token Bucket确保每 20ms 最多推 1-2 个音频帧让下发速度和播放速度基本匹配。改完之后连续播放几十秒语音再也没有出现过卡断。这个案例给我一个很大的启发实时音频链路的问题往往不在你的代码逻辑是否正确而在于数据的到达速率是否和消费速率匹配。WebSocket 底层是 TCP本身保证可靠有序传输但如果生产者的发送速度超过消费者的消费速度可靠传输反而会导致延迟无限增大、缓冲堆积、最终体验崩溃。所以做这类系统流量控制是必不可少的。7. 优化方向与后续计划这次重构解决了“连续对话”的传输基础但整个系统还远没到完美的程度。回顾整个技术方案目前最值得优化的方向有三个一是下行的 Opus 解码可以在 ESP32 的 I2S DMA 缓冲区中直接排队播放减少一次 PCM 数据拷贝进一步降低 CPU 占用。当前方案中 Opus 解码后的 PCM 先写入播放 FIFO再由音频任务读出来写入 I2S中间有一次多余的拷贝。如果 DMA 缓冲区设计得足够大可以直接将解码结果写入 DMA 缓冲区省去中间环节。二是加语音活动检测VAD而不是纯靠能量阈值来触发打断。能量阈值在安静环境里表现不错但在背景噪音大的地方会频繁误触发。用 ESP32 上跑一个极简的 VAD 模型或者用 Opus 自带的 VAD 功能效果会好很多。三是服务端做多会话状态管理。目前一个 WebSocket 连接对应一个对话会话但服务器没有区分“不同的用户”。如果未来要支持多台玩偶同时在线、甚至同一台玩偶绑定不同的用户画像服务端需要引入会话管理和认证机制。从“能对话”到“连续对话”表面上看只是把 HTTP 换成了 WebSocket、把 JSON 换成了二进制实际上涉及的是对整个交互模型的重新思考数据怎么流动、状态怎么切换、延迟怎么优化、异常怎么恢复。这个重构做完后我对“实时语音链路”这件事的理解深了不少。如果你也在做类似的项目希望这篇记录能帮你少走一些弯路。特别是那套二进制帧协议的设计、播放缓冲区的取舍、服务端流式编排的思路这些都是从实际项目中踩坑踩出来的经验。最后再分享一个我自己的体会做嵌入式语音交互别老想着“用最强的方案”而要想着“用最合适的方案”。ESP32 资源有限但它并不缺算力和内存到“什么都不能跑”的程度关键是把每一份资源都用在刀刃上。把音频数据的体积压到最小、把网络链路的状态管理到最简、把缓冲区的大小调到最合适这样你手里的 ESP32 就能爆发出远超它硬件规格的交互潜力。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →