@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 +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 +15 -2
- package/voice/panel.js +23 -6
- package/voice/pcm-player.js +46 -6
- package/voice/presentation.js +18 -1
- package/voice/stream-client.js +30 -6
- 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,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 },
|
|
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") {
|
|
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 {
|
|
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())
|
|
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 () => {
|
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
|
@@ -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
|
|
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);
|
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 }) => {
|
|
@@ -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
|
-
|
|
69
|
-
connection.addEventListener("
|
|
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
|
-
|
|
105
|
-
connection.addEventListener("
|
|
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 {
|
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
|
}
|