ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ESP32圆屏语音助手不跑模型?端云分离架构解析

ESP32圆屏语音助手不跑模型?端云分离架构解析 做智能硬件这些年我最大的感触就是很多人一拿到带屏幕的ESP32开发板第一反应就是要让它“本地跑个模型”仿佛不上模型就浪费了这块屏。这其实是个误区。拿我手头的糖球系列来说前两代分别解决了圆屏UI交互和音频采集播放的问题第三代我直接定了调子——糖球不跑模型它只是后台大模型的语音客户端。这篇就把整个方案的选型逻辑、数据链路、端侧实现和踩坑点完整拆开讲一遍给正在做类似桌面语音助手的兄弟做个参考。作为“糖球系列③”本文适合三类人看想在ESP32圆屏上做语音交互但不知道怎么设计架构的手上已经有圆屏开发板、想接大模型但不想让设备卡死的以及单纯想了解嵌入式设备如何与大模型后端协同工作的。内容偏实操代码片段可以直接抄但更重要的是理解为什么这样设计。1. 先理清定位糖球不是“模型设备”而是“语音终端”1.1 糖球到底是个什么东西糖球项目本质上是一个桌面级语音交互设备ESP32-S3做主控正面一块圆形触摸屏内部塞了一个I2S数字麦克风和一个I2S功放喇叭外壳做成球形或圆饼形摆在桌面上。它的用法很简单——你对着它说话它把语音传上去然后屏幕上显示回复文字、喇叭里放出语音回复。听起来像不像智能音箱对本质就是一个私有化的、可自己改代码的桌面语音助手。但这里要划重点糖球自己是不会“思考”的。它既没有跑ASR语音识别也没有跑LLM更没有跑TTS。它只做三件事采集你的声音、把声音发给后台、把后台返回的文字和语音播放出来。换句话说它是一个非常纯粹的“语音客户端”。这不是偷懒而是被资源限制逼出来的正确选择。ESP32-S3这颗芯片双核240MHz自带最多512KB SRAM外挂PSRAM顶到8MB左右。而一个像样的量化大模型参数少说也要几十亿光权重就是几个GB起步这还不算推理时的中间激活值。而且LLM推理那种密集矩阵运算要的是GPU或至少带NPU的SoCMCU级别的芯片连门都摸不着。1.2 为什么不让ESP直接跑模型先算一笔账。就算我们把模型压缩到极致搞一个几十MB的微型模型能不能塞进8MB PSRAM不能。就算塞进去了推理速度是多少一个token可能要好几秒你问它一句“今天天气怎么样”它憋两分钟才回你一个“你”字这体验谁受得了。更不用说内存带宽和发热问题MCU长时间高负载推理机身烫手是必然的。还有个更实际的问题模型不是跑起来就完事了它需要不断更新、调优、换版本。如果模型在设备端每次改提示词、换模型权重都要重新编译固件、OTA升级运维成本高到爆炸。而把模型放后台前端固件几乎不用动后台升级模型对设备完全透明。这就是“端云分离”的核心价值。我见过不少朋友非要在ESP32上跑个TinyML的唤醒词模型那确实可以因为唤醒词模型就几百KB跑在MCU上没什么压力。但要是把LLM也往MCU上塞那就属于方向性错误了。糖球的设计哲学就是把“大脑”留给后台把“嘴和耳朵”留给端侧各干各擅长的活。1.3 后台选型云API还是本地模型既然模型放后台又面临一个选择用云端大模型API还是自己在局域网里跑本地模型这俩我都试过各有适用场景。云端API的优点是接入快、效果强、不用管硬件。你只要注册一个平台账号拿一个API Key把音频传上去、把文本发出去、把音频拉回来半小时就能打通全链路。缺点是每轮对话都有延迟波动而且完全依赖外网质量。我家里网络算稳定的但高峰期调用大模型接口返回一段几十字的文字也要花两三秒加上TTS生成和音频传输总延迟轻松到五秒以上。偶尔网络抖动还会出现连接超时设备直接卡在等待状态。局域网本地模型则舒服很多。我在书房一台带RTX 3060的旧电脑上装了Ollama跑一个7B的量化模型局域网内调用延迟稳定在一秒左右TTS用本地引擎整体交互延迟压到两秒内。而且不依赖外网数据也留在家里隐私性更好。缺点是首次配置有门槛显卡显存至少6GB起步效果也不如云端大模型那么聪明。糖球的方案是两者都兼容后台服务做成统一接口通过配置切换云API或本地模型。这样既能快速跑通演示也能在稳定场景下切到本地。这个设计我会在第3节详细讲。2. 整体链路和核心模块拆解2.1 从按住说话到语音回复数据流怎么走糖球的完整交互链路是这样的用户触摸屏幕上的语音按钮设备开始录音用户说完松手设备把音频压缩上传到后台后台先做ASR识别成文字再把文字喂给大模型得到回复文本大模型文本交给TTS合成语音后台把回复文字和音频一起下发给糖球糖球一边在圆屏上显示文字一边通过I2S功放播放音频。整个链路里糖球只参与了录音、上传、接收、播放这四步。ASR、LLM、TTS全在后台完成。如果哪一步出问题糖球只负责超时复位而不是自己去“思考”怎么回答。这套链路用一句话概括就是糖球是一个“远程大脑”的外围终端。这也是我在系列前两篇里反复强调的——先定架构再写代码。架构不对后面全是补丁。2.2 端侧四大模块采集、回放、网络、状态机我把糖球端侧的代码按功能拆成四个模块每个模块独立运行通过消息队列通信。音频采集模块负责从I2S麦克风读取PCM数据做简单的降噪和VAD语音活动检测判断用户是否在说话、什么时候说完。音频回放模块负责把后台返回的压缩音频解码成PCM写入I2S功放播放同时支持“打断”功能——用户再次触摸屏幕时立刻停止当前播放重新进入录音状态。网络模块负责维护与后台的WebSocket长连接处理断线重连、心跳保活和上下行消息分发。状态机模块则是整个设备的骨架管理当前处于什么状态、允许什么操作、超时后怎么切换。这四个模块里最容易被忽视的是状态机。刚开始做糖球的时候我直接用if-else处理交互逻辑结果录音中收到播放指令、播放中用户又按下录音这种边界情况能把设备搞死。后来痛定思痛用标准的有限状态机重写了交互逻辑定义了空闲、录音中、等待回复、播放中、错误五个状态每个状态的跳转条件都列清楚稳定性提升了一个量级。2.3 协议设计怎么让端侧“省脑子”因为糖球只是客户端我给它设计的通信协议也尽量简单。整体采用WebSocket长连接消息格式是轻量JSON事件按方向分为上行和下行。上行糖球→后台有三个核心事件audio_start表示开始上传一段新的语音请求audio_chunk是二进制音频数据分片audio_end表示语音数据发送完毕后台可以开始处理。下行后台→糖球有三个核心事件asr_result返回识别出的文字llm_answer返回大模型的回复文本tts_audio返回合成好的音频数据base64编码或二进制。另外还有一个error事件后台通过它向下推送错误码和错误描述。为什么用WebSocket而不是HTTP因为语音交互是双向实时的。HTTP是“一问一答”模式糖球上传完音频要一直轮询后台“好了没”不仅浪费流量延迟还高。WebSocket建立一条连接后两端随时可以互推数据后台处理完直接推送糖球不需要频繁发请求。而且WebSocket的二进制帧天生适合传音频数据我甚至不需要额外做分帧协议直接在消息头里加个字段标记音频编码格式就行。协议里还有个细节每条消息都带request_id用于关联一次完整的语音交互。这样糖球本地可以判断当前播放的音频和屏幕上显示的文字是不是同一次请求的结果避免视频和音频错乱。3. 实际落地从录音到播放的完整实现3.1 前端采集VAD切分的朴素做法录音的原始格式我选的是16kHz、16bit、单声道PCM。这是ASR模型最友好的格式也是I2S麦克风最直接的输出格式。但如果直接把PCM往后台传一分钟音频就是1.92MB网络再快也经不起这么造。所以在端侧我做了一道Opus编码码率压到24kbps一分钟音频只有180KB体积缩小了十倍以上。VAD我用的是一种朴素的能量检测方法没有上语音活动检测模型计算量小效果也够用。具体逻辑是持续计算每20ms一帧的短时能量能量高于阈值判定为“有人声”进入录音缓冲如果连续超过600ms没有高能量帧判定为“话已说完”自动结束录音并上传。阈值我调过很多次最终定为环境底噪的2.5倍这个值在普通室内环境比较稳。关键代码逻辑示意如下#define FRAME_MS 20 #define SILENCE_MS 600 #define ENERGY_THRESHOLD_RATIO 2.5f static float calc_energy(const int16_t *samples, int len) { float sum 0; for (int i 0; i len; i) { sum (float)samples[i] * samples[i]; } return sum / len; } // 在I2S读取回调里调用 float energy calc_energy(buffer, samples_read); if (energy noise_floor * ENERGY_THRESHOLD_RATIO) { silent_frames 0; recording true; } else if (recording) { silent_frames; if (silent_frames SILENCE_MS / FRAME_MS) { // 结束录音发送audio_end send_audio_end(); recording false; } }这里有个容易踩的坑noise_floor不能写死最好在设备启动后先采样200ms环境噪声作为初始底噪否则换个环境设备就傻了。另外很多I2S麦克风本身有直流偏置读出来的音频会有一个很大的固定偏移导致静音时能量也不低。解决办法是先做高通滤波或用减法器去掉直流分量我在实际项目中是直接减掉一个固定的DC偏移值简单有效。3.2 网络传输WebSocket的可靠复用端侧网络我用的是ESP-IDF官方维护的esp_websocket_client组件对接WebSocket服务端非常顺手。建立连接后先发送一条hello消息带上设备ID和协议版本后台收到后回复welcome同时设置心跳。心跳我采用的是每隔30秒发送一次ping消息后台收到后回pong。如果不做心跳运营商路由器和云服务器的NAT映射会在几十秒到几分钟内自动释放空闲连接导致连接假死——表面上看Socket还在实际上数据根本发不出去。音频上传的时候我用的是WebSocket的二进制帧通过esp_websocket_client_send_bin发送。每帧数据控制在1024字节以内因为ESP32的TCP发送缓冲有限一次性塞太多数据会触发阻塞影响录音采集。正确的做法是边录音边将Opus编码后的音频包塞进发送队列后台消费者线程再从队列里取出并发送这样录音和上传互不阻塞。断线重连也是必须做的。我遇到的情况是家里路由器偶尔重启或者WiFi信号弱导致连接断开。最开始我没做重连设备一旦断线就只能重启体验极差。后来加了指数退避重连第一次断线后等1秒重连失败则等2秒、4秒、8秒最多等60秒就不再加倍避免无限重试把服务器打崩。重连成功后糖球会自动重新发hello并恢复心跳用户感知不到发生了什么。3.3 服务端中转一个最小可用的Python服务后台服务我用Python FastAPI写的它承担三件事接收音频、调用ASR、调用LLM、调用TTS然后把结果组包下发给设备。因为ASR和LLM都需要不确定时间必须用异步或线程池处理否则一个请求卡住整个服务就瘫痪了。下面是简化版核心代码实际项目中我会把ASR、LLM、TTS三个环节各封装成一个可替换的类通过配置文件切换实现from fastapi import FastAPI, WebSocket import asyncio app FastAPI() app.websocket(/ws) async def websocket_endpoint(ws: WebSocket): await ws.accept() audio_chunks [] request_id while True: msg await ws.receive() if msg[type] websocket.connect: continue if msg[type] websocket.disconnect: break data msg[bytes] if bytes in msg else msg[text] # 二进制帧音频数据累积到列表 if isinstance(data, bytes): audio_chunks.append(data) else: # 文本帧控制消息 import json obj json.loads(data) if obj[event] audio_start: request_id obj[request_id] audio_chunks.clear() elif obj[event] audio_end: # 合并完整音频 pcm b.join(audio_chunks) # 三个AI环节串联 text await asr_worker(pcm) # 语音转文字 answer await llm_worker(text) # 大模型生成回复 tts_data await tts_worker(answer) # 文字转语音 # 下发结果 await ws.send_json({event: asr_result, request_id: request_id, text: text}) await ws.send_json({event: llm_answer, request_id: request_id, text: answer}) await ws.send_bytes({event: tts_audio, request_id: request_id, audio: tts_data})ASR我用的是WhisperLLM则通过OpenAI兼容接口调用。这里特别说一下接口设计后台服务定义了一个llm_worker内部根据配置文件选择调用云端API还是本地模型。初始阶段用云端API跑通全流程后面切换本地模型只需要改配置不用动设备端代码。这个兼容层设计帮我省了大量后续调试时间。还有一个很实用的设置给大模型的系统提示词里明确要求“回复控制在50字以内口语化适合语音播放”。这么做是因为TTS读长文本的时候很生硬用户也没耐心听一长段文字。更重要的是大模型有输出token上限如果回复又长又杂容易在生成中途被打断返回的内容可能是不完整的半句话接上TTS之后用户听到的就是“然后呢没了”这种体验。3.4 端侧播放解码、播放与打断后台下发的音频我统一用PCM格式省去端侧解码步骤代价是流量大一点。因为糖球是WiFi设备局域网环境下流量完全不敏感但如果是4G/5G物联网卡方案就得改用压缩格式了。PCM数据到达端侧后直接按I2S协议写入DAC播放代码路径最短出问题概率最小。播放环节有个细节必须实现“打断”。用户正在听糖球回答的时候如果又按下了语音按钮糖球应该立刻停止播放清空播放缓冲重新进入录音模式。这个动作看似简单但如果不做缓冲清空旧的音频会和新录的音频在DAC里混着播放用户会听到串音。我的实现是在状态切换时调用i2s_zero_dma_buffer清空DMA缓冲区同时给播放线程发送一个cancel标志让它在下一个数据块边界退出循环。圆屏显示我用的LVGL。识别出来的文字、大模型回复的文字通过LVGL的label控件实时刷新。因为圆屏分辨率不高每屏显示不了太多字所以代码里做了一行文本跑马灯或自动分页的逻辑。这里要提醒一句LVGL刷新不要在主循环里拖太久尤其播放音频时I2S中断和LVGL刷新会竞争CPU容易出现卡顿。我是把LVGL的刷新频率降到30fps音频优先实测效果不错。4. 常见问题与排查技巧实录4.1 录音声音小或爆音这个问题我调试了很久。现象是录音时波形幅度很小把音量拉高后又都是底噪和爆音。后来排查发现罪魁祸首是麦克风的参考电压没配好。很多I2S数字麦克风芯片需要一个正好等于麦克风供电电压一半的参考电压如果电路设计偷懒直接用电阻分压而分压电阻精度不够参考电压偏移波形就会直接饱和或偏置。排查方法很简单用逻辑分析仪或直接在代码里把原始PCM数据通过串口导出来用Python画波形。如果静音时波形不在0附近说明直流偏置有问题如果人声一出现就削顶参考电压或者增益设置需要调。这类问题在原理图阶段最好就留好调试引脚不然后期只能用飞线测很痛苦。4.2 WebSocket频繁掉线最典型的表现是设备用着用着就“失联”了后台看不到心跳重新上电又恢复正常。大多数情况下是NAT超时或路由器踢设备。如果家里路由器开了“节能模式”WiFi模块在空闲时会进入省电状态TCP长连接很容易被路由器判定为死连接。我的解决办法是双管齐下。第一ESP32的WiFi配置里显式关闭省电模式让WiFi模块始终保持活跃第二心跳间隔从60秒缩短到25秒确保连接在NAT超时前有数据流动。改完这两个地方掉线率大幅下降。另外服务端也要设置合理的空闲超时和自动重连清理机制避免僵尸连接占满资源。4.3 大模型返回超时或回答被截断这类问题和后台服务关系最大。云端API如果遇到高峰期单次请求可能十几秒才返回但糖球的等待超时我设置的是8秒到点就自动复位进入空闲状态。这会导致用户明明问了问题糖球却像没听见一样。其实服务端可以做成流式返回大模型先生成第一段文字后台立即推送设备先显示并播放第一段后续内容再陆续推送。这样用户感知到的延迟会大大降低。不过端侧要支持流式播放状态机得增加一个“播放中可继续接收”的状态复杂度会上升我是在做第二个版本时才完善的。至于回答被截断我自己就遇到过好几次。大模型生成长文本时达到输出token上限回复突然中断后半句话没了。解决办法就是第3节说的系统提示词里强制限制回复长度同时在服务端再做一道保险——如果回复文本超过200字截断到最后一个完整句号再拼接一个“以上是我的回答”至少不会让用户听到半句话。4.4 屏幕卡死或内存不足LVGL和音频缓冲区都会吃内存。ESP32-S3即使外挂PSRAM也不能无限制分配尤其DMA缓冲区必须放在内部SRAM而内部SRAM总共才512KB稍不注意就爆。我的经验是音频DMA缓冲区开4个、每个2KBLVGL刷新缓冲区开两块、每块32KB这样内部SRAM还剩不少余量不会出现分配失败。排查内存问题我习惯在关键路径打印heap详细信息ESP_LOGI(MEM, internal free: %d, psram free: %d, heap_caps_get_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_free_size(MALLOC_CAP_SPIRAM));如果发现某次操作后内存持续减少多半是哪里内存泄漏了。语音项目里最常见的泄漏点是没有释放解码后的PCM缓冲区每次播放泄漏几百字节跑几小时内存就耗尽。这类问题只能靠仔细审查所有malloc/new和对应的free/delete。4.5 排查速查表我把糖球开发过程中整理出的常见问题汇总成了下面这张表方便遇到问题时快速定位症状可能原因排查手段与解决方案录音声音小/爆音麦克风参考电压不准、增益不足导PCM波形看直流偏置调整参考电压和AGC参数录音有严重底噪供电纹波大、地线处理不好用锂电池供电测试改为星型接地布局上传后后台识别为空字VAD静音阈值太低录音不完整调低能量阈值延长静音判定时间到800ms后台返回超时云端API响应慢、等待时间设置太短开启流式返回延长端侧等待超时到10秒音频播放有杂音播放数据未对齐、I2S配置错误确认采样率和通道数与后台下发数据一致设备运行几小时卡死内存泄漏定时打印内存剩余定位泄漏点触摸偶尔失灵状态机未释放I2C总线录音/播放时避免占用触摸扫描任务屏幕亮度不一PWM频率过低/过高调到1kHz左右匹配背光驱动5. 进一步扩展从“能用”到“好用”5.1 接本地模型彻底摆脱外网依赖如果想把糖球部署成真正家庭内部使用、稳定不依赖外网的设备强烈建议把后台切到局域网内跑本地模型。在书房的老电脑上装一个Ollama拉一个7B的Q4量化模型显存占用5GB左右RTX 3060就能舒服跑。TTS也可以用本地引擎比如VITS或Piper效果虽然比云端差一点但胜在零延迟、零成本。换到本地模型后整条链路都会变快ASR用Whisper的small模型本地推理300ms左右LLM生成回复1秒上下TTS合成300ms加上网络传输总延迟能压在2秒内。这才是“桌面语音助手”该有的体验。云端方案只能拿来演示真正日常用还是要本地。5.2 多设备联动和场景指令糖球做出来之后我又多做了一个平板端的控制面板和一个手机端小程序三端共享同一个后台。这里就遇到了新的协议问题WebSocket之间无法直接互相通信需要一个中心化的消息分发。我用的是本地MQTT Broker糖球、平板、手机都作为MQTT客户端接入设备和设备之间通过主题订阅实现状态同步。比如手机发一条“启动回家模式”的命令MQTT推送全场景糖球收到后自动播报“欢迎回家”。如果只是糖球单个设备用WebSocket就够了。一旦设备多起来建议升级为MQTT扩展性和可靠性都会好很多。5.3 后续迭代方向糖球系列做到第三篇基础能力已经闭环了。后续我想做的方向有几个一是离线唤醒词让糖球不用触摸按键直接喊“糖球糖球”就能唤醒唤醒词模型可以放在端侧因为很小二是多轮对话记忆让后台给大模型带一个session id关联上下文三是音质提升给功放加一颗D类芯片外接一个无源喇叭音质会比现在板载蜂鸣器好很多。最后聊几句实在的做糖球系列这几篇我最深的体感就是嵌入式设备接大模型最大的坑不是技术难而是定位不清晰。很多人一听AI语音助手立刻就想把模型跑在本机上结果硬件选型、内存规划、散热设计全得推翻重来。糖球这套方案反其道行之把模型全扔后台前端只做采集与展示反而让整个系统变得极其简单、极其稳定。最后再分享一个实际调试中很有用的小技巧给后台服务加一个“空音频保护”。如果端侧在网络传输中丢了数据或者用户没说话就松手了后台收到的音频可能为空或极短。如果不加保护ASR会返回空字符串大模型会胡编一句“我没听清你在说什么”TTS也会强行合成一段尴尬的音频。我是在audio_end处理逻辑里加了一层判断音频时长小于300ms直接丢弃返回error事件端侧提示“没有听清请再说一次”。这个改动让整个交互的容错性提升了很多属于花小钱买大体验的典型。如果你也正准备做一个圆屏语音设备我的建议是先想清楚设备在整个系统里的角色再动手写第一行代码。把糖球当成后台大模型的语音客户端你会发现可玩性和扩展性都大得多。后续如果大家有兴趣我可以再单独写一篇文章详细讲讲ASR、LLM、TTS三件套在局域网内的部署与调优。
返回列表