@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,339 @@
1
+ /**
2
+ * kokoro-engine.js — local neural TTS engine for <read-along> via kokoro-js.
3
+ *
4
+ * Why: Web Speech is free but its sync is guesswork (onboundary fails
5
+ * broadly) and embedded browsers often expose it with zero voices. Kokoro
6
+ * (82M params, WASM, runs locally in-browser) fixes both: real voices
7
+ * everywhere, and because we hold the actual audio samples, word timing is
8
+ * derived from measured chunk durations — not a chars/sec heuristic.
9
+ *
10
+ * Word timing method (honest about its limits): the public ONNX export does
11
+ * not expose the model's native alignment output (Python KPipeline has it;
12
+ * JS does not — the ONNX graph itself lacks the outputs). So we distribute
13
+ * each chunk's REAL audio duration across its characters, then merge char
14
+ * spans into words. Sentence-sized chunks keep the error bounded (the same
15
+ * approach validated in production by ReadAloudTTS's overlay highlighter).
16
+ * Sync source = the AudioContext sample clock, not wall-clock guessing.
17
+ *
18
+ * Playback model: one AudioContext; per chunk, synthesize → cache →
19
+ * schedule as AudioBufferSourceNode. pause() = ctx.suspend() (freezes the
20
+ * sample clock and all scheduled nodes — resume continues from the exact
21
+ * sample). Synthesis runs one chunk ahead so CPU-speed synthesis never
22
+ * stalls playback between chunks (the pipelined-chunker lesson from
23
+ * ReadAloudTTS).
24
+ *
25
+ * This module imports "kokoro-js" — it is NOT part of read-along's zero-dep
26
+ * core. Use it when you want local neural voices; the core component works
27
+ * with it through the same 4-method engine contract as everything else.
28
+ *
29
+ * Usage:
30
+ * import { KokoroEngine } from "@designesy/read-along/engines/kokoro.js";
31
+ * const engine = new KokoroEngine({ voice: "af_heart" });
32
+ * el.engine = engine; // set BEFORE first play; loads model on first play
33
+ */
34
+
35
+ import { KokoroTTS, TextSplitterStream } from "kokoro-js";
36
+
37
+ const TRAILING_SIL_MS = 250;
38
+
39
+ import { locateTokenChunk } from "./webspeech.js"; // acoustic buffer so the last word isn't clipped
40
+ import { wordTimingsFromChunk } from "../timings.js";
41
+
42
+ export class KokoroEngine {
43
+ /**
44
+ * @param {object} [options]
45
+ * @param {string} [options.voice="af_heart"] voice id (af_heart, af_bella,
46
+ * am_michael, …; af_* = American female, am_* = American male)
47
+ * @param {number} [options.speed=1] synthesis speed multiplier
48
+ * @param {string} [options.model="onnx-community/Kokoro-82M-v1.0-ONNX"]
49
+ * @param {string} [options.dtype="q8"] quantization ("fp32" best / "q8"
50
+ * small+fast — ~80 MB vs ~300 MB download)
51
+ * @param {string} [options.device="wasm"] execution device: "wasm" (CPU
52
+ * via WebAssembly — the browser-safe default; "cpu" is a Node-only
53
+ * device name) or "webgpu" where available
54
+ */
55
+ constructor(options = {}) {
56
+ this.voice = options.voice ?? "af_heart";
57
+ this.speed = options.speed ?? 1;
58
+ this.model = options.model ?? "onnx-community/Kokoro-82M-v1.0-ONNX";
59
+ this.dtype = options.dtype ?? "q8";
60
+ // "cpu" works in Node, but browser transformers.js only accepts
61
+ // wasm/webgpu — translate the Node-ism instead of failing.
62
+ this.device = options.device === "cpu" ? "wasm" : (options.device ?? "wasm");
63
+ this.onToken = options.onToken || null;
64
+ this.onChunkStart = options.onChunkStart || null;
65
+ this.onChunkEnd = options.onChunkEnd || null;
66
+ this.onEnd = options.onEnd || null;
67
+ this.onError = options.onError || null;
68
+ this.onProgress = options.onProgress || null; // (pct 0..1, label)
69
+ this._tts = null;
70
+ this._chunks = [];
71
+ this._cache = new Map(); // cacheKey -> {samples, sampleRate}
72
+ this._ctx = null;
73
+ this._src = null;
74
+ this._raf = null;
75
+ this._timings = null;
76
+ this._startTime = 0;
77
+ this._chunkIdx = -1;
78
+ this._tokenIndex = -1;
79
+ this._stopped = true;
80
+ this._paused = false;
81
+ }
82
+
83
+ /** Engine contract: register chunks without speaking. */
84
+ setChunks(chunks) {
85
+ this._chunks = chunks || [];
86
+ }
87
+
88
+ /** True once the model is loaded (for UI gating). */
89
+ get ready() {
90
+ return !!this._tts;
91
+ }
92
+
93
+ /**
94
+ * Load the model (idempotent). Call up-front to shift the model download
95
+ * off the play button, or let the first play trigger it.
96
+ * @returns {Promise<void>}
97
+ */
98
+ async load() {
99
+ if (this._tts) return;
100
+ this.onProgress?.(0, "Loading voice model");
101
+ try {
102
+ this._tts = await KokoroTTS.from_pretrained(this.model, {
103
+ dtype: this.dtype,
104
+ device: this.device,
105
+ });
106
+ this.onProgress?.(1, "Voice model ready");
107
+ } catch (err) {
108
+ this.onError?.(new Error(`kokoro load failed: ${err?.message ?? err}`));
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ async speak(chunks, startWord = 0) {
114
+ this._chunks = chunks || this._chunks;
115
+ this._stopped = false;
116
+ this._paused = false;
117
+ let startChunk = 0;
118
+ let startOffsetMs = 0;
119
+ if (startWord > 0) {
120
+ const ci = locateTokenChunk(this._chunks, startWord);
121
+ if (ci >= 0) {
122
+ startChunk = ci;
123
+ this._seekWord = startWord; // _playBuffer computes the sample offset
124
+ }
125
+ } else {
126
+ this._seekWord = null;
127
+ }
128
+ try {
129
+ await this.load();
130
+ } catch {
131
+ return; // onError already fired
132
+ }
133
+ this._playChunk(startChunk);
134
+ }
135
+
136
+ async _playChunk(i) {
137
+ if (this._stopped) return;
138
+ if (i >= this._chunks.length) {
139
+ this._finish();
140
+ return;
141
+ }
142
+ const chunk = this._chunks[i];
143
+ this.onChunkStart?.(i, chunk);
144
+ try {
145
+ const audio = await this._synth(chunk);
146
+ if (this._stopped) return;
147
+ // Pipelined synthesis: while chunk i plays, synthesize chunk i+1 so
148
+ // CPU-speed synthesis never inserts a gap between chunks.
149
+ const next = this._chunks[i + 1];
150
+ if (next) this._synth(next).catch(() => {});
151
+ this._playBuffer(audio, i, chunk);
152
+ } catch (err) {
153
+ if (this._stopped) return;
154
+ this.onError?.(new Error(`kokoro synthesis failed: ${err?.message ?? err}`));
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Synthesize a chunk to samples (Float32Array @ 24 kHz). Cached: replaying
160
+ * a chunk (restart, click-to-seek) is instant.
161
+ */
162
+ async _synth(chunk) {
163
+ const key = this._cacheKey(chunk);
164
+ const hit = this._cache.get(key);
165
+ if (hit) return hit;
166
+ const text = chunk.tokens.map((t) => t.text).join(" ");
167
+ const splitter = new TextSplitterStream();
168
+ const stream = this._tts.stream(splitter, {
169
+ voice: this.voice,
170
+ speed: this.speed,
171
+ });
172
+ splitter.push(text);
173
+ splitter.close();
174
+ let samples = new Float32Array(0);
175
+ let sampleRate = 24000;
176
+ for await (const part of stream) {
177
+ samples = concatFloat32(samples, part.audio.audio);
178
+ sampleRate = part.audio.sampling_rate || sampleRate;
179
+ }
180
+ const audio = { samples, sampleRate };
181
+ this._cache.set(key, audio);
182
+ return audio;
183
+ }
184
+
185
+ /** FNV-1a over voice|speed|dtype|model|chunk-text — the audio identity. */
186
+ _cacheKey(chunk) {
187
+ const s = `${this.voice}|${this.speed}|${this.dtype}|${this.model}|` +
188
+ chunk.tokens.map((t) => t.text).join(" ");
189
+ let h = 2166136261;
190
+ for (let i = 0; i < s.length; i++) {
191
+ h ^= s.charCodeAt(i);
192
+ h = Math.imul(h, 16777619);
193
+ }
194
+ return h.toString(36);
195
+ }
196
+
197
+ /**
198
+ * Schedule the chunk's audio on the AudioContext timeline and drive word
199
+ * tokens from the sample clock. A pending `_seekWord` starts playback at
200
+ * that word's sample offset (mid-chunk seek) and is consumed once.
201
+ */
202
+ _playBuffer(audio, chunkIdx, chunk) {
203
+ const ctx = this._ensureCtx();
204
+ this._cancelPoll();
205
+ // Raw pad so the final word's tail isn't clipped by stop timing slop.
206
+ const pad = Math.round(audio.sampleRate * TRAILING_SIL_MS / 1000);
207
+ const padded = concatFloat32(audio.samples, new Float32Array(pad));
208
+ const buffer = ctx.createBuffer(1, padded.length, audio.sampleRate);
209
+ buffer.copyToChannel(padded, 0);
210
+ const src = ctx.createBufferSource();
211
+ src.buffer = buffer;
212
+ src.connect(ctx.destination);
213
+ this._src = src;
214
+ this._chunkIdx = chunkIdx;
215
+ this._tokenIndex = -1;
216
+ this._timings = wordTimingsFromChunk(chunk, audio);
217
+
218
+ // Word-level seek: start the source at the word's sample position. The
219
+ // trimmed leading audio shifts every word's timing by the same offset,
220
+ // so the sample clock stays the single source of truth.
221
+ let offsetMs = 0;
222
+ if (this._seekWord != null) {
223
+ const hit = this._timings.find((w) => w.tokenIndex === this._seekWord);
224
+ if (hit) offsetMs = hit.startMs;
225
+ this._seekWord = null;
226
+ }
227
+ const offsetSec = Math.min(offsetMs / 1000, Math.max(0, buffer.duration - 0.05));
228
+
229
+ this._startTime = ctx.currentTime - offsetSec; // pos math stays uniform
230
+ src.onended = () => {
231
+ if (this._stopped || this._src !== src) return;
232
+ this.onChunkEnd?.(chunkIdx, chunk);
233
+ this._playChunk(chunkIdx + 1);
234
+ };
235
+ src.start(0, offsetSec);
236
+ this._advanceTo(offsetMs); // show the seeked word immediately
237
+ this._startPoll();
238
+ }
239
+
240
+ _ensureCtx() {
241
+ if (!this._ctx || this._ctx.state === "closed") {
242
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
243
+ this._ctx = new AC();
244
+ }
245
+ return this._ctx;
246
+ }
247
+
248
+ _startPoll() {
249
+ this._cancelPoll();
250
+ const step = () => {
251
+ if (this._stopped || this._paused) return;
252
+ const ctx = this._ctx;
253
+ if (!ctx || ctx.state === "closed") return;
254
+ // Position inside the current chunk, in ms, from the sample clock.
255
+ const posMs = (ctx.currentTime - this._startTime) * 1000;
256
+ this._advanceTo(posMs);
257
+ this._raf = requestAnimationFrame(step);
258
+ };
259
+ this._raf = requestAnimationFrame(step);
260
+ }
261
+
262
+ /** Set the active word to the last one whose startMs <= posMs. */
263
+ _advanceTo(posMs) {
264
+ const t = this._timings;
265
+ if (!t) return;
266
+ let active = -1;
267
+ for (let k = 0; k < t.length; k++) {
268
+ if (t[k].startMs <= posMs) active = k;
269
+ else break;
270
+ }
271
+ if (active >= 0) this._emitToken(t[active].tokenIndex);
272
+ }
273
+
274
+ _emitToken(globalIdx) {
275
+ if (globalIdx === this._tokenIndex) return;
276
+ this._tokenIndex = globalIdx;
277
+ this.onToken?.(globalIdx);
278
+ }
279
+
280
+ _cancelPoll() {
281
+ if (this._raf) cancelAnimationFrame(this._raf);
282
+ this._raf = null;
283
+ }
284
+
285
+ pause() {
286
+ if (this._stopped || this._paused) return;
287
+ this._paused = true;
288
+ this._cancelPoll();
289
+ // suspend() freezes the sample clock — resume() continues from the
290
+ // exact sample; timings stay correct with zero bookkeeping.
291
+ this._ctx?.suspend().catch(() => {});
292
+ }
293
+
294
+ resume() {
295
+ if (!this._paused) return;
296
+ this._paused = false;
297
+ this._ctx?.resume().catch(() => {});
298
+ this._startPoll();
299
+ }
300
+
301
+ stop() {
302
+ if (this._stopped) return;
303
+ this._stopped = true;
304
+ this._cancelPoll();
305
+ try { this._src?.stop(); } catch { /* already ended */ }
306
+ try { this._src?.disconnect(); } catch { /* already disconnected */ }
307
+ this._src = null;
308
+ const ctx = this._ctx;
309
+ this._ctx = null;
310
+ ctx?.close?.().catch?.(() => {});
311
+ this.onEnd?.();
312
+ }
313
+
314
+ get position() {
315
+ return { chunk: this._chunkIdx, token: this._tokenIndex };
316
+ }
317
+
318
+ get mode() {
319
+ return "audio";
320
+ }
321
+
322
+ _finish() {
323
+ this._cancelPoll();
324
+ this.onEnd?.();
325
+ }
326
+ }
327
+
328
+ // wordTimingsFromChunk moved to ../timings.js so hosts can build
329
+ // MediaEngine/ExternalEngine manifests from kokoro output without
330
+ // importing this module (kokoro-js is an optional peer dep).
331
+
332
+ function concatFloat32(a, b) {
333
+ if (!b.length) return a;
334
+ if (!a.length) return b;
335
+ const out = new Float32Array(a.length + b.length);
336
+ out.set(a, 0);
337
+ out.set(b, a.length);
338
+ return out;
339
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * media.js — pre-synthesized audio engine (build-time TTS, e.g. Piper Opus).
3
+ *
4
+ * The designesy.org Listen pattern: synthesize audio at build time, serve as
5
+ * static files, and map playback position to tokens. This engine consumes
6
+ * one audio file per chunk plus word timings (ms) per chunk — both formats
7
+ * are plain JSON so any offline synthesizer (Piper CLI, kokoro export,
8
+ * cloud TTS batch) can produce them.
9
+ *
10
+ * manifest format:
11
+ * {
12
+ * "chunks": [
13
+ * { "src": "audio/c000.opus", "duration": 6.4,
14
+ * "words": [[tokenIndex, startMs, endMs], ...] },
15
+ * ...
16
+ * ]
17
+ * }
18
+ * `tokenIndex` refers to the token array produced by tokenize() on the host.
19
+ */
20
+
21
+ const POLL_MS = 60;
22
+
23
+ /** Chunk index containing global token index `word` (-1 if not found). */
24
+ function locateTokenChunk(chunks, word) {
25
+ for (let i = 0; i < chunks.length; i++) {
26
+ const toks = chunks[i].tokens;
27
+ if (word >= toks[0].index && word <= toks[toks.length - 1].index) return i;
28
+ }
29
+ return -1;
30
+ }
31
+
32
+ export class MediaEngine {
33
+ constructor(options = {}) {
34
+ this.manifest = options.manifest ?? { chunks: [] };
35
+ this.onToken = options.onToken || null;
36
+ this.onChunkStart = options.onChunkStart || null;
37
+ this.onChunkEnd = options.onChunkEnd || null;
38
+ this.onEnd = options.onEnd || null;
39
+ this.onError = options.onError || null;
40
+ this._audio = null;
41
+ this._timer = null;
42
+ this._chunkIdx = -1;
43
+ this._stopped = true;
44
+ this._paused = false;
45
+ this._lastToken = -1;
46
+ }
47
+
48
+ setChunks(chunks) { this._chunks = chunks || []; }
49
+
50
+ get rate() { return this._audio?.playbackRate ?? 1; }
51
+ set rate(r) { if (this._audio) this._audio.playbackRate = r; }
52
+
53
+ speak(_chunks, startWord = 0) {
54
+ this._stopped = false;
55
+ this._paused = false;
56
+ let startChunk = 0;
57
+ let offsetMs = 0;
58
+ if (startWord > 0) {
59
+ const ci = locateTokenChunk(this._chunks, startWord);
60
+ if (ci >= 0) {
61
+ startChunk = ci;
62
+ const entry = this.manifest.chunks[ci];
63
+ const hit = entry?.words?.find((w) => w[0] === startWord);
64
+ if (hit) offsetMs = hit[1];
65
+ }
66
+ }
67
+ this._playChunk(startChunk, offsetMs);
68
+ }
69
+
70
+ _playChunk(i, offsetMs = 0) {
71
+ if (this._stopped) return;
72
+ const entry = this.manifest.chunks[i];
73
+ if (!entry) { this.onEnd?.(); return; }
74
+ this._chunkIdx = i;
75
+ this._lastToken = -1; // each chunk restarts the word pointer
76
+
77
+ if (!this._audio) {
78
+ this._audio = new Audio();
79
+ this._audio.preload = "auto";
80
+ this._audio.addEventListener("ended", () => {
81
+ if (this._stopped) return;
82
+ this.onChunkEnd?.(this._chunkIdx, null);
83
+ this._playChunk(this._chunkIdx + 1);
84
+ });
85
+ this._audio.addEventListener("error", () => {
86
+ if (this._stopped) return;
87
+ this._stopPoll();
88
+ this.onError?.(new Error(`audio failed: ${this.manifest.chunks[this._chunkIdx]?.src}`));
89
+ });
90
+ }
91
+
92
+ this._audio.src = entry.src;
93
+ this.onChunkStart?.(i, null);
94
+ this._playPromise = this._audio.play();
95
+ // Word-level seek: drop the needle at the word's start time.
96
+ if (offsetMs > 0) {
97
+ this._playPromise = this._playPromise
98
+ .then(() => { this._audio.currentTime = offsetMs / 1000; })
99
+ .catch(() => {});
100
+ }
101
+ this._playPromise.catch((e) => {
102
+ if (this._stopped) return;
103
+ this.onError?.(new Error(`playback blocked: ${e.message}`));
104
+ });
105
+ this._stopPoll();
106
+ this._timer = setInterval(() => this._poll(entry), POLL_MS);
107
+ }
108
+
109
+ _poll(entry) {
110
+ if (this._stopped || !this._audio) return;
111
+ const t = this._audio.currentTime * 1000;
112
+ // Last word whose startMs has been reached (timings must be sorted).
113
+ const words = entry?.words;
114
+ if (!words) return; // manifest chunk without timings — highlight off
115
+ let idx = -1;
116
+ for (let k = 0; k < words.length; k++) {
117
+ if (words[k][1] <= t) idx = words[k][0];
118
+ else break;
119
+ }
120
+ if (idx >= 0 && idx !== this._lastToken) {
121
+ this._lastToken = idx;
122
+ this.onToken?.(idx);
123
+ }
124
+ }
125
+
126
+ _stopPoll() {
127
+ if (this._timer) clearInterval(this._timer);
128
+ this._timer = null;
129
+ }
130
+
131
+ pause() {
132
+ if (this._stopped || this._paused) return;
133
+ this._paused = true;
134
+ this._audio?.pause();
135
+ }
136
+
137
+ resume() {
138
+ if (!this._paused) return;
139
+ this._paused = false;
140
+ this._audio?.play().catch(() => {});
141
+ }
142
+
143
+ stop() {
144
+ if (this._stopped) return;
145
+ this._stopped = true;
146
+ this._stopPoll();
147
+ if (this._audio) { this._audio.pause(); this._audio.removeAttribute("src"); }
148
+ this.onEnd?.();
149
+ }
150
+
151
+ get position() { return { chunk: this._chunkIdx, token: this._lastToken ?? -1 }; }
152
+ }