@olares/lares-core 0.15.27 → 0.15.29

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.
@@ -16,6 +16,10 @@ 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;
19
23
  const TICKET_MS = 30_000;
20
24
  // Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
21
25
  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 +50,7 @@ export function joinSpoken(parts) {
46
50
  * `lares-realtime-v1` (docs/realtime-protocol.md) on one endpoint. Bearer voice-key callers get a
47
51
  * scope owned by the socket; ticket sockets attach to the Lares client's open voice scope.
48
52
  */
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 }) {
53
+ 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
54
  const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
51
55
  const clients = new Map();
52
56
  const tickets = new Map();
@@ -61,14 +65,31 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
61
65
  let closed = false, busy = false, pendingCommit = false, configuring = false, alive = true, generation = 0, submittedTurns = 0;
62
66
  let response = null, awaitingPlayback = false, acknowledges = false, lastReply = null, lastSpoken = null, turn = null, port = null, unbind = () => {};
63
67
  let hostOutput = Promise.resolve();
68
+ let ackTimer = null;
64
69
  const tasks = new Map();
65
70
  const spoken = new Map();
66
71
  const played = new Map();
67
72
 
73
+ // A client that vanishes in the middle of a reply never acknowledges it.
74
+ // Without a watchdog the half-duplex gate stays closed for that connection
75
+ // and every later utterance fails with waiting_for_playback.
76
+ const PLAYBACK_ACK_GRACE_MS = playbackAckGraceMs;
77
+ const clearAckWatch = () => { if (ackTimer) { clearTimeout(ackTimer); ackTimer = null; } };
78
+ const watchAck = () => {
79
+ clearAckWatch();
80
+ if (!awaitingPlayback || closed) return;
81
+ ackTimer = setTimeout(() => {
82
+ ackTimer = null;
83
+ if (closed || !awaitingPlayback) return;
84
+ awaitingPlayback = false;
85
+ send({ type: "lares.playback.timeout" });
86
+ }, PLAYBACK_ACK_GRACE_MS);
87
+ ackTimer.unref?.();
88
+ };
89
+
68
90
  const bargeActive = () => type === "realtime" && !headset && echoCancellation && bargeIn() === true;
69
91
  const send = (event) => {
70
- if (client.readyState !== 1) return;
71
- if (client.bufferedAmount > 1024 * 1024) { close(); return; }
92
+ if (closed || client.readyState !== 1) return;
72
93
  client.send(JSON.stringify({ event_id: `event_${randomUUID()}`, ...event }));
73
94
  };
74
95
  const report = (cause, eventId) => {
@@ -98,6 +119,9 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
98
119
  };
99
120
  const finish = (current, status) => {
100
121
  if (response === current) response = null;
122
+ // Audio that never left the server will not be acknowledged. Holding the
123
+ // gate here is how a failed or empty reply silences every later one.
124
+ if (!current.outputMs) { awaitingPlayback = false; clearAckWatch(); }
101
125
  if (current.done) return;
102
126
  current.done = true;
103
127
  remember(current);
@@ -121,21 +145,48 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
121
145
  current.suspended?.settle("stop");
122
146
  if (headset) { hostOutput = Promise.resolve(); void Promise.resolve(port?.interrupt()).catch(() => close()); send({ type: "lares.device.playback", playing: false }); }
123
147
  awaitingPlayback = false;
148
+ clearAckWatch();
124
149
  finish(current, status);
125
150
  if (truncate) truncateTo(current, played.get(current.itemId) ?? 0);
126
151
  };
127
152
  const playOut = async (current, audio) => {
153
+ if (current.abort.signal.aborted || current.done || closed) return;
128
154
  if (headset) {
129
155
  if (!current.devicePlaying) { current.devicePlaying = true; send({ type: "lares.device.playback", playing: true }); }
130
156
  hostOutput = hostOutput.then(() => current.abort.signal.aborted ? undefined : port?.write(audio));
131
157
  current.outputMs += audio.length / BYTES_PER_MS;
132
158
  return hostOutput;
133
159
  }
134
- if (acknowledges) awaitingPlayback = true;
160
+ if (acknowledges) { awaitingPlayback = true; watchAck(); }
161
+ // A long reply is several seconds of PCM. Dumping it in one burst fills the
162
+ // socket and used to close the call, which cut the readout off and made the
163
+ // client's next progress tick throw voice_disconnected. Wait for the buffer
164
+ // the way the speech stream does; a stuck client fails this reply only.
165
+ const OUTPUT_BUFFER = 128 * 1024;
166
+ let sentAt = Date.now();
167
+ let lastPlayed = played.get(current.itemId) ?? 0;
168
+ let playedAt = Date.now();
135
169
  for (let offset = 0; offset < audio.length; offset += CHUNK_BYTES) {
170
+ while (client.bufferedAmount > OUTPUT_BUFFER) {
171
+ if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
172
+ if (Date.now() - sentAt > 10_000) throw new Error("voice_audio_backpressure");
173
+ await new Promise((resolve) => setTimeout(resolve, 20));
174
+ }
175
+ // Playout credit: never get more than two seconds ahead of what the
176
+ // client has actually rendered. Progress advances in real time, so a
177
+ // long answer waits here instead of filling the worklet queue.
178
+ while (acknowledges && current.outputMs - lastPlayed > PLAY_AHEAD_MS) {
179
+ if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
180
+ const heard = played.get(current.itemId) ?? 0;
181
+ if (heard > lastPlayed) { lastPlayed = heard; playedAt = Date.now(); }
182
+ if (Date.now() - playedAt > 10_000) throw new Error("voice_audio_backpressure");
183
+ await new Promise((resolve) => setTimeout(resolve, 20));
184
+ }
185
+ if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
136
186
  const chunk = audio.subarray(offset, offset + CHUNK_BYTES);
137
187
  current.outputMs += chunk.length / BYTES_PER_MS;
138
188
  send({ type: "response.output_audio.delta", response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0, delta: chunk.toString("base64") });
189
+ sentAt = Date.now();
139
190
  }
140
191
  };
141
192
  const respond = async (text, origin, key, input) => {
@@ -209,11 +260,20 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
209
260
  if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
210
261
  };
211
262
  const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
212
- const commit = async ({ endpointed = false } = {}) => {
213
- if (!bytes) return;
214
- // Server VAD keeps listening while a turn is processed; the next endpoint waits for it.
215
- if (busy && endpointed) { pendingCommit = true; return; }
216
- if (busy) fail("turn_busy");
263
+ const commit = async () => {
264
+ if (closed) return;
265
+ // A commit with no samples still has a client waiting in transcribing.
266
+ // Returning here never completes that take, so the composer stays on 转写中.
267
+ if (!bytes) {
268
+ const id = itemId || `item_${randomUUID()}`;
269
+ reset();
270
+ send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: "" });
271
+ return;
272
+ }
273
+ // The previous turn is still being transcribed or submitted. The mic is
274
+ // shared and the client cannot know that, so queue this take instead of
275
+ // rejecting it; the composer would otherwise see a hard turn_busy.
276
+ if (busy) { pendingCommit = true; return; }
217
277
  const input = Buffer.concat(chunks), id = itemId || `item_${randomUUID()}`, ms = input.length / BYTES_PER_MS, opened = stream;
218
278
  stream = null; reset(); scope.voiceInputActive = false; busy = true;
219
279
  send({ type: "input_audio_buffer.committed", item_id: id, previous_item_id: null });
@@ -239,20 +299,33 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
239
299
  } catch (cause) { if (!closed && epoch === generation) report(cause); }
