ESP32-S3圆屏语音客户端:不跑模型,专注交互节奏
2026/9/9 3:58:30 网站建设 项目流程

做糖球这个系列做到第三篇,我发现很多人的注意力都被“圆屏”和“模型”吸走了。每次发视频都有人问:这屏幕能跑个语音模型吗?能离线对话吗?能不能接个大模型当桌面助手?说实话,这些问题我一开始也认真想过。但糖球三代的最终方案恰恰是反着来的——它不在ESP上跑任何ASR模型、不跑LLM,只老老实实做一个后台语音服务的客户端。这篇就把这个决策背后的账、音频链路的实现细节、以及从“Demo能跑”到“日用不翻车”中间那些坑,一次性讲透。

先交代清楚糖球三代是什么形态:一颗ESP32-S3,一块1.28寸圆形GC9A01屏幕,一颗I2S数字麦克风,一颗小功放加扬声器。它能干嘛?你对着它说话,它把音频送到后台,后台完成语音识别、语义理解和语音合成,再把结果流回来,它负责播放、亮屏、用动画告诉你当前状态。整个链路里,ESP的算力几乎都用在了I2S采集、音频编码、网络收发和屏幕渲染上,没有一毫秒花在模型推理上。


1. "端侧不跑模型":先把这个决策背后的账算清楚

1.1 同一颗芯片,跑ASR和跑语音客户端的差距

很多人看到ESP32-S3带向量指令、带PSRAM,就觉得它能跑点模型。严格讲,S3确实能跑一些经过极端量化的唤醒词模型和命令词模型,ESP-SR框架里也封装好了WakeNet和MultiNet,跑个十几KB的唤醒词模型没问题。但问题在于,语音对话需要的远不止“识别出几个词”。完整链路是:语音活动检测(VAD)→ 语音识别(ASR)→ 语义理解(NLU)→ 对话管理 → 语音合成(TTS)。这里面随便拿出一个环节,都不是几百KB模型能搞定的。

以ASR为例,想在端侧获得还能用的中文识别效果,模型文件至少几十MB起步,而且运行时内存占用会远超S3能承受的范围。即便硬塞进去,识别延迟、错误率也会让你怀疑人生:环境一吵,识别结果基本没法看。更别提对话模型了,哪怕是一个1B参数、4bit量化的模型,也得几百MB内存,S3直接出局。

所以我的结论很明确:ESP32-S3这颗芯片的定位是“IoT级”而非“AI推理级”。把它当语音客户端用,每个算力周期都花在刀刃上;硬塞模型,只会把它拖成一个又卡又蠢的电子垃圾。

1.2 语音链路的真实瓶颈:不是"本地/云端",而是响应节奏

还有一个反直觉的点我要重点说:语音交互体验差的根源,往往不是“识别在本地还是云端”,而是整条链路没有设计好“人机对话的节奏感”。

人在跟设备说话的时候,最敏感的不是那100ms的识别延迟,而是“设备有没有在听我”“设备听懂了吗”“设备正在干什么”这三件事。PC端的语音助手之所以让人觉得流畅,不是因为模型跑得快,而是因为状态反馈做得密:你一开口,它的波形就动;你一说完,它立刻说“正在处理”;回复时,它能流式吐字。这套反馈机制,比单纯追求低延迟更重要。

糖球正是围绕这个理念做的:ESP端只管录音、发送、接收、播放、驱动屏幕动画。因为不做推理,DSP负载极低,音频采集的实时性有保障;因为不做推理,屏幕渲染的帧率也稳定,任何状态变化都能立刻反映到动画上。把有限的资源全砸在“交互节奏”上,体验自然就上来了。

1.3 这也让后台服务能力自由生长

端侧不跑模型还有一个实际好处:后台想换什么引擎就换什么引擎。今天用Whisper做ASR,明天换Paraformer;今天接闭源大模型,明天换本地的Ollama服务。这些变更只在服务端发生,糖球完全不用升级固件。如果哪天我想给糖球加一个“声纹识别”功能,也只需要后台多跑一个模块,客户端连感知都没有。模型的世界变化太快,把模型相关的部分全部剥离到后台,设备端才能保持长期稳定。


2. 糖球的硬件分工:屏幕UI、麦克风、和那颗S3

2.1 圆屏不是装饰:它是最重要的状态反馈出口

说回到硬件。1.28寸GC9A01圆屏,240x240分辨率,SPI接口,刷新率实测能做到40FPS以上。当时选这块屏的原因很简单:圆形在视觉上没有方向性,特别适合做“呼吸、闪烁、流转”这类状态反馈。很多人觉得圆屏只是好看,但在语音客户端这个场景里,它是交互的灵魂。

