@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/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 { pcmToWav, silencePcm, synthesize, synthesizeStream } from "../router/tts.js";
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 when it concludes. A request
160
- * for the readout already under way joins it instead of restarting the
161
- * headset and cancelling the page that asked first.
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 segments = speechSegments(text);
167
- if (!segments.length) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
168
- const model = options.model ?? await pickTtsModelId(await listModels(), config.ttsModel);
169
- if (!model) throw new VoiceError("voice_tts_unavailable", 503, "no speech model available");
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 current = readouts.get(coordinator);
172
- if (current?.text === text && current.active()) return current.result;
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
- const result = readAloud(segments, model, lease, options);
175
- readouts.set(coordinator, { text, active: lease.active, result });
176
- return result;
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(segments, model, lease, options) {
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 (const segment of segments) {
217
- if (!lease.active()) break;
218
- const outcome = await synthesize({ text: segment.text, model, signal: lease.signal }, writeChunk);
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
- if (segment.gapMs > 0 && lease.active()) writeChunk(gap(segment.gapMs));
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
- if (lease.signal.aborted) throw cancelledSpeech();
230
- throw err;
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 assistant message that
3
- * concludes it. The agent loop stops stepping once a settled message carries
4
- * no tool call, so that message is the answer even while turn-stopping work
5
- * still holds the turn open. Reasoning and text said alongside a tool call
6
- * are intermediate and stay silent.
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
- /** The answer text when this event concludes its turn, otherwise "". */
32
- export function concludingReply(event) {
33
- if (event?.type !== "assistant/message" || event.data?.interrupted) return "";
34
- if (messageContent(event).some((block) => block?.type === "tool-call")) return "";
35
- return assistantText(event);
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
- * Which concluded answers a live view reads aloud: only those it watched
40
- * arrive while their turn was running. Opening a session, or history that is
41
- * already finished, stays silent.
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 spokenSeq = -1;
116
+ let skipKey = null;
46
117
  return {
47
- /** @returns {string} text to speak now, or "" */
48
- observe({ sessionChanged, wasRunning, running, reply }) {
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
- spokenSeq = reply?.seq ?? -1;
52
- return "";
122
+ skipKey = utterance?.key ?? null;
123
+ return null;
53
124
  }
54
125
  if (!wasRunning && running) {
55
126
  armed = true;
56
- // A turn start resets the reply, so one already here was loaded with
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 text;
131
+ return follow;
67
132
  },
68
133
  };
69
134
  }
70
135
 
71
- export function emptySpeechState() {
72
- return { text: "", seq: -1 };
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
- * A steer that lands after the answer runs another step in the same turn, so
77
- * a turn may conclude more than once; `seq` tells the answers apart.
78
- * @param {{ text: string, seq: number }} state
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 reduceSpeechEvent(state, event) {
81
- const current = state ?? emptySpeechState();
82
- if (event?.type === "turn/start") return emptySpeechState();
83
- const text = concludingReply(event);
84
- return text ? { text, seq: Number(event.seq) } : current;
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
- export function matchSpeechEvent(event) {
88
- const type = event?.type;
89
- if (type !== "turn/start" && type !== "assistant/message") return null;
90
- const turn = turnOf(event);
91
- if (turn === null) return null;
92
- return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
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
  }