@designesy/read-along 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Le Vain Bey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # read-along
2
+
3
+ An embeddable, engine-agnostic web component for **bimodal reading**:
4
+ karaoke-style word highlighting synchronized to spoken audio, for the open web.
5
+
6
+ Wrap it around any content. Press Listen. The words light up as they're read.
7
+
8
+ ```html
9
+ <script type="module" src="read-along.js"></script>
10
+
11
+ <read-along>
12
+ <p>Any content. Any <em>inline markup</em>. Your links stay clickable,
13
+ your styles stay yours — the highlight paints over the text without
14
+ touching the DOM.</p>
15
+ </read-along>
16
+ ```
17
+
18
+ ## Why
19
+
20
+ - **Evidence-backed.** Bimodal (listen-while-read) presentation measurably
21
+ improves comprehension for readers with dyslexia and other reading
22
+ disabilities — strongest when highlighting follows the voice word-by-word
23
+ (Wood et al. 2017 meta-analysis). Word-level and sentence-level
24
+ highlighting are both provided: karaoke word tracking over a soft sentence
25
+ tint, since research treats granularity as a user preference
26
+ (w3c/epub-specs#2917). Yet no embeddable, open-source read-along component
27
+ exists for the web. This is that missing primitive.
28
+ - **For everyone.** Dyslexic readers, language learners, low-vision readers,
29
+ tired commuters, cooks with flour on their hands.
30
+ - **Local-first.** Speech runs in the browser. Nothing is sent anywhere by
31
+ default.
32
+
33
+ ## How this relates to prior art (honest table)
34
+
35
+ | | read-along | `@liiift-studio/speechtype` | MS Immersive Reader | ReadSpeaker / NaturalReader |
36
+ |---|---|---|---|---|
37
+ | Open source | ✅ MIT | ✅ MIT | ❌ Azure commercial | ❌ subscription |
38
+ | Embeddable web component | ✅ | ⚠️ React/vanilla functions | ⚠️ Azure SDK | ⚠️ embed script |
39
+ | Engine-agnostic | ✅ pluggable | ❌ Web Speech only | ❌ | ❌ |
40
+ | Sync when `boundary` events don't fire | ✅ interpolation fallback | ❌ ("text simply stays un-emphasised") | — | — |
41
+ | Markup-safe highlighting | ✅ Range-based (Highlight API) | ❌ span-wrapping, flattens markup | — | — |
42
+
43
+ `speechtype` is the closest open prior art and a genuinely nice piece — its
44
+ README honestly documents the Safari boundary-event gap that read-along's
45
+ interpolation fallback exists to close. The abandoned `readalong` npm
46
+ package (2018) played pre-aligned audio files; not TTS, not embeddable.
47
+ Everything else in the space is commercial.
48
+
49
+ ## Features
50
+
51
+ - **Zero dependencies**, ~5 kB unminified, MIT license.
52
+ - **Markup-safe highlighting** via the CSS Custom Highlight API (`Highlight`
53
+ + `::highlight()`), with a graceful `<mark>` fallback. Your content's DOM
54
+ is never re-wrapped on the modern path.
55
+ - **Chrome's 15-second speech bug defeated.** Desktop Chrome's speech engine
56
+ watchdog kills long utterances (a bug open for years, ~200–250 chars).
57
+ read-along speaks in sentence-bounded chunks that stay under the cap,
58
+ plus a desktop-only keep-alive (skipped on Android, where it breaks
59
+ synthesis).
60
+ - **Robust word sync.** Locks onto `onboundary` events when the engine
61
+ provides them; falls back to char-proportional interpolation when it
62
+ doesn't (remote voices, Firefox).
63
+ - **Never a silent dead end.** Browsers that expose `speechSynthesis` with
64
+ zero voices (common in embedded/webview browsers) get detected by a stall
65
+ watchdog and the component switches to visual-only mode — karaoke word
66
+ pacing without sound, announced to screen readers, instead of hanging on
67
+ "Playing" forever.
68
+ - **One-voice policy.** Starting one `<read-along>` stops any other
69
+ currently playing on the page.
70
+ - **Word-level seek.** `seekToToken(i)` (or the `seekable` attribute — click
71
+ any word mid-read) restarts playback at an exact word, in every engine.
72
+ - **External clock.** An engine that renders-only: a host process outside
73
+ the browser supplies word timings and ticks the clock — the bridge
74
+ contract for desktop readers and assistive controllers.
75
+ - **Theming.** `--ra-highlight` / `--ra-accent` CSS custom properties set
76
+ the highlight color per page or per instance (plain `rgb()` values —
77
+ exotic color functions risk silent drops in hostile engines).
78
+ - **Pluggable engines.** Ships with Web Speech (default), a
79
+ pre-synthesized-media engine (build-time TTS + JSON timing manifest —
80
+ Piper/kokoro/cloud batch outputs all fit), a local neural voice (Kokoro),
81
+ and the external-clock engine. Bring any engine implementing
82
+ `speak/pause/resume/stop/setChunks` + `onToken` callbacks.
83
+ - **Accessible controls.** Real buttons, `aria-pressed`, visible focus,
84
+ polite live announcements, keyboard operable end to end.
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ npm install @designesy/read-along
90
+ ```
91
+
92
+ or vendor the files — it's dependency-free ES modules.
93
+
94
+ ## Attributes
95
+
96
+ | Attribute | Default | Purpose |
97
+ |---|---|---|
98
+ | `lang` | page language | BCP-47 tag passed to the speech engine |
99
+ | `rate` | `1` | initial speaking rate |
100
+ | `seekable` | off | clicking/tapping a word while playing restarts the reading from that word (never steals clicks on links/buttons) |
101
+ | `force-fallback` | off | skip the CSS Custom Highlight API and wrap the active word in a `<mark>` element instead — for engines that expose the highlight registry but never paint it (undetectable programmatically), or when you want maximum-render-compatibility certainty |
102
+
103
+ The word highlight is page-global: all `<read-along>` elements share one
104
+ `Highlight` registry entry per name, so multiple instances on a page
105
+ coexist correctly (each contributes and removes only the Ranges it owns).
106
+
107
+ ## Controls
108
+
109
+ - `play()` / `pause()` / `stop()` / `toggle()`
110
+ - `seekToToken(i)` — start playing from word *i* (0-based)
111
+ - `activeToken` — index of the word currently spoken (-1 when idle)
112
+ - `engine` property — swap in your own engine before first play
113
+ - `state` — `"idle" | "playing" | "paused"`
114
+ - events: `play` / `pause` / `stop` / `done` / `seek` (bubbles; `detail.token`
115
+ carries the engine's position) — hosts driving an external clock use these
116
+ to anchor their own timers
117
+
118
+ ## Custom engines
119
+
120
+ Anything with this shape works — WASM neural TTS, pre-synthesized audio, a
121
+ remote service, or a lab prototype:
122
+
123
+ ```js
124
+ class MyEngine {
125
+ setChunks(chunks) {}
126
+ speak(chunks, startWord = 0) { /* call onToken(i) as words start */ }
127
+ pause() {}
128
+ resume() {}
129
+ stop() {}
130
+ }
131
+ readAlongEl.engine = new MyEngine();
132
+ ```
133
+
134
+ `startWord` is a global token index — seek lands on the exact word, not the
135
+ containing chunk.
136
+
137
+ The engine contract and token/chunk formats live in `src/tokenizer.js` and
138
+ `src/engines/*.js`.
139
+
140
+ ## External clock (host-driven highlighting)
141
+
142
+ `src/engines/external.js` — render-only mode: the host owns the audio and the
143
+ clock, the component just highlights. This is the bridge contract for
144
+ non-browser speech systems:
145
+
146
+ - a **desktop reader daemon** (e.g. a Piper TTS process) streaming word
147
+ timings while it plays audio itself
148
+ - an **assistive/BCI controller** that knows the reading position and needs
149
+ a text surface to mirror it
150
+ - **build-time timings with no audio at all** — silent visual karaoke
151
+
152
+ ```js
153
+ import { ExternalEngine } from "@designesy/read-along/engines/external.js";
154
+ const engine = new ExternalEngine({ words: [] }); // [tokenIndex, startMs, endMs]
155
+ el.engine = engine;
156
+ el.play();
157
+ engine.setWords([[0, 0, 260], [1, 260, 520], /* … */]); // whenever ready
158
+ engine.tick(1234); // host heartbeat: elapsed playback ms
159
+ ```
160
+
161
+ Semantics: `tick()` advances the word pointer (monotonic; jumps larger than
162
+ a word are followed, so a host restarting earlier works too). If the host
163
+ goes silent for >250 ms the engine falls back to real time — a dead bridge
164
+ never freezes the karaoke. `setWords()` can arrive late and grow during
165
+ playback (progressive synthesis). `speak(chunks, startWord)` starts at any
166
+ word. Pause freezes everything until `resume()`.
167
+
168
+ Timings format matches the MediaEngine manifest (`[tokenIndex, startMs,
169
+ endMs]`) — one producer can feed both engines.
170
+
171
+ ## Local neural voice (Kokoro)
172
+
173
+ `src/engines/kokoro.js` is an optional engine that runs the
174
+ [Kokoro-82M](https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX)
175
+ neural voice (via [kokoro-js](https://www.npmjs.com/package/kokoro-js))
176
+ entirely in the browser — real voices on any tab, including embedded
177
+ browsers whose Web Speech has none.
178
+
179
+ ```js
180
+ import { KokoroEngine } from "@designesy/read-along/engines/kokoro.js"; // (or vendor the file)
181
+ const engine = new KokoroEngine({ voice: "af_heart" }); // af_bella, am_michael, …
182
+ await engine.load(); // optional: preload the ~80 MB model (q8)
183
+ el.engine = engine; // else it loads on first play
184
+ ```
185
+
186
+ Honest notes:
187
+
188
+ - **Timing.** The public ONNX export does not expose the model's native
189
+ word alignment (Python `KPipeline` has it; the ONNX graph itself does
190
+ not). The engine distributes each chunk's *measured* audio duration
191
+ across its characters — real durations, sentence-bounded error — and
192
+ drives the highlight from the `AudioContext` sample clock, not wall-clock
193
+ guessing. Pause is sample-accurate (`AudioContext.suspend()` freezes the
194
+ clock; resume continues from the exact sample).
195
+ - **Cost.** First load downloads ~80 MB (q8) and compiles WASM — expect
196
+ ~30–60 s on a cold cache, seconds warm. Synthesis is cached per chunk
197
+ (restart/seek is instant) and pipelined one chunk ahead.
198
+ - **Demo wiring.** kokoro-js's ESM dist statically imports the Node
199
+ built-ins `path` and `fs/promises`, which browsers can't resolve — an
200
+ import map must stub both (see `demo/index.html`). And in the browser,
201
+ transformers.js accepts only `wasm`/`webgpu` devices — `"cpu"` is a
202
+ Node-only device name (the engine translates it).
203
+ - This engine pulls in `kokoro-js` as a dependency, so it lives outside
204
+ the zero-dependency core; the component itself never imports it.
205
+
206
+ ## Engine notes (Web Speech, 2026)
207
+
208
+ - Desktop Chrome truncates long utterances (~15 s watchdog, ~200–250 chars;
209
+ [chromium:41294170](https://issues.chromium.org/issues/41294170)) — handled
210
+ by sentence-bounded chunking (every utterance under the cap), plus a
211
+ desktop-only pause/resume keep-alive (the keep-alive is skipped on Android,
212
+ where it breaks synthesis).
213
+ - `onboundary` is an optimization, not a sync source: it doesn't fire for
214
+ remote voices (Chromium 41195426), fails on Chrome Android, fires sparsely
215
+ on Safari, and iOS effectively never — read-along falls back to
216
+ char-proportional interpolation whenever boundaries stay silent.
217
+ - Voices load async (`voiceschanged`); first `getVoices()` may be empty —
218
+ handled. Pick a voice explicitly via `engine.voiceName` if it matters.
219
+ - iOS requires `speak()` inside a user gesture — playing from the Listen
220
+ button satisfies this; don't call `play()` from timers on iOS.
221
+ - The Web Speech spec is actively maintained under the Web Audio CG (draft
222
+ dated 2026-08-10); read-along tracks it conservatively.
223
+
224
+ ## Browser support
225
+
226
+ - **Highlighting:** CSS Custom Highlight API is Baseline 2025 (Chrome/Edge
227
+ 105+, Safari 17.2+, Firefox 140+). Older engines get the `<mark>` fallback.
228
+ - **Speech:** Web Speech synthesis is available in all desktop evergreen
229
+ browsers; Firefox for Android lacks it entirely — that's what the pluggable
230
+ engine slot is for (kokoro-js / Piper WASM / pre-synthesized media all fit
231
+ the same contract).
232
+
233
+ ## Accessibility
234
+
235
+ - Real `<button>`s (≥ 30×30 px targets — WCAG 2.5.8 needs 24×24), visible
236
+ focus (2.4.7), full keyboard operability (2.1.1).
237
+ - Polite `aria-live` region for state changes (4.1.3): announces
238
+ play/pause/finish — deliberately NOT per-word (per-word announcements
239
+ flood screen readers; sentence-boundary announcing is the considerate
240
+ pattern).
241
+ - The Highlight API paints carry no semantic meaning (MDN) — visual karaoke
242
+ is decoration; the semantic channel is the live region + natural DOM order
243
+ (1.3.2), which the Range-based approach never disturbs.
244
+
245
+ ## Status
246
+
247
+ v0 — core engine, highlight layer, four engines (Web Speech, media,
248
+ Kokoro, external clock), word-level seek + click-to-seek, CSS-var theming,
249
+ demo. Roadmap: formal test suite, npm publish, desktop-overlay reference
250
+ integration. Not yet production-hardened; verify on your target browsers.
251
+
252
+ ## License
253
+
254
+ MIT © 2026 Le Vain Bey
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@designesy/read-along",
3
+ "version": "0.1.0",
4
+ "description": "Embeddable bimodal read-along web component: karaoke-sync word highlighting with pluggable TTS engines (Web Speech, pre-synthesized audio, or your own).",
5
+ "type": "module",
6
+ "main": "src/read-along.js",
7
+ "exports": {
8
+ ".": "./src/read-along.js",
9
+ "./timings.js": "./src/timings.js",
10
+ "./engines/webspeech.js": "./src/engines/webspeech.js",
11
+ "./engines/media.js": "./src/engines/media.js",
12
+ "./engines/kokoro.js": "./src/engines/kokoro.js",
13
+ "./engines/external.js": "./src/engines/external.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test tests/tokenizer.test.mjs tests/webspeech.test.mjs tests/external.test.mjs tests/media.test.mjs tests/highlight.test.mjs tests/kokoro.test.mjs"
22
+ },
23
+ "keywords": [
24
+ "read-along",
25
+ "read-aloud",
26
+ "tts",
27
+ "text-to-speech",
28
+ "accessibility",
29
+ "a11y",
30
+ "bimodal",
31
+ "karaoke",
32
+ "highlight",
33
+ "dyslexia",
34
+ "web-components",
35
+ "screen-reader"
36
+ ],
37
+ "license": "MIT",
38
+ "author": "Le Vain Bey",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/xcapselx/read-along.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/xcapselx/read-along/issues"
45
+ },
46
+ "homepage": "https://github.com/xcapselx/read-along#readme",
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * external.js — external-clock engine: the host owns speech, the component
3
+ * only renders.
4
+ *
5
+ * Use when something OUTSIDE the browser is producing the audio/timing:
6
+ * the ReadAloudTTS desktop daemon (Piper + AHK) streaming word timings, a
7
+ * native app TTS bridge, a BCI/eye-gaze layer, or simply timings computed
8
+ * at build time with no audio at all (visual-only karaoke).
9
+ *
10
+ * The host supplies word timings once, then ticks a monotonic clock:
11
+ *
12
+ * const engine = new ExternalEngine({
13
+ * words: [[tokenIndex, startMs, endMs], ...] // sorted by startMs
14
+ * });
15
+ * readAlong.engine = engine;
16
+ * readAlong.play(); // engine.speak() starts the rAF clock
17
+ * engine.tick(1234); // host calls this with elapsed ms
18
+ *
19
+ * `tick(ms)` only advances the word pointer — it never seeks backwards on
20
+ * its own (hosts send monotonic clocks), but a jump larger than a word is
21
+ * followed, so a host that restarts at an earlier position works too.
22
+ * When the host is silent, the engine falls back to real time, so the
23
+ * karaoke never stalls waiting for a bridge that died.
24
+ *
25
+ * Timings format matches the MediaEngine manifest (`[tokenIndex, startMs,
26
+ * endMs]`) — one producer can feed both engines.
27
+ */
28
+
29
+ const HOST_SILENT_MS = 250; // no tick for this long → advance on real time
30
+
31
+ export class ExternalEngine {
32
+ /**
33
+ * @param {{words?: Array<[number, number, number]>, onTick?: Function}} [options]
34
+ */
35
+ constructor(options = {}) {
36
+ this.words = (options.words ?? []).slice().sort((a, b) => a[1] - b[1]);
37
+ this.onToken = options.onToken || null;
38
+ this.onChunkStart = options.onChunkStart || null;
39
+ this.onChunkEnd = options.onChunkEnd || null;
40
+ this.onEnd = options.onEnd || null;
41
+ this.onError = options.onError || null;
42
+ this.onMode = options.onMode || null;
43
+ this._chunks = [];
44
+ this._stopped = true;
45
+ this._paused = false;
46
+ this._raf = 0;
47
+ this._token = -1; // last token index emitted
48
+ this._clock = 0; // ms position, host-driven when ticking
49
+ this._lastTickAt = 0; // performance.now() of the last host tick
50
+ this._basePerf = 0; // performance.now() at speak/resume — real-time base
51
+ }
52
+
53
+ /** Late-arriving or updated timings (host synthesizes progressively). */
54
+ setWords(words) {
55
+ this.words = (words ?? []).slice().sort((a, b) => a[1] - b[1]);
56
+ if (!this._stopped && this.words.length) {
57
+ // A late word may already be behind the clock — re-emit if so.
58
+ const w = this._find(this._clock);
59
+ if (w >= 0 && w !== this._token) this._emit(w);
60
+ }
61
+ }
62
+
63
+ setChunks(chunks) { this._chunks = chunks || []; }
64
+
65
+ get rate() { return 1; }
66
+ set rate(_r) { /* external clock: speed belongs to the host */ }
67
+
68
+ speak(_chunks, startWord = 0) {
69
+ this._stopped = false;
70
+ this._paused = false;
71
+ this._startClock(startWord);
72
+ }
73
+
74
+ /** Host heartbeat: elapsed playback milliseconds (monotonic, may jump). */
75
+ tick(ms) {
76
+ if (this._stopped || this._paused) return;
77
+ this._lastTickAt = performance.now(); // host is alive
78
+ this._advance(ms);
79
+ }
80
+
81
+ pause() {
82
+ if (this._stopped || this._paused) return;
83
+ this._paused = true;
84
+ this._cancelRaf();
85
+ }
86
+
87
+ resume() {
88
+ if (this._stopped || !this._paused) return;
89
+ this._paused = false;
90
+ this._lastTickAt = performance.now();
91
+ this._basePerf = performance.now() - this._clock;
92
+ this._armRaf();
93
+ }
94
+
95
+ stop() {
96
+ if (this._stopped) return;
97
+ this._stopped = true;
98
+ this._cancelRaf();
99
+ this.onEnd?.();
100
+ }
101
+
102
+ get position() { return this._token; }
103
+
104
+ // -- internals -----------------------------------------------------------
105
+
106
+ /** Move the clock to `ms` and emit any word transitions it crosses. */
107
+ _advance(ms) {
108
+ if (this._stopped || this._paused) return;
109
+ this._clock = Math.max(this._clock, ms);
110
+ const w = this._find(this._clock);
111
+ if (w >= 0 && w !== this._token) this._emit(w);
112
+ // Past the last word's end (+ grace) → utterance over.
113
+ const last = this.words[this.words.length - 1];
114
+ if (last && this._clock > last[2] + HOST_SILENT_MS) {
115
+ this._stopped = true;
116
+ this._cancelRaf();
117
+ this.onEnd?.();
118
+ }
119
+ }
120
+
121
+ _startClock(startWord = 0) {
122
+ const w = this.words.find(([tok]) => tok === startWord);
123
+ this._clock = w ? w[1] : 0;
124
+ this._token = startWord > 0 ? startWord : -1;
125
+ this._basePerf = performance.now() - this._clock;
126
+ this._lastTickAt = performance.now();
127
+ const chunkIdx = this._chunkOf(startWord);
128
+ if (chunkIdx >= 0) this.onChunkStart?.(chunkIdx, this._chunks[chunkIdx] ?? null);
129
+ this._emit(startWord > 0 ? startWord : this._find(this._clock));
130
+ this._armRaf();
131
+ }
132
+
133
+ _emit(i) {
134
+ if (i < 0) return;
135
+ const prev = this._token;
136
+ this._token = i;
137
+ this.onToken?.(i);
138
+ const pc = this._chunkOf(prev);
139
+ const cc = this._chunkOf(i);
140
+ if (pc >= 0 && pc !== cc) this.onChunkEnd?.(pc, this._chunks[pc] ?? null);
141
+ if (cc >= 0 && cc !== pc) this.onChunkStart?.(cc, this._chunks[cc] ?? null);
142
+ // Past the last word's end → utterance over.
143
+ const last = this.words[this.words.length - 1];
144
+ if (last && this._clock > last[2] + HOST_SILENT_MS) {
145
+ this._stopped = true;
146
+ this._cancelRaf();
147
+ this.onEnd?.();
148
+ }
149
+ }
150
+
151
+ _find(ms) {
152
+ let idx = -1;
153
+ for (const [tok, startMs] of this.words) {
154
+ if (startMs <= ms) idx = tok;
155
+ else break;
156
+ }
157
+ return idx;
158
+ }
159
+
160
+ _chunkOf(tokenIndex) {
161
+ if (tokenIndex < 0 || !this._chunks.length) return -1;
162
+ for (let i = 0; i < this._chunks.length; i++) {
163
+ const toks = this._chunks[i].tokens;
164
+ if (tokenIndex >= toks[0].index && tokenIndex <= toks[toks.length - 1].index) {
165
+ return i;
166
+ }
167
+ }
168
+ return -1;
169
+ }
170
+
171
+ _armRaf() {
172
+ if (this._raf) return;
173
+ const step = () => {
174
+ this._raf = 0;
175
+ if (this._stopped || this._paused) return;
176
+ const hostLive = performance.now() - this._lastTickAt < HOST_SILENT_MS;
177
+ if (hostLive) {
178
+ this._advance(this._clock); // host driving — check for end-of-words
179
+ } else {
180
+ this._advance(performance.now() - this._basePerf); // host silent → real time
181
+ }
182
+ if (!this._stopped && !this._paused) this._armRaf();
183
+ };
184
+ this._raf = requestAnimationFrame(step);
185
+ }
186
+
187
+ _cancelRaf() {
188
+ if (this._raf) cancelAnimationFrame(this._raf);
189
+ this._raf = 0;
190
+ }
191
+ }