我给它设计了四套状态动画:

  • 待机:屏幕低亮度呼吸,表示在听唤醒词;
  • 聆听:屏幕边缘水波纹扩散,表示正在录音;
  • 思考:中心有个转动的环形光雾,表示后台正在处理;
  • 说话:波形起伏,表示正在播放TTS音频。

这些动画全部基于LVGL自定义绘制,CPU占用很轻。真正要小心的是SPI总线冲突:如果音频I2S和屏幕SPI共用总线,一旦传输大块数据,屏幕就会闪。我最后把屏幕SPI放在单独的SPI2主机上,I2S走独立引脚,彻底解决。

2.2 I2S音频通道的器件与接线

麦克风用的是INMP441,这是一颗I2S接口的MEMS数字麦克风,24bit输出,信噪比61dB,对糖球这种近场语音场景足够。功放是MAX98357A,3W D类,直接驱动8Ω/2W的小喇叭。接线就五根:VDD、GND、SCK(BCLK)、WS(LRCLK)、SD(数据)。注意INMP441的L/R引脚:接GND代表左声道,接VDD代表右声道。这个细节坑过很多人,焊反了就是无声。

我个人建议I2S引脚分配用ESP32-S3的GPIO 4/5/6/7,方便走线,也避开与PSRAM共用的引脚。ESP32-S3的PSRAM和部分外设共用总线,选引脚时务必参考IDF的引脚矩阵文档,不要随便飞线。

2.3 供电与底噪:桌面设备最容易翻车的地方

硬件上最容易翻车的不是接错线,而是供电。INMP441对电源纹波非常敏感,如果你用劣质USB线或者电源适配器纹波大,录音里会带明显的“嗡嗡”电流声。我一开始用了面包板供电,底噪惨不忍睹,后来改成独立的3.3V LDO + 10uF和0.1uF去耦电容,底噪才压下去。

喇叭和麦克风在物理上也要尽量分开。MAX98357A输出功率虽小,但喇叭震动会通过PCB传导给麦克风,形成机械回声。我把喇叭固定在设备底部,麦克风朝上,中间加了硅胶减震垫,效果立竿见影。千万别贪方便把两个器件焊在同一个刚性板上,哭都来不及。


3. 录音端到端:从麦克风到WebSocket的一帧音频

3.1 采样参数、DMA缓冲与音频格式的确定

音频参数我建议直接按语音链路的通用标准来:单声道、16kHz采样率、16bit位深。为什么是16kHz?因为绝大多数ASR引擎的输入标准就是16k单声道,用48k采样还要重采样,白白增加开销。16bit位深对语音也足够,24bit的动态范围在近场场景下根本用不上。

ESP-IDF的I2S驱动里,DMA缓冲区配置很有讲究。糖球用的是I2S_NUM_0,DMA描述符个数8个,每个缓冲长度1024字节。换算一下:16kHz、16bit、单声道,每秒32000字节。DMA每块缓存1024字节就是32ms的音频,8块缓冲能撑约256ms,这个余量足以应对WiFi瞬时卡顿,同时延迟又不至于太高。

配置I2S的核心代码大致长这样:

i2s_config_t i2s_cfg = { .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, .dma_buf_count = 8, .dma_buf_len = 1024, .use_apll = false, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1 }; i2s_driver_install(I2S_NUM_0, &i2s_cfg, 0, NULL); i2s_pin_config_t pins = { .bck_io_num = GPIO_BCLK, .ws_io_num = GPIO_WS, .data_out_num = GPIO_DOUT, .data_in_num = GPIO_DIN }; i2s_set_pin(I2S_NUM_0, &pins);

如果读取时发现I2S返回的数据全是0xFFFFFFFF,别急着查代码,先检查INMP441的L/R引脚接地没有。

3.2 Opus编码还是裸PCM?

音频数据直接走WebSocket发裸PCM,这是最简单的方式,但会浪费带宽和后台解析成本。16k/16bit单声道是32KB/s,一次30秒对话就有近1MB裸数据,传输体验不佳。所以我用了Opus编码,码率24kbps,延迟20ms一帧,同样的30秒语音压缩到约90KB。

在ESP32上跑Opus很简单,esp-opus这个组件已经封装好了编码器:

OpusEncoder *enc = opus_encoder_create(16000, 1, OPUS_APPLICATION_VOIP); opus_encoder_ctl(enc, OPUS_SET_BITRATE(24000)); opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(5));

