文章

你好小鑫:把M5Stack StopWatch变成有表情的AI语音助手

ESP32-S3直连豆包实时语音,会听、会想、能插话,再给它装上一张会变表情的脸

你好小鑫:把M5Stack StopWatch变成有表情的AI语音助手

题记

之前折腾过GPT语音助手,也让Vector听中文、说中文。这次想把语音助手做得再小一点,顺便给它一张脸:叫它的时候会睁大眼睛,听你说话的时候盯着你,思考时变成三个点,讲笑话时自己也会笑。

正好M5Stack的StopWatch把圆形AMOLED屏幕、麦克风、喇叭和电池都装进了一个小小的表壳里。硬件现成,剩下的就是换上自己的固件,直连豆包实时语音,再让屏幕上的小家伙动起来。

于是有了stopwatch-doubao。唤醒词是「你好小鑫」,那就叫它小鑫吧。

拿在手里的小鑫:黑底、蓝色小球和两只白眼睛

先看效果

把小鑫的一天做成了一段视频:早上叫起床、听新闻,中午帮泡面计时,下午控制家里的灯,晚上讲笑话、练英语,最后说声晚安。

播放不了的话,可以到B站观看。

视频用3D手表和情景对白介绍功能,头像动画来自固件里的同一套引擎。上面的照片是实物;下面聊聊这些功能在小板子上是怎么做出来的。

一块表能做什么

最基本的用法是喊一句「你好小鑫」,然后直接聊天。也可以按侧键或点屏幕开启对话,问天气、聊新闻,或者让它陪你练几句英语。联网搜索需要另外配置相应的服务凭证。

除了聊天,还给它接了几件日常会用到的事:

  • 闹钟、计时和秒表:用中文说时间,提醒保存在本机,到点有声音和振动,也支持重复闹钟;
  • 跨会话记忆:对话原文和长期摘要保存在设备上,下次聊天能带上之前的上下文;
  • 设备设置:音量、屏幕亮度、头像颜色都可以用语音调整,「换成粉色」就能换一张粉色的脸;
  • Home Assistant:通过工具查询家里的设备状态,并提供控制接口。视频里的关灯是情景演示,仓库记录已经验证了状态查询,实际设备控制还需要继续实测。

表盘默认只有头像,没有文字。想看时间、电量和提醒,就从屏幕顶部往下拉;对话时拉下来,还能看自己的话和回复字幕。从底部往上滑,再把整块屏幕还给那张脸。

硬件不用从头焊

这次用的是M5Stack StopWatch,主要配置如下:

部件配置
主控ESP32-S3R8
屏幕1.75寸圆形AMOLED,466×466
音频ES8311音频芯片、单麦克风、1W喇叭
操作触摸屏、两个侧键
提醒喇叭和振动马达

固件用ESP-IDF 5.5和C++写,文字界面用LVGL 9。语音识别、模型思考和语音合成都在云端,手表负责采集、播放、回声消除、打断和显示,通过WiFi直连豆包实时语音服务,不需要另起一台语音中转服务器。

这也意味着聊天依赖网络和云端服务。它没有电池RTC,重启后要先联网校时,提醒调度才会开始;不能把它当成一块断网也能放心叫醒人的普通闹钟。

小鑫的大脑:豆包实时语音模型3.0

目前接的是豆包实时语音模型3.0(Seeduplex,全双工版)。它是端到端的语音到语音模型:持续接收音频,也能流式输出语音,在对话过程中处理用户的插话。模型能力和接入协议可以看火山引擎官方说明。

以前做语音助手,通常要自己把「语音识别 → 文本大模型 → 语音合成」三段串起来,每段都要处理等待、缓冲和打断。这里由实时语音服务统一处理语音理解与回复生成,设备接收返回的音频、转写和工具事件。问天气、设计时器、查家里设备时,模型发起工具调用,手表执行后把结果回传,再继续回答。

产品名称和接口里的模型编号是两回事:当前固件在创建会话时传入的model是1.2.6.1,具体接入配置以源码和服务端文档为准。ESP32上运行的是唤醒与音频处理所需的小模型,豆包这个语音大模型运行在云端。

「你好小鑫」:先在本地叫醒它

