@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 +21 -0
- package/README.md +254 -0
- package/package.json +53 -0
- package/src/engines/external.js +191 -0
- package/src/engines/kokoro.js +339 -0
- package/src/engines/media.js +152 -0
- package/src/engines/webspeech.js +473 -0
- package/src/highlight.js +204 -0
- package/src/read-along.css +50 -0
- package/src/read-along.js +391 -0
- package/src/timings.js +30 -0
- package/src/tokenizer.js +105 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* read-along.css — ::highlight() styles + fallback mark styling.
|
|
2
|
+
Ships as a separate file for pages that prefer a <link>, and can be
|
|
3
|
+
imported from src/read-along.js context.
|
|
4
|
+
|
|
5
|
+
Color strategy: plain rgb() only. The first draft used
|
|
6
|
+
color-mix(in oklab, Highlight 45%, transparent) — if an engine can't
|
|
7
|
+
resolve the system Highlight color or color-mix, the declaration is
|
|
8
|
+
dropped silently and NOTHING paints (an invisible highlight is worse
|
|
9
|
+
than no highlight API). rgb(… / alpha) is universally parsable, works
|
|
10
|
+
on light and dark page themes, and needs no supports() dance.
|
|
11
|
+
|
|
12
|
+
Two visibility channels on the active word: a strong background tint
|
|
13
|
+
plus a solid underline. If a hostile engine drops one channel, the
|
|
14
|
+
other still shows. ::highlight() supports color, background-color,
|
|
15
|
+
text-decoration, text-shadow — NOT border/padding/border-radius.
|
|
16
|
+
|
|
17
|
+
Theming: --ra-highlight/--ra-accent override the word color per page
|
|
18
|
+
or per instance (e.g. read-along { --ra-highlight: rgb(255 196 0 / .5); }).
|
|
19
|
+
Same trap as color-mix applies: keep the value a plain rgb(); exotic
|
|
20
|
+
color functions risk silent drop in hostile engines. */
|
|
21
|
+
|
|
22
|
+
::highlight(read-along-sentence) {
|
|
23
|
+
background-color: rgb(64 132 255 / 0.16);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
::highlight(read-along-word) {
|
|
27
|
+
background-color: var(--ra-highlight, rgb(64 132 255 / 0.5));
|
|
28
|
+
color: inherit;
|
|
29
|
+
text-decoration: underline;
|
|
30
|
+
text-decoration-color: var(--ra-accent, rgb(33 74 255));
|
|
31
|
+
text-decoration-thickness: 2px;
|
|
32
|
+
text-underline-offset: 2px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Fallback path (no CSS Custom Highlight API, or force-fallback):
|
|
36
|
+
a real <mark data-read-along> wrapping the active word. As a real
|
|
37
|
+
element it may also use padding/radius. */
|
|
38
|
+
mark[data-read-along] {
|
|
39
|
+
background-color: var(--ra-highlight, rgb(64 132 255 / 0.5));
|
|
40
|
+
color: inherit;
|
|
41
|
+
text-decoration: underline;
|
|
42
|
+
text-decoration-color: var(--ra-accent, rgb(33 74 255));
|
|
43
|
+
text-decoration-thickness: 2px;
|
|
44
|
+
text-underline-offset: 2px;
|
|
45
|
+
border-radius: 3px;
|
|
46
|
+
padding: 0 1px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Seekable instances hint that words are clickable. */
|
|
50
|
+
[seekable] { cursor: pointer; }
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-along.js — <read-along> custom element: bimodal read-along player.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <read-along>
|
|
6
|
+
* <p>Any inline markup. The words light up as they're spoken.</p>
|
|
7
|
+
* </read-along>
|
|
8
|
+
*
|
|
9
|
+
* The element wraps host content in a player (play/pause, restart, speed) and
|
|
10
|
+
* speaks the text with a pluggable engine. Default engine: Web Speech with
|
|
11
|
+
* the Chrome ~15s cutoff defeated. Pass your own engine via the `engine`
|
|
12
|
+
* property — anything implementing { speak(chunks), pause(), resume(),
|
|
13
|
+
* stop(), setChunks() } and calling back onToken/onChunkStart/onEnd works
|
|
14
|
+
* (e.g. a kokoro-js or Piper WASM adapter, or a pre-synthesized-audio
|
|
15
|
+
* engine with per-chunk audio + timings).
|
|
16
|
+
*
|
|
17
|
+
* Engine-agnostic by design: the controller never touches speechSynthesis;
|
|
18
|
+
* it only consumes token/chunk offsets and drives the Highlighter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { tokenize, chunkTokens } from "./tokenizer.js";
|
|
22
|
+
import {
|
|
23
|
+
Highlighter,
|
|
24
|
+
buildTokenRanges,
|
|
25
|
+
supportsHighlightAPI,
|
|
26
|
+
} from "./highlight.js";
|
|
27
|
+
import { WebSpeechEngine } from "./engines/webspeech.js";
|
|
28
|
+
|
|
29
|
+
const template = document.createElement("template");
|
|
30
|
+
template.innerHTML = `
|
|
31
|
+
<style>
|
|
32
|
+
:host { display: block; }
|
|
33
|
+
.ra-wrap { display: grid; gap: 10px; }
|
|
34
|
+
.ra-controls {
|
|
35
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
36
|
+
font: 500 13px/1.2 ui-sans-serif, system-ui, sans-serif;
|
|
37
|
+
color: CanvasText;
|
|
38
|
+
}
|
|
39
|
+
.ra-btn {
|
|
40
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
41
|
+
gap: 6px; min-height: 30px; min-width: 30px; padding: 5px 12px;
|
|
42
|
+
border-radius: 999px; border: 1px solid color-mix(in oklab, currentColor 30%, transparent);
|
|
43
|
+
background: Canvas; color: CanvasText; cursor: pointer;
|
|
44
|
+
}
|
|
45
|
+
.ra-btn:hover { border-color: currentColor; }
|
|
46
|
+
.ra-btn:focus-visible, .ra-speed:focus-visible {
|
|
47
|
+
outline: 2px solid Highlight; outline-offset: 2px;
|
|
48
|
+
}
|
|
49
|
+
.ra-btn[aria-pressed="true"] { background: color-mix(in oklab, Highlight 18%, Canvas); }
|
|
50
|
+
.ra-btn svg { width: 13px; height: 13px; fill: currentColor; flex: none; }
|
|
51
|
+
.ra-status { font-variant-numeric: tabular-nums; opacity: 0.75; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
52
|
+
.ra-speed { border-radius: 8px; padding: 5px 7px; border: 1px solid color-mix(in oklab, currentColor 25%, transparent); background: Canvas; color: CanvasText; }
|
|
53
|
+
.ra-content { display: block; }
|
|
54
|
+
</style>
|
|
55
|
+
<div class="ra-wrap" part="wrap">
|
|
56
|
+
<div class="ra-controls" role="group" aria-label="Read-along controls">
|
|
57
|
+
<button class="ra-btn" id="play" aria-pressed="false">
|
|
58
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path id="playicon" d="M4 2.5v11l9-5.5z"/></svg>
|
|
59
|
+
<span id="playlabel">Listen</span>
|
|
60
|
+
</button>
|
|
61
|
+
<button class="ra-btn" id="restart" aria-label="Restart from the beginning">
|
|
62
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3a5 5 0 1 1-4.9 6h1.55A3.5 3.5 0 1 0 8 4.5V7L4 4l4-3z"/></svg>
|
|
63
|
+
</button>
|
|
64
|
+
<label class="ra-nowrap" style="display:inline-flex;align-items:center;gap:6px">
|
|
65
|
+
<span class="ra-status" id="status">Ready</span>
|
|
66
|
+
</label>
|
|
67
|
+
<select class="ra-speed" id="speed" aria-label="Reading speed">
|
|
68
|
+
<option value="0.75">0.75×</option>
|
|
69
|
+
<option value="1" selected>1×</option>
|
|
70
|
+
<option value="1.25">1.25×</option>
|
|
71
|
+
<option value="1.5">1.5×</option>
|
|
72
|
+
<option value="2">2×</option>
|
|
73
|
+
</select>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="ra-content"><slot></slot></div>
|
|
76
|
+
<p id="ra-live" aria-live="polite" style="position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);"></p>
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const ICON_PLAY = "M4 2.5v11l9-5.5z";
|
|
81
|
+
const ICON_PAUSE = "M3.5 2.5h3.2v11H3.5zM9.3 2.5h3.2v11H9.3z";
|
|
82
|
+
|
|
83
|
+
/** Instances holding the voice right now — enforces a one-voice policy. */
|
|
84
|
+
const ACTIVE = new Set();
|
|
85
|
+
|
|
86
|
+
class ReadAlong extends HTMLElement {
|
|
87
|
+
constructor() {
|
|
88
|
+
super();
|
|
89
|
+
this.attachShadow({ mode: "open" });
|
|
90
|
+
this.shadowRoot.appendChild(template.content.cloneNode(true));
|
|
91
|
+
this._engine = null;
|
|
92
|
+
this._tokens = [];
|
|
93
|
+
this._chunks = [];
|
|
94
|
+
this._highlighter = null;
|
|
95
|
+
this._state = "idle"; // idle | playing | paused
|
|
96
|
+
this._wireUI();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
connectedCallback() {
|
|
100
|
+
if (this._prepared && this._highlighter?.destroyed) {
|
|
101
|
+
// Re-connected after disconnect destroyed the highlighter — rebuild
|
|
102
|
+
// it and re-map token ranges (the DOM may have changed too).
|
|
103
|
+
this._highlighter = new Highlighter(this, {
|
|
104
|
+
forceFallback: this.hasAttribute("force-fallback"),
|
|
105
|
+
});
|
|
106
|
+
this._highlighter.setTokenRanges(buildTokenRanges(this, this._tokens));
|
|
107
|
+
}
|
|
108
|
+
this._prepare();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
disconnectedCallback() {
|
|
112
|
+
this.stop();
|
|
113
|
+
this._highlighter?.destroy();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -- public API ----------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
get state() { return this._state; }
|
|
119
|
+
|
|
120
|
+
/** Engine instance (WebSpeechEngine default). Set before first play. */
|
|
121
|
+
get engine() { return this._engine; }
|
|
122
|
+
set engine(e) {
|
|
123
|
+
if (this._state !== "idle") this.stop();
|
|
124
|
+
this._engine = e;
|
|
125
|
+
this._bindEngine();
|
|
126
|
+
if (e && this._chunks.length) e.setChunks?.(this._chunks);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
play() { this._play(); }
|
|
130
|
+
pause() { this._pause(); }
|
|
131
|
+
toggle() { this._state === "playing" ? this._pause() : this._play(); }
|
|
132
|
+
stop() { this._stop(); }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Seek to a token index and start playing from there (word granularity).
|
|
136
|
+
* No-op while paused — resume first, or stop() then seekToToken().
|
|
137
|
+
* @param {number} i token index (0-based)
|
|
138
|
+
*/
|
|
139
|
+
seekToToken(i) {
|
|
140
|
+
this._prepare();
|
|
141
|
+
if (!this._engine || this._state === "paused") return;
|
|
142
|
+
if (i < 0 || i >= this._tokens.length) return;
|
|
143
|
+
this._stop();
|
|
144
|
+
this._state = "playing";
|
|
145
|
+
this._bindEngine();
|
|
146
|
+
this._engine.rate = parseFloat(this._els.speed.value) || 1;
|
|
147
|
+
this._engine.speak(this._chunks, i);
|
|
148
|
+
this._setPlayingUi(true);
|
|
149
|
+
this._announce(`Playing from word ${i + 1}`);
|
|
150
|
+
this._emitEvent("seek");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Index of the word currently being spoken (-1 when idle). */
|
|
154
|
+
get activeToken() { return this._highlighter ? this._highlighter._markIndex : -1; }
|
|
155
|
+
|
|
156
|
+
static get observedAttributes() { return ["lang", "rate", "seekable"]; }
|
|
157
|
+
|
|
158
|
+
attributeChangedCallback(name, _old, value) {
|
|
159
|
+
if (!this._engine) return;
|
|
160
|
+
if (name === "lang") this._engine.lang = value;
|
|
161
|
+
if (name === "rate") this._engine.rate = parseFloat(value) || 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -- internals -----------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
_wireUI() {
|
|
167
|
+
const $ = (id) => this.shadowRoot.getElementById(id);
|
|
168
|
+
this._els = {
|
|
169
|
+
play: $("play"), playicon: $("playicon"), playlabel: $("playlabel"),
|
|
170
|
+
restart: $("restart"), status: $("status"), speed: $("speed"),
|
|
171
|
+
live: $("ra-live"),
|
|
172
|
+
};
|
|
173
|
+
this._els.play.addEventListener("click", () => this.toggle());
|
|
174
|
+
this._els.restart.addEventListener("click", () => {
|
|
175
|
+
this.stop();
|
|
176
|
+
this.play();
|
|
177
|
+
});
|
|
178
|
+
this._els.speed.addEventListener("change", () => {
|
|
179
|
+
const r = parseFloat(this._els.speed.value) || 1;
|
|
180
|
+
if (this._engine) this._engine.rate = r;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_prepare() {
|
|
185
|
+
if (this._prepared) return;
|
|
186
|
+
this._prepared = true;
|
|
187
|
+
// Tokenize the RAW textContent — no whitespace collapsing, because
|
|
188
|
+
// offsets must match what the TreeWalker sees in the real text nodes.
|
|
189
|
+
const text = this.textContent || "";
|
|
190
|
+
this._tokens = tokenize(text);
|
|
191
|
+
this._chunks = chunkTokens(this._tokens);
|
|
192
|
+
this._highlighter = new Highlighter(this, {
|
|
193
|
+
forceFallback: this.hasAttribute("force-fallback"),
|
|
194
|
+
});
|
|
195
|
+
this._highlighter.setTokenRanges(buildTokenRanges(this, this._tokens));
|
|
196
|
+
if (this.hasAttribute("seekable")) this._wireSeek();
|
|
197
|
+
if (!this._engine) {
|
|
198
|
+
this.engine = new WebSpeechEngine({
|
|
199
|
+
lang: this.getAttribute("lang") || undefined,
|
|
200
|
+
rate: parseFloat(this.getAttribute("rate")) || 1,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// -- click-to-seek ---------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* When the `seekable` attribute is present, clicks/taps on the host's
|
|
209
|
+
* words restart playback from that word. Word hit-testing reuses the
|
|
210
|
+
* token ranges (a click inside a token's Range owns that token); clicks
|
|
211
|
+
* between words fall to the NEAREST token, so every tap seeks somewhere
|
|
212
|
+
* useful instead of only exact hits.
|
|
213
|
+
*/
|
|
214
|
+
_wireSeek() {
|
|
215
|
+
if (this._seekWired) return;
|
|
216
|
+
this._seekWired = true;
|
|
217
|
+
const isInteractive = (el) =>
|
|
218
|
+
el.closest && el.closest("a, button, input, select, textarea, [contenteditable]");
|
|
219
|
+
const handler = (ev) => {
|
|
220
|
+
if (this._state !== "playing") return; // seek only while reading
|
|
221
|
+
if (isInteractive(ev.target)) return; // never steal link/button clicks
|
|
222
|
+
const i = this._tokenAt(ev);
|
|
223
|
+
if (i >= 0) {
|
|
224
|
+
ev.preventDefault();
|
|
225
|
+
this.seekToToken(i);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
this.addEventListener("pointerdown", handler);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Token index under a pointer event, or nearest if between words. */
|
|
232
|
+
_tokenAt(ev) {
|
|
233
|
+
const host = this;
|
|
234
|
+
if (document.caretRangeFromPoint) {
|
|
235
|
+
const r = document.caretRangeFromPoint(ev.clientX, ev.clientY);
|
|
236
|
+
if (r && host.contains(r.startContainer)) return this._tokenForOffset(r.startContainer, r.startOffset, true);
|
|
237
|
+
} else if (document.caretPositionFromPoint) {
|
|
238
|
+
const p = document.caretPositionFromPoint(ev.clientX, ev.clientY);
|
|
239
|
+
if (p && host.contains(p.offsetNode)) return this._tokenForOffset(p.offsetNode, p.offset, true);
|
|
240
|
+
}
|
|
241
|
+
return -1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Map a (text node, offset) pair to a token index by binary search over
|
|
246
|
+
* the concatenated text. When the offset falls in whitespace (not inside
|
|
247
|
+
* any token), snap to the nearest token.
|
|
248
|
+
*/
|
|
249
|
+
_tokenForOffset(node, offset, _snap) {
|
|
250
|
+
// Absolute offset of this node's start within the host's text stream.
|
|
251
|
+
const walker = document.createTreeWalker(this, NodeFilter.SHOW_TEXT);
|
|
252
|
+
let absBase = 0, n = walker.nextNode(), target = null, targetBase = 0;
|
|
253
|
+
while (n !== null) {
|
|
254
|
+
if (n === node) { target = n; targetBase = absBase; break; }
|
|
255
|
+
absBase += n.data.length;
|
|
256
|
+
n = walker.nextNode();
|
|
257
|
+
}
|
|
258
|
+
if (!target) return -1;
|
|
259
|
+
const abs = targetBase + offset;
|
|
260
|
+
// Binary search tokens by [start, end).
|
|
261
|
+
let lo = 0, hi = this._tokens.length - 1, best = -1, bestDist = Infinity;
|
|
262
|
+
while (lo <= hi) {
|
|
263
|
+
const mid = (lo + hi) >> 1;
|
|
264
|
+
const t = this._tokens[mid];
|
|
265
|
+
if (abs < t.start) { hi = mid - 1; }
|
|
266
|
+
else if (abs >= t.end) { lo = mid + 1; }
|
|
267
|
+
else return t.index; // inside a word
|
|
268
|
+
// Track nearest edge for the whitespace-snap fallback.
|
|
269
|
+
const d = Math.min(Math.abs(abs - t.start), Math.abs(abs - t.end));
|
|
270
|
+
if (d < bestDist) { bestDist = d; best = t.index; }
|
|
271
|
+
}
|
|
272
|
+
return best; // between words → nearest
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_bindEngine() {
|
|
276
|
+
const e = this._engine;
|
|
277
|
+
if (!e) return;
|
|
278
|
+
e.onToken = (i) => {
|
|
279
|
+
this._highlighter?.setActive(i);
|
|
280
|
+
};
|
|
281
|
+
e.onChunkStart = (idx, chunk) => {
|
|
282
|
+
// Sentence tint under the word karaoke (native highlight only).
|
|
283
|
+
if (this._highlighter?.native) {
|
|
284
|
+
const first = this._highlighter.tokenRanges.get(chunk.tokens[0].index);
|
|
285
|
+
const last = this._highlighter.tokenRanges.get(
|
|
286
|
+
chunk.tokens[chunk.tokens.length - 1].index
|
|
287
|
+
);
|
|
288
|
+
if (first && last) {
|
|
289
|
+
const r = document.createRange();
|
|
290
|
+
try {
|
|
291
|
+
r.setStart(first.startContainer, first.startOffset);
|
|
292
|
+
r.setEnd(last.endContainer, last.endOffset);
|
|
293
|
+
this._highlighter.setSentence(r);
|
|
294
|
+
} catch { /* straddles weird markup — skip tint */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
e.onEnd = () => this._finish();
|
|
299
|
+
e.onError = (err) => {
|
|
300
|
+
this._announce(`Read-along error: ${err.message}`);
|
|
301
|
+
this._setStatus("Error");
|
|
302
|
+
this._setPlayingUi(false);
|
|
303
|
+
this._state = "idle";
|
|
304
|
+
};
|
|
305
|
+
e.onMode = (mode) => {
|
|
306
|
+
if (mode !== "visual") return;
|
|
307
|
+
this._setStatus("Visual mode — no voices");
|
|
308
|
+
this._announce("No speech voices are available in this browser. Following the words without sound.");
|
|
309
|
+
};
|
|
310
|
+
if (this._chunks.length) e.setChunks?.(this._chunks);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_play() {
|
|
314
|
+
this._prepare();
|
|
315
|
+
if (!this._engine) return;
|
|
316
|
+
// One-voice policy: starting this player stops any other on the page.
|
|
317
|
+
for (const other of ACTIVE) if (other !== this) other.stop();
|
|
318
|
+
ACTIVE.add(this);
|
|
319
|
+
if (this._state === "paused") {
|
|
320
|
+
this._state = "playing";
|
|
321
|
+
this._engine.resume();
|
|
322
|
+
this._setPlayingUi(true);
|
|
323
|
+
this._emitEvent("play"); // resumed
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (this._state === "playing") return;
|
|
327
|
+
if (!this._chunks.length) return;
|
|
328
|
+
this._state = "playing";
|
|
329
|
+
this._bindEngine();
|
|
330
|
+
this._engine.rate = parseFloat(this._els.speed.value) || 1;
|
|
331
|
+
this._engine.speak(this._chunks);
|
|
332
|
+
this._setPlayingUi(true);
|
|
333
|
+
this._announce("Playing, automated voice");
|
|
334
|
+
this._emitEvent("play");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_pause() {
|
|
338
|
+
if (this._state !== "playing") return;
|
|
339
|
+
this._state = "paused";
|
|
340
|
+
this._engine.pause();
|
|
341
|
+
this._setPlayingUi(false);
|
|
342
|
+
this._announce("Paused");
|
|
343
|
+
this._emitEvent("pause");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_stop() {
|
|
347
|
+
ACTIVE.delete(this);
|
|
348
|
+
if (this._state === "idle") return;
|
|
349
|
+
const wasEngine = this._engine;
|
|
350
|
+
this._state = "idle";
|
|
351
|
+
wasEngine?.stop?.();
|
|
352
|
+
this._highlighter?.clear();
|
|
353
|
+
this._setPlayingUi(false);
|
|
354
|
+
this._setStatus("Ready");
|
|
355
|
+
this._emitEvent("stop");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_finish() {
|
|
359
|
+
ACTIVE.delete(this);
|
|
360
|
+
this._state = "idle";
|
|
361
|
+
this._highlighter?.clear();
|
|
362
|
+
this._setPlayingUi(false);
|
|
363
|
+
this._setStatus("Done");
|
|
364
|
+
this._announce("Finished reading");
|
|
365
|
+
this._emitEvent("done");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Hosts (e.g. an external-clock bridge) listen to these to stay in sync. */
|
|
369
|
+
_emitEvent(name) {
|
|
370
|
+
this.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { token: this._engine?.position } }));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// -- UI helpers ----------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
_setPlayingUi(on) {
|
|
376
|
+
this._els.play.setAttribute("aria-pressed", String(on));
|
|
377
|
+
this._els.playicon.setAttribute("d", on ? ICON_PAUSE : ICON_PLAY);
|
|
378
|
+
this._els.playlabel.textContent = on ? "Pause" : "Listen";
|
|
379
|
+
this._setStatus(on ? "Playing" : "Paused");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_setStatus(msg) { this._els.status.textContent = msg; }
|
|
383
|
+
|
|
384
|
+
_announce(msg) { this._els.live.textContent = msg; }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!customElements.get("read-along")) {
|
|
388
|
+
customElements.define("read-along", ReadAlong);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { ReadAlong, WebSpeechEngine, tokenize, chunkTokens, Highlighter, buildTokenRanges, supportsHighlightAPI };
|
package/src/timings.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timings.js — word-timing utilities shared across engines and hosts.
|
|
3
|
+
*
|
|
4
|
+
* wordTimingsFromChunk derives per-word spans from a chunk's REAL audio
|
|
5
|
+
* duration (char-proportional). It lives here rather than in
|
|
6
|
+
* engines/kokoro.js so hosts can build MediaEngine/ExternalEngine
|
|
7
|
+
* manifests from kokoro output without importing kokoro-js (an optional
|
|
8
|
+
* peer dep that must never be required to import the core package).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Word timings from a chunk's real audio duration, distributed by char
|
|
13
|
+
* count and merged into the chunk's tokens (global token indexes).
|
|
14
|
+
* Returns [{tokenIndex, startMs, endMs}, ...] sorted by startMs.
|
|
15
|
+
*/
|
|
16
|
+
export function wordTimingsFromChunk(chunk, audio) {
|
|
17
|
+
const chars = chunk.tokens.map((t) => t.text).join(" ");
|
|
18
|
+
if (!chars.length) return [];
|
|
19
|
+
const durMs = (audio.samples.length / audio.sampleRate) * 1000;
|
|
20
|
+
const charMs = durMs / chars.length;
|
|
21
|
+
const words = [];
|
|
22
|
+
let acc = 0; // chars consumed so far (including joining spaces)
|
|
23
|
+
for (const tok of chunk.tokens) {
|
|
24
|
+
const startMs = acc * charMs;
|
|
25
|
+
acc += tok.text.length + 1; // +1 for the joining space
|
|
26
|
+
const endMs = (acc - 1) * charMs;
|
|
27
|
+
words.push({ tokenIndex: tok.index, startMs, endMs });
|
|
28
|
+
}
|
|
29
|
+
return words;
|
|
30
|
+
}
|
package/src/tokenizer.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tokenizer.js — whitespace tokenization that preserves source offsets.
|
|
3
|
+
*
|
|
4
|
+
* Tokens carry [start, end) offsets into the original text so the highlight
|
|
5
|
+
* layer can build DOM Ranges without re-wrapping the host's markup. Works on
|
|
6
|
+
* the text content of the host element; the element's own children (links,
|
|
7
|
+
* emphasis) are allowed and ranges are computed against them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const WORD_RE = /\S+/g;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @returns {Array<{text: string, start: number, end: number, index: number}>}
|
|
15
|
+
*/
|
|
16
|
+
export function tokenize(text) {
|
|
17
|
+
const tokens = [];
|
|
18
|
+
WORD_RE.lastIndex = 0;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = WORD_RE.exec(text)) !== null) {
|
|
21
|
+
tokens.push({
|
|
22
|
+
text: m[0],
|
|
23
|
+
start: m.index,
|
|
24
|
+
end: m.index + m[0].length,
|
|
25
|
+
index: tokens.length,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return tokens;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} text
|
|
33
|
+
* @returns {Array<{text: string, start: number, end: number}>} sentence spans
|
|
34
|
+
*/
|
|
35
|
+
export function sentences(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const re = /[^.!?\n]+[.!?]*[\s]*/g;
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = re.exec(text)) !== null) {
|
|
40
|
+
if (m[0].trim().length > 0) {
|
|
41
|
+
out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SENTENCE_END = /[.!?]["')\]]?$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Group tokens into speakable chunks: sentence-bounded, char-capped.
|
|
51
|
+
*
|
|
52
|
+
* Chunks ACCUMULATE sentences up to the char cap (the ReadAloudTTS lesson:
|
|
53
|
+
* one-chunk-per-sentence means 3-5x more chunks than necessary and, for
|
|
54
|
+
* streaming engines, 3-5x more synthesis calls). Emission rules:
|
|
55
|
+
* - emit when the cap is reached AND the current token closes a sentence
|
|
56
|
+
* - emit anyway at 1.5x the cap (hard overflow guard for run-on text)
|
|
57
|
+
* - chunk 0 is capped at `firstChunkChars` so audio starts fast
|
|
58
|
+
*
|
|
59
|
+
* @param {Array<{text:string,start:number,end:number}>} tokens
|
|
60
|
+
* @param {number} chunkChars max chars per chunk (chunks 1+)
|
|
61
|
+
* @param {number} firstChunkChars cap for chunk 0 (0 = same as chunkChars)
|
|
62
|
+
* @returns {Array<{tokens: Array, start: number, end: number}>}
|
|
63
|
+
*/
|
|
64
|
+
export function chunkTokens(tokens, chunkChars = 180, firstChunkChars = 80) {
|
|
65
|
+
if (!tokens.length) return [];
|
|
66
|
+
const limitFor = (i) => (i === 0 && firstChunkChars > 0 ? firstChunkChars : chunkChars);
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let current = [];
|
|
69
|
+
let currentChars = 0;
|
|
70
|
+
let chunkIndex = 0;
|
|
71
|
+
|
|
72
|
+
const emit = () => {
|
|
73
|
+
if (!current.length) return;
|
|
74
|
+
chunks.push({
|
|
75
|
+
tokens: current,
|
|
76
|
+
start: current[0].start,
|
|
77
|
+
end: current[current.length - 1].end,
|
|
78
|
+
});
|
|
79
|
+
current = [];
|
|
80
|
+
currentChars = 0;
|
|
81
|
+
chunkIndex++;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const tok of tokens) {
|
|
85
|
+
const limit = limitFor(chunkIndex);
|
|
86
|
+
const endsSentence = SENTENCE_END.test(tok.text);
|
|
87
|
+
if (current.length) {
|
|
88
|
+
if (currentChars >= limit && endsSentence) {
|
|
89
|
+
emit();
|
|
90
|
+
} else if (currentChars >= limit * 1.5) {
|
|
91
|
+
// Run-on text with no sentence punctuation — hard-emit at 1.5x.
|
|
92
|
+
emit();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
current.push(tok);
|
|
96
|
+
currentChars += tok.text.length + 1;
|
|
97
|
+
// Fast start: chunk 0 emits the moment it hits its own smaller cap,
|
|
98
|
+
// even mid-sentence — audio starting fast beats sentence purity.
|
|
99
|
+
if (chunkIndex === 0 && firstChunkChars > 0 && currentChars >= firstChunkChars) {
|
|
100
|
+
emit();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
emit();
|
|
104
|
+
return chunks;
|
|
105
|
+
}
|