注意复杂度不要拉满。ESP32-S3虽然不弱,但复杂度10会让单核CPU飙到40%以上,而复杂度5-6就能在听感几乎不变的情况下把占用压到10%以内。每一帧编码前,先做VAD,检测到静音就跳过发送,只在用户说话时传数据,这能让后台服务省下大量无效计算。

3.3 发送时序与背压控制

音频数据是实时流,不能攒一大堆再发。我的做法是每20ms一帧,用定时器驱动,编码完直接扔进WebSocket发送队列。WebSocket客户端用的是esp_websocket_client组件,发送接口本身是异步的,底层会帮你排队。但要注意:如果WiFi信号差或后台处理慢,发送队列会堆积,延迟越来越大。

解决办法是背压控制:维护一个待发帧计数器,每次发送加一,每次收到后台的“该帧已收到”确认消息减一。当积压帧数超过20帧(相当于400ms),就丢弃最老的音频帧,保持实时性。语音识别这行,延迟比丢帧更致命。你宁可让后台少听几个字,也别让它听到延迟半秒的话。


4. 下行链路:接收结果、TTS播放与状态切换

4.1 用二进制帧还是JSON

糖球和后台的通信协议,我一开始想用纯JSON,结构清晰、调试方便。但接收TTS音频流时,JSON的Base64编码会让数据膨胀33%,编解码也浪费CPU。后来改成二进制帧协议,帧头4字节:1字节帧类型、2字节负载长度、1字节标志位,后面跟负载数据。帧类型分成这么几种:

帧类型负载内容
状态帧0x01当前阶段(思考中/说话中)
文本帧0x02识别出的中间文本或最终文本
音频帧0x03Opus编码的TTS音频数据
确认帧0x04客户端回传给后台的记录确认
指令帧0x05控制命令(停止播放/重连等)

UI上的“思考中”动画由状态帧驱动,“文字信息”可以滚动展示在屏幕底部。TTS音频帧则在解码后直接送I2S播放。这套协议在UDP不可靠、TCP粘包的场景下也很稳,因为每帧自带长度字段,后台解析时只要按长度截断就行。

4.2 TTS流式播放与回声问题

TTS音频的播放要注意回声问题。如果麦克风还在录音,喇叭突然放出后台的语音应答,麦克风会把自家喇叭的声音录进去,后台再识别就乱套了。所以糖球在下行链路建立后,会立刻暂停上行采集,即进入半双工模式:说话时只听不讲,播放时只讲不听。这是语音设备最基础的“按键说话”逻辑,但也是很多人最容易忽略的地方。

还有一个细节是播放前的缓存。TTS网络抖动时,如果边收边播,会出现“一顿一顿”的听感。我设置了一个短缓存:先攒够150ms的音频再开始出声,之后按固定节奏播放。这样缓冲能吸收大部分网络抖动,听感会顺滑很多。缺点是首字延迟多了150ms,但在实际对话中几乎感知不到。

4.3 界面状态机:待机/聆听/思考/说话

屏幕动画和音频链路之间需要一个清晰的状态机。糖球的状态流是这样的:待机中唤醒词触发录音→进入聆听态;录音结束发送完最后一帧,后台返回状态帧表示开始解析→进入思考态;后台开始返回TTS音频帧→进入说话态;播放完毕→回到待机态。

其中“录音结束”的判断,我在ESP端做了一层简单的能量VAD:如果连续600ms没有检测到足够响度,就认为一句话说完了,主动发送一个“音频结束”帧给后台。这样省去了后台做端到端检测的等待时间,对话节奏会明显变快。后台还可以继续做二次VAD,两者配合,识别准确率更稳。


5. 连接可靠性:语音客户端最容易死在的地方

5.1 心跳、断线重连与指数退避

语音客户端在桌面上跑一天,最怕的不是功能bug,而是网络连接悄悄死掉。TCP连接看着还在,但实际上后台已经不再响应,或者路由清掉了会话。为了防这个,糖球每隔30秒发一个心跳包,后台需要在5秒内回一个心跳确认。连续3次没有确认,就判定连接断开,进入重连流程。

重连不能用固定间隔,必须用指数退避。我实测过:固定3秒重连,WiFi一抖动,所有设备同时重连,直接把后台打挂。指数退避从1秒开始,每次翻倍,封顶30秒:1s、2s、4s、8s、16s、30s、30s……这样单台设备最多每30秒发一次重连请求,后台压力小得多。

5.2 后台服务组件的对接细节

后台我拆成了三个独立组件:ASR服务、对话服务、TTS服务,中间用消息队列串起来。

糖球上传音频→ASR服务返回文本→对话服务把文本发给大模型→得到回复后发给TTS服务→TTS流式返回Opus音频→糖球播放。这套流水线是异步串行的,任何一个环节卡住都不影响其他会话。