唤醒用的是esp-sr的WakeNet9,在ESP32本地识别「你好小鑫」。待机时麦克风持续采集,音频送给本地唤醒模型;默认不保持豆包预连接,也不把待机录音持续上传给豆包。命中唤醒词以后,才切换到对话状态、建立云端会话,开始实时音频收发。

这里还有一个内存取舍:WakeNet大约需要24KB内部RAM,AEC大约需要76KB。为了给WiFi、TLS和音频缓冲留空间,两者按状态轮换:待机保留唤醒模型,进入会话时销毁唤醒模型、建立AEC,结束对话后再恢复唤醒。已经在聊天时直接说下一句即可,不用每句都喊「你好小鑫」。

想换唤醒词,要选相应的WakeNet模型并重新构建固件。VOICE_WAKE_PHRASE只负责界面提示文字,改成另一个名字并不会让模型自动学会那个词。按键和点屏幕也能开启对话,不一定要用语音唤醒。

最难的是:它说话时,你也能说话

接上语音接口,让设备听一句、答一句,比较容易。真正费时间的是它正在说话的时候,你插一句,它能听见并停下来。

全双工首先要让录音和播放同时工作:小鑫回答时麦克风继续采集,经过回声消除后的声音仍然往云端送。一次唤醒建立一条WebSocket会话,在同一会话里持续传音频和接收回复,不用每聊一句就重新连接。

这条链路可以拆成两路看:

  • 上行:麦克风采集 → 本地回声消除 → 16kHz单声道PCM,按32毫秒分片 → WebSocket持续上传;
  • 下行:云端流式语音 → 接收队列 → Opus解码、降采样 → 播放队列 → 喇叭,同时按播放进度更新字幕和表情。

两路并行运行。云端负责理解语音和决定对话节奏,设备负责把声音按时送到,并在插话时及时让喇叭停下来。模型支持全双工,还需要整条音频链路配合,才能变成用起来顺畅的实时对话。

flowchart TB
    user["你说话 / 随时插话"]
    subgraph device["StopWatch · ESP32-S3"]
        direction TB
        mic["麦克风持续采集"]
        wake["待机:WakeNet9 本地唤醒<br/>识别「你好小鑫」"]
        session["切换到对话模式<br/>释放 WakeNet,建立 AEC 与云端会话"]
        aec["AEC 回声消除<br/>保留用户声音,抑制喇叭回声"]
        tx["上行音频<br/>16kHz PCM · 32ms 分片"]
        rx["下行音频<br/>Opus 解码 → 降采样"]
        queue["本地播放队列"]
        speaker["喇叭播放回复"]
        ref["ES8311 内部播放回环<br/>提供回声参考信号"]
        vad["本地人声检测<br/>连续约160ms命中"]
        stop["立即停播<br/>丢弃旧回复剩余音频"]
        ui["按播放进度<br/>更新字幕与表情"]
    end
    cloud["豆包实时语音3.0 · 云端<br/>理解语音、生成回复、处理工具调用"]
    user --> mic
    mic -->|"待机时 · 不持续上传"| wake
    wake -->|"唤醒命中"| session
    session -. "初始化" .-> aec
    mic -->|"对话时"| aec --> tx
    tx -->|"同一 WebSocket 会话 · 持续上传"| cloud
    cloud -->|"流式返回 · 与上传并行"| rx
    rx --> queue --> speaker
    queue --> ui
    speaker -. "播放声被麦克风拾取" .-> mic
    speaker --> ref
    ref -. "参考信号" .-> aec
    aec --> vad --> stop
    stop -. "停止并清空" .-> queue
    classDef audio fill:#eff6ff,stroke:#3b82f6,color:#172554;
    classDef interrupt fill:#fff7ed,stroke:#ea580c,color:#7c2d12;
    classDef model fill:#f0fdf4,stroke:#16a34a,color:#14532d;
    class mic,wake,session,aec,tx,rx,queue,speaker,ref,ui audio;
    class vad,stop interrupt;
    class cloud model;

蓝色是设备上的音频链路,绿色是云端模型,橙色是插话时的本地停播流程。麦克风在待机和对话时分别走唤醒检测、AEC上行两条路径;两种模式轮换,WakeNet与AEC不同时驻留。停播只清掉旧回复,麦克风和上行继续工作,用户的新一句话仍会送给云端。图中的160毫秒是本地检测门槛;每次会话第一句回复的前3秒还要等待AEC收敛,暂不启用本地打断。

