@olares/lares-core 0.15.32 → 0.15.34

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.32",
3
+ "version": "0.15.34",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
- stream?.getTracks().forEach((track) => track.stop());
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;
@@ -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 prepared = preparation; preparation = null;
36
- recorder = createVoiceCapture({ media, streamInput, preparation: prepared, transcribe, submit: done, onStream: (stream) => {
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 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;
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
- while (client.bufferedAmount > OUTPUT_BUFFER) {
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
- 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));
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
- if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
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?.text || state.utterance.key === lastReply || state.utterance.silent) return;
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
- if (mode === "single" && snapshot && !(sessionId === state.sessionId ? state.raw : drafts.get(sessionId)?.raw)) inputPreparation = prepareInput(snapshot);
172
- const token = ++intent; emit({ visible: true });
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();
@@ -1,6 +1,9 @@
1
1
  import { PCM_PLAYER_WORKLET } from "./pcm-worklet.js";
2
2
  import { pcmSamples } from "./realtime-audio.js";
3
3
 
4
+ /** Unread audio the speaker may be holding. A longer reply waits here; it is not a failed call. */
5
+ export const PLAYBACK_QUEUE_SECONDS = 4;
6
+
4
7
  let primed = null;
5
8
  const worklets = new WeakMap();
6
9
  let watchingVisibility = false;
@@ -43,7 +46,10 @@ export function primePlaybackContext(Context = globalThis.AudioContext || global
43
46
  }
44
47
 
45
48
  export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
46
- let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false, owned = false;
49
+ let context, node, resolve, reject, played = 0, accepted = 0, rate = 0, generation = 0, pendingByte = null, paused = false, owned = false;
50
+ let tail = Promise.resolve();
51
+ const waiters = new Set();
52
+ const wake = () => { for (const listen of [...waiters]) listen(); };
47
53
  /**
48
54
  * The page shares one primed context. iOS interrupts it (a call, the lock
49
55
  * screen) and it never resumes on its own, so a reply that starts after that
@@ -85,11 +91,14 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
85
91
  owned = false;
86
92
  if (closing && closing.state !== "closed") void closing.close().catch(() => {});
87
93
  context = null; resolve?.(); resolve = reject = null; pendingByte = null;
94
+ wake();
95
+ tail = Promise.resolve();
88
96
  };
89
97
  return {
90
98
  async start({ sampleRate, channels, encoding }) {
91
- stop(); failed = null; played = 0;
99
+ stop(); played = 0; accepted = 0;
92
100
  if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
101
+ rate = sampleRate;
93
102
  const epoch = generation;
94
103
  const shared = primed && primed.state !== "closed" ? primed : null;
95
104
  let opened;
@@ -109,27 +118,61 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
109
118
  });
110
119
  node.port.onmessage = ({ data }) => {
111
120
  if (epoch !== generation) return;
112
- if (data.type === "progress") { played = data.frames; onProgress(played); }
121
+ if (data.type === "progress") { played = data.frames; onProgress(played); wake(); }
113
122
  if (data.type === "started" || data.type === "underrun") onMetric({ stage: `playback_${data.type}` });
114
123
  if (data.type === "done") { resolve?.(); resolve = reject = null; }
115
- if (data.type === "error") { failed = new Error("voice_audio_backpressure"); reject?.(failed); }
116
124
  };
117
125
  node.connect(opened.destination);
118
126
  },
119
127
  write(bytes) {
120
- if (!node) throw new Error("voice_cancelled");
121
- let chunk = bytes;
122
- if (pendingByte !== null) { chunk = new Uint8Array(bytes.length + 1); chunk[0] = pendingByte; chunk.set(bytes, 1); pendingByte = null; }
123
- if (chunk.length % 2) { pendingByte = chunk[chunk.length - 1]; chunk = chunk.subarray(0, -1); }
124
- if (!chunk.length) return;
125
- const samples = pcmSamples(chunk);
126
- node.port.postMessage({ type: "audio", samples }, [samples.buffer]);
128
+ const epoch = generation;
129
+ const run = () => {
130
+ if (epoch !== generation || !node) throw new Error("voice_cancelled");
131
+ let chunk = bytes;
132
+ if (pendingByte !== null) { chunk = new Uint8Array(bytes.length + 1); chunk[0] = pendingByte; chunk.set(bytes, 1); pendingByte = null; }
133
+ if (chunk.length % 2) { pendingByte = chunk[chunk.length - 1]; chunk = chunk.subarray(0, -1); }
134
+ if (!chunk.length) return;
135
+ const source = pcmSamples(chunk);
136
+ const budget = rate * PLAYBACK_QUEUE_SECONDS;
137
+ let offset = 0;
138
+ const post = (from, count) => {
139
+ const samples = count === source.length && from === 0 ? source : source.slice(from, from + count);
140
+ accepted += samples.length;
141
+ node.port.postMessage({ type: "audio", samples }, [samples.buffer]);
142
+ };
143
+ const pump = () => {
144
+ while (offset < source.length) {
145
+ const room = budget - (accepted - played);
146
+ if (room <= 0) return false;
147
+ const count = Math.min(source.length - offset, room);
148
+ post(offset, count);
149
+ offset += count;
150
+ }
151
+ return true;
152
+ };
153
+ if (pump()) return;
154
+ // The rest of a long reply plays as the speaker catches up.
155
+ return new Promise((done, fail) => {
156
+ const listen = () => {
157
+ if (epoch !== generation || !node) { waiters.delete(listen); fail(new Error("voice_cancelled")); return; }
158
+ if (!pump()) return;
159
+ waiters.delete(listen);
160
+ done();
161
+ };
162
+ waiters.add(listen);
163
+ });
164
+ };
165
+ const pending = tail.then(run, run);
166
+ tail = pending.catch(() => {});
167
+ return pending;
127
168
  },
128
169
  finish() {
129
- if (failed) return Promise.reject(failed);
130
- if (!node) return Promise.resolve();
131
- if (pendingByte !== null) return Promise.reject(new Error("voice_audio_format"));
132
- return new Promise((done, fail) => { resolve = done; reject = fail; node.port.postMessage({ type: "finish" }); });
170
+ const epoch = generation;
171
+ return tail.catch(() => {}).then(() => {
172
+ if (epoch !== generation || !node) return;
173
+ if (pendingByte !== null) throw new Error("voice_audio_format");
174
+ return new Promise((done, fail) => { resolve = done; reject = fail; node.port.postMessage({ type: "finish" }); });
175
+ });
133
176
  },
134
177
  canPause: true,
135
178
  pause() { paused = true; node?.port.postMessage({ type: "pause" }); },
@@ -10,7 +10,6 @@ class LaresPcmPlayer extends AudioWorkletProcessor {
10
10
  this.paused = false; this.queued = 0; this.finished = false; this.started = false; this.starved = false;
11
11
  this.port.onmessage = ({ data }) => {
12
12
  if (data.type === 'audio') {
13
- if (this.queued + data.samples.length > this.rate * 4) { this.port.postMessage({ type: 'error' }); return; }
14
13
  this.queue.push(data.samples); this.queued += data.samples.length;
15
14
  } else if (data.type === 'finish') this.finished = true;
16
15
  else if (data.type === 'pause') this.paused = true;
@@ -0,0 +1,55 @@
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 player holds about four seconds. While reports are still arriving, the
7
+ * wall clock may lead the speaker by one send-ahead and no further: a long
8
+ * reply is not already played just because time passed. A report that stops
9
+ * falls back to the wall clock, so one missed tick does not end the reply.
10
+ */
11
+ export const PLAYOUT_LEAD_MS = 2_000;
12
+ export const SOCKET_FUSE_BYTES = 1024 * 1024;
13
+
14
+ /**
15
+ * Credit for one reply. The playhead moves at 1x and stops when it catches
16
+ * the audio already handed to the client. A gap while the next sentence is
17
+ * synthesized does not pre-pay a burst. Pause freezes the clock.
18
+ */
19
+ export function createPlayoutClock({ leadMs = PLAYOUT_LEAD_MS, now = Date.now } = {}) {
20
+ let heard = 0, heardAt = 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; heardAt = stamp(); } },
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
+ const wall = Math.max(heard, playhead);
46
+ if (heardAt && stamp() - heardAt <= leadMs) return Math.min(wall, heard + leadMs);
47
+ return wall;
48
+ },
49
+ blocked(sentMs) { return sentMs - this.credit(sentMs) > leadMs; },
50
+ wait(sentMs) {
51
+ const delay = sentMs - leadMs - this.credit(sentMs);
52
+ return delay > 0 ? delay : 0;
53
+ },
54
+ };
55
+ }
@@ -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 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.
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;
@@ -126,7 +126,7 @@ export async function playAudioStream(options) {
126
126
  }
