@olares/lares-core 0.15.11 → 0.15.13

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.
Files changed (44) hide show
  1. package/i18n/voice.js +180 -10
  2. package/larepass/runtime.js +2 -0
  3. package/larepass/settings.js +3 -0
  4. package/larepass/transcript.js +7 -0
  5. package/package.json +28 -2
  6. package/router/tts.js +1 -1
  7. package/tools/http.js +3 -2
  8. package/voice/agent-session.js +134 -0
  9. package/voice/agent.js +9 -1
  10. package/voice/browser-capture.js +274 -0
  11. package/voice/browser-playback.js +30 -0
  12. package/voice/capture.js +12 -2
  13. package/voice/client.js +29 -14
  14. package/voice/composite-gateway.js +156 -0
  15. package/voice/config.js +28 -18
  16. package/voice/controller.js +154 -0
  17. package/voice/conversation.js +135 -0
  18. package/voice/device-input.js +79 -0
  19. package/voice/dialogue.js +99 -0
  20. package/voice/duplex.js +1 -1
  21. package/voice/external.js +26 -0
  22. package/voice/gpu-mode.js +147 -59
  23. package/voice/gpu-problem.js +47 -0
  24. package/voice/host/bluetooth-ready.py +2 -0
  25. package/voice/host-input.js +38 -0
  26. package/voice/host-service.js +216 -0
  27. package/voice/input.js +2 -1
  28. package/voice/install.js +9 -5
  29. package/voice/languages.js +1 -1
  30. package/voice/lease.js +16 -0
  31. package/voice/options.js +23 -0
  32. package/voice/panel.js +88 -0
  33. package/voice/projection.js +67 -0
  34. package/voice/protocol-client.js +24 -0
  35. package/voice/realtime-audio.js +107 -0
  36. package/voice/realtime-conversation.js +126 -0
  37. package/voice/realtime-gateway.js +168 -0
  38. package/voice/realtime.js +63 -0
  39. package/voice/records.js +96 -0
  40. package/voice/router-audio.js +26 -0
  41. package/voice/script.js +33 -12
  42. package/voice/service.js +185 -38
  43. package/voice/session.js +11 -1
  44. package/voice/speech.js +9 -3
package/voice/service.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { writeReport } from "../log/report.js";
2
+ import { createPcmResampler, pcmSamples } from "./realtime-audio.js";
3
+ import { realtimeModelIds, validatedRealtimeModels } from "./realtime.js";
1
4
  import { readConfig, validateConfigPatch, writeConfig } from "./config.js";
