@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.
- package/i18n/voice.js +246 -13
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +5 -3
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +4 -6
- package/olares/trusted-host.js +11 -1
- package/package.json +27 -2
- package/router/tts.js +25 -1
- package/tools/http.js +27 -1
- package/voice/agent-session.js +133 -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 +71 -9
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +30 -17
- package/voice/controller.js +147 -0
- package/voice/conversation.js +131 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +71 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +460 -0
- 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 +171 -0
- package/voice/input.js +2 -1
- package/voice/install.js +33 -12
- package/voice/languages.js +2 -6
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +122 -0
- package/voice/realtime-gateway.js +162 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +72 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +85 -15
- package/voice/service.js +340 -57
- package/voice/session.js +11 -1
- 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 {
|
|
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
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const coordinator = options.coordinator ?? duplex;
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
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 ??
|
|
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()
|
|
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 (
|
|
217
|
-
if (
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
preferred: config.model,
|
|
291
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
return
|
|
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);
|