@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.
- package/i18n/voice.js +180 -10
- package/larepass/runtime.js +2 -0
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +7 -0
- package/package.json +28 -2
- package/router/tts.js +1 -1
- package/tools/http.js +3 -2
- package/voice/agent-session.js +134 -0
- package/voice/agent.js +9 -1
- package/voice/browser-capture.js +274 -0
- package/voice/browser-playback.js +30 -0
- package/voice/capture.js +12 -2
- package/voice/client.js +29 -14
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +28 -18
- package/voice/controller.js +154 -0
- package/voice/conversation.js +135 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +99 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +147 -59
- package/voice/gpu-problem.js +47 -0
- package/voice/host/bluetooth-ready.py +2 -0
- package/voice/host-input.js +38 -0
- package/voice/host-service.js +216 -0
- package/voice/input.js +2 -1
- package/voice/install.js +9 -5
- package/voice/languages.js +1 -1
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/panel.js +88 -0
- package/voice/projection.js +67 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +126 -0
- package/voice/realtime-gateway.js +168 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +96 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +33 -12
- package/voice/service.js +185 -38
- package/voice/session.js +11 -1
- 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 ??
|
|
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()
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
preferred: config.model,
|
|
427
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
return
|
|
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
|
}
|