ESP32圆屏不跑大模型:语音客户端+后台AI架构设计与实现
2026/9/8 13:23:27 网站建设 项目流程

这是糖球系列的第三期。前两期我一直在折腾那块小小的圆形屏幕,想方设法让它更像一个能“自己思考”的桌面玩具。这一期我反而学会做减法了——ESP圆屏不跑任何大模型,它彻底变成了后台的语音客户端。整块板子只负责三件事:录音、放音、把状态画在屏幕上。真正干活的大脑全在后台:语音识别、大语言模型、语音合成,全在电脑或者服务器上跑。你如果也动过“ESP32能不能直接跑大模型”的念头,应该能理解这个决定有多解脱。

这篇文章就是把我这一版“糖球”的完整思路、架构、固件要点、后台服务搭建和踩坑记录整理出来。适合的人群很明确:手头有一块ESP32圆屏但不知道除了表盘还能做点什么的人,想给桌面加一个自建语音助手又不想花大价钱买成品音箱的人,以及想理解“音频客户端+后台AI服务”这套典型架构的人。看完你至少能自己搭出一套能用的语音助手雏形。

1. 为什么不让圆屏跑模型:算力账和体验账

1.1 ESP32跑不了大模型,这不是优化问题

先算一笔硬账。ESP32-S3是双核240MHz的Cortex-M7级别MCU,带8MB PSRAM,听上去还行,但你要跑的是7B、14B这种量化大模型,参数规模再小也小不到哪去。7B模型哪怕全部压到4bit量化,也有大约3.5GB的权重,PSRAM差了几个数量级。就算退一步跑语音识别,目前效果能看的Whisper small也有几百MB的模型尺寸,单个芯片根本装不下。

有些朋友会说,那跑TinyML不是可以吗?对,跑跑关键词唤醒、手势识别、简单的分类任务是可以,但语义理解、对话生成、自然语音合成这些核心体验,在MCU上就算能跑也是“能用”和“好用”的天壤之别。我上一期尝试过在ESP32上放一个微型语音命令识别模型,准确率能用,但它只能识别固定十来个指令,换个说法就听不懂了。这不是工程优化能解决的,是模型表达能力和算力的物理边界。

1.2 客户端/后台分离带来的实际好处

把“聪明”的部分放到后台以后,第一感觉是开发效率高了好几倍。后台的环境是完整的Python生态,模型随便换,Whisper不好用就换FunASR,LLM不满意就换Ollama拉的别的模型,TTS音色不满意就换引擎。刷固件?一次都不用。模型升级、提示词修改、对话系统调整,全部在后台重启服务就完成了。

第二是功耗和发热,圆屏设备在桌面上一直连着电源还好,但如果你打算用电池,跑本地模型会让温度快速上升,续航也急剧下降。现在作为语音客户端,平时待机功耗可以压到很低,只有麦克风在工作,需要交互的时候才全速收发音频。

第三是稳定性。MCU上做多任务实时处理本来就容易出问题,跑模型、跑UI、跑网络栈全挤在一颗芯片上,内存和CPU调度很容易互相打架。把重计算移走之后,固件端逻辑简单到几乎不出错,WiFi断了重连就行,模型服务崩了也不影响设备本身。

2. 整体架构设计:音频流、控制流、状态流

2.1 单向音频流是最稳的起点

很多人在设计语音助手时会直接想“双向音频全双工”,这其实是个坑。全双工意味着同时采集和播放,回声消除(AEC)如果不做好,后台听下去全是回声。在我这个项目里,第一版就明确做了半双工:用户在说话的阶段,设备只录音不上行播放;后台回复TTS的阶段,设备只播放不采集。这样天然避免了一大半回声问题。

半双工虽然显得“呆”,但体验上完全够用,因为人对语音助手说话的节奏本来就是“我说你听,你说我听”。真要以后做长时间自由对话,再引入AEC模块也不迟。架构上把音频设计成独立通道,之后要升级成全双工不需要推倒重来,只需要把通道内的方向控制策略改掉。

2.2 控制协议怎么设计:JSON over WebSocket

传输协议我用的是WebSocket,理由很简单:浏览器、Python、ESP32都有成熟库,而且它基于TCP,能保证音频数据不丢不乱序。音频走TCP的代价是延迟受网络波动影响,但在局域网内这个代价完全可以接受。

