@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
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
|
+
}
|