2
5
  import {
3
6
  chartModelId,
@@ -18,6 +21,7 @@ import {
18
21
  } from "./session.js";
19
22
  import { createCaptureSession } from "./capture.js";
20
23
  import { createDuplexCoordinator } from "./duplex.js";
24
+ import { createDeviceLease } from "./lease.js";
21
25
  import { normalizeSttPcm } from "./pcm.js";
22
26
  import { gapAfter, speechSegments } from "./script.js";
23
27
  import {
@@ -43,6 +47,57 @@ import {
43
47
  export { STT_MAX_AUDIO_BYTES };
44
48
 
45
49
  const duplex = createDuplexCoordinator(stopBluetoothPlayback);
50
+ const deviceLease = createDeviceLease();
51
+ const scopes = new Map();
52
+
53
+ export function voiceSessionPrompt(sessionId) {
54
+ return [...scopes.values()].some((scope) => scope.session?.snapshot().sessionId === sessionId && scope.active && Date.now() - scope.seen < 30_000);
55
+ }
56
+
57
+ export function voiceScope(id, owner = "", device = "headset") {
58
+ if (!/^[a-zA-Z0-9_-]{8,128}$/.test(id)) throw new VoiceError("voice_session_invalid", 400, "invalid voice connection");
59
+ const key = `${owner}:${id}`;
60
+ for (const [other, scope] of scopes) {
61
+ if (Date.now() - scope.seen > 120_000 && !deviceLease.owns(scope)) {
62
+ scopes.delete(other);
63
+ void scope.duplex.interrupt().catch(() => {});
64
+ scope.capture?.stop(true);
65
+ void scope.session?.close();
66
+ }
67
+ }
68
+ let scope = scopes.get(key);
69
+ if (!scope) {
70
+ if (scopes.size >= 128) throw new VoiceError("voice_busy", 429, "too many voice connections");
71
+ scope = { id, owner, device, seen: Date.now(), capture: null, duplex: null };
72
+ scope.duplex = createDuplexCoordinator(async () => {
73
+ if (deviceLease.owns(scope)) {
74
+ await stopBluetoothPlayback();
75
+ deviceLease.release(scope);
76
+ }
77
+ });
78
+ scopes.set(key, scope);
79
+ }
80
+ if (scope.device !== device) {
81
+ if (deviceLease.owns(scope)) throw new VoiceError("voice_device_busy", 409, "stop the current audio device first");
82
+ scope.device = device;
83
+ }
84
+ scope.seen = Date.now();
85
+ return scope;
86
+ }
87
+
88
+ function scopedPlayback(scope) {
89
+ if (scope.device === "device") return Promise.resolve({ skipped: true });
90
+ deviceLease.acquire(scope);
91
+ return openBluetoothPlayback({ waitForDrain: true }).then((player) => {
92
+ if (player.skipped) deviceLease.release(scope);
93
+ return {
94
+ ...player,
95
+ async write(chunk) { deviceLease.touch(scope); return player.write(chunk); },
96
+ async close() { try { return await player.close?.(); } finally { deviceLease.release(scope); } },
97
+ abort() { try { player.abort?.(); } finally { deviceLease.release(scope); } },
98
+ };
99
+ }, (error) => { deviceLease.release(scope); throw error; });
100
+ }
46
101
 
47
102
  export async function voiceStatusPayload(options) {
48
103
  const config = readConfig();
@@ -79,6 +134,9 @@ export async function voiceModelsPayload(options) {
79
134
  models: ids,
80
135
  stt,
81
136
  tts,
137
+ chat: models.filter((model) => model.mode === "chat").map((model) => model.id),
138
+ realtime: realtimeModelIds(models),
139
+ realtimeReady: validatedRealtimeModels(models),
82
140
  selected: pickSttModelId(models, config.model),
83
141
  ttsSelected: pickTtsModelId(models, config.ttsModel),
84
142
  charts,
@@ -87,8 +145,10 @@ export async function voiceModelsPayload(options) {
87
145
 
88
146
  export async function saveVoiceConfig(body) {
89
147
  const patch = validateConfigPatch(body);
90
- if (patch.model || patch.ttsModel) {
148
+ if (patch.model || patch.ttsModel || patch.realtimeModel || patch.backend === "realtime") {
91
149
  const models = await listModels();
150
+ if (patch.realtimeModel && !realtimeModelIds(models).includes(patch.realtimeModel)) throw new VoiceError("voice_model_unavailable", 400, "Realtime model is not available");
151
+ if (patch.backend === "realtime" && !validatedRealtimeModels(models).includes(patch.realtimeModel || readConfig().realtimeModel)) throw new VoiceError("voice_realtime_unavailable", 409, "Realtime model has not been validated");
92
152
  if (patch.model && !sttModelIds(models).includes(patch.model)) {
93
153
  throw new VoiceError("voice_model_unavailable", 400, "selected voice model is not available");
94
154
  }
@@ -228,7 +288,7 @@ export async function speakFromConfig(text, options = {}) {
228
288
  const done = key ? options.done === true : true;
229
289
  const segments = speechSegments(text, { done });
230
290
  const part = partIndex(options.part);
231
- const coordinator = options.coordinator ?? duplex;
291
+ const coordinator = options.coordinator ?? options.scope?.duplex ?? duplex;
232
292
  const id = key ?? text;
233
293
  let readout = joinable(readouts.get(coordinator), id, key, part);
234
294
  if (!readout) {
@@ -245,6 +305,8 @@ export async function speakFromConfig(text, options = {}) {
245
305
  if (part >= readout.segments.length) {
246
306
  throw new VoiceError("voice_config_invalid", 400, `answer has ${readout.segments.length} sentence(s)`);
247
307
  }
308
+ readout.requestedPart = Math.max(readout.requestedPart, part);
309
+ readout.extend([], false);
248
310
  return readout.parts[part].promise;
249
311
  }
250
312
 
@@ -253,13 +315,16 @@ function startReadout(coordinator, id, model, options) {
253
315
  let wake = () => {};
254
316
  const readout = {
255
317
  id,
318
+ createdAt: Date.now(),
319
+ firstAudio: false,
256
320
  active: lease.active,
257
321
  signal: lease.signal,
258
322
  segments: [],
259
323
  parts: [],
260
324
  final: false,
325
+ requestedPart: 0,
261
326
  extend(segments, done) {
262
- if (readout.final) return;
327
+ if (readout.final) { wake(); return; }
263
328
  for (const sentence of segments.slice(readout.segments.length)) {
264
329
  readout.segments.push(sentence);
265
330
  readout.parts.push(deferred());
@@ -283,7 +348,7 @@ function startReadout(coordinator, id, model, options) {
283
348
  });
284
349
  },
285
350
  };
286
- readout.result = pinnedVoice(model, options.listVoices)
351
+ readout.result = Promise.resolve(options.config?.voice || pinnedVoice(model, options.listVoices))
287
352
  .then((voice) => readAloud(readout, model, voice, lease, options));
288
353
  readout.result.catch(() => {});
289
354
  readouts.set(coordinator, readout);
@@ -291,9 +356,13 @@ function startReadout(coordinator, id, model, options) {
291
356
  }
292
357
 
293
358
  async function readAloud(readout, model, voice, lease, options) {
294
- const openPlayback = options.openPlayback ?? openBluetoothPlayback;
359
+ const openPlayback = options.openPlayback ?? (options.scope ? () => scopedPlayback(options.scope) : openBluetoothPlayback);
295
360
  const synthesize = options.synthesize ?? synthesizeStream;
296
- const playFile = options.playFile ?? playBluetoothAudio;
361
+ const playFile = options.playFile ?? (options.scope ? async (audio) => {
362
+ if (options.scope.device === "device") return { played: false };
363
+ deviceLease.acquire(options.scope);
364
+ try { return await playBluetoothAudio(audio); } finally { deviceLease.release(options.scope); }
365
+ } : playBluetoothAudio);
297
366
  const toWav = options.pcmToWav ?? pcmToWav;
298
367
  const gap = options.silencePcm ?? silencePcm;
299
368
  const idleMs = options.idleMs ?? STREAM_IDLE_MS;
@@ -321,10 +390,15 @@ async function readAloud(readout, model, voice, lease, options) {
321
390
  let feeding = Promise.resolve();
322
391
  const writeChunk = (chunk) => {
323
392
  if (!chunk?.length) return;
393
+ if (!readout.firstAudio) {
394
+ readout.firstAudio = true;
395
+ if (options.scope) writeReport({ scope: "voice", event: "metric", voiceId: options.scope.id, stage: "synthesis_first_audio", durationMs: Date.now() - readout.createdAt, queueDepth: readout.segments.length });
396
+ }
324
397
  pcm.push(chunk);
325
398
  sentence.push(chunk);
326
399
  if (!lease.active()) return;
327
- opening ??= openPlayback().catch(() => ({ skipped: true }));
400
+ opening ??= openPlayback();
401
+ opening.catch(() => {});
328
402
  feeding = feeding.then(async () => {
329
403
  player ??= await opening;
330
404
  if (player.skipped || !lease.active()) return;
@@ -335,6 +409,7 @@ async function readAloud(readout, model, voice, lease, options) {
335
409
  player.abort?.();
336
410
  }
337
411
  });
412
+ if (options.scope?.device === "headset") return feeding;
338
413
  };
339
414
  try {
340
415
  for (let index = 0; lease.active();) {
@@ -343,8 +418,12 @@ async function readAloud(readout, model, voice, lease, options) {
343
418
  if (!(await readout.more(idleMs))) readout.final = true;
344
419
  continue;
345
420
  }
421
+ if (options.key && index > readout.requestedPart + 1) {
422
+ if (!(await readout.more(idleMs))) throw new VoiceError("voice_playback_timeout", 408, "playback consumer stopped");
423
+ continue;
424
+ }
346
425
  sentence = [];
347
- if (index > 0) writeChunk(gap(gapAfter(readout.segments[index - 1])));
426
+ if (index > 0) await writeChunk(gap(gapAfter(readout.segments[index - 1])));
348
427
  const outcome = await synthesize({ text: readout.segments[index], model, voice, signal: lease.signal }, writeChunk);
349
428
  if (!outcome.streamed) {
350
429
  fileAudio = outcome.audio;
@@ -392,8 +471,10 @@ async function readAloud(readout, model, voice, lease, options) {
392
471
  return { bluetooth, audio };
393
472
  }
394
473
 
395
- export function interruptSpeech() {
396
- return duplex.interrupt();
474
+ export function playbackStatus(scope) { return { playing: (scope?.duplex ?? duplex).isSpeaking() }; }
475
+
476
+ export function interruptSpeech(scope) {
477
+ return (scope?.duplex ?? duplex).interrupt();
397
478
  }
398
479
 
399
480
  export function languageForTranscribe(requestedLanguage, configLanguage) {
@@ -401,47 +482,45 @@ export function languageForTranscribe(requestedLanguage, configLanguage) {
401
482
  return validateConfigPatch({ language: requestedLanguage }).language || undefined;
402
483
  }
403
484
 
404
- export async function transcribeFromHttp({ languageParam, contentType, bytes }) {
485
+ export async function transcribeFromHttp({ languageParam, contentType, bytes, signal }) {
405
486
  const config = readConfig();
406
487
  const text = await transcribeAudio({
407
488
  bytes,
408
489
  contentType,
409
490
  language: languageForTranscribe(languageParam, config.language),
410
491
  preferred: config.model,
492
+ signal,
411
493
  });
412
494
  return { text };
413
495
  }
414
496
 
415
- const hostCapture = createCaptureSession({
416
- open: beginHostCapture,
417
- read: (from) => readHostCapture(from),
418
- close: endHostCapture,
419
- reset: resetHostCapture,
420
- transcribe(wav, signal) {
421
- const config = readConfig();
422
- return transcribeAudio({
423
- bytes: wav,
424
- contentType: "audio/wav",
425
- language: languageForTranscribe(null, config.language),
426
- preferred: config.model,
427
- signal,
428
- });
429
- },
430
- });
431
-
432
- export async function captureStart() {
433
- await interruptSpeech();
434
- return hostCapture.start();
497
+ function createHostCapture(scope) {
498
+ return createCaptureSession({
499
+ async open() {
500
+ deviceLease.acquire(scope);
501
+ try { const opened = await beginHostCapture(); if (!opened.ready) deviceLease.release(scope); return opened; } catch (error) { deviceLease.release(scope); throw error; }
502
+ },
503
+ read(from) { if (!deviceLease.owns(scope)) throw new VoiceError("voice_device_busy", 409, "audio lease expired"); deviceLease.touch(scope); return readHostCapture(from); },
504
+ async close() { if (!deviceLease.owns(scope)) return; try { await endHostCapture(); } finally { deviceLease.release(scope); } },
505
+ reset: resetHostCapture,
506
+ transcribe(wav, signal) {
507
+ const config = readConfig();
508
+ return transcribeAudio({ bytes: wav, contentType: "audio/wav", language: languageForTranscribe(null, config.language), preferred: config.model, signal });
509
+ },
510
+ });
435
511
  }
436
-
437
- export function captureStop(cancel) {
438
- hostCapture.stop(cancel);
439
- return hostCapture.status();
512
+ const defaultScope = { duplex };
513
+ function captureFor(scope = defaultScope) { return scope.capture ??= createHostCapture(scope); }
514
+ export async function captureStart(scope = defaultScope) {
515
+ deviceLease.acquire(scope);
516
+ await interruptSpeech(scope);
517
+ return captureFor(scope).start();
440
518
  }
441
-
442
- export function captureStatus() {
443
- return hostCapture.status();
519
+ export function captureStop(cancel, scope = defaultScope) {
520
+ captureFor(scope).stop(cancel);
521
+ return captureFor(scope).status();
444
522
  }
523
+ export function captureStatus(scope = defaultScope) { return captureFor(scope).status(); }
445
524
 
446
525
  export async function transcribeAudio({ bytes, contentType, language, preferred, signal }) {
447
526
  const model = await resolveSttModel(preferred);
@@ -450,6 +529,7 @@ export async function transcribeAudio({ bytes, contentType, language, preferred,
450
529
  }
451
530
  if (bytes.length === 0) throw new VoiceError("voice_audio_unreadable", 400, "audio content is empty");
452
531
  const pcm = await normalizeSttPcm(bytes, contentType);
532
+ signal?.throwIfAborted();
453
533
  return transcribe({
454
534
  bytes: pcm,
455
535
  filename: "voice.wav",
@@ -460,3 +540,70 @@ export async function transcribeAudio({ bytes, contentType, language, preferred,
460
540
  signal,
461
541
  });
462
542
  }
543
+
544
+
545
+ /** Host input and output share one physical lease; half-duplex is intentional. */
546
+ export async function openRealtimeHostAudio(scope, send) {
547
+ if (scope.device !== "headset") return null;
548
+ deviceLease.acquire(scope);
549
+ let closed = false;
550
+ let capturing = false;
551
+ let inputPaused = true;
552
+ let offset = 0;
553
+ let player = null;
554
+ let timer = null;
555
+ let generation = 0;
556
+ const convert = createPcmResampler(16000);
557
+ const start = async () => {
558
+ if (closed || capturing || inputPaused) return;
559
+ const opened = await beginHostCapture();
560
+ if (!opened.ready) throw new VoiceError("voice_bluetooth_not_ready", 503, "headset is not ready");
561
+ capturing = true; offset = opened.offset;
562
+ };
563
+ const read = async () => {
564
+ if (closed) return;
565
+ if (!deviceLease.owns(scope)) throw new VoiceError("voice_device_busy", 409, "audio lease expired");
566
+ deviceLease.touch(scope);
567
+ if (capturing) {
568
+ const bytes = await readHostCapture(offset, { maxBytes: 32_000 });
569
+ offset += bytes.length;
570
+ if (capturing && bytes.length) send({ type: "input_audio_buffer.append", audio: Buffer.from(convert(pcmSamples(bytes))).toString("base64") });
571
+ }
572
+ if (!closed) timer = setTimeout(() => { void read().catch(() => scope.realtimeSocket?.close()); }, 100);
573
+ };
574
+ try { await start(); void read(); }
575
+ catch (error) { deviceLease.release(scope); throw error; }
576
+ return {
577
+ async pauseInput() { inputPaused = true; if (capturing) { capturing = false; await endHostCapture(); } },
578
+ async resumeInput() { inputPaused = false; if (!player) await start(); },
579
+ async write(bytes) {
580
+ if (closed) return;
581
+ if (capturing) { capturing = false; await endHostCapture(); }
582
+ if (!player) {
583
+ player = await openBluetoothPlayback({ waitForDrain: true });
584
+ if (player.skipped) throw new VoiceError("voice_device_unavailable", 503, "headset speaker is unavailable");
585
+ }
586
+ deviceLease.touch(scope);
587
+ await player.write(bytes);
588
+ },
589
+ async finish() {
590
+ const epoch = generation;
591
+ const current = player; player = null;
592
+ await current?.close();
593
+ if (!closed && epoch === generation) await start();
594
+ },
595
+ async interrupt() {
596
+ generation++;
597
+ player?.abort(); player = null;
598
+ await stopBluetoothPlayback();
599
+ if (!closed) await start();
600
+ },
601
+ async stop() {
602
+ if (closed) return;
603
+ closed = true; generation++; clearTimeout(timer);
604
+ player?.abort(); player = null;
605
+ try { if (deviceLease.owns(scope)) { await endHostCapture(); await stopBluetoothPlayback(); } }
606
+ finally { deviceLease.release(scope); }
607
+ },
608
+ };
609
+ }
package/voice/session.js CHANGED
@@ -65,6 +65,16 @@ export async function openBluetoothPlayback(options = {}) {
65
65
  });
66
66
  stream.end();
67
67
  });
68
+ if (options.waitForDrain) {
69
+ const until = Date.now() + 120_000;
70
+ while (true) {
71
+ const state = await askAgent("playback-status", {}, options);
72
+ if (state.error) throw new VoiceError("voice_host_upgrade_required", 409, "update the Lares host audio helper");
73
+ if (!state.playing) break;
74
+ if (Date.now() >= until) throw new VoiceError("voice_playback_timeout", 408, "host playback did not finish");
75
+ await new Promise((resolve) => setTimeout(resolve, 100));
76
+ }
77
+ }
68
78
  },
69
79
  abort() {
70
80
  stream.destroy();
@@ -102,7 +112,7 @@ export async function readHostCapture(from = 0, options = {}) {
102
112
  try {
103
113
  handle = await open(`${voiceDir(options)}/${CAPTURE_FILE}`, "r");
104
114
  const { size } = await handle.stat();
105
- const length = size - start;
115
+ const length = Math.min(size - start, options.maxBytes ?? 64 * 1024);
106
116
  if (length <= 0) return Buffer.alloc(0);
107
117
  const buf = Buffer.alloc(length);
108
118
  const { bytesRead } = await handle.read(buf, 0, length, start);
package/voice/speech.js CHANGED
@@ -165,7 +165,7 @@ const BOUNDARY = /[.!?;。!?;…\n]/;
165
165
  * fail?: (err: unknown) => void,
166
166
  * }} io
167
167
  */
168
- export function createSentencePlayer({ send, play, halt, silence, fail = () => {} }) {
168
+ export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {} }) {
169
169
  let gen = 0;
170
170
  let current = null;
171
171
  // An utterance is read once: finishing it, failing, or being stopped all end it for good.
@@ -177,6 +177,7 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
177
177
  gen += 1;
178
178
  run.wake?.();
179
179
  current = null;
180
+ onState(false);
180
181
  if (err?.code !== "voice_cancelled") fail(err);
181
182
  };
182
183
 
@@ -219,7 +220,7 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
219
220
  await play(sentence);
220
221
  part += 1;
221
222
  }
222
- if (run.gen === gen) current = null;
223
+ if (run.gen === gen) { current = null; onState(false); }
223
224
  };
224
225
 
225
226
  const stop = () => {
@@ -227,6 +228,7 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
227
228
  current?.wake?.();
228
229
  if (current) halt();
229
230
  current = null;
231
+ onState(false);
230
232
  };
231
233
 
232
234
  return {
@@ -258,6 +260,7 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
258
260
  wake: null,
259
261
  };
260
262
  const run = current;
263
+ onState(true);
261
264
  handOver(run);
262
265
  read(run).catch((err) => failed(run, err));
263
266
  return;
@@ -266,6 +269,9 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
266
269
  current.done = utterance.done;
267
270
  handOver(current);
268
271
  },
269
- stop,
272
+ stop() {
273
+ stop();
274
+ silence();
275
+ },
270
276
  };
271
277
  }