@olares/lares-core 0.15.23 → 0.15.25

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/voice/keys.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { dirname, join } from "node:path";
3
+ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
4
+ import { dshPluginConfigPath } from "../workspace/home.js";
5
+ import { VOICE_KEY_MODELS } from "./key-models.js";
6
+
7
+ export { VOICE_KEY_MODELS };
8
+ const KEY = /^lvk_([a-f0-9]{16})_[A-Za-z0-9_-]{43}$/;
9
+ const LIMIT = 32;
10
+
11
+ function invalid(message) {
12
+ throw Object.assign(new Error(message), { code: "voice_key_invalid", status: 400 });
13
+ }
14
+ const digest = (key) => createHash("sha256").update(key).digest();
15
+ const view = ({ hash: _hash, ...record }) => record;
16
+ const optional = (value, name) => {
17
+ if (value == null || value === "") return null;
18
+ if (typeof value !== "string" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) invalid(`invalid ${name}`);
19
+ return value;
20
+ };
21
+
22
+ /**
23
+ * Owner-issued credentials for server-side Realtime callers such as Router.
24
+ * Only hashes are stored; the file is re-read on every check so revocation needs no restart.
25
+ */
26
+ export function createVoiceKeyStore({ path = join(dirname(dshPluginConfigPath("voice-input")), "keys.json"), now = Date.now } = {}) {
27
+ const read = () => {
28
+ const stored = readJsonFile(path);
29
+ return Array.isArray(stored?.keys) ? stored.keys : [];
30
+ };
31
+ return {
32
+ list: () => read().map(view),
33
+ issue(body = {}) {
34
+ const name = typeof body.name === "string" ? body.name.trim() : "";
35
+ if (!name || name.length > 64 || /[\u0000-\u001f\u007f]/.test(name)) invalid("invalid key name");
36
+ const models = Array.isArray(body.models) ? [...new Set(body.models)] : [];
37
+ if (!models.length || models.some((model) => !VOICE_KEY_MODELS.includes(model))) invalid("invalid key models");
38
+ const keys = read();
39
+ if (keys.length >= LIMIT) throw Object.assign(new Error("too many voice keys"), { code: "voice_key_limit", status: 409 });
40
+ const id = randomBytes(8).toString("hex");
41
+ const key = `lvk_${id}_${randomBytes(32).toString("base64url")}`;
42
+ const record = { id, name, models, sessionId: optional(body.sessionId, "sessionId"), workspaceId: optional(body.workspaceId, "workspaceId"), createdAt: new Date(now()).toISOString() };
43
+ writeJsonFile(path, { keys: [...keys, { ...record, hash: digest(key).toString("hex") }] });
44
+ return { ...record, key };
45
+ },
46
+ revoke(id) {
47
+ const keys = read();
48
+ const next = keys.filter((record) => record.id !== id);
49
+ if (next.length === keys.length) return false;
50
+ writeJsonFile(path, { keys: next });
51
+ return true;
52
+ },
53
+ /** @returns {null | { id: string, name: string, models: string[], sessionId: string | null, workspaceId: string | null }} */
54
+ verify(authorization) {
55
+ const key = typeof authorization === "string" && authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
56
+ const match = KEY.exec(key);
57
+ if (!match) return null;
58
+ const record = read().find((value) => value.id === match[1]);
59
+ if (!record || typeof record.hash !== "string" || !/^[a-f0-9]{64}$/.test(record.hash)) return null;
60
+ return timingSafeEqual(digest(key), Buffer.from(record.hash, "hex")) ? view(record) : null;
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,491 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocketServer } from "ws";
3
+ import { validateConfigPatch } from "./config.js";
4
+ import { createSpeechEndpoint, frameRms } from "./endpoint.js";
5
+ import { pcmSamples } from "./realtime-audio.js";
6
+ import { speechSegments } from "./script.js";
7
+ import { bindRouterSession } from "../router/session-alias.js";
8
+
9
+ const PRESETS = Object.freeze({ "voice-dictation": "dictation", "voice-chat": "chat", "voice-assistant": "assistant", "voice-direct": "direct" });
10
+ const FORMAT = Object.freeze({ type: "audio/pcm", rate: 24000 });
11
+ const BYTES_PER_MS = 48;
12
+ const TURN_BYTES = 60_000 * BYTES_PER_MS;
13
+ const CHUNK_BYTES = 500 * BYTES_PER_MS;
14
+ const SPEECH_RMS = 0.004;
15
+ // Interruption policy (docs/realtime-protocol.md): speech must last this long to suspect barge-in,
16
+ // and a suspected interruption that never resolves stops the reply.
17
+ const BARGE_IN_MS = 500;
18
+ const BARGE_IN_LIMIT_MS = 10_000;
19
+ const TICKET_MS = 30_000;
20
+ // Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
21
+ const ZERO_USAGE = Object.freeze({ total_tokens: 0, input_tokens: 0, output_tokens: 0, input_token_details: { text_tokens: 0, audio_tokens: 0, cached_tokens: 0 }, output_token_details: { text_tokens: 0, audio_tokens: 0 } });
22
+ const TASK_STATUS = Object.freeze({ accepted: "pending", waiting: "pending", running: "running", completed: "completed", failed: "failed", cancelled: "cancelled" });
23
+
24
+ export const REALTIME_CAPABILITIES = Object.freeze({ protocol: "lares-realtime-v1", transport: "websocket", barge_in: false, playback_ack: true, message_truncation: "played", turn_detection: ["manual", "server_vad"], user_transcription: true, context_resume: true, client_tools: false });
25
+
26
+ class ProtocolError extends Error {
27
+ constructor(code, type = "invalid_request_error") { super(code); this.code = code; this.type = type; }
28
+ }
29
+ const fail = (code, type) => { throw new ProtocolError(code, type); };
30
+ const only = (value, keys) => {
31
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) fail("unsupported_parameter");
32
+ return value;
33
+ };
34
+ const refuse = (socket, status, text) => { socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); };
35
+ const routerSession = (value) => typeof value === "string" && value.trim() && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value) ? value.trim() : null;
36
+ const ticketOf = (req) => String(req.headers["sec-websocket-protocol"] || "").split(",").map((value) => value.trim()).find((value) => value.startsWith("lares-ticket."))?.slice(13);
37
+ const CJK_END = /[ -〿㐀-鿿＀-￯]$/;
38
+ const CJK_START = /^[ -〿㐀-鿿＀-￯]/;
39
+ const separator = (before, after) => !before || CJK_END.test(before) || CJK_START.test(after) ? "" : " ";
40
+ /** Spoken segments are cleaned clauses; rejoin them without inventing spaces inside CJK text. */
41
+ export function joinSpoken(parts) {
42
+ return parts.reduce((text, part) => `${text}${separator(text, part)}${part}`, "");
43
+ }
44
+
45
+ /**
46
+ * `lares-realtime-v1` (docs/realtime-protocol.md) on one endpoint. Bearer voice-key callers get a
47
+ * scope owned by the socket; ticket sockets attach to the Lares client's open voice scope.
48
+ */
49
+ export function createLaresRealtimeGateway({ service, config, transcribe, synthesize, openTranscription, keys, hostOwner = "", hostAudio, busyDefault = () => "queue", bargeIn = () => process.env.LARES_VOICE_BARGE_IN === "1", segment = speechSegments, onError = () => {}, onOpen = () => {}, limit = 64 }) {
50
+ const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
51
+ const clients = new Map();
52
+ const tickets = new Map();
53
+
54
+ function run(client, { scope, model, preset, owned, sessionId }) {
55
+ const type = preset === "dictation" ? "transcription" : "realtime";
56
+ const headset = !owned && scope.device === "headset";
57
+ scope.voiceOverrides ??= {};
58
+ let chunks = [], bytes = 0, turnMs = 0, totalMs = 0, speaking = false, speechMs = 0, itemId = null, partial = "", stream = null;
59
+ let automatic = false, silenceMs = config().endpointSilenceMs || 500, endpoint = createSpeechEndpoint({ silenceMs });
60
+ let audioOutput = type === "realtime" && (owned || scope.readAloud === true), busyTurn = null, echoCancellation = true;
61
+ let closed = false, busy = false, pendingCommit = false, configuring = false, alive = true, generation = 0, submittedTurns = 0;
62
+ let response = null, awaitingPlayback = false, acknowledges = false, lastReply = null, lastSpoken = null, turn = null, port = null, unbind = () => {};
63
+ let hostOutput = Promise.resolve();
64
+ const tasks = new Map();
65
+ const spoken = new Map();
66
+ const played = new Map();
67
+
68
+ const bargeActive = () => type === "realtime" && !headset && echoCancellation && bargeIn() === true;
69
+ const send = (event) => {
70
+ if (client.readyState !== 1) return;
71
+ if (client.bufferedAmount > 1024 * 1024) { close(); return; }
72
+ client.send(JSON.stringify({ event_id: `event_${randomUUID()}`, ...event }));
73
+ };
74
+ const report = (cause, eventId) => {
75
+ const known = cause instanceof ProtocolError;
76
+ if (!known) { try { onError(cause, scope); } catch {} }
77
+ send({ type: "error", error: { type: known ? cause.type : "server_error", code: known ? cause.code : "upstream_failed", message: known ? "Unsupported or invalid realtime event" : "Voice operation failed", ...(eventId ? { event_id: eventId } : {}) } });
78
+ };
79
+ const view = () => ({
80
+ id: scope.id, object: "realtime.session", type, model,
81
+ ...(type === "realtime" ? { output_modalities: [audioOutput ? "audio" : "text"] } : {}),
82
+ audio: {
83
+ input: { format: FORMAT, turn_detection: automatic ? { type: "server_vad", silence_duration_ms: silenceMs } : null, ...(scope.voiceOverrides.language ? { transcription: { language: scope.voiceOverrides.language } } : {}) },
84
+ ...(type === "realtime" ? { output: { format: FORMAT, ...(scope.voiceOverrides.voice ? { voice: scope.voiceOverrides.voice } : {}) } } : {}),
85
+ },
86
+ lares: { capabilities: { ...REALTIME_CAPABILITIES, barge_in: bargeActive() }, context_id: scope.contextId, device: headset ? "headset" : "device" },
87
+ });
88
+ const reset = () => {
89
+ chunks = []; bytes = 0; turnMs = 0; pendingCommit = false; speaking = false; speechMs = 0; itemId = null; partial = "";
90
+ stream?.ready.then((value) => value?.cancel()); stream = null;
91
+ endpoint = createSpeechEndpoint({ silenceMs });
92
+ };
93
+
94
+ // Responses: one reply at a time, synthesized sentence by sentence so truncate lands on what was heard.
95
+ const remember = (current) => {
96
+ spoken.set(current.itemId, current);
97
+ while (spoken.size > 8) spoken.delete(spoken.keys().next().value);
98
+ };
99
+ const finish = (current, status) => {
100
+ if (response === current) response = null;
101
+ if (current.done) return;
102
+ current.done = true;
103
+ remember(current);
104
+ const text = joinSpoken(current.sentences.map((sentence) => sentence.text));
105
+ const item = { id: current.itemId, object: "realtime.item", type: "message", role: "assistant", status: status === "completed" ? "completed" : "incomplete", content: [audioOutput ? { type: "output_audio", transcript: text } : { type: "output_text", text }] };
106
+ send({ type: "response.output_item.done", response_id: current.id, output_index: 0, item });
107
+ send({ type: "conversation.item.done", item });
108
+ send({ type: "response.done", response: { id: current.id, object: "realtime.response", status, output: [], usage: ZERO_USAGE, metadata: { lares: { origin: current.origin, usage: { billing: "child-models", input_audio_ms: Math.round(current.inputMs), output_audio_ms: Math.round(current.outputMs) } } } } });
109
+ };
110
+ const truncateTo = (current, audioEndMs) => {
111
+ const heard = Math.max(0, Math.min(audioEndMs, current.outputMs));
112
+ current.sentences = current.sentences.filter((sentence) => sentence.endMs <= heard + 1);
113
+ const kept = current.sentences.map((sentence) => sentence.text);
114
+ service.truncate?.(scope, current.key, joinSpoken(kept));
115
+ send({ type: "conversation.item.truncated", item_id: current.itemId, content_index: 0, audio_end_ms: Math.round(heard) });
116
+ };
117
+ const cancel = (status = "cancelled", { truncate = false } = {}) => {
118
+ const current = response;
119
+ if (!current) return;
120
+ current.abort.abort(); generation++;
121
+ current.suspended?.settle("stop");
122
+ if (headset) { hostOutput = Promise.resolve(); void Promise.resolve(port?.interrupt()).catch(() => close()); send({ type: "lares.device.playback", playing: false }); }
123
+ awaitingPlayback = false;
124
+ finish(current, status);
125
+ if (truncate) truncateTo(current, played.get(current.itemId) ?? 0);
126
+ };
127
+ const playOut = async (current, audio) => {
128
+ if (headset) {
129
+ if (!current.devicePlaying) { current.devicePlaying = true; send({ type: "lares.device.playback", playing: true }); }
130
+ hostOutput = hostOutput.then(() => current.abort.signal.aborted ? undefined : port?.write(audio));
131
+ current.outputMs += audio.length / BYTES_PER_MS;
132
+ return hostOutput;
133
+ }
134
+ if (acknowledges) awaitingPlayback = true;
135
+ for (let offset = 0; offset < audio.length; offset += CHUNK_BYTES) {
136
+ const chunk = audio.subarray(offset, offset + CHUNK_BYTES);
137
+ current.outputMs += chunk.length / BYTES_PER_MS;
138
+ send({ type: "response.output_audio.delta", response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0, delta: chunk.toString("base64") });
139
+ }
140
+ };
141
+ const respond = async (text, origin, key, input) => {
142
+ if (response) cancel("cancelled");
143
+ const current = { id: `resp_${randomUUID()}`, itemId: `item_${randomUUID()}`, origin, key, inputMs: input, outputMs: 0, sentences: [], abort: new AbortController(), suspended: null, done: false };
144
+ response = current; lastSpoken = { text, origin, key };
145
+ const part = { response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0 };
146
+ send({ type: "response.created", response: { id: current.id, object: "realtime.response", status: "in_progress", output: [], metadata: { lares: { origin } } } });
147
+ send({ type: "response.output_item.added", response_id: current.id, output_index: 0, item: { id: current.itemId, object: "realtime.item", type: "message", role: "assistant", status: "in_progress", content: [] } });
148
+ if (!audioOutput) {
149
+ current.sentences = [{ text, endMs: 0 }];
150
+ send({ type: "response.output_text.delta", ...part, delta: text });
151
+ send({ type: "response.output_text.done", ...part, text });
152
+ finish(current, "completed");
153
+ return;
154
+ }
155
+ const sentences = segment(text).filter((value) => value.trim());
156
+ scope.outputAbort = current.abort;
157
+ try {
158
+ for (const sentence of sentences.length ? sentences : [text]) {
159
+ if (current.suspended && await current.suspended.wait === "stop") return;
160
+ if (current.abort.signal.aborted || closed) return;
161
+ send({ type: "response.output_audio_transcript.delta", ...part, delta: `${separator(current.sentences.at(-1)?.text, sentence)}${sentence}` });
162
+ const audio = await synthesize(sentence, scope, (pcm) => playOut(current, pcm));
163
+ if (current.abort.signal.aborted || closed) return;
164
+ if (audio) await playOut(current, audio);
165
+ current.sentences.push({ text: sentence, endMs: current.outputMs });
166
+ }
167
+ if (headset) {
168
+ await hostOutput; await port?.finish();
169
+ send({ type: "lares.device.playback", playing: false });
170
+ }
171
+ } catch (cause) {
172
+ if (current.abort.signal.aborted || closed) return;
173
+ finish(current, "failed");
174
+ throw cause;
175
+ }
176
+ if (current.abort.signal.aborted || closed) return;
177
+ send({ type: "response.output_audio_transcript.done", ...part, transcript: joinSpoken(current.sentences.map((sentence) => sentence.text)) });
178
+ send({ type: "response.output_audio.done", ...part });
179
+ finish(current, "completed");
180
+ };
181
+ const speak = (text, origin, key, input) => { void respond(text, origin, key, input).catch((cause) => report(cause)); };
182
+
183
+ // Turns.
184
+ const observeTasks = () => {
185
+ const snapshot = scope.session?.snapshot();
186
+ if (owned && scope.routerSessionId && snapshot?.sessionId && !scope.aliasBound) { scope.aliasBound = true; unbind = bindRouterSession(snapshot.sessionId, scope.routerSessionId); }
187
+ for (const task of snapshot?.tasks || []) {
188
+ const status = TASK_STATUS[task.status] || "unknown";
189
+ if (turn && task.requestId.startsWith(turn.id)) turn.accepted = true;
190
+ if (tasks.get(task.requestId) === status) continue;
191
+ tasks.set(task.requestId, status);
192
+ send({ type: "lares.task.updated", task_id: task.requestId, status, session_id: snapshot.sessionId || null });
193
+ }
194
+ };
195
+ const submit = async (text, requestId, epoch, input) => {
196
+ submittedTurns++;
197
+ turn = { id: requestId, accepted: false };
198
+ const running = scope.session?.snapshot().running === true;
199
+ const mode = running ? busyTurn || busyDefault() : undefined;
200
+ let state;
201
+ try { state = await service.prompt(scope, { text, requestId, ...(mode ? { mode } : {}) }); }
202
+ finally { observeTasks(); }
203
+ if (closed) return;
204
+ if (epoch !== generation) {
205
+ // Interrupted before the task was confirmed: take the submission back when dsh still allows it.
206
+ if (!turn.accepted) await service.withdraw?.(scope, requestId).catch(() => {});
207
+ return;
208
+ }
209
+ if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
210
+ };
211
+ const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
212
+ const commit = async ({ endpointed = false } = {}) => {
213
+ if (!bytes) return;
214
+ // Server VAD keeps listening while a turn is processed; the next endpoint waits for it.
215
+ if (busy && endpointed) { pendingCommit = true; return; }
216
+ if (busy) fail("turn_busy");
217
+ const input = Buffer.concat(chunks), id = itemId || `item_${randomUUID()}`, ms = input.length / BYTES_PER_MS, opened = stream;
218
+ stream = null; reset(); scope.voiceInputActive = false; busy = true;
219
+ send({ type: "input_audio_buffer.committed", item_id: id, previous_item_id: null });
220
+ let epoch = generation;
221
+ try {
222
+ let text = null;
223
+ const live = opened && await opened.ready;
224
+ if (live) text = await live.finish().catch(() => null);
225
+ text ??= await transcribe(input, scope);
226
+ if (preset === "dictation" && owned) text = await service.clean(scope, text);
227
+ if (closed || epoch !== generation) return;
228
+ if (response?.suspended) {
229
+ // Barge-in resolves on the interrupting turn: silence resumes the reply, words stop it.
230
+ if (!text.trim()) { response.suspended.settle("resume"); return; }
231
+ cancel("cancelled", { truncate: true });
232
+ epoch = generation;
233
+ }
234
+ // An empty transcript still completes the item, so a client waiting on its take can settle.
235
+ send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: text });
236
+ if (!text.trim()) return;
237
+ userItem(id, { type: "input_audio", transcript: text });
238
+ if (preset !== "dictation") await submit(text, id, epoch, ms);
239
+ } catch (cause) { if (!closed && epoch === generation) report(cause); }
240
+ finally {
241
+ busy = false;
242
+ if (pendingCommit) { pendingCommit = false; void commit({ endpointed: true }).catch(report); }
243
+ }
244
+ };
245
+ const typed = async (event) => {
246
+ if (type !== "realtime") fail("unsupported_parameter");
247
+ const item = only(event.item, ["id", "type", "role", "content"]);
248
+ const content = Array.isArray(item.content) && item.content.length === 1 ? only(item.content[0], ["type", "text"]) : fail("unsupported_parameter");
249
+ if (item.type !== "message" || item.role !== "user" || content.type !== "input_text" || typeof content.text !== "string" || !content.text.trim() || content.text.length > 16000) fail("unsupported_parameter");
250
+ if (busy) fail("turn_busy");
251
+ busy = true;
252
+ const id = typeof item.id === "string" && item.id ? item.id.slice(0, 128) : `item_${randomUUID()}`;
253
+ userItem(id, { type: "input_text", text: content.text });
254
+ try { await submit(content.text, id, generation, 0); }
255
+ finally { busy = false; }
256
+ };
257
+ const startStream = () => {
258
+ const current = { queue: [], live: null, id: itemId };
259
+ current.write = (chunk) => { if (current.live) current.live.write(chunk); else current.queue.push(chunk); };
260
+ current.ready = Promise.resolve(openTranscription?.(scope, (text) => {
261
+ if (text.startsWith(partial) && text.length > partial.length && itemId === current.id) send({ type: "conversation.item.input_audio_transcription.delta", item_id: current.id, content_index: 0, delta: text.slice(partial.length) });
262
+ partial = text;
263
+ })).catch(() => null).then((live) => {
264
+ current.live = live;
265
+ for (const chunk of live ? current.queue : []) live.write(chunk);
266
+ current.queue = [];
267
+ return live;
268
+ });
269
+ return current;
270
+ };
271
+ const append = (audio) => {
272
+ if (awaitingPlayback && !bargeActive()) fail("waiting_for_playback");
273
+ if (busy && !automatic) fail("turn_busy");
274
+ if (typeof audio !== "string" || audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(audio)) fail("unsupported_audio_format");
275
+ const chunk = Buffer.from(audio, "base64");
276
+ if (chunk.length % 2) fail("unsupported_audio_format");
277
+ if (bytes + chunk.length > TURN_BYTES) fail("audio_limit_exceeded");
278
+ itemId ??= `item_${randomUUID()}`;
279
+ if (openTranscription && !stream) stream = startStream();
280
+ chunks.push(chunk); bytes += chunk.length; turnMs += chunk.length / BYTES_PER_MS; totalMs += chunk.length / BYTES_PER_MS;
281
+ stream?.write(chunk);
282
+ const rms = frameRms(pcmSamples(chunk));
283
+ if (rms >= SPEECH_RMS) { touch(); speechMs += chunk.length / BYTES_PER_MS; } else speechMs = 0;
284
+ if (rms >= SPEECH_RMS && !speaking) { speaking = true; scope.voiceInputActive = true; send({ type: "input_audio_buffer.speech_started", audio_start_ms: Math.round(totalMs), item_id: itemId }); }
285
+ if (automatic && bargeActive() && response && !response.suspended && speechMs >= BARGE_IN_MS) suspend(response);
286
+ if (automatic && endpoint.push(1, rms, chunk.length / BYTES_PER_MS)) {
287
+ send({ type: "input_audio_buffer.speech_stopped", audio_end_ms: Math.round(totalMs), item_id: itemId });
288
+ void commit({ endpointed: true }).catch(report);
289
+ }
290
+ };
291
+ const suspend = (current) => {
292
+ let settle;
293
+ const wait = new Promise((resolve) => { settle = resolve; });
294
+ const timer = setTimeout(() => { if (response === current) cancel("cancelled", { truncate: true }); }, BARGE_IN_LIMIT_MS);
295
+ current.suspended = { wait, settle: (value) => { clearTimeout(timer); if (current.suspended?.wait === wait) current.suspended = null; settle(value); } };
296
+ };
297
+
298
+ const configure = async (event) => {
299
+ const session = only(event.session, ["type", "model", "audio", "output_modalities", "instructions", "lares"]);
300
+ if ((session.type && session.type !== type) || (session.model && session.model !== model)) fail("unsupported_parameter");
301
+ const next = { automatic, silenceMs, audioOutput, busyTurn, echoCancellation, overrides: { ...scope.voiceOverrides } };
302
+ let contextId = null, instructions = null;
303
+ if (session.audio !== undefined) {
304
+ const audio = only(session.audio, type === "realtime" ? ["input", "output"] : ["input"]);
305
+ if (audio.input !== undefined) {
306
+ const input = only(audio.input, ["format", "turn_detection", "transcription"]);
307
+ if (input.format !== undefined && (input.format?.type !== FORMAT.type || input.format?.rate !== FORMAT.rate)) fail("unsupported_audio_format");
308
+ if (input.turn_detection === null) next.automatic = false;
309
+ else if (input.turn_detection !== undefined) {
310
+ const detection = only(input.turn_detection, ["type", "silence_duration_ms"]);
311
+ if (detection.type !== "server_vad") fail("unsupported_parameter");
312
+ if (detection.silence_duration_ms !== undefined && (!Number.isInteger(detection.silence_duration_ms) || detection.silence_duration_ms < 200 || detection.silence_duration_ms > 3000)) fail("unsupported_parameter");
313
+ next.automatic = true;
314
+ next.silenceMs = detection.silence_duration_ms ?? next.silenceMs;
315
+ }
316
+ if (input.transcription !== undefined) {
317
+ const { language } = only(input.transcription, ["language"]);
318
+ try { next.overrides.language = validateConfigPatch({ language: language ?? "" }).language || undefined; } catch { fail("unsupported_parameter"); }
319
+ }
320
+ }
321
+ if (audio.output !== undefined) {
322
+ const output = only(audio.output, ["format", "voice"]);
323
+ if (output.format !== undefined && (output.format?.type !== FORMAT.type || output.format?.rate !== FORMAT.rate)) fail("unsupported_audio_format");
324
+ if (output.voice !== undefined) {
325
+ try { next.overrides.voice = validateConfigPatch({ voice: output.voice ?? "" }).voice || undefined; } catch { fail("unsupported_parameter"); }
326
+ }
327
+ }
328
+ }
329
+ if (session.output_modalities !== undefined) {
330
+ if (type !== "realtime" || !Array.isArray(session.output_modalities) || session.output_modalities.length !== 1 || !["audio", "text"].includes(session.output_modalities[0])) fail("unsupported_parameter");
331
+ next.audioOutput = session.output_modalities[0] === "audio";
332
+ }
333
+ if (session.instructions !== undefined) {
334
+ if (preset !== "chat" || typeof session.instructions !== "string" || session.instructions.length > 2000) fail("unsupported_parameter");
335
+ instructions = session.instructions;
336
+ }
337
+ if (session.lares !== undefined) {
338
+ const lares = only(session.lares, ["context_id", "echo_cancellation", "busy_turn"]);
339
+ if (lares.echo_cancellation !== undefined && typeof lares.echo_cancellation !== "boolean") fail("unsupported_parameter");
340
+ if (lares.busy_turn !== undefined && lares.busy_turn !== null && !["queue", "steer"].includes(lares.busy_turn)) fail("unsupported_parameter");
341
+ if (lares.busy_turn !== undefined) next.busyTurn = lares.busy_turn;
342
+ if (lares.echo_cancellation !== undefined) next.echoCancellation = lares.echo_cancellation;
343
+ if (lares.context_id !== undefined && lares.context_id !== scope.contextId) {
344
+ if (!owned || typeof lares.context_id !== "string") fail("unsupported_parameter");
345
+ if (submittedTurns || bytes || busy) fail("context_locked");
346
+ contextId = lares.context_id;
347
+ }
348
+ }
349
+ if (contextId) {
350
+ configuring = true;
351
+ try {
352
+ await service.close(scope);
353
+ await service.open(scope, { preset, sessionId, contextId, readAloud: false });
354
+ } catch { fail("unsupported_parameter"); }
355
+ finally { configuring = false; }
356
+ }
357
+ if (instructions !== null) service.instructions(scope, instructions);
358
+ if (!owned && next.audioOutput !== audioOutput) scope.readAloud = next.audioOutput;
359
+ ({ automatic, silenceMs, audioOutput, busyTurn, echoCancellation } = next);
360
+ scope.voiceOverrides = next.overrides;
361
+ endpoint = createSpeechEndpoint({ silenceMs });
362
+ send({ type: "session.updated", session: view() });
363
+ };
364
+ const truncate = (event) => {
365
+ const current = spoken.get(event.item_id) || (response?.itemId === event.item_id ? response : null);
366
+ if (!current || event.content_index !== 0 || !Number.isInteger(event.audio_end_ms) || event.audio_end_ms < 0) fail("unsupported_parameter");
367
+ if (response === current) cancel("cancelled");
368
+ truncateTo(current, event.audio_end_ms);
369
+ };
370
+ const handle = async (event) => {
371
+ switch (event.type) {
372
+ case "session.update": return configure(event);
373
+ case "input_audio_buffer.append": return append(event.audio);
374
+ case "input_audio_buffer.commit": return commit();
375
+ case "input_audio_buffer.clear": reset(); scope.voiceInputActive = false; send({ type: "input_audio_buffer.cleared" }); return;
376
+ case "conversation.item.create": return typed(event);
377
+ case "conversation.item.truncate": return truncate(event);
378
+ case "response.create":
379
+ if (event.response !== undefined && (typeof event.response !== "object" || event.response === null || Object.keys(event.response).length)) fail("unsupported_parameter");
380
+ if (busy || response) fail("turn_busy");
381
+ if (!lastSpoken) fail("unsupported_parameter");
382
+ speak(lastSpoken.text, lastSpoken.origin, lastSpoken.key, 0);
383
+ return;
384
+ case "response.cancel":
385
+ if (busy && turn) generation++;
386
+ cancel("cancelled");
387
+ return;
388
+ case "lares.playback.progress":
389
+ acknowledges = true;
390
+ if (typeof event.item_id === "string" && Number.isFinite(event.played_ms) && event.played_ms >= 0) played.set(event.item_id, event.played_ms);
391
+ return;
392
+ case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; return;
393
+ case "lares.task.cancel": {
394
+ if (typeof event.task_id !== "string" || !tasks.has(event.task_id)) fail("task_not_cancellable");
395
+ if (await service.withdraw(scope, event.task_id) === "not_cancellable") fail("task_not_cancellable");
396
+ observeTasks();
397
+ return;
398
+ }
399
+ case "lares.device.input.pause":
400
+ if (!headset) fail("unsupported_event");
401
+ await port?.pauseInput(); reset(); return;
402
+ case "lares.device.input.resume":
403
+ if (!headset) fail("unsupported_event");
404
+ await port?.resumeInput(); return;
405
+ default: fail("unsupported_event");
406
+ }
407
+ };
408
+
409
+ const close = () => {
410
+ if (closed) return;
411
+ closed = true; response?.abort.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
412
+ unbind(); clients.delete(client); client.close();
413
+ void Promise.resolve(port?.stop()).catch(() => {});
414
+ if (scope.realtimeSocket === client) scope.realtimeSocket = null;
415
+ if (owned) void service.close(scope).catch(() => {});
416
+ };
417
+ let idle;
418
+ const touch = () => {
419
+ clearTimeout(idle);
420
+ idle = setTimeout(() => { send({ type: "error", error: { type: "invalid_request_error", code: "session_expired", message: "Voice session idle" } }); close(); }, config().idleTimeoutMs || 300000);
421
+ };
422
+ touch();
423
+ clients.set(client, close);
424
+ if (!owned) scope.realtimeSocket = client;
425
+ const heartbeat = setInterval(() => { if (!alive) close(); else { alive = false; client.ping(); } }, 15000);
426
+ const results = setInterval(() => {
427
+ if (!scope.active) return;
428
+ try {
429
+ observeTasks();
430
+ if (busy || speaking || awaitingPlayback || response) return;
431
+ const state = service.state(scope);
432
+ if (!state.utterance?.text || state.utterance.key === lastReply || state.utterance.silent) return;
433
+ lastReply = state.utterance.key;
434
+ speak(state.utterance.text, String(state.utterance.key).startsWith("task:") ? "task" : "turn", state.utterance.key, 0);
435
+ } catch { close(); }
436
+ }, 250);
437
+ client.on("pong", () => { alive = true; });
438
+ client.on("close", close); client.on("error", close);
439
+ const opening = owned ? service.open(scope, { preset, sessionId, readAloud: false }) : Promise.resolve();
440
+ void opening.then(async () => {
441
+ if (headset) port = await hostAudio(scope, (event) => { if (event.type === "input_audio_buffer.append") { try { append(event.audio); } catch {} } });
442
+ if (closed) { void port?.stop(); return; }
443
+ send({ type: "session.created", session: view() });
444
+ client.on("message", async (raw, binary) => {
445
+ let event;
446
+ try {
447
+ if (configuring) fail("turn_busy");
448
+ if (binary) fail("unsupported_event");
449
+ try { event = JSON.parse(raw.toString()); } catch { fail("unsupported_event"); }
450
+ if (!event || typeof event.type !== "string") fail("unsupported_event");
451
+ await handle(event);
452
+ } catch (cause) { report(cause, typeof event?.event_id === "string" ? event.event_id : undefined); }
453
+ });
454
+ }).catch(() => { send({ type: "error", error: { type: "server_error", code: "upstream_failed", message: "Voice session failed to open" } }); close(); });
455
+ }
456
+
457
+ return {
458
+ /** Ticket for a Lares client's already open pipeline scope; single use, 30 s. */
459
+ ticket(scope) {
460
+ if (!scope.active || !scope.session) throw Object.assign(new Error("voice session closed"), { status: 409, code: "voice_session_closed" });
461
+ for (const [key, value] of tickets) if (value.expires < Date.now() || value.scope === scope) tickets.delete(key);
462
+ const ticket = randomUUID();
463
+ tickets.set(ticket, { scope, expires: Date.now() + TICKET_MS });
464
+ return { ticket };
465
+ },
466
+ accepts(req) { return Boolean(req.headers.authorization) || tickets.has(ticketOf(req)); },
467
+ upgrade(req, socket, head) {
468
+ if (clients.size >= limit) return refuse(socket, 429, "Too Many Requests");
469
+ if (!req.headers.authorization) {
470
+ const token = ticketOf(req), grant = tickets.get(token);
471
+ tickets.delete(token);
472
+ if (!grant || grant.expires < Date.now()) return refuse(socket, 401, "Unauthorized");
473
+ if (!grant.scope.active || grant.scope.realtimeSocket) return refuse(socket, 403, "Forbidden");
474
+ const preset = grant.scope.voiceConfig?.preset || "chat";
475
+ server.handleUpgrade(req, socket, head, (client) => run(client, { scope: grant.scope, model: `voice-${preset}`, preset, owned: false }));
476
+ return;
477
+ }
478
+ const key = keys.verify(req.headers.authorization);
479
+ if (!key) return refuse(socket, 401, "Unauthorized");
480
+ const model = new URL(req.url, "http://voice.invalid").searchParams.get("model");
481
+ const preset = PRESETS[model];
482
+ if (!preset || !key.models.includes(model)) return refuse(socket, 403, "Forbidden");
483
+ const dependencies = config();
484
+ if ([dependencies.model, dependencies.chatModel, dependencies.ttsModel, dependencies.realtimeModel].some((id) => id === model || id?.endsWith(`/${model}`))) return refuse(socket, 503, "Service Unavailable");
485
+ const scope = { id: randomUUID(), owner: hostOwner, authorizedBinding: { sessionId: key.sessionId, workspaceId: key.workspaceId }, device: "device", routerSessionId: routerSession(req.headers["x-session-id"]), voiceOverrides: {} };
486
+ try { onOpen({ voiceId: scope.id, keyId: key.id, model, routerSession: scope.routerSessionId, remote: req.socket?.remoteAddress }); } catch {}
487
+ server.handleUpgrade(req, socket, head, (client) => run(client, { scope, model, preset, owned: true, sessionId: key.sessionId }));
488
+ },
489
+ close() { for (const close of clients.values()) close(); server.close(); tickets.clear(); },
490
+ };
491
+ }
package/voice/options.js CHANGED
@@ -16,6 +16,8 @@ export const VOICE_DEFAULTS = {
16
16
  recoveryTimeoutMs: 120_000,
17
17
  device: "auto",
18
18
  backend: "pipeline",
19
+ // Lares clients speak lares-realtime-v1 by default; "legacy" keeps the pipeline endpoints until device acceptance.
20
+ transport: "unified",
19
21
  realtimeModel: "",
20
22
  model: "",
21
23
  ttsModel: "",
package/voice/panel.js CHANGED
@@ -55,16 +55,28 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
55
55
  if (processing === abort) { processing = null; renderDraft(); }
56
56
  }
