@olares/lares-core 0.15.28 → 0.15.30
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 +14 -0
- package/package.json +4 -2
- package/voice/browser-capture.js +8 -1
- package/voice/controller.js +35 -4
- package/voice/device-input.js +17 -43
- package/voice/endpoint.js +58 -2
- package/voice/errors.js +40 -0
- package/voice/host-service.js +27 -11
- package/voice/lares-realtime-gateway.js +90 -18
- package/voice/panel.js +13 -1
- package/voice/pcm-player.js +55 -8
- package/voice/presentation.js +11 -0
- package/voice/protocol-client.js +12 -4
- package/voice/service.js +19 -3
- package/voice/speech.js +7 -1
- package/voice/stream-client.js +16 -1
- package/voice/stream-gateway.js +19 -4
- package/voice/synthesis-run.js +11 -1
- package/voice/unified-conversation.js +84 -18
- package/voice/vad.js +169 -0
|
@@ -16,6 +16,13 @@ const SPEECH_RMS = 0.004;
|
|
|
16
16
|
// and a suspected interruption that never resolves stops the reply.
|
|
17
17
|
const BARGE_IN_MS = 500;
|
|
18
18
|
const BARGE_IN_LIMIT_MS = 10_000;
|
|
19
|
+
// How far ahead of the client's actual playout the Host may send. The client's
|
|
20
|
+
// AudioWorklet keeps a bounded queue; sending a whole answer at network speed
|
|
21
|
+
// overflowed it and dropped audio.
|
|
22
|
+
const PLAY_AHEAD_MS = 2_000;
|
|
23
|
+
// A temporarily interrupted AudioContext (device switch, lock screen) must not
|
|
24
|
+
// end a reply; only a client that stays silent this long is treated as stuck.
|
|
25
|
+
const PLAYOUT_STALL_MS = 30_000;
|
|
19
26
|
const TICKET_MS = 30_000;
|
|
20
27
|
// Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
|
|
21
28
|
const ZERO_USAGE = Object.freeze({ total_tokens: 0, input_tokens: 0, output_tokens: 0, input_token_details: { text_tokens: 0, audio_tokens: 0, cached_tokens: 0 }, output_token_details: { text_tokens: 0, audio_tokens: 0 } });
|
|
@@ -46,7 +53,7 @@ export function joinSpoken(parts) {
|
|
|
46
53
|
* `lares-realtime-v1` (docs/realtime-protocol.md) on one endpoint. Bearer voice-key callers get a
|
|
47
54
|
* scope owned by the socket; ticket sockets attach to the Lares client's open voice scope.
|
|
48
55
|
*/
|
|
49
|
-
export function createLaresRealtimeGateway({ service, config, transcribe, synthesize, openTranscription, keys, hostOwner = "", hostAudio, busyDefault = () => "queue", bargeIn = () => process.env.LARES_VOICE_BARGE_IN === "1", segment = speechSegments, onError = () => {}, onOpen = () => {}, limit = 64 }) {
|
|
56
|
+
export function createLaresRealtimeGateway({ service, config, transcribe, synthesize, openTranscription, keys, hostOwner = "", hostAudio, busyDefault = () => "queue", bargeIn = () => process.env.LARES_VOICE_BARGE_IN === "1", segment = speechSegments, onError = () => {}, onOpen = () => {}, limit = 64, playbackAckGraceMs = 60_000 }) {
|
|
50
57
|
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
|
|
51
58
|
const clients = new Map();
|
|
52
59
|
const tickets = new Map();
|
|
@@ -61,14 +68,31 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
61
68
|
let closed = false, busy = false, pendingCommit = false, configuring = false, alive = true, generation = 0, submittedTurns = 0;
|
|
62
69
|
let response = null, awaitingPlayback = false, acknowledges = false, lastReply = null, lastSpoken = null, turn = null, port = null, unbind = () => {};
|
|
63
70
|
let hostOutput = Promise.resolve();
|
|
71
|
+
let ackTimer = null;
|
|
64
72
|
const tasks = new Map();
|
|
65
73
|
const spoken = new Map();
|
|
66
74
|
const played = new Map();
|
|
67
75
|
|
|
76
|
+
// A client that vanishes in the middle of a reply never acknowledges it.
|
|
77
|
+
// Without a watchdog the half-duplex gate stays closed for that connection
|
|
78
|
+
// and every later utterance fails with waiting_for_playback.
|
|
79
|
+
const PLAYBACK_ACK_GRACE_MS = playbackAckGraceMs;
|
|
80
|
+
const clearAckWatch = () => { if (ackTimer) { clearTimeout(ackTimer); ackTimer = null; } };
|
|
81
|
+
const watchAck = () => {
|
|
82
|
+
clearAckWatch();
|
|
83
|
+
if (!awaitingPlayback || closed) return;
|
|
84
|
+
ackTimer = setTimeout(() => {
|
|
85
|
+
ackTimer = null;
|
|
86
|
+
if (closed || !awaitingPlayback) return;
|
|
87
|
+
awaitingPlayback = false;
|
|
88
|
+
send({ type: "lares.playback.timeout" });
|
|
89
|
+
}, PLAYBACK_ACK_GRACE_MS);
|
|
90
|
+
ackTimer.unref?.();
|
|
91
|
+
};
|
|
92
|
+
|
|
68
93
|
const bargeActive = () => type === "realtime" && !headset && echoCancellation && bargeIn() === true;
|
|
69
94
|
const send = (event) => {
|
|
70
|
-
if (client.readyState !== 1) return;
|
|
71
|
-
if (client.bufferedAmount > 1024 * 1024) { close(); return; }
|
|
95
|
+
if (closed || client.readyState !== 1) return;
|
|
72
96
|
client.send(JSON.stringify({ event_id: `event_${randomUUID()}`, ...event }));
|
|
73
97
|
};
|
|
74
98
|
const report = (cause, eventId) => {
|
|
@@ -98,6 +122,9 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
98
122
|
};
|
|
99
123
|
const finish = (current, status) => {
|
|
100
124
|
if (response === current) response = null;
|
|
125
|
+
// Audio that never left the server will not be acknowledged. Holding the
|
|
126
|
+
// gate here is how a failed or empty reply silences every later one.
|
|
127
|
+
if (!current.outputMs) { awaitingPlayback = false; clearAckWatch(); }
|
|
101
128
|
if (current.done) return;
|
|
102
129
|
current.done = true;
|
|
103
130
|
remember(current);
|
|
@@ -121,6 +148,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
121
148
|
current.suspended?.settle("stop");
|
|
122
149
|
if (headset) { hostOutput = Promise.resolve(); void Promise.resolve(port?.interrupt()).catch(() => close()); send({ type: "lares.device.playback", playing: false }); }
|
|
123
150
|
awaitingPlayback = false;
|
|
151
|
+
clearAckWatch();
|
|
124
152
|
finish(current, status);
|
|
125
153
|
if (truncate) truncateTo(current, played.get(current.itemId) ?? 0);
|
|
126
154
|
};
|
|
@@ -132,11 +160,36 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
132
160
|
current.outputMs += audio.length / BYTES_PER_MS;
|
|
133
161
|
return hostOutput;
|
|
134
162
|
}
|
|
135
|
-
if (acknowledges) awaitingPlayback = true;
|
|
163
|
+
if (acknowledges) { awaitingPlayback = true; watchAck(); }
|
|
164
|
+
// A long reply is several seconds of PCM. Dumping it in one burst fills the
|
|
165
|
+
// socket and used to close the call, which cut the readout off and made the
|
|
166
|
+
// client's next progress tick throw voice_disconnected. Wait for the buffer
|
|
167
|
+
// the way the speech stream does; a stuck client fails this reply only.
|
|
168
|
+
const OUTPUT_BUFFER = 128 * 1024;
|
|
169
|
+
let sentAt = Date.now();
|
|
170
|
+
let lastPlayed = played.get(current.itemId) ?? 0;
|
|
171
|
+
let playedAt = Date.now();
|
|
136
172
|
for (let offset = 0; offset < audio.length; offset += CHUNK_BYTES) {
|
|
173
|
+
while (client.bufferedAmount > OUTPUT_BUFFER) {
|
|
174
|
+
if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
|
|
175
|
+
if (Date.now() - sentAt > 10_000) throw new Error("voice_audio_backpressure");
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
177
|
+
}
|
|
178
|
+
// Playout credit: never get more than two seconds ahead of what the
|
|
179
|
+
// client has actually rendered. Progress advances in real time, so a
|
|
180
|
+
// long answer waits here instead of filling the worklet queue.
|
|
181
|
+
while (acknowledges && current.outputMs - lastPlayed > PLAY_AHEAD_MS) {
|
|
182
|
+
if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
|
|
183
|
+
const heard = played.get(current.itemId) ?? 0;
|
|
184
|
+
if (heard > lastPlayed) { lastPlayed = heard; playedAt = Date.now(); }
|
|
185
|
+
if (Date.now() - playedAt > PLAYOUT_STALL_MS) throw new Error("voice_audio_backpressure");
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
187
|
+
}
|
|
188
|
+
if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
|
|
137
189
|
const chunk = audio.subarray(offset, offset + CHUNK_BYTES);
|
|
138
190
|
current.outputMs += chunk.length / BYTES_PER_MS;
|
|
139
191
|
send({ type: "response.output_audio.delta", response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0, delta: chunk.toString("base64") });
|
|
192
|
+
sentAt = Date.now();
|
|
140
193
|
}
|
|
141
194
|
};
|
|
142
195
|
const respond = async (text, origin, key, input) => {
|
|
@@ -210,7 +263,8 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
210
263
|
if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
|
|
211
264
|
};
|
|
212
265
|
const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
|
|
213
|
-
const commit = async (
|
|
266
|
+
const commit = async () => {
|
|
267
|
+
if (closed) return;
|
|
214
268
|
// A commit with no samples still has a client waiting in transcribing.
|
|
215
269
|
// Returning here never completes that take, so the composer stays on 转写中.
|
|
216
270
|
if (!bytes) {
|
|
@@ -219,9 +273,10 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
219
273
|
send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: "" });
|
|
220
274
|
return;
|
|
221
275
|
}
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
276
|
+
// The previous turn is still being transcribed or submitted. The mic is
|
|
277
|
+
// shared and the client cannot know that, so queue this take instead of
|
|
278
|
+
// rejecting it; the composer would otherwise see a hard turn_busy.
|
|
279
|
+
if (busy) { pendingCommit = true; return; }
|
|
225
280
|
const input = Buffer.concat(chunks), id = itemId || `item_${randomUUID()}`, ms = input.length / BYTES_PER_MS, opened = stream;
|
|
226
281
|
stream = null; reset(); scope.voiceInputActive = false; busy = true;
|
|
227
282
|
send({ type: "input_audio_buffer.committed", item_id: id, previous_item_id: null });
|
|
@@ -247,20 +302,33 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
247
302
|
} catch (cause) { if (!closed && epoch === generation) report(cause); }
|
|
248
303
|
finally {
|
|
249
304
|
busy = false;
|
|
250
|
-
if (pendingCommit) { pendingCommit = false; void commit(
|
|
305
|
+
if (pendingCommit) { pendingCommit = false; void commit().catch(report); }
|
|
306
|
+
else drainTyped();
|
|
251
307
|
}
|
|
252
308
|
};
|
|
309
|
+
// Typed text that arrives while a take is in flight waits its turn instead
|
|
310
|
+
// of being rejected: on a shared link the client cannot tell it is early.
|
|
311
|
+
const typedQueue = [];
|
|
312
|
+
const drainTyped = () => {
|
|
313
|
+
if (closed || busy || !typedQueue.length) return;
|
|
314
|
+
const next = typedQueue.shift();
|
|
315
|
+
void runTyped(next).catch(report);
|
|
316
|
+
};
|
|
317
|
+
const runTyped = async ({ id, text }) => {
|
|
318
|
+
if (closed) return;
|
|
319
|
+
busy = true;
|
|
320
|
+
userItem(id, { type: "input_text", text });
|
|
321
|
+
try { await submit(text, id, generation, 0); }
|
|
322
|
+
finally { busy = false; drainTyped(); }
|
|
323
|
+
};
|
|
253
324
|
const typed = async (event) => {
|
|
254
325
|
if (type !== "realtime") fail("unsupported_parameter");
|
|
255
326
|
const item = only(event.item, ["id", "type", "role", "content"]);
|
|
256
327
|
const content = Array.isArray(item.content) && item.content.length === 1 ? only(item.content[0], ["type", "text"]) : fail("unsupported_parameter");
|
|
257
328
|
if (item.type !== "message" || item.role !== "user" || content.type !== "input_text" || typeof content.text !== "string" || !content.text.trim() || content.text.length > 16000) fail("unsupported_parameter");
|
|
258
|
-
if (busy) fail("turn_busy");
|
|
259
|
-
busy = true;
|
|
260
329
|
const id = typeof item.id === "string" && item.id ? item.id.slice(0, 128) : `item_${randomUUID()}`;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
finally { busy = false; }
|
|
330
|
+
if (busy) { if (typedQueue.length >= 8) fail("turn_busy"); typedQueue.push({ id, text: content.text }); return; }
|
|
331
|
+
await runTyped({ id, text: content.text });
|
|
264
332
|
};
|
|
265
333
|
const startStream = () => {
|
|
266
334
|
const current = { queue: [], live: null, id: itemId };
|
|
@@ -278,7 +346,8 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
278
346
|
};
|
|
279
347
|
const append = (audio) => {
|
|
280
348
|
if (awaitingPlayback && !bargeActive()) fail("waiting_for_playback");
|
|
281
|
-
|
|
349
|
+
// Audio that arrives while the previous take is being transcribed belongs
|
|
350
|
+
// to the next turn; keep it and let that commit queue behind the first one.
|
|
282
351
|
if (typeof audio !== "string" || audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(audio)) fail("unsupported_audio_format");
|
|
283
352
|
const chunk = Buffer.from(audio, "base64");
|
|
284
353
|
if (chunk.length % 2) fail("unsupported_audio_format");
|
|
@@ -293,7 +362,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
293
362
|
if (automatic && bargeActive() && response && !response.suspended && speechMs >= BARGE_IN_MS) suspend(response);
|
|
294
363
|
if (automatic && endpoint.push(1, rms, chunk.length / BYTES_PER_MS)) {
|
|
295
364
|
send({ type: "input_audio_buffer.speech_stopped", audio_end_ms: Math.round(totalMs), item_id: itemId });
|
|
296
|
-
void commit(
|
|
365
|
+
void commit().catch(report);
|
|
297
366
|
}
|
|
298
367
|
};
|
|
299
368
|
const suspend = (current) => {
|
|
@@ -393,14 +462,17 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
393
462
|
// The client has stopped listening. A reply that already finished must
|
|
394
463
|
// not keep the next utterance behind waiting_for_playback.
|
|
395
464
|
awaitingPlayback = false;
|
|
465
|
+
clearAckWatch();
|
|
396
466
|
if (busy && turn) generation++;
|
|
397
467
|
cancel("cancelled");
|
|
398
468
|
return;
|
|
399
469
|
case "lares.playback.progress":
|
|
400
470
|
acknowledges = true;
|
|
401
471
|
if (typeof event.item_id === "string" && Number.isFinite(event.played_ms) && event.played_ms >= 0) played.set(event.item_id, event.played_ms);
|
|
472
|
+
// Progress is the client proving it is still rendering the reply.
|
|
473
|
+
watchAck();
|
|
402
474
|
return;
|
|
403
|
-
case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; return;
|
|
475
|
+
case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; clearAckWatch(); return;
|
|
404
476
|
case "lares.task.cancel": {
|
|
405
477
|
if (typeof event.task_id !== "string" || !tasks.has(event.task_id)) fail("task_not_cancellable");
|
|
406
478
|
if (await service.withdraw(scope, event.task_id) === "not_cancellable") fail("task_not_cancellable");
|
|
@@ -419,7 +491,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
419
491
|
|
|
420
492
|
const close = () => {
|
|
421
493
|
if (closed) return;
|
|
422
|
-
closed = true; response?.abort.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
|
|
494
|
+
closed = true; response?.abort.abort(); generation++; reset(); clearAckWatch(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
|
|
423
495
|
unbind(); clients.delete(client); client.close();
|
|
424
496
|
void Promise.resolve(port?.stop()).catch(() => {});
|
|
425
497
|
if (scope.realtimeSocket === client) scope.realtimeSocket = null;
|
package/voice/panel.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { setVoiceActivity } from "./activity.js";
|
|
2
2
|
import { DICTATION_PRESETS } from "./dictation.js";
|
|
3
3
|
|
|
4
|
+
/** Some mobile webviews lack AbortSignal.any/timeout; the caller signal still cancels. */
|
|
5
|
+
function budgetedSignal(signal, ms) {
|
|
6
|
+
const signals = [signal];
|
|
7
|
+
if (typeof AbortSignal?.timeout === "function") {
|
|
8
|
+
try { signals.push(AbortSignal.timeout(ms)); } catch { /* caller signal only */ }
|
|
9
|
+
}
|
|
10
|
+
if (signals.length > 1 && typeof AbortSignal.any === "function") {
|
|
11
|
+
try { return AbortSignal.any(signals); } catch { /* caller signal only */ }
|
|
12
|
+
}
|
|
13
|
+
return signal;
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
/** The panel owns drafts and turn policy; adapters only supply settings, audio and insertion ports. */
|
|
5
17
|
export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null, armOutput = () => {} }) {
|
|
6
18
|
let state = { visible: false, inputActive: false, previewVisible: false, mode: "single", sessionId: null, raw: "", draft: "", edited: false, preset: "raw", stage: "idle", snapshot: null, busy: false, error: null, readAloudError: null, device: "", levels: [], startedAt: null, elapsedMs: 0, holding: false, cancelArmed: false };
|
|
@@ -39,7 +51,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
39
51
|
if (!DICTATION_PRESETS.includes(preset)) throw new Error("voice_config_invalid");
|
|
40
52
|
cancelProcessing(); const abort = processing = new AbortController(); const token = generation;
|
|
41
53
|
emit({ preset, stage: "processing", error: null });
|
|
42
|
-
const signal =
|
|
54
|
+
const signal = budgetedSignal(abort.signal, 45_000);
|
|
43
55
|
let interrupted;
|
|
44
56
|
const cancellation = new Promise((_, reject) => { interrupted = () => reject(signal.reason); signal.addEventListener("abort", interrupted, { once: true }); });
|
|
45
57
|
try {
|
package/voice/pcm-player.js
CHANGED
|
@@ -3,6 +3,21 @@ import { pcmSamples } from "./realtime-audio.js";
|
|
|
3
3
|
|
|
4
4
|
let primed = null;
|
|
5
5
|
const worklets = new WeakMap();
|
|
6
|
+
let watchingVisibility = false;
|
|
7
|
+
|
|
8
|
+
/** iOS/macOS suspend the page's context on a call, the lock screen or a device switch. */
|
|
9
|
+
function resumeIfInterrupted(context) {
|
|
10
|
+
if (!context || context.state === "closed" || context.state === "running") return;
|
|
11
|
+
void context.resume?.().catch(() => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function watchVisibility() {
|
|
15
|
+
if (watchingVisibility || typeof document === "undefined" || !document.addEventListener) return;
|
|
16
|
+
watchingVisibility = true;
|
|
17
|
+
document.addEventListener("visibilitychange", () => {
|
|
18
|
+
if (document.visibilityState === "visible") resumeIfInterrupted(primed);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
6
21
|
|
|
7
22
|
function ensureWorklet(ctx) {
|
|
8
23
|
const pending = worklets.get(ctx);
|
|
@@ -29,6 +44,41 @@ export function primePlaybackContext(Context = globalThis.AudioContext || global
|
|
|
29
44
|
|
|
30
45
|
export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
|
|
31
46
|
let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false, owned = false;
|
|
47
|
+
/**
|
|
48
|
+
* The page shares one primed context. iOS interrupts it (a call, the lock
|
|
49
|
+
* screen) and it never resumes on its own, so a reply that starts after that
|
|
50
|
+
* gets a fresh context instead of a silent failure.
|
|
51
|
+
*/
|
|
52
|
+
const openContext = async (shared) => {
|
|
53
|
+
watchVisibility();
|
|
54
|
+
if (shared) {
|
|
55
|
+
shared.addEventListener?.("statechange", () => resumeIfInterrupted(shared));
|
|
56
|
+
try {
|
|
57
|
+
await ensureWorklet(shared);
|
|
58
|
+
await shared.resume?.();
|
|
59
|
+
if (!shared.state || shared.state === "running") {
|
|
60
|
+
owned = false; context = shared;
|
|
61
|
+
return { opened: shared, shared: true };
|
|
62
|
+
}
|
|
63
|
+
} catch { /* replaced below */ }
|
|
64
|
+
worklets.delete(shared);
|
|
65
|
+
if (primed === shared) primed = null;
|
|
66
|
+
}
|
|
67
|
+
const fresh = new Context();
|
|
68
|
+
owned = true;
|
|
69
|
+
fresh.addEventListener?.("statechange", () => resumeIfInterrupted(fresh));
|
|
70
|
+
try {
|
|
71
|
+
await ensureWorklet(fresh);
|
|
72
|
+
await fresh.resume?.();
|
|
73
|
+
if (fresh.state && fresh.state !== "running") throw new Error("voice_playback_failed");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
owned = false;
|
|
76
|
+
if (fresh.state !== "closed") void fresh.close?.().catch(() => {});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
context = fresh;
|
|
80
|
+
return { opened: fresh, shared: false };
|
|
81
|
+
};
|
|
32
82
|
const stop = () => {
|
|
33
83
|
generation++; paused = false; node?.disconnect(); if (node) node.port.onmessage = null; node = null;
|
|
34
84
|
const closing = owned ? context : null;
|
|
@@ -42,17 +92,14 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
|
|
|
42
92
|
if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
|
|
43
93
|
const epoch = generation;
|
|
44
94
|
const shared = primed && primed.state !== "closed" ? primed : null;
|
|
45
|
-
|
|
46
|
-
context = shared || new Context(); const opened = context;
|
|
95
|
+
let opened;
|
|
47
96
|
try {
|
|
48
|
-
await
|
|
49
|
-
await opened.resume?.();
|
|
97
|
+
({ opened } = await openContext(shared));
|
|
50
98
|
if (epoch !== generation) throw new Error("voice_cancelled");
|
|
51
|
-
|
|
52
|
-
onMetric({ stage: "playback_context", state: opened.state || "unknown", shared: Boolean(shared) });
|
|
99
|
+
onMetric({ stage: "playback_context", state: opened.state || "unknown", shared: Boolean(shared && opened === shared) });
|
|
53
100
|
} catch (error) {
|
|
54
|
-
onMetric({ stage: "playback_context", state:
|
|
55
|
-
if (owned &&
|
|
101
|
+
onMetric({ stage: "playback_context", state: context?.state || "missing", shared: Boolean(shared), code: error?.message || "voice_playback_failed" });
|
|
102
|
+
if (owned && context && context.state !== "closed") void context.close?.().catch(() => {});
|
|
56
103
|
owned = false; context = null;
|
|
57
104
|
throw error;
|
|
58
105
|
}
|
package/voice/presentation.js
CHANGED
|
@@ -38,6 +38,17 @@ export function voiceCallFailure(view) {
|
|
|
38
38
|
return { code: ui.error, action: recoverable ? "resume" : "restart" };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A voice call that is still playing keeps one strip at the composer until it
|
|
43
|
+
* ends: status plus the control that stops it. The hold button is not that
|
|
44
|
+
* strip. A chat readout stays on the assistant row that is being read.
|
|
45
|
+
*/
|
|
46
|
+
export function voicePlaybackChrome(view) {
|
|
47
|
+
const playback = view?.snapshot?.playback;
|
|
48
|
+
if (!playback || playback === "idle") return null;
|
|
49
|
+
return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
/** Shared inline labels; clients own focus, positioning and the timer tick. */
|
|
42
53
|
export function voiceInputPresentation(view, now = Date.now()) {
|
|
43
54
|
const ui = voicePanelPresentation(view);
|
package/voice/protocol-client.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/** Transport-neutral `lares-realtime-v1` client: the caller obtains an authenticated socket. */
|
|
2
2
|
export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose = () => {} } = {}) {
|
|
3
|
+
// A command that does not reach the host fails the take. Progress ticks are
|
|
4
|
+
// observations from the audio callback: drop them when the socket cannot take
|
|
5
|
+
// them, and never throw. Completion, cancel and truncate are different: they
|
|
6
|
+
// are what lets the host speak the next reply. Dropping them leaves the call
|
|
7
|
+
// open and every later answer silent.
|
|
8
|
+
const open = () => socket.readyState === 1 && socket.bufferedAmount <= 1024 * 1024;
|
|
3
9
|
const send = (event) => {
|
|
4
10
|
if (socket.readyState !== 1) throw new Error("voice_disconnected");
|
|
5
11
|
if (socket.bufferedAmount > 1024 * 1024) throw new Error("voice_audio_backpressure");
|
|
6
12
|
socket.send(JSON.stringify(event));
|
|
7
13
|
};
|
|
14
|
+
const observe = (event) => { if (open()) socket.send(JSON.stringify(event)); };
|
|
15
|
+
const release = (event) => { if (socket.readyState === 1) socket.send(JSON.stringify(event)); };
|
|
8
16
|
const message = (event) => {
|
|
9
17
|
let value;
|
|
10
18
|
try { value = JSON.parse(String(event.data)); } catch { onEvent({ type: "error", error: { code: "voice_protocol" } }); return; }
|
|
@@ -24,10 +32,10 @@ export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose
|
|
|
24
32
|
commit() { send({ type: "input_audio_buffer.commit" }); },
|
|
25
33
|
discard() { send({ type: "input_audio_buffer.clear" }); },
|
|
26
34
|
say(text) { send({ type: "conversation.item.create", item: { type: "message", role: "user", content: [{ type: "input_text", text }] } }); },
|
|
27
|
-
playbackProgress(itemId, playedMs) {
|
|
28
|
-
playbackCompleted(itemId = null) {
|
|
29
|
-
stopOutput() {
|
|
30
|
-
truncate(itemId, audioEndMs) {
|
|
35
|
+
playbackProgress(itemId, playedMs) { observe({ type: "lares.playback.progress", item_id: itemId, played_ms: playedMs }); },
|
|
36
|
+
playbackCompleted(itemId = null) { release({ type: "lares.playback.completed", item_id: itemId }); },
|
|
37
|
+
stopOutput() { release({ type: "response.cancel" }); },
|
|
38
|
+
truncate(itemId, audioEndMs) { release({ type: "conversation.item.truncate", item_id: itemId, content_index: 0, audio_end_ms: Math.max(0, Math.round(audioEndMs)) }); },
|
|
31
39
|
setOutput(audio) { send({ type: "session.update", session: { output_modalities: [audio ? "audio" : "text"] } }); },
|
|
32
40
|
setBusyTurn(mode) { send({ type: "session.update", session: { lares: { busy_turn: mode === "steer" ? "steer" : "queue" } } }); },
|
|
33
41
|
deviceInput(active) { send({ type: active ? "lares.device.input.resume" : "lares.device.input.pause" }); },
|
package/voice/service.js
CHANGED
|
@@ -59,8 +59,13 @@ export function voiceScope(id, owner = "", device = "headset") {
|
|
|
59
59
|
if (!/^[a-zA-Z0-9_-]{8,128}$/.test(id)) throw new VoiceError("voice_session_invalid", 400, "invalid voice connection");
|
|
60
60
|
const key = `${owner}:${id}`;
|
|
61
61
|
for (const [other, scope] of scopes) {
|
|
62
|
-
|
|
62
|
+
// A socket that is still open is a connected client, even when it has made
|
|
63
|
+
// no HTTP call for two minutes. Its own recovery timer retires it; evicting
|
|
64
|
+
// it here would close the session under a call that is merely paused.
|
|
65
|
+
const liveSocket = scope.realtimeSocket && scope.realtimeSocket.readyState === 1;
|
|
66
|
+
if (Date.now() - scope.seen > 120_000 && !deviceLease.owns(scope) && !liveSocket) {
|
|
63
67
|
scopes.delete(other);
|
|
68
|
+
scope.active = false;
|
|
64
69
|
void scope.duplex.interrupt().catch(() => {});
|
|
65
70
|
scope.capture?.stop(true);
|
|
66
71
|
void scope.session?.close();
|
|
@@ -312,9 +317,17 @@ export async function speakFromConfig(text, options = {}) {
|
|
|
312
317
|
const deadline = readout.segmenter.deadline;
|
|
313
318
|
if (deadline !== null) {
|
|
314
319
|
await new Promise((resolve, reject) => {
|
|
320
|
+
// setTimeout can fire before Date.now() reaches the segmenter deadline.
|
|
321
|
+
// Flushing early drops the pending list item and reports zero sentences.
|
|
322
|
+
let timer;
|
|
315
323
|
const cleanup = () => { clearTimeout(timer); readout.signal.removeEventListener("abort", cancel); };
|
|
316
324
|
const cancel = () => { cleanup(); reject(cancelledSpeech()); };
|
|
317
|
-
const
|
|
325
|
+
const tick = () => {
|
|
326
|
+
const remaining = deadline - Date.now();
|
|
327
|
+
if (remaining <= 0) { cleanup(); resolve(); return; }
|
|
328
|
+
timer = setTimeout(tick, remaining);
|
|
329
|
+
};
|
|
330
|
+
timer = setTimeout(tick, Math.max(0, deadline - Date.now()));
|
|
318
331
|
readout.signal.addEventListener("abort", cancel, { once: true });
|
|
319
332
|
if (readout.signal.aborted) cancel();
|
|
320
333
|
});
|
|
@@ -390,6 +403,9 @@ async function readAloud(readout, model, voice, lease, options) {
|
|
|
390
403
|
let player;
|
|
391
404
|
const abortPlayer = () => player?.abort?.();
|
|
392
405
|
lease.signal.addEventListener("abort", abortPlayer);
|
|
406
|
+
// A keyed readout is consumed sentence by sentence; only the whole-answer
|
|
407
|
+
// call needs the full PCM copy.
|
|
408
|
+
const collect = !options.key;
|
|
393
409
|
const pcm = [];
|
|
394
410
|
let sentence = [];
|
|
395
411
|
let streamed = false;
|
|
@@ -414,7 +430,7 @@ async function readAloud(readout, model, voice, lease, options) {
|
|
|
414
430
|
readout.firstAudio = true;
|
|
415
431
|
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 });
|
|
416
432
|
}
|
|
417
|
-
pcm.push(chunk);
|
|
433
|
+
if (collect) pcm.push(chunk);
|
|
418
434
|
sentence.push(chunk);
|
|
419
435
|
if (!lease.active()) return;
|
|
420
436
|
opening ??= openPlayback();
|
package/voice/speech.js
CHANGED
|
@@ -192,7 +192,13 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
192
192
|
let lastKey = null;
|
|
193
193
|
let silencedKey = null;
|
|
194
194
|
const consumed = new Set();
|
|
195
|
-
|
|
195
|
+
// One page reads many turns; keep the names bounded without re-reading any.
|
|
196
|
+
const REMEMBERED = 256;
|
|
197
|
+
const mark = (_set, key) => {
|
|
198
|
+
if (key == null) return;
|
|
199
|
+
consumed.add(key);
|
|
200
|
+
if (consumed.size > REMEMBERED) consumed.delete(consumed.values().next().value);
|
|
201
|
+
};
|
|
196
202
|
|
|
197
203
|
const emitOutput = value => {
|
|
198
204
|
if (value || readoutState.getSnapshot()?.key === lastKey || onOutput !== readoutState.publish) onOutput(value);
|
package/voice/stream-client.js
CHANGED
|
@@ -105,6 +105,9 @@ export async function startTranscriptionStream(options) {
|
|
|
105
105
|
const connection = await openAudioStream({ ...options, operation: "stt" });
|
|
106
106
|
if (!connection) return null;
|
|
107
107
|
let final = null, finished = false, completed = false, failed = false, finishAt = 0, resolve, reject;
|
|
108
|
+
// The Host owns the ASR request; this bound only stops this client waiting.
|
|
109
|
+
// Falling back to the recorded blob is what keeps the take usable.
|
|
110
|
+
const FINAL_TIMEOUT_MS = 60_000;
|
|
108
111
|
const result = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
109
112
|
void result.catch(() => {});
|
|
110
113
|
let readyResolve, readyReject;
|
|
@@ -134,8 +137,18 @@ export async function startTranscriptionStream(options) {
|
|
|
134
137
|
if (options.signal?.aborted) fail(new Error("voice_cancelled"));
|
|
135
138
|
await ready;
|
|
136
139
|
return {
|
|
140
|
+
// A committed take is final on the Host; a late or empty result must not be
|
|
141
|
+
// re-sent as a recording. This socket only transcribes, so replaying is safe.
|
|
142
|
+
get committed() { return finished; },
|
|
143
|
+
get replayable() { return true; },
|
|
137
144
|
write(bytes) { if (finished || connection.readyState !== 1 || connection.bufferedAmount > 256 * 1024) throw new Error("voice_audio_backpressure"); connection.send(bytes); },
|
|
138
|
-
async finish() {
|
|
145
|
+
async finish() {
|
|
146
|
+
finishAt = Date.now(); finished = true; connection.send(JSON.stringify({ type: "finish" }));
|
|
147
|
+
let timer;
|
|
148
|
+
const timeout = new Promise((_, no) => { timer = setTimeout(() => no(new Error("voice_asr_timeout")), FINAL_TIMEOUT_MS); });
|
|
149
|
+
try { return await Promise.race([result, timeout]); }
|
|
150
|
+
finally { clearTimeout(timer); connection.close(); }
|
|
151
|
+
},
|
|
139
152
|
cancel() { fail(new Error("voice_cancelled")); },
|
|
140
153
|
};
|
|
141
154
|
}
|
|
@@ -170,6 +183,8 @@ export async function createStreamingCapture(stream, options) {
|
|
|
170
183
|
};
|
|
171
184
|
source.connect(node); node.connect(context.destination);
|
|
172
185
|
return {
|
|
186
|
+
get committed() { return port.committed === true; },
|
|
187
|
+
get replayable() { return port.replayable !== false; },
|
|
173
188
|
async finish() {
|
|
174
189
|
try {
|
|
175
190
|
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("voice_timeout")), 2000); flushed = () => { clearTimeout(timer); resolve(); }; node.port.postMessage({ type: "flush" }); });
|
package/voice/stream-gateway.js
CHANGED
|
@@ -10,7 +10,7 @@ import { routerGatewayUrl, routerAuthHeaders } from "../router/gateway.js";
|
|
|
10
10
|
const fault = (code, status = 409) => new VoiceError(code, status, code);
|
|
11
11
|
|
|
12
12
|
/** Single-use HTTP grants bind raw audio sockets to the authenticated Host scope. */
|
|
13
|
-
export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {} }) {
|
|
13
|
+
export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {}, playbackStallMs = 45_000 }) {
|
|
14
14
|
const tickets = new Map(), clients = new Set();
|
|
15
15
|
const synthesis = createSynthesisRuns({ synthesize, metric });
|
|
16
16
|
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false,
|
|
@@ -49,11 +49,18 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
49
49
|
clients.add(client);
|
|
50
50
|
const abort = new AbortController();
|
|
51
51
|
let upstream = null, sentFrames = 0, playedFrames = 0, rate = 0, waiter = null, outputFormat = null;
|
|
52
|
-
let fileDone = null, pausedAt = 0, closing = false;
|
|
52
|
+
let fileDone = null, pausedAt = 0, closing = false, ttsKey = null;
|
|
53
53
|
let received = 0, committed = false, outputStarted = false;
|
|
54
54
|
const started = Date.now();
|
|
55
55
|
const send = (value) => { if (client.readyState === 1) client.send(JSON.stringify(value)); };
|
|
56
|
-
const close = () => {
|
|
56
|
+
const close = () => {
|
|
57
|
+
if (closing) return;
|
|
58
|
+
closing = true; abort.abort(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate();
|
|
59
|
+
// A consumer that disappeared stops its synthesis instead of leaving
|
|
60
|
+
// the whole answer to be rendered with nobody listening.
|
|
61
|
+
if (ttsKey) synthesis.release(ttsKey);
|
|
62
|
+
clients.delete(client); client.close();
|
|
63
|
+
};
|
|
57
64
|
const error = (cause) => {
|
|
58
65
|
const code = cause?.code || "voice_stream_failed";
|
|
59
66
|
if (grant.operation === "tts" && code !== "voice_cancelled") metric({ stage: "playback_stop", voiceId: grant.scope.id, code, sentFrames, playedFrames });
|
|
@@ -104,6 +111,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
104
111
|
upstream.on("close", () => { send({ type: "eof" }); close(); });
|
|
105
112
|
} else {
|
|
106
113
|
const key = grant.replySource ? utteranceKey(grant.replySource) : `text:${grant.text}`;
|
|
114
|
+
ttsKey = key;
|
|
107
115
|
const run = synthesis.attach(key, (signal) => grant.reply ? grant.reply(signal) : (async function* text() {
|
|
108
116
|
const parts = speechSegments(grant.text, { done: true });
|
|
109
117
|
for (let index = 0; index < parts.length; index += 1) yield { text: parts[index], index };
|
|
@@ -137,7 +145,14 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
137
145
|
for (let offset = 0; offset < event.chunk.length; offset += 16384) {
|
|
138
146
|
while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
|
|
139
147
|
if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
|
|
140
|
-
|
|
148
|
+
// A device switch or a locked screen can stop playout for a
|
|
149
|
+
// while without the client being gone. Wait for it to come
|
|
150
|
+
// back instead of ending the answer after ten seconds.
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
const limit = pausedAt ? Math.max(1, 10_000 - (Date.now() - pausedAt)) : playbackStallMs;
|
|
153
|
+
const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
|
|
154
|
+
waiter = () => { clearTimeout(timer); resolve(); };
|
|
155
|
+
});
|
|
141
156
|
if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
|
|
142
157
|
}
|
|
143
158
|
if (client.readyState !== 1) throw fault("voice_cancelled");
|
package/voice/synthesis-run.js
CHANGED
|
@@ -79,7 +79,10 @@ export function createSynthesisRuns({ synthesize, metric = () => {} }) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
return {
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* `open` is used only for the first subscriber of `key`; every other observer
|
|
84
|
+
* of the same answer shares that one synthesis.
|
|
85
|
+
*/
|
|
83
86
|
attach(key, open, request) {
|
|
84
87
|
let run = runs.get(key);
|
|
85
88
|
if (!run) {
|
|
@@ -89,6 +92,13 @@ export function createSynthesisRuns({ synthesize, metric = () => {} }) {
|
|
|
89
92
|
}
|
|
90
93
|
return run;
|
|
91
94
|
},
|
|
95
|
+
/** Dropping the last consumer stops synthesis instead of draining the whole answer. */
|
|
96
|
+
release(key) {
|
|
97
|
+
const run = key && runs.get(key);
|
|
98
|
+
if (!run) return;
|
|
99
|
+
run.cancel();
|
|
100
|
+
if (runs.get(key) === run) runs.delete(key);
|
|
101
|
+
},
|
|
92
102
|
cancel(key) { if (key) runs.get(key)?.cancel(); },
|
|
93
103
|
close() { for (const run of runs.values()) run.cancel(); runs.clear(); },
|
|
94
104
|
};
|