直接说结论:把 AI 玩偶从“能对话”做成“连续对话”,关键不在模型选得多大,而在音频链路的实时性设计。我这次用 ESP32 搭配 WebSocket 二进制帧重构了整个音频通路,把原来一拍一停的按键式对讲,改成了可以随时打断、连续轮流的自然会话体验。这篇文章就把完整方案拆开讲透,覆盖链路设计、协议帧格式、数据处理、时序控制和踩坑记录,给做智能玩具、桌面机器人、语音交互硬件的朋友一个可以直接复用的参考。
1. 为什么“能对话”和“连续对话”是两码事
很多人觉得,玩偶能对话不就是“录音上传→拿回复→播放”三步吗?确实,很多早期方案就是这么干的,也是市面上不少“智能玩具”的真实实现。但这种方案本质上还是 HTTP 短连接思维:一段音频录完,POST 上去,等服务器把整段 TTS 结果生成完、合成完、返回来,再开始播放。用户听到的永远是“你说完、它停顿几秒、然后机械回答”的节奏,根本没有对话感。体验好的产品,底层一定不是这个逻辑。
1.1 传统方案到底卡在哪
我拆过几款市面上的 AI 玩偶和语音助手设备,交互链路基本都是这样:
- 用户按下按键,开始录音
- 录完松开,整段音频上传到服务器
- 服务端跑完 ASR(语音识别)→ LLM(大模型)→ TTS(语音合成),把完整音频文件返回
- 设备下载完整音频后播放
这个链路有三个致命问题。第一是延迟不可控,一次完整请求下来,顺利的话 2 到 3 秒,服务端负载一高直接奔 5 秒去。第二是完全无法打断,播放过程中用户说话没有任何途径让设备停下来,只能傻等。第三是每轮都要重新建立连接、上传、下载,网络开销巨大,对电量和流量都是灾难。
有朋友会说,那我不按键,用 VAD(语音活动检测)实现“唤醒→说话→自动结束→回复”行不行?行,但体验上只是把“手动按键”换成了“自动按键”,本质上还是请求-响应模式,用户必须严格遵循“说完停一下→等待→再继续说”的节奏,真实对话里的人自然会觉得它很“笨”。
1.2 “连续对话”的真实需求拆解
真正自然的连续对话,至少要满足四个条件:
- 全双工通路:说话和接收回复可以同时进行,至少要做到“边说边听”
- 打断能力:AI 在播报时,用户一开口,设备立刻能通过 VAD 检测到,暂停播放、重新采集指令
- 流式返回:大模型回复不用等完整文本生成完,TTS 边合成边下发,设备边收边播
- 低延迟:从用户说完到 AI 开口,控制在 500ms 到 800ms 以内,人类对话的节奏感才能建立起来
这些条件,用 HTTP 那一套很难满足,尤其是“低延迟”和“打断能力”这两条,从架构上就要换成长连接、双向实时通信的链路。WebSocket 几乎是为这个场景量身定的,它天然支持全双工、支持二进制帧传输、支持服务端主动推送,迁移成本也不高。
2. 为什么选 WebSocket 二进制帧而不是 JSON 文本
这一步是架构落地最核心的决策点。我第一版原型其实用的是 JSON 文本传输,把 PCM 音频 base64 编码后塞进 JSON 字段里。调通后发现两个问题:一是虽然能跑,但每一帧音频都要经过 base64 编码和解码,CPU 占用率高,尤其是 ESP32 这种资源受限的 MCU,采样率一高就吃不消;二是 JSON 本身有解析开销,帧率一高,串行化解析变成了瓶颈。后来我把协议改成 WebSocket 二进制帧直接传输裸 PCM 数据,整体延迟降了 30%,CPU 占用也明显下降。
2.1 裸 PCM 还是 Opus 压缩,这里有个计算过程
先说采样率。语音识别侧主流模型常用 16kHz 采样率,这也是 WebRTC、Kaldi、Whisper 的标准输入之一。16kHz、16bit、单声道,每秒数据量是 16000 × 2 × 1 = 32000 字节,也就是 32KB/s。这个体量在局域网或者 4G/5G 移动网络下其实不大,WiFi 环境实测发送延迟稳定在 20ms 到 50ms 之间。
但如果你要跑公网、跨国或者弱网环境,32KB/s 就不算小了,我会建议上 Opus 压缩。Opus 在 16kHz 语音场景下,24kbps 码率就已经能达到不错的清晰度,相比裸 PCM 的 256kbps 压缩了 10 倍以上。代价是 ESP32 端需要跑 Opus 编码器,如果你想用 top-level 的 opus_encode API,一条指令调用并不复杂,但要注意踩内存和算力的坑。实测 ESP32-S3 @240MHz 跑 Opus 编码,一帧 20ms 语音编码耗时大约 3ms 到 5ms,完全可以接受。
如果做原型验证,我建议先用裸 PCM,把链路调通后再决定要不要上压缩。
2.2 帧大小的选择逻辑
音频采集端我选了 20ms 一帧。为什么不是 10ms 也不是 60ms?
- 10ms 一帧:延迟最低,但帧率太高,每秒 100 个帧包,WebSocket 包头、WiFi 网络包头的开销占比大幅上升,而且 ESP32 处理中断过于频繁
- 20ms 一帧:每秒 50 帧,16kHz × 2 × 0.02 = 640 字节净荷,加上 2 到 4 字节自定义协议头,一个包不到 650 字节,网络效率高,延迟也可控
- 60ms 一帧:延迟偏高,服务端做 VAD 检测时要等足够长的缓冲才敢判定用户是否说完,对话节奏受影响
我做压测时对比过,20ms 是延迟和带宽最平衡的点。这个值也是 WebRTC 和很多实时语音系统的标准选择,生态兼容性好。
2.3 二进制帧格式怎么设计才不会给自己挖坑
裸 PCM 帧直接发肯定不行,因为接收方不知道一帧从哪开始、到哪里结束,也不知道这是音频数据还是控制指令。所以我设计了一个 4 字节头部 + 可变长负载的格式:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | 版本号 | 固定 0x01 |
| 1 | 1 | 帧类型 | 0x01=音频帧,0x02=控制指令,0x03=心跳 |
| 2 | 2 | 负载长度 | 大端序,说明负载字节数 |
音频帧的负载就是 640 字节的裸 PCM;控制指令的负载是一段 JSON,比如{"type":"start","sample_rate":16000}或者{"type":"stop","reason":"vad_end"};心跳帧负载为空,用于长连接保活。
这个设计的核心思想是把“数据”和“信令”放在同一个连接里传输,但通过帧类型区分。这样服务端解析时,先读 4 字节头部,判断是音频还是信令,再决定走哪条处理管线,逻辑清晰,调试时抓包也一目了然。
3. 设备端 WebSocket 客户端与音频采集播放的实现
这一节是整篇的硬核部分,从硬件选型到代码结构,我把直接可用的方案铺开来讲。
3.1 硬件选型与接线
我在这个项目里用的是 ESP32-S3-WROOM-1 模组的开发板,选它的原因很直接:240MHz 双核、内置向量指令加速、原生支持 I2S 外设、带 WiFi 和 BLE,关键是性价比高。如果你只做原型,ESP32 经典款也能跑,但 S3 在做音频处理时算力余量明显更足,双核可以一个核跑采集发送、一个核跑接收播放,不容易被打断。
音频采集端我用的是 INMP441 数字 I2S 麦克风,接线是标准的 I2S 四线:
| 引脚 | INMP441 | ESP32-S3 |
|---|---|---|
| SCK | SCK | GPIO 4 |
| WS | WS | GPIO 5 |
| SD | SD | GPIO 6 |
| L/R | GND | GND(选择左声道) |
播放端用 MAX98357A I2S 功放模块,接一个 3W 小喇叭。接线类似:BCLK 接 GPIO 15,LRC 接 GPIO 16,DIN 接 GPIO 17。两边共用 I2S 外设的话,注意 ESP32-S3 有多个 I2S 控制器,采集和播放可以分别挂在不同的控制器上,避免时钟互相干扰。实测 INMP441 和 MAX98357A 共用同一个 I2S 总线也能跑,但偶尔会有数据冲突,分开更省心。
3.2 工程配置:ESP-IDF 环境的三个关键点
我用 ESP-IDF v5.1 作为开发环境,创建工程后需要配置几处关键项:
其一,启用 WebSocket 客户端组件。新版 IDF 直接idf.py add-dependency esp_websocket_client即可,组件本身基于 esp_http_client 实现,支持 ws:// 和 wss://。
其二,打开全局事件循环。WebSocket 组件的连接、断开、数据接收事件需要事件循环来分发,必须在 menuconfig 里确认Enable EVENT_LOOP已勾选。
其三,增加内存和栈配置。音频缓冲区、播放队列占内存较多,建议在 menuconfig 里把Component config → ESP System Settings → Main task stack size调到 8192 以上。WiFi LwIP 的 socket 接收缓冲默认 4096 不够用,改到 8192 或 16384,否则高频音频帧容易被内核丢弃。
3.3 核心代码结构:三个任务加一个队列
整个设备端程序我拆成三个 FreeRTOS 任务和一个共享队列:
// 音频帧队列,生产者和消费者解耦 QueueHandle_t audio_tx_queue; // 接收播放队列 QueueHandle_t audio_rx_queue; // 任务1:I2S 采集 -> 队列 void task_mic_capture(void *arg) { int16_t pcm_buf[320]; // 20ms * 16kHz = 320 samples while (1) { size_t bytes_read = 0; i2s_channel_read(mic_channel, pcm_buf, sizeof(pcm_buf), &bytes_read, pdMS_TO_TICKS(100)); if (bytes_read == sizeof(pcm_buf)) { xQueueSend(audio_tx_queue, pcm_buf, 0); } } } // 任务2:从队列取数据 -> WebSocket 二进制帧发送 void task_ws_send(void *arg) { int16_t pcm_buf[320]; while (1) { if (xQueueReceive(audio_tx_queue, pcm_buf, portMAX_DELAY) == pdTRUE) { uint8_t header[4]; header[0] = 0x01; header[1] = 0x01; // 音频帧 header[2] = (uint16_t)sizeof(pcm_buf) >> 8; header[3] = (uint16_t)sizeof(pcm_buf) & 0xFF; // 拼包发送 uint8_t send_buf[4 + sizeof(pcm_buf)]; memcpy(send_buf, header, 4); memcpy(send_buf + 4, pcm_buf, sizeof(pcm_buf)); esp_websocket_client_send_bin(client, send_buf, sizeof(send_buf), pdMS_TO_TICKS(50)); } } } // 任务3:接收播放 void task_ws_recv_play(void *arg) { // 由 WebSocket 事件回调喂数据到 audio_rx_queue // 本任务只负责从队列取数据并写入 I2S DAC }队列在这个架构里是核心。采集任务和发送任务通过队列解耦,采集快了能自动积压,采集慢了发送任务会阻塞等待,不会互相踩踏。播放端同理,WebSocket 回调收到二进制帧后解析、把 PCM 数据推入播放队列,播放任务从队列取数据写 I2S,这样回调里不做耗时操作,避免阻塞 WebSocket 底层处理。
3.4 WebSocket 事件的接收与解析
ESP-IDF 的 esp_websocket_client 是通过事件回调上报数据的,核心事件有WEBSOCKET_EVENT_CONNECTED、WEBSOCKET_EVENT_DISCONNECTED、WEBSOCKET_EVENT_DATA和WEBSOCKET_EVENT_ERROR。其中WEBSOCKET_EVENT_DATA回调里需要判断op_code,如果等于 0x02 就表示收到的是二进制帧,这时候data_ptr指向的就是原始二进制数据,data_len是长度。
我盘过的坑是,ESP-IDF 官方的 WebSocket 客户端默认把收到的数据按文本推送,如果服务端发送二进制帧,需要手动识别 op_code。具体做法是:
static void ws_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data; switch (event_id) { case WEBSOCKET_EVENT_DATA: if (data->op_code == 0x02) { // 二进制帧,解析自定义协议头 process_binary_frame(data->data_ptr,>SUSE Linux SAP HANA高可用配置:HAE脚本自动化生成Pacemaker集群
简介:这是一套面向SUSE Linux平台SAP HANA高可用环境的HAE配置脚本,主要供SUSE 12 SPx运维与实施人员使用,解决手工配置Corosync及Pacemaker步骤繁杂、易出错的问题。脚本支持HANA 1.0与2.0,并兼容基于IPMI和SBD两种fence模式&…
深入浅出爱德万V93000测试机:SmarTest 8数据模型与数字逻辑测试实战
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
车载Android串口开发全链路:UART/RS232/RS485实战指南
1. 项目概述:为什么车载Android设备必须啃下串口这根硬骨头?在车载电子系统里,UART不是什么新潮概念,而是连接车规级硬件的“神经末梢”。我做过三年车载中控开发,从2019年第一台基于Android 9的智能后视镜,…
OpenCV多目标追踪实战:基于KCF的鼠标交互实现
简介:这是一套基于OpenCV的多目标追踪实战项目,面向计算机视觉学习者与开发者,使用KCF算法实现视频中多个目标的检测与跟踪,并加入鼠标交互,允许用户自定义选择需要追踪的对象,涵盖从算法原理、代码实现到实…
Refine useSelect Hook 完全指南:无头 Select 数据绑定、搜索、默认值与实时更新
Refine useSelect Hook 完全指南:无头 Select 数据绑定、搜索、默认值与实时更新 【免费下载链接】refine A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility. 项目地址: https://gitcode.com…
Resume-Matcher 测试策略与验证方案:从「绿勾即剧院」到 444 个确定性测试与本地 pre-push 门禁
Resume-Matcher 测试策略与验证方案:从「绿勾即剧院」到 444 个确定性测试与本地 pre-push 门禁 【免费下载链接】Resume-Matcher The #1 AI Harness for Building Resumes, PDFs, Cover Letters & more, locally with 100 LLMs support. 项目地址: https://g…