@olares/lares-core 0.15.31 → 0.15.33
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/package.json +1 -1
- package/voice/browser-capture.js +23 -5
- package/voice/device-input.js +2 -2
- package/voice/lares-realtime-gateway.js +24 -29
- package/voice/panel.js +30 -3
- package/voice/playout-clock.js +53 -0
- package/voice/presentation.js +4 -3
- package/voice/speech.js +27 -2
- package/voice/stream-client.js +167 -85
- package/voice/stream-gateway.js +30 -19
- package/voice/unified-conversation.js +3 -1
package/package.json
CHANGED
package/voice/browser-capture.js
CHANGED
|
@@ -49,7 +49,22 @@ export function prepareBrowserCapture({ media, onTrace = () => {}, now = Date.no
|
|
|
49
49
|
})();
|
|
50
50
|
// Permission may fail while other initialization is still pending.
|
|
51
51
|
void ready.catch(() => {});
|
|
52
|
+
const live = async () => {
|
|
53
|
+
let next;
|
|
54
|
+
try { next = await ready; }
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof Error && err.message.startsWith("voice_")) throw err;
|
|
57
|
+
throw new Error(micFailure(err));
|
|
58
|
+
}
|
|
59
|
+
if (consumed || cancelled || !next) throw new Error("voice_cancelled");
|
|
60
|
+
if (next.getTracks().every((track) => track.readyState === "ended")) throw new Error("voice_device_lost");
|
|
61
|
+
return next;
|
|
62
|
+
};
|
|
52
63
|
return {
|
|
64
|
+
/** Resolves once the microphone is open. Does not take the stream. */
|
|
65
|
+
opened: live,
|
|
66
|
+
/** Another take of the same voice session. Does not open a second microphone. */
|
|
67
|
+
borrow: live,
|
|
53
68
|
recording() {
|
|
54
69
|
try { onTrace("ready", { durationMs: now() - started }); } catch { /* Telemetry is optional. */ }
|
|
55
70
|
},
|
|
@@ -79,6 +94,7 @@ export function createVoiceCapture({
|
|
|
79
94
|
now = Date.now,
|
|
80
95
|
media,
|
|
81
96
|
preparation,
|
|
97
|
+
retainStream = false,
|
|
82
98
|
}) {
|
|
83
99
|
const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
84
100
|
const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
|
|
@@ -122,7 +138,9 @@ export function createVoiceCapture({
|
|
|
122
138
|
const release = () => {
|
|
123
139
|
detector?.stop?.();
|
|
124
140
|
detector = null;
|
|
125
|
-
|
|
141
|
+
// A session microphone stays open for the next hold. Only a take that
|
|
142
|
+
// opened its own stream stops the tracks when the take ends.
|
|
143
|
+
if (!retainStream) stream?.getTracks().forEach((track) => track.stop());
|
|
126
144
|
stream = null;
|
|
127
145
|
recorder = null;
|
|
128
146
|
};
|
|
@@ -241,7 +259,7 @@ export function createVoiceCapture({
|
|
|
241
259
|
async start() {
|
|
242
260
|
if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
|
|
243
261
|
if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
|
|
244
|
-
preparation?.cancel();
|
|
262
|
+
if (!retainStream) preparation?.cancel();
|
|
245
263
|
fail("voice_unsupported");
|
|
246
264
|
return;
|
|
247
265
|
}
|
|
@@ -253,9 +271,9 @@ export function createVoiceCapture({
|
|
|
253
271
|
emit("preparing", { elapsed: 0, error: "" });
|
|
254
272
|
let next;
|
|
255
273
|
try {
|
|
256
|
-
next = await (preparation ? preparation.consume() : openDeviceMic(mediaDevices));
|
|
274
|
+
next = await (retainStream ? preparation.borrow() : preparation ? preparation.consume() : openDeviceMic(mediaDevices));
|
|
257
275
|
} catch (err) {
|
|
258
|
-
preparation?.cancel();
|
|
276
|
+
if (!retainStream) preparation?.cancel();
|
|
259
277
|
const code = micFailure(err);
|
|
260
278
|
trace("mic-fail", { code, name: err?.name || "" });
|
|
261
279
|
if (!dead) fail(code);
|
|
@@ -351,7 +369,7 @@ export function createVoiceCapture({
|
|
|
351
369
|
}
|
|
352
370
|
},
|
|
353
371
|
dispose() {
|
|
354
|
-
preparation?.cancel();
|
|
372
|
+
if (!retainStream) preparation?.cancel();
|
|
355
373
|
inputAbort?.abort();
|
|
356
374
|
dead = true;
|
|
357
375
|
cancel = true;
|
package/voice/device-input.js
CHANGED
|
@@ -32,8 +32,8 @@ export function createDeviceInput({ transcribe, onPhase = () => {}, endpoint = d
|
|
|
32
32
|
let ready = false;
|
|
33
33
|
settle = resolve;
|
|
34
34
|
const done = (text) => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); settle = null; resolve(text); };
|
|
35
|
-
const
|
|
36
|
-
recorder = createVoiceCapture({ media, streamInput, preparation
|
|
35
|
+
const retainStream = continuous && typeof preparation?.borrow === "function";
|
|
36
|
+
recorder = createVoiceCapture({ media, streamInput, preparation, retainStream, transcribe, submit: done, onStream: (stream) => {
|
|
37
37
|
for (const track of stream.getTracks()) track.addEventListener?.("ended", () => { settle = null; cancel(); reject(new Error("voice_device_lost")); }, { once: true });
|
|
38
38
|
if (!automatic) return createInputMeter(stream, onLevel);
|
|
39
39
|
return endpoint(stream, (heard = true) => { if (heard) recorder?.stop(); else { cancel(); done(""); } }, onSpeech, onLevel, { silenceMs: endpointSilenceMs, interruptionMs: echoTested ? 500 : 0 });
|
|
@@ -3,6 +3,7 @@ import { WebSocketServer } from "ws";
|
|
|
3
3
|
import { validateConfigPatch } from "./config.js";
|
|
4
4
|
import { createSpeechEndpoint, frameRms } from "./endpoint.js";
|
|
5
5
|
import { pcmSamples } from "./realtime-audio.js";
|
|
6
|
+
import { createPlayoutClock, PLAYOUT_LEAD_MS, SOCKET_FUSE_BYTES } from "./playout-clock.js";
|
|
6
7
|
import { speechSegments } from "./script.js";
|
|
7
8
|
import { bindRouterSession } from "../router/session-alias.js";
|
|
8
9
|
|
|
@@ -16,13 +17,6 @@ const SPEECH_RMS = 0.004;
|
|
|
16
17
|
// and a suspected interruption that never resolves stops the reply.
|
|
17
18
|
const BARGE_IN_MS = 500;
|
|
18
19
|
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;
|
|
26
20
|
const TICKET_MS = 30_000;
|
|
27
21
|
// Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
|
|
28
22
|
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 } });
|
|
@@ -44,6 +38,9 @@ const ticketOf = (req) => String(req.headers["sec-websocket-protocol"] || "").sp
|
|
|
44
38
|
const CJK_END = /[ -〿㐀-鿿-]$/;
|
|
45
39
|
const CJK_START = /^[ -〿㐀-鿿-]/;
|
|
46
40
|
const separator = (before, after) => !before || CJK_END.test(before) || CJK_START.test(after) ? "" : " ";
|
|
41
|
+
// `done === false` is a live partial. A finished utterance sets done, and a caller
|
|
42
|
+
// that never reports it (tests, a one-shot reply) is spoken as-is.
|
|
43
|
+
const speakableUtterance = (utterance) => Boolean(utterance?.text) && utterance.done !== false && !utterance.silent;
|
|
47
44
|
/** Spoken segments are cleaned clauses; rejoin them without inventing spaces inside CJK text. */
|
|
48
45
|
export function joinSpoken(parts) {
|
|
49
46
|
return parts.reduce((text, part) => `${text}${separator(text, part)}${part}`, "");
|
|
@@ -161,29 +158,23 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
161
158
|
return hostOutput;
|
|
162
159
|
}
|
|
163
160
|
if (acknowledges) { awaitingPlayback = true; watchAck(); }
|
|
164
|
-
// A
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
161
|
+
// A client that announces playback acknowledgement used to stop after two
|
|
162
|
+
// seconds unless later ticks kept arriving. Those ticks are observations.
|
|
163
|
+
// The reply keeps moving with the clock; the socket fuse is only for a
|
|
164
|
+
// client that has stopped reading.
|
|
165
|
+
if (!current.clock) current.clock = createPlayoutClock({ leadMs: PLAYOUT_LEAD_MS });
|
|
166
|
+
const clock = current.clock;
|
|
169
167
|
let sentAt = Date.now();
|
|
170
|
-
let lastPlayed = played.get(current.itemId) ?? 0;
|
|
171
|
-
let playedAt = Date.now();
|
|
172
168
|
for (let offset = 0; offset < audio.length; offset += CHUNK_BYTES) {
|
|
173
|
-
|
|
169
|
+
clock.start();
|
|
170
|
+
clock.heard(played.get(current.itemId) ?? 0);
|
|
171
|
+
while ((acknowledges && clock.blocked(current.outputMs)) || client.bufferedAmount > SOCKET_FUSE_BYTES) {
|
|
174
172
|
if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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));
|
|
173
|
+
clock.heard(played.get(current.itemId) ?? 0);
|
|
174
|
+
if (client.bufferedAmount > SOCKET_FUSE_BYTES && Date.now() - sentAt > 10_000) throw new Error("voice_audio_backpressure");
|
|
175
|
+
if (!((acknowledges && clock.blocked(current.outputMs)) || client.bufferedAmount > SOCKET_FUSE_BYTES)) break;
|
|
176
|
+
const delay = client.bufferedAmount > SOCKET_FUSE_BYTES ? 20 : Math.max(20, Math.min(200, clock.wait(current.outputMs)));
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
187
178
|
}
|
|
188
179
|
if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
|
|
189
180
|
const chunk = audio.subarray(offset, offset + CHUNK_BYTES);
|
|
@@ -213,6 +204,8 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
213
204
|
if (current.suspended && await current.suspended.wait === "stop") return;
|
|
214
205
|
if (current.abort.signal.aborted || closed) return;
|
|
215
206
|
send({ type: "response.output_audio_transcript.delta", ...part, delta: `${separator(current.sentences.at(-1)?.text, sentence)}${sentence}` });
|
|
207
|
+
// A slice has to reach the player before synthesize continues, so a
|
|
208
|
+
// caller that pauses after the first slice can still be interrupted.
|
|
216
209
|
const audio = await synthesize(sentence, scope, (pcm) => playOut(current, pcm));
|
|
217
210
|
if (current.abort.signal.aborted || closed) return;
|
|
218
211
|
if (audio) await playOut(current, audio);
|
|
@@ -260,7 +253,9 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
260
253
|
if (!turn.accepted) await service.withdraw?.(scope, requestId).catch(() => {});
|
|
261
254
|
return;
|
|
262
255
|
}
|
|
263
|
-
|
|
256
|
+
// A snapshot taken while the answer is still streaming only has the first
|
|
257
|
+
// sentence. Speaking that and remembering the key drops the rest.
|
|
258
|
+
if (speakableUtterance(state.utterance)) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
|
|
264
259
|
};
|
|
265
260
|
const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
|
|
266
261
|
const commit = async () => {
|
|
@@ -512,7 +507,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
|
|
|
512
507
|
observeTasks();
|
|
513
508
|
if (busy || speaking || awaitingPlayback || response) return;
|
|
514
509
|
const state = service.state(scope);
|
|
515
|
-
if (!state.utterance
|
|
510
|
+
if (!speakableUtterance(state.utterance) || state.utterance.key === lastReply) return;
|
|
516
511
|
lastReply = state.utterance.key;
|
|
517
512
|
speak(state.utterance.text, String(state.utterance.key).startsWith("task:") ? "task" : "turn", state.utterance.key, 0);
|
|
518
513
|
} catch { close(); }
|
package/voice/panel.js
CHANGED
|
@@ -168,9 +168,25 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
168
168
|
if (state.busy) return Promise.resolve();
|
|
169
169
|
cancelPreparation();
|
|
170
170
|
const snapshot = configSnapshot ? structuredClone(configSnapshot) : null;
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
const held = sessionId === state.sessionId ? state.raw : drafts.get(sessionId)?.raw;
|
|
172
|
+
if (!(mode === "single" && held)) inputPreparation = prepareInput(snapshot || {});
|
|
173
|
+
const mic = inputPreparation;
|
|
174
|
+
const token = ++intent;
|
|
175
|
+
// The composer stays on text until the microphone is actually open.
|
|
176
|
+
if (!mic) emit({ visible: true });
|
|
173
177
|
return enqueue(async () => {
|
|
178
|
+
if (mic) {
|
|
179
|
+
try { await mic.opened(); }
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (inputPreparation === mic) cancelPreparation();
|
|
182
|
+
if (token !== intent) return;
|
|
183
|
+
const code = error.code || error.message || "voice_failed";
|
|
184
|
+
if (code !== "voice_cancelled") emit({ visible: false, error: code });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (token !== intent) return;
|
|
188
|
+
emit({ visible: true, error: null });
|
|
189
|
+
}
|
|
174
190
|
if (sessionId !== state.sessionId || mode !== state.mode) {
|
|
175
191
|
try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, mode, raw: mode === "single" ? held?.raw || "" : "", draft: mode === "single" ? held?.draft || "" : "", edited: mode === "single" && (held?.edited || false), preset: held?.preset || "raw", stage: mode === "single" && held?.raw ? "preview" : "idle" }); }
|
|
176
192
|
}
|
|
@@ -188,7 +204,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
188
204
|
return enqueue(async () => { try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, raw: held?.raw || "", draft: held?.draft || "", edited: held?.edited || false, preset: held?.preset || "raw", stage: held?.raw ? "preview" : "idle" }); } });
|
|
189
205
|
},
|
|
190
206
|
close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
|
|
191
|
-
pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
|
|
207
|
+
pause() { intent++; if (!state.inputActive) cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
|
|
192
208
|
start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
|
|
193
209
|
openReadout() {
|
|
194
210
|
if (state.mode !== "continuous") return Promise.resolve();
|
|
@@ -219,6 +235,17 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
219
235
|
if (state.busy || state.holding || state.snapshot?.capture === "transcribing") return Promise.resolve();
|
|
220
236
|
const token = ++intent; emit({ holding: true, cancelArmed: false });
|
|
221
237
|
return enqueue(async () => {
|
|
238
|
+
if (inputPreparation?.borrow) {
|
|
239
|
+
try { await inputPreparation.borrow(); }
|
|
240
|
+
catch (error) {
|
|
241
|
+
cancelPreparation();
|
|
242
|
+
if (token !== intent) return;
|
|
243
|
+
const code = error.code || error.message || "voice_failed";
|
|
244
|
+
if (code !== "voice_cancelled") emit({ visible: false, holding: false, cancelArmed: false, error: code });
|
|
245
|
+
else emit({ visible: false, holding: false, cancelArmed: false });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
222
249
|
await prepare(token);
|
|
223
250
|
if (token !== intent || !state.holding || !state.visible) return;
|
|
224
251
|
await call?.controller.stopPlayback();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far ahead of playout a reply may be sent, and the socket buffer that
|
|
3
|
+
* means the client has stopped reading. Both gates share this so a dropped
|
|
4
|
+
* progress tick cannot freeze one path while the other keeps going.
|
|
5
|
+
*
|
|
6
|
+
* The client's queue holds about four seconds. This lead stays inside that
|
|
7
|
+
* queue: a chunk past the lead is still under the cap.
|
|
8
|
+
*/
|
|
9
|
+
export const PLAYOUT_LEAD_MS = 2_000;
|
|
10
|
+
export const SOCKET_FUSE_BYTES = 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Credit for one reply. The playhead moves at 1x and stops when it catches
|
|
14
|
+
* the audio already handed to the client. A gap while the next sentence is
|
|
15
|
+
* synthesized does not pre-pay a burst, and a missing progress tick does not
|
|
16
|
+
* hold the rest: time keeps the playhead moving up to what was sent. Pause
|
|
17
|
+
* freezes it.
|
|
18
|
+
*/
|
|
19
|
+
export function createPlayoutClock({ leadMs = PLAYOUT_LEAD_MS, now = Date.now } = {}) {
|
|
20
|
+
let heard = 0, heldAt = 0, playhead = 0, markedAt = 0, sent = 0;
|
|
21
|
+
const stamp = () => heldAt || now();
|
|
22
|
+
const advance = (sentMs) => {
|
|
23
|
+
if (Number.isFinite(sentMs) && sentMs > sent) sent = sentMs;
|
|
24
|
+
const t = stamp();
|
|
25
|
+
if (!markedAt) { markedAt = t; return; }
|
|
26
|
+
playhead = Math.min(sent, playhead + Math.max(0, t - markedAt));
|
|
27
|
+
markedAt = t;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
heard(ms) { if (Number.isFinite(ms) && ms > heard) heard = ms; },
|
|
31
|
+
pause() {
|
|
32
|
+
if (heldAt) return;
|
|
33
|
+
advance(sent);
|
|
34
|
+
heldAt = now();
|
|
35
|
+
},
|
|
36
|
+
resume() {
|
|
37
|
+
if (!heldAt) return;
|
|
38
|
+
heldAt = 0;
|
|
39
|
+
markedAt = now();
|
|
40
|
+
},
|
|
41
|
+
get paused() { return heldAt !== 0; },
|
|
42
|
+
start() { if (!markedAt) markedAt = now(); },
|
|
43
|
+
credit(sentMs) {
|
|
44
|
+
advance(sentMs);
|
|
45
|
+
return Math.max(heard, playhead);
|
|
46
|
+
},
|
|
47
|
+
blocked(sentMs) { return sentMs - this.credit(sentMs) > leadMs; },
|
|
48
|
+
wait(sentMs) {
|
|
49
|
+
const delay = sentMs - leadMs - this.credit(sentMs);
|
|
50
|
+
return delay > 0 ? delay : 0;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/voice/presentation.js
CHANGED
|
@@ -39,9 +39,10 @@ export function voiceCallFailure(view) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* A voice call that is still playing keeps one strip
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* A voice call that is still playing keeps one strip until it ends: status
|
|
43
|
+
* plus the control that stops it. The hold button is not that strip. Clients
|
|
44
|
+
* render it under the assistant reply that owns playback. A chat readout stays
|
|
45
|
+
* on that same reply and is not this strip.
|
|
45
46
|
*/
|
|
46
47
|
export function voicePlaybackChrome(view) {
|
|
47
48
|
const playback = view?.snapshot?.playback;
|
package/voice/speech.js
CHANGED
|
@@ -134,19 +134,24 @@ export function matchSpeechEvent(event) {
|
|
|
134
134
|
export function createReplySpeaker() {
|
|
135
135
|
let armed = false;
|
|
136
136
|
let skipKey = null;
|
|
137
|
+
// The answer that was already on screen when a turn opened is history. A new
|
|
138
|
+
// turn can deliver its first text in the same snapshot that reports the turn,
|
|
139
|
+
// and that text must still be read: the old code skipped it by its own key.
|
|
140
|
+
let lastKey = null;
|
|
137
141
|
return {
|
|
138
142
|
/** @returns {Utterance | null} the utterance to follow now, or null */
|
|
139
143
|
observe({ sessionChanged, wasRunning, running, utterance }) {
|
|
140
144
|
if (sessionChanged) {
|
|
141
145
|
armed = Boolean(running);
|
|
142
|
-
skipKey = utterance?.key ?? null;
|
|
146
|
+
skipKey = lastKey = utterance?.key ?? null;
|
|
143
147
|
return null;
|
|
144
148
|
}
|
|
145
149
|
if (!wasRunning && running) {
|
|
146
150
|
armed = true;
|
|
147
|
-
skipKey =
|
|
151
|
+
skipKey = lastKey;
|
|
148
152
|
}
|
|
149
153
|
const follow = armed && utterance && utterance.key !== skipKey ? utterance : null;
|
|
154
|
+
if (utterance?.key) lastKey = utterance.key;
|
|
150
155
|
if (!running) armed = false;
|
|
151
156
|
return follow;
|
|
152
157
|
},
|
|
@@ -324,6 +329,26 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
324
329
|
get canPause() { return current?.controls?.canPause === true; },
|
|
325
330
|
pause() { if (!current?.controls?.canPause) return false; current.controls.pause(); emitOutput({ key: current.key, status: "confirming" }); return true; },
|
|
326
331
|
resume() { if (!current?.controls?.canPause) return false; current.controls.resume(); emitOutput({ key: current.key, status: "playing" }); return true; },
|
|
332
|
+
/**
|
|
333
|
+
* Stop the current audio without marking the answer read. A voice take
|
|
334
|
+
* that is cancelled must be able to bring the same answer back; a take
|
|
335
|
+
* that is submitted consumes it instead.
|
|
336
|
+
*/
|
|
337
|
+
suspend() {
|
|
338
|
+
if (!current) return null;
|
|
339
|
+
const key = current.key;
|
|
340
|
+
const source = current.source;
|
|
341
|
+
// `follow` marks a key as read when it starts. A cancelled voice take
|
|
342
|
+
// must be able to follow the same key again, so undo that mark here.
|
|
343
|
+
if (key != null) consumed.delete(key);
|
|
344
|
+
stop();
|
|
345
|
+
silence(source);
|
|
346
|
+
return key;
|
|
347
|
+
},
|
|
348
|
+
/** Mark an answer read without playing it (a voice take owns it now). */
|
|
349
|
+
consume(key) {
|
|
350
|
+
mark(consumed, key);
|
|
351
|
+
},
|
|
327
352
|
stop() {
|
|
328
353
|
if (!current) return;
|
|
329
354
|
mark(consumed, current.key);
|
package/voice/stream-client.js
CHANGED
|
@@ -45,98 +45,180 @@ function clientFacts() {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const RECONNECT_ATTEMPTS = 3;
|
|
49
|
+
const RECONNECT_DELAY_MS = 400;
|
|
50
|
+
|
|
51
|
+
/** A phone can lose the socket (lock screen, app switch, Wi-Fi/cellular) mid-answer. */
|
|
52
|
+
function reconnectable(error) {
|
|
53
|
+
const code = error?.message;
|
|
54
|
+
return code === "voice_disconnected";
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export async function playAudioStream(options) {
|
|
49
58
|
const started = Date.now();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
let
|
|
58
|
-
const
|
|
59
|
-
lastProgressAt = Date.now();
|
|
60
|
-
if (gapReported) { gapReported = false; streamMetric(options, { stage: "playback_gap_end", frames, gapMs: Date.now() - gapAt, ...clientFacts(), context: player.contextState() }); }
|
|
61
|
-
if (connection.readyState !== 1) {
|
|
62
|
-
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
try { connection.send(JSON.stringify({ type: "progress", frames })); }
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
71
|
-
},
|
|
72
|
-
onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
|
|
73
|
-
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
74
|
-
const gapWatch = setInterval(() => {
|
|
75
|
-
if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
|
|
76
|
-
gapReported = true;
|
|
77
|
-
gapAt = lastProgressAt;
|
|
78
|
-
streamMetric(options, { stage: "playback_gap", gapMs: Date.now() - lastProgressAt, frames: player.playedFrames(), ...clientFacts(), context: player.contextState(), sampleRate: player.sampleRate() });
|
|
79
|
-
}, 1000);
|
|
80
|
-
gapWatch.unref?.();
|
|
81
|
-
const noteVisibility = () => {
|
|
82
|
-
if (ready && !complete) streamMetric(options, { stage: "playback_visibility", frames: player.playedFrames(), paused, ...clientFacts(), context: player.contextState() });
|
|
83
|
-
};
|
|
84
|
-
if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", noteVisibility);
|
|
59
|
+
const attempts = Math.max(1, Number(options.reconnectAttempts) || RECONNECT_ATTEMPTS);
|
|
60
|
+
const reconnectDelay = Number.isFinite(options.reconnectDelayMs) ? Math.max(0, options.reconnectDelayMs) : RECONNECT_DELAY_MS;
|
|
61
|
+
// Frames already rendered before the current socket. A reconnected socket
|
|
62
|
+
// replays the answer from its first segment; dropping this prefix keeps the
|
|
63
|
+
// listener where the dropped connection left off.
|
|
64
|
+
let skipFrames = 0;
|
|
65
|
+
let lastError = null;
|
|
66
|
+
let paused = false;
|
|
67
|
+
const controls = { player: null, connection: null, file: null, ready: false };
|
|
85
68
|
options.controls?.({
|
|
86
|
-
get canPause() { return ready; },
|
|
87
|
-
pause() {
|
|
88
|
-
|
|
69
|
+
get canPause() { return controls.ready; },
|
|
70
|
+
pause() {
|
|
71
|
+
if (!controls.ready || paused) return;
|
|
72
|
+
paused = true; controls.player?.pause(); controls.file?.pause();
|
|
73
|
+
if (controls.connection?.readyState === 1) controls.connection.send(JSON.stringify({ type: "pause" }));
|
|
74
|
+
},
|
|
75
|
+
resume() {
|
|
76
|
+
if (!controls.ready || !paused) return;
|
|
77
|
+
paused = false; controls.player?.resume();
|
|
78
|
+
void controls.file?.resume()?.catch?.(() => controls.connection?.close());
|
|
79
|
+
if (controls.connection?.readyState === 1) controls.connection.send(JSON.stringify({ type: "resume" }));
|
|
80
|
+
},
|
|
89
81
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
82
|
+
|
|
83
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
84
|
+
if (attempt > 0) {
|
|
85
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
86
|
+
streamMetric(options, { stage: "playback_reconnect", attempt, skipFrames, code: lastError?.message || "voice_disconnected", ...clientFacts() });
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
|
|
88
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
89
|
+
}
|
|
90
|
+
const connection = await openAudioStream({ ...options, operation: "tts" });
|
|
91
|
+
if (!connection) throw new Error("voice_stream_failed");
|
|
92
|
+
const file = createBrowserPlayer();
|
|
93
|
+
let progressNoted = false, progressBlocked = false;
|
|
94
|
+
let lastProgressAt = Date.now(), gapReported = false, gapAt = 0, formatNoted = false;
|
|
95
|
+
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, segmentIndex = -1;
|
|
96
|
+
// The skipped prefix belongs to this new socket and is acknowledged as it
|
|
97
|
+
// arrives, so the host keeps sending it without waiting for a playhead
|
|
98
|
+
// that only starts after the skip.
|
|
99
|
+
let skipBytes = skipFrames * 2;
|
|
100
|
+
let skippedFrames = 0;
|
|
101
|
+
let skipAckAt = 0;
|
|
102
|
+
const player = createPcmStreamPlayer({
|
|
103
|
+
onProgress: (frames) => {
|
|
104
|
+
lastProgressAt = Date.now();
|
|
105
|
+
if (gapReported) { gapReported = false; streamMetric(options, { stage: "playback_gap_end", frames: skipFrames + frames, gapMs: Date.now() - gapAt, ...clientFacts(), context: player.contextState() }); }
|
|
106
|
+
ack(skipFrames + frames);
|
|
107
|
+
},
|
|
108
|
+
onMetric: (event) => {
|
|
109
|
+
if (event.stage === "playback_started") options.onStatus?.("playing");
|
|
110
|
+
streamMetric(options, { ...event, durationMs: Date.now() - started });
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
controls.player = player;
|
|
114
|
+
controls.connection = connection;
|
|
115
|
+
controls.file = file;
|
|
116
|
+
controls.ready = false;
|
|
117
|
+
const ack = (frames) => {
|
|
118
|
+
if (connection.readyState !== 1) {
|
|
119
|
+
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try { connection.send(JSON.stringify({ type: "progress", frames })); }
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
128
|
+
};
|
|
129
|
+
const writeAudio = (bytes) => {
|
|
130
|
+
let chunk = bytes;
|
|
131
|
+
if (skipBytes > 0) {
|
|
132
|
+
const drop = Math.min(skipBytes, chunk.length);
|
|
133
|
+
skipBytes -= drop;
|
|
134
|
+
skippedFrames += drop / 2;
|
|
135
|
+
if (Date.now() - skipAckAt >= 40) { skipAckAt = Date.now(); ack(skippedFrames); }
|
|
136
|
+
chunk = chunk.subarray(drop);
|
|
137
|
+
if (!chunk.length) return;
|
|
138
|
+
}
|
|
139
|
+
player.write(chunk);
|
|
140
|
+
};
|
|
141
|
+
const gapWatch = setInterval(() => {
|
|
142
|
+
if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
|
|
143
|
+
gapReported = true;
|
|
144
|
+
gapAt = lastProgressAt;
|
|
145
|
+
streamMetric(options, { stage: "playback_gap", gapMs: Date.now() - lastProgressAt, frames: skipFrames + player.playedFrames(), ...clientFacts(), context: player.contextState(), sampleRate: player.sampleRate() });
|
|
146
|
+
}, 1000);
|
|
147
|
+
gapWatch.unref?.();
|
|
148
|
+
const noteVisibility = () => {
|
|
149
|
+
if (ready && !complete) streamMetric(options, { stage: "playback_visibility", frames: skipFrames + player.playedFrames(), paused, ...clientFacts(), context: player.contextState() });
|
|
150
|
+
};
|
|
151
|
+
if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", noteVisibility);
|
|
152
|
+
try {
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
let logged = false;
|
|
155
|
+
const fail = (error) => {
|
|
156
|
+
if (logged) return;
|
|
157
|
+
logged = true;
|
|
158
|
+
reject(error);
|
|
159
|
+
};
|
|
160
|
+
const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
|
|
161
|
+
options.signal?.addEventListener("abort", cancelled, { once: true });
|
|
162
|
+
connection.addEventListener("message", ({ data }) => {
|
|
163
|
+
if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
|
|
164
|
+
let event = null;
|
|
165
|
+
if (typeof data === "string") {
|
|
166
|
+
try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
|
|
167
|
+
if (event?.type === "done") ending = true;
|
|
120
168
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
169
|
+
chain = chain.then(async () => {
|
|
170
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
171
|
+
if (typeof data !== "string") { writeAudio(new Uint8Array(data)); return; }
|
|
172
|
+
if (event.type === "segment") {
|
|
173
|
+
if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
|
|
174
|
+
segmentIndex = event.index;
|
|
175
|
+
}
|
|
176
|
+
else if (event.type === "segment.end") options.onSegment?.(event);
|
|
177
|
+
else if (event.type === "format") {
|
|
178
|
+
await player.start(event); ready = controls.ready = true; lastProgressAt = Date.now();
|
|
179
|
+
if (paused) {
|
|
180
|
+
player.pause();
|
|
181
|
+
if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" }));
|
|
182
|
+
}
|
|
183
|
+
if (!formatNoted) { formatNoted = true; streamMetric(options, { stage: "playback_client", sampleRate: event.sampleRate, context: player.contextState(), ...clientFacts() }); }
|
|
184
|
+
}
|
|
185
|
+
else if (event.type === "file") {
|
|
186
|
+
// A whole-file answer cannot be offset-skipped; a reconnect plays it again.
|
|
187
|
+
ready = controls.ready = true; options.onStatus?.("playing");
|
|
188
|
+
await file.play(wavBlob(event.audio));
|
|
189
|
+
if (connection.readyState === 1) connection.send(JSON.stringify({ type: "file.done" }));
|
|
190
|
+
}
|
|
191
|
+
else if (event.type === "error") throw new Error(event.code || "voice_stream_failed");
|
|
192
|
+
else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
|
|
193
|
+
}).catch(fail);
|
|
194
|
+
});
|
|
195
|
+
const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
|
|
196
|
+
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
197
|
+
connection.addEventListener("close", () => {
|
|
198
|
+
options.signal?.removeEventListener("abort", cancelled);
|
|
199
|
+
if (!complete && !ending) fail(dropped());
|
|
200
|
+
}, { once: true });
|
|
201
|
+
if (options.signal?.aborted) cancelled();
|
|
125
202
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
lastError = error;
|
|
206
|
+
if (reconnectable(error) && attempt < attempts - 1) {
|
|
207
|
+
skipFrames += player.playedFrames();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (error?.message !== "voice_cancelled") {
|
|
211
|
+
streamMetric(options, { stage: "playback_failed", code: error?.message || "voice_playback_failed", durationMs: Date.now() - started });
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
clearInterval(gapWatch);
|
|
216
|
+
if (typeof document !== "undefined" && document.removeEventListener) document.removeEventListener("visibilitychange", noteVisibility);
|
|
217
|
+
if (controls.player === player) { controls.player = null; controls.connection = null; controls.file = null; controls.ready = false; }
|
|
218
|
+
player.stop(); file.stop(); connection.close();
|
|
219
|
+
}
|
|
139
220
|
}
|
|
221
|
+
throw lastError || new Error("voice_disconnected");
|
|
140
222
|
}
|
|
141
223
|
|
|
142
224
|
/** A single final ASR result; partials never submit a task or replace the draft. */
|
package/voice/stream-gateway.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ttsVoiceForModel } from "./tts-voice.js";
|
|
2
2
|
import { speechSegments } from "./script.js";
|
|
3
|
+
import { createPlayoutClock, SOCKET_FUSE_BYTES } from "./playout-clock.js";
|
|
3
4
|
import { createSynthesisRuns, utteranceKey } from "./synthesis-run.js";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -48,14 +49,15 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
48
49
|
server.handleUpgrade(req, socket, head, (client) => {
|
|
49
50
|
clients.add(client);
|
|
50
51
|
const abort = new AbortController();
|
|
51
|
-
let upstream = null, sentFrames = 0, playedFrames = 0, rate = 0,
|
|
52
|
+
let upstream = null, sentFrames = 0, playedFrames = 0, rate = 0, eventWaiter = null, creditWaiter = null, outputFormat = null;
|
|
53
|
+
const clock = createPlayoutClock();
|
|
52
54
|
let fileDone = null, pausedAt = 0, closing = false, ttsKey = null;
|
|
53
55
|
let received = 0, committed = false, outputStarted = false;
|
|
54
56
|
const started = Date.now();
|
|
55
57
|
const send = (value) => { if (client.readyState === 1) client.send(JSON.stringify(value)); };
|
|
56
58
|
const close = () => {
|
|
57
59
|
if (closing) return;
|
|
58
|
-
closing = true; abort.abort();
|
|
60
|
+
closing = true; abort.abort(); eventWaiter?.(); creditWaiter?.(); eventWaiter = creditWaiter = null; fileDone?.(); clearTimeout(lifetime); upstream?.terminate();
|
|
59
61
|
// A consumer that disappeared stops its synthesis instead of leaving
|
|
60
62
|
// the whole answer to be rendered with nobody listening.
|
|
61
63
|
if (ttsKey) synthesis.release(ttsKey);
|
|
@@ -84,12 +86,14 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
84
86
|
} else {
|
|
85
87
|
if (binary) throw fault("voice_stream_state");
|
|
86
88
|
const frame = JSON.parse(String(raw));
|
|
87
|
-
if (frame.type === "pause") { if (!pausedAt) pausedAt = Date.now();
|
|
88
|
-
if (frame.type === "resume") { pausedAt = 0;
|
|
89
|
+
if (frame.type === "pause") { clock.pause(); if (!pausedAt) pausedAt = Date.now(); creditWaiter?.(); creditWaiter = null; return; }
|
|
90
|
+
if (frame.type === "resume") { clock.resume(); pausedAt = 0; creditWaiter?.(); creditWaiter = null; return; }
|
|
89
91
|
if (frame.type === "file.done" && fileDone) { fileDone(); fileDone = null; return; }
|
|
90
92
|
if (frame.type !== "progress" || !Number.isSafeInteger(frame.frames) || frame.frames < playedFrames || frame.frames > sentFrames) throw fault("voice_stream_state");
|
|
91
93
|
const firstProgress = playedFrames === 0 && frame.frames > 0;
|
|
92
|
-
playedFrames = frame.frames;
|
|
94
|
+
playedFrames = frame.frames;
|
|
95
|
+
if (rate) clock.heard(frame.frames * 1000 / rate);
|
|
96
|
+
creditWaiter?.(); creditWaiter = null;
|
|
93
97
|
if (firstProgress) metric({ stage: "playback_progress", voiceId: grant.scope.id, frames: playedFrames });
|
|
94
98
|
}
|
|
95
99
|
} catch (cause) { error(cause); }
|
|
@@ -117,11 +121,16 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
117
121
|
for (let index = 0; index < parts.length; index += 1) yield { text: parts[index], index };
|
|
118
122
|
})(), { model: grant.model, voice: ttsVoiceForModel(grant.config, grant.model), voiceId: grant.scope.id });
|
|
119
123
|
let cursor = 0;
|
|
120
|
-
const
|
|
124
|
+
const wakeEvent = () => { const fn = eventWaiter; eventWaiter = null; fn?.(); };
|
|
125
|
+
// New PCM must not clear the playout timer. A long answer keeps
|
|
126
|
+
// producing chunks; waking the credit wait on each one restarted the
|
|
127
|
+
// stall deadline, so a phone that had already played its buffer never
|
|
128
|
+
// received the rest and never got an error either.
|
|
129
|
+
const unsub = run.subscribe(wakeEvent);
|
|
121
130
|
const waitFor = () => new Promise((resolve, reject) => {
|
|
122
131
|
if (abort.signal.aborted) { reject(fault("voice_cancelled")); return; }
|
|
123
|
-
const wake = () => { abort.signal.removeEventListener("abort", wake);
|
|
124
|
-
|
|
132
|
+
const wake = () => { abort.signal.removeEventListener("abort", wake); eventWaiter = null; if (abort.signal.aborted) reject(fault("voice_cancelled")); else resolve(); };
|
|
133
|
+
eventWaiter = wake;
|
|
125
134
|
abort.signal.addEventListener("abort", wake, { once: true });
|
|
126
135
|
});
|
|
127
136
|
let reportedWait = false;
|
|
@@ -143,21 +152,23 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
143
152
|
} else if (event.type === "audio") {
|
|
144
153
|
if (!rate) throw fault("voice_audio_format", 502);
|
|
145
154
|
for (let offset = 0; offset < event.chunk.length; offset += 16384) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
// Stay a short lead ahead of playout. Reported frames move
|
|
156
|
+
// the playhead; if those ticks stop, the reply still advances
|
|
157
|
+
// with the clock. The socket buffer is only a fuse for a
|
|
158
|
+
// client that has stopped reading.
|
|
159
|
+
const sentMs = () => sentFrames * 1000 / rate;
|
|
160
|
+
while (clock.paused || clock.blocked(sentMs()) || client.bufferedAmount > SOCKET_FUSE_BYTES) {
|
|
161
|
+
if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: clock.paused }); }
|
|
151
162
|
await new Promise((resolve, reject) => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
waiter = () => { clearTimeout(timer); resolve(); };
|
|
163
|
+
const reading = !clock.paused && client.bufferedAmount <= SOCKET_FUSE_BYTES;
|
|
164
|
+
const limit = clock.paused ? Math.max(1, pauseHoldMs - (Date.now() - pausedAt)) : reading ? Math.max(15, Math.min(250, clock.wait(sentMs()) || 15)) : playbackStallMs;
|
|
165
|
+
const timer = setTimeout(() => { creditWaiter = null; if (reading) resolve(); else reject(fault(clock.paused ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
|
|
166
|
+
creditWaiter = () => { clearTimeout(timer); resolve(); };
|
|
167
|
+
if (!(clock.paused || clock.blocked(sentMs()) || client.bufferedAmount > SOCKET_FUSE_BYTES)) { clearTimeout(timer); creditWaiter = null; resolve(); }
|
|
158
168
|
});
|
|
159
169
|
if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
|
|
160
170
|
}
|
|
171
|
+
clock.start();
|
|
161
172
|
if (client.readyState !== 1) throw fault("voice_cancelled");
|
|
162
173
|
const part = event.chunk.subarray(offset, offset + 16384);
|
|
163
174
|
sentFrames += part.length / 2;
|
|
@@ -274,7 +274,9 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
274
274
|
lastSessionId = state.sessionId;
|
|
275
275
|
if (lastSessionId) controller.bindSession(lastSessionId);
|
|
276
276
|
if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
|
|
277
|
-
|
|
277
|
+
// A finished reply is not a running agent: keeping it "running" left the
|
|
278
|
+
// panel on Working forever and blocked the next continuous turn.
|
|
279
|
+
controller.observeAgent(Boolean(state.running), Boolean(reply?.audio));
|
|
278
280
|
};
|
|
279
281
|
const watch = async () => {
|
|
280
282
|
if (disposed || controller.getSnapshot().connection === "closed") return;
|