这里有个小技巧:如果对话服务接的是带流式输出的模型(比如OpenAI兼容接口的stream模式),不要让TTS等到整段文本生成完再合成。应该按句自然切分,模型流式吐出完整句子时立刻合成,这样首句回复会快很多。我实测同样一段300字的回复,流式切句方案的首字延迟比整段合成方案快了1.5秒左右,体感差距非常大。

本地模型方面,如果后台部署了Ollama这类本地推理服务,糖球这边完全不用做任何适配,因为对话组件接的是标准HTTP接口,只关心文本输入输出。这也是当初坚持“糖球不跑模型”的回报——后台想换本地模型还是云端模型,客户端零改动。

5.3 ESP-IDF编译绕坑记录(vscode/ninja)

开发环境这块也该说说,因为在Windows上配ESP-IDF真的能劝退一批人。这里推荐直接用VS Code加Espressif IDF插件,装好之后会自动管理工具链。但有个常见问题:用vscode编译时,ninja.exe会在中途退出,报“exit code 1”之类,十有八九不是代码问题,而是路径里有中文或空格。ESP-IDF工具链对路径极其敏感,项目路径宁可全英文也不要有任何特殊字符。

另外如果你打开多个IDF项目窗口,终端会提示python环境冲突。解决办法就是每次只开一个项目,或者用IDF插件自带的“ESP-IDF: Select Port”选择正确的串口。还有一点:升级IDF版本后,务必先执行一次“ESP-IDF: Clear ESP-IDF environment”,让插件重建缓存的配置。否则会出现“头文件找不到”但代码本身没错的诡异bug。

糖球的配置信息(WiFi密码、后台地址、唤醒词模型版本)我放在NVS分区里,用idf.py menuconfig设置好一次之后就再也不用改。这样规避了每次改代码都要重新烧录全量固件的麻烦。


6. 实测调优记录:延迟、功耗与稳定性

6.1 各环节延迟实测

我自己搭了一个测试环境:糖球放在卧室,后台跑在一个路由器旁边的迷你主机上,WiFi走的是2.4G频段。用100句测试语音实测,统计各环节时间:

环节平均耗时备注
本地VAD判定说完0.6s连续静音检测耗时
音频上传+后台ASR0.9s24kbps Opus上传耗时
对话模型处理1.2s3B模型,本地推理
TTS合成+流式下行1.5s30字回复,流式切句
客户端播放缓存0.15s150ms平滑缓冲
整体首字延迟约4.4s用户说完到听到首个字

4.4秒的首字延迟不算极致,但在桌面语音助手里是可以接受的。如果想进一步压,可以把VAD判定说完的时间从600ms缩短到400ms,或启动TTS和ASR的并行预取,能再省0.4秒左右。

6.2 功耗与内存占用

用功率计实测,糖球工作状态下的功耗大概是:

状态电流(5V供电)说明
待机(屏幕低亮)80mA深度睡眠外的最低功耗
聆听(录音+WiFi发送)210mA音频编码和网络占大头
思考(屏幕动画)150mA等待后台返回
说话(TTS播放)260mA扬声器峰值功耗
平均日常使用180mA按交互频率估算

内存方面,系统跑起来之后:空闲堆内存在180KB左右,PSRAM用量约1.2MB(主要是LVGL缓冲区、Opus编码器工作区、网络收发缓冲)。这个余量还算健康,如果后续想加屏幕更复杂的动画,内存也够。

6.3 稳定性测试

我做了72小时不间断压力测试:每小时触发50次对话,加上随机断网模拟。最终结果:断线重连成功率100%,平均重连时间3.2秒;WebSocket连接在连续运行期间掉线3次,全部靠心跳机制发现并恢复。测试过程中实际暴露过一个坑:WiFi掉线时WebSocket客户端会自动重连,但重连成功后TCP窗口没有重置,导致音频帧乱序。后来在每次重连成功时清空发送队列和接收缓冲,问题解决。


最后再分享一个个人很受用的小经验:做这类“设备端只管交互、后台负责能力”的项目,一定要在前几版固件里就把日志体系和状态上报做好。糖球每次对话的关键节点(VAD触发、音频帧发送、状态帧切换、播放结束)都会用ESP_LOG输出,并且上报一条结构化日志到后台。后期调稳定性时,没有这些日志,你都不知道问题出在设备端还是服务端。希望这次的拆解能帮到正在做类似桌面语音设备的朋友。如果你也在折腾圆屏、语音客户端、或者纠结“端侧到底要不要跑模型”,欢迎一起交流。

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

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

立即咨询