🔊

Spiel

System Architecture & Technical Reference — a Chrome extension that reads the web aloud, entirely on your own machine

Version 1.1 July 2026 github.com/preet01/spiel

Contents

  1. Architecture Overview
  2. The Four Execution Contexts
  3. The Playback Pipeline
  4. Word-Level Highlighting
  5. Reading PDFs
  6. Surviving Chrome
  7. The Privacy Model

1. Architecture Overview

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.

┌──────────────────────────────────────────────────────────────────────────────┐ │ SPIEL SYSTEM │ └──────────────────────────────────────────────────────────────────────────────┘ CHROME (four isolated execution contexts) ┌────────────────────────────────────────────────────────────────────────┐ │ │ │ POPUP BACKGROUND SERVICE WORKER │ │ (toolbar click) (the brain — state machine) │ │ ┌──────────────┐ PLAY ┌──────────────────────────────┐ │ │ │ Listen button│ ─────────► │ sentence queue · prefetch │ │ │ └──────────────┘ │ LRU audio cache · watchdogs │ │ │ └───┬──────────────┬───────────┘ │ │ GET_ARTICLE│ │PLAY_AUDIO │ │ ▼ ▼ │ │ CONTENT SCRIPT (per tab) OFFSCREEN DOCUMENT │ │ ┌──────────────────────────┐ ┌───────────────────┐ │ │ │ Readability extraction │ │ Web Audio playback │ │ │ │ word index + highlighting│ │ (autoplay-exempt) │ │ │ │ floating player · pill │ └─────────┬─────────┘ │ │ └──────────────────────────┘ │AUDIO_ENDED │ │ ▼ (advance queue) │ └───────────────────────────────┬────────────────────────────────────────┘ │ HTTP (localhost only) ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ KOKORO VOICE ENGINE — 127.0.0.1:8880 │ │ FastAPI + Kokoro-82M · /v1/audio/speech · word timestamps │ │ installed once via install.sh · runs as a LaunchAgent │ └────────────────────────────────────────────────────────────────────────┘ nothing crosses the machine boundary

Figure 1 — High-level system architecture

Key Design Principles

2. The Four Execution Contexts

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.

ContextDOMWeb AudioLifetimeSpiel uses it for
Background service workerNoNoKilled after ~30s idleState machine, TTS fetches, orchestration
Content scriptHost page'sYesPage lifetimeExtraction, highlighting, floating player
Offscreen documentOwnYes — autoplay-exemptReclaimed ~30s after audio stopsThe only place audio plays
PopupOwnYesCloses on blurThe 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.

The rule that fell out of this: before writing a feature, write down which API it needs (DOM? Worker? AudioContext? cross-origin fetch?) and let that choose the context. Choosing by convenience is how audio ends up in a service worker that cannot make sound.

3. The Playback Pipeline

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.

1The popup opens → the background prewarms the engine and asks the content script to extract the article before Play is pressed, so the click itself is cheap.
2Extraction runs Mozilla Readability (with per-site selectors for journals it mishandles), strips URLs, reference brackets, and parentheticals — they read terribly aloud — and splits the text into sentences.
3The background fetches sentence n from Kokoro as MP3 + word timestamps, hands it to the offscreen document to play, and immediately prefetches n+1 and n+2 behind it.
4AUDIO_ENDED comes back → advance the index, serve the next clip (usually already cached), repeat until done.

The parts that earn their keep

Popup Background SW Kokoro :8880 Offscreen doc │ │ │ │ │ PLAY │ │ │ │───────────────────►│ (article already │ │ │ popup closes │ extracted at open) │ │ │ │ sentence 0 │ │ │ │────────────────────────►│ │ │ │ MP3 + word timestamps │ │ │ │◄────────────────────────│ │ │ │ PLAY_AUDIO (clip 0) │ │ │ │──────────────────────────────────────────────►│ │ │ prefetch 1, 2 │ ▶ playing │ │ │────────────────────────►│ │ │ │ │ AUDIO_ENDED │ │ │◄──────────────────────────────────────────────│ │ │ PLAY_AUDIO (clip 1 — from cache) │ │ │──────────────────────────────────────────────►│

Figure 2 — One sentence through the pipeline, with prefetch hiding generation latency

4. Word-Level Highlighting

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.

How a word gets highlighted

1Kokoro returns per-word timestamps with every clip ({word, start, end}).
2The content script builds a word index of the page once — every text node, tokenized, with offsets — and matches each spoken sentence against it.
3When the offscreen document reports the clip actually started, the content script anchors a requestAnimationFrame clock to that moment and walks the timestamps, wrapping the current word in a highlight span.
4If the sentence can't be located in the DOM (PDFs, dynamic pages), the floating player shows its own caption with the same karaoke — the fallback stage.

Lessons that shaped it

5. Reading PDFs

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.

6. Surviving Chrome

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 thisSpiel'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 pauseRestore detects the loss and re-fetches the current sentence on Resume instead of resuming into the void
Orphans old content scripts on extension reloadEvery 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 controlGeneration 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 foreverA 90-second watchdog turns hangs into a visible error card with a retry — never infinite silence
The recurring mistake class in this codebase was never exotic: a state check done before an 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.

7. The Privacy Model

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.

PermissionWhy it exists
activeTab, scriptingInject the reader into the tab the user invoked it on — no blanket tabs access
offscreenCreate the audio playback document
storage, alarmsPreferences, state persistence across SW restarts, keep-warm pings
host: 127.0.0.1:8880The voice engine — loopback only, unreachable from the network
file:///*Reading local PDFs (still requires the user's explicit toggle)
The guarantee: page text goes to 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.