我的WebSocket消息分两类:二进制帧走音频数据,文本帧走控制事件。二进制帧格式刻意做得很简单,前面12字节是自定义头,包含协议版本、消息类型、序列号、时间戳,后面是音频数据(PCM或Opus编码)。控制事件用JSON,比如{"type":"state","state":"thinking"}这种,设备收到以后切换UI状态。为什么不用纯JSON传音频?因为base64编码会让音频体积膨胀约33%,对不是特别宽裕的网络环境来说没必要,二进制更直接。

2.3 显示层要和语音状态联动

圆屏这块屏幕如果只是显示个时钟,那就浪费了。但如果你让它做太复杂的事,又会影响语音流程的实时性。我的做法是LVGL只做状态呈现,不做任何业务逻辑。设备端维护一个简单的状态机:idle、listening、thinking、speaking,每个状态对应屏幕上的颜色和动画。

这里有个很关键的体验细节:当用户说完话,后台正在推理时,屏幕上必须立刻显示“思考中”的状态,否则用户不知道设备是在工作还是死机了。所以我让固件在检测到VAD静音后立刻上报一个"stage":"asr_start"事件,后台再往后走识别、生成、合成,每个阶段都会下发状态事件。屏幕只是被动接收状态并绘制,即使后台卡住,用户也能一眼看出卡在哪个环节。

3. 硬件选型与环境搭建

3.1 我用的圆屏方案:ESP32-S3 + 1.28寸GC9A01

圆屏方案市面上不少,我最终选的是ESP32-S3加1.28寸GC9A01圆形LCD的集成开发板。为什么选S3不选老款ESP32?S3有AI指令加速和更多GPIO,PSRAM也有更大配置,关键是它内置了向量指令,对后续如果要在本地跑轻量KWS(关键词唤醒)有实际帮助。

板载外设也很重要。我找的这块板子集成了PDM麦克风、D类功放、锂电池充电管理和触摸按键,基本上一个Type-C线就能完成开发,不用外接模块。如果你手上是单独的ESP32-S3开发板加GC9A01屏,也能做,但需要额外接INMP441麦克风和MAX98357功放模块,硬件连线会多一些,注意I2S数据线的引脚冲突。

3.2 开发环境:ESP-IDF还是Arduino

固件开发环境我推荐ESP-IDF。虽然Arduino上手快,但这个项目涉及I2S双工、WebSocket、LVGL、低功耗管理,这些在ESP-IDF里控制粒度更细,尤其是在音频采集这块,IDF的I2S驱动对DMA缓冲和采样率的控制比Arduino库更稳定。Arduino也不是不能用,但我遇到过I2S麦克风采样偶尔丢数据的问题,排查半天最后发现是底层调度和DMA配置的问题,IDF下同样配置就稳定得多。

ESP-IDF的安装没什么好说的,官方安装器或者VS Code插件都能搞定。需要注意一点:LVGL和ESP-IDF的版本绑定要提前查清楚,我用的是ESP-IDF v5.1加LVGL 8.3,组合比较成熟,网上资料也多。如果用LVGL 9.x,接口变化很大,很多旧教程对不上,新手上手容易卡在编译错误上。

4. 固件端实现要点

4.1 I2S音频采集与播放

音频采集是整条链路的源头,这块写不好,后面所有环节都是白搭。我用的板载PDM麦克风,在IDF里配置成I2S外设的PDM模式,采样率16kHz、16bit、单声道,这个配置是语音识别最通用的输入格式,也是后台Whisper能直接接受的采样率。

初始化代码核心是这样:

i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); i2s_pdm_rx_config_t pdm_cfg = { .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(16000), .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), .gpio_cfg = { .clk = GPIO_NUM_5, .din = GPIO_NUM_4, }, };

每次读取用DMA双缓冲,一帧缓冲设成320字节,也就是10ms的音频,这样循环读取时延迟低,也方便后续按固定间隔通过WebSocket发送。播放方向同理,用I2S TX通道,数据从WebSocket收进来之后直接写进I2S输出到功放。需要特别注意的是,PDM麦克风的时钟引脚和功放的I2S引脚不要冲突,布局前先查清楚板子的原理图。

