@olares/lares-core 0.15.24 → 0.15.26

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.
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { WebSocket, WebSocketServer } from "ws";
3
3
  import { routerAuthHeaders, routerGatewayUrl } from "../router/gateway.js";
4
4
  import { createRealtimeDelegate, LARES_TOOL, REALTIME_INSTRUCTIONS, validatedRealtimeModels } from "./realtime.js";
5
+ import { REALTIME_CAPABILITIES } from "./lares-realtime-gateway.js";
5
6
 
6
7
  /** HTTP-issued, single-use tickets keep upstream credentials and tools on the Host. */
7
8
  export function createRealtimeGateway({ models, config, audio, record = () => {}, env = process.env }) {
@@ -9,6 +10,7 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
9
10
  const connections = new Set();
10
11
  const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
11
12
  return {
13
+ owns(req) { return tickets.has(String(req.headers["sec-websocket-protocol"] || "").split(",").map((s) => s.trim()).find((s) => s.startsWith("lares-ticket."))?.slice(13)); },
12
14
  async ticket(scope) {
13
15
  if (!scope.active || !scope.session) throw Object.assign(new Error("voice session closed"), { status: 409, code: "voice_session_closed" });
14
16
  if (scope.voiceConfig && !["chat", "assistant"].includes(scope.voiceConfig.preset)) throw Object.assign(new Error("This preset requires the pipeline backend"), { status: 409, code: "voice_preset_backend_unsupported" });
@@ -51,10 +53,18 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
51
53
  let responding = false;
52
54
  let userSpeaking = false;
53
55
  const completed = [];
56
+ // Lares clients speak lares-realtime-v1 here too (docs/realtime-protocol.md); the native upstream stays OpenAI.
57
+ const translate = (value) => {
58
+ if (value.type === "lares.ready") return { type: "session.created", session: { id: scope.id, object: "realtime.session", type: "realtime", model: grant.model, output_modalities: [scope.readAloud ? "audio" : "text"], lares: { capabilities: { ...REALTIME_CAPABILITIES, message_truncation: scope.device === "headset" ? "none" : "played", backend: "native" }, context_id: scope.contextId, device: scope.device === "headset" ? "headset" : "device" } } };
59
+ if (value.type === "lares.error") return { type: "error", error: { type: "server_error", code: value.code, message: "Voice session failed" } };
60
+ if (value.type === "lares.playback") return { type: "lares.device.playback", playing: value.playing };
61
+ if (value.type === "lares.task") return { type: "lares.task.updated", task_id: value.requestId || null, status: value.status, session_id: value.sessionId || null };
62
+ return value;
63
+ };
54
64
  const sendClient = (value) => {
55
65
  if (client.readyState !== WebSocket.OPEN) return;
56
66
  if (client.bufferedAmount > 1024 * 1024) { close("voice_audio_backpressure"); return; }
57
- client.send(JSON.stringify(value));
67
+ client.send(JSON.stringify(translate(value)));
58
68
  };
59
69
  const send = (value) => {
60
70
  if (closed || upstream.readyState !== WebSocket.OPEN) return;
@@ -151,9 +161,12 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
151
161
  try { event = JSON.parse(raw.toString()); } catch { close("voice_realtime_protocol"); return; }
152
162
  if (!event || typeof event !== "object" || Array.isArray(event)) { close("voice_realtime_protocol"); return; }
153
163
  if (event.type === "input_audio_buffer.append" && scope.device === "device" && typeof event.audio === "string" && event.audio.length <= 64 * 1024 && /^[A-Za-z0-9+/]*={0,2}$/.test(event.audio)) send({ type: event.type, audio: event.audio });
154
- else if (event.type === "lares.output" && typeof event.enabled === "boolean") { scope.readAloud = event.enabled; send({ type: "session.update", session: { output_modalities: event.enabled ? ["audio"] : ["text"] } }); }
155
- else if (event.type === "lares.input.pause") { send({ type: "input_audio_buffer.clear" }); void Promise.resolve(audioPort?.pauseInput()).catch(() => close("voice_device_unavailable")); }
156
- else if (event.type === "lares.input.resume") void Promise.resolve(audioPort?.resumeInput()).catch(() => close("voice_device_unavailable"));
164
+ else if (event.type === "session.update" && event.session && Object.keys(event.session).every((key) => key === "output_modalities") && Array.isArray(event.session.output_modalities) && ["audio", "text"].includes(event.session.output_modalities[0])) {
165
+ scope.readAloud = event.session.output_modalities[0] === "audio"; send({ type: "session.update", session: { output_modalities: [event.session.output_modalities[0]] } });
166
+ }
167
+ else if (event.type === "lares.playback.progress" || event.type === "lares.playback.completed") return;
168
+ else if (event.type === "lares.device.input.pause") { send({ type: "input_audio_buffer.clear" }); void Promise.resolve(audioPort?.pauseInput()).catch(() => close("voice_device_unavailable")); }
169
+ else if (event.type === "lares.device.input.resume") void Promise.resolve(audioPort?.resumeInput()).catch(() => close("voice_device_unavailable"));
157
170
  else if (event.type === "response.cancel") { audioGeneration++; send({ type: "response.cancel" }); void Promise.resolve(audioPort?.interrupt()).catch(() => close("voice_device_unavailable")); }
158
171
  else if (event.type === "conversation.item.truncate" && typeof currentItem === "string" && event.item_id === currentItem && event.content_index === 0 && Number.isInteger(event.audio_end_ms) && event.audio_end_ms >= 0 && event.audio_end_ms <= Math.ceil(receivedMs)) send({ type: event.type, item_id: currentItem, content_index: 0, audio_end_ms: event.audio_end_ms });
159
172
  else close("voice_realtime_event_forbidden");
package/voice/records.js CHANGED
@@ -56,7 +56,7 @@ export function createVoiceRecords({ directory = join(dshHome(), "voice-records"
56
56
  taskId: record.taskId || null, requestId: record.requestId || record.taskId || record.turnId,
57
57
  taskTurn: Number.isInteger(record.taskTurn) ? record.taskTurn : null,
58
58
  anchorSeq: previous?.anchorSeq ?? (Number.isInteger(record.anchorSeq) ? record.anchorSeq : -1),
59
- status: ["streaming", "failed"].includes(record.status) ? record.status : "completed",
59
+ status: ["streaming", "failed", "truncated"].includes(record.status) ? record.status : "completed",
60
60
  sequence: previous?.sequence || (index >= 0 ? index + 1 : value.records.length + 1), at: previous?.at || now() };
61
61
  if (previous && JSON.stringify(previous) === JSON.stringify(next)) return value;
62
62
  if (index < 0) value.records.push(next); else value.records[index] = next;
@@ -6,13 +6,17 @@ const STATUS = { voice_reply_invalid: 400, voice_reply_forbidden: 403 };
6
6
  const fault = code => new VoiceError(code, STATUS[code] ?? 409, code);
7
7
  const identity = source => JSON.stringify([source.sessionId, source.turn, source.step]);
8
8
 
9
- /** Only trusted Agent events may authorize live text; client input supplies an address, never text. */
10
- export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }) } = {}) {
9
+ /**
10
+ * Trusted Agent text only; a client supplies an address, never the words.
11
+ * Read-while-writing releases each closed sentence as it arrives. Reading the
12
+ * finished answer waits until that text has settled. A tool call ends either readout.
13
+ */
14
+ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }), streaming = () => false } = {}) {
11
15
  const rows = new Map();
12
16
  const notify = row => { row.version = (row.version || 0) + 1; for (const wake of row.listeners) wake(); };
13
17
  const fail = (row, code) => { row.segmenter.reset(); row.ready = []; row.error = code; row.done = true; row.at = now(); notify(row); };
14
18
  const advance = row => {
15
- if (!row.subscribers || row.error || !row.eligible && !row.done) return;
19
+ if (!row.subscribers || row.error || streaming() !== true && !row.done) return;
16
20
  try { row.ready.push(...row.segmenter.update(row.utterance?.text || "", { done: row.done })); }
17
21
  catch (error) { fail(row, error.message === "voice_reply_rewritten" ? error.message : "voice_reply_invalid"); }
18
22
  };
@@ -28,7 +32,7 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
28
32
  }
