System Architecture & Technical Reference — a Chrome extension that reads the web aloud, entirely on your own machine
Spiel is a Manifest V3 Chrome extension paired with a local neural text-to-speech
engine. The extension extracts readable text from any article or PDF, splits it into
sentences, and streams each sentence through Kokoro-82M — an 82-million-parameter TTS
model served by Kokoro-FastAPI on 127.0.0.1:8880. Audio plays with
word-by-word highlighting that follows the voice across the page. There is no backend,
no account, and no network traffic beyond localhost: turn off Wi-Fi and it still works.
Figure 1 — High-level system architecture
127.0.0.1 only. The single piece of network traffic in Spiel's lifetime is the one-time engine download at install.A Manifest V3 extension is not one program. It is four programs with different powers, different lifetimes, and a message bus between them. Getting a feature to work is mostly a question of placing each piece of it in the only context that can run it.
| Context | DOM | Web Audio | Lifetime | Spiel uses it for |
|---|---|---|---|---|
| Background service worker | No | No | Killed after ~30s idle | State machine, TTS fetches, orchestration |
| Content script | Host page's | Yes | Page lifetime | Extraction, highlighting, floating player |
| Offscreen document | Own | Yes — autoplay-exempt | Reclaimed ~30s after audio stops | The only place audio plays |
| Popup | Own | Yes | Closes on blur | The Listen button and first-run setup |
The offscreen document deserves a note, because it is the least obvious of the four.
A service worker cannot play audio — it has no AudioContext, no DOM, no
Worker. A content script can play audio, but it dies when the tab
navigates, and playback should survive scrolling away. The offscreen document is
Chrome's answer: an invisible extension page created on demand
(chrome.offscreen.createDocument) whose sole job here is to decode MP3
clips and play them through Web Audio. Extension pages are exempt from the autoplay
policy, so playback starts without a user gesture in that context.
From the user's side, Play means "start reading now." Under it sits a pipeline tuned around one constraint: the local engine serializes generation — one request at a time, and it crashes if a request is cancelled mid-generation. Everything below exists to hide that from the listener.
AUDIO_ENDED comes back → advance the index, serve the next clip (usually already cached), repeat until done.Figure 2 — One sentence through the pipeline, with prefetch hiding generation latency
The karaoke effect — each word lighting up as it is spoken — is the hardest part of Spiel, and the source of most of its interesting bugs. Two independent systems have to agree: the audio clock and the page's DOM.
{word, start, end}).requestAnimationFrame clock to that moment and walks the timestamps, wrapping the current word in a highlight span.textContent lies about prose. It concatenates block elements with no separator, producing glued tokens like network.Example:One that match nothing in the word index. Text is derived from the article HTML with explicit breaks at block boundaries instead.Chrome renders PDFs inside a plugin whose text the DOM cannot reach, so the normal extraction path is useless there. Spiel instead fetches the PDF's own bytes — a same-origin request from the content script, needing no extra permission — and parses them with pdf.js.
pdf-content.js is injected only into PDF tabs, only when needed.import.meta.url, which breaks when bundled into extension formats. The worker path is set explicitly to an extension URL and the worker file ships as a web_accessible_resource.file:// PDFs require the user's "Allow access to file URLs" toggle. Spiel detects the injection failure and shows the exact instruction instead of a raw error.MV3's economics are hostile to long-running state: Chrome kills the service worker after ~30 seconds of quiet, reclaims the offscreen document ~30 seconds after audio stops, and orphans every injected content script the moment the extension reloads. Spiel assumes all of this will happen mid-listen and plans for it.
| Chrome does this | Spiel's defense |
|---|---|
| Kills the SW while paused (no message traffic) | The whole playback state persists to storage.session on every change and is restored before the first message is handled |
| Reclaims the offscreen document during a pause | Restore detects the loss and re-fetches the current sentence on Resume instead of resuming into the void |
| Orphans old content scripts on extension reload | Every document-level listener checks chrome.runtime?.id first; orphans remove their UI and go quiet — the fresh script owns the tab |
| Lets users hammer every control | Generation counters, a single-flight queue, and post-await state re-checks make play/pause/skip spam-safe; every await is treated as a point where the world may have changed |
| Lets a fetch hang forever | A 90-second watchdog turns hangs into a visible error card with a retry — never infinite silence |
await or a queue wait, acted on after.
Pause landing during Resume's await, a sentence generated twice because the cache was
checked before entering the queue. The fix is always the same — re-validate at
execution time, not submission time.
Read-aloud tools are either subscriptions or cloud services that see everything you read. Spiel's answer is structural, not policy: there is no server to send anything to.
| Permission | Why it exists |
|---|---|
activeTab, scripting | Inject the reader into the tab the user invoked it on — no blanket tabs access |
offscreen | Create the audio playback document |
storage, alarms | Preferences, state persistence across SW restarts, keep-warm pings |
host: 127.0.0.1:8880 | The voice engine — loopback only, unreachable from the network |
file:///* | Reading local PDFs (still requires the user's explicit toggle) |
127.0.0.1 and nowhere
else. No analytics, no telemetry, no accounts. The only network traffic Spiel ever
creates is the one-time engine download at install — and after that, it reads to you
with the Wi-Fi off.
Source, build gates, and the full lessons-learned log are on GitHub.