@olares/lares-core 0.15.19 → 0.15.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/i18n/voice.js CHANGED
@@ -140,6 +140,7 @@ export const ZH = {
140
140
  "error.voice_tts_unavailable": "没有可用的语音播报模型,请先在语音设置里安装",
141
141
  "error.voice_speak_disabled": "语音播报是关着的",
142
142
  "error.voice_speak_failed": "语音播报失败,请重试",
143
+ "error.voice_playback_failed": "语音播报失败,请重试",
143
144
  "error.voice_speak_timeout": "语音播报超时,请重试",
144
145
  "error.voice_speak_unready": "当前不能保存语音播报开关",
145
146
  "error.voice_gpu_failed": "切换显卡模式失败({detail})。可以点重试;仍然失败,请到 Olares「设置 → GPU」查看显卡状态",
@@ -423,6 +424,7 @@ export const EN = {
423
424
  "error.voice_tts_unavailable": "No speech model is available. Install one in voice settings first",
424
425
  "error.voice_speak_disabled": "Speech playback is off",
425
426
  "error.voice_speak_failed": "Couldn't speak the answer, please retry",
427
+ "error.voice_playback_failed": "Couldn't speak the answer, please retry",
426
428
  "error.voice_speak_timeout": "Speech timed out, please retry",
427
429
  "error.voice_speak_unready": "Speech playback can't be saved right now",
428
430
  "error.voice_gpu_failed":
@@ -221,7 +221,7 @@ export function transcriptBlocks(items, { running = false } = {}) {
221
221
  if (showHeader && !folded && memberSet.has(entry)) {
222
222
  blocks.push({
223
223
  kind: "fold",
224
- key: `fold-${group.turn ?? `g${groupIndex}`}-${entry.index}`,
224
+ key: `fold-${group.turn ?? `g${groupIndex}`}-${entry.row?.key || entry.row?.id || entry.index}`,
225
225
  phase,
226
226
  foldable,
227
227
  reason,
@@ -232,7 +232,12 @@ export function transcriptBlocks(items, { running = false } = {}) {
232
232
  folded = true;
233
233
  }
234
234
  if (memberSet.has(entry) && showHeader) continue;
235
- blocks.push({ kind: "row", key: `row-${entry.index}`, index: entry.index, row: entry.row });
235
+ blocks.push({
236
+ kind: "row",
237
+ key: entry.row?.key || entry.row?.id || `row-${entry.index}`,
238
+ index: entry.index,
239
+ row: entry.row,
240
+ });
236
241
  }
237
242
  });
238
243
  return blocks;
@@ -152,6 +152,9 @@ export function foldTranscript(entries) {
152
152
  };
153
153
 
154
154
  const putBlock = (turn, step, index, row) => {
155
+ // The block is one row for its whole life. Keying it by the event being
156
+ // folded makes the key change on every chunk, and the list remounts.
157
+ row.key = `block:${blockKey(turn, step, index)}:${row.type}`;
155
158
  const stored = upsert(items, blocks, blockKey(turn, step, index), tag(row));
156
159
  // The turn's readings belong under its closing reply, the row whose action
157
160
  // bar ends the turn on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.19",
3
+ "version": "0.15.21",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
package/router/tts.js CHANGED
@@ -89,6 +89,9 @@ export async function synthesizeStream(request, onChunk) {
89
89
  const signal = request.signal
90
90
  ? AbortSignal.any([request.signal, timeout])
91
91
  : timeout;
92
+ // A complete PCM body. The Qwen speech engine's streaming generator does not
93
+ // yield, so `stream: true` holds the model slot until the client times out
94
+ // and the readout never leaves "preparing".
92
95
  res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
93
96
  method: "POST",
94
97
  headers: { "content-type": "application/json", accept: "audio/l16,audio/wav" },
@@ -96,7 +99,6 @@ export async function synthesizeStream(request, onChunk) {
96
99
  model: request.model,
97
100
  input: text,
98
101
  response_format: "pcm",
99
- stream: true,
100
102
  ...(request.voice ? { voice: request.voice } : {}),
101
103
  }),
102
104
  signal,
@@ -121,15 +123,17 @@ export async function synthesizeStream(request, onChunk) {
121
123
  if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new VoiceError("voice_audio_format", 502, "missing or invalid PCM sample rate");
122
124
  await request.onFormat?.({ sampleRate, channels: 1, encoding: "pcm_s16le" });
123
125
  const reader = res.body.getReader();
126
+ let bytes = 0;
124
127
  try {
125
128
  while (true) {
126
129
  const { done, value } = await reader.read();
127
130
  if (done) break;
128
- if (value?.byteLength) await onChunk(Buffer.from(value));
131
+ if (value?.byteLength) { bytes += value.byteLength; await onChunk(Buffer.from(value)); }
129
132
  }
130
133
  } finally {
131
134
  await reader.cancel().catch(() => {});
132
135
  reader.releaseLock();
133
136
  }
137
+ if (!bytes) throw new VoiceError("voice_audio_format", 502, "speech returned no audio");
134
138
  return { streamed: true, sampleRate };
135
139
  }
package/voice/client.js CHANGED
@@ -106,8 +106,15 @@ export async function postSpeak({ key, text, done, part }) {
106
106
  };
107
107
  }
108
108
 
109
- export async function postPlaybackStop(signal) {
110
- const res = await fetch(`${API}/playback/stop`, { method: "POST", signal, headers: voiceHeaders() });
109
+ export async function postPlaybackStop(source, signal) {
110
+ const body = source && typeof source === "object" && typeof source.addEventListener !== "function" ? source : undefined;
111
+ if (!body || typeof body.sessionId !== "string" || !Number.isSafeInteger(body.turn)) return { stopped: false };
112
+ const res = await fetch(`${API}/playback/stop`, {
113
+ method: "POST",
114
+ signal: body ? signal : source,
115
+ headers: { ...voiceHeaders(), ...(body ? { "content-type": "application/json" } : {}) },
116
+ body: body ? JSON.stringify({ sessionId: body.sessionId, turn: body.turn, step: body.step, attemptId: body.attemptId }) : undefined,
117
+ });
111
118
  if (!res.ok) throw new Error(String(res.status));
112
119
  return res.json();
113
120
  }
@@ -34,7 +34,12 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
34
34
  const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null, metric: (event) => {
35
35
  metric(event);
36
36
  void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {});
37
- }, output: { get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() { mute(lastUtterance, lastSessionId); playback.stop(); await call("/playback/stop", {}); } }, agent: {
37
+ }, output: { get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() {
38
+ const reply = lastUtterance;
39
+ playback.stop();
40
+ if (!reply || !Number.isFinite(reply.turn) || typeof lastSessionId !== "string") return;
41
+ await call("/playback/stop", { sessionId: lastSessionId, turn: reply.turn, step: reply.step, attemptId: reply.attemptId });
42
+ } }, agent: {
38
43
  lazy: true,
39
44
  create: async () => null,
40
45
  async submit({ text, requestId, signal }) {
@@ -67,7 +72,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
67
72
  if (reserve) spokenKey = key;
68
73
  controller.observeAgent(Boolean(state.running), Boolean(reserve));
69
74
  if (!readAloud || reply?.silent) controller.observePlayback(false);
70
- if (!turnMuted && readAloud && key !== mutedKey && reply) onReply({ ...reply, sessionId: state.sessionId, ...(device === "device" ? { source: { sessionId: state.sessionId, turn: reply.turn, step: reply.step, attemptId: reply.attemptId } } : {}), key: `${voiceId}:${state.sessionId}:${reply.key}` }, headers);
75
+ if (!turnMuted && readAloud && key !== mutedKey && reply) onReply({ ...reply, sessionId: state.sessionId, source: { sessionId: state.sessionId, turn: reply.turn, step: reply.step, attemptId: reply.attemptId }, key: `${voiceId}:${state.sessionId}:${reply.key}` }, headers);
71
76
  }
72
77
  if (device === "headset" && !state.running && !reading) {
73
78
  const audio = await call("/playback/status");
@@ -106,6 +111,13 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
106
111
  clearTimeout(timer);
107
112
  const initial = onTranscript ? {} : await call("/session", { sessionId, readAloud, presentationSession, ...(preset ? { preset } : {}), ...(backend ? { backend } : {}) });
108
113
  if (!shouldStart()) { await stop(); return; }
114
+ // The answer already on screen belongs to the chat reader. Re-subscribing
115
+ // here attaches to a run that reader is about to cancel.
116
+ if (initial?.utterance && typeof initial.sessionId === "string") {
117
+ mute(initial.utterance, initial.sessionId);
118
+ mutedKey = `${initial.sessionId}:${initial.utterance.key}`;
119
+ spokenKey = mutedKey;
120
+ }
109
121
  await controller.start({ sessionId, continuous, manual, running: initial.running, shouldStart });
110
122
  controller.setOutputMuted(!readAloud);
111
123
  if (!onTranscript) void watch();
package/voice/duplex.js CHANGED
@@ -5,13 +5,16 @@
5
5
  export function createDuplexCoordinator(stopPlayback = async () => {}) {
6
6
  let speech = null;
7
7
 
8
- function beginSpeech(externalSignal) {
9
- if (speech) {
8
+ function beginSpeech(externalSignal, id = "") {
9
+ if (speech && (!id || speech.id !== id)) {
10
10
  speech.controller.abort();
11
11
  void Promise.resolve(stopPlayback()).catch(() => {});
12
+ speech = null;
12
13
  }
14
+ if (speech && id && speech.id === id) return speech.pub;
13
15
  const controller = new AbortController();
14
16
  const lease = {
17
+ id,
15
18
  controller,
16
19
  active() {
17
20
  return speech === lease && !controller.signal.aborted;
@@ -20,6 +23,7 @@ export function createDuplexCoordinator(stopPlayback = async () => {}) {
20
23
  if (speech === lease) speech = null;
21
24
  },
22
25
  };
26
+ lease.pub = { signal: controller.signal, active: lease.active, finish: lease.finish };
23
27
  speech = lease;
24
28
  if (externalSignal) {
25
29
  const abort = () => {
@@ -30,10 +34,11 @@ export function createDuplexCoordinator(stopPlayback = async () => {}) {
30
34
  if (externalSignal.aborted) abort();
31
35
  else externalSignal.addEventListener("abort", abort, { once: true });
32
36
  }
33
- return { signal: controller.signal, active: lease.active, finish: lease.finish };
37
+ return lease.pub;
34
38
  }
35
39
 
36
- async function interrupt() {
40
+ async function interrupt(id = "") {
41
+ if (id && speech?.id !== id) return;
37
42
  const current = speech;
38
43
  speech = null;
39
44
  current?.controller.abort();
@@ -12,7 +12,7 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
12
12
  const notify = row => { row.version = (row.version || 0) + 1; for (const wake of row.listeners) wake(); };
13
13
  const fail = (row, code) => { row.segmenter.reset(); row.ready = []; row.error = code; row.done = true; row.at = now(); notify(row); };
14
14
  const advance = row => {
15
- if (!row.claimed || row.error || !row.eligible && !row.done) return;
15
+ if (!row.subscribers || row.error || !row.eligible && !row.done) return;
16
16
  try { row.ready.push(...row.segmenter.update(row.utterance?.text || "", { done: row.done })); }
17
17
  catch (error) { fail(row, error.message === "voice_reply_rewritten" ? error.message : "voice_reply_invalid"); }
18
18
  };
@@ -28,7 +28,7 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
28
28
  }
29
29
  if (rows.size >= 128) return;
30
30
  rows.set(key, { sessionId, turn: frame.turn, step: frame.step, attemptId: frame.attemptId,
31
- segmenter: segmenterFactory(), ready: [], eligible: frame.speechEligible === true, utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set() });
31
+ segmenter: segmenterFactory(), ready: [], eligible: frame.speechEligible === true, utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
32
32
  return;
33
33
  }
34
34
  for (const row of rows.values()) {
@@ -61,10 +61,10 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
61
61
  await inspect(source.sessionId);
62
62
  const row = rows.get(identity(source));
63
63
  if (!row || source.attemptId && source.attemptId !== row.attemptId) throw fault("voice_reply_unavailable");
64
- if (row.claimed) throw fault("voice_output_owned");
65
- row.claimed = true; advance(row);
66
64
  return async function* segments(signal) {
67
65
  let index = 0;
66
+ row.subscribers += 1;
67
+ advance(row);
68
68
  try {
69
69
  while (!signal.aborted) {
70
70
  if (row.error) throw fault(row.error);
@@ -88,11 +88,17 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
88
88
  }
89
89
  throw fault("voice_cancelled");
90
90
  } finally {
91
- if (!row.done) fail(row, "voice_cancelled");
92
- row.segmenter.reset();
91
+ row.subscribers -= 1;
93
92
  }
94
93
  };
95
94
  },
95
+ /** Ends this answer for every subscriber. A later turn is a different row. */
96
+ cancel(source) {
97
+ if (!source || typeof source.sessionId !== "string") return;
98
+ const row = rows.get(identity(source));
99
+ if (!row || row.done) return;
100
+ fail(row, "voice_cancelled");
101
+ },
96
102
  remove(sessionId) { for (const [key, row] of rows) if (row.sessionId === sessionId) { fail(row, "voice_session_closed"); rows.delete(key); } },
97
103
  close() { for (const row of rows.values()) fail(row, "voice_session_closed"); rows.clear(); },
98
104
  };
package/voice/service.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./session.js";
23
23
  import { createCaptureSession } from "./capture.js";
24
24
  import { createDuplexCoordinator } from "./duplex.js";
25
+ import { utteranceKey } from "./synthesis-run.js";
25
26
  import { createDeviceLease } from "./lease.js";
26
27
  import { normalizeSttPcm } from "./pcm.js";
27
28
  import { gapAfter, createSpeechSegmenter } from "./script.js";
@@ -486,8 +487,8 @@ async function readAloud(readout, model, voice, lease, options) {
486
487
 
487
488
  export function playbackStatus(scope) { return { playing: (scope?.duplex ?? duplex).isSpeaking() }; }
488
489
 
489
- export function interruptSpeech(scope) {
490
- return (scope?.duplex ?? duplex).interrupt();
490
+ export function interruptSpeech(scope, source) {
491
+ return (scope?.duplex ?? duplex).interrupt(utteranceKey(source));
491
492
  }
492
493
 
493
494
  export function languageForTranscribe(requestedLanguage, configLanguage) {
package/voice/speech.js CHANGED
@@ -192,9 +192,6 @@ 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 mutedTurns = new Set();
196
- let lastTurn = null;
197
- const turnKey = (value) => Number.isFinite(value.turn) ? `${value.sessionId ?? ""}:${value.turn}` : null;
198
195
  const mark = (set, key) => { if (key != null) set.add(key); };
199
196
 
200
197
  const emitOutput = value => {
@@ -273,20 +270,20 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
273
270
  if (utterance.silent) {
274
271
  if (utterance.key !== lastKey || silencedKey === lastKey) return;
275
272
  silencedKey = lastKey;
273
+ const active = Boolean(current);
274
+ const source = current?.source;
276
275
  stop();
277
- silence();
276
+ if (active) silence(source);
278
277
  return;
279
278
  }
280
279
  if (!utterance.text) return;
281
- const identity = turnKey(utterance);
282
- if (identity && mutedTurns.has(identity)) return;
283
280
  // A later tool call can invalidate uncommitted assistant text.
284
281
  if (stream && !utterance.done && !utterance.source) return;
285
282
  if (current?.key !== utterance.key) {
286
283
  if (consumed.has(utterance.key)) return;
287
- if (current) { mark(mutedTurns, lastTurn); stop(); }
284
+ if (current) { const source = current.source; stop(); silence(source); }
288
285
  gen += 1;
289
- lastKey = utterance.key; lastTurn = identity; mark(consumed, lastKey);
286
+ lastKey = utterance.key; mark(consumed, lastKey);
290
287
  current = {
291
288
  gen,
292
289
  abort: new AbortController(),
@@ -302,7 +299,7 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
302
299
  };
303
300
  current.identity = utterance;
304
301
  const run = current;
305
- onState(true); emitOutput({ ...utterance, status: "preparing", stop: () => { mark(mutedTurns, lastTurn); stop(); silence(); } });
302
+ onState(true); emitOutput({ ...utterance, status: "preparing", stop: () => { mark(consumed, run.key); const source = run.source; stop(); silence(source); } });
306
303
  handOver(run);
307
304
  read(run).catch((err) => failed(run, err));
308
305
  return;
@@ -316,9 +313,11 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
316
313
  pause() { if (!current?.controls?.canPause) return false; current.controls.pause(); emitOutput({ key: current.key, status: "confirming" }); return true; },
317
314
  resume() { if (!current?.controls?.canPause) return false; current.controls.resume(); emitOutput({ key: current.key, status: "playing" }); return true; },
318
315
  stop() {
319
- mark(mutedTurns, lastTurn);
316
+ if (!current) return;
317
+ mark(consumed, current.key);
318
+ const source = current.source;
320
319
  stop();
321
- silence();
320
+ silence(source);
322
321
  },
323
322
  };
324
323
  }
@@ -33,19 +33,7 @@ function streamMetric(options, event) {
33
33
  export async function playAudioStream(options) {
34
34
  const started = Date.now();
35
35
  const connection = await openAudioStream({ ...options, operation: "tts" });
36
- if (!connection) {
37
- const response = await options.request("/api/lares/voice/speak", { method: "POST", headers: options.headers, signal: options.signal, body: { text: options.text } });
38
- if (!response?.ok) throw new Error(response?.body?.error?.code || "voice_stream_failed");
39
- if (response.body.sentences === 0) return true;
40
- if (response.body.bluetooth === "played") return true;
41
- if (options.source) throw new Error("voice_reply_unavailable");
42
- const fallback = createBrowserPlayer();
43
- options.controls?.({ canPause: true, pause: () => fallback.pause(), resume: () => fallback.resume() });
44
- options.onStatus?.("playing");
45
- const cancel = () => fallback.stop(); options.signal?.addEventListener("abort", cancel, { once: true });
46
- try { if (!options.signal?.aborted) await fallback.play(wavBlob(response.body.audio)); } finally { options.signal?.removeEventListener("abort", cancel); fallback.stop(); }
47
- return true;
48
- }
36
+ if (!connection) throw new Error("voice_stream_failed");
49
37
  const file = createBrowserPlayer();
50
38
  const player = createPcmStreamPlayer({ onProgress: (frames) => { if (connection.readyState === 1) connection.send(JSON.stringify({ type: "progress", frames })); },
51
39
  onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
@@ -1,5 +1,6 @@
1
1
  import { ttsVoiceForModel } from "./tts-voice.js";
2
2
  import { speechSegments } from "./script.js";
3
+ import { createSynthesisRuns, utteranceKey } from "./synthesis-run.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { WebSocket, WebSocketServer } from "ws";
5
6
  import { synthesizeStream } from "../router/tts.js";
@@ -11,13 +12,14 @@ const fault = (code, status = 409) => new VoiceError(code, status, code);
11
12
  /** Single-use HTTP grants bind raw audio sockets to the authenticated Host scope. */
12
13
  export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {} }) {
13
14
  const tickets = new Map(), clients = new Set();
15
+ const synthesis = createSynthesisRuns({ synthesize, metric });
14
16
  const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false,
15
17
  handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
16
18
  return {
17
19
  async ticket(scope, body) {
18
- if (scope.device !== "device") return { supported: false };
19
20
  if (!["tts", "stt"].includes(body.operation)) throw fault("voice_config_invalid", 400);
20
21
  const tts = body.operation === "tts";
22
+ if (!tts && scope.device !== "device") return { supported: false };
21
23
  const snapshot = structuredClone(scope.voiceConfig || config());
22
24
  if (tts && (scope.presentationSession || !scope.active)) {
23
25
  const current = config();
@@ -35,7 +37,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
35
37
  const reply = tts && body.source ? await replies?.bind(scope, body.source) : null;
36
38
  if (body.source && !reply) throw fault("voice_reply_unavailable");
37
39
  const ticket = randomUUID();
38
- tickets.set(ticket, { scope, model: selected, operation: body.operation, text: body.text, reply,
40
+ tickets.set(ticket, { scope, model: selected, operation: body.operation, text: body.text, reply, replySource: body.source || null,
39
41
  config: snapshot, language: body.language || snapshot.language, expires: Date.now() + 30000 });
40
42
  return { supported: true, ticket };
41
43
  },
@@ -95,52 +97,68 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
95
97
  upstream.on("error", error);
96
98
  upstream.on("close", () => { send({ type: "eof" }); close(); });
97
99
  } else {
98
- // A stop/new output owns the scope's existing duplex lease too.
99
- const lease = grant.scope.duplex.beginSpeech(abort.signal);
100
- const segments = grant.reply ? grant.reply(lease.signal) : speechSegments(grant.text, { done: true }).map((text, index) => ({ text, index }));
101
- let carry = null;
102
- const run = async () => { for await (const segment of segments) {
103
- if (!lease.active()) throw fault("voice_cancelled");
104
- const { text, index } = segment;
105
- metric({ stage: "synthesis_segment", voiceId: grant.scope.id, index, chars: text.length, at: Date.now() });
106
- send({ type: "segment", index, attemptId: segment.attemptId, startFrame: sentFrames });
107
- const result = await synthesize({ text, model: grant.model, voice: ttsVoiceForModel(grant.config, grant.model), signal: lease.signal,
108
- onFormat(format) { if (outputFormat && (format.channels !== outputFormat.channels || format.encoding !== outputFormat.encoding)) throw fault("voice_audio_format"); outputFormat = format; if (!rate) { rate = format.sampleRate; send({ type: "format", ...format }); } else if (rate !== format.sampleRate) throw fault("voice_audio_format"); },
109
- }, async (chunk) => {
110
- if (!lease.active()) throw fault("voice_cancelled");
111
- if (!rate) throw fault("voice_audio_format", 502);
112
- if (carry !== null) { chunk = Buffer.concat([Buffer.from([carry]), chunk]); carry = null; }
113
- if (chunk.length % 2) { carry = chunk[chunk.length - 1]; chunk = chunk.subarray(0, -1); }
114
- for (let offset = 0; offset < chunk.length; offset += 16384) {
115
- while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
116
- await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
117
- if (!lease.active() || abort.signal.aborted) throw fault("voice_cancelled");
100
+ const key = grant.replySource ? utteranceKey(grant.replySource) : `text:${grant.text}`;
101
+ const run = synthesis.attach(key, (signal) => grant.reply ? grant.reply(signal) : (async function* text() {
102
+ const parts = speechSegments(grant.text, { done: true });
103
+ for (let index = 0; index < parts.length; index += 1) yield { text: parts[index], index };
104
+ })(), { model: grant.model, voice: ttsVoiceForModel(grant.config, grant.model), voiceId: grant.scope.id });
105
+ let cursor = 0;
106
+ const unsub = run.subscribe(() => { waiter?.(); waiter = null; });
107
+ const waitFor = () => new Promise((resolve, reject) => {
108
+ if (abort.signal.aborted) { reject(fault("voice_cancelled")); return; }
109
+ const wake = () => { abort.signal.removeEventListener("abort", wake); waiter = null; if (abort.signal.aborted) reject(fault("voice_cancelled")); else resolve(); };
110
+ waiter = wake;
111
+ abort.signal.addEventListener("abort", wake, { once: true });
112
+ });
113
+ const pump = async () => {
114
+ while (!abort.signal.aborted) {
115
+ if (cursor >= run.events.length) {
116
+ if (run.settled) return;
117
+ await waitFor();
118
+ continue;
118
119
  }
119
- if (!lease.active() || client.readyState !== 1) throw fault("voice_cancelled");
120
- const part = chunk.subarray(offset, offset + 16384);
121
- sentFrames += part.length / 2;
122
- client.send(part);
123
- if (!outputStarted) { outputStarted = true; metric({ stage: "synthesis_first_audio", voiceId: grant.scope.id, at: Date.now(), durationMs: Date.now() - started }); }
120
+ const event = run.events[cursor++];
121
+ if (event.type === "segment") send({ type: "segment", index: event.index, attemptId: event.attemptId, startFrame: sentFrames });
122
+ else if (event.type === "format") {
123
+ const format = event.format;
124
+ if (outputFormat && (format.channels !== outputFormat.channels || format.encoding !== outputFormat.encoding)) throw fault("voice_audio_format");
125
+ outputFormat = format;
126
+ if (!rate) { rate = format.sampleRate; send({ type: "format", ...format }); }
127
+ else if (rate !== format.sampleRate) throw fault("voice_audio_format");
128
+ } else if (event.type === "audio") {
129
+ if (!rate) throw fault("voice_audio_format", 502);
130
+ for (let offset = 0; offset < event.chunk.length; offset += 16384) {
131
+ while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
132
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
133
+ if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
134
+ }
135
+ if (client.readyState !== 1) throw fault("voice_cancelled");
136
+ const part = event.chunk.subarray(offset, offset + 16384);
137
+ sentFrames += part.length / 2;
138
+ client.send(part);
139
+ if (!outputStarted) { outputStarted = true; metric({ stage: "synthesis_first_audio", voiceId: grant.scope.id, at: Date.now(), durationMs: Date.now() - started }); }
140
+ }
141
+ } else if (event.type === "segment.end") send({ type: "segment.end", index: event.index, endFrame: sentFrames });
142
+ else if (event.type === "file") {
143
+ if (rate) throw fault("voice_audio_format");
144
+ await new Promise((resolve, reject) => {
145
+ const timer = setTimeout(() => reject(fault("voice_timeout")), 120000);
146
+ fileDone = () => { clearTimeout(timer); resolve(); };
147
+ send({ type: "file", audio: Buffer.from(event.audio).toString("base64") });
148
+ });
149
+ } else if (event.type === "done") { send({ type: "done" }); return; }
150
+ else if (event.type === "cancelled") throw fault("voice_cancelled");
151
+ else if (event.type === "error") throw fault(event.code || "voice_stream_failed");
124
152
  }
125
- });
126
- send({ type: "segment.end", index, endFrame: sentFrames });
127
- if (carry !== null) throw fault("voice_audio_format");
128
- if (!result.streamed) {
129
- if (rate) throw fault("voice_audio_format");
130
- await new Promise((resolve, reject) => {
131
- const timer = setTimeout(() => reject(fault("voice_timeout")), 120000);
132
- fileDone = () => { clearTimeout(timer); resolve(); };
133
- send({ type: "file", audio: result.audio.toString("base64") });
134
- });
135
- }
136
- if (!lease.active()) throw fault("voice_cancelled");
137
- } };
138
- const wake = () => { waiter?.(); waiter = null; fileDone?.(); fileDone = null; close(); };
139
- lease.signal.addEventListener("abort", wake, { once: true });
140
- void run().then(() => { if (!abort.signal.aborted) send({ type: "done" }); }).catch(error).finally(() => { lease.signal.removeEventListener("abort", wake); lease.finish(); });
153
+ };
154
+ void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => { unsub(); close(); });
141
155
  }
142
156
  });
143
157
  },
144
- close() { tickets.clear(); for (const client of clients) client.terminate(); server.close(); },
158
+ cancel(source) {
159
+ synthesis.cancel(utteranceKey(source));
160
+ replies?.cancel?.(source);
161
+ },
162
+ close() { tickets.clear(); synthesis.close(); for (const client of clients) client.terminate(); server.close(); },
145
163
  };
146
164
  }
@@ -0,0 +1,95 @@
1
+ /** One answer is `sessionId + turn + step + attemptId`. Listeners share that id. */
2
+ export function utteranceKey(source) {
3
+ if (!source || typeof source.sessionId !== "string" || !Number.isSafeInteger(source.turn) || !Number.isSafeInteger(source.step)) return "";
4
+ return JSON.stringify([source.sessionId, source.turn, source.step, source.attemptId || ""]);
5
+ }
6
+
7
+ /**
8
+ * Synthesizes each answer once and fans the PCM out.
9
+ * Cancelling stops delivery immediately. The current response body is still
10
+ * read to the end before the next request is allowed to start, so a cut-off
11
+ * transfer cannot occupy the speech path for the following answer.
12
+ */
13
+ export function createSynthesisRuns({ synthesize, metric = () => {} }) {
14
+ const runs = new Map();
15
+ let chain = Promise.resolve();
16
+ const enqueue = (task) => {
17
+ const next = chain.then(task, task);
18
+ chain = next.then(() => {}, () => {});
19
+ return next;
20
+ };
21
+
22
+ function start(key, open, request) {
23
+ const subscribers = new Set();
24
+ const events = [];
25
+ const abort = new AbortController();
26
+ let cancelled = false;
27
+ let settled = false;
28
+ let carry = null;
29
+ const notify = () => { for (const listener of subscribers) listener(); };
30
+ const push = (event) => { events.push(event); notify(); };
31
+ const finished = (async () => {
32
+ const segments = open(abort.signal);
33
+ try {
34
+ for await (const segment of segments) {
35
+ if (cancelled) break;
36
+ metric({ stage: "synthesis_segment", voiceId: request.voiceId, index: segment.index, chars: segment.text?.length || 0, at: Date.now() });
37
+ push({ type: "segment", index: segment.index, attemptId: segment.attemptId });
38
+ const result = await enqueue(async () => {
39
+ if (cancelled) return { skipped: true };
40
+ return synthesize({
41
+ text: segment.text,
42
+ model: request.model,
43
+ voice: request.voice,
44
+ onFormat(format) { push({ type: "format", format }); },
45
+ }, async (chunk) => {
46
+ if (cancelled || !chunk?.byteLength) return;
47
+ let buf = Buffer.from(chunk);
48
+ if (carry !== null) { buf = Buffer.concat([Buffer.from([carry]), buf]); carry = null; }
49
+ if (buf.length % 2) { carry = buf[buf.length - 1]; buf = buf.subarray(0, -1); }
50
+ if (!buf.length) return;
51
+ push({ type: "audio", chunk: buf });
52
+ });
53
+ });
54
+ if (cancelled || result?.skipped) break;
55
+ if (carry !== null) throw Object.assign(new Error("voice_audio_format"), { code: "voice_audio_format" });
56
+ push({ type: "segment.end", index: segment.index });
57
+ if (!result.streamed) {
58
+ push({ type: "file", audio: result.audio });
59
+ break;
60
+ }
61
+ }
62
+ push(cancelled ? { type: "cancelled" } : { type: "done" });
63
+ } catch (err) {
64
+ const code = err?.code || err?.message || "voice_failed";
65
+ push(cancelled || code === "voice_cancelled" ? { type: "cancelled" } : { type: "error", code });
66
+ } finally {
67
+ settled = true;
68
+ notify();
69
+ await segments?.return?.().catch(() => {});
70
+ }
71
+ })();
72
+ return {
73
+ events,
74
+ get settled() { return settled; },
75
+ subscribe(listener) { subscribers.add(listener); listener(); return () => subscribers.delete(listener); },
76
+ cancel() { if (cancelled || settled) return; cancelled = true; abort.abort(); notify(); },
77
+ finished,
78
+ };
79
+ }
80
+
81
+ return {
82
+ /** `open` is used only for the first subscriber of `key`. */
83
+ attach(key, open, request) {
84
+ let run = runs.get(key);
85
+ if (!run) {
86
+ run = start(key, open, request);
87
+ runs.set(key, run);
88
+ run.finished.finally(() => { if (runs.get(key) === run) runs.delete(key); });
89
+ }
90
+ return run;
91
+ },
92
+ cancel(key) { if (key) runs.get(key)?.cancel(); },
93
+ close() { for (const run of runs.values()) run.cancel(); runs.clear(); },
94
+ };
95
+ }