@olares/lares-core 0.15.22 → 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 +25 -2
- package/i18n/voice.js +2 -0
- package/package.json +1 -1
- package/router/catalog.d.ts +1 -1
- package/router/catalog.js +5 -4
- package/voice/controller.js +1 -0
- package/voice/conversation.js +2 -1
- package/voice/panel.js +6 -3
- package/voice/pcm-player.js +46 -6
- package/voice/presentation.js +15 -0
- package/voice/stream-client.js +25 -3
- package/voice/stream-gateway.js +9 -1
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
|
-
"
|
|
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
|
-
-
|
|
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
package/router/catalog.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 /
|
|
52
|
+
const cap = Math.max(1, Math.floor(contextWindow / 10));
|
|
52
53
|
return declared === null ? cap : Math.min(declared, cap);
|
|
53
54
|
}
|
|
54
55
|
|
package/voice/controller.js
CHANGED
|
@@ -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;
|
package/voice/conversation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -46,7 +47,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
|
|
|
46
47
|
const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null, metric: (event) => {
|
|
47
48
|
metric(event);
|
|
48
49
|
void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {});
|
|
49
|
-
}, 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() {
|
|
50
51
|
const reply = lastUtterance;
|
|
51
52
|
playback.stop();
|
|
52
53
|
if (!reply || !Number.isFinite(reply.turn) || typeof lastSessionId !== "string") return;
|
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;
|
|
@@ -136,8 +136,10 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
136
136
|
readAloudPending = pending;
|
|
137
137
|
return pending.promise;
|
|
138
138
|
},
|
|
139
|
-
retryReadAloud() { return panel.setReadAloud(readAloudPreference); },
|
|
139
|
+
retryReadAloud() { armOutput(); return panel.setReadAloud(readAloudPreference); },
|
|
140
|
+
arm() { armOutput(); },
|
|
140
141
|
open({ mode = state.mode, sessionId = state.sessionId, preset, configSnapshot } = {}) {
|
|
142
|
+
armOutput();
|
|
141
143
|
if (state.busy) return Promise.resolve();
|
|
142
144
|
cancelPreparation();
|
|
143
145
|
const snapshot = configSnapshot ? structuredClone(configSnapshot) : null;
|
|
@@ -162,7 +164,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
162
164
|
},
|
|
163
165
|
close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
|
|
164
166
|
pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
|
|
165
|
-
start() { const token = ++intent; return enqueue(() => prepare(token)); },
|
|
167
|
+
start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
|
|
166
168
|
finish() {
|
|
167
169
|
if (state.busy) return Promise.resolve();
|
|
168
170
|
const token = intent;
|
|
@@ -182,6 +184,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
182
184
|
return enqueue(async () => { await transform(preset); return true; });
|
|
183
185
|
},
|
|
184
186
|
holdStart() {
|
|
187
|
+
armOutput();
|
|
185
188
|
if (state.busy || state.holding || state.snapshot?.capture === "transcribing") return Promise.resolve();
|
|
186
189
|
const token = ++intent; emit({ holding: true, cancelArmed: false });
|
|
187
190
|
return enqueue(async () => {
|
package/voice/pcm-player.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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;
|
package/voice/presentation.js
CHANGED
|
@@ -14,6 +14,21 @@ export function voicePanelPresentation(view) {
|
|
|
14
14
|
return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
|
|
15
15
|
}
|
|
16
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
|
+
|
|
17
32
|
/** Shared inline labels; clients own focus, positioning and the timer tick. */
|
|
18
33
|
export function voiceInputPresentation(view, now = Date.now()) {
|
|
19
34
|
const ui = voicePanelPresentation(view);
|
package/voice/stream-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 }) => {
|
|
@@ -67,7 +86,10 @@ export async function playAudioStream(options) {
|
|
|
67
86
|
});
|
|
68
87
|
const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
|
|
69
88
|
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
70
|
-
connection.addEventListener("close", () => {
|
|
89
|
+
connection.addEventListener("close", () => {
|
|
90
|
+
options.signal?.removeEventListener("abort", cancelled);
|
|
91
|
+
if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
|
|
92
|
+
}, { once: true });
|
|
71
93
|
if (options.signal?.aborted) cancelled();
|
|
72
94
|
});
|
|
73
95
|
return true;
|
package/voice/stream-gateway.js
CHANGED
|
@@ -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) => {
|
|
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
|
}
|