做了三年糖球系列,前两篇都在折腾屏显和动画,这一篇我想聊点不一样的:同一块 ESP32 圆屏,我不让它跑模型,只把它当后台的语音客户端。
先说结论:这块圆屏不是“智能音箱本体”,它是“智能音箱的嘴和耳朵”——真正的模型推理发生在局域网里的另一台主机,或云端服务器上。ESP32 负责录音、播放、显示状态、发请求、收结果。听上去像个降级方案,但如果你试过在 ESP32 上跑过任何超过 10MB 的模型,就会明白这不是偷懒,而是架构上最直接的解法。
这篇文适合所有想在 ESP32 圆屏上做语音交互、但卡在“本地跑模型”这一步的朋友。我会从整体架构拆起,把 ESP 端、后台端、通信协议、模型加载、状态回显这几块讲透,最后附上我这几个月踩过的真实坑。
1. 内容整体设计与思路拆解
1.1 为什么不让圆屏跑模型
先算一笔账。ESP32-S3 这颗芯片,双核 240MHz,内置 SRAM 大约 512KB,外挂 PSRAM 最高 8MB 到 16MB。看起来不少,但一个像样的语音识别模型(比如 Whisper-tiny 量化版)就要 40MB 左右的存储和至少 10MB 以上的运行内存,更别提 LLM 动辄几 GB 的权重。
就算硬塞进去,推理速度也没法看。我实测过一个轻量级关键词识别模型,在 ESP32-S3 上跑一次前向传播需要 1.2 秒,这还没算特征提取的时间。用在按键触发场景勉强能忍,但做连续语音交互,用户一句话说完等 AI 回应得几十秒,体验直接归零。
所以“不跑模型”不是能力问题,是性价比问题。把大模型推理交给后台服务器,ESP 圆屏专注做采集和呈现,整条链路延迟能压到 300ms 以内——这个数据我在局域网环境下实测过,比很多商业智能音箱的反应还快。
1.2 客户端-后台架构的选型理由
有人可能会问:为什么不直接用现成的智能音箱方案?答案是定制空间。用 ESP 圆屏做语音客户端,你可以完全控制交互逻辑、显示界面、唤醒词、甚至可以对接自己的模型服务。
这套架构本质上是个典型的“薄客户端 + 厚服务端”模型。ESP 端只干四件事:
- 采集麦克风音频,做基本的 VAD(语音活动检测)
- 把音频流或压缩后的音频发送到后台
- 播放后台返回的音频回复
- 通过串口或 SPI 驱动圆屏展示状态、波形、字幕
后台端则承担所有重活:语音识别(ASR)、大语言模型推理(LLM)、语音合成(TTS)、对话管理。这种分工的好处是:模型迭代、换模型、加能力,只需要动后台,ESP 端固件可以几个月不更新。
另一个隐性好处是功耗和发热。ESP32 跑满双核做矩阵运算时,芯片表面温度能到 60 度以上。但当它只是做音频 DMA 传输和 Wi-Fi 通信时,功耗能压到 200mA 以内,长时间放在桌面上完全不会烫手。
1.3 整个流程的数据流转路径
我最终敲定的数据链路是这样的:
用户说话 → 圆屏麦克风 → ESP32 本地 VAD → 压缩音频 → Wi-Fi → 后台 WebSocket 服务 → ASR 识别文本 → LLM 生成回复 → TTS 合成语音 → 音频回传 → ESP32 播放 → 圆屏同步显示状态这条链路里最容易被忽略的是音频格式的统一。ESP32 的 I2S 接口从麦克风采到的原始数据是 16bit、16kHz 单声道 PCM,而后台 ASR 模型往往需要 16kHz 或 8kHz 的采样率。如果不做重采样直接丢给后台,识别率会明显下降。
我这里的处理方式是在后台做一个音频格式自适应层,读取 WebSocket 收到的二进制数据中的采样率字段,自动重采样后再送入 ASR。ESP 端不用关心后台用的是什么模型,只需要在握手时告诉后台“我发的是 16kHz 16bit PCM”。
2. 核心细节解析与实操要点
2.1 ESP32 圆屏端的功能边界划分
圆屏端听起来简单,但细节都在边界处理上。我习惯把 ESP 端代码拆成 5 个模块,每个模块管一件事:
- 音频采集模块:负责 I2S 初始化、麦克风数据读取、噪声门限判断
- 通信模块:负责 Wi-Fi 连接、WebSocket 建连、心跳保活、断线重连
- 播放模块:负责音频解码(PCM 直通或 AAC/MP3 解码)、I2S 输出
- 显示模块:负责圆屏渲染,包括音量波形、状态指示灯、字幕滚动
- 状态机模块:负责协调上述模块的调度状态
状态机的存在很关键。如果没有明确的状态管理,很容易出现“正在录音时收到回复音频”这种竞态。我的状态机只有四个状态:IDLE、LISTENING、THINKING、SPEAKING,每个状态下的按键和网络事件都有清晰的响应逻辑。
状态流转:
- IDLE:待机,圆屏显示时钟或待机动画,按下按键进入 LISTENING
- LISTENING:录音中,屏幕显示实时音量波形,VAD 检测到静音超时或按键松开,进入 THINKING
- THINKING:等待后台响应,屏幕显示加载动画(转动的小糖球)
- SPEAKING:播放回复音频,屏幕显示字幕或表情动画,播放完毕回到 IDLE
这个状态机看着简单,但实际调试的时候帮了我大忙。以前总出现“AI 还在说话,我已经开始录音”的情况,引入状态机后就彻底解决了。
2.2 音频采集与 VAD 的取舍
VAD 这块我试过三种方案:简单能量阈值、过零率检测、以及轻量模型 VAD。
简单能量阈值最容易实现,但误触发严重。我书房里开个风扇,或者远处有人说话,都会导致误唤醒。过零率好一些,但对环境噪声依然敏感。轻量模型 VAD 准确率最高,但需要额外的几百 KB 内存和几毫秒的推理时间,在 ESP32 上属于“能用但有点奢侈”。
我最后选了折中方案:能量阈值 + 过零率双重判断,加上一个 300ms 的稳定时间窗口。也就是说,必须连续 300ms 满足“能量超阈值且过零率在合理范围”,才判定为有效语音输入。这样既避免了单点误判,又不会因为模型 VAD 占用太多资源而影响采集实时性。
采集缓冲区大小也值得注意。I2S 驱动 DMA 的缓冲区我设为 512 字节,即每通道 128 个采样点。每隔 20ms 从 DMA 缓冲区读取一次数据,判断 VAD 状态。这个 20ms 的粒度足够灵敏,又不会频繁触发任务调度导致 CPU 占用过高。
2.3 通信协议的选择与优化
通信协议我纠结过好久。最终方案是 WebSocket + 二进制帧,而不是 HTTP 轮询或 MQTT。
HTTP 在这里有个天然的劣势:它是半双工模型。客户端发完请求要等响应,服务器主动推送消息必须依赖轮询或 SSE。而语音交互天然是双向的——客户端要实时上传音频,服务器要随时下发回复。WebSocket 的全双工特性完美匹配这个需求。
MQTT 做语音基站也许是可行的,但它强于设备间发布订阅,弱于流式数据传输,而且丢包重传机制在实时语音场景需要额外定制。WebSocket 的二进制帧直接承载 PCM 数据块,头部的几个字节用来做消息类型标记,简单直接。
消息类型我定义了四种:
| 类型标识 | 方向 | 说明 |
|---|---|---|
| 0x01 | ESP → Server | 音频数据块(PCM) |
| 0x02 | Server → ESP | 语音回复(PCM/AAC) |
| 0x03 | Server → ESP | 状态文本(用于字幕显示) |
| 0x04 | ESP → Server | 控制指令(停止、重连等) |
协议头固定 8 字节:2 字节类型、2 字节长度、4 字节序列号。剩下的是 payload。解析代码在 ESP 端就是一个 switch-case 的事,功耗和代码复杂度都压得很低。
2.4 后台服务的模型加载策略
后台我跑的是一个 Python 服务,用 FastAPI 接收 WebSocket 连接,ASR 部分挂了本地 Whisper 模型,LLM 部分接的是 Ollama 加载的本地模型,TTS 用的开源 Edge-TTS 接口。
为什么强调“本地模型”?因为如果 TTS 或 LLM 也走云端 API,语音交互的延迟会变成 2 秒以上——HTTP 请求、鉴权、排队、生成、传输,每一层都在加延迟。而本地推理,尤其是用 Ollama 加载 7B 级别的量化模型,没网络抖动的情况下首 token 延迟能控制在 500ms 以内。
这里有个启发式调优的点:后台服务启动时,ASR 模型常驻内存,LLM 模型按需加载。Whisper-base 大约 150MB 显存,7B 量化模型大约 4.5GB 显存。如果显卡只有 8GB,两个模型同时常驻会很吃力。我的做法是:ASR 模型永远驻留,LLM 模型在第一次被调用时加载,之后 10 分钟无请求就卸载释放显存。
10 分钟这个阈值不是拍脑袋。我对照过 5 分钟、10 分钟、30 分钟三档,5 分钟在用户闲聊时容易反复加载拖慢首轮响应,30 分钟对显存释放不友好,10 分钟在两者之间最平衡。
3. 实操过程与核心环节实现
3.1 ESP 端工程的目录结构和开发环境
开发环境我用的 ESP-IDF,版本 v5.1.3。为什么不用 Arduino?因为需要精细控制 I2S 时序和 WebSocket 二进制帧的收发,Arduino 封装好归好,但中间隔着太多层,出了问题不好查。
工程目录结构如下:
sugar_ball/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── app_main.c # 程序入口,初始化各模块 │ ├── audio_capture.c # I2S 采集与 VAD 判断 │ ├── audio_playback.c # I2S 播放与解码 │ ├── net_websocket.c # WebSocket 通信 │ ├── ui_round_lcd.c # 圆屏显示逻辑 │ └── state_machine.c # 状态机调度 ├── components/ │ ├── esp_websocket_client/ # ESP-IDF 官方 WebSocket 组件 │ └── lvgl/ # 显示驱动,来自 LVGL 官方组件库 └── partitions.csv分区表这里我专门做了规划。ESP32-S3 的 Flash 我用的是 16MB 版本,其中 8MB 分配给 SPIFFS 文件系统存放字库、图标资源和预置音频。剩余 8MB 给固件和 OTA。字体资源如果没有特殊需求,建议用 LVGL 内置的字体就行,能省下至少 1MB 的空间。
我最初图方便,直接在代码里内置了一套 2MB 的字库,结果 OTA 分区被挤得只剩 1.5MB,后续功能迭代根本不敢动。后来改成 SPIFFS 挂载外部资源,问题迎刃而解。
3.2 音频采集模块的代码实现
音频采集这一段是 ESP 端最容易出错的地方。直接贴核心代码:
#include <driver/i2s.h> #include <driver/i2s_std.h> #define I2S_WS GPIO_NUM_8 #define I2S_SCK GPIO_NUM_9 #define I2S_SD GPIO_NUM_10 void audio_init(void) { i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(16000), .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG( I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), .gpio_cfg = { .ws = I2S_WS, .sck = I2S_SCK, .dout = I2S_GPIO_UNUSED, .din = I2S_SD, }, }; i2s_channel_handle_t rx_channel; i2s_new_channel(&std_cfg, NULL, &rx_channel); i2s_channel_enable(rx_channel); } int audio_read_pcm(uint8_t *buf, size_t len) { size_t bytes_read = 0; i2s_channel_read(rx_channel, buf, len, &bytes_read, pdMS_TO_TICKS(100)); return bytes_read; }这是 ESP-IDF v5.x 的风格。注意采样率、位深和声道数必须和后台约定一致,否则识别出来全是噪音。我调试的时候经常遇到一个坑:麦克风是 PDM 接口,但代码里配置成 I2S 的标准 PHILIPS 模式,读出来的数据乍一看是正常的,频谱却全乱了。PCM 数据无法直接播放。PDM 麦克风要用专门的 PDM 通道配置,如果用标准 I2S 模式去读 PDM 麦克风,得到的数据需要经过滤波才能用。我的硬件上用的是 INMP441 数字麦克风,走的就是标准 I2S 接口,所以这里直接配成 PHILIPS 模式没问题。
3.3 WebSocket 通信模块的实现思路
ESP-IDF 官方有esp_websocket_client组件,但它是个同步阻塞模型,在事件回调里做数据处理容易卡主循环。我改造了一下,使用事件回调 + 队列的方式:
static void websocket_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) { // 语音回复,直接放到播放队列 audio_play_queue_send(data->data_ptr,>from fastapi import FastAPI, WebSocket import whisper import numpy as np import subprocess import json app = FastAPI() model = whisper.load_model("base") @app.websocket("/ws") async def handle_ws(ws: WebSocket): await ws.accept() while True: # 接收一条二进制消息 data = await ws.receive_bytes() # 解析协议头 msg_type = int.from_bytes(data[0:2], "big") length = int.from_bytes(data[2:4], "big") audio_data = data[8:8+length] if msg_type == 0x01: # 转成 numpy 数组(假设 16kHz 16bit) pcm = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0 # ASR result = model.transcribe(pcm, language="zh") text = result["text"] # 交给 LLM 生成回复 reply = ollama_generate(text) # TTS 合成语音 tts_audio = edge_tts_synthesize(reply) # 通过 WebSocket 发回去 await ws.send_bytes(build_binary_frame(0x02, tts_audio)) await ws.send_bytes(build_binary_frame(0x03, reply.encode("utf-8")))这里ollama_generate是调 Ollama 的 REST API,edge_tts_synthesize是 Edge-TTS 的异步接口,都需要超时和重试机制,否则后台任何一家服务挂掉,ESP 端就要傻等。
3.5 圆屏 UI 的状态反馈
圆屏显示是用户体验的一半。我用的是一块 1.28 英寸 GC9A01 驱动圆形 LCD,分辨率 240×240,用 LVGL 画界面。
UI 设计上,我定了四条状态反馈原则:
- 每个状态都必须有即时反馈,不能让用户干等
- 状态切换必须有动画过渡,平滑滑动和淡入淡出
- 音量波形要实时反映当前录音电平,让用户知道它在听
- 字幕只在 SPEAKING 状态滚动显示,避免信息过载
比如在 LISTENING 状态下,圆屏中心是一个大的圆形 VU 表,音量实时驱动圆圈半径变化。这个效果用 LVGL 的 lv_arc 控件实现,几十行代码就完成:
// 假设音量值 range 0~100 lv_arc_set_value(ui_arc_mic, volume_percent); lv_arc_set_rotation(ui_arc_mic, map(volume_percent, 0, 100, 0, 360));THINKING 状态则是一个旋转的糖球动画,用 lv_anim 让一个小圆点沿圆弧轨道运动。Loding 动画不要做超过 2 秒的循环,因为后台推理一般很快,动画只是为了过渡感,做太久反而显得后台很慢。
4. 常见问题与排查技巧实录
4.1 音频丢失与断流排查
最常见的故障是“ESP 录音之后,后台识别出乱码或没有任何内容”。这类问题九成出在音频数据的连续性和格式上。
排查步骤我按这个顺序走:
- 先确认采样率是不是精确 16000Hz。用逻辑分析仪看 I2S 的 SCK 和 WS 引脚,确认时钟频率符合预期。
- 再确认麦克风左右声道。INMP441 这种芯片的 L/R 引脚决定它在 I2S 总线上占据哪个声道。如果配置的是 MONO 模式但麦克风实际挂在 RIGHT 声道,读取的数据就会全部偏移或者只有一半有效。
- 检查 WebSocket 分包是否正确。音频数据被拆成多个 TCP 包传送后,服务器端如果没做缓冲,会出现“半个 PCM 采样点”的解析错位。我建议服务器端用一个累积缓冲区,每收到一帧就 append,然后解析完整帧处理。
- 最后看 VAD 阈值。如果 VAD 阈值设太高,人说话的声音被当成噪声过滤掉了,服务器收到的是空白音频。用串口打印音量值,观察实际说话时音量峰值分布区域再调阈值。
4.2 圆屏驱动的“幽灵触摸”和花屏问题
圆屏本身有个常见坑:GC9A01 这类圆形 LCD 的驱动 IC 对初始化序列特别敏感。如果初始化时序不对,会出现边缘颜色异常或整体偏色。
我的花屏问题排查记录:
- 最初现象:屏幕下半部偏紫,上半部颜色正常
- 排查过程:先改 SPI 时钟频率,从 40MHz 降到 20MHz,偏色依旧;再检查 RGB 通道顺序,发现 GC9A01 默认是 RGB 666,但代码里配置成 RGB 565,强行把低 2 位截断导致颜色失真
- 解决办法:把 LVGL 的颜色格式改为
LV_COLOR_FORMAT_RGB565,并确保 SPI 数据位宽是 8bit,重新刷屏就正常了
另一个问题是触控。我用的圆屏是电容触控,但 ESP 端的触摸控制器和 LCD 共用同一组 SPI。如果两者没有做时间片隔离,就会出现“幽灵触摸”——触摸屏自己乱报坐标。解决办法很简单:触控和显示要做 CS 片选互斥,同一时刻只有一个设备在工作。
4.3 后台服务资源不足时的降级策略
本地模型部署最大的变量是后台机器的性能。如果同时跑 Whisper、LLM、TTS,整机内存和显存会非常紧张。我试过在只有 8GB 内存的旧笔记本上跑这套系统,结果对话延迟飙到 10 秒以上,TTS 合成还经常超时。
降级策略有三层:
- 模型最小化:ASR 从 base 降到 tiny,LLM 从 7B 降到 3B 量化版,TTS 从高质量神经音色切换到标准音色
- 请求排队:对并发的语音请求做队列,同一时间只处理一个对话,避免多个模型同时推理抢占资源
- 缓存复用:相同或相似的问句直接查缓存,不再重新让 LLM 生成
这三层下来,同样的旧笔记本能把延迟压回 2~3 秒。虽然不如高配机器,但至少系统不会崩。
4.4 断线重连与心跳保活
ESP 端是一个移动设备,Wi-Fi 不稳定是常态。我调试环境里最常见的问题是:路由器休眠导致 Wi-Fi 断开,但 ESP 还没感知到,一直发数据发不出去。
解决方案是双重保活机制:
- 一级保活:TCP 层 keepalive,间隔 60 秒
- 二级保活:应用层心跳包,每 30 秒 WebSocket 发一次 0x04 类型的控制帧
如果 90 秒内既没有收到服务器的心跳应答,也没有发出过任何音频数据,ESP 强制断开当前连接,重连并重发一条“RECONNECTED”状态消息。服务器端收到这条消息后,会重新初始化 ASR 上下文,避免语音识别状态残留。
4.5 一个让人意外的坑:Flash 分区表不够用
最后分享一个特别容易踩的坑。ESP32-S3 的固件编译出来通常只有几百 KB,但加了 LVGL 组件后,固件体积轻松超过 1.5MB。如果你的分区表还是默认的 1MB app / 1.5MB app 结构,编译会直接报错。
我当时就卡在这个问题上。把分区表改成:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 2M, storage, data, spiffs, 0x210000, 4M,分别分配给 NVS、phy、固件区 2MB、文件系统 4MB。这样固件有充足空间,字库音频也有地方放。
5. 从“后台语音客户端”看后续扩展方向
整套系统跑通之后,我发现“ESP 圆屏不跑模型”这套架构的扩展性其实比想象中要强。
最简单的扩展方向是增加按键自定义指令。比如拨动圆屏侧边的物理按键,直接发一条“播放我喜欢的音乐”的控制消息,不需要无线唤醒词。这个在 ESP 端只是一个 GPIO 中断的事。
再进一步,可以给后台接上整屋智能设备的控制接口。把 ASR + LLM 的输出接到一套 MQTT 指令集上,用户对着圆屏说“把客厅灯调到最亮”,后台识别完成后直接发 MQTT 给智能灯网关。这个场景里,ESP 圆屏始终是语音客户端,不参与任何设备控制逻辑,所有后端的业务能力都在服务器侧承载。
我自己正在做的下一步是给这套系统加本地知识库。后台服务里嵌入一个向量数据库,把家里的手册、菜谱、日程等文档切片存储。当 LLM 生成回复时,先从知识库里检索相关片段,再让模型根据上下文组织回答。有了这套检索增强生成逻辑,我的“糖球”就能回答一些私人定制的问题,而不再是泛泛的通用聊天。
这套架构的弹性在于:模型能力全在后台,前端硬件只是一个交互媒介。以后就算换更强的模型、更大的上下文窗口,甚至换成云端 API,ESP 端代码完全不用动。这其实就是我们做嵌入式语音交互应该有的姿态——硬件做好采集、呈现、交互的本分,把智能留给软件和云端去生长。
如果你也想复刻一套,建议先从最简链路开始:一块圆屏开发板、一台电脑、一根 USB 线、一个本地 Whisper 模型。把“录音 → 发送 → 识别 → 返回文本 → 显示字幕”这条最基础的链路跑通,再逐步加语音合成、状态动画和大模型对话。这条路走完,你就不会再纠结“要不要在 ESP 上硬跑模型”了。