TTS 有声书与逐词卡拉OK同步(桌面端,完全本地)
上级:key-designs
仅限桌面端。设计依据见 desktop/docs/TTS-PLAN.md:完全本地、不上云、不用 API key;引擎锁定为通过 sherpa-onnx 运行的 Piper(VITS/ONNX)(WASM 形态——不依赖 Python,也不用原生 .node 插件,从而绕开 macOS 强化运行时的签名要求)。引擎随应用一起打包;唯一需要用户自行下载的是从 R2 拉取的按语言划分的语音包(约 60 MB)。
合成过程与主进程隔离。 main/tts/piper.ts 为每个语音(VITS 配置)加载一个缓存的 OfflineTts 实例。这段 WASM 运行在一个长驻的 utilityProcess 子进程(main/tts/engine-runner.ts)里,一次只处理一个请求——因为同一份 WASM 直接跑在繁忙的主进程里会崩溃("memory access out of bounds"),但放进干净的子进程就非常稳定。语音包下载(voice-download.ts)先流式写入 *.part,通过一个透传的 Transform 在流中同步计算哈希(用 data 事件监听会导致大文件损坏),再做 md5 校验,最后原子重命名;只要整个语音包下载失败,就直接清空该目录(不允许半成品安装)。
增量式预烤。 渲染进程侧的 generateAudiobook.ts 把 TTS_TEXT_VERSION 并入内容哈希,构建按句子划分的工作列表和章节映射,并把已经烤好的章节作为续跑集合传入。主进程侧的 prebake.ts 每次引擎调用只合成一个段落(以保留自然的韵律),段落间插入 0.45 秒静音,把 PCM 包装成单声道 16 位 WAV,并且每个章节的文件一落地就立刻写入对应的数据行——因此这本书在烤制过程中就是可播放的("partial" 状态,"烤多少听多少"),取消后重启也能续跑。
逐词卡拉OK同步——最有意思的部分。 Piper 不提供对齐信息,所以时间戳是从 PCM 能量里反推出来的:main/tts/word-cues.ts 以每 20 毫秒为窗口计算 RMS 判断是否有声,裁掉静音段,再按词长把词元分配到有声时间段内——因此高亮跟着真实的语音能量走,能撑过 Piper 在标点处的停顿,而不是按恒定的字符速率推进。关键的一点是,词元没有在主进程里重新分词——它们就是渲染进程里的 [data-word] span 序列(核心的 tokenize+isWordToken、wordSpans.ts)经 IPC 原样传过去的,所以字幕与屏幕上的 span 是一一对应的。
锚点技巧(同步为什么不会漂移)。 每条 VTT 字幕都打上该词的全书绝对原始偏移量(r<rawOffset> 标识行),这个偏移量来自每个句子的 displayToRaw 映射表。播放时,useReaderAudio.ts 遍历页面上的 [data-word] span,把每个 span 映射到它的原始偏移量,paintKaraoke 再通过把当前字幕的 rawStart 与某个 span 匹配,来切换三态 class:current/read/未读。由于字幕和屏幕上的 span 都锚定在同一个规范的原始偏移量(raw-display-cursor)上,即使重新分页或换字体,两者依然对得上——让高亮不受排版影响的那套底层机制,同样让卡拉OK同步不受排版影响。此外还有跟随当前词自动翻页、双击跳转播放位置、以及查词时自动暂停播放。
回退方案。 TTSProviderRegistry 按优先级解析:按语言的覆盖设置 → 默认设置 → WebSpeechProvider(window.speechSynthesis,始终可用)——它没有音频 blob,而是从原生的 onboundary 逐词事件构建 WordTiming[]。这就是 Piper 未安装时卡拉OK同步的数据来源。