把 AI 助手塞进一块硬币大小的 ESP32 里,不依赖 Linux、不跑 Node.js,整条链路用纯 C 实现,这就是 MimiClaw 这个项目最吸引我的地方。它不是一个跑在云端的神仙 Demo,而是一台真正在 MCU 上"裸奔"的语音助手:麦克风采集声音,经唤醒、识别、联网调用大模型,再把答案用语音播出来,全程不碰通用操作系统,也没有任何重量级运行时。
我关注 MimiClaw,是因为市面上绝大多数嵌入式 AI 助手的参考实现,要么跑在树莓派这类装 Linux 的板子上,要么需要 Node.js 进程做中转代理。MimiClaw 反其道而行,直接在 ESP-IDF 原生环境下用 C 语言把所有环节串成一条流水线。这篇我不想只贴链接,而是从架构、核心代码到调试踩坑完整拆一遍,给想在微控制器上做语音助手的同学一份能直接照做的实战笔记。
如果你用过 Arduino 或 ESP-IDF,想进一步了解音频采集、唤醒词、HTTPS 调用大模型这些模块怎么在自己板子上组合起来,这篇会比较对味;如果完全从零开始,也能通过它搞清楚一台嵌入式 AI 语音助手的边界——哪些能在本地做,哪些必须交给云端。
1. 项目拆解:MimiClaw 的定位是什么
1.1 传统嵌入式语音助手的方案困境
现在做"AI 音箱"这类个人项目,实现方式不外乎三种:
- 树莓派 + Linux + Python/Node.js:开发快、生态全,但成本高、功耗大、开机要几十秒,本质上是一台小电脑,不算嵌入式方案。
- ESP32 + MicroPython:能快速写业务逻辑,但实时音频处理和内存控制能力不足,跑起唤醒词和 TTS 容易卡顿。
- ESP32 + ESP-IDF,把复杂逻辑全部丢给云服务器:设备端只负责录音和播放,服务器端用 Node.js 或 Python 做中转。
MimiClaw 的思路是第三条路但更进一步——云服务器只负责大模型推理(这一层绕不开),其余所有逻辑,包括音频采集、唤醒词检测、录音管理、HTTP 请求、响应解析、TTS 播放调度,全部在 ESP32 本地用 C 语言完成。没有 Linux,意味着不需要进程、文件系统、虚拟内存那套开销;没有 Node.js,意味着没有解释器和事件循环带来的额外内存占用与启动延迟。
这样做的收益非常直接:
- 成本:ESP32 模块批量价格比树莓派便宜一个数量级。
- 功耗:整机运行功耗远低于 Linux 板卡,可以电池供电。
- 启动:上电到开始工作几乎是秒级,不用等系统启动完。
- 可控:每个环节都看得见摸得着,出了问题能直接 debug。
1.2 这台设备能做什么、边界在哪里
从使用体验上,MimiClaw 和一台"能聊天的语音助手"类似:
- 说出唤醒词(比如"你好小咪"),设备从待机进入工作状态;
- 说出问题,设备录音并识别成文字;
- 文字内容联网发送给大模型 API;
- 拿到回复后用 TTS 合成语音,从喇叭播出来。
不能做什么?受限于 MCU 算力和几百 KB 的内存,它做不了本地大模型推理,也做不了复杂的自然语言理解。它更像一根"语音管道":把人类语音变成文本,去云端问答案,再把文本变回语音。这个定位很重要——它解决的是"嵌入式设备如何优雅接入大模型语音能力"的问题,而不是"在单片机上跑大模型"的问题。
理解了项目边界,再看它的架构选型和代码设计,就能明白很多看似"绕远路"的做法其实都是被资源约束逼出来的最优解。
2. 硬件与系统架构:从外到内看清一台 AI 助手
2.1 为什么选 ESP32-S3,而不是树莓派
如果要我复刻 MimiClaw,首选不是经典 ESP32,而是 ESP32-S3,或者带 PSRAM 的 ESP32。原因在于音频处理和联网对内存、外设的要求很具体:
- 内置 I2S 外设:I2S 是数字音频的标准接口,能直接连接数字麦克风或音频编解码芯片。
- 双核 Xtensa 处理器:一个核跑音频采集,一个核跑网络任务,互不阻塞,这是语音助手的刚需。
- 带 PSRAM 的版本:这个非常关键。一段 16kHz/16bit 的音频帧缓冲,加上 TLS 握手缓冲区、JSON 解析缓存,动不动就是几十 KB 到一两百 KB,纯内部 SRAM 根本不够。PSRAM 能用外部内存映射扩展,把大块缓冲丢到 PSRAM 里,内部 SRAM 留给实时性要求高的音频任务。
这里我多说一句选型策略。ESP32 家族型号非常多,如果只跑一个简单的联网传感器,经典 ESP32 够用;但是一旦涉及"音频采集 + 唤醒词 + HTTPS + TTS 播放"四件事同时发生,内部 RAM 的紧张程度会直接教你做人。我实际推荐 ESP32-S3-WROOM-1-N16R8(16MB Flash + 8MB PSRAM),Flash 大可以存唤醒词模型和提示音,PSRAM 大可以放心开缓冲区。
2.2 音频链路硬件:麦克风、编解码芯片与喇叭
语音助手的物理第一环,是把模拟声波变成数字信号。常见两种方案:
方案 A:数字硅麦(比如 MSM261S4030H0),通过 I2S 直接输出 PCM 数据。电路简单,不需要额外编解码芯片,但数字麦克风灵敏度通常不如模拟方案,增益调节要靠软件做,搞不好声音会偏小。
方案 B:模拟麦克风 + 音频编解码芯片(比如 ES8311、ES8388)。这类项目更推荐方案 B。因为编解码芯片自带 ADC、DAC、功放接口,一条 I2S 总线既能录音也能放音,麦克风/喇叭增益、静音都能用 I2C 寄存器精确控制,调试时非常省事。
我实际调试用的板子是 ESP32-S3 + ES8311 的语音开发板,接线重点如下:
- I2S_SCLK、I2S_LRCK、I2S_DIN、I2S_DOUT 四根线,分别接不同的 GPIO;
- I2C_SDA / I2C_SCL 用来配置 ES8311 内部寄存器;
- 喇叭接在编解码芯片的 SPK 输出端;如果功放是单独的 PAM8403,建议加音量电位器,否则默认增益会相当吵。
不建议第一次就把所有硬件手工飞线焊在一起。先买一块集成度高的语音开发板把软件跑通,再按需裁剪,是性价比最高的路径。
2.3 软件分层:ESP-IDF + FreeRTOS + 组件化
MimiClaw 的软件栈不复杂,但层次很清晰:
| 层级 | 内容 | 作用 |
|---|---|---|
| 应用层 | 状态机(IDLE / WAKE / LISTEN / ASR / LLM / TTS) | 控制整体流程 |
| 组件层 | ESP-SR、esp_http_client、cJSON、es8311 驱动 | 提供语音识别、网络、解析、播放能力 |
| 系统层 | FreeRTOS + ESP-IDF 驱动 | 任务调度、外设驱动、协议栈 |
| 硬件层 | ESP32-S3 + ES8311 + 麦克风 + 喇叭 | 物理环境 |
这个分层的价值在于:每一层都可以被单独替换。你不想用 ESP-SR 的唤醒词,可以换成自己的 VAD 方案;不想用某个 TTS 云服务,只需要改 tts_task 一个模块。项目源码里最值得学习的,不是某段炫技代码,而是这种"模块边界清晰 + 任务解耦"的组织方式。
3. 核心实现:从"你好小咪"到 AI 开口说话的完整链路
3.1 音频采集与唤醒词检测:第一道门槛
唤醒词的实现,乐鑫提供了现成的 ESP-SR 库,内置 WakeNet 模型。MimiClaw 的常规做法是:
- 在 ESP-IDF 组件管理器中引入 esp-sr 组件;
- 加载唤醒词模型,唤醒词分内置和自训练两类,常见内置词有"你好小智"等,可以选一个顺耳的;
- 把 AFE(声学前端)打开,启用回声消除、降噪、自动增益,再喂给 WakeNet。
典型代码长这样:
#include "esp_afe_sr_iface.h" #include "esp_wakernet.h" static const esp_afe_sr_iface_t *afe_handle = &esp_afe_sr_iface_default; static model_t *wakernet_model = NULL; // 配置 AFE afe_config_t afe_config = { .aec_init = true, .agc_init = true, .ns_init = true, .vad_init = true, .sample_rate = 16000, .pcm_format = ESP_AFE_PCM_FORMAT_L16, }; static void audio_task(void *arg) { // 从 I2S 读 PCM,送入 AFE 增强,再送入 WakeNet while (1) { esp_afe_sr_iface_t *afe = &esp_afe_sr_iface_default; esp_afe_sr_data_t *afe_data = afe->create(&afe_config); // 喂入音频数据并取回处理结果 // 当 afe->get_fetch_state() 返回 ESP_AFE_SR_STATE_WAKE 时,唤醒成功 // 通过信号量通知状态机进入录音识别流程 } }这里最容易被忽略的是为什么一定要加 AFE 而不是直接读麦克风。因为设备自己会播放 TTS 声音,如果不去除回声,麦克风会把喇叭的声音当成用户语音,形成"自己说话自己答应"的死循环。AEC(回声消除)就是解决这个问题的关键,它需要 TTS 播放的参考信号,所以音频链路的回调设计必须把"正在播放的音频"同步喂给 AFE。
3.2 语音识别:本地命令词与云端 ASR 双通道
唤醒之后要走识别。MimiClaw 有两手准备:
一手是本地命令词识别。对"打开灯""查天气""下一首"这类固定指令,用 ESP-SR 的 MultiNet 就能离线完成,延迟低、不花钱、响应快。
另一手是开放式问答。因为要问大模型任意问题,不能靠本地命令词,实际做法是先把语音片段完整录下来,上传到云端 ASR 服务转成文字,再拿文字去问大模型。
开放式识别的录音格式建议用 16kHz、16bit、单声道 WAV(PCM)。为什么?大部分云端 ASR 默认接受这种格式,而且 WAV 文件头非常简单,在 ESP32 上用手写 44 字节的文件头就能生成,不需要任何第三方库。录音完成后,用 esp_http_client 走 HTTPS 把文件传上去,超时时间设在 10 到 15 秒,避免网络波动时任务卡死。
3.3 大模型接入:HTTP 请求、JSON 构造与响应解析
大模型 API 走标准 HTTP/HTTPS,请求体是一个 JSON。以兼容 OpenAI 协议的接口为例:
const char *json_body = "{" "\"model\": \"qwen-turbo\"," "\"messages\": [{\"role\": \"user\", \"content\": \"%s\"}]," "\"max_tokens\": 256," "\"temperature\": 0.7" "}";在 ESP32 上拼这个 JSON,我踩过一次坑:直接 sprintf 拼接,遇到换行、引号或中文转义就乱套。正确做法是用 cJSON 库生成 JSON,尤其是消息内容里可能包含用户输入的特殊字符,cJSON 会自动处理转义,省去一堆麻烦。
cJSON *root = cJSON_CreateObject(); cJSON *messages = cJSON_AddArrayToObject(root, "messages"); cJSON *msg = cJSON_CreateObject(); cJSON_AddStringToObject(msg, "role", "user"); cJSON_AddStringToObject(msg, "content", user_text); cJSON_AddItemToArray(messages, msg); char *request_body = cJSON_PrintUnformatted(root);响应解析同样用 cJSON 找choices[0].message.content。注意大模型的响应通常很长,如果非流式请求,一次性接收几 KB 甚至几十 KB 的 JSON,ESP32 内存可能直接爆掉。我建议:
- 设置 HTTP 读取超时在合理区间;
- 用分段回调接收 body,边收边解析;
- 解析时只保留需要的字段,其余内存及时释放;
- 第一次跑通用非流式,简单可靠;后续想降低首字延迟再切 SSE 流式。
另外有一个非常容易忽略的细节:HTTPS 证书验证需要正确的系统时间,所以项目初始化时必须做 NTP 时间同步。否则 TLS 握手会报证书有效性错误,表现就是"请求偶尔成功、偶尔失败",调试半天找不到原因。
3.4 状态机调度:让链路变成流水线
MimiClaw 的灵魂,我认为是一个状态机:
typedef enum { STATE_IDLE, STATE_WAKE, STATE_LISTEN, STATE_ASR, STATE_LLM, STATE_TTS, STATE_ERROR } app_state_t;状态机不是花架子,它解决了嵌入式 AI 助手的核心调度问题:麦克风录音时喇叭不能放 TTS,网络请求等待响应时音频任务不能跑去触发唤醒。每个状态跳转都用 FreeRTOS 队列或信号量驱动。比如唤醒成功给 ASR 任务发"开始录音"事件,ASR 返回后给 LLM 任务发"带文本请求"事件,LLM 返回后给 TTS 任务发"合成播放"事件。任务之间完全解耦。
这样做的好处是任何一个环节阻塞,其他任务还能正常工作;坏处是调试时要维护队列语义。所以代码里最好打印每个状态跳转的日志,否则设备卡在哪个环节,你会一头雾水。
4. 内存与并发:在几百 KB 内存里不翻车的方法
4.1 FreeRTOS 任务划分与栈大小设计
MimiClaw 的任务划分,按常见实践可以这样分:
| 任务名 | 栈大小建议 | 优先级 | 职责 |
|---|---|---|---|
| audio_task | 4096 | 4 | 读取 I2S PCM、喂 AFE、唤醒检测 |
| network_task | 6144 | 2 | 发起 HTTP/HTTPS 请求 |
| llm_task | 8192 | 2 | 拼装请求、解析响应 |
| tts_task | 4096 | 3 | 接收 TTS 音频数据、播放 |
| 主任务 | 4096 | 1 | 状态机调度、轮询队列 |
栈大小不是越大越好,ESP32 内部 SRAM 一共才几百 KB,每个任务 8K 栈,五个任务就吃掉 40K。调试时我用uxTaskGetStackHighWaterMark检查每个任务的实际峰值水位,反向调整栈大小。比如 llm_task 栈常爆,是因为 cJSON 解析和响应缓冲区都放在栈上,后来把大缓冲区改成 malloc 到 PSRAM,栈需求立刻降下来。
4.2 PSRAM 与大缓冲区的正确用法
ESP32 的 RAM 分内部 SRAM 和外部 PSRAM:
- 内部 SRAM 速度快,但容量小;
- PSRAM 速度慢一些,但容量大,适合放音频缓冲、HTTP 响应缓冲、TTS 数据。
在 ESP-IDF 中启用 PSRAM 后,malloc 默认不一定分配到 PSRAM,需要配置,或者用heap_caps_malloc(size, MALLOC_CAP_SPIRAM)显式指定。MimiClaw 的做法是:音频 PCM 缓冲、HTTP 响应缓冲、TTS 音频块都明确用 PSRAM 分配;唤醒词模型和语音识别模型这类只读数据放 XIP Flash,按需映射,不占 RAM。
这里有个容易踩的坑:PSRAM 中的数据不适合被 DMA 直接访问。I2S 读取音频数据时如果用 DMA,缓冲区必须从内部 RAM 分配(MALLOC_CAP_DMA),否则会出现随机杂音或数据错位。所以音频环形缓冲区放内部 RAM,响应文本、WAV 文件这些"慢速"数据才放 PSRAM。
4.3 并发安全:队列、信号量与超时机制
多任务共享麦克风、喇叭和网络,必须有并发控制。我的经验是:
- 用
xQueueSend/xQueueReceive传事件,不要用裸全局变量,否则状态错乱很难查; - 播放 TTS 时,其他任务不能同时往 I2S 写数据,否则会爆音;
- 所有网络等待都加超时,比如
xSemaphoreTake(sem, pdMS_TO_TICKS(5000)),否则服务器不响应时设备会永久卡死。
我见过很多 ESP32 项目不注重超时,设备一遇到网络抖动就"死机",实际上不是真正死机,而是一个信号量永远没等到。每一个可能阻塞的调用,都问自己一句:如果它永远不返回,系统怎么办。
5. 实操避坑:我调试 MimiClaw 时踩过的 5 个坑
5.1 唤醒率上不去的排查思路
现象是唤醒词要说两三遍,环境稍有噪音就漏唤醒。排查顺序:先看麦克风增益,ES8311 的 ADC 增益寄存器可以调高一点;再确认 AFE 采样率是不是 16kHz,唤醒词模型和音频参数不匹配时,识别率会崩;最后检查 AEC 有没有开,否则 TTS 一播放,唤醒词就被自己的声音盖住了。建议在串口日志里打印 AFE 输出的能量值,观察说话时是否能超过阈值;如果能量很低,先调硬件增益,再谈算法。
5.2 I2S 不出声或只有杂音
这个基本锁定到三件事:
- GPIO 引脚配置错误,I2S_DIN/DOUT 接反;
- ES8311 的 I2C 初始化失败,芯片还在默认状态,没有进入正确的 master/slave 模式;
- MCLK 时钟没给对,SCLK 的 MCLK 通常要给到 256 倍采样率,如果 MCLK 没接,多数编解码芯片直接罢工。
用逻辑分析仪看 I2S 时钟最省事;没有逻辑分析仪,就先在初始化完成后读 ES8311 的寄存器 ID,确认 I2C 通了,再排查音频配置。寄存器 ID 能读回来,说明芯片活着,问题基本在 I2S 引脚或时钟。
5.3 大模型请求触发重启
这个大概率是内存不足。ESP32 配 Wi-Fi 时会临时占用几十 KB 内存做协议栈缓冲,TLS 握手也要将近 40KB,再加上 cJSON 构造请求和响应缓冲,稍不注意一 malloc 失败就触发断言重启。
排查方法:
- 在项目配置里关闭
CONFIG_HEAP_POISONING_DISABLE相关的过度检测选项(生产环境再开回来); - 用
esp_get_free_heap_size()在关键节点打印剩余堆内存; - 把大缓冲全部改到 PSRAM;
- 长文本可以分两次请求,第一次拿提纲,第二次按需生成,避免单次响应过大。
另外,HTTPS 证书配置里可以裁剪不需要的加密套件,也能省出几 KB 到几十 KB 的 RAM。
5.4 TTS 播放卡顿、爆音
卡顿很多时候是网络下载和播放速度不匹配:TTS 音频数据还没下载完,播放已经追到末尾,然后停下来等,表现出来就是一顿一顿。解决办法:
- 用流式读取 TTS 音频,边下载边播放,让"下载 + 播放"变成流水线;
- 在播放器里加 2 到 3 个音频块的环形缓冲;
- 如果 TTS 服务默认返回 MP3,解码在 ESP32 上非常吃 CPU,建议直接选择返回 PCM/WAV 格式的 TTS 服务,省去解码步骤。
爆音问题则多出在增益设置和 I2S 位深不匹配上。确认播放的 PCM 位深与 I2S 配置一致,增益不要顶到削波,爆音就能缓解。
5.5 网络偶发失败与重试策略
Wi-Fi 信号弱、DNS 解析慢、TLS 握手超时,都会让"问一句答一句"的体验断断续续。我会在代码里加一套重试机制:HTTP 请求失败后最多重试 3 次,退避间隔按 1 秒、2 秒、4 秒递增;单次请求超过 15 秒无响应就直接放弃本轮,回到 IDLE 状态,等用户再次唤醒。
如果条件允许,还可以给设备接有线网络,比如通过 LAN8720 以太网模块把 ESP32 接入网线,语音请求的稳定性会提升很多,代价是额外占用几个 GPIO 和一个复位引脚。适合对时延和稳定性要求更高的固定场景。
| 问题 | 典型原因 | 快速定位方法 |
|---|---|---|
| 唤醒率低 | 增益低、AEC 未开、参数不匹配 | 打印 AFE 能量值 |
| I2S 无声 | 引脚错、I2C 不通、MCLK 缺失 | 读 codec 寄存器 ID |
| 请求重启 | 内存不足、TLS 开销过大 | 打印剩余堆内存 |
| TTS 卡顿 | 下载与播放速度不匹配 | 加环形缓冲,换 WAV 格式 |
| 网络偶发失败 | Wi-Fi 弱、超时短 | 重试 + 退避,返回 IDLE |
6. 我的整体体会与后续扩展方向
6.1 这套方案真正值钱的地方
把 MimiClaw 跑通之后,我对"嵌入式 AI 助手"这件事的理解变了很多。以前总觉得 AI 助手必须跑在一个庞大的系统上,至少也要有 Python 环境。结果用纯 C 在 ESP32 上把链路走下来,我发现真正困难的不是语法,而是怎么在有限资源里做实时音频、网络、解析、播放的多任务调度。这种"戴着镣铐跳舞"的过程,会让你把每个字节、每个时钟周期都看得清清楚楚。
这套方案的另一个价值是成本极低。整套硬件下来比一台二手手机还便宜,却能实现接近智能音箱的对话体验,很适合做智能家居中控、语音交互教学板、离线命令词产品原型。如果你想理解 AI 应用在嵌入式端是如何被"压缩"进 MCU 的,MimiClaw 就是一份很好的解剖样本。
6.2 如果我重做一遍,会怎么下手
如果现在让我重新复刻一遍,我不会一上来就写代码,而是按这个顺序推进:
- 先买一块集成语音方案的 ESP32-S3 开发板,用官方例程跑通"本地唤醒 + 播放提示音",确认硬件和音频链路没问题。
- 把云端 ASR 和 TTS 分别用 PC 端脚本调通,确定请求格式和返回格式,避免在 MCU 上反复试错。
- 在 ESP32 里先只做一件事:录音并通过 HTTP 上传到云端 ASR,打印返回文字,其他什么都不接。这一步能暴露大量网络和内存问题。
- 再接入大模型对话,用 cJSON 构造请求、解析响应,先跑非流式。
- 最后串状态机和 TTS 播放,把整条链路连起来。
每步都留一个可回退的检查点,出问题能快速定位到具体模块。这个顺序帮我省掉了至少一半的调试时间,强烈建议照着来。
后续我打算往这几个方向折腾:把大模型响应改成 SSE 流式,让首字延迟更低;在本地做简单意图分类,把"开灯""关灯"这类固定指令离线完成;再挂一块小屏幕显示对话字幕。如果你也在做类似的东西,建议从音频回路起步,一点点加模块,别幻想一次成型。只要链路拆得足够细,ESP32 上的 AI 助手,真的不是只有大板子才能干的事。