127
127
  if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
128
128
  };
129
- const writeAudio = (bytes) => {
129
+ const writeAudio = async (bytes) => {
130
130
  let chunk = bytes;
131
131
  if (skipBytes > 0) {
132
132
  const drop = Math.min(skipBytes, chunk.length);
@@ -136,7 +136,7 @@ export async function playAudioStream(options) {
136
136
  chunk = chunk.subarray(drop);
137
137
  if (!chunk.length) return;
138
138
  }
139
- player.write(chunk);
139
+ await player.write(chunk);
140
140
  };
141
141
  const gapWatch = setInterval(() => {
142
142
  if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
@@ -168,7 +168,7 @@ export async function playAudioStream(options) {
168
168
  }
169
169
  chain = chain.then(async () => {
170
170
  if (options.signal?.aborted) throw new Error("voice_cancelled");
171
- if (typeof data !== "string") { writeAudio(new Uint8Array(data)); return; }
171
+ if (typeof data !== "string") { await writeAudio(new Uint8Array(data)); return; }
172
172
  if (event.type === "segment") {
173
173
  if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
174
174
  segmentIndex = event.index;
@@ -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, waiter = null, outputFormat = null;
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(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate();
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(); waiter?.(); waiter = null; return; }
88
- if (frame.type === "resume") { pausedAt = 0; waiter?.(); waiter = null; return; }
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; waiter?.(); waiter = null;
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 unsub = run.subscribe(() => { waiter?.(); waiter = null; });
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); waiter = null; if (abort.signal.aborted) reject(fault("voice_cancelled")); else resolve(); };
124
- waiter = wake;
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
- while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
147
- if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
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.
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
- // A locked screen or a device switch is a pause, not a
153
- // lost client: hold the answer long enough for the page
154
- // to come back before failing it.
155
- const limit = pausedAt ? Math.max(1, pauseHoldMs - (Date.now() - pausedAt)) : playbackStallMs;
156
- const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
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;
@@ -64,7 +64,7 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
64
64
  return;
65
65
  }
66
66
  if (!current.audio) { current.audio = true; current.chain = current.chain.then(() => player.start({ sampleRate: RATE, channels: 1, encoding: "pcm_s16le" })); controller.observePlayback(true); }
67
- current.chain = current.chain.then(() => { if (reply === current) player.write(decodeBase64(delta)); });
67
+ current.chain = current.chain.then(() => { if (reply === current) return player.write(decodeBase64(delta)); });
68
68
  current.chain.catch(() => {});
69
69
  },
70
70
  async done() {