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

tts-audiobook

TTS audiobook + word-level karaoke (desktop, 100% local)

Parent: key-designs

Desktop-only. Design rationale (desktop/docs/TTS-PLAN.md): fully local, no cloud, no API keys; engine locked to Piper (VITS/ONNX) via sherpa-onnx (WASM — no Python, no native .node addon, to dodge macOS hardened-runtime signing). The engine ships bundled; the only opt-in download is the per-language voice pack (~60 MB) from R2.

Synthesis, process-isolated. main/tts/piper.ts loads one cached OfflineTts per voice (VITS config). The WASM runs in a long-lived utilityProcess child (main/tts/engine-runner.ts), one request at a time — because the same WASM crashes ("memory access out of bounds") inline in the busy main process but is rock-solid in a clean child. Voice download (voice-download.ts) streams to *.part, hashes in-stream via a pass-through Transform (a data listener would corrupt large files), md5-verifies, then atomic-renames; a whole-voice failure wipes the dir (no partial install).

Prebake, incremental. generateAudiobook.ts (renderer) folds a TTS_TEXT_VERSION into the content hash, builds a per-sentence work list + chapter map, and passes already-baked chapters as a resume set. prebake.ts (main) synthesizes one paragraph per engine call (natural prosody) with a 0.45 s inter-paragraph silence, wraps PCM into mono 16-bit WAV, and writes each chapter's row the instant its file lands — so the book is playable ('partial') mid-bake ("烤多少听多少") and cancel/restart resumes.

Word-level karaoke — the interesting part. Piper gives no alignment, so timing is derived from PCM energy: main/tts/word-cues.ts computes per-20 ms RMS voiced flags, trims silence, and distributes the word tokens across the voiced time weighted by word length — so the highlight follows real speech energy and holds through Piper's punctuation pauses, instead of a flat character rate. Critically, the tokens are not re-tokenized in main — they are the renderer's [data-word] span sequence (core tokenize+isWordToken, wordSpans.ts) shipped through IPC, so cues align 1:1 with on-screen spans.

The anchor trick (why it stays in sync). Each VTT cue is stamped with the word's book-absolute raw offset (r<rawOffset> identifier line), computed from each sentence's displayToRaw map. At playback, useReaderAudio.ts walks the page's [data-word] spans, maps each to its raw offset, and paintKaraoke toggles 3-state classes current/read/unread by matching the active cue's rawStart to a span. Because both the cue and the on-screen span are anchored to the same canonical raw offset (raw-display-cursor), they line up even across re-pagination/font changes — the same backbone that makes highlights layout-independent makes karaoke layout-independent. Plus follow-the-word page turning, double-click-to-seek, and auto-pause-on-lookup.

Fallback. TTSProviderRegistry resolves per-language override → default → WebSpeechProvider (window.speechSynthesis, always available) — which has no audio blob and instead builds WordTiming[] from native onboundary word events. That's the karaoke source when Piper isn't installed.

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