把课本音频做成“能逐词跟读”的形态——播到哪个词,哪个词就亮起来,点任意一句还能跳回去重听。
怎么长出来的
这个项目一开始不是做朗读的。
2026-06-06,立项。 当时它是一个“帮小学生记课本单词”的 Web PWA:后端有 SM-2 间隔重复算法,前端有单词卡与学习记录,还配了管理后台和 JWT 登录。
6 月 7~11 日,单词线成型。 管理后台、学习记录、PWA 离线,一周内跑通。
7 月 13 日,主线换了。 这一天加进来的是课文朗读:五年级的音频、AudioView.vue、页面到 Unit 的映射。第二天补上离线生成的时间轴、点文本跳转,并把自写的歌词滚动换成了 AMLL。7 月 15 日改解析 TTML,逐词时间轴从此定型。
7 月 24 日到 9 月 14 日,逐单元补内容。 一个单元一个单元地加音频与字幕,中间还专门提交过一次人工修正 TTML 的分段与词级时间。
为什么转向,仓库里没有记载——设计文档停在 6 月 6 日,之后的提交信息只说做了什么、没说为什么。我能确认的是:单词学习那部分代码还在仓库里,只是 7 月 13 日之后再没被碰过。这段动机只有我自己知道,改天补上。
和主线的结合点
我的主线是「探索面向 AI 的交互边界」。这块作品在这条线上站的位置不太一样,得说清楚。
它运行时没有任何 AI。 全仓没有一处模型调用。唯一真实的 AI 在离线脚本里:用 Whisper(stable-ts)把课文音频对齐成词级时间轴。
所以它不是“AI 在系统里有位置”的探针。 按三条判据——产出可继续编辑的结构、能主动发起、和人共用工作台——后两条完全不成立,产品里根本没有 AI 这个角色。
它回答的是同一个问题的另一面:AI 该放在哪里。 这里给出的答案是把重活放在离线,把结果固化成人和机器都能继续改的中间格式(TTML)。判据里第一条在这里是弱形式:TTML 是可读、可人工继续修改的结构,有提交为证——确实有人工修正分段与词级时间的那一次。
这条取舍很具体:用户点开就要立刻能听,不能等模型。 代价是新增课文要跑一次离线脚本;换来的是运行时零推理、朗读页首启约 1 秒。
和白板放在一起看会更有意思:那块白板把 AI 放进系统内部、和人的操作走同一条路;这块把 AI 挡在运行时之外。同一个“位置”问题,两个相反的答案。
难点
Whisper 的输出和课本的结构对不上。 它吐出的是整段词流,而课本要按句分组。解法是按原句的词数把词流切回去;切不齐时会出现零时长的词,还得插值补上——否则那一段的歌词会卡住不动。
TTML 比想象中松。 它用 itunes 命名空间标记 timing="word",时间还有 0.2s 和 00:00:01.540 两种写法。前端用 DOMParser 加 getElementsByTagNameNS 解析,两种都认;解析失败就降级成空,不让整页挂掉。
滚动会打架。 用户手动滚歌词时,播放器仍在按时间推高亮,两者抢同一个滚动位置,画面会一跳一跳。解法是在用户滚动期间冻结传给歌词组件的时间输入,等 1.5 秒惯性结束再恢复。
配置写了两份。 页面到 Unit 的映射在前端 audio-pages.ts 里,离线脚本又“抄”了一份(脚本注释里自己也承认了)。加一个 Unit 必须两边同时改——这是一个明确的债,还没还。
iframe 里的自动播放。 演示入口希望点开就播,但浏览器要求宿主委派 allow="autoplay" 并且顶层文档有过用户手势;而选页和音频解码又都是异步的。最后是轮询重试,直到暂停图标真的出现为止。
亮点
静态资源驱动。 音频与时间轴走一套路径约定,加一课只需要放两个文件、改一个配置数组,不碰后端。这也是它能以纯静态形态嵌进来的前提。
TTML 当人机共用的中间格式。 它既是离线脚本的产物,也是人能打开来改的文件——前面那次人工修正就是这么做的。前端不需要自己写歌词滚动,直接喂给现成的词级组件。
波形自己算自己画。 用 AudioContext 解码,按 300ms 分桶取最大振幅,画在 canvas 上——比逐帧采样快一个数量级,也没有引第三方的波形库。canvas 还按 devicePixelRatio 缩放过,Retina 屏上不发虚。
演示入口零侵入。 独立入口加一次多入口构建就够了:装自己的路由、不挂登录守卫、只写偏好不写 token。主应用逻辑一行没改,登录流程也完全不受影响。
核心功能
- 年级 → Unit 1~10 → 课文,逐层进入后点播放
- 播放时歌词按单词粒度高亮,时间轴来自离线对齐
- 点击任意句子立即跳转回听,音频 seek 与歌词滚动同步
- 波形进度条:实时解码 mp3,300ms 颗粒度取振幅
- 手动滚动歌词时冻结播放时间,避免滚动与高亮互相打架
现状
朗读功能已经可以页内试玩,线上地址就是主站 iframe 指向的那个演示入口。
它一度做不到,但原因不是“技术架构不允许”,而是被登录守卫挡着:/audio 路由声明了 requiresAuth,全局守卫检查 localStorage 里的 token,未登录直接跳登录页。
核对源码后发现三件事:
AudioView.vue运行时只fetch时间轴与音频,没有任何后端调用- 音频与时间轴都在
public/下随产物发布(59 个音频、57 个时间轴) - 而那个守卫只是前端判断——只看 token 存不存在,并不校验有效性
所以解法不是“改造架构”,而是给它加一个独立演示入口。已实现并验证:首屏约 197 KB(gzip),运行时零后端请求,在 iframe 内能正常播放并逐词高亮。
因为是手机竖屏应用,这里用 9:16 的框展示。