掘金投稿用稿。系列上一篇:审核完成后我来改地址 标签建议:
ESP32WebSocket本地大模型语音助手嵌入式Ollama
先把结论放在标题里:这块 466×466 的 AMOLED 圆屏,开机之后一个 token 都不会算。
它算的是别的东西:什么时候该开口、什么时候该闭嘴、TLS 握手怎么在「刚拿到 DHCP」的那几百毫秒里活下来、掉线时麦克风要不要继续转。模型、检索、航班号、腹黑人格------全部在家里那台已经听 127.0.0.1:8000 的后台里。
系列①把公网接到这扇门。系列②把门后面的能力摊开。这篇写我真正花时间最多的那一层:
把一块会说话的屏,收成后台的一种客户端。
不是「ESP32 调用 ChatGPT」。是一套能出门、能打断、能听写、能重连的设备协议。固件瘦,协议胖,大脑在服务端------这才是能同时养活圆屏、方屏、以后再挂别的板的原因。
一句话职责表
| 圆屏固件必须做 | 圆屏固件禁止做 |
|---|---|
| 连网、鉴权、会话、录音、播放、UI、休眠 | 直连 Ollama / ASR / TTS |
| Opus 编解码、VAD、打断、重连 | 自己发明「要不要搜」 |
| 把偏好上报(含腹黑开关) | 把人格、模型名烧进 if 里 |
| 把「现在听不清 / 现在没网」画给人看 | 假装自己在思考 |
方屏(4.3C)UI 完全另一套,协议同一套。换皮肤,不换大脑。寻呼机更瘦,那是系列④的戏。
一块屏,怎么「进门」
外出场景下,设备连的是公网域名背后的云机,再经 FRP 打到家里 backend。板上看到的是:
text
STA 拿到 IPv4
│
▼
WSS 握手(URI 用 IP,证书校验用域名)
│ Header: X-API-Key
▼
hello { device_id, session_id?, fuhhei? }
│
▼
session_ack { protocol: 2, resumed: true|false }
│
▼
听写 listen start / 按住说话 / 下行 Opus
这里有三个我愿意拿出来讲的细节。它们看起来像边角料,掉线、Guru Meditation、鉴权刷屏,都从这儿长出来。
1. URI 走 IP,证书走域名
Wi‑Fi 刚关联时 DNS 经常还是空的。如果你用域名去连 wss://your.example.com/api/device/ws,第一跳就会卡在解析。糖球的做法是:
- 连接目标写成
wss://<公网IP>/api/device/ws esp_websocket_client的host/cert_common_name仍填证书上的域名- CRT bundle 校验 SNI,不关 TLS
于是:路由按 IP 走,身份按域名验。 证书不会因为你「抄了个 IP 图省事」而变成 skip_cert_common_name=true 这种自杀选项。
配套还有一个 lwIP 钩子:当解析请求正好是那张证书域名时,同步填入已知 IP。这里踩过一次真崩------钩子里如果去调 found() 回调,会在 netconn 栈帧已经返回之后再解引用,表现为「拿到 IP 立刻 LoadProhibited 循环重启」。正确做法是只填 addr、返回 ERR_OK,让协议栈自己往下走。
这种 bug 不会出现在「用 Arduino 调一个 HTTPS GET」的教程里。它只出现在:你真的要让一块屏,在咖啡馆的 2.4G 上,对着家里的后台建立一个可重连的 WSS。
2. Key 写在握手里,不要写在 CONNECTED 之后
X-API-Key 放进 esp_websocket_client_init 的 headers。自动重连会沿用同一份 config。
有人会在 WEBSOCKET_EVENT_BEFORE_CONNECT 里再 set_headers。IDF 这个 API 要求已经 CONNECTED------在握手前调用必然 ESP_ERR_INVALID_ARG,日志刷屏,鉴权还是旧的。糖球选择:一次写对,重连别碰。
公网只信任进过糖球门的请求。Ollama 继续只听家里的回环。这和系列①、②是同一条纪律。
3. hello 要能 resume
断线重连不是新开一个人生。板上带着 session_id 再 hello,服务端能把 WebSocket 登记回同一会话:上下文、腹黑标志、进行中的 turn 语义还在。
做不到这一点,用户体验就是:说一半,Wi‑Fi 抖一下,助手失忆。嵌入式语音产品里,会话比连接长,这是后台当基石时才能做的事。
一轮话,究竟经过谁的手
按住说,或者听写模式开口,板上只产生一件事:Opus 帧。
text
圆屏 ──WSS JSON──► listen start / audio_start
──WSS binary / HTTPS audio_turn──► Opus 帧
│
▼
糖球 backend
┌────┴────┬─────────┬──────────┐
▼ ▼ ▼ ▼
STT 前置路由 作答 LLM TTS
(本机ASR) need_search need_large CosyVoice
need_flight 腹黑覆盖
│
▼
圆屏 ◄── JSON:stt_partial / processing / 流式字
◄── binary:Opus 包(可先 burst 再按帧节拍)
◄── JPEG 分片:需要「一张图说完」的显示模式
上行:为什么不一条 WebSocket 打天下
控制面走 WSS 文本帧:hello、listen、abort、偏好。音频有两条路:
- 听写 / 短突发 :WS binary,服务端按
audio_start门闩收包;没开门的 bin 直接丢,防止重连后旧包炸协议。 - 一整轮上传 :
POST /api/device/audio_turn(HTTPS JSON 里带 Opus)。注释里写得很直白------大包走 HTTP 比 WS 更稳;结果仍然从已经登记的那条 WebSocket 下行。
这是典型的「炫」完要解释的设计:不是协议洁癖,是现场。TLS 写队列一堵,IDF 客户端 transport_poll_write 超时就断。于是 network_timeout_ms 给到 20s,ping 拉到 45s,还关掉 pingpong 断线。该厚的地方要厚。
HTTP 那条还要求 session 上已经有活 WS,否则 409。上传和会话绑死,避免「文件传到一个没人听的房间」。
听写:板上 VAD + 云端 stt_partial
听写不是「录完再转」。板上用能量 VAD(最短说话、静音收尾、回声抑制窗口),服务端大约每 1.2s、至少十来帧,把已缓冲 Opus 批转写一次,下发 stt_partial。
断句因此可以看两件事:本地能量,以及「文案已经稳定了多久」。字数不够不算说完;STT 稳定后再等一小段,才 listen stop 开一轮。
这是设备协议最不像 Demo 的地方:说话结束是产品定义,不是 silence < threshold。
路由:固件一个 bit 都没有
转写出来的文本进前置模型,只准吐 JSON:
- 要不要搜、搜什么(指代要还原)
- 要不要按航班号查动态(有号才查,没有就不要拿网页搜冒充)
- 这题要不要走后置更大档
腹黑开着,前后置被会话标志拧到同一套宽松小模型------开关在 NVS 里,经 hello/prefs 上报。烧两份固件来换人格,是业余的。
地理 IP 可以给天气/附近用,但系统提示写死:用户没问位置,禁止拿来寒暄。这种约束只能放服务端。板子不知道自己在哪个咖啡馆,也不该知道。
下行:先灌满,再按帧走,字和声音不是同一份稿
TTS 按句(或短首段)合成,Opus 包先 burst 几十包 填设备预取,再按略快于实时的节拍(约 12ms/包,帧长 20ms)往下灌。欠载比过冲更难听。
还有两件「只有真做过屏」才会管的事:
- 屏上可以有 emoji,嘴里不能念「sparkles」。 显示走 imgfont / JPEG;朗读前剥表情区码点。空语音包曾经就是表情包搞出来的。
- 图走 base64 分片 JSON,语音走 binary。 部分代理会吞 WS 二进制;图片宁可拆 text 帧。音频要对齐小智那种逐包 binary,才播得顺。
回合进行中服务端还打 processing keepalive。家里 GPU 一忙就是数秒,FRP 中间盒如果当连接死了,你会在用户最不想掉线的时候掉线。
打断(barge-in)是一等公民:板上停播、抑制旧轮次尾包、发 abort,服务端停 TTS。麦克风长期预热会把扬声器音量吃掉,还会引出隐私争议------所以 codec 惰性初始化 ,第一次播音才 codec_new。
掉线时,屏应该像个产品,而不是像个栈回溯
WSS 断了,IDF 会按 reconnect_timeout_ms 自己连。固件要决定的是状态机,不是再 start() 一次造成竞态。
糖球现在的策略:
| 现场 | 做法 |
|---|---|
| 听写中途掉线 | 麦克风继续转,重连后 resume listen |
| 上传 / 回合中掉线 | 保留 session,清空 TX 队列(新连接 in_audio=0,残留 bin 会触发 missing audio_start) |
| 故意断开 | 不计入失败、不累计退避 |
| 连续下行失败 | 超过阈值中止 turn,避免半包音频仍 turn_done |
UI 上只需要诚实:没网、在重连、在听、在想。不要转圈假装推理。推理在家里,圈转在板上,用户会被骗两次。
会话 resume 加上「听写保麦」,才能把咖啡馆里那次 DHCP 续租,从事故变成一次闪烁。
板端工程:炫的是约束,不是组件名
圆屏是 Waveshare ESP32-S3 AMOLED 1.75C:无 SD、32MB Flash、CO5300、AXP2101。从旧圆屏 LCD 迁过来时,不是改个分辨率就完。
几件我认的硬约束(不写引脚表):
DMA 不能按整屏一次性吐。 max_transfer_sz 按满屏申请,启动即 OOM。改成按行块(二十行一级),显示栈才活。
CJK 字库进 Flash,不进 PSRAM 整库加载。 启动要先有字,再抢 Wi‑Fi 堆。字库和 Wi‑Fi 并行,是另一类「能跑 Demo、不能量产」的死法。
LVGL worker 默认栈太小。 加大并放到 PSRAM;这不是调参游戏,是第一帧就栈溢出还是能进表盘的差别。
ES8311 要先被 PMU 喂电。 音频「有驱动没声音」,优先查 LDO,而不是查 I2S 配置贴。
这些加在一起,才配叫语音客户端:它要在 160MHz 级 MCU 上,同时扛 UI、麦、喇叭、TLS、Opus。它仍然不配叫大脑。
方屏分辨率、触摸、横竖完全不同,只要继续打同一套 /api/device。新板是新皮肤。新业务(寻呼)是新路由,不是新 Ollama。
和网页客户端比,设备端到底特殊在哪
网页可以把 SSE、DOM、浏览器 TTS 当玩具。圆屏不行。
- 网页掉线,人会刷新。圆屏掉线,人会拍桌子。
- 网页可以「先出字再出声」。圆屏必须双缓冲接段,否则像电话卡字。
- 网页可以塞 RAG、生图扫描、观察者双模型。设备第一版故意 不上 Comfy、不把整条网页 intent 搬过来。能出门的,先保证:听清、答完、播完、能打断。
特殊的是物理和链路,不是另搞一套智能。智能继续住在系列②那层编排里。
仓库里怎么看这一层
text
backend/device_ws.py ← WSS + HTTP audio_turn + 会话登记
backend/device_voice_turn.py ← Opus → STT → 路由 → 作答 → TTS/图
backend/device_route.py ← need_search / flight / large / 腹黑
hardware/.../1.75C/firmware ← 圆屏:协议状态机 + UI + 音频
hardware/.../4.3C/firmware ← 方屏:同一协议,另一套皮
密钥、Wi‑Fi、云机 IP 在 gitignore 的本地头文件里。公开仓只留 example。克隆之后你填自己的门牌,连的是你自己的后台------这才叫客户端。
系列定位不变:入口、基石、客户端、垂直业务。
我自己同时写固件和后台。求职向一句话仍然是 本地 AI 应用工程 + 多端设备协议------屏是作品集的封面,不是架构的中心。