这么小的表壳,喇叭就在麦克风旁边。没有回声消除,小鑫很容易把自己的回答当成你说的话,接着自己跟自己聊。

这里用esp-sr做本地回声消除,参考信号取自ES8311内部的播放回环。踩过的一个坑是:回声压得更干净,不一定更适合对话。一开始选的模式在双方同时说话时,也把用户的声音压坏了,听起来有声音,送给识别却认不出来。最后换成适合全双工的FD模式,再实际听上行录音、检查转写结果。

第二个坑是网络。服务端发语音的速度比播放快得多,直接收PCM,大块下行数据会挤住上行录音。结果就是它在说,你也在说,但你的话过了好一会儿才送出去。改用Opus压缩音频,把接收和解码拆开,再分开WebSocket的收发锁,上行才能按实时节奏持续发送。

最后还是加了本地先停播:在回声消除后的音频上检测人声,连续约160毫秒命中,就先停掉喇叭、丢弃旧回复剩余的音频,让云端继续识别用户的新一句话。这个数字是本地检测门槛,不是整段对话的响应延迟。

提示词也要配合:明确告诉模型,用户在它说话时发言,即使只是「停」「等一下」,也要让出话轮、倾听新请求。当前豆包实现没有在本地停播时直接发送response.cancel,因为实测这样会让后续识别卡住;先停止本机播放、继续上传用户语音,是这版固件验证后采用的处理方式。

目前也有边界:每次会话刚开始,回声消除需要收敛,第一句回复的前3秒暂时不能打断;偶尔仍会误判,把回答截掉。比起只看“全双工”三个字,这些实际听感更值得继续调。

给语音助手装上一张脸

头像来自bloub,作者用SVG复刻了一套Grok风格的机器人形变动画。我把它的形变引擎移植成C++,在ESP32上直接画出来。

圆形AMOLED很适合这个小家伙:黑色背景不发光,中间一颗彩球,两只白眼睛。待机时眨眼,叫醒时睁大眼睛,听的时候转过来看你,想的时候缩成三个点。再加上16种表情,同一句回答配上开心、困惑或者得意的眼神,感觉就不一样了。

下面几组循环动画用固件里的同一套形变引擎导出,能直接看到小鑫怎么换表情:

小鑫从专注听到睁大眼睛,再缩成三个思考点的循环动画

听你说话 → 思考

Grok风格头像从平静变成开心和大笑的循环动画

开心 → 大笑

小鑫从好奇变得困倦,再收成睡眠状态的循环动画

好奇 → 困了

表情还要和声音对得上。云端返回的文字往往比语音播放快,如果文字一到就换脸,它已经开始笑了,喇叭却还在说上一句。

现在让模型用emoji标记回复的情绪,设备识别后从字幕里去掉,再按语音播放进度推进字幕和表情。这样说到那句话时,才换成对应的脸,也不用每句话都额外调用一次工具来报告心情。

小屏幕并不等于好画。 全屏的球一形变,就要重画十几万像素,还得和录音、播放抢资源。最后把头像放到独立任务,只刷新真正变化的区域,通常只重画眼睛;全屏头像直接通过小块DMA缓冲上屏,拉出信息层时再交给LVGL叠文字。目标是让头像保持30fps,同时给音频留出优先级。

bloub的代码采用MIT许可,原作者是Jérémy Perret;头像设计来源也应单独说明。这个项目与x.ai无关。

不只聊天:让模型调用工具办事

能说「三分钟后叫你」,还得真的三分钟后响铃。这里用的是Function Calling(工具调用):把设备能做的事情注册给模型,模型理解用户的意思后给出工具名和参数,固件执行具体动作,再把结果回传,让它根据执行结果回答。

目前固件定义了下面这些工具;Home Assistant的两个工具只在配置HA_TOKEN后注册,其余随固件初始化注册。

