给 100 块的 AI 玩具写了一套英语启蒙 App:100 个词、看图选词、开口打分
硬件是 FoloToy AI Passport(目前约 100 元,ESP32-C3 / 8MB Flash / 240×320 屏 / 三个按键),我从零做了一个「英文学习」应用:100 个词分 8 个主题,看图选词、答对即切题、一局 10 题、结算页报分并说一句英文鼓励语。全程由 AI 代理在本地写代码、刷机、读串口日志定位崩溃。
下载地址: ai-passport.folotoy.cn/plays/2144/
一、先看成品
一局游戏的完整路径:
首页(像素小生物 + 入口)
↓ 按「确定」
题目页:英文单词在上,四张图标卡在下,自动朗读单词
↓ 上/下 移动高亮,确定 提交
答对 → 一声升调「叮」→ 立刻切到下一题
答错 → 一声降调 → 画面停留,显示「答案 苹果」
↓ 第 10 题答完
结算页:得分 / 正确率 / 最佳连对 + 一句英文鼓励语
↓ 上/下 选择
「再来一轮」或「回首页」
几个刻意的设计:
- 答对立刻切题,不用再按一次确定。反馈交给声音,屏幕不设 0.8 秒停留。
- 答错才停,因为要让孩子看清正确答案。
- 一局固定 10 题,有终点才有成就感;结算页给「得分 / 正确率 / 最佳连对」。
- 鼓励语只在结算页说,按正确率分档:≥80% 说 "Great job!",<80% 说 "Keep going!"。
- 新题出现后 400ms 内的「确定」不算作答,防止连按把下一题直接答掉。
二、技术栈
| 层 | 选型 |
|---|---|
| 芯片 | ESP32-C3,8MB Flash,无 PSRAM(240×320 屏幕 + 100 张图 + 100 段语音全塞进去) |
| 框架 | ESP-IDF v5.5.3 |
| UI | LVGL 9.5 |
| 音频 | ES8311 codec + I2S,16kHz/16bit 单声道 PCM 存 Flash |
| 字体 | Noto Sans SC 走 lv_font_conv 生成位图字体,覆盖 202 个中文字形 |
界面是深底 + 薄荷绿点缀,两套字号(16px / 28px),全部中文走自己生成的位图字体。
三、内容流水线:从 emoji 到固件
素材不是手搓的,全是脚本生成,改词表重跑一遍就行。
1. 图标:emoji → 48px 图
Apple Color Emoji 按 160px 渲染、裁剪、缩放到 48px,转成 RGB565A8(彩色 + 独立 alpha 平面)。这样贴在任意卡片底色上都不会出现方块底色。
100 张图 = 100 × 48 × 48 × 3 ≈ 676 KB,直接从 Flash 读,不占 RAM。
2. 发音:TTS → PCM
say -v Samantha -r 85 -o tts.aiff "Apple"
afconvert -f WAVE -d LEI16@16000 -c 1 tts.aiff tts.wav
再裁掉首尾静音、归一化到 70% 峰值、加 10ms 淡入淡出(避免爆音)。
85 wpm 是调出来的:原始 150 wpm 时孩子跟不上,降到 85 后每个词的时长约 0.5 秒,100 个词合计 1.76 MB。
3. 像素小生物:脚本画的,不是贴图
首页 64px、结算页 48px,两只原创小生物。在 16×16 的格子上按几何形状逐像素生成(椭圆身体 + 描边 + 顶部高光 + 底部两行压暗 + 固定位置的眼睛嘴巴),再整数倍放大(4× / 3×),所以边缘永远是硬像素。
为什么不直接用宝可梦 / 我的世界素材:那是任天堂和 Mojang 的版权资产,塞进固件再分发不合适。原创图形可以随便改颜色、改配件、改尺寸——脚本里每只生物就是一组常量。
四、踩过的五个坑(这篇的重点)
坑 1:LVGL 的悬空回调,直接把设备打崩
跑到第 3 题时设备突然重启:
Guru Meditation Error: Core 0 panic'ed (Load access fault).
MEPC : 0x42016574 MTVAL : 0x000168d6
反查地址:
0x42016574: lv_obj_get_display (lv_obj_tree.c:311)
0x42033cea: update_layout_completed_cb (lv_label.c:1081)
0x4202603e: lv_timer_handler
原因:lv_label_set_text() 会往 display 上挂一个「布局完成后刷新文字」的延迟回调,回调的 user_data 就是那个 label 的指针。而 LVGL 删除对象时,只清理对象自己的事件列表,不会清理挂在 display 上、以该对象为 user_data 的回调。
于是「刚设置过文字就删屏」就变成:布局一完成,回调拿着已释放的指针去访问 → MTVAL = 0x168d6(一块被复用的垃圾地址)。
修法:页面只建一次,之后只做 lv_screen_load(),运行期绝不删控件。三块页面在启动时各建一次,几十个控件常驻,代价是十几 KB 堆,换来的是这类生命周期 bug 彻底消失。
坑 2:lv_timer 的 repeat_count 归零后不再执行回调
想做「答对停 800ms 再跳题」,于是建了一个一次性定时器:
lv_timer_t *t = lv_timer_create(cb, 800, NULL);
lv_timer_set_repeat_count(t, 1);
第一次触发后,repeat_count 被减到 0 并自动 pause。之后再 lv_timer_reset() + lv_timer_resume() 都不会执行回调——因为 lv_timer_exec() 里是这样的:
if (timer->timer_cb && original_repeat_count != 0) { timer->timer_cb(timer); }
每次重新武装都必须重设 repeat_count:
lv_timer_set_repeat_count(t, 1);
lv_timer_reset(t);
lv_timer_resume(t);
后来干脆把定时器删了:答对直接切题,反馈交给声音,状态机更简单。
坑 3:音量调了两次都「没变化」,因为刻度算错了
答对/答错的提示音太吵,我把音量从 75% 调到 64%,用户反馈「还是太大」。
查驱动才发现:esp_codec_dev_set_out_vol() 用的是应用层音量曲线,0% = -50dB,100% = 0dB,线性。我拿 ES8311 芯片内部的 -95.5dB ~ +32dB 去换算——那是已经映射过一层之后的寄存器量程,不是百分比刻度。
正确换算:
db = percent * 0.5 - 50
75% = -12.5dB 64% = -18dB 50% = -25dB
所以 75% → 64% 只降了 5.5 dB(幅度约 53%),难怪听不出来。这次直接降到 -25 dB,并且把波形从方波换成正弦——方波的高次谐波才是「刺耳」的主因,光调音量治不了。
坑 4:编译通过 ≠ 屏幕上有字
LVGL 缺字形时会画一个占位方框,构建日志不会有任何警告。
所以加了一道门禁检查:解析字体文件里的 cmaps 得到覆盖码点,再扫描源码里的中文字符串字面量,缺一个字就让构建失败。
这个检查器本身也踩过坑:最初用正则 "([^"]*)" 直接扫全文,结果注释里的英文引号被当成字符串边界,把后面注释里的中文也算进了字表,报出一堆假缺字。修法是先剥掉 // 和 /* */ 注释(字符串和字符字面量里的引号仍正确处理),再取字面量。
坑 5:100 个词不能让「Lion」配「car / apple / duck」
词表扩到 100 个之后,如果四个选项全库随机抽,干扰项会荒谬得没有区分度。所以给每个词加了主题字段(农场动物 / 野生动物 / 海洋 / 水果 / 食物 / 交通 / 自然 / 家居),出题时干扰项优先从同一主题里抽,同主题不足 4 个词再退回全库:
// 先把候选拆成"同主题"和"其它主题"两摞, 同主题优先
while (written < need && same_count > 0) { ... }
while (written < need && other_count > 0) { ... }
这样一局题目集中在同一个主题上,接近 RAZ 分级读物「一本一个主题」的做法。降级路径也有单元测试覆盖(主题只有 2 个词时仍要凑出 4 个不重复选项)。
五、质量保障:一条命令跑完
./tools/validate.sh
依次是:仓库规范检查 → 中文字形覆盖 → 音频素材校验 → host 单元测试 → 隔离构建 → 合并镜像。
能纯函数化的逻辑全部做成了不依赖 LVGL / ESP-IDF 的状态机,在 Mac 上直接 cc 编出来跑:
app_quiz.c:出题、选项、计分、连对、一局边界、同主题干扰项、小主题兜底sound_pcm.c:音调合成(升/降调的过零数、首尾静音、峰值上限)
音频校验也进了门禁:每个词必须有发音、时长 0.15–1.6 秒、峰值不削顶、RMS 够响、首尾必须是静音(否则会爆音)。
六、数据
| 项 | 数值 |
|---|---|
| 词库 | 100 词 / 8 主题(13×6 + 11×2) |
| 图标 | 100 张 48×48 RGB565A8 ≈ 676 KB |
| 单词发音 | 100 段 16kHz PCM ≈ 1.76 MB |
| 鼓励语 | 7 句 ≈ 169 KB |
| 中文字形 | 202 个(16px / 28px 两套位图字体) |
| 固件 | app 3.53 MB / 分区 8.23 MB(约 43%) |
| 启动 | 880 ms 进入首页 |
8.23 MB 的应用分区用了不到一半,后面加词、加主题、加音效都还有余量。
七、下一步
- 用 NVS 存「最好成绩」和「最近玩的主题」,结算页显示历史最佳
- 主题选择页:让孩子自己挑今天玩水果还是动物
- 答对后播放该词的中文释义(现在只显示文字)
- 待机时的省电策略
八、写在最后
这套东西最特别的地方不是功能,而是开发方式:需求是口述的(「答对后直接进入下一题」「10 题就结束」「鼓励语放在结算页」),改动由 AI 代理直接落到代码里,然后自己刷机、自己读串口日志定位崩溃、自己跑门禁。
上面五个坑里,有三个是只有真机跑起来才会暴露的:悬空回调导致的 panic、repeat_count 归零、音量曲线算错。AI 能读日志、反查地址、改代码、再刷机验证,这一轮闭环在十几分钟内就完成了——比人肉试错快得多。
硬件:FoloToy AI Passport · 固件:ESP-IDF 5.5.3 + LVGL 9.5 · 素材:脚本生成,iOS/macOS emoji 与系统 TTS,仅供个人设备使用