apps/readest-app/docs/read-along-narration.md
When a book ships with a recorded human narration, Readest plays that recording instead of synthesizing speech — while keeping everything else Read Aloud already does: the moving highlight, page-following, sentence/paragraph skip, the scrubber and seek, speed, sleep timer, lock-screen and CarPlay controls, and background sessions.
Synchronized read-along is a feature the major ecosystems have converged on:
Readest's version is the Kindle/Audible experience built on the open EPUB standard rather than a store-side pairing of two purchases. It needs no account and no matching entitlements: any narrated EPUB you own or generate plays, on every platform Readest runs on.
The recording is read from EPUB 3 Media Overlays: a SMIL file per spine
section whose <par> elements each pair a text fragment
(chapter.xhtml#sentence-3) with a clip of a narration audio file
(clipBegin/clipEnd). Those pairs are the publisher's own text-to-audio sync
points, which is why read-along playback needs no alignment of its own.
Commercially narrated read-along EPUBs exist but are uncommon. If you have an ebook and a separate professionally narrated audiobook — the usual case — generate the Media Overlays yourself.
Storyteller is the recommended tool. It is a self-hosted platform that takes an ebook plus its audiobook, transcribes the audio with Whisper, force-aligns the transcript against the book text, and emits an EPUB 3 with Media Overlays — audio and SMIL packaged inside the container. Because the output is standard EPUB, it plays in Readest with no Readest-specific step. Source: gitlab.com/storyteller-platform/storyteller; the alignment method is described under How it works.
Alternatives if you'd rather not run a service: syncabook (CLI, aimed at LibriVox + Gutenberg pairings) and aeneas (the forced-alignment library underneath several such tools).
Readest deliberately does not do the alignment itself. Dropping a bare MP3 next to a book gives no timings, and inventing them would mean shipping a speech-recognition model. Alignment is a separate, one-time, offline job; tools like Storyteller already do it well.
media:narrator metadata, or "Book narration" when the book
declares none).Two behaviours worth knowing:
Narration reuses the whole Read Aloud stack by swapping the two seams it already
had. TTSClient abstracts where audio comes from; foliate's TTS class
abstracts how text is cut into marks. Recorded narration is exactly "a
different audio source with a different segmentation".
Everything lives in src/services/tts/mediaOverlay/:
| File | Role |
|---|---|
parseSmil.ts | Pure SMIL parsing: parseSmilClock (SMIL clock values) and parseSmil (walks <body>/<seq>/<par> in document order, resolving hrefs against the SMIL file). |
MediaOverlaySection.ts | Per-section index: resolves each par's text fragment to a DOM Range in the section document, groups pars into blocks by nearest block-level ancestor, and builds the SSML the controller consumes. |
MediaOverlayTTS.ts | Stands in for foliate's TTS. Same navigation surface (start/resume/next/prev/nextMark/prevMark/from/setMark/getLastRange), but marks come from the par list. |
MediaOverlayClient.ts | implements TTSClient. Plays clips off one HTMLMediaElement, emitting a boundary as each par becomes audible. |
Consequences of that shape:
Marks are 1:1 with clips by construction. Mark names are section-global par ordinals, so the client resolves a mark straight to its clip and there is no text↔audio matching anywhere in the feature.
The whole section plays as one continuous span. Media Overlay clips are contiguous and in document order, so sequential playback needs no seeking at all: the element keeps rolling while boundaries are fired at par thresholds, and a narrated sentence or paragraph has no seam mid-way. The playhead moves only for a genuine discontinuity - session start, a sentence skip, a scrub, or a new audio file where the publisher split the recording - decided from the element's own position rather than from bookkeeping.
The scrubber is exact. TimelineSentence.duration carries
clipEnd - clipBegin and outranks the measured/estimated duration tiers in
SectionTimeline, so a narrated chapter reports the recording's real length
with no ~. It is deliberately not routed through the text-keyed duration
cache in ttsDuration.ts, where two identical sentences would collide.
Capabilities, not identity checks. The client reports
{ wordBoundaries: false, mediaClock: true, gapControl: false, liveRateChange: true, continuousTimeline: true },
and ensureTimeline/supportsPlaybackInfo/getPlaybackInfo gate on
mediaClock rather than comparing against the Edge client — which is what
TTSCapabilities in TTSClient.ts existed for.
A continuous timeline is handed over, not stopped. continuousTimeline
tells the controller that consecutive blocks are one recording, so it neither
pads paragraph transitions with its own delay nor treats the stop between two
utterances of a session as a real stop. That stop passes handover to
TTSClient.stop(), and the narration client stays rolling through it. Both
additions to TTSClient.ts are optional, so the synthesizing clients
(NativeTTSClient, WebSpeechClient, BufferedTTSClient) ignore them and
behave exactly as before.
Page-following inside one sentence. A sentence laid out across a page
break gets one mark, on the page it starts on, and a phrase-timed recording
reports no words in between — so the view used to sit still while the voice
read the tail on the next page. getChunkProgress() says how far through the
phrase the audio is; where the page stops showing the sentence is measured,
not assumed, since the same sentence breaks at a different word on another
screen or font size. pageBreakFraction (utils/ttsPageFollow.ts) bisects the
live layout — probing characters through getTextSubRange, because each probe
forces a reflow — and returns the break as a fraction of the sentence's text.
The page turns once audio progress passes it, re-measuring after each turn so a
sentence spanning three pages advances one page at a time. No word position is
invented, so the highlight still follows the recording exactly. Paginated
layout only; scrolled layout keeps its at-mark behaviour.
Selection is the existing Voice picker: TTSController.getVoices prepends a
narration group for books that have overlays, and setVoice routes
MEDIA_OVERLAY_VOICE_ID to the narration client, rebuilding the section's mark
source (the two segment differently, so the instance itself is replaced).
ttsUseNarration on TTSConfig records the per-book opt-out; it is separate
from ttsVoice because ttsVoice inherits the global default and so cannot
distinguish "never chose" from "chose a synthetic voice for this book".
The narration data comes from foliate's EPUB parser, which already exposes
section.mediaOverlay, book.media, book.loadText and book.loadBlob;
Readest's narrowed BookDoc/SectionItem types in src/libs/document.ts were
widened to surface them. foliate also ships its own standalone MediaOverlay
player, which Readest does not use: it owns its own <audio> and iteration
state and highlights via the publisher's media:active-class, so routing
through it would bypass the scrubber, sleep timer, media session, and the
reader's own highlight style.
<seq> skippability. epub:type="pagebreak"/footnote escape is not
implemented. The parser keeps the <seq> structure so it can be added without
a rewrite.media:active-class is ignored on purpose — the reader's own TTS
highlight style and colour win.getChunkProgress(), so engines without one (Web Speech) keep the old
behaviour: a sentence straddling a page break waits for the next mark.NativeNarrationPlayer → native-tts playout load/seek). A plain
HTMLMediaElement is interrupted when the media session claims the app's
non-mixable .playback session; web / Android / desktop keep using
HTMLAudioElement.A book that carries narration shows a headphones badge on its library cover.
Book.hasNarration is set at import time (importBook in
src/services/bookService.ts) because the library list never opens the file. It
is derived from the file on every import, like format, so it needs none of the
field-level LWW timestamps that user-editable book fields carry.
Consequence: books already in the library before this shipped carry no badge until they are re-imported. Narration itself still works on them — only the badge is missing, because nothing has re-read the file since.
src/__tests__/services/tts/media-overlay-*.test.ts covers the SMIL parser, the
section index, the mark iterator, the client (against a fake media element), and
controller-level narration selection, timeline exactness, and section skipping.
media-overlay-real-epub.test.ts runs the real DocumentLoader against a real
Media Overlays book. The fixture is a ~10 MB binary and is not committed, so the
suite soft-skips without it:
curl -sLO https://github.com/IDPF/epub3-samples/releases/download/20230704/moby-dick-mo.epub
READEST_MO_EPUB=$PWD/moby-dick-mo.epub pnpm test -- media-overlay-real-epub
Moby-Dick MO is the canonical W3C sample
and a deliberately awkward one: chapter 1 mixes a heading par, three per-word
pars and seven per-sentence pars inside a single <p>, under a nested <seq>
carrying epub:textref. Only 2 of its 144 spine sections are narrated, so it
exercises gap handling too.
Verified end to end against two very different real books:
h:mm:ss.mmm clock values, mixed word/sentence
granularity, audio in an .mp4 container, 2 of 144 sections narrated.1705.600s),
parent-relative hrefs (../Audio/00010-00001.mp3), 17 SMIL files, 22 MP3s in a
330 MB container, no media:narrator (hence the "Book narration" fallback),
4 unnarrated front-matter sections. Its computed chapter timeline came out at
2966.8s against the book's declared media:duration of 2966.79s.