@olares/lares-core 0.15.10 → 0.15.12

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 (45) hide show
  1. package/i18n/voice.js +246 -13
  2. package/larepass/host.js +6 -0
  3. package/larepass/mux.js +4 -3
  4. package/larepass/runtime.js +5 -3
  5. package/larepass/settings.js +3 -0
  6. package/larepass/transcript.js +4 -6
  7. package/olares/trusted-host.js +11 -1
  8. package/package.json +27 -2
  9. package/router/tts.js +25 -1
  10. package/tools/http.js +27 -1
  11. package/voice/agent-session.js +133 -0
  12. package/voice/agent.js +9 -1
  13. package/voice/browser-capture.js +274 -0
  14. package/voice/browser-playback.js +30 -0
  15. package/voice/capture.js +12 -2
  16. package/voice/client.js +71 -9
  17. package/voice/composite-gateway.js +156 -0
  18. package/voice/config.js +30 -17
  19. package/voice/controller.js +147 -0
  20. package/voice/conversation.js +131 -0
  21. package/voice/device-input.js +79 -0
  22. package/voice/dialogue.js +71 -0
  23. package/voice/duplex.js +1 -1
  24. package/voice/external.js +26 -0
  25. package/voice/gpu-mode.js +460 -0
  26. package/voice/gpu-problem.js +47 -0
  27. package/voice/host/bluetooth-ready.py +2 -0
  28. package/voice/host-input.js +38 -0
  29. package/voice/host-service.js +171 -0
  30. package/voice/input.js +2 -1
  31. package/voice/install.js +33 -12
  32. package/voice/languages.js +2 -6
  33. package/voice/lease.js +16 -0
  34. package/voice/options.js +23 -0
  35. package/voice/protocol-client.js +24 -0
  36. package/voice/realtime-audio.js +107 -0
  37. package/voice/realtime-conversation.js +122 -0
  38. package/voice/realtime-gateway.js +162 -0
  39. package/voice/realtime.js +63 -0
  40. package/voice/records.js +72 -0
  41. package/voice/router-audio.js +26 -0
  42. package/voice/script.js +85 -15
  43. package/voice/service.js +340 -57
  44. package/voice/session.js +11 -1
  45. package/voice/speech.js +228 -44
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,9 +21,17 @@ 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
- import { speechSegments } from "./script.js";
23
- import { pcmToWav, silencePcm, synthesize, synthesizeStream } from "../router/tts.js";
26
+ import { gapAfter, speechSegments } from "./script.js";
27
+ import {
28
+ listTtsVoices,
29
+ pcmToWav,
30
+ pickTtsVoice,
31
+ silencePcm,
32
+ synthesize,
33
+ synthesizeStream,
34
+ } from "../router/tts.js";
24
35
  import {
25
36
  STT_MAX_AUDIO_BYTES,
26
37
  VoiceError,
@@ -36,6 +47,57 @@ import {
36
47
  export { STT_MAX_AUDIO_BYTES };
37
48
 
38
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
+ }
39
101
 
40
102
  export async function voiceStatusPayload(options) {
41
103
  const config = readConfig();
@@ -72,6 +134,9 @@ export async function voiceModelsPayload(options) {
72
134
  models: ids,
73
135
  stt,
74
136
  tts,
137
+ chat: models.filter((model) => model.mode === "chat").map((model) => model.id),
138
+ realtime: realtimeModelIds(models),
139
+ realtimeReady: validatedRealtimeModels(models),
75
140
  selected: pickSttModelId(models, config.model),
76
141
  ttsSelected: pickTtsModelId(models, config.ttsModel),
77
142
  charts,
@@ -80,8 +145,10 @@ export async function voiceModelsPayload(options) {
80
145
 
81
146
  export async function saveVoiceConfig(body) {
82
147
  const patch = validateConfigPatch(body);
83
- if (patch.model || patch.ttsModel) {
148
+ if (patch.model || patch.ttsModel || patch.realtimeModel || patch.backend === "realtime") {
84
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");
85
152
  if (patch.model && !sttModelIds(models).includes(patch.model)) {
86
153
  throw new VoiceError("voice_model_unavailable", 400, "selected voice model is not available");
87
154
  }
@@ -155,52 +222,183 @@ function cancelledSpeech() {
155
222
 
156
223
  const readouts = new WeakMap();
157
224
 
225
+ /** model id → pinned voice. A failed lookup is not cached, so the next readout asks again. */
226
+ const pinnedVoices = new Map();
227
+
228
+ async function pinnedVoice(model, listVoices = listTtsVoices) {
229
+ if (pinnedVoices.has(model)) return pinnedVoices.get(model);
230
+ let voice = null;
231
+ try {
232
+ voice = pickTtsVoice(await listVoices(model));
233
+ } catch {
234
+ return null;
235
+ }
236
+ if (voice) pinnedVoices.set(model, voice);
237
+ return voice;
238
+ }
239
+
240
+ function deferred() {
241
+ let resolve;
242
+ let reject;
243
+ const promise = new Promise((ok, fail) => {
244
+ resolve = ok;
245
+ reject = fail;
246
+ });
247
+ promise.catch(() => {});
248
+ return { promise, resolve, reject };
249
+ }
250
+
251
+ function partIndex(value) {
252
+ return Number.isInteger(value) && value >= 0 ? value : null;
253
+ }
254
+
255
+ /** How long a readout of a streaming answer waits for its next sentence before it lets the headset go. */
256
+ const STREAM_IDLE_MS = 30_000;
257
+
258
+ function joinable(current, id, key, part) {
259
+ if (current?.id !== id) return null;
260
+ if (key && current.signal.aborted) throw cancelledSpeech();
261
+ if (key || part > 0) return current.signal.aborted ? null : current;
262
+ return current.active() ? current : null;
263
+ }
264
+
158
265
  /**
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.
266
+ * Every open page asks to read the same answer. A request for the readout
267
+ * already under way joins it instead of restarting the headset and cancelling
268
+ * the page that asked first.
269
+ *
270
+ * `options.key` names an answer that is still being written: each request
271
+ * carries the text so far, and `options.done` once it is complete. The readout
272
+ * appends the sentences it has not seen yet, so the headset reads on without a
273
+ * restart. A keyed request without `options.part` only hands over the text.
274
+ * Once interrupted, a key stays cancelled.
275
+ *
276
+ * `options.part` asks for one sentence instead of the whole answer, so a page
277
+ * can play it while the rest is still being written or synthesized. A later
278
+ * sentence joins its readout even after synthesis has finished.
279
+ *
280
+ * Without a key the text is the whole answer and names its readout.
281
+ *
282
+ * @returns {Promise<{ bluetooth: string, audio: Buffer } | { sentences: number }>}
162
283
  */
163
284
  export async function speakFromConfig(text, options = {}) {
164
285
  const config = options.config ?? readConfig();
165
286
  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");
170
- const coordinator = options.coordinator ?? duplex;
171
- const current = readouts.get(coordinator);
172
- if (current?.text === text && current.active()) return current.result;
287
+ const key = typeof options.key === "string" && options.key ? options.key : null;
288
+ const done = key ? options.done === true : true;
289
+ const segments = speechSegments(text, { done });
290
+ const part = partIndex(options.part);
291
+ const coordinator = options.coordinator ?? options.scope?.duplex ?? duplex;
292
+ const id = key ?? text;
293
+ let readout = joinable(readouts.get(coordinator), id, key, part);
294
+ if (!readout) {
295
+ if (!segments.length) {
296
+ if (key && part === null) return { sentences: 0 };
297
+ throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
298
+ }
299
+ const model = options.model ?? await pickTtsModelId(await listModels(), config.ttsModel);
300
+ if (!model) throw new VoiceError("voice_tts_unavailable", 503, "no speech model available");
301
+ readout = joinable(readouts.get(coordinator), id, key, part) ?? startReadout(coordinator, id, model, options);
302
+ }
303
+ readout.extend(segments, done);
304
+ if (part === null) return key ? { sentences: readout.segments.length } : readout.result;
305
+ if (part >= readout.segments.length) {
306
+ throw new VoiceError("voice_config_invalid", 400, `answer has ${readout.segments.length} sentence(s)`);
307
+ }
308
+ readout.requestedPart = Math.max(readout.requestedPart, part);
309
+ readout.extend([], false);
310
+ return readout.parts[part].promise;
311
+ }
312
+
313
+ function startReadout(coordinator, id, model, options) {
173
314
  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;
315
+ let wake = () => {};
316
+ const readout = {
317
+ id,
318
+ createdAt: Date.now(),
319
+ firstAudio: false,
320
+ active: lease.active,
321
+ signal: lease.signal,
322
+ segments: [],
323
+ parts: [],
324
+ final: false,
325
+ requestedPart: 0,
326
+ extend(segments, done) {
327
+ if (readout.final) { wake(); return; }
328
+ for (const sentence of segments.slice(readout.segments.length)) {
329
+ readout.segments.push(sentence);
330
+ readout.parts.push(deferred());
331
+ }
332
+ if (done) readout.final = true;
333
+ wake();
334
+ },
335
+ /** true once more text arrives or the readout is cancelled, false after `idleMs` of silence. */
336
+ more(idleMs) {
337
+ return new Promise((resolve) => {
338
+ const settle = (value) => {
339
+ clearTimeout(timer);
340
+ lease.signal.removeEventListener("abort", woken);
341
+ wake = () => {};
342
+ resolve(value);
343
+ };
344
+ const woken = () => settle(true);
345
+ const timer = setTimeout(() => settle(false), idleMs);
346
+ wake = woken;
347
+ lease.signal.addEventListener("abort", woken, { once: true });
348
+ });
349
+ },
350
+ };
351
+ readout.result = Promise.resolve(options.config?.voice || pinnedVoice(model, options.listVoices))
352
+ .then((voice) => readAloud(readout, model, voice, lease, options));
353
+ readout.result.catch(() => {});
354
+ readouts.set(coordinator, readout);
355
+ return readout;
177
356
  }
178
357
 
179
- async function readAloud(segments, model, lease, options) {
180
- const openPlayback = options.openPlayback ?? openBluetoothPlayback;
358
+ async function readAloud(readout, model, voice, lease, options) {
359
+ const openPlayback = options.openPlayback ?? (options.scope ? () => scopedPlayback(options.scope) : openBluetoothPlayback);
181
360
  const synthesize = options.synthesize ?? synthesizeStream;
182
- 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);
183
366
  const toWav = options.pcmToWav ?? pcmToWav;
184
367
  const gap = options.silencePcm ?? silencePcm;
368
+ const idleMs = options.idleMs ?? STREAM_IDLE_MS;
185
369
  let opening;
186
370
  let player;
187
371
  const abortPlayer = () => player?.abort?.();
188
372
  lease.signal.addEventListener("abort", abortPlayer);
189
373
  const pcm = [];
374
+ let sentence = [];
190
375
  let streamed = false;
191
376
  let fileAudio;
192
377
  const release = () => {
193
378
  lease.signal.removeEventListener("abort", abortPlayer);
194
379
  lease.finish();
195
380
  };
381
+ const dropParts = (err) => {
382
+ for (const part of readout.parts) part.reject(err);
383
+ };
384
+ const headsetState = async () => {
385
+ const opened = opening ? await opening : null;
386
+ return opened && !opened.skipped ? "played" : "skipped";
387
+ };
196
388
  // The speaker drains in real time; synthesis must not wait on it, or the
197
389
  // browser copy arrives only after the headset has finished.
198
390
  let feeding = Promise.resolve();
199
391
  const writeChunk = (chunk) => {
200
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
+ }
201
397
  pcm.push(chunk);
398
+ sentence.push(chunk);
202
399
  if (!lease.active()) return;
203
- opening ??= openPlayback().catch(() => ({ skipped: true }));
400
+ opening ??= openPlayback();
401
+ opening.catch(() => {});
204
402
  feeding = feeding.then(async () => {
205
403
  player ??= await opening;
206
404
  if (player.skipped || !lease.active()) return;
@@ -211,31 +409,48 @@ async function readAloud(segments, model, lease, options) {
211
409
  player.abort?.();
212
410
  }
213
411
  });
412
+ if (options.scope?.device === "headset") return feeding;
214
413
  };
215
414
  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);
415
+ for (let index = 0; lease.active();) {
416
+ if (index >= readout.segments.length) {
417
+ if (readout.final) break;
418
+ if (!(await readout.more(idleMs))) readout.final = true;
419
+ continue;
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
+ }
425
+ sentence = [];
426
+ if (index > 0) await writeChunk(gap(gapAfter(readout.segments[index - 1])));
427
+ const outcome = await synthesize({ text: readout.segments[index], model, voice, signal: lease.signal }, writeChunk);
219
428
  if (!outcome.streamed) {
220
429
  fileAudio = outcome.audio;
430
+ readout.parts[0].resolve({ audio: fileAudio, bluetooth: "skipped" });
431
+ readout.final = true;
221
432
  break;
222
433
  }
223
434
  streamed = true;
224
- if (segment.gapMs > 0 && lease.active()) writeChunk(gap(segment.gapMs));
435
+ readout.parts[index].resolve({ audio: toWav(Buffer.concat(sentence)), bluetooth: await headsetState() });
436
+ index += 1;
225
437
  }
226
438
  } catch (err) {
227
439
  void opening?.then((opened) => opened.abort?.());
228
440
  release();
229
- if (lease.signal.aborted) throw cancelledSpeech();
230
- throw err;
441
+ const failure = lease.signal.aborted ? cancelledSpeech() : err;
442
+ dropParts(failure);
443
+ throw failure;
231
444
  }
232
- const audio = streamed ? toWav(Buffer.concat(pcm)) : fileAudio;
233
445
  const headset = opening ? await opening : null;
234
446
  if (!lease.active()) {
235
447
  headset?.abort?.();
236
448
  release();
449
+ dropParts(cancelledSpeech());
237
450
  throw cancelledSpeech();
238
451
  }
452
+ if (!streamed) dropParts(new VoiceError("voice_config_invalid", 400, "this engine returns the answer whole"));
453
+ const audio = streamed ? toWav(Buffer.concat(pcm)) : fileAudio;
239
454
  if (streamed && headset && !headset.skipped) {
240
455
  void feeding
241
456
  .then(() => (headset.skipped || !lease.active() ? headset.abort?.() : headset.close()))
@@ -256,8 +471,10 @@ async function readAloud(segments, model, lease, options) {
256
471
  return { bluetooth, audio };
257
472
  }
258
473
 
259
- export function interruptSpeech() {
260
- 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();
261
478
  }
262
479
 
263
480
  export function languageForTranscribe(requestedLanguage, configLanguage) {
@@ -265,47 +482,45 @@ export function languageForTranscribe(requestedLanguage, configLanguage) {
265
482
  return validateConfigPatch({ language: requestedLanguage }).language || undefined;
266
483
  }
267
484
 
268
- export async function transcribeFromHttp({ languageParam, contentType, bytes }) {
485
+ export async function transcribeFromHttp({ languageParam, contentType, bytes, signal }) {
269
486
  const config = readConfig();
270
487
  const text = await transcribeAudio({
271
488
  bytes,
272
489
  contentType,
273
490
  language: languageForTranscribe(languageParam, config.language),
274
491
  preferred: config.model,
492
+ signal,
275
493
  });
276
494
  return { text };
277
495
  }
278
496
 
279
- const hostCapture = createCaptureSession({
280
- open: beginHostCapture,
281
- read: (from) => readHostCapture(from),
282
- close: endHostCapture,
283
- reset: resetHostCapture,
284
- transcribe(wav, signal) {
285
- const config = readConfig();
286
- return transcribeAudio({
287
- bytes: wav,
288
- contentType: "audio/wav",
289
- language: languageForTranscribe(null, config.language),
290
- preferred: config.model,
291
- signal,
292
- });
293
- },
294
- });
295
-
296
- export async function captureStart() {
297
- await interruptSpeech();
298
- 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
+ });
299
511
  }
300
-
301
- export function captureStop(cancel) {
302
- hostCapture.stop(cancel);
303
- 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();
304
518
  }
305
-
306
- export function captureStatus() {
307
- return hostCapture.status();
519
+ export function captureStop(cancel, scope = defaultScope) {
520
+ captureFor(scope).stop(cancel);
521
+ return captureFor(scope).status();
308
522
  }
523
+ export function captureStatus(scope = defaultScope) { return captureFor(scope).status(); }
309
524
 
310
525
  export async function transcribeAudio({ bytes, contentType, language, preferred, signal }) {
311
526
  const model = await resolveSttModel(preferred);
@@ -314,6 +529,7 @@ export async function transcribeAudio({ bytes, contentType, language, preferred,
314
529
  }
315
530
  if (bytes.length === 0) throw new VoiceError("voice_audio_unreadable", 400, "audio content is empty");
316
531
  const pcm = await normalizeSttPcm(bytes, contentType);
532
+ signal?.throwIfAborted();
317
533
  return transcribe({
318
534
  bytes: pcm,
319
535
  filename: "voice.wav",
@@ -324,3 +540,70 @@ export async function transcribeAudio({ bytes, contentType, language, preferred,
324
540
  signal,
325
541
  });
326
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);