57
57
  };
58
- const prepare = async (token) => {
58
+ const readoutWanted = async () => {
59
+ if (readAloudPreference === true) return true;
60
+ if (readAloudPreference === false) return false;
61
+ return (await loadConfig())?.speak === true;
62
+ };
63
+ const prepare = async (token, { readout = false } = {}) => {
64
+ const armed = () => token === intent && (readout || state.visible);
65
+ if (readout && !await readoutWanted()) return;
59
66
  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);
67
+ await call.resume({ shouldResume: armed, ...(readout ? { resumeListening: false } : {}) });
68
+ if (armed() && readout) await panel.setReadAloud(true);
69
+ else if (armed() && state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
70
+ return;
71
+ }
72
+ if (call?.controller.getSnapshot().connection === "open") {
73
+ if (readout && readAloudPreference === true) await panel.setReadAloud(true);
62
74
  return;
63
75
  }
64
- if (call?.controller.getSnapshot().connection === "open") return;
65
76
  await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
66
- if (token !== intent || !state.visible) return;
77
+ if (!armed()) return;
67
78
  if (state.mode === "continuous" && typeof readAloudPreference === "boolean") config = { ...config, speak: readAloudPreference };
79
+ if (readout && config.speak !== true) return;
68
80
  const initialReadAloud = state.mode === "continuous" && config.speak === true;
69
81
  const target = state.sessionId, epoch = ++generation;
70
82
  const conversation = await create({ mode: state.mode, sessionId: target, config, preparation: inputPreparation,
@@ -73,7 +85,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
73
85
  const raw = [state.raw, text].filter(Boolean).join(" "); emit({ raw, draft: raw, edited: false }); save();
74
86
  }, onDevice(device) { if (epoch === generation) emit({ device }); },
75
87
  });