4.2 唤醒词:只跑轻量的,不跑重的

标题说“不跑模型”,准确说法是不跑大模型。为了让设备不用一直处于录音状态,我保留了一个轻量关键词唤醒,用的是ESP-SR里的WakeNet模型,专门识别“你好小糖”这个词。这个模型很小,两个唤醒词大概只占几百KB内存,ESP32-S3跑起来毫无压力。

如果不想用ESP-SR,也可以用最简单粗暴的方案:触摸按键唤醒。我实际使用中发现,在桌面环境中,轻敲屏幕或者按一下触摸键触发语音交互,反而比喊唤醒词更可靠,尤其在旁边有电视或音箱的环境里,误唤醒率很低。所以我的固件里同时支持两种触发方式,默认是触摸优先,唤醒词做成可选配置。

4.3 WebSocket客户端和音频流传输

固件里WebSocket客户端用ESP-IDF自带的esp_websocket_client组件,稳定性不错。音频上行逻辑是这样的:设备进入listening状态后,I2S DMA从麦克风取到10ms一帧PCM数据,经过Opus编码(我用的是libopus移植到ESP-IDF的版本),塞进WebSocket二进制帧,帧头里带上序列号和采样率信息,发送到后台。

为什么用Opus压缩而不是直接传PCM?16kHz单声道16bit裸流是32kB/s,局域网虽然扛得住,但一旦遇到WiFi波动,TCP重传会让延迟雪上加霜。Opus压到16kbps左右,字节数只有原来的八分之一,网络传输那几十毫秒的抖动基本感受不到。代价是ESP32端需要跑Opus编码,大概占一个核20%左右的CPU,完全值得。

4.4 LVGL界面:别让它抢CPU

LVGL在我这个项目中承担的是状态展示:圆形表盘、几个状态图标、一个简单的音频电平条。设计原则是界面常驻但不做复杂动画,尤其是不要在音频流收发的同时跑大量控件刷新,否则I2S的DMA会受到影响,出现音频毛刺。

我建议把LVGL的刷新任务优先级设得比WiFi任务低一点,并且在进入listening状态后,把不必要的动画暂停,只保留一个呼吸灯效果和电平指示。这样既保持了视觉反馈,又不会干扰音频链路的实时性。实际测试下来,整个LVGL任务占用CPU大概15%左右,对录音和播放的影响可以忽略。

5. 后台语音服务搭建

5.1 技术选型:FastAPI做入口,服务之间解耦

后台我用Python写,核心框架是FastAPI,WebSocket接入和REST接口都方便。语音识别用的faster-whisper,模型选了small级别,在CPU机器上识别一句五六秒的语音大约需要1.5秒左右,效果比base好很多。对话模型用的Ollama上的qwen2.5:7b,TTS我试过两个方案:edge-tts在线合成音质好但依赖网络,本地用piper跑离线合成响应更快。最终我选了edge-tts,因为运行时并不是完全禁止联网,音质和自然度比本地小模型好太多。

服务端不是单文件跑所有逻辑的,我拆成了三个模块:audio_server.py负责WebSocket收音频流和VAD静音检测;llm_bridge.py负责调用Ollama并生成回复文本;tts_engine.py负责把回复文本合成为音频并编码为Opus下发。三个模块之间通过队列通信,一个请求进来后,在audio_server里完成VAD分段,然后依次调用ASR、LLM、TTS,同步串行处理。刚开始图简单串行没问题,并发请求多了就得改成消息队列,但在家用场景下完全够用。

5.2 音频接收入口与VAD

后台WebSocket收流后,先把Opus数据解码成16kHz PCM,然后喂进VAD模块,我用的是webrtcvad。VAD的灵敏度参数我调成了mode 1,在安静环境下能准确区分语音和静音,不易过早截断或拖尾。静音持续1.2秒我就认为当前句说完,触发识别。

这个1.2秒的阈值是经验值。太短,用户说话中途稍微停顿一下就被截断;太长,交互节奏拖沓。初次使用可以在0.8~1.5秒之间调整,环境安静用短一点的,有背景噪声就调长点。

5.3 TTS回传音频流与播放

