ESP32-S3圆屏语音客户端:不跑模型,专注交互节奏 做糖球这个系列做到第三篇我发现很多人的注意力都被“圆屏”和“模型”吸走了。每次发视频都有人问这屏幕能跑个语音模型吗能离线对话吗能不能接个大模型当桌面助手说实话这些问题我一开始也认真想过。但糖球三代的最终方案恰恰是反着来的——它不在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、麦克风、和那颗S32.1 圆屏不是装饰它是最重要的状态反馈出口说回到硬件。1.28寸GC9A01圆屏240x240分辨率SPI接口刷新率实测能做到40FPS以上。当时选这块屏的原因很简单圆形在视觉上没有方向性特别适合做“呼吸、闪烁、流转”这类状态反馈。很多人觉得圆屏只是好看但在语音客户端这个场景里它是交互的灵魂。我给它设计了四套状态动画待机屏幕低亮度呼吸表示在听唤醒词聆听屏幕边缘水波纹扩散表示正在录音思考中心有个转动的环形光雾表示后台正在处理说话波形起伏表示正在播放TTS音频。这些动画全部基于LVGL自定义绘制CPU占用很轻。真正要小心的是SPI总线冲突如果音频I2S和屏幕SPI共用总线一旦传输大块数据屏幕就会闪。我最后把屏幕SPI放在单独的SPI2主机上I2S走独立引脚彻底解决。2.2 I2S音频通道的器件与接线麦克风用的是INMP441这是一颗I2S接口的MEMS数字麦克风24bit输出信噪比61dB对糖球这种近场语音场景足够。功放是MAX98357A3W D类直接驱动8Ω/2W的小喇叭。接线就五根VDD、GND、SCKBCLK、WSLRCLK、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_0DMA描述符个数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输出并且上报一条结构化日志到后台。后期调稳定性时没有这些日志你都不知道问题出在设备端还是服务端。希望这次的拆解能帮到正在做类似桌面语音设备的朋友。如果你也在折腾圆屏、语音客户端、或者纠结“端侧到底要不要跑模型”欢迎一起交流。