Novel Reader
Read or listen to serialised fiction, with narration that follows every word.
- Role
- Author
- Period
- 2026
- Status
- Live
- License
- MIT
- Latest
- Updated Sep 2026
Live app (opens in a new tab)Source on GitHub (opens in a new tab)
Overview
A browser reader for novels written in markdown. Point it at a folder of chapters and read or listen with word-synced narration. Nothing uploads and there’s no account. Or publish a novel to server-rendered pages with SEO derived from the prose itself.
- 2
- voice engines behind one interface
- 145–313
- ms mean word-timing error per sentence, measured
- 2
- builds from one codebase: Node host and static Pages
- 0
- uploads in local mode
Problem
Novels written in markdown end up read in a word processor or an editor, where read-aloud is an afterthought and the voice loses its place in the text.
What I built
- Local mode (/read): point it at a folder of .md chapters. The library persists in IndexedDB, and the app installs and reads offline as a PWA.
- Cloud mode (/n/<slug>): publish a novel to server-rendered pages. Metadata, JSON-LD, share cards and the sitemap are derived from the text; the author fills in nothing.
- Narration with word-level highlighting, from device voices or an on-device neural voice.
- A GitHub Pages build that, with Supabase configured, prerenders the public catalogue into real HTML at build time.
Architecture
Markdown is parsed and rendered, then tokenised from the rendered DOM into word spans. Two voice engines sit behind one interface, and a cue schedule drives the highlighted word.
tokenize.ts
word spans, sentence ranges
Walks the rendered DOM, not the markdown, because offsets otherwise desync at the first italic. Each word becomes a span; sentences are word-index ranges.
Markdown chapters
local folder or Supabase
Order comes from the filename number, then front-matter order, then natural sort. Title from front matter, then the first H1, then the filename.
markdown.ts
front matter, ordering
An isomorphic parser for the fiction subset: headings, emphasis, blockquotes, lists, rules, code and links.
Rendered DOM
what the reader sees
Highlighting works from what was rendered, not from the source text.
tokenize.ts
word spans, sentence ranges
Walks the rendered DOM, not the markdown, because offsets otherwise desync at the first italic. Each word becomes a span; sentences are word-index ranges.
timing.ts
calibrated estimator
When an engine emits no boundary events, each word is weighted 0.6 × letters + 1.4 × syllables, with pauses for punctuation. It resyncs every sentence, so error never accumulates.
System voice
Web Speech, instant
Uses real boundary events when the engine emits them; falls back to the estimator for engines that don’t, like Chrome’s network voices.
Kokoro-82M
ONNX on WebGPU or WASM
An on-device neural voice, loaded only when you ask for better voices, never on page load. Audio duration is known after synthesis, so the per-word schedule is exact.
usePlayer
transport, cues, highlight
Queues clips so buffers join sample-accurately while synthesis keeps up, and drives the highlighted word from the cue schedule.
Estimator schedule, running live
Read or listen to serialized fiction in a browser. Word-synced narration, no install.
weight = 0.6 × letters + 1.4 × syllables, pauses at punctuation, resyncs each sentence
Decisions
Tokenise the rendered DOM, not the markdown
Character offsets into markdown drift the moment formatting appears. Wrapping words in the rendered DOM keeps the highlight on the word the reader actually sees.
Heavy model, loaded on request
Kokoro takes around 30 seconds to load once, then it’s cached. It never loads with the page, only when the reader presses “Better voices”.
An estimator that’s measured, not assumed
Checked against real speech boundary timings: 145 to 313 ms mean error per sentence, worst case 705 ms in dialogue. Quote-aware weights cut mean error from 403 ms to 360 ms.
Capabilities declared in one place
lib/mode.tsdeclares what each build can do (can.*) and the UI checks it, so the server build and the static GitHub Pages build share one codebase.Voice labels describe sound, not people
Voices are labelled by accent and gender plus a timbre note. Accent is not ethnicity; the label describes how the voice sounds, nothing about who speaks it.
Result
Live on GitHub Pages as a static build, with the server build supporting accounts, uploads and live public sharing.
Known limitations
- Verified in Chrome only; the Firefox and Safari folder-picker fallback is wired but not run in a real Firefox.
- Markdown covers the fiction subset: no tables or footnotes.
Stack
Next.js, React, TypeScript, Supabase, ONNX (Kokoro-82M), Web Speech API, PWA