@olares/lares-core 0.15.21 → 0.15.23

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/brand/identity.js CHANGED
@@ -10,6 +10,11 @@ export const UPSTREAM_TITLE = "DeepSeek Harness";
10
10
 
11
11
  export const THEME_COLOR = "#6366F1";
12
12
 
13
+ /** Letter-by-letter, so a model cannot drop the "a" and emit "olores". */
14
+ function platformSpelling() {
15
+ return `Spell the platform name exactly ${PLATFORM_NAME} (${[...PLATFORM_NAME].join(", ")}).`;
16
+ }
17
+
13
18
  export function replaceProductTitle(title) {
14
19
  return String(title ?? "").split(UPSTREAM_TITLE).join(PRODUCT_NAME);
15
20
  }
@@ -18,10 +23,11 @@ export function replaceProductTitle(title) {
18
23
  export function identityPrompt() {
19
24
  return [
20
25
  `You are ${PRODUCT_NAME}, a helpful assistant running on ${PLATFORM_NAME}.`,
26
+ platformSpelling(),
21
27
  `Prefer olares-cli for ${PLATFORM_NAME} platform tasks when skills apply.`,
22
28
  `olares-cli is on PATH and already signed in as the user: ${PLATFORM_NAME} mounts this application's credential into the container. Never run profile login or profile import — a platform-issued profile refuses both.`,
23
29
  "For image, video, audio, or 3D generation, creation, or image editing — including FlowStudio scenes and workflows (T2V, I2V, R2V) — follow the lares-media-create skill: list that family on Router, then call media_generate, which waits for the result and publishes it. Do not curl FlowStudio. Do not diagnose Router or sync catalogs unless that list is empty.",
24
- "Use read/write/edit for files; use background jobs for long shell work.",
30
+ "To answer a question or look something up, only read: use read, search, and read-only commands such as olares-cli. Do not use write or edit to look something up, take notes, or try a change the user did not ask for. Use write or edit only when the user asked you to change or produce that file, and prefer them over shell for that file work. Use background jobs for long shell work.",
25
31
  "User-visible files belong in the session workspace (cwd) as relative paths. Do not write them under /app: that is the application overlay, and the chat preview cannot open it.",
26
32
  "For the current date or time, run `date`; the process TZ is the user's configured Olares time zone.",
27
33
  "Images the user attaches are already in context; read_image is only for image files that exist on disk, never for an attachment.",
@@ -40,6 +46,7 @@ export function surfacePrompt(webUrl) {
40
46
  `You are interacting with the user through ${PRODUCT_NAME} at ${webUrl}.`,
41
47
  `When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean ${PRODUCT_NAME}.`,
42
48
  "The browser provides no implicit DOM, route, or screenshot context.",
49
+ platformSpelling(),
43
50
  `If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
44
51
  ].join(" ");
45
52
  }
@@ -54,7 +61,9 @@ You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
54
61
  - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
55
62
  - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
56
63
  - Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
57
- - Prefer structured fs tools (read / write / edit) over shell for file work.
64
+ - To look something up, only read. Do not use write or edit to look something up, take notes, or try a change the user did not ask for.
65
+ - Use write or edit only when the user asked you to change or produce that file. Prefer those tools over shell for that file work.
66
+ - ${platformSpelling()}
58
67
  - Long-running shell work can use background jobs; check results with job_output.
59
68
  - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
60
69
  - If asked who you are, you are ${PRODUCT_NAME} on ${PLATFORM_NAME}.
@@ -67,6 +76,20 @@ export const LEGACY_AGENTS_SEEDS = [
67
76
 
68
77
  You are helping inside an Olares workspace via Lares.
69
78
 
79
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
80
+ - olares-cli is already signed in as the user from the credential Olares mounts into this container. Never run \`profile login\` or \`profile import\`; a platform-issued profile refuses both.
81
+ - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
82
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
83
+ - Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
84
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
85
+ - Long-running shell work can use background jobs; check results with job_output.
86
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
87
+ - If asked who you are, you are Lares on Olares.
88
+ `,
89
+ `# AGENTS.md
90
+
91
+ You are helping inside an Olares workspace via Lares.
92
+
70
93
  - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
71
94
  - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
72
95
  - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
package/i18n/voice.js CHANGED
@@ -105,6 +105,7 @@ export const ZH = {
105
105
  "call.start": "语音对话",
106
106
  "call.end": "结束语音",
107
107
  "call.resume": "恢复语音",
108
+ "call.restart": "重新开始",
108
109
  "call.stopPlayback": "停止朗读",
109
110
  "call.cancelTask": "取消任务",
110
111
  "call.listening": "正在收听",
@@ -387,6 +388,7 @@ export const EN = {
387
388
  "call.start": "Voice conversation",
388
389
  "call.end": "End voice",
389
390
  "call.resume": "Resume voice",
391
+ "call.restart": "Start again",
390
392
  "call.stopPlayback": "Stop speaking",
391
393
  "call.cancelTask": "Cancel task",
392
394
  "call.listening": "Listening",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.21",
3
+ "version": "0.15.23",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -14,7 +14,7 @@ export interface RouterCatalogRow {
14
14
  reasoningDefault?: string;
15
15
  /** Tokens one request may span, or null when Router states none. */
16
16
  contextWindow: number | null;
17
- /** Output tokens a turn may reserve: Router's limit capped at a quarter of the window. */
17
+ /** Output tokens a turn may reserve: Router's limit capped at a tenth of the window. */
18
18
  maxTokens: number | null;
19
19
  }
20
20
 
package/router/catalog.js CHANGED
@@ -42,13 +42,14 @@ function tokenCount(value) {
42
42
  /**
43
43
  * Output tokens a turn may ask for. A local engine reports its whole context
44
44
  * as max_output_tokens, and a request that reserves all of it cannot fit
45
- * beside its own prompt: the engine's KV budget refuses it or, worse, admits
46
- * it and evicts every other request. A quarter of the window leaves room for
47
- * the prompt and still exceeds any reply a chat turn writes.
45
+ * beside its own prompt. Compaction subtracts this cap from the model's
46
+ * context window before the 80% pressure line, so the cap has to fit in the
47
+ * remaining fifth or that line never fires. A tenth of the window does, once
48
+ * the summary headroom (8192) is counted, for any window of at least 80k.
48
49
  */
49
50
  export function outputTokenBudget(declared, contextWindow) {
50
51
  if (contextWindow === null) return declared;
51
- const cap = Math.max(1, Math.floor(contextWindow / 4));
52
+ const cap = Math.max(1, Math.floor(contextWindow / 10));
52
53
  return declared === null ? cap : Math.min(declared, cap);
53
54
  }
54
55
 
@@ -107,6 +107,7 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
107
107
  bindSession(sessionId) { if (sessionId) change({ sessionId }); },
108
108
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
109
109
  async start({ sessionId = null, continuous = false, running = false, manual = false, shouldStart = () => true } = {}) {
110
+ output.arm?.();
110
111
  manualTurns = manual;
111
112
  const startedEpoch = ++epoch;
112
113
  creating = null;
@@ -1,8 +1,21 @@
1
+ import { primePlaybackContext } from "./pcm-player.js";
1
2
  import { createVoiceController } from "./controller.js";
2
3
  import { createDeviceInput } from "./device-input.js";
3
4
  import { createHostInput } from "./host-input.js";
4
5
  import { mintRpcId } from "../larepass/rpc.js";
5
6
 
7
+ /** Bound a request without requiring AbortSignal.any / timeout, which some mobile webviews lack. */
8
+ function outputSignal(abort) {
9
+ const signals = [abort.signal];
10
+ if (typeof AbortSignal?.timeout === "function") {
11
+ try { signals.push(AbortSignal.timeout(10_000)); } catch { /* the caller signal still cancels the request */ }
12
+ }
13
+ if (signals.length > 1 && typeof AbortSignal.any === "function") {
14
+ try { return AbortSignal.any(signals); } catch { /* same */ }
15
+ }
16
+ return abort.signal;
17
+ }
18
+
6
19
  export function createVoiceConversation({ request, transcribe, playback, onReply = () => {}, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, backend, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, input: inputPort, captureConfig, streamInput, preparation }) {
7
20
  const voiceId = mintRpcId();
8
21
  const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
@@ -34,7 +47,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
34
47
  const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null, metric: (event) => {
35
48
  metric(event);
36
49
  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() {
50
+ }, output: { arm() { primePlaybackContext(); }, get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() {
38
51
  const reply = lastUtterance;
39
52
  playback.stop();
40
53
  if (!reply || !Number.isFinite(reply.turn) || typeof lastSessionId !== "string") return;
@@ -154,7 +167,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
154
167
  if (error) throw error;
155
168
  const abort = outputAbort = new AbortController();
156
169
  try {
157
- await call("/session/output", { readAloud: next }, AbortSignal.any([abort.signal, AbortSignal.timeout(10_000)]));
170
+ await call("/session/output", { readAloud: next }, outputSignal(abort));
158
171
  if (!disposed && revision === outputRevision) confirmedReadAloud = next;
159
172
  } catch (error) {
160
173
  if (!disposed && revision === outputRevision) {
package/voice/panel.js CHANGED
@@ -2,7 +2,7 @@ import { setVoiceActivity } from "./activity.js";
2
2
  import { DICTATION_PRESETS } from "./dictation.js";
3
3
 
4
4
  /** The panel owns drafts and turn policy; adapters only supply settings, audio and insertion ports. */
5
- export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null }) {
5
+ export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null, armOutput = () => {} }) {
6
6
  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 };
7
7
  let call = null, config = null, generation = 0, intent = 0, processing = null;
8
8
  let inputPreparation = null;
@@ -56,7 +56,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
56
56
  }
57
57
  };
58
58
  const prepare = async (token) => {
59
- if (call?.controller.getSnapshot().connection === "paused") { await call.resume({ shouldResume: () => token === intent && state.visible }); return; }
59
+ if (call?.controller.getSnapshot().connection === "paused") {
60
+ await call.resume({ shouldResume: () => token === intent && state.visible });
61
+ if (token === intent && state.visible && state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
62
+ return;
63
+ }
60
64
  if (call?.controller.getSnapshot().connection === "open") return;
61
65
  await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
62
66
  if (token !== intent || !state.visible) return;
@@ -87,7 +91,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
87
91
  };
88
92
  const pause = async () => {
89
93
  try { if (state.mode === "single") { await finishCapture(); renderDraft(); } else await call?.controller.cancelRecording(); }
90
- finally { if (call?.controller.getSnapshot().connection === "open") await call.pause(); }
94
+ finally {
95
+ if (call?.controller.getSnapshot().connection === "open") await call.pause();
96
+ // The banner belongs to the open call. Leaving it must not keep a mute/sync notice on the chat.
97
+ if (!state.visible) { outputRevision++; emit({ readAloudError: null }); }
98
+ }
91
99
  };
92
100
  const insertDraft = async () => {
93
101
  if (!state.draft || state.stage !== "preview") return;
@@ -108,6 +116,10 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
108
116
  if (!readAloudPending && appliedReadAloud === next && !state.readAloudError) return Promise.resolve(true);
109
117
  const revision = ++outputRevision;
110
118
  const current = () => target === call && epoch === generation && revision === outputRevision;
119
+ // Phone playback is local. Asking for silence already stopped it, so a missed
120
+ // server ack is not a readout failure. A headset can keep playing until the host agrees.
121
+ const localSilence = next !== true && state.device !== "headset";
122
+ if (localSilence) emit({ readAloudError: null });
111
123
  const pending = { target, value: next, promise: null };
112
124
  pending.promise = Promise.resolve().then(() => {
113
125
  if (target !== call || epoch !== generation) return;
@@ -116,14 +128,18 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
116
128
  if (current()) { appliedReadAloud = next; emit({ readAloudError: null }); }
117
129
  return true;
118
130
  }, error => {
119
- if (current()) emit({ readAloudError: error.code || error.message || "voice_failed" });
131
+ if (!current()) return false;
132
+ if (localSilence) { appliedReadAloud = false; emit({ readAloudError: null }); return true; }
133
+ emit({ readAloudError: error.code || error.message || "voice_failed" });
120
134
  return false;
121
135
  }).finally(() => { if (readAloudPending === pending) readAloudPending = null; });
122
136
  readAloudPending = pending;
123
137
  return pending.promise;
124
138
  },
125
- retryReadAloud() { return panel.setReadAloud(readAloudPreference); },
139
+ retryReadAloud() { armOutput(); return panel.setReadAloud(readAloudPreference); },
140
+ arm() { armOutput(); },
126
141
  open({ mode = state.mode, sessionId = state.sessionId, preset, configSnapshot } = {}) {
142
+ armOutput();
127
143
  if (state.busy) return Promise.resolve();
128
144
  cancelPreparation();
129
145
  const snapshot = configSnapshot ? structuredClone(configSnapshot) : null;
@@ -148,7 +164,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
148
164
  },
149
165
  close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
150
166
  pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
151
- start() { const token = ++intent; return enqueue(() => prepare(token)); },
167
+ start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
152
168
  finish() {
153
169
  if (state.busy) return Promise.resolve();
154
170
  const token = intent;
@@ -168,6 +184,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
168
184
  return enqueue(async () => { await transform(preset); return true; });
169
185
  },
170
186
  holdStart() {
187
+ armOutput();
171
188
  if (state.busy || state.holding || state.snapshot?.capture === "transcribing") return Promise.resolve();
172
189
  const token = ++intent; emit({ holding: true, cancelArmed: false });
173
190
  return enqueue(async () => {
@@ -1,21 +1,61 @@
1
1
  import { PCM_PLAYER_WORKLET } from "./pcm-worklet.js";
2
2
  import { pcmSamples } from "./realtime-audio.js";
3
3
 
4
+ let primed = null;
5
+ const worklets = new WeakMap();
6
+
7
+ function ensureWorklet(ctx) {
8
+ const pending = worklets.get(ctx);
9
+ if (pending) return pending;
10
+ const url = URL.createObjectURL(new Blob([PCM_PLAYER_WORKLET], { type: "text/javascript" }));
11
+ let tracked;
12
+ tracked = Promise.resolve(ctx.audioWorklet.addModule(url)).finally(() => URL.revokeObjectURL(url)).catch((error) => {
13
+ if (worklets.get(ctx) === tracked) worklets.delete(ctx);
14
+ throw error;
15
+ });
16
+ worklets.set(ctx, tracked);
17
+ return tracked;
18
+ }
19
+
20
+ /** Create and resume the playback context inside the gesture that asked for sound. */
21
+ export function primePlaybackContext(Context = globalThis.AudioContext || globalThis.webkitAudioContext) {
22
+ if (!Context) return null;
23
+ if (!primed || primed.state === "closed") primed = new Context();
24
+ const resume = primed.resume?.();
25
+ if (resume && typeof resume.catch === "function") resume.catch(() => {});
26
+ ensureWorklet(primed).catch(() => {});
27
+ return primed;
28
+ }
29
+
4
30
  export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
5
- let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false;
31
+ let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false, owned = false;
6
32
  const stop = () => {
7
33
  generation++; paused = false; node?.disconnect(); if (node) node.port.onmessage = null; node = null;
8
- if (context && context.state !== "closed") void context.close().catch(() => {}); context = null; resolve?.(); resolve = reject = null; pendingByte = null;
34
+ const closing = owned ? context : null;
35
+ owned = false;
36
+ if (closing && closing.state !== "closed") void closing.close().catch(() => {});
37
+ context = null; resolve?.(); resolve = reject = null; pendingByte = null;
9
38
  };
10
39
  return {
11
40
  async start({ sampleRate, channels, encoding }) {
12
41
  stop(); failed = null; played = 0;
13
42
  if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
14
43
  const epoch = generation;
15
- context = new Context(); const opened = context;
16
- const url = URL.createObjectURL(new Blob([PCM_PLAYER_WORKLET], { type: "text/javascript" }));
17
- try { await opened.audioWorklet.addModule(url); await opened.resume(); } finally { URL.revokeObjectURL(url); }
18
- if (epoch !== generation) { await opened.close().catch(() => {}); throw new Error("voice_cancelled"); }
44
+ const shared = primed && primed.state !== "closed" ? primed : null;
45
+ owned = !shared;
46
+ context = shared || new Context(); const opened = context;
47
+ try {
48
+ await ensureWorklet(opened);
49
+ await opened.resume?.();
50
+ 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) });
53
+ } 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(() => {});
56
+ owned = false; context = null;
57
+ throw error;
58
+ }
19
59
  node = new AudioWorkletNode(opened, "lares-pcm-player", { processorOptions: { inputRate: sampleRate }, numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
20
60
  node.port.onmessage = ({ data }) => {
21
61
  if (epoch !== generation) return;
@@ -6,12 +6,29 @@ export function voicePanelPresentation(view) {
6
6
  const preview = view.mode === "single" && ["preview", "processing"].includes(view.stage);
7
7
  const preparing = view.busy || capture === "recording" && view.snapshot?.inputReady === false;
8
8
  const recording = capture === "recording" && !preparing;
9
- const error = view.error || view.snapshot?.error;
9
+ const reported = view.error || view.snapshot?.error;
10
+ // A muted call is not a listen session. "Resume to listen again" is the readout copy.
11
+ const error = reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
10
12
  const phase = view.cancelArmed ? "cancel" : error ? "error" : processing ? "processing" : transcribing ? "transcribing" : preparing ? "preparing" : recording ? "recording" : preview ? "preview" : view.snapshot && view.snapshot.playback !== "idle" ? "speaking" : view.snapshot?.agent === "running" ? "working" : "waiting";
11
13
  const status = error ? `error.${error}` : ({ cancel: "gesture.cancelRelease", processing: "dictation.processing", transcribing: "mic.transcribing", preparing: "mic.preparing", recording: "call.listening", preview: "dictation.preview", speaking: "dictation.speaking", working: "call.working", waiting: view.mode === "continuous" ? "gesture.waiting" : "dictation.preview" })[phase];
12
14
  return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
13
15
  }
14
16
 
17
+ const RESTART_ERRORS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
18
+
19
+ /**
20
+ * A continuous call that has failed is one composer banner.
21
+ * The session can still be resumed while it is paused; once the recovery
22
+ * window has closed, the only way back is a new call.
23
+ */
24
+ export function voiceCallFailure(view) {
25
+ if (!view?.visible || view.mode !== "continuous") return null;
26
+ const ui = voicePanelPresentation(view);
27
+ if (!ui.error) return null;
28
+ const recoverable = view.snapshot?.connection === "paused" && !RESTART_ERRORS.has(ui.error);
29
+ return { code: ui.error, action: recoverable ? "resume" : "restart" };
30
+ }
31
+
15
32
  /** Shared inline labels; clients own focus, positioning and the timer tick. */
16
33
  export function voiceInputPresentation(view, now = Date.now()) {
17
34
  const ui = voicePanelPresentation(view);
@@ -35,7 +35,19 @@ export async function playAudioStream(options) {
35
35
  const connection = await openAudioStream({ ...options, operation: "tts" });
36
36
  if (!connection) throw new Error("voice_stream_failed");
37
37
  const file = createBrowserPlayer();
38
- const player = createPcmStreamPlayer({ onProgress: (frames) => { if (connection.readyState === 1) connection.send(JSON.stringify({ type: "progress", frames })); },
38
+ let progressNoted = false, progressBlocked = false;
39
+ const player = createPcmStreamPlayer({ onProgress: (frames) => {
40
+ if (connection.readyState !== 1) {
41
+ if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
42
+ return;
43
+ }
44
+ try { connection.send(JSON.stringify({ type: "progress", frames })); }
45
+ catch (error) {
46
+ if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
47
+ return;
48
+ }
49
+ if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
50
+ },
39
51
  onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
40
52
  let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
41
53
  options.controls?.({
@@ -45,7 +57,14 @@ export async function playAudioStream(options) {
45
57
  });
46
58
  try {
47
59
  await new Promise((resolve, reject) => {
48
- const fail = (error) => { reject(error); connection.close(); };
60
+ let failureLogged = false;
61
+ const noteFailure = (error) => {
62
+ const code = error?.message || "voice_playback_failed";
63
+ if (failureLogged || code === "voice_cancelled") return;
64
+ failureLogged = true;
65
+ streamMetric(options, { stage: "playback_failed", code, durationMs: Date.now() - started });
66
+ };
67
+ const fail = (error) => { noteFailure(error); reject(error); connection.close(); };
49
68
  const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
50
69
  options.signal?.addEventListener("abort", cancelled, { once: true });
51
70
  connection.addEventListener("message", ({ data }) => {
@@ -65,8 +84,12 @@ export async function playAudioStream(options) {
65
84
  else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
66
85
  }).catch(fail);
67
86
  });
68
- connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
69
- connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(new Error("voice_disconnected")); }, { once: true });
87
+ const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
88
+ connection.addEventListener("error", () => fail(dropped()), { once: true });
89
+ connection.addEventListener("close", () => {
90
+ options.signal?.removeEventListener("abort", cancelled);
91
+ if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
92
+ }, { once: true });
70
93
  if (options.signal?.aborted) cancelled();
71
94
  });
72
95
  return true;
@@ -101,8 +124,9 @@ export async function startTranscriptionStream(options) {
101
124
  else if (event.type === "error") fail(new Error(event.code));
102
125
  } catch { fail(new Error("voice_stream_failed")); }
103
126
  });
104
- connection.addEventListener("close", () => fail(new Error("voice_disconnected")), { once: true });
105
- connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
127
+ const dropped = () => fail(new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected"));
128
+ connection.addEventListener("close", dropped, { once: true });
129
+ connection.addEventListener("error", dropped, { once: true });
106
130
  if (options.signal?.aborted) fail(new Error("voice_cancelled"));
107
131
  await ready;
108
132
  return {
@@ -54,7 +54,11 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
54
54
  const started = Date.now();
55
55
  const send = (value) => { if (client.readyState === 1) client.send(JSON.stringify(value)); };
56
56
  const close = () => { if (closing) return; closing = true; abort.abort(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate(); clients.delete(client); client.close(); };
57
- const error = (cause) => { send({ type: "error", code: cause.code || "voice_stream_failed" }); close(); };
57
+ const error = (cause) => {
58
+ const code = cause?.code || "voice_stream_failed";
59
+ if (grant.operation === "tts" && code !== "voice_cancelled") metric({ stage: "playback_stop", voiceId: grant.scope.id, code, sentFrames, playedFrames });
60
+ send({ type: "error", code }); close();
61
+ };
58
62
  const lifetime = setTimeout(() => error(fault("voice_timeout")), 180000);
59
63
  client.on("close", close); client.on("error", close);
60
64
  client.on("message", (raw, binary) => {
@@ -77,7 +81,9 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
77
81
  if (frame.type === "resume") { pausedAt = 0; waiter?.(); waiter = null; return; }
78
82
  if (frame.type === "file.done" && fileDone) { fileDone(); fileDone = null; return; }
79
83
  if (frame.type !== "progress" || !Number.isSafeInteger(frame.frames) || frame.frames < playedFrames || frame.frames > sentFrames) throw fault("voice_stream_state");
84
+ const firstProgress = playedFrames === 0 && frame.frames > 0;
80
85
  playedFrames = frame.frames; waiter?.(); waiter = null;
86
+ if (firstProgress) metric({ stage: "playback_progress", voiceId: grant.scope.id, frames: playedFrames });
81
87
  }
82
88
  } catch (cause) { error(cause); }
83
89
  });
@@ -110,6 +116,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
110
116
  waiter = wake;
111
117
  abort.signal.addEventListener("abort", wake, { once: true });
112
118
  });
119
+ let reportedWait = false;
113
120
  const pump = async () => {
114
121
  while (!abort.signal.aborted) {
115
122
  if (cursor >= run.events.length) {
@@ -129,6 +136,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
129
136
  if (!rate) throw fault("voice_audio_format", 502);
130
137
  for (let offset = 0; offset < event.chunk.length; offset += 16384) {
131
138
  while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
139
+ if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
132
140
  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
141
  if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
134
142
  }