2026-09-23·by Sijie Wang#lucerna#software#architecture

tts-audiobook

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.tsTTS_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+isWordTokenwordSpans.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 按优先级解析:按语言的覆盖设置 → 默认设置 → WebSpeechProviderwindow.speechSynthesis,始终可用)——它没有音频 blob,而是从原生的 onboundary 逐词事件构建 WordTiming[]。这就是 Piper 未安装时卡拉OK同步的数据来源。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

tts-audiobook