工具能做什么可以怎么说
get_weather查询当前天气和未来三天预报「杭州明天会下雨吗?」
get_system_info查询日期时间、电量与充电状态、音量、亮度、网络、芯片温度和内存「还有多少电?」
set_volume、set_brightness设置百分比,或者调高、调低一档「声音小一点」「亮度调到50」
set_avatar_color用颜色名或#RRGGBB换头像颜色「换成粉色」
alarm设置、查询、取消闹钟,支持单次、每天、工作日和周末「工作日早上七点半叫我」
timer开始、查询和取消倒计时「泡面计时三分钟」
stopwatch开始或继续、暂停、记圈、归零、查询用时「开始秒表」「记一下」
home_query按设备名或房间名查询HA设备状态、传感器读数「客厅灯开着吗?」
home_control提供开关、亮度、温度、风速、窗帘位置等控制接口「把客厅灯关了」

这张表介绍的是源码提供的能力,不代表每种组合都已实机验证。尤其是Home Assistant控制、重复闹钟跨天行为和部分提醒场景,还需要继续补测。联网搜索和唱歌则是豆包会话中的云端扩展能力,没有注册成上述本地工具。

一句「泡面计时三分钟」,怎么变成计时器

每个工具有名字、用途说明、JSON Schema参数定义和一个C++执行函数。创建云端会话时,把这份工具清单一起发给模型。模型看到「三分钟」,会把它转换成总秒数,生成这样的调用参数:

1
2
3
4
5
6
7
8
{
  "name": "timer",
  "arguments": {
    "action": "start",
    "seconds": 180,
    "label": "泡面"
  }
}

这是参数示例,实际协议事件还带有call_id等字段,用来把返回结果对应到这一次调用。设备按工具名分发给提醒模块,检查时长、建立倒计时并保存提醒;后续由本地调度器负责到点响铃,不需要模型在云端一直数180秒。

sequenceDiagram
    participant U as 用户
    participant M as 豆包实时语音
    participant T as 设备工具任务
    participant R as 本地提醒模块
    U->>M: 泡面计时三分钟
    M->>T: timer · start / 180秒 / 泡面
    T->>R: 检查参数,建立并保存倒计时
    R-->>T: 返回执行结果
    T-->>M: 回传工具结果,关联 call_id
    T->>M: 触发继续生成回复
    M-->>U: 根据结果语音确认
    Note over R: 到点后由设备调度响铃与振动

实现上还有两个容易踩的地方。首先,天气和HA查询可能要等HTTP返回,工具放在单独的FreeRTOS任务里执行,不能在WebSocket收包回调里等,否则收音频和处理事件都容易被拖住。其次,在当前豆包接入里,回传工具结果只是把它放进上下文,还需要发送input_audio_buffer.commit触发后续回复。固件用conversation.item.create回传role=tool的结果,再让模型开口确认。

工具说明和提示词也得写清楚:设置音量、亮度、提醒和家电时,必须实际调用工具,只有执行成功才能说「调好了」。模型偶尔仍会跳过工具直接回答,所以调试时要同时看串口里的调用与返回结果,不能只听它有没有说“成功”。

天气和智能家居,怎么接到小板子上

天气工具通过HTTP调用Open-Meteo,先把城市名转换成经纬度,再获取天气数据,整理成简短文本回传。没说城市时,通过网络出口IP推测本地城市,并不是用GPS定位。

Home Assistant则由固件带着配置好的令牌访问REST API。为了少收、少解析数据,没有直接拉整份/api/states,而是通过/api/template只取相关设备的名称、状态和必要属性,返回紧凑文本。模型只需填「客厅灯」这样的中文名,固件再做名称匹配,找到实际的entity_id;控制时根据设备类型和动作调用对应的/api/services接口。

本机设置更直接:音量和亮度调用设备控制接口,头像颜色交给渲染模块,设置保存到NVS。闹钟和计时交给提醒模块,提醒数据保存到SPIFFS。模型负责理解自然语言,真正改状态、存数据和操作硬件的是这些本地模块。

后续接入GPT-Live或Gemini Live时,这些执行函数可以复用,需要重新适配的是工具声明、调用事件和结果回传协议。

电源管理:安静待机,用时再忙起来

小鑫要一直听唤醒词,但不能一直按聊天时的功耗跑。现在按工作状态调整CPU频率、WiFi和音频供电:

