@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.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * webspeech.js — Web Speech API engine with the Chrome cutoff defeated.
3
+ *
4
+ * Failure modes handled:
5
+ * 1. The ~15s watchdog in desktop Chrome kills long utterances
6
+ * (chromium:41294170, ~200-250 chars). Defeat: every utterance stays
7
+ * under the cap via sentence-bounded chunking, plus a desktop-only
8
+ * pause()/resume() keep-alive (it breaks speech on Android).
9
+ * 2. onboundary is an optimization, not a sync source: it never fires for
10
+ * remote voices, fails on Chrome Android, fires sparsely on Safari,
11
+ * effectively never on iOS. Defeat: char-proportional interpolation
12
+ * (the proven ReadAloudTTS approach) driven by rAF when boundaries
13
+ * stay silent for BOUNDARY_GRACE_MS.
14
+ * 3. Dead engines: embedded browsers (CEF/webviews) often expose
15
+ * speechSynthesis with ZERO voices — speak() is a silent no-op, no
16
+ * start/boundary/end ever fires. Defeat: a stall watchdog — if nothing
17
+ * has progressed within STALL_MS, the engine switches to VISUAL-ONLY
18
+ * mode: karaoke word pacing without audio, announced via onMode, so
19
+ * the read-along still works (and never hangs in "Playing" forever).
20
+ */
21
+
22
+ const KEEPALIVE_MS = 10_000;
23
+ const BOUNDARY_GRACE_MS = 600;
24
+ const STALL_MS = 2_800;
25
+ const CHARS_PER_SEC = 14.5; // ~150 wpm × ~5.8 chars/word, heuristic at rate 1
26
+
27
+ export class WebSpeechEngine {
28
+ constructor(options = {}) {
29
+ this.lang = options.lang ?? navigator.language ?? "en-US";
30
+ this.rate = options.rate ?? 1;
31
+ this.pitch = options.pitch ?? 1;
32
+ this.voiceName = options.voiceName ?? null;
33
+ this.onToken = options.onToken || null;
34
+ this.onChunkStart = options.onChunkStart || null;
35
+ this.onChunkEnd = options.onChunkEnd || null;
36
+ this.onEnd = options.onEnd || null;
37
+ this.onError = options.onError || null;
38
+ this.onVoices = options.onVoices || null;
39
+ this.onMode = options.onMode || null; // 'visual' when audio is unavailable
40
+ this._voices = [];
41
+ this._keepalive = null;
42
+ this._raf = null;
43
+ this._graceTimer = null;
44
+ this._stallTimer = null;
45
+ this._stopped = true;
46
+ this._paused = false;
47
+ this._visualOnly = false;
48
+ this._interpActive = false;
49
+ this._interpElapsed = 0;
50
+ this._vraf = null;
51
+ this._vT0 = 0;
52
+ this._chunkIdx = -1;
53
+ this._tokenIndex = -1;
54
+ this._chunks = [];
55
+ if (WebSpeechEngine.available) {
56
+ this._refreshVoices();
57
+ speechSynthesis.onvoiceschanged = () => this._refreshVoices();
58
+ }
59
+ }
60
+
61
+ static get available() {
62
+ return typeof window !== "undefined" && "speechSynthesis" in window;
63
+ }
64
+
65
+ _refreshVoices() {
66
+ this._voices = speechSynthesis.getVoices() || [];
67
+ if (this._voices.length) this.onVoices?.(this._voices);
68
+ }
69
+
70
+ get voices() {
71
+ return this._voices;
72
+ }
73
+
74
+ /** Engine contract: register chunks without speaking. */
75
+ setChunks(chunks) {
76
+ this._chunks = chunks || [];
77
+ }
78
+
79
+ /**
80
+ * Speak chunks sequentially. Each chunk is one short utterance.
81
+ * @param {Array<{tokens:Array,start:number,end:number}>} chunks
82
+ * @param {number} startWord global token index to start from (seek)
83
+ */
84
+ speak(chunks, startWord = 0) {
85
+ this._chunks = chunks;
86
+ this._pendingSlice = null;
87
+ let startChunk = 0;
88
+ if (startWord > 0) {
89
+ const ci = locateTokenChunk(chunks, startWord);
90
+ if (ci >= 0) {
91
+ startChunk = ci;
92
+ this._pendingSlice = startWord;
93
+ }
94
+ }
95
+ if (!WebSpeechEngine.available) {
96
+ // No speechSynthesis at all (Firefox Android) — straight to visual.
97
+ this._stopped = false;
98
+ this._paused = false;
99
+ this._engageVisualOnly();
100
+ return;
101
+ }
102
+ this._stopped = false;
103
+ this._paused = false;
104
+ this._visualOnly = false;
105
+ this._progress = false;
106
+ this._cancelVisual();
107
+ this._startKeepalive();
108
+ this._speakChunk(startChunk);
109
+ this._startStallWatchdog();
110
+ }
111
+
112
+ /**
113
+ * The chunk to speak for index i, consuming a pending seek slice. A seek
114
+ * into mid-chunk swaps in a sliced copy (tokens from the seek word on);
115
+ * tokens keep their GLOBAL .index so highlights still map. The slice is
116
+ * one-shot: the next _speakChunk(i+1) gets the full chunk.
117
+ */
118
+ _chunkFor(i) {
119
+ const chunk = this._chunks[i];
120
+ if (chunk && this._pendingSlice != null) {
121
+ const from = this._pendingSlice;
122
+ this._pendingSlice = null;
123
+ const k = chunk.tokens.findIndex((t) => t.index === from);
124
+ if (k > 0) {
125
+ return { tokens: chunk.tokens.slice(k), start: chunk.tokens[k].start, end: chunk.end };
126
+ }
127
+ }
128
+ return chunk;
129
+ }
130
+
131
+ _speakChunk(i) {
132
+ if (this._stopped || this._visualOnly) return;
133
+ this._cancelInterpolation();
134
+ if (i >= this._chunks.length) {
135
+ this._finish();
136
+ return;
137
+ }
138
+ this._chunkIdx = i;
139
+ const chunk = this._chunkFor(i);
140
+ const utter = new SpeechSynthesisUtterance(chunkText(chunk));
141
+ utter.lang = this.lang;
142
+ utter.rate = this.rate;
143
+ utter.pitch = this.pitch;
144
+ const voice = this._pickVoice();
145
+ if (voice) {
146
+ utter.voice = voice;
147
+ utter.lang = voice.lang;
148
+ }
149
+ this.onChunkStart?.(i, chunk);
150
+
151
+ let boundarySeen = false;
152
+ this._tokenIndex = -1;
153
+ this._chunkT0 = performance.now();
154
+
155
+ utter.onboundary = (e) => {
156
+ if (this._stopped || this._paused || this._visualOnly) return;
157
+ if (e.name && e.name !== "word") return;
158
+ boundarySeen = true;
159
+ this._progress = true;
160
+ this._clearGrace();
161
+ this._cancelInterpolation();
162
+ const tok = tokenAtChar(chunk, e.charIndex ?? 0);
163
+ if (tok) this._emitToken(tok.index);
164
+ };
165
+
166
+ utter.onstart = () => {
167
+ if (this._stopped || this._visualOnly) return;
168
+ this._progress = true;
169
+ };
170
+
171
+ utter.onend = () => {
172
+ if (this._stopped || this._visualOnly) return;
173
+ this._progress = true;
174
+ this._clearGrace();
175
+ this._cancelInterpolation();
176
+ this.onChunkEnd?.(i, chunk);
177
+ this._speakChunk(i + 1);
178
+ };
179
+
180
+ utter.onerror = (ev) => {
181
+ // cancel() surfaces as interrupted/canceled — a clean stop, not an error.
182
+ const err = ev?.error ?? "unknown";
183
+ if (err === "interrupted" || err === "canceled" || err === "Canceled") return;
184
+ this._progress = true;
185
+ this._clearGrace();
186
+ this._cancelInterpolation();
187
+ this._stopKeepalive();
188
+ this.onError?.(new Error(`speech synthesis error: ${err}`));
189
+ };
190
+
191
+ speechSynthesis.speak(utter);
192
+
193
+ // If this voice never fires boundaries, interpolate word timing.
194
+ this._armGrace(chunk, i, boundarySeen);
195
+ }
196
+
197
+ /**
198
+ * After BOUNDARY_GRACE_MS without a word boundary, take over word
199
+ * timing with char-proportional interpolation. Re-armed on resume()
200
+ * in case the window elapsed while paused (otherwise a boundary-silent
201
+ * voice would leave the highlight frozen for the rest of the chunk).
202
+ */
203
+ _armGrace(chunk, i, hadBoundary) {
204
+ this._clearGrace();
205
+ this._graceTimer = setTimeout(() => {
206
+ if (!this._stopped && !this._paused && !hadBoundary &&
207
+ !this._visualOnly && this._chunkIdx === i) {
208
+ this._startInterpolation(chunk, i);
209
+ }
210
+ }, BOUNDARY_GRACE_MS);
211
+ }
212
+
213
+ _emitToken(globalIdx) {
214
+ if (globalIdx === this._tokenIndex) return;
215
+ this._tokenIndex = globalIdx;
216
+ this.onToken?.(globalIdx);
217
+ }
218
+
219
+ _startInterpolation(chunk, chunkIdx) {
220
+ this._cancelRaf();
221
+ this._interpActive = true;
222
+ this._chunkT0 = performance.now() - this._interpElapsed;
223
+ const step = () => {
224
+ if (this._stopped || this._paused || this._chunkIdx !== chunkIdx) return;
225
+ const elapsed = (performance.now() - this._chunkT0) / 1000;
226
+ const chars = elapsed * CHARS_PER_SEC * this.rate;
227
+ const tok = tokenAtChar(chunk, Math.floor(chars));
228
+ if (tok) this._emitToken(tok.index);
229
+ this._raf = requestAnimationFrame(step);
230
+ };
231
+ this._raf = requestAnimationFrame(step);
232
+ }
233
+
234
+ /** Stop the rAF loop but KEEP interpolation mode + frozen offset. */
235
+ _cancelRaf() {
236
+ if (this._raf) cancelAnimationFrame(this._raf);
237
+ this._raf = null;
238
+ }
239
+
240
+ /** Leave interpolation mode entirely (boundary takeover, chunk end, stop). */
241
+ _cancelInterpolation() {
242
+ this._cancelRaf();
243
+ this._interpActive = false;
244
+ this._interpElapsed = 0;
245
+ }
246
+
247
+ // -- stall watchdog → visual-only mode -----------------------------------
248
+
249
+ _startStallWatchdog() {
250
+ this._clearStall();
251
+ this._stallTimer = setTimeout(() => {
252
+ if (this._stopped || this._paused || this._visualOnly || this._progress) return;
253
+ // speak() was a silent no-op (typical zero-voice embedded browser):
254
+ // nothing is speaking and nothing is even queued.
255
+ if (!speechSynthesis.speaking && !speechSynthesis.pending) {
256
+ this._engageVisualOnly();
257
+ }
258
+ }, STALL_MS);
259
+ }
260
+
261
+ _clearStall() {
262
+ if (this._stallTimer) clearTimeout(this._stallTimer);
263
+ this._stallTimer = null;
264
+ }
265
+
266
+ _clearGrace() {
267
+ if (this._graceTimer) clearTimeout(this._graceTimer);
268
+ this._graceTimer = null;
269
+ }
270
+
271
+ /** Audio is dead — run karaoke word pacing without sound. */
272
+ _engageVisualOnly() {
273
+ this._visualOnly = true;
274
+ this._cancelInterpolation();
275
+ this._clearGrace();
276
+ this._stopKeepalive();
277
+ if (WebSpeechEngine.available) {
278
+ try { speechSynthesis.cancel(); } catch { /* nothing to cancel */ }
279
+ }
280
+ this.onMode?.("visual");
281
+ let i = this._chunkIdx >= 0 ? this._chunkIdx : 0;
282
+ if (this._pendingSlice != null) {
283
+ const ci = locateTokenChunk(this._chunks, this._pendingSlice);
284
+ if (ci >= 0) i = ci;
285
+ }
286
+ this._visualChunk(i);
287
+ }
288
+
289
+ _visualChunk(i) {
290
+ if (this._stopped || !this._visualOnly) return;
291
+ if (i >= this._chunks.length) {
292
+ this._finish();
293
+ return;
294
+ }
295
+ this._chunkIdx = i;
296
+ const chunk = this._chunkFor(i);
297
+ this.onChunkStart?.(i, chunk);
298
+ const text = chunkText(chunk);
299
+ const durMs = (text.length / (CHARS_PER_SEC * this.rate)) * 1000;
300
+ this._vT0 = performance.now();
301
+ this._tokenIndex = -1;
302
+ const step = () => {
303
+ if (this._stopped || this._paused || !this._visualOnly || this._chunkIdx !== i) return;
304
+ const elapsed = performance.now() - this._vT0;
305
+ if (elapsed >= durMs) {
306
+ this.onChunkEnd?.(i, chunk);
307
+ this._visualChunk(i + 1);
308
+ return;
309
+ }
310
+ const chars = Math.floor((elapsed / 1000) * CHARS_PER_SEC * this.rate);
311
+ const tok = tokenAtChar(chunk, chars);
312
+ if (tok) this._emitToken(tok.index);
313
+ this._vraf = requestAnimationFrame(step);
314
+ };
315
+ this._vraf = requestAnimationFrame(step);
316
+ }
317
+
318
+ _cancelVisual() {
319
+ if (this._vraf) cancelAnimationFrame(this._vraf);
320
+ this._vraf = null;
321
+ }
322
+
323
+ // -- shared plumbing ------------------------------------------------------
324
+
325
+ _pickVoice() {
326
+ if (!this._voices.length) return null;
327
+ if (this.voiceName) {
328
+ const named = this._voices.find((v) => v.name === this.voiceName);
329
+ if (named) return named;
330
+ }
331
+ const base = this.lang.split("-")[0];
332
+ return (
333
+ this._voices.find((v) => v.lang === this.lang) ||
334
+ this._voices.find((v) => v.lang?.startsWith(base)) ||
335
+ null
336
+ );
337
+ }
338
+
339
+ _startKeepalive() {
340
+ this._stopKeepalive();
341
+ // Desktop-Chrome-only belt-and-suspenders for the ~15s watchdog
342
+ // (chromium:41294170). On Android, pause()/resume() mid-utterance
343
+ // breaks synthesis entirely — sentence chunking alone is the fix there.
344
+ if (/Android/i.test(navigator.userAgent)) return;
345
+ this._keepalive = setInterval(() => {
346
+ if (this._stopped || this._paused || this._visualOnly) return;
347
+ if (speechSynthesis.speaking && !speechSynthesis.paused) {
348
+ speechSynthesis.pause();
349
+ speechSynthesis.resume();
350
+ }
351
+ }, KEEPALIVE_MS);
352
+ }
353
+
354
+ _stopKeepalive() {
355
+ if (this._keepalive) clearInterval(this._keepalive);
356
+ this._keepalive = null;
357
+ }
358
+
359
+ _finish() {
360
+ this._stopKeepalive();
361
+ this._clearGrace();
362
+ this._clearStall();
363
+ this._cancelInterpolation();
364
+ this._cancelVisual();
365
+ this.onEnd?.();
366
+ }
367
+
368
+ pause() {
369
+ if (this._stopped || this._paused) return;
370
+ this._paused = true;
371
+ if (this._visualOnly) {
372
+ this._vElapsedVisual = this._vT0 ? performance.now() - this._vT0 : 0;
373
+ this._cancelVisual();
374
+ } else {
375
+ // Freeze the interpolation clock but KEEP the mode: resume() restarts
376
+ // the rAF from this offset (_cancelInterpolation would zero it).
377
+ if (this._interpActive) this._interpElapsed = performance.now() - this._chunkT0;
378
+ this._cancelRaf();
379
+ speechSynthesis.pause();
380
+ }
381
+ }
382
+
383
+ resume() {
384
+ if (!this._paused) return;
385
+ this._paused = false;
386
+ if (this._visualOnly) {
387
+ // Continue visual pacing from where it froze.
388
+ const i = this._chunkIdx;
389
+ const chunk = this._chunks[i];
390
+ if (!chunk) return;
391
+ const text = chunkText(chunk);
392
+ const durMs = (text.length / (CHARS_PER_SEC * this.rate)) * 1000;
393
+ const frozen = Math.min(this._vElapsedVisual ?? 0, durMs);
394
+ this._vT0 = performance.now() - frozen;
395
+ this._tokenIndex = -1;
396
+ const step = () => {
397
+ if (this._stopped || !this._visualOnly || this._chunkIdx !== i) return;
398
+ const elapsed = performance.now() - this._vT0;
399
+ if (elapsed >= durMs) {
400
+ this.onChunkEnd?.(i, chunk);
401
+ this._visualChunk(i + 1);
402
+ return;
403
+ }
404
+ const chars = Math.floor((elapsed / 1000) * CHARS_PER_SEC * this.rate);
405
+ const tok = tokenAtChar(chunk, chars);
406
+ if (tok) this._emitToken(tok.index);
407
+ this._vraf = requestAnimationFrame(step);
408
+ };
409
+ this._vraf = requestAnimationFrame(step);
410
+ } else {
411
+ speechSynthesis.resume();
412
+ const chunk = this._chunks[this._chunkIdx];
413
+ if (this._interpActive && chunk) {
414
+ // Continue the interpolation clock from the frozen offset.
415
+ this._startInterpolation(chunk, this._chunkIdx);
416
+ } else if (chunk && !this._progress) {
417
+ // Engine was dead all along (no voices): re-arm the stall watchdog.
418
+ this._startStallWatchdog();
419
+ } else if (chunk) {
420
+ // Grace window elapsed while paused on a boundary-silent voice —
421
+ // re-arm or the highlight freezes for the rest of this chunk.
422
+ this._chunkT0 = performance.now();
423
+ this._armGrace(chunk, this._chunkIdx, false);
424
+ }
425
+ }
426
+ }
427
+
428
+ stop() {
429
+ if (this._stopped) return;
430
+ this._stopped = true;
431
+ this._stopKeepalive();
432
+ this._clearGrace();
433
+ this._clearStall();
434
+ this._cancelInterpolation();
435
+ this._cancelVisual();
436
+ if (WebSpeechEngine.available) {
437
+ try { speechSynthesis.cancel(); } catch { /* not speaking — fine */ }
438
+ }
439
+ this.onEnd?.();
440
+ }
441
+
442
+ get position() {
443
+ return { chunk: this._chunkIdx, token: this._tokenIndex };
444
+ }
445
+
446
+ get mode() {
447
+ return this._visualOnly ? "visual" : "audio";
448
+ }
449
+ }
450
+
451
+ /** Join a chunk's tokens back into speakable text. */
452
+ export function chunkText(chunk) {
453
+ return chunk.tokens.map((t) => t.text).join(" ");
454
+ }
455
+
456
+ /** Chunk index containing global token index `word` (-1 if not found). */
457
+ export function locateTokenChunk(chunks, word) {
458
+ for (let i = 0; i < chunks.length; i++) {
459
+ const toks = chunks[i].tokens;
460
+ if (word >= toks[0].index && word <= toks[toks.length - 1].index) return i;
461
+ }
462
+ return -1;
463
+ }
464
+
465
+ /** Token whose chunk-local [offset, offset+len) contains charIndex. */
466
+ export function tokenAtChar(chunk, charIndex) {
467
+ let offset = 0;
468
+ for (const tok of chunk.tokens) {
469
+ if (charIndex >= offset && charIndex <= offset + tok.text.length) return tok;
470
+ offset += tok.text.length + 1;
471
+ }
472
+ return chunk.tokens.length ? chunk.tokens[chunk.tokens.length - 1] : null;
473
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * highlight.js — karaoke highlight over arbitrary inline markup.
3
+ *
4
+ * Primary path: CSS Custom Highlight API (Highlight + CSS.highlights +
5
+ * ::highlight() pseudo) — paints a Range without touching the DOM, so host
6
+ * markup (links, emphasis, listeners) stays intact.
7
+ *
8
+ * The registry (CSS.highlights) is PAGE-WIDE and keyed by name. Two
9
+ * <read-along> elements on one page must therefore SHARE one Highlight
10
+ * object per name: if each instance registered its own, the second
11
+ * constructor would silently REPLACE the first's object, and every range
12
+ * added to the orphaned object would never paint — the first element's
13
+ * highlight dies with no error anywhere. Highlight objects are thus
14
+ * module-level singletons, refcounted across instances; each instance
15
+ * adds/removes only the Ranges it owns.
16
+ *
17
+ * Fallback path: wrap the active word in <mark data-read-along> for engines
18
+ * without the Highlight API, or when forced via the component's
19
+ * force-fallback option (for engines that expose the registry but never
20
+ * paint it — there is no way to detect that programmatically).
21
+ */
22
+
23
+ const HL_WORD = "read-along-word";
24
+ const HL_SENTENCE = "read-along-sentence";
25
+ const FALLBACK_TAG = "mark";
26
+
27
+ /** name -> { highlight: Highlight, refs: number } */
28
+ const SHARED = new Map();
29
+
30
+ function acquire(name) {
31
+ let entry = SHARED.get(name);
32
+ if (!entry) {
33
+ const highlight = new Highlight();
34
+ CSS.highlights.set(name, highlight);
35
+ entry = { highlight, refs: 0 };
36
+ SHARED.set(name, entry);
37
+ }
38
+ entry.refs++;
39
+ return entry;
40
+ }
41
+
42
+ function release(name) {
43
+ const entry = SHARED.get(name);
44
+ if (!entry) return;
45
+ entry.refs--;
46
+ if (entry.refs <= 0) {
47
+ try { CSS.highlights.delete(name); } catch { /* registry gone */ }
48
+ SHARED.delete(name);
49
+ }
50
+ }
51
+
52
+ export function supportsHighlightAPI() {
53
+ return (
54
+ typeof Highlight !== "undefined" &&
55
+ typeof CSS !== "undefined" &&
56
+ typeof CSS.highlights !== "undefined" &&
57
+ CSS.highlights instanceof HighlightRegistry
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Map token text-offsets to DOM Ranges by walking the host's text nodes once.
63
+ * Offsets are into the concatenated text-node content (what tokenize() saw).
64
+ * A token that straddles a text-node boundary maps to its first fragment only.
65
+ * @returns {Map<number, Range>} token index -> Range
66
+ */
67
+ export function buildTokenRanges(host, tokens) {
68
+ const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
69
+ const ranges = new Map();
70
+ let node = walker.nextNode();
71
+ let nodeStart = 0;
72
+ for (const tok of tokens) {
73
+ while (node !== null && nodeStart + node.data.length <= tok.start) {
74
+ nodeStart += node.data.length;
75
+ node = walker.nextNode();
76
+ }
77
+ if (node === null) break; // tokens outlive the DOM text — stop
78
+ const localStart = Math.max(0, tok.start - nodeStart);
79
+ const localEnd = Math.min(node.data.length, tok.end - nodeStart);
80
+ if (localEnd <= localStart) continue;
81
+ const r = document.createRange();
82
+ r.setStart(node, localStart);
83
+ r.setEnd(node, localEnd);
84
+ ranges.set(tok.index, r);
85
+ }
86
+ return ranges;
87
+ }
88
+
89
+ export class Highlighter {
90
+ /**
91
+ * @param {Element} host element whose light-DOM text is tokenized
92
+ * @param {{forceFallback?: boolean}} [options]
93
+ */
94
+ constructor(host, { forceFallback = false } = {}) {
95
+ this.host = host;
96
+ this.native = !forceFallback && supportsHighlightAPI();
97
+ this.destroyed = false;
98
+ this.tokenRanges = new Map();
99
+ // Ranges this instance currently owns inside the shared Highlight objects.
100
+ this._wordRanges = [];
101
+ this._sentenceRanges = [];
102
+ this.markEl = null;
103
+ this._markIndex = -2;
104
+ if (this.native) {
105
+ this._wordEntry = acquire(HL_WORD);
106
+ this._sentenceEntry = acquire(HL_SENTENCE);
107
+ }
108
+ }
109
+
110
+ /** Registered shared Highlight objects (introspection/testing handle). */
111
+ get wordHighlight() { return this._wordEntry?.highlight ?? null; }
112
+ get sentenceHighlight() { return this._sentenceEntry?.highlight ?? null; }
113
+
114
+ setTokenRanges(ranges) {
115
+ this.tokenRanges = ranges;
116
+ }
117
+
118
+ /** Highlight token index i as the active word; clear the previous word. */
119
+ setActive(i) {
120
+ if (this.native) {
121
+ const hl = this._wordEntry.highlight;
122
+ for (const r of this._wordRanges) hl.delete(r);
123
+ this._wordRanges.length = 0;
124
+ const r = this.tokenRanges.get(i);
125
+ if (r) {
126
+ hl.add(r);
127
+ this._wordRanges.push(r);
128
+ }
129
+ } else {
130
+ this._fallbackMark(i);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Sentence-track tint: keep the current chunk's range softly highlighted
136
+ * while the word pointer moves through it. Range or null.
137
+ */
138
+ setSentence(range) {
139
+ if (!this.native) return;
140
+ const hl = this._sentenceEntry.highlight;
141
+ for (const r of this._sentenceRanges) hl.delete(r);
142
+ this._sentenceRanges.length = 0;
143
+ if (range) {
144
+ hl.add(range);
145
+ this._sentenceRanges.push(range);
146
+ }
147
+ }
148
+
149
+ clear() {
150
+ if (this.native) {
151
+ for (const r of this._wordRanges) this._wordEntry.highlight.delete(r);
152
+ for (const r of this._sentenceRanges) this._sentenceEntry.highlight.delete(r);
153
+ this._wordRanges.length = 0;
154
+ this._sentenceRanges.length = 0;
155
+ }
156
+ this._unwrapMark();
157
+ }
158
+
159
+ destroy() {
160
+ this.clear();
161
+ if (this.native) {
162
+ release(HL_WORD);
163
+ release(HL_SENTENCE);
164
+ this._wordEntry = null;
165
+ this._sentenceEntry = null;
166
+ this.native = false;
167
+ }
168
+ this.destroyed = true;
169
+ }
170
+
171
+ // -- fallback path ------------------------------------------------------
172
+
173
+ _fallbackMark(i) {
174
+ if (i === this._markIndex) return;
175
+ const r = this.tokenRanges.get(i);
176
+ this._unwrapMark();
177
+ this._markIndex = -2;
178
+ if (!r) return;
179
+ try {
180
+ const contents = r.extractContents();
181
+ const mark = document.createElement(FALLBACK_TAG);
182
+ mark.dataset.readAlong = "";
183
+ this._markIndex = i;
184
+ mark.appendChild(contents);
185
+ r.insertNode(mark);
186
+ this.markEl = mark;
187
+ } catch { /* DOM changed mid-speech — skip this token */ }
188
+ }
189
+
190
+ _unwrapMark() {
191
+ const mark = this.markEl;
192
+ if (!mark) return;
193
+ const parent = mark.parentNode;
194
+ if (!parent) {
195
+ this.markEl = null;
196
+ return;
197
+ }
198
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
199
+ parent.removeChild(mark);
200
+ parent.normalize();
201
+ this.markEl = null;
202
+ this._markIndex = -2;
203
+ }
204
+ }