240
300
  finally {
241
301
  busy = false;
242
- if (pendingCommit) { pendingCommit = false; void commit({ endpointed: true }).catch(report); }
302
+ if (pendingCommit) { pendingCommit = false; void commit().catch(report); }
303
+ else drainTyped();
243
304
  }
244
305
  };
306
+ // Typed text that arrives while a take is in flight waits its turn instead
307
+ // of being rejected: on a shared link the client cannot tell it is early.
308
+ const typedQueue = [];
309
+ const drainTyped = () => {
310
+ if (closed || busy || !typedQueue.length) return;
311
+ const next = typedQueue.shift();
312
+ void runTyped(next).catch(report);
313
+ };
314
+ const runTyped = async ({ id, text }) => {
315
+ if (closed) return;
316
+ busy = true;
317
+ userItem(id, { type: "input_text", text });
318
+ try { await submit(text, id, generation, 0); }
319
+ finally { busy = false; drainTyped(); }
320
+ };
245
321
  const typed = async (event) => {
246
322
  if (type !== "realtime") fail("unsupported_parameter");
247
323
  const item = only(event.item, ["id", "type", "role", "content"]);
248
324
  const content = Array.isArray(item.content) && item.content.length === 1 ? only(item.content[0], ["type", "text"]) : fail("unsupported_parameter");
249
325
  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");
250
- if (busy) fail("turn_busy");
251
- busy = true;
252
326
  const id = typeof item.id === "string" && item.id ? item.id.slice(0, 128) : `item_${randomUUID()}`;
253
- userItem(id, { type: "input_text", text: content.text });
254
- try { await submit(content.text, id, generation, 0); }
255
- finally { busy = false; }
327
+ if (busy) { if (typedQueue.length >= 8) fail("turn_busy"); typedQueue.push({ id, text: content.text }); return; }
328
+ await runTyped({ id, text: content.text });
256
329
  };