状态电源管理方式
待机等唤醒CPU动态调频,空闲时可降到80MHz;WiFi使用MAX_MODEM省电模式,默认不保活云端预连接
实时对话CPU锁定240MHz,WiFi切到MIN_MODEM,优先保证音频收发的实时性
播放声音按需开启5V升压和功放,先开升压、等待20毫秒再开功放,减少爆音;停播2秒后关闭
长时间没操作待机20秒调暗屏幕,120秒熄屏;熄屏后停止头像绘制,降低界面轮询频率

熄屏以后,本地唤醒仍然在听。唤醒词、按键、触摸和到点提醒都能点亮屏幕;熄屏不等于整机睡眠。当前固件没有开启light sleep,因为持续的I2S音频采集和USB串口限制了这条省电路径。

默认的VOICE_WARM=0选择唤醒后再连接云端,减少待机时的网络活动;如果更在意唤醒后的等待,也可以开启预连接保活,用更高的待机功耗换连接速度。屏幕调暗和熄屏时间通过VOICE_SCREEN_DIM、VOICE_SCREEN_OFF配置,填0可关闭对应的自动行为。

续航目前还没有完整的电池放电实测。README里优化后熄屏待机约40–60mA、约8–10小时的数字属于估算,而且聊天、亮屏和音量都会影响耗电,暂时不拿它当实际续航承诺。后面准备把纯待机和日常聊天分开测,看看这些省电措施最后能换来多少使用时间。

想自己刷一块

开发环境是ESP-IDF 5.5。在stopwatch-doubao项目目录下,先复制配置文件,填入WiFi和豆包实时语音凭证:

1
cp secrets.example.env secrets.env

基础配置是WIFI_SSID、WIFI_PASSWORD和豆包凭证;Home Assistant、联网搜索、记忆摘要相关的配置按需再填。具体字段以项目里的示例文件为准。

加载ESP-IDF环境后,编译并烧录:

1
2
3
. ~/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/cu.usbmodem* -b 2000000 flash monitor

串口路径换成自己设备对应的端口。凭证会在构建时写入固件,secrets.env不要提交,也别把填过个人凭证的固件直接分享出去。改了音色、人设或其他构建配置,重新编译烧录即可。

WiFi配网复用了小智的esp-wifi-connect组件,可以记住多个网络。连不上已保存的网络时,会开启StopWatch-XXXX热点,手机连上后在网页里选网络、填密码;如果没有自动弹出页面,可以打开http://192.168.4.1。

后续:给小鑫换几个大脑

做完以后,最想反复看的还是那张脸:叫一声,它睁眼看过来;说到一半插话,它又转回认真听的样子。语音助手多了这点反馈,放在桌上就很容易想跟它聊两句。

后续准备把云端连接和协议处理抽成可替换的后端,让同一块StopWatch除了豆包,还能接OpenAI的GPT-Live / Realtime API、Google的Gemini Live等实时语音服务。现在跑通的是豆包,这些后端属于接下来的适配计划。

OpenAI提供双向音频与控制事件的WebSocket连接;Gemini Live也支持持续音频输入,并通过语音活动检测处理插话。两家的接口资料分别见OpenAI语音WebSocket文档和Gemini Live能力说明。

设备上的麦克风、回声消除、喇叭、头像和提醒可以继续复用,接入层需要分别适配鉴权、会话配置、音频格式、字幕和工具事件。尤其是打断:不同服务对取消回复、清空播放和保留上下文的约定不同,不能直接照搬豆包这一版的处理。例如OpenAI Realtime的WebSocket对话需要客户端按已播放时长截断未播放的回复,Gemini Live收到打断通知时需要停止播放并清空队列,具体分别按OpenAI打断处理文档和上面的Gemini文档实现。

等这些后端接好,就能在同一块硬件上比较中文聊天、英语陪练、响应速度和插话体验。小鑫还是那张脸,背后的大脑可以按需要换。

接下来继续调打断的误触发,补测重复闹钟、记忆摘要和Home Assistant实际控制,再测一轮电池续航。现在仓库里的续航数字只是功耗估算,还不能当成实测结果;保存对话时偶尔出现的轻微卡顿,也需要继续处理。

从给音箱接大模型,到让Vector说中文,再到这块小小的StopWatch,折腾的东西越来越小,表情倒是越来越多了。

本文由作者按照 CC BY 4.0 进行授权