TTS合成完成后,我把音频也通过同一个WebSocket连接发回设备,这样不用维护第二个长连接,避免端口和状态同步的问题。下发时同样用Opus编码,按20ms一帧发送,每帧之间不做额外延时,设备端收一帧播一帧,实测音频播放非常连贯,没有卡顿。

有一段时间我遇到过一个诡异的问题:TTS开头总被吃掉一小截,百思不得其解。后来发现是WebSocket消息在ESP端进入播放缓冲时,I2S还没完全准备好,早期的几帧被丢掉了。解决方案是在固件里做了一个“启动预滚”逻辑:收到第一帧音频后,不立即播放,而是等积累了120ms的数据再启动I2S输出。这个缓冲量对互动语音来说增加延迟可忽略,但能彻底避免开头截断。

6. 延迟拆解与实测优化

6.1 端到端延迟构成

整套系统实测下来,从用户说完话到听到回复,大约需要2.8到4秒。拆解开来看:

环节耗时说明
设备音频上传50~150ms网络传输+Opus编码
VAD静音判定1200ms等待用户停顿,可调
ASR识别800~1500msfaster-whisper small
LLM生成首token300~1000msOllama加载和推理
TTS合成300~800msedge-tts整句合成
音频回传播放50~150ms数据回传+播放缓冲

最大的瓶颈是VAD等待时间和ASR识别。VAD的1.2秒等待是结构性的,除非做流式ASR边听边识别,否则很难省掉。ASR换成base模型能省一些时间,但识别准确率下降明显,我权衡后保留small。

6.2 我做的几项关键优化

第一是TTS整句下发改为边合成边下发。edge-tts支持流式合成,我改成了按句子切分,合成完一句就发一句,用户听到第一句的时间能提前不少,体验上感觉快很多。第二是在ESP端尽量保持WiFi连接稳定,避免频繁重连。ESP-IDF的WiFi省电模式我直接关掉了,宁可多耗一点电也要降低连接抖动。第三是后台服务加了一个asyncio并发处理,虽然实际请求还是一个一个来,但WebSocket心跳和音频接收不会因为阻塞而断开。

7. 常见问题与排查实录

7.1 常见问题速查表

问题现象可能原因处理方法
录进去的音频有爆音/杂音电源不稳或I2S位深配置不一致换成独立电源,检查位深统一16bit
TTS播放开头丢字I2S未准备好就写入增加播放预滚缓冲,积攒120ms再播放
端到端延迟越来越大后台队列堆积检查ASR和TTS是否阻塞,改成异步调用
唤醒词经常误触发环境噪声干扰调高WakeNet灵敏度阈值,或改用触摸触发
WebSocket偶发断线WiFi省电模式导致ESP-IDF里关闭WiFi省电或设置最大性能模式
后台报内存不足多路任务占用降低Whisper推理线程数,或增大后台机器内存

7.2 独家避坑经验

最值得说的一条经验是:永远不要在第一版就追求全双工。我做原型时为了省事,让录音和TTS播放完全并行,结果回声严重到后台识别出来全是自己的声音,花了整整两天调AEC,最后发现与其在那调算法,不如改成半双工逻辑,效果立刻能用了。等核心流程跑通,再考虑AEC也不迟。

另一条是关于WiFi环境的。ESP32-S3只支持2.4GHz频段,如果你的路由器同时开了2.4G和5G且同名,设备可能会频繁在频段之间漫游,导致连接不稳定。我最后给圆屏单独分了一个2.4GHz的SSID,避免漫游带来的断流。这在很多人住的小区WiFi拥堵的环境下尤其重要,信道也建议手动固定,别用自动,否则邻居路由器一换信道,你的延迟就跟着飘。

还有一条是后台服务的日志问题。音频流服务一旦跑起来,日志量非常大,每个帧都打日志会拖垮性能。我建议只打印状态切换和错误事件,音频帧本身不要打日志。调试时用临时开关控制,生产环境默认关闭。

做了三期糖球,我最大的感受是:硬件工程师很容易陷入“什么都要在设备上做”的执念,但真正好用的产品一定知道哪些事不应该自己做。ESP圆屏做语音客户端,不是无奈妥协,而是让每个环节都用最擅长的方式工作。动手做一个吧,你会发现在的MCU设备接上后台大模型之后,能玩出之前完全不敢想的花样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询