257
330
  const startStream = () => {
258
331
  const current = { queue: [], live: null, id: itemId };
@@ -270,7 +343,8 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
270
343
  };
271
344
  const append = (audio) => {
272
345
  if (awaitingPlayback && !bargeActive()) fail("waiting_for_playback");
273
- if (busy && !automatic) fail("turn_busy");
346
+ // Audio that arrives while the previous take is being transcribed belongs
347
+ // to the next turn; keep it and let that commit queue behind the first one.
274
348
  if (typeof audio !== "string" || audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(audio)) fail("unsupported_audio_format");
275
349
  const chunk = Buffer.from(audio, "base64");
276
350
  if (chunk.length % 2) fail("unsupported_audio_format");
@@ -285,7 +359,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
285
359
  if (automatic && bargeActive() && response && !response.suspended && speechMs >= BARGE_IN_MS) suspend(response);
286
360
  if (automatic && endpoint.push(1, rms, chunk.length / BYTES_PER_MS)) {
287
361
  send({ type: "input_audio_buffer.speech_stopped", audio_end_ms: Math.round(totalMs), item_id: itemId });
288
- void commit({ endpointed: true }).catch(report);
362
+ void commit().catch(report);
289
363
  }
290
364
  };
291
365
  const suspend = (current) => {
@@ -382,14 +456,20 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
382
456
  speak(lastSpoken.text, lastSpoken.origin, lastSpoken.key, 0);
383
457
  return;
384
458
  case "response.cancel":
459
+ // The client has stopped listening. A reply that already finished must
460
+ // not keep the next utterance behind waiting_for_playback.
461
+ awaitingPlayback = false;
462
+ clearAckWatch();
385
463
  if (busy && turn) generation++;
386
464
  cancel("cancelled");
387
465
  return;
388
466
  case "lares.playback.progress":
389
467
  acknowledges = true;
390
468
  if (typeof event.item_id === "string" && Number.isFinite(event.played_ms) && event.played_ms >= 0) played.set(event.item_id, event.played_ms);
469
+ // Progress is the client proving it is still rendering the reply.
470
+ watchAck();
391
471
  return;
392
- case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; return;
472
+ case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; clearAckWatch(); return;
393
473
  case "lares.task.cancel": {
394
474
  if (typeof event.task_id !== "string" || !tasks.has(event.task_id)) fail("task_not_cancellable");
395
475
  if (await service.withdraw(scope, event.task_id) === "not_cancellable") fail("task_not_cancellable");
@@ -408,7 +488,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
408
488
 
409
489
  const close = () => {
410
490
  if (closed) return;
411
- closed = true; response?.abort.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
491
+ closed = true; response?.abort.abort(); generation++; reset(); clearAckWatch(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
412
492
  unbind(); clients.delete(client); client.close();
413
493
  void Promise.resolve(port?.stop()).catch(() => {});
414
494
  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 = AbortSignal.any([abort.signal, AbortSignal.timeout(45_000)]);
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 {
@@ -29,6 +29,38 @@ export function primePlaybackContext(Context = globalThis.AudioContext || global
29
29
 
30
30
  export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
31
31
  let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false, owned = false;
32
+ /**
33
+ * The page shares one primed context. iOS interrupts it (a call, the lock
34
+ * screen) and it never resumes on its own, so a reply that starts after that
35
+ * gets a fresh context instead of a silent failure.
36
+ */
37
+ const openContext = async (shared) => {
38
+ if (shared) {
39
+ try {
40
+ await ensureWorklet(shared);
41
+ await shared.resume?.();
42
+ if (!shared.state || shared.state === "running") {
43
+ owned = false; context = shared;
44
+ return { opened: shared, shared: true };
45
+ }
46
+ } catch { /* replaced below */ }
47
+ worklets.delete(shared);
48
+ if (primed === shared) primed = null;
49
+ }
50
+ const fresh = new Context();
51
+ owned = true;
52
+ try {
53
+ await ensureWorklet(fresh);
54
+ await fresh.resume?.();
55
+ if (fresh.state && fresh.state !== "running") throw new Error("voice_playback_failed");
56
+ } catch (error) {
57
+ owned = false;
58
+ if (fresh.state !== "closed") void fresh.close?.().catch(() => {});
59
+ throw error;
60
+ }
61
+ context = fresh;
62
+ return { opened: fresh, shared: false };
63
+ };
32
64
  const stop = () => {
33
65
  generation++; paused = false; node?.disconnect(); if (node) node.port.onmessage = null; node = null;
34
66
  const closing = owned ? context : null;
@@ -42,17 +74,14 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
42
74
  if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
43
75
  const epoch = generation;
44
76
  const shared = primed && primed.state !== "closed" ? primed : null;
45
- owned = !shared;
46
- context = shared || new Context(); const opened = context;
77
+ let opened;
47
78
  try {
48
- await ensureWorklet(opened);
49
- await opened.resume?.();
79
+ ({ opened } = await openContext(shared));
50
80
  if (epoch !== generation) throw new Error("voice_cancelled");
51
- if (opened.state && opened.state !== "running") throw new Error("voice_playback_failed");
52
- onMetric({ stage: "playback_context", state: opened.state || "unknown", shared: Boolean(shared) });
81
+ onMetric({ stage: "playback_context", state: opened.state || "unknown", shared: Boolean(shared && opened === shared) });
53
82
  } catch (error) {
54
- onMetric({ stage: "playback_context", state: opened.state || "missing", shared: Boolean(shared), code: error?.message || "voice_playback_failed" });
55
- if (owned && opened.state !== "closed") void opened.close?.().catch(() => {});
83
+ onMetric({ stage: "playback_context", state: context?.state || "missing", shared: Boolean(shared), code: error?.message || "voice_playback_failed" });
84
+ if (owned && context && context.state !== "closed") void context.close?.().catch(() => {});
56
85
  owned = false; context = null;
57
86
  throw error;
58
87
  }
@@ -38,6 +38,20 @@ export function voiceCallFailure(view) {
38
38
  return { code: ui.error, action: recoverable ? "resume" : "restart" };
39
39
  }
40
40
 
41
+ /**
42
+ * Playback stays visible at the composer until it ends.
43
+ * A hidden readout call and a chat reader both use this one strip:
44
+ * status plus the control that stops it. The hold button is not that strip.
45
+ */
46
+ export function voicePlaybackChrome(view, readout) {
47
+ const playback = view?.snapshot?.playback;
48
+ if (playback && playback !== "idle") {
49
+ return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
50
+ }
51
+ if (!readout) return null;
52
+ return { status: `readout.${readout.status}`, stop: "readout", stopLabel: readout.status === "error" ? "readout.dismiss" : "panel.close" };
53
+ }
54
+
41
55
  /** Shared inline labels; clients own focus, positioning and the timer tick. */
42
56
  export function voiceInputPresentation(view, now = Date.now()) {
43
57
  const ui = voicePanelPresentation(view);
@@ -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) { send({ type: "lares.playback.progress", item_id: itemId, played_ms: playedMs }); },
28
- playbackCompleted(itemId = null) { send({ type: "lares.playback.completed", item_id: itemId }); },
29
- stopOutput() { send({ type: "response.cancel" }); },
30
- truncate(itemId, audioEndMs) { send({ type: "conversation.item.truncate", item_id: itemId, content_index: 0, audio_end_ms: Math.max(0, Math.round(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" }); },
@@ -56,7 +56,21 @@ export function createRouterAudio(config) {
56
56
  let chain = opened;
57
57
  return {
58
58
  write(pcm) { chain = chain.then(() => { if (socket.readyState === 1) socket.send(Buffer.from(convert(pcmSamples(pcm)))); }); chain.catch(() => {}); },
59
- async finish() { await chain; socket.send(JSON.stringify({ type: "finish" })); try { return await final; } finally { socket.close(); } },
59
+ async finish() {
60
+ // Close the socket from a timer so a silent upstream cannot pin `final`:
61
+ // the close handler is what rejects it, and that close used to wait on `final`.
62
+ let timer;
63
+ try {
64
+ await chain;
65
+ if (socket.readyState !== 1) return null;
66
+ socket.send(JSON.stringify({ type: "finish" }));
67
+ timer = setTimeout(() => socket.close(), 12000);
68
+ return await final;
69
+ } finally {
70
+ clearTimeout(timer);
71
+ if (socket.readyState === 1) socket.close();
72
+ }
73
+ },
60
74
  cancel() { socket.terminate(); },
61
75
  };
62
76
  },
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
- if (Date.now() - scope.seen > 120_000 && !deviceLease.owns(scope)) {
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 timer = setTimeout(() => { cleanup(); resolve(); }, Math.max(0, deadline - Date.now()));
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
- const mark = (set, key) => { if (key != null) set.add(key); };
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);
@@ -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() { finishAt = Date.now(); finished = true; connection.send(JSON.stringify({ type: "finish" })); try { return await result; } finally { connection.close(); } },
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
  }
@@ -144,7 +157,7 @@ export async function startTranscriptionStream(options) {
144
157
  export async function createStreamingCapture(stream, options) {
145
158
  const port = await (options.port ? options.port(options) : startTranscriptionStream(options));
146
159
  if (!port) return null;
147
- let context, source, node, failure, flushed;
160
+ let context, source, node, failure, flushed, captured = 0;
148
161
  const release = () => {
149
162
  source?.disconnect(); source = null;
150
163
  node?.disconnect(); if (node) node.port.onmessage = null; node = null;
@@ -162,16 +175,25 @@ export async function createStreamingCapture(stream, options) {
162
175
  node = new AudioWorkletNode(context, "lares-pcm-capture", { numberOfOutputs: 1, outputChannelCount: [1] });
163
176
  node.port.onmessage = ({ data }) => {
164
177
  if (data.type === "flushed") { flushed?.(); return; }
165
- try { port.write(convert(data.samples)); } catch (cause) { failure = cause; port.cancel(); }
178
+ try {
179
+ const pcm = convert(data.samples);
180
+ if (pcm?.length) captured += pcm.length;
181
+ port.write(pcm);
182
+ } catch (cause) { failure = cause; port.cancel(); }
166
183
  };
167
184
  source.connect(node); node.connect(context.destination);
168
185
  return {
186
+ get committed() { return port.committed === true; },
187
+ get replayable() { return port.replayable !== false; },
169
188
  async finish() {
170
189
  try {
171
190
  await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("voice_timeout")), 2000); flushed = () => { clearTimeout(timer); resolve(); }; node.port.postMessage({ type: "flush" }); });
172
191
  // No capture messages may follow the ASR commit, even while finalization waits.
173
192
  release();
174
193
  if (failure) throw failure;
194
+ // No PCM reached the socket. Committing it makes the Host drop the take
195
+ // without a transcript, and the composer stays on 转写中.
196
+ if (!captured) { port.cancel(); return ""; }
175
197
  return await port.finish();
176
198
  } catch (cause) { port.cancel(); throw cause; }
177
199
  finally { release(); }
@@ -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 = () => { if (closing) return; closing = true; abort.abort(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate(); clients.delete(client); client.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 };
@@ -79,7 +79,10 @@ export function createSynthesisRuns({ synthesize, metric = () => {} }) {
79
79
  }
80
80
 
81
81
  return {
82
- /** `open` is used only for the first subscriber of `key`. */
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
  };