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