把「沉浸式阅读」自动化:Storyteller 的强制对齐算法是怎么工作的
文章摘要
作者 Shane Moore(HN 用户名 smoores)是开源项目 Storyteller 的作者。Storyteller 如今已经长成一整套软件生态:完整的全栈 Web 应用、Android 和 iOS 原生应用、KOReader 插件,以及即将推出的 macOS、watchOS 和 tvOS 应用,v3 版本正在 alpha/beta 测试。但这一切的核心只有一样东西——对齐算法。
Storyteller 做的事是:你提供一本电子书和一本有声书,它自动把两者「强制对齐」(forced alignment),找出电子书里每一个词在有声书音频中被念出的位置,全程无需人工输入。然后它用 EPUB 规范内建的音频同步机制 Media Overlays,把音频和同步信息嵌进 EPUB。这样你就能一边读一边听,阅读器会随着朗读高亮当前句子。
项目最初只是一个 Python 脚本:输入一个有声书文件和一个电子书文件,输出一个带音频同步元数据的新 EPUB。当时能消费这类文件的阅读应用极少,专用阅读设备一个都没有;作者在电脑上跑脚本,把结果 EPUB 拷到手机,用 BookFusion 刚起步的 Media Overlay 支持来读听。那时他对自己一头扎进的「强制对齐」领域一无所知,第一版对齐算法是一堆摇摇欲坠的嵌套 while 循环——「感觉像在黑暗中摸索,知道尽头大概有光,但看不见」。
书籍带来的特殊难题。 即便是专门为电子书/有声书对齐设计的现有工具,也在几个书籍特有的挑战上表现挣扎,而他最初的粗糙尝试反而(以不同的成功程度)处理了这些情况:
- 章节顺序不同。 电子书里算前置材料的内容(比如献词),在有声书里可能被放到最后,因为有声书往往希望立刻进入正文。他举例说某本书的致谢在电子书最前面,在有声书里却在最末尾。
- 各自缺失的章节。 附录、前言、目录几乎总是在有声书里被跳过;反过来有声书也常有电子书里不存在的小章节。
- 跳过的片段。 有时较小范围的内容在朗读中被跳过,或者有声书包含电子书没有的内容(比如对插图的描述)。他用 Rosner 译本的《悉达多》举例:朗读跳过了电子书里的若干句子,其余则匹配。
- 用词差异。 有声书导演或朗读者会在原词难以流畅念出或读起来别扭时故意换词,有时也单纯是念错了。他举了个具体的例子:一本书里 “read”/”reading” 常常被换成 “listen”/”listening”。
这几类问题里,用词差异连比较基础的强制对齐系统一般都能处理;跳过的片段有挑战(尤其是音频跳过文本时),但结果通常不至于灾难性,只是不完美。真正会让很多强制对齐器直接崩掉的是章节缺失和重排。 Storyteller 之前的工具(比如作者称赞过的 afaligner)要求用户事先指定电子书的哪一章对应有声书的哪一章——这既非常手工,也相当困难,因为很多有声书连章节元数据或者按章分文件都没有。他想做得更好,这意味着必须解决这个问题,而这需要一个搜索算法。
前置问题:先大致找到章节在音频里的位置。 这个问题相当棘手——我们还没做任何对齐,所以对音频的口语内容一无所知;而且即便有一份完美的转录(我们也没有办法得到,那本身就是后面要解决的强制对齐问题),也不能直接在转录里搜索章节内容,因为完美转录也会偏离电子书基线文本。
但虽然拿不到完整准确的转录,我们可以拿到一个近似的文本表示:用 MMS(Massively Multilingual Speech)模型生成 CTC emissions,再贪婪解码成文本。
作者在这里把术语拆开讲了个透。CTC(Connectionist Temporal Classification,作者调侃这名字听起来像《沙丘》里的东西)十多年来一直是自动语音识别和强制对齐的支柱,它本质上是个损失函数;要用它,模型必须有一个「CTC head」层来输出 emissions。因为任何使用 CTC head 的模型输出形状相同,所以有标准算法把 emissions 进一步解码成文本——本文关心其中两个:「无约束贪婪解码」和「Viterbi 强制对齐」。Wav2Vec 2.0 走的是相反方向,它是一个预训练编码器,负责把音频数据转成模型能操作的内部表示。而MMS 就是把 Wav2Vec 2.0 编码器和 CTC 解码器结合、再在语料上微调出权重的那个模型。
把音频分块喂进 MMS,得到的 emissions 是一个二维矩阵:每一帧音频(一帧 = 20 毫秒)对应一个字符概率向量。作者用《彼得潘》第一句第一个词的真实 emission 数据做了可视化:展示每帧概率最高的 5 个字符,背景色饱和度表示该 token 在该帧被念出的概率。
贪婪解码。 emissions 有个很好的性质:它们是规整的——每个 emission 向量恰好代表一帧 20 毫秒音频。要搜索文本,我们需要能与文本比较的东西,而 emissions 本身不符合。但我们可以从中抽出文本:走过每个 emission 向量,取概率最高的那个字符。这不是任何意义上的准确转录,但它是文本,而且很大一部分大概是对的。算法很简单:逐帧取最高概率 token,折叠所有相邻的相同 token,丢掉所有 blank。得到的是一段没有大小写、没有标点、没有空格的、对口语内容的近似。他称之为「无约束贪婪解码」——无约束是因为没有提供基线文本作为解码目标,贪婪是因为每一步都取最优概率、从不回头重新考虑。
得到的这串东西看起来已经相当像电子书文本了。我们还可以对电子书文本做条件化处理让两者更接近:去标点、折叠空白、全部小写,甚至把数字转成拼写形式(比如 “2,000” → “two thousand”)。(他提到另一篇文章讲过如何在不丢失文本在原始 XHTML 中位置信息的前提下做这种条件化。)
n-gram + RANSAC 定位。 贪婪解码不会完美匹配查询文本,大多数有声书都有偏离,大多数贪婪解码还有一堆转录错误,所以不能简单扫描文档去找精确匹配。解决办法是把文档和查询都拆成小到「两边很可能有很多片段能对上」的碎片,也就是 n-gram——这里一个 gram 等于一个字符,n 取 10。在文档和查询中都记录每个 10 字符片段及其起始位置,其中很多会在两边都存在,用这些匹配来定位查询在文档中的位置。这个算法有两个很好的性质:一是对噪声解码极其鲁棒——无论是有声书朗读与电子书文本偏离很多,还是贪婪解码估计得特别糟,哪怕只有 10% 的 n-gram 能匹配,那也仍是成千上万个可用于拟合直线的数据点;二是它顺带给出大量额外信息,比如局部语速和已知内点(inliers)的位置,这些会在真正的强制对齐环节派上用场。
Viterbi 强制对齐。 有了「RANSAC 化的 n-gram」边界搜索确定的章节起止,就可以进入相对直接的 CTC Viterbi 算法了。强制对齐通常被表述为一个全局优化问题:有一个损失函数(比如所有被选中字符概率之和),要在整章范围内最大化它。这类全局优化通常难以高效计算,但这里有两个技巧。
第一个技巧是锚点分段。之前算 n-gram 匹配、用 RANSAC 找内点时,我们得到了一批相当确信是真实匹配的点。如果再加一个约束——只考虑在各自文档中全局唯一的匹配——我们就能几乎确信它们是真匹配。既然知道章节和音频在这些点上确实对齐,那就只需要在这些锚点之间的帧上跑强制对齐。也就是说可以在锚点处切开,一段一段地跑。如果大约每 2000 个字符找一个这样的唯一匹配锚点,那么每次只需要对约 2000 字符做强制对齐,而不是对可能几万字符甚至更长的整章一次性处理。
第二个技巧是 Viterbi 本身——一个自底向上的动态规划算法:先解子问题,再从子解构建出最终解。让 CTC 对齐能用动态规划的关键洞察是:对于状态 A、B、C,如果 B 位于 A 到 C 的最短路径上,那么这条最短路径中从 A 到 B 的那一段也必然是 A 到 B 的最短路径——否则你就可以把它换成更短的 A→B 路径,从而让整条 A→C 路径也更短。
他用第一个单词 “All” 详细演示。状态就是章节文本,构造状态序列时在每个字母之间插入 blank token,这样双写字母(”ll”)可以表示成 “l → blank → l”,最终状态序列是 “blank → a → blank → l → blank → l → blank”。允许的转移有三种:停在当前状态;移动到下一个状态;或者跳过下一个 blank 状态直接到再下一个——跳过转移只允许发生在两个不相等的字符之间,所以 “a” 和 “l” 之间的 blank 可以跳,两个 “l” 之间的不行。路径也允许跳过首尾的 blank。
穷举法在 7 帧 7 状态的情况下有 210 条可能路径——仅仅是一个单词,而且随着帧数和状态数从 7 增长到比如 70,000,这个数量呈指数爆炸,完全不可行。Viterbi 的做法是:先建一个 7 元素的「当前分数」向量,除了两个可能的起始状态(索引 0 的起始 blank 和索引 1 的 “a”)之外都初始化为负无穷,这两个直接用第一帧 emissions 里的分数。然后逐帧迭代,每一帧查看下一帧的分数来决定采取哪个转移,记录转移并累加分数。走完所有帧后,「当前分数」向量里存的就是到达每个状态的最佳路径分数,同时有一份转移历史记录了怎么到达每个状态的。最后挑出分数最高的终止状态(第二个 “l” 或最后的 blank),沿着转移历史回溯,就得到完整路径。结果是:不用比较 210 条路径,只需要计算 2 条——每个合法终止帧恰好对应一条最优路径。
有了路径,只需遍历帧、记录每个 token 起止于哪一帧;因为每帧固定 20 毫秒,就能算出时间。放大来看,这样就得到了书中每个词和每个句子的起止时间。作者说这个新算法已经可以通过一个新工具或者 Storyteller v3 beta 里的 CTC 对齐器选项亲自试用,很快会向所有人开放。
HN 评论精华
作者 smoores 在 127 分的帖子下几乎回复了每一条评论,讨论质量很高,但意外的是很大一部分讨论并不在算法本身,而是围绕「谁需要沉浸式阅读」和「这个功能是不是在惯坏人」展开——作者被迫为产品的存在意义辩护了好几次。
作者自己的背景说明: smoores 说他最近请了一周假来重新实现 Storyteller 的强制对齐算法。Storyteller 是一个开源、自托管的平台,用于创建、管理和阅读/收听「readaloud」书籍——内嵌有声书朗读、能随朗读高亮每个句子(有了新算法之后还能高亮每个词)的书。他对新算法的效果很满意,希望别人也觉得有意思。
最实际的用例,也是这个项目的起源: mobeets 说他一直想要的功能是——比如做饭时听一本电子书,之后能坐下来从刚才听到的地方接着读——他觉得这本质上需要 Storyteller 提供的同样能力(同步电子书和有声书进度),只是对他的用例来说可能有点大材小用,因为粗粒度对齐就够了。smoores 回答说这恰恰就是他最初做 Storyteller 的原因:他想在长跑时听书,回来后切换到读。Storyteller 的移动应用对此支持很好,有完整的有声书播放器和电子书阅读器,一键切换;当然也可以双击某个句子从那里开始播放,应用会高亮当前朗读的句子。tcoff91 现身作证:Storyteller 在这方面很好用,还能跨设备同步位置——他在 iPhone 上听,真要静下心读的时候切到 Boox 墨水屏,正好接上。
生态与硬件: zackify 说他刚拿到一台 xteink X3 并越狱了 Kindle,在做自定义东西,问对齐后通过 OPDS 拉到 KOReader 里,蓝牙音频和高亮能不能一起工作。smoores 指出有一个 KOReader 插件(audiobook.koplugin)对 Media Overlays 有进行中的支持;另外还有 StorytellerSync 插件可以把 KOReader 的进度直接同步到 Storyteller 服务器。zackify 顺势想到可以用设备端 AI,打算配合 Kokoro(他见过的同尺寸里最自然的 TTS)试试,好奇 12 代 Paperwhite 能不能跑得动。leobg 泼冷水说他在 iPhone XS 上跑 Kokoro 比实时还慢,不指望 Kindle 能行;deivid 补充说 Kokoro 可以做些魔改(他贴了脚本)提速约 3 倍,让他的手机从「略慢于实时」变成「非常舒适」,首个发声的延迟也更低。chrisweekly 说 Whispersync 是他还在给亚马逊/Audible 付钱的原因之一,很期待有竞争和可行的开源替代;smoores 回应说 v3 应用发布时会再发帖,「即便在 alpha 阶段我也觉得它们把 Kindle 应用甩开了」。
「这功能到底给谁用」的争论(本帖火药味最重的部分):
orphereus 的提问相当不客气:除了残障人士之外这有什么用?为什么需要逐词高亮,这不就像那些一次显示一个词好把你钩住的 TikTok 和 IG reels 吗?如果你(在没有残障的前提下)需要靠这个来读书,你应该改掉现在的习惯。smoores 的回应是全帖最长也最动情的一条:首先,确实有很多人因为阅读障碍或神经多样性而难以长时间读文本;其次,残障不是二元的——有些人读文本就是比别人更吃力,「你的论点是因为你个人觉得他们不该需要这个,所以书对这些人就该保持更难获取的状态吗?」再者,就是有大量人单纯喜欢这样读书:有声书制作本身就是一门艺术,很多人喜欢同时体验文本和音频;对有些人这有助于在嘈杂环境里集中注意力,有些人发现有朗读时读得更快,有些人就是享受。他说:每次他发关于 Storyteller 的内容,评论区都会有好几个人坚称这东西不该存在,因为没人应该用与他们不同的方式消费书籍。「如果你不想这样读书,完全没问题!你甚至仍然可以用 Storyteller——它对纯 EPUB 也很好用。为什么需要让别人因为自己接触故事的方式而感到难受呢?如果更多人读更多书,世界会更好;劝阻人们用让他们感到愉快和可及的方式读书,会让世界变糟。」他最后回敬了一句:「如果你觉得有必要指导你不认识的人『改掉他们现在的习惯』,只因为你个人不理解他们的需求或愿望,也许你该重新考虑的是你自己的习惯。」
yorwba 补充了一个作者没提到的用例:读文本的同时听音频对学外语很有帮助,但语言还不熟练时你很容易跟丢——逐词高亮让你很容易重新跟上;他猜刚开始学母语阅读的人也会同样受益。nottorp 半开玩笑地说「我以为书好看阅读自然就沉浸了」,smoores 认真解释后,nottorp 回了句「恐怕这词让我想起新话(newspeak)」。smoores 顺带说明了术语选择:「沉浸式阅读」(immersive reading)似乎是业界通行说法,他用它是因为最可能被广泛读者认出;他个人和 Storyteller 生态内部叫它 “readaloud”,觉得那个说法至少更有用一点。
关于阅读速度的讨论: thelisu 说这恰恰点出了他不喜欢有声书、播客甚至电影的核心原因——太慢了;看这个例子他的阅读速度是音频的两三倍。他想要的是反过来的东西:某种眼动追踪软件,在他读到的位置播放音频。smoores 回应说:几乎所有用 Storyteller 朗读功能的人都会把音频调快不少,常常 2–3 倍,好匹配自己的阅读速度;Storyteller 应用允许为纯音频播放和 readaloud 分别设置速度,因为能看到文字通常会让人更容易听更快。他也补了一句「显然,如果这就是不适合你,也完全没问题」。altmanaltman 提了个有意思的现象:他读得很快,发现过了某个点之后,没有内心声音反而更有帮助——更像是看到一堆文字然后直接理解它,而不是逐词进行;他认为这在音频上做不到。trunch 追问他是本来就有内心声音、只是快读时压制它,还是根本没有(他自己缺少这个,好奇要不要往反方向培养)。altmanaltman 说他必须压制它、强迫自己进入「快速模式」,刻意看着词而不带声音地理解它们——这是他从某本速读指南里读到后开始练的,确实有效;「但我们居然能不发声也不内心默读就直接获取信息,这件事本身对我来说还是很不可思议」。
ek_erilaz 顺着「反转因果方向」这个想法写了本帖最富想象力的一条,把它当成把内心朗读声音外化的显然下一步,并提了一串问题:这会改善阅读理解、速度、愉悦感吗?如果音频是你自己合成的声音,会不会有助于理解和沉浸?如果音频是与你自己非常不同的声音,而你每天用好几个小时,它会改变你天然的内心阅读声音、甚至自我感知的其他方面吗?他还提到自己阅读时常会飞速前进然后跳回一个词、一句或一段慢慢复看——在这种系统里,音频会随着他慢下来一起放慢,几乎是在陪他一起把词拼读出来;他刚读完一本满是冷僻法国村庄名的书,没法忽略它们,结果卡在拼读和猜发音上,有个系统替他/陪他念出来会很棒(这假设系统擅长多语言发音——大体已解决——以及在发音冲突时选合理默认值,这个大概还没解决)。他最后一个设想相当天马行空:对非技术类阅读,可以试着在朗读背后铺一层极低音量的环境音,由 LLM 分析文本里的明显线索来驱动——酒馆声、山间风声、篝火噼啪声,甚至生成音乐(阴森地牢之类);当你停下来把目光从书上移开时朗读当然会停,但酒馆的声音会继续。这可能能帮那些平时读着读着就分心的人「锁进去」。
SamBam 提了个很实际的设计问题:对阅读障碍者来说,逐词高亮真的比整句宽泛高亮更好吗?他读书时视线会在句子里前后跳跃,那个 demo 让这一点变得非常明显——要盯住那个高亮的词其实相当困难。他猜眼睛能在句内自由前后跳跃是有好处的,也建议这至少应该做成用户偏好开关;他还提了个巧妙的 UI 想法:让高亮更「弥散」,聚焦在正在念的词但向两侧渗出(也许直到句子边界这个硬停),把视线引导过去,类似 BeeLine 编辑器的红蓝文字配色。smoores 说他猜想这和很多无障碍工具一样因人而异;Storyteller 应用目前其实还不支持 demo 里展示的多级粒度——在这一版对齐算法之前,时间精度不足以支持词级高亮,所以现在只做句级。等推出多级粒度时,确实会做成可自定义的,包括每一级如何指示(比如句子设背景色、词加下划线)以及是否指示。他说「弥散高亮」这个想法很妙,之前没听过。
其他有意思的: cainxinth 说这正是他校对的方式——一边读自己的稿子一边放屏幕朗读器;大脑会做一些你根本察觉不到的自动纠错,纯读的时候偶尔就会跳过错别字。justinhunt 提到他在做一件相关的事:把学生的口头朗读转录与他们被指定阅读的文本做匹配——学生会读错然后重读、跳句、跳段——他认为这与本文相似,因为两者都在给句子和词分配音频时间戳。usern20260720 甩了一句「我用 Claude 花了几个下午就给 Athenaze 生成了沉浸式阅读」,smoores 的回复只有三个字符:「… Okay!」
最后还有一条纯粹的排版吵架:lstodd 抱怨「这明明是讲阅读的,为什么我在 Firefox 里要放大到 140% 才能开始读」,smoores 追问具体是什么问题,对方最终说清了:在 24 寸 1920×1200 的横屏上,小字号配窄栏浪费了 60% 以上的屏幕空间,而他用标签页——「标签页不是窗口,我不会为了一个标签页去缩放浏览器」——「这是横向 24 寸 1920×1200,不是一台该死的手机,所以我恳请你和所有人忘掉那些荒谬的左右留白」。