29
33
  if (rows.size >= 128) return;
30
34
  rows.set(key, { sessionId, turn: frame.turn, step: frame.step, attemptId: frame.attemptId,
31
- segmenter: segmenterFactory(), ready: [], eligible: frame.speechEligible === true, utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
35
+ segmenter: segmenterFactory(), ready: [], utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
32
36
  return;
33
37
  }
34
38
  for (const row of rows.values()) {
@@ -1,5 +1,7 @@
1
+ import { WebSocket } from "ws";
1
2
  import { ttsVoiceForModel } from "./tts-voice.js";
2
- import { transcribe } from "../router/stt.js";
3
+ import { listModels, pickSttModelId, pickTtsModelId, transcribe } from "../router/stt.js";
4
+ import { routerAuthHeaders, routerGatewayUrl } from "../router/gateway.js";
3
5
  import { pcmToWav, synthesizeStream } from "../router/tts.js";
4
6
  import { normalizeSttPcm } from "./pcm.js";
5
7
  import { createPcmResampler, pcmSamples } from "./realtime-audio.js";
@@ -14,17 +16,58 @@ export function wavData(wav) {
14
16
  throw new Error("voice_audio_format");
15
17
  }
16
18
 
19
+ // Router groups child requests of one Realtime session by this header (Router ADR-83).
20
+ const routerSession = (scope) => scope.routerSessionId ? { "x-session-id": scope.routerSessionId } : undefined;
21
+
17
22
  export function createRouterAudio(config) {
18
23
  return {
19
- transcribe: (pcm, scope) => transcribe({ bytes: pcmToWav(pcm, 24000), filename: "voice.wav", contentType: "audio/wav", model: config().model || "default-stt", language: config().language || undefined, signal: scope.voiceAbort?.signal, once: true }),
24
+ // Same resolution as the streaming path: a configured model that is not installed falls back to one that is.
25
+ transcribe: async (pcm, scope) => transcribe({ bytes: pcmToWav(pcm, 24000), filename: "voice.wav", contentType: "audio/wav", model: pickSttModelId(await listModels(), config().model) || config().model || "default-stt", language: scope.voiceOverrides?.language || config().language || undefined, signal: scope.voiceAbort?.signal, once: true, headers: routerSession(scope) }),
26
+ /**
27
+ * Streaming ASR for one turn when the selected model supports it: 24 kHz PCM in,
28
+ * partial transcripts out. Returns null so the caller keeps the batch path otherwise.
29
+ */
30
+ async openTranscription(scope, onPartial, { env = process.env } = {}) {
31
+ const rows = await listModels();
32
+ const settings = config();
33
+ const model = pickSttModelId(rows, settings.model);
34
+ if (!model || !rows.find((row) => row.id === model)?.supportsSttStream) return null;
35
+ const socket = new WebSocket(`${routerGatewayUrl(env).replace(/^http/, "ws")}/audio/stream?model=${encodeURIComponent(model)}`, {
36
+ headers: { ...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, scope.owner), ...routerSession(scope) }, handshakeTimeout: 15_000, maxPayload: 96 * 1024 });
37
+ const convert = createPcmResampler(24000, 16000);
38
+ let text = "", settle;
39
+ const final = new Promise((resolve, reject) => { settle = { resolve, reject }; });
40
+ final.catch(() => {});
41
+ const opened = new Promise((resolve, reject) => {
42
+ socket.once("open", () => { socket.send(JSON.stringify({ type: "start", sample_rate: 16000, language: scope.voiceOverrides?.language || settings.language || undefined })); resolve(); });
43
+ socket.once("error", reject);
44
+ });
45
+ opened.catch(() => {});
46
+ socket.on("message", (raw, binary) => {
47
+ if (binary) return;
48
+ let value;
49
+ try { value = JSON.parse(String(raw)); } catch { return; }
50
+ if (value.type === "partial") { text = String(value.text || ""); onPartial(text); }
51
+ if (value.type === "final") { text = String(value.text || ""); settle.resolve(text); }
52
+ if (value.type === "error") settle.reject(new Error(value.code || "voice_stream_failed"));
53
+ });
54
+ socket.on("close", () => settle.reject(new Error("voice_stream_closed")));
55
+ socket.on("error", () => settle.reject(new Error("voice_stream_failed")));
56
+ let chain = opened;
57
+ return {
58
+ write(pcm) { chain = chain.then(() => { if (socket.readyState === 1) socket.send(Buffer.from(convert(pcmSamples(pcm)))); }); chain.catch(() => {}); },
59
+ async finish() { await chain; socket.send(JSON.stringify({ type: "finish" })); try { return await final; } finally { socket.close(); } },
60
+ cancel() { socket.terminate(); },
61
+ };
62
+ },
20
63
  async synthesize(text, scope, onChunk) {
21
- const settings = structuredClone(config());
22
- const model = settings.ttsModel || "default-tts";
64
+ const settings = { ...structuredClone(config()), ...scope.voiceOverrides?.voice && { voice: scope.voiceOverrides.voice } };
65
+ const model = pickTtsModelId(await listModels(), settings.ttsModel) || settings.ttsModel || "default-tts";
23
66
  const chunks = [];
24
67
  let convert = null;
25
68
  const write = async (pcm) => { if (onChunk) await onChunk(pcm); else chunks.push(pcm); };
26
69
  let pending = Buffer.alloc(0);
27
- const result = await synthesizeStream({ text, model, voice: settings.voice || ttsVoiceForModel(settings, model), signal: scope.outputAbort?.signal,
70
+ const result = await synthesizeStream({ text, model, voice: settings.voice || ttsVoiceForModel(settings, model), signal: scope.outputAbort?.signal, headers: routerSession(scope),
28
71
  onFormat: (format) => { convert = createPcmResampler(format.sampleRate, 24000); },
29
72
  }, async (chunk) => {
30
73
  const joined = Buffer.concat([pending, chunk]);
package/voice/service.js CHANGED
@@ -158,6 +158,12 @@ export async function saveVoiceConfig(body) {
158
158
  throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
159
159
  }
160
160
  }
161
+ if (patch.streaming === true) {
162
+ const { speechGpu } = await import("./gpu-mode.js");
163
+ const gpu = await speechGpu();
164
+ if (!gpu) throw new VoiceError("voice_gpu_no_gpu", 409, "no NVIDIA GPU");
165
+ if (gpu.type !== "MemorySlice") throw new VoiceError("voice_streaming_time_slice", 409, gpu.type || "TimeSlice");
166
+ }
161
167
  if (patch.ttsVoiceByModel) {
162
168
  const current = readConfig().ttsVoiceByModel;
163
169
  const changed = Object.entries(patch.ttsVoiceByModel).filter(([model, voice]) => voice && current[model] !== voice);
package/voice/speech.js CHANGED
@@ -197,6 +197,9 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
197
197
  const emitOutput = value => {
198
198
  if (value || readoutState.getSnapshot()?.key === lastKey || onOutput !== readoutState.publish) onOutput(value);
199
199
  };
200
+ // A dropped socket is the same quiet ending as a cancel: the next utterance
201
+ // opens a new stream. It is not a broken voice setting.
202
+ const quietPlayback = new Set(["voice_cancelled", "voice_output_owned", "voice_disconnected"]);
200
203
  const failed = (run, err) => {
201
204
  if (run.gen !== gen) return;
202
205
  run.abort?.abort();
@@ -204,7 +207,10 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
204
207
  run.wake?.();
205
208
  current = null;
206
209
  onState(false); emitOutput(null);
207
- if (!["voice_cancelled", "voice_output_owned"].includes(err?.code || err?.message)) { emitOutput({ ...run.identity, status: "error", error: err?.code || err?.message || "voice_playback_failed", stop: () => emitOutput(null) }); fail(err); }
210
+ const code = err?.code || err?.message;
211
+ if (quietPlayback.has(code)) return;
212
+ emitOutput({ ...run.identity, status: "error", error: code || "voice_playback_failed", stop: () => emitOutput(null) });
213
+ fail(err);
208
214
  };
209
215
 
210
216
  const handOver = (run) => {
@@ -100,6 +100,14 @@ function withoutEmoji(text) {
100
100
  });
101
101
  }
102
102
 
103
+ // A longer unbroken identifier is not speech. Synthesizing it stalls the one
104
+ // shared speech path, so this answer and the next one stay silent. Dots stay
105
+ // outside the run so "First one." is still a sentence; a following short
106
+ // extension (`name.jpg`) leaves with the run.
107
+ function withoutUnsayable(text) {
108
+ return text.replace(/[A-Za-z0-9][A-Za-z0-9_~:/?#@!$&*+=%-]{31,}(?:\.[A-Za-z0-9]{1,8})?/g, "");
109
+ }
110
+
103
111
  /** The assistant's spoken copy; never apply this to stored messages or raw TTS API input. */
104
112
  function prepareSource(text, done, structural = false) {
105
113
  let pending = false;
@@ -143,9 +151,10 @@ export function spokenLines(text, { done = true } = {}) {
143
151
  plain = plain.slice(0, lineStart) + end.slice(0, tail.index);
144
152
  pending = true;
145
153
  }
146
- if (pending || inline.pending) plain += "\uFFFC";
147
154
  }
148
- return withoutEmoji(plain).split("\n").map((line) => line.replace(/\s+/g, " ").trim());
155
+ plain = withoutUnsayable(withoutEmoji(plain));
156
+ if (!done && (pending || inline.pending)) plain += "\uFFFC";
157
+ return plain.split("\n").map((line) => line.replace(/\s+/g, " ").trim());
149
158
  }
150
159
 
151
160
 
@@ -183,8 +192,9 @@ export function spokenBlocks(text, { done = true } = {}) {
183
192
  const tail = [...graphemes.segment(plain.trimEnd())].at(-1);
184
193
  if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) { plain = plain.slice(0, tail.index); opaque = true; }
185
194
  }
195
+ plain = withoutUnsayable(withoutEmoji(plain));
186
196
  if (!done && opaque) plain += "\uFFFC";
187
- plain = withoutEmoji(plain).replace(/\s+/g, " ").trim();
197
+ plain = plain.replace(/\s+/g, " ").trim();
188
198
  const continuation = lists.length && paragraphs.has(items.at(-1));
189
199
  if (lists.length) paragraphs.add(items.at(-1));
190
200
  if (plain) result.push({ id, text: plain, closed,
@@ -136,8 +136,9 @@ export async function startTranscriptionStream(options) {
136
136
  };
137
137
  }
138
138
 
139
+ /** `options.port` swaps the `/stream` STT socket for another PCM sink at `options.sampleRate`. */
139
140
  export async function createStreamingCapture(stream, options) {
140
- const port = await startTranscriptionStream(options);
141
+ const port = await (options.port ? options.port(options) : startTranscriptionStream(options));
141
142
  if (!port) return null;
142
143
  let context, source, node, failure, flushed;
143
144
  const release = () => {
@@ -152,7 +153,7 @@ export async function createStreamingCapture(stream, options) {
152
153
  const url = URL.createObjectURL(new Blob([PCM_CAPTURE_WORKLET], { type: "text/javascript" }));
153
154
  try { await context.audioWorklet.addModule(url); await context.resume(); } finally { URL.revokeObjectURL(url); }
154
155
  if (options.signal?.aborted) throw new Error("voice_cancelled");
155
- const convert = createPcmResampler(context.sampleRate, 16000);
156
+ const convert = createPcmResampler(context.sampleRate, options.sampleRate || 16000);
156
157
  source = context.createMediaStreamSource(stream);
157
158
  node = new AudioWorkletNode(context, "lares-pcm-capture", { numberOfOutputs: 1, outputChannelCount: [1] });
158
159
  node.port.onmessage = ({ data }) => {
@@ -0,0 +1,256 @@
1
+ import { createVoiceController } from "./controller.js";
2
+ import { createDeviceInput } from "./device-input.js";
3
+ import { createPcmStreamPlayer } from "./pcm-player.js";
4
+ import { createVoiceProtocolClient } from "./protocol-client.js";
5
+ import { createStreamingCapture } from "./stream-client.js";
6
+ import { mintRpcId } from "../larepass/rpc.js";
7
+
8
+ const RATE = 24000;
9
+ const PROGRESS_MS = 250;
10
+
11
+ export function encodeBase64(bytes) {
12
+ let text = "";
13
+ for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
14
+ return btoa(text);
15
+ }
16
+ const decodeBase64 = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
17
+
18
+ export function browserRealtimeSocket(ticket) {
19
+ const url = new URL("/api/lares/voice/realtime", globalThis.location.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
20
+ return new WebSocket(url, ["lares-voice", `lares-ticket.${ticket}`]);
21
+ }
22
+
23
+ /**
24
+ * The Lares client conversation over `lares-realtime-v1` (docs/realtime-protocol.md). It keeps the
25
+ * shape of `createVoiceConversation`, so the panel and controller stay transport-neutral: the
26
+ * microphone streams PCM into the socket, the Host transcribes, submits and speaks, and the reply
27
+ * audio plays from the socket instead of a separate synthesis stream.
28
+ */
29
+ export function createUnifiedConversation({ request, socket: openSocket = browserRealtimeSocket, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, preparation, media, player: createPlayer = createPcmStreamPlayer, input: inputPort }) {
30
+ const voiceId = mintRpcId();
31
+ const headset = device === "headset";
32
+ const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": headset ? "headset" : "device" };
33
+ let protocol = null, socketOpen = null, disposed = false, timer = null, lastSessionId = null, continuous = false;
34
+ let turn = null, reply = null, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud;
35
+ const call = async (path, body, signal) => {
36
+ const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers, ...(body === undefined ? {} : { body }), signal });
37
+ if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
38
+ return res.body;
39
+ };
40
+ const sessionBody = (sessionId) => ({ sessionId, readAloud, presentationSession, preset: onTranscript ? "dictation" : preset, backend: "pipeline" });
41
+
42
+ // Reply playback: one PCM player per response, acknowledged back to the Host.
43
+ const player = createPlayer({ onProgress: (frames) => {
44
+ if (!reply || !protocol) return;
45
+ reply.playedMs = frames / (RATE / 1000);
46
+ if (Date.now() - (reply.reported || 0) >= PROGRESS_MS) { reply.reported = Date.now(); protocol.playbackProgress(reply.itemId, Math.round(reply.playedMs)); }
47
+ } });
48
+ const playback = {
49
+ begin(event) {
50
+ reply = { id: event.response?.id, itemId: null, text: "", audio: false, playedMs: 0, chain: Promise.resolve() };
51
+ },
52
+ write(delta) {
53
+ const current = reply;
54
+ if (!current) return;
55
+ if (!current.audio) { current.audio = true; current.chain = current.chain.then(() => player.start({ sampleRate: RATE, channels: 1, encoding: "pcm_s16le" })); controller.observePlayback(true); }
56
+ current.chain = current.chain.then(() => { if (reply === current) player.write(decodeBase64(delta)); });
57
+ current.chain.catch(() => {});
58
+ },
59
+ async done() {
60
+ const current = reply;
61
+ if (!current) return;
62
+ if (current.audio) {
63
+ try { await current.chain; await player.finish(); } catch {}
64
+ if (reply !== current) return;
65
+ protocol?.playbackCompleted(current.itemId);
66
+ }
67
+ if (reply === current) reply = null;
68
+ controller.observePlayback(false);
69
+ },
70
+ async stop() {
71
+ const current = reply;
72
+ reply = null;
73
+ player.stop();
74
+ if (!current || !protocol) return;
75
+ protocol.stopOutput();
76
+ if (current.itemId && current.audio) protocol.truncate(current.itemId, current.playedMs);
77
+ },
78
+ };
79
+
80
+ // Takes: the device microphone streams PCM over the socket; the Host transcribes on commit.
81
+ const take = () => {
82
+ const current = { resolve: null, reject: null, onPartial: null };
83
+ turn = current;
84
+ return current;
85
+ };
86
+ const transcription = ({ onPartial }) => {
87
+ if (!protocol) throw new Error("voice_disconnected");
88
+ const current = take();
89
+ current.onPartial = onPartial;
90
+ return {
91
+ write(bytes) { if (turn === current && protocol) protocol.append(encodeBase64(bytes)); },
92
+ finish() {
93
+ return new Promise((resolve, reject) => {
94
+ current.resolve = resolve; current.reject = reject;
95
+ if (!onTranscript) protocol.setBusyTurn(mode());
96
+ protocol.commit();
97
+ });
98
+ },
99
+ cancel() { if (turn === current) { turn = null; protocol?.discard(); } },
100
+ };
101
+ };
102
+ const deviceInput = () => createDeviceInput({ media, preparation, onPhase, endpointSilenceMs,
103
+ transcribe: async () => { throw new Error("voice_unsupported"); },
104
+ streamInput: (stream, options) => createStreamingCapture(stream, { ...options, sampleRate: RATE, port: async () => transcription(options) }),
105
+ });
106
+ // Headset: the Host owns the microphone; the socket only opens and closes it.
107
+ const hostInput = () => ({
108
+ capabilities: { duplex: false, bargeIn: false, audio: "pcm16-24000" },
109
+ capture({ signal, onReady, onSpeech, onTranscribing }) {
110
+ const current = take();
111
+ current.onSpeech = onSpeech; current.onTranscribing = onTranscribing;
112
+ return new Promise((resolve, reject) => {
113
+ current.resolve = (text) => { protocol?.deviceInput(false); resolve(text); };
114
+ current.reject = (error) => { protocol?.deviceInput(false); reject(error); };
115
+ signal.addEventListener("abort", () => { if (turn === current) { turn = null; protocol?.discard(); protocol?.deviceInput(false); resolve(""); } }, { once: true });
116
+ if (!onTranscript) protocol.setBusyTurn(mode());
117
+ protocol.deviceInput(true);
118
+ onReady?.();
119
+ });
120
+ },
121
+ stop() { protocol?.commit(); },
122
+ cancel() { const current = turn; turn = null; protocol?.discard(); protocol?.deviceInput(false); current?.resolve?.(""); },
123
+ });
124
+ const input = inputPort ?? (headset ? hostInput() : deviceInput());
125
+
126
+ const onEvent = (event) => {
127
+ switch (event.type) {
128
+ case "input_audio_buffer.speech_started": turn?.onSpeech?.(); break;
129
+ case "input_audio_buffer.committed": turn?.onTranscribing?.(); break;
130
+ case "conversation.item.input_audio_transcription.delta": if (turn?.onPartial) { turn.partial = `${turn.partial || ""}${event.delta}`; turn.onPartial(turn.partial); } break;
131
+ case "conversation.item.input_audio_transcription.completed": { const current = turn; turn = null; current?.resolve?.(event.transcript || ""); break; }
132
+ case "response.created": playback.begin(event); break;
133
+ case "response.output_item.added": if (reply) reply.itemId = event.item?.id || null; break;
134
+ case "response.output_audio_transcript.delta":
135
+ case "response.output_text.delta": if (reply) { reply.text += event.delta || ""; onText(reply.text); } break;
136
+ case "response.output_audio.delta": playback.write(event.delta); break;
137
+ case "response.done": void playback.done(); break;
138
+ case "lares.device.playback": controller.observePlayback(event.playing === true); break;
139
+ case "error": {
140
+ const code = event.error?.code || "voice_failed";
141
+ if (turn && ["unsupported_audio_format", "audio_limit_exceeded", "turn_busy", "waiting_for_playback", "upstream_failed"].includes(code)) { const current = turn; turn = null; current.reject?.(new Error(code)); }
142
+ if (code === "session_expired") void controller.disconnect("voice_idle_timeout");
143
+ break;
144
+ }
145
+ default: break;
146
+ }
147
+ };
148
+ const connect = async () => {
149
+ if (protocol) return;
150
+ const { ticket } = await call("/realtime/ticket", {});
151
+ const ws = openSocket(ticket);
152
+ socketOpen = new Promise((resolve, reject) => {
153
+ const timeout = setTimeout(() => reject(new Error("voice_realtime_timeout")), 15_000);
154
+ protocol = createVoiceProtocolClient(ws, {
155
+ onEvent: (event) => { if (event.type === "session.created") { clearTimeout(timeout); resolve(); } onEvent(event); },
156
+ onClose: () => { clearTimeout(timeout); reject(new Error("voice_disconnected")); protocol = null; const current = turn; turn = null; current?.reject?.(new Error("voice_disconnected")); if (!disposed) void controller.disconnect("voice_disconnected"); },
157
+ });
158
+ });
159
+ socketOpen.catch(() => {});
160
+ await socketOpen;
161
+ // Device takes keep client endpointing; the headset microphone lives on the Host, so its VAD does.
162
+ protocol.configure({ type: onTranscript ? "transcription" : "realtime", automatic: headset && continuous, audio: readAloud });
163
+ };
164
+ const disconnectSocket = () => { const current = protocol; protocol = null; current?.close(); };
165
+
166
+ const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(),
167
+ onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null,
168
+ metric: (event) => { metric(event); void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {}); },
169
+ output: { get canPause() { return !headset; }, key: () => reply?.id, pause: () => player.pause?.(), resume: () => player.resume?.(), stop: () => playback.stop() },
170
+ agent: {
171
+ lazy: true,
172
+ create: async () => null,
173
+ // The Host submitted this turn when its audio was committed; only the lifecycle remains to observe.
174
+ async submit() { await poll(); },
175
+ cancel: () => call("/session/cancel", {}),
176
+ running: async () => (await call("/session")).running === true,
177
+ },
178
+ });
179
+ const poll = async () => {
180
+ if (disposed || onTranscript) return;
181
+ const state = await call("/session");
182
+ if (disposed || controller.getSnapshot().connection !== "open") return;
183
+ lastSessionId = state.sessionId;
184
+ if (lastSessionId) controller.bindSession(lastSessionId);
185
+ if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
186
+ controller.observeAgent(Boolean(state.running) || Boolean(reply), Boolean(reply?.audio));
187
+ };
188
+ const watch = async () => {
189
+ if (disposed || controller.getSnapshot().connection !== "open") return;
190
+ try { await poll(); }
191
+ catch (error) { await controller.disconnect(error.code || error.message || "voice_disconnected"); return; }
192
+ if (!disposed) timer = setTimeout(watch, 500);
193
+ };
194
+ const stop = async () => {
195
+ disposed = true; clearTimeout(timer);
196
+ player.stop(); reply = null;
197
+ await controller.disconnect();
198
+ disconnectSocket();
199
+ await call("/session/close", {}).catch(() => {});
200
+ };
201
+ return {
202
+ controller, headers,
203
+ // Reply audio is played here, not by the panel's reader.
204
+ playbackState() {},
205
+ get sessionId() { return lastSessionId; },
206
+ async start({ sessionId = null, continuous: nextContinuous = false, manual = false, shouldStart = () => true } = {}) {
207
+ continuous = nextContinuous; disposed = false; clearTimeout(timer);
208
+ const initial = await call("/session", sessionBody(sessionId));
209
+ if (!shouldStart()) { await stop(); return; }
210
+ await connect();
211
+ if (!shouldStart()) { await stop(); return; }
212
+ await controller.start({ sessionId, continuous, manual, running: initial.running, shouldStart });
213
+ controller.setOutputMuted(!readAloud);
214
+ if (!onTranscript) void watch();
215
+ },
216
+ stop,
217
+ async pause() { clearTimeout(timer); await controller.pause(); },
218
+ async resume(options = {}) {
219
+ disposed = false;
220
+ if (!onTranscript) {
221
+ const current = await call("/session");
222
+ if (!current.active) await call("/session", sessionBody(lastSessionId));
223
+ else await call("/session/resume", {});
224
+ }
225
+ await connect();
226
+ await controller.resume(options);
227
+ if (!onTranscript) void watch();
228
+ },
229
+ setReadAloud(value) {
230
+ const next = value === true;
231
+ if (readAloud === next && confirmedReadAloud === next) return Promise.resolve();
232
+ readAloud = next;
233
+ controller.setOutputMuted(!next);
234
+ const stopped = !next ? Promise.resolve(controller.stopPlayback()).then(() => null, (error) => error) : Promise.resolve(null);
235
+ const result = outputSerial.catch(() => {}).then(async () => {
236
+ const error = await stopped;
237
+ if (error) throw error;
238
+ if (protocol) protocol.setOutput(next);
239
+ confirmedReadAloud = next;
240
+ });
241
+ outputSerial = result;
242
+ return result;
243
+ },
244
+ records: async () => {
245
+ const res = await request("/api/lares/voice/records", { method: "GET", headers });
246
+ if (!res?.ok) throw new Error("voice_records_failed");
247
+ return res.body;
248
+ },
249
+ clearRecords: async () => {
250
+ await stop();
251
+ const res = await request("/api/lares/voice/records", { method: "DELETE", headers });
252
+ if (!res?.ok) throw new Error("voice_records_failed");
253
+ },
254
+ speak: (body) => call("/speak", body),
255
+ };
256
+ }