76
- if (token !== intent || !state.visible) { await conversation.stop(); return; }
88
+ if (!armed()) { await conversation.stop(); return; }
77
89
  call = conversation; appliedReadAloud = initialReadAloud;
78
90
  unsubscribe = call.controller.subscribe(() => {
79
91
  if (epoch !== generation) return;
@@ -82,11 +94,12 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
82
94
  emit({ snapshot, ...(snapshot.inputLevel == null ? {} : { levels: [...state.levels.slice(-19), snapshot.inputLevel] }),
83
95
  ...(snapshot.capture === "recording" && snapshot.inputReady && !state.startedAt ? { startedAt: Date.now(), elapsedMs: 0 } : snapshot.capture !== "recording" && state.startedAt ? { startedAt: null, elapsedMs: Date.now() - state.startedAt } : {}) });
84
96
  });
85
- await call.start({ sessionId: target, continuous: state.mode === "continuous", manual: state.mode === "continuous" && manualTurns, shouldStart: () => token === intent && state.visible });
86
- if (token !== intent || !state.visible) await call.controller.cancelRecording();
97
+ await call.start({ sessionId: target, continuous: state.mode === "continuous", manual: state.mode === "continuous" && (manualTurns || readout), shouldStart: armed });
98
+ if (!armed()) await call.controller.cancelRecording();
87
99
  else {
88
100
  readAloudReady = true;
89
- if (state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
101
+ if (readout) await panel.setReadAloud(true);
102
+ else if (state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
90
103
  }
91
104
  };
92
105
  const pause = async () => {
@@ -165,6 +178,12 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
165
178
  close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
166
179
  pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
167
180
  start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
181
+ openReadout() {
182
+ if (state.mode !== "continuous") return Promise.resolve();
183
+ armOutput();
184
+ const token = ++intent;
185
+ return enqueue(() => prepare(token, { readout: true }));
186
+ },
168
187
  finish() {
169
188
  if (state.busy) return Promise.resolve();
170
189
  const token = intent;