@olares/lares-core 0.15.10 → 0.15.11
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/i18n/voice.js +98 -9
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +3 -3
- package/larepass/transcript.js +4 -6
- package/olares/trusted-host.js +11 -1
- package/package.json +2 -1
- package/router/tts.js +24 -0
- package/tools/http.js +25 -0
- package/voice/client.js +50 -3
- package/voice/config.js +6 -3
- package/voice/gpu-mode.js +372 -0
- package/voice/install.js +24 -7
- package/voice/languages.js +2 -6
- package/voice/script.js +56 -7
- package/voice/service.js +158 -22
- package/voice/speech.js +222 -44
package/voice/service.js
CHANGED
|
@@ -19,8 +19,15 @@ import {
|
|
|
19
19
|
import { createCaptureSession } from "./capture.js";
|
|
20
20
|
import { createDuplexCoordinator } from "./duplex.js";
|
|
21
21
|
import { normalizeSttPcm } from "./pcm.js";
|
|
22
|
-
import { speechSegments } from "./script.js";
|
|
23
|
-
import {
|
|
22
|
+
import { gapAfter, speechSegments } from "./script.js";
|
|
23
|
+
import {
|
|
24
|
+
listTtsVoices,
|
|
25
|
+
pcmToWav,
|
|
26
|
+
pickTtsVoice,
|
|
27
|
+
silencePcm,
|
|
28
|
+
synthesize,
|
|
29
|
+
synthesizeStream,
|
|
30
|
+
} from "../router/tts.js";
|
|
24
31
|
import {
|
|
25
32
|
STT_MAX_AUDIO_BYTES,
|
|
26
33
|
VoiceError,
|
|
@@ -155,50 +162,167 @@ function cancelledSpeech() {
|
|
|
155
162
|
|
|
156
163
|
const readouts = new WeakMap();
|
|
157
164
|
|
|
165
|
+
/** model id → pinned voice. A failed lookup is not cached, so the next readout asks again. */
|
|
166
|
+
const pinnedVoices = new Map();
|
|
167
|
+
|
|
168
|
+
async function pinnedVoice(model, listVoices = listTtsVoices) {
|
|
169
|
+
if (pinnedVoices.has(model)) return pinnedVoices.get(model);
|
|
170
|
+
let voice = null;
|
|
171
|
+
try {
|
|
172
|
+
voice = pickTtsVoice(await listVoices(model));
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (voice) pinnedVoices.set(model, voice);
|
|
177
|
+
return voice;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function deferred() {
|
|
181
|
+
let resolve;
|
|
182
|
+
let reject;
|
|
183
|
+
const promise = new Promise((ok, fail) => {
|
|
184
|
+
resolve = ok;
|
|
185
|
+
reject = fail;
|
|
186
|
+
});
|
|
187
|
+
promise.catch(() => {});
|
|
188
|
+
return { promise, resolve, reject };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function partIndex(value) {
|
|
192
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** How long a readout of a streaming answer waits for its next sentence before it lets the headset go. */
|
|
196
|
+
const STREAM_IDLE_MS = 30_000;
|
|
197
|
+
|
|
198
|
+
function joinable(current, id, key, part) {
|
|
199
|
+
if (current?.id !== id) return null;
|
|
200
|
+
if (key && current.signal.aborted) throw cancelledSpeech();
|
|
201
|
+
if (key || part > 0) return current.signal.aborted ? null : current;
|
|
202
|
+
return current.active() ? current : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
/**
|
|
159
|
-
* Every open page asks to read the same answer
|
|
160
|
-
*
|
|
161
|
-
*
|
|
206
|
+
* Every open page asks to read the same answer. A request for the readout
|
|
207
|
+
* already under way joins it instead of restarting the headset and cancelling
|
|
208
|
+
* the page that asked first.
|
|
209
|
+
*
|
|
210
|
+
* `options.key` names an answer that is still being written: each request
|
|
211
|
+
* carries the text so far, and `options.done` once it is complete. The readout
|
|
212
|
+
* appends the sentences it has not seen yet, so the headset reads on without a
|
|
213
|
+
* restart. A keyed request without `options.part` only hands over the text.
|
|
214
|
+
* Once interrupted, a key stays cancelled.
|
|
215
|
+
*
|
|
216
|
+
* `options.part` asks for one sentence instead of the whole answer, so a page
|
|
217
|
+
* can play it while the rest is still being written or synthesized. A later
|
|
218
|
+
* sentence joins its readout even after synthesis has finished.
|
|
219
|
+
*
|
|
220
|
+
* Without a key the text is the whole answer and names its readout.
|
|
221
|
+
*
|
|
222
|
+
* @returns {Promise<{ bluetooth: string, audio: Buffer } | { sentences: number }>}
|
|
162
223
|
*/
|
|
163
224
|
export async function speakFromConfig(text, options = {}) {
|
|
164
225
|
const config = options.config ?? readConfig();
|
|
165
226
|
if (!config.speak) throw new VoiceError("voice_speak_disabled", 409, "speech playback is off");
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
227
|
+
const key = typeof options.key === "string" && options.key ? options.key : null;
|
|
228
|
+
const done = key ? options.done === true : true;
|
|
229
|
+
const segments = speechSegments(text, { done });
|
|
230
|
+
const part = partIndex(options.part);
|
|
170
231
|
const coordinator = options.coordinator ?? duplex;
|
|
171
|
-
const
|
|
172
|
-
|
|
232
|
+
const id = key ?? text;
|
|
233
|
+
let readout = joinable(readouts.get(coordinator), id, key, part);
|
|
234
|
+
if (!readout) {
|
|
235
|
+
if (!segments.length) {
|
|
236
|
+
if (key && part === null) return { sentences: 0 };
|
|
237
|
+
throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
238
|
+
}
|
|
239
|
+
const model = options.model ?? await pickTtsModelId(await listModels(), config.ttsModel);
|
|
240
|
+
if (!model) throw new VoiceError("voice_tts_unavailable", 503, "no speech model available");
|
|
241
|
+
readout = joinable(readouts.get(coordinator), id, key, part) ?? startReadout(coordinator, id, model, options);
|
|
242
|
+
}
|
|
243
|
+
readout.extend(segments, done);
|
|
244
|
+
if (part === null) return key ? { sentences: readout.segments.length } : readout.result;
|
|
245
|
+
if (part >= readout.segments.length) {
|
|
246
|
+
throw new VoiceError("voice_config_invalid", 400, `answer has ${readout.segments.length} sentence(s)`);
|
|
247
|
+
}
|
|
248
|
+
return readout.parts[part].promise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function startReadout(coordinator, id, model, options) {
|
|
173
252
|
const lease = coordinator.beginSpeech(options.signal);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
253
|
+
let wake = () => {};
|
|
254
|
+
const readout = {
|
|
255
|
+
id,
|
|
256
|
+
active: lease.active,
|
|
257
|
+
signal: lease.signal,
|
|
258
|
+
segments: [],
|
|
259
|
+
parts: [],
|
|
260
|
+
final: false,
|
|
261
|
+
extend(segments, done) {
|
|
262
|
+
if (readout.final) return;
|
|
263
|
+
for (const sentence of segments.slice(readout.segments.length)) {
|
|
264
|
+
readout.segments.push(sentence);
|
|
265
|
+
readout.parts.push(deferred());
|
|
266
|
+
}
|
|
267
|
+
if (done) readout.final = true;
|
|
268
|
+
wake();
|
|
269
|
+
},
|
|
270
|
+
/** true once more text arrives or the readout is cancelled, false after `idleMs` of silence. */
|
|
271
|
+
more(idleMs) {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const settle = (value) => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
lease.signal.removeEventListener("abort", woken);
|
|
276
|
+
wake = () => {};
|
|
277
|
+
resolve(value);
|
|
278
|
+
};
|
|
279
|
+
const woken = () => settle(true);
|
|
280
|
+
const timer = setTimeout(() => settle(false), idleMs);
|
|
281
|
+
wake = woken;
|
|
282
|
+
lease.signal.addEventListener("abort", woken, { once: true });
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
readout.result = pinnedVoice(model, options.listVoices)
|
|
287
|
+
.then((voice) => readAloud(readout, model, voice, lease, options));
|
|
288
|
+
readout.result.catch(() => {});
|
|
289
|
+
readouts.set(coordinator, readout);
|
|
290
|
+
return readout;
|
|
177
291
|
}
|
|
178
292
|
|
|
179
|
-
async function readAloud(
|
|
293
|
+
async function readAloud(readout, model, voice, lease, options) {
|
|
180
294
|
const openPlayback = options.openPlayback ?? openBluetoothPlayback;
|
|
181
295
|
const synthesize = options.synthesize ?? synthesizeStream;
|
|
182
296
|
const playFile = options.playFile ?? playBluetoothAudio;
|
|
183
297
|
const toWav = options.pcmToWav ?? pcmToWav;
|
|
184
298
|
const gap = options.silencePcm ?? silencePcm;
|
|
299
|
+
const idleMs = options.idleMs ?? STREAM_IDLE_MS;
|
|
185
300
|
let opening;
|
|
186
301
|
let player;
|
|
187
302
|
const abortPlayer = () => player?.abort?.();
|
|
188
303
|
lease.signal.addEventListener("abort", abortPlayer);
|
|
189
304
|
const pcm = [];
|
|
305
|
+
let sentence = [];
|
|
190
306
|
let streamed = false;
|
|
191
307
|
let fileAudio;
|
|
192
308
|
const release = () => {
|
|
193
309
|
lease.signal.removeEventListener("abort", abortPlayer);
|
|
194
310
|
lease.finish();
|
|
195
311
|
};
|
|
312
|
+
const dropParts = (err) => {
|
|
313
|
+
for (const part of readout.parts) part.reject(err);
|
|
314
|
+
};
|
|
315
|
+
const headsetState = async () => {
|
|
316
|
+
const opened = opening ? await opening : null;
|
|
317
|
+
return opened && !opened.skipped ? "played" : "skipped";
|
|
318
|
+
};
|
|
196
319
|
// The speaker drains in real time; synthesis must not wait on it, or the
|
|
197
320
|
// browser copy arrives only after the headset has finished.
|
|
198
321
|
let feeding = Promise.resolve();
|
|
199
322
|
const writeChunk = (chunk) => {
|
|
200
323
|
if (!chunk?.length) return;
|
|
201
324
|
pcm.push(chunk);
|
|
325
|
+
sentence.push(chunk);
|
|
202
326
|
if (!lease.active()) return;
|
|
203
327
|
opening ??= openPlayback().catch(() => ({ skipped: true }));
|
|
204
328
|
feeding = feeding.then(async () => {
|
|
@@ -213,29 +337,41 @@ async function readAloud(segments, model, lease, options) {
|
|
|
213
337
|
});
|
|
214
338
|
};
|
|
215
339
|
try {
|
|
216
|
-
for (
|
|
217
|
-
if (
|
|
218
|
-
|
|
340
|
+
for (let index = 0; lease.active();) {
|
|
341
|
+
if (index >= readout.segments.length) {
|
|
342
|
+
if (readout.final) break;
|
|
343
|
+
if (!(await readout.more(idleMs))) readout.final = true;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
sentence = [];
|
|
347
|
+
if (index > 0) writeChunk(gap(gapAfter(readout.segments[index - 1])));
|
|
348
|
+
const outcome = await synthesize({ text: readout.segments[index], model, voice, signal: lease.signal }, writeChunk);
|
|
219
349
|
if (!outcome.streamed) {
|
|
220
350
|
fileAudio = outcome.audio;
|
|
351
|
+
readout.parts[0].resolve({ audio: fileAudio, bluetooth: "skipped" });
|
|
352
|
+
readout.final = true;
|
|
221
353
|
break;
|
|
222
354
|
}
|
|
223
355
|
streamed = true;
|
|
224
|
-
|
|
356
|
+
readout.parts[index].resolve({ audio: toWav(Buffer.concat(sentence)), bluetooth: await headsetState() });
|
|
357
|
+
index += 1;
|
|
225
358
|
}
|
|
226
359
|
} catch (err) {
|
|
227
360
|
void opening?.then((opened) => opened.abort?.());
|
|
228
361
|
release();
|
|
229
|
-
|
|
230
|
-
|
|
362
|
+
const failure = lease.signal.aborted ? cancelledSpeech() : err;
|
|
363
|
+
dropParts(failure);
|
|
364
|
+
throw failure;
|
|
231
365
|
}
|
|
232
|
-
const audio = streamed ? toWav(Buffer.concat(pcm)) : fileAudio;
|
|
233
366
|
const headset = opening ? await opening : null;
|
|
234
367
|
if (!lease.active()) {
|
|
235
368
|
headset?.abort?.();
|
|
236
369
|
release();
|
|
370
|
+
dropParts(cancelledSpeech());
|
|
237
371
|
throw cancelledSpeech();
|
|
238
372
|
}
|
|
373
|
+
if (!streamed) dropParts(new VoiceError("voice_config_invalid", 400, "this engine returns the answer whole"));
|
|
374
|
+
const audio = streamed ? toWav(Buffer.concat(pcm)) : fileAudio;
|
|
239
375
|
if (streamed && headset && !headset.skipped) {
|
|
240
376
|
void feeding
|
|
241
377
|
.then(() => (headset.skipped || !lease.active() ? headset.abort?.() : headset.close()))
|
package/voice/speech.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The utterance a turn may speak: the text of the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The utterance a turn may speak: the text of the step being written, read
|
|
3
|
+
* while it streams. Only a step that ends without a tool call answers the
|
|
4
|
+
* turn, and that is not known until the step settles, so a step turns silent
|
|
5
|
+
* the moment a tool call shows up in it, and an interrupted step is silent
|
|
6
|
+
* too. Reasoning is never read.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
function turnOf(event) {
|
|
@@ -13,6 +13,12 @@ function turnOf(event) {
|
|
|
13
13
|
return Number.isFinite(number) ? number : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function stepOf(event) {
|
|
17
|
+
const data = event?.data ?? {};
|
|
18
|
+
const number = Number(data.step ?? data.message?.step);
|
|
19
|
+
return Number.isFinite(number) ? number : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function messageContent(event) {
|
|
17
23
|
const data = event?.data ?? {};
|
|
18
24
|
const content = data.message?.content ?? data.content;
|
|
@@ -28,66 +34,238 @@ export function assistantText(event) {
|
|
|
28
34
|
.trim();
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
function isChunkEvent(event) {
|
|
38
|
+
return event?.type === "assistant/chunk" || event?.type === "assistant/live-chunk";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function opensToolCall(chunk) {
|
|
42
|
+
return chunk?.type === "tool-call-delta"
|
|
43
|
+
|| (chunk?.type === "block-start" && chunk.blockType === "tool-call")
|
|
44
|
+
|| (chunk?.type === "block-end" && chunk.block?.type === "tool-call");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stepUtterance(turn, step) {
|
|
48
|
+
return { key: `${turn}:${step}`, turn, step, blocks: {}, text: "", done: false, silent: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withBlock(state, index, text) {
|
|
52
|
+
const blocks = { ...state.blocks, [index]: text };
|
|
53
|
+
const joined = Object.keys(blocks)
|
|
54
|
+
.map(Number)
|
|
55
|
+
.sort((a, b) => a - b)
|
|
56
|
+
.map((at) => blocks[at].trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join("\n");
|
|
59
|
+
return { ...state, blocks, text: joined };
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
63
|
+
* @typedef {{ key: string, turn: number, step: number, text: string, done: boolean, silent: boolean }} Utterance
|
|
64
|
+
* @param {Utterance | null} state
|
|
65
|
+
* @returns {Utterance | null}
|
|
66
|
+
*/
|
|
67
|
+
export function reduceUtterance(state, event) {
|
|
68
|
+
const type = event?.type;
|
|
69
|
+
if (type === "turn/start") return null;
|
|
70
|
+
if (type === "turn/end") return state && !state.done ? { ...state, done: true } : state;
|
|
71
|
+
const turn = turnOf(event);
|
|
72
|
+
if (turn === null) return state;
|
|
73
|
+
const step = stepOf(event);
|
|
74
|
+
const same = state?.turn === turn && state.step === step;
|
|
75
|
+
if (isChunkEvent(event)) {
|
|
76
|
+
if (same && state.done) return state;
|
|
77
|
+
const chunk = event.data?.chunk;
|
|
78
|
+
const current = same ? state : stepUtterance(turn, step);
|
|
79
|
+
if (opensToolCall(chunk)) return current.silent ? current : { ...current, silent: true };
|
|
80
|
+
const index = Number.isInteger(chunk?.index) ? chunk.index : 0;
|
|
81
|
+
if (chunk?.type === "text-delta" && typeof chunk.text === "string") {
|
|
82
|
+
return withBlock(current, index, `${current.blocks[index] ?? ""}${chunk.text}`);
|
|
83
|
+
}
|
|
84
|
+
if (chunk?.type === "block-end" && chunk.block?.type === "text" && typeof chunk.block.text === "string") {
|
|
85
|
+
return withBlock(current, index, chunk.block.text);
|
|
86
|
+
}
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
if (type === "assistant/message") {
|
|
90
|
+
const content = messageContent(event);
|
|
91
|
+
return {
|
|
92
|
+
...stepUtterance(turn, step),
|
|
93
|
+
text: assistantText(event),
|
|
94
|
+
done: true,
|
|
95
|
+
silent: Boolean(event.data?.interrupted) || content.some((block) => block?.type === "tool-call"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function matchSpeechEvent(event) {
|
|
102
|
+
const type = event?.type;
|
|
103
|
+
if (type !== "turn/start" && type !== "turn/end" && type !== "assistant/message" && !isChunkEvent(event)) return null;
|
|
104
|
+
const turn = turnOf(event);
|
|
105
|
+
if (turn === null) return null;
|
|
106
|
+
return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Which utterances a live view reads aloud: only those it watched being
|
|
111
|
+
* written while their turn was running. Opening a session mid-answer, or
|
|
112
|
+
* history that is already finished, stays silent.
|
|
42
113
|
*/
|
|
43
114
|
export function createReplySpeaker() {
|
|
44
115
|
let armed = false;
|
|
45
|
-
let
|
|
116
|
+
let skipKey = null;
|
|
46
117
|
return {
|
|
47
|
-
/** @returns {
|
|
48
|
-
observe({ sessionChanged, wasRunning, running,
|
|
118
|
+
/** @returns {Utterance | null} the utterance to follow now, or null */
|
|
119
|
+
observe({ sessionChanged, wasRunning, running, utterance }) {
|
|
49
120
|
if (sessionChanged) {
|
|
50
121
|
armed = Boolean(running);
|
|
51
|
-
|
|
52
|
-
return
|
|
122
|
+
skipKey = utterance?.key ?? null;
|
|
123
|
+
return null;
|
|
53
124
|
}
|
|
54
125
|
if (!wasRunning && running) {
|
|
55
126
|
armed = true;
|
|
56
|
-
|
|
57
|
-
// history, not watched.
|
|
58
|
-
if (reply) spokenSeq = Math.max(spokenSeq, reply.seq);
|
|
59
|
-
}
|
|
60
|
-
let text = "";
|
|
61
|
-
if (armed && reply && reply.seq > spokenSeq) {
|
|
62
|
-
spokenSeq = reply.seq;
|
|
63
|
-
text = reply.text;
|
|
127
|
+
skipKey = utterance?.key ?? null;
|
|
64
128
|
}
|
|
129
|
+
const follow = armed && utterance && utterance.key !== skipKey ? utterance : null;
|
|
65
130
|
if (!running) armed = false;
|
|
66
|
-
return
|
|
131
|
+
return follow;
|
|
67
132
|
},
|
|
68
133
|
};
|
|
69
134
|
}
|
|
70
135
|
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Which state of an utterance the reader gets. Streaming reads while the step
|
|
138
|
+
* is written; otherwise the step is read once it has settled.
|
|
139
|
+
* @param {Utterance | null} utterance
|
|
140
|
+
*/
|
|
141
|
+
export function speakable(utterance, streaming) {
|
|
142
|
+
if (!utterance) return null;
|
|
143
|
+
return streaming || utterance.done || utterance.silent ? utterance : null;
|
|
73
144
|
}
|
|
74
145
|
|
|
146
|
+
/** Text past this may have closed a sentence; the host decides whether it did. */
|
|
147
|
+
const BOUNDARY = /[.!?;。!?;…\n]/;
|
|
148
|
+
|
|
75
149
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
150
|
+
* Reads one utterance at a time, sentence by sentence, while it is still being
|
|
151
|
+
* written. The host owns sentence boundaries (sentencex does not run in a
|
|
152
|
+
* page): the text is handed over whenever it may have finished a sentence, and
|
|
153
|
+
* the host answers how many sentences are complete. That keeps a headset
|
|
154
|
+
* reading on without gaps; the page fetches a sentence's audio just before it
|
|
155
|
+
* needs it and plays it through `play`.
|
|
156
|
+
*
|
|
157
|
+
* `halt` stops the page's own audio. `silence` also stops the host, for a step
|
|
158
|
+
* that turned out to call a tool while the headset was reading it.
|
|
159
|
+
*
|
|
160
|
+
* @param {{
|
|
161
|
+
* send: (request: { key: string, text: string, done: boolean, part?: number }) => Promise<any>,
|
|
162
|
+
* play: (sentence: { audio: unknown, bluetooth?: string }) => Promise<unknown>,
|
|
163
|
+
* halt: () => void,
|
|
164
|
+
* silence: () => void,
|
|
165
|
+
* fail?: (err: unknown) => void,
|
|
166
|
+
* }} io
|
|
79
167
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
168
|
+
export function createSentencePlayer({ send, play, halt, silence, fail = () => {} }) {
|
|
169
|
+
let gen = 0;
|
|
170
|
+
let current = null;
|
|
171
|
+
// An utterance is read once: finishing it, failing, or being stopped all end it for good.
|
|
172
|
+
let lastKey = null;
|
|
173
|
+
let silencedKey = null;
|
|
86
174
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
const failed = (run, err) => {
|
|
176
|
+
if (run.gen !== gen) return;
|
|
177
|
+
gen += 1;
|
|
178
|
+
run.wake?.();
|
|
179
|
+
current = null;
|
|
180
|
+
if (err?.code !== "voice_cancelled") fail(err);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handOver = (run) => {
|
|
184
|
+
if (run.sending || run.gen !== gen) return;
|
|
185
|
+
const due = (run.done && !run.sentDone) || BOUNDARY.test(run.text.slice(run.sentLength));
|
|
186
|
+
if (!due) return;
|
|
187
|
+
const { text, done } = run;
|
|
188
|
+
run.sending = send({ key: run.key, text, done })
|
|
189
|
+
.then((result) => {
|
|
190
|
+
run.sending = null;
|
|
191
|
+
run.sent = Math.max(run.sent, Number(result?.sentences) || 0);
|
|
192
|
+
run.sentLength = text.length;
|
|
193
|
+
run.sentDone = done;
|
|
194
|
+
const wake = run.wake;
|
|
195
|
+
run.wake = null;
|
|
196
|
+
wake?.();
|
|
197
|
+
handOver(run);
|
|
198
|
+
})
|
|
199
|
+
.catch((err) => failed(run, err));
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const fetchPart = (run, part) => {
|
|
203
|
+
const pending = send({ key: run.key, text: run.text, done: run.done, part });
|
|
204
|
+
pending.catch(() => {});
|
|
205
|
+
return pending;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const read = async (run) => {
|
|
209
|
+
let next = null;
|
|
210
|
+
for (let part = 0; run.gen === gen;) {
|
|
211
|
+
if (part >= run.sent) {
|
|
212
|
+
if (run.sentDone) break;
|
|
213
|
+
await new Promise((resolve) => { run.wake = resolve; });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const sentence = await (next ?? fetchPart(run, part));
|
|
217
|
+
next = part + 1 < run.sent ? fetchPart(run, part + 1) : null;
|
|
218
|
+
if (run.gen !== gen) return;
|
|
219
|
+
await play(sentence);
|
|
220
|
+
part += 1;
|
|
221
|
+
}
|
|
222
|
+
if (run.gen === gen) current = null;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const stop = () => {
|
|
226
|
+
gen += 1;
|
|
227
|
+
current?.wake?.();
|
|
228
|
+
if (current) halt();
|
|
229
|
+
current = null;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
/** Feed the latest state of the utterance; null leaves the reading as it is. */
|
|
234
|
+
follow(utterance) {
|
|
235
|
+
if (!utterance) return;
|
|
236
|
+
if (utterance.silent) {
|
|
237
|
+
if (utterance.key !== lastKey || silencedKey === lastKey) return;
|
|
238
|
+
silencedKey = lastKey;
|
|
239
|
+
stop();
|
|
240
|
+
silence();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (!utterance.text) return;
|
|
244
|
+
if (current?.key !== utterance.key) {
|
|
245
|
+
if (utterance.key === lastKey) return;
|
|
246
|
+
if (current) stop();
|
|
247
|
+
gen += 1;
|
|
248
|
+
lastKey = utterance.key;
|
|
249
|
+
current = {
|
|
250
|
+
gen,
|
|
251
|
+
key: utterance.key,
|
|
252
|
+
text: utterance.text,
|
|
253
|
+
done: utterance.done,
|
|
254
|
+
sent: 0,
|
|
255
|
+
sentLength: 0,
|
|
256
|
+
sentDone: false,
|
|
257
|
+
sending: null,
|
|
258
|
+
wake: null,
|
|
259
|
+
};
|
|
260
|
+
const run = current;
|
|
261
|
+
handOver(run);
|
|
262
|
+
read(run).catch((err) => failed(run, err));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
current.text = utterance.text;
|
|
266
|
+
current.done = utterance.done;
|
|
267
|
+
handOver(current);
|
|
268
|
+
},
|
|
269
|
+
stop,
|
|
270
|
+
};
|
|
93
271
|
}
|