ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32圆屏不跑模型:薄客户端+厚服务端语音交互架构实战

ESP32圆屏不跑模型:薄客户端+厚服务端语音交互架构实战 做了三年糖球系列前两篇都在折腾屏显和动画这一篇我想聊点不一样的同一块 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待机圆屏显示时钟或待机动画按下按键进入 LISTENINGLISTENING录音中屏幕显示实时音量波形VAD 检测到静音超时或按键松开进入 THINKINGTHINKING等待后台响应屏幕显示加载动画转动的小糖球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 数据块头部的几个字节用来做消息类型标记简单直接。消息类型我定义了四种类型标识方向说明0x01ESP → Server音频数据块PCM0x02Server → ESP语音回复PCM/AAC0x03Server → ESP状态文本用于字幕显示0x04ESP → 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:8length] if msg_type 0x01: # 转成 numpy 数组假设 16kHz 16bit pcm np.frombuffer(audio_data, dtypenp.int16).astype(np.float32) / 32768.0 # ASR result model.transcribe(pcm, languagezh) 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 APIedge_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 降到 tinyLLM 从 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 生成回复时先从知识库里检索相关片段再让模型根据上下文组织回答。有了这套检索增强生成逻辑我的“糖球”就能回答一些私人定制的问题而不再是泛泛的通用聊天。这套架构的弹性在于模型能力全在后台前端硬件只是一个交互媒介。以后就算换更强的模型、更大的上下文窗口甚至换成云端 APIESP 端代码完全不用动。这其实就是我们做嵌入式语音交互应该有的姿态——硬件做好采集、呈现、交互的本分把智能留给软件和云端去生长。如果你也想复刻一套建议先从最简链路开始一块圆屏开发板、一台电脑、一根 USB 线、一个本地 Whisper 模型。把“录音 → 发送 → 识别 → 返回文本 → 显示字幕”这条最基础的链路跑通再逐步加语音合成、状态动画和大模型对话。这条路走完你就不会再纠结“要不要在 ESP 上硬跑模型”了。
返回列表