@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.
@@ -56,7 +56,10 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
56
56
  owned = false; context = null;
57
57
  throw error;
58
58
  }
59
- node = new AudioWorkletNode(opened, "lares-pcm-player", { processorOptions: { inputRate: sampleRate }, numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
59
+ node = new AudioWorkletNode(opened, "lares-pcm-player", {
60
+ processorOptions: { inputRate: sampleRate, outputRate: opened.sampleRate || sampleRate, preroll: Math.round(sampleRate * 0.2) },
61
+ numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1],
62
+ });
60
63
  node.port.onmessage = ({ data }) => {
61
64
  if (epoch !== generation) return;
62
65
  if (data.type === "progress") { played = data.frames; onProgress(played); }
@@ -3,7 +3,10 @@ export const PCM_PLAYER_WORKLET = `
3
3
  class LaresPcmPlayer extends AudioWorkletProcessor {
4
4
  constructor(options) {
5
5
  super(); this.queue = []; this.offset = 0; this.frames = 0; this.reported = 0;
6
- this.rate = options.processorOptions.inputRate; this.step = this.rate / sampleRate;
6
+ this.rate = options.processorOptions.inputRate;
7
+ const outputRate = options.processorOptions.outputRate || sampleRate;
8
+ this.step = this.rate / outputRate;
9
+ this.preroll = options.processorOptions.preroll || 0;
7
10
  this.paused = false; this.queued = 0; this.finished = false; this.started = false; this.starved = false;
8
11
  this.port.onmessage = ({ data }) => {
9
12
  if (data.type === 'audio') {
@@ -18,6 +21,8 @@ class LaresPcmPlayer extends AudioWorkletProcessor {
18
21
  const out = outputs[0][0];
19
22
  out.fill(0);
20
23
  if (this.paused) return true;
24
+ // A late first packet must not open a hole inside the first syllable.
25
+ if (!this.started && !this.finished && this.queued < this.preroll) return true;
21
26
  for (let i = 0; i < out.length; i++) {
22
27
  while (this.queue.length && this.offset >= this.queue[0].length) {
23
28
  const head = this.queue.shift(); this.offset -= head.length; this.queued -= head.length;
@@ -6,26 +6,35 @@ 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 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;
9
+ const error = visibleCallError(view);
12
10
  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";
13
11
  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];
14
12
  return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
15
13
  }
16
14
 
17
- const RESTART_ERRORS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
15
+ const QUIET_ENDINGS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
16
+
17
+ /**
18
+ * Recovery, idle, and a dropped continuous socket are not failures.
19
+ * Hold-to-speak and send open the call again, so the composer stays quiet.
20
+ * A muted dictation is not a listen session.
21
+ */
22
+ function visibleCallError(view) {
23
+ const reported = view?.error || view?.snapshot?.error;
24
+ if (!reported || QUIET_ENDINGS.has(reported)) return null;
25
+ if (view.mode === "continuous" && reported === "voice_disconnected") return null;
26
+ return reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
27
+ }
18
28
 
19
29
  /**
20
30
  * 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.
31
+ * A paused session can still be resumed. Quiet endings have no banner.
23
32
  */
24
33
  export function voiceCallFailure(view) {
25
34
  if (!view?.visible || view.mode !== "continuous") return null;
26
35
  const ui = voicePanelPresentation(view);
27
36
  if (!ui.error) return null;
28
- const recoverable = view.snapshot?.connection === "paused" && !RESTART_ERRORS.has(ui.error);
37
+ const recoverable = view.snapshot?.connection === "paused" && !QUIET_ENDINGS.has(ui.error);
29
38
  return { code: ui.error, action: recoverable ? "resume" : "restart" };
30
39
  }
31
40
 
@@ -45,7 +54,7 @@ export function voiceDictationPresentation(view, now = Date.now()) {
45
54
  }
46
55
 
47
56
  export function voiceIndicatorPresentation(view, settingsError = null) {
48
- const error = view.error || view.snapshot?.error || view.readAloudError || settingsError;
57
+ const error = visibleCallError(view) || view.readAloudError || settingsError;
49
58
  const continuous = view.mode === "continuous";
50
59
  const active = view.visible && (view.snapshot?.connection === "open" || view.busy && !view.snapshot);
51
60
  return {
@@ -1,4 +1,4 @@
1
- /** Transport-neutral client: the caller obtains an authenticated Router socket. */
1
+ /** Transport-neutral `lares-realtime-v1` client: the caller obtains an authenticated socket. */
2
2
  export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose = () => {} } = {}) {
3
3
  const send = (event) => {
4
4
  if (socket.readyState !== 1) throw new Error("voice_disconnected");
@@ -13,12 +13,25 @@ export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose
13
13
  const close = () => onClose();
14
14
  socket.addEventListener("message", message); socket.addEventListener("close", close);
15
15
  return {
16
- configure({ automatic = false, contextId, audio = true } = {}) { send({ type: "session.update", session: { type: "realtime", output_modalities: audio ? ["audio"] : ["text"], ...(contextId ? { voice_context_id: contextId } : {}), audio: { input: { format: { type: "audio/pcm", rate: 24000 }, turn_detection: automatic ? { type: "server_vad" } : null }, output: { format: { type: "audio/pcm", rate: 24000 } } } }); },
16
+ // Announcing playback acknowledgement up front keeps the first reply half-duplex too.
17
+ configure({ type = "realtime", automatic = false, contextId, audio = true, busyTurn } = {}) {
18
+ const input = { format: { type: "audio/pcm", rate: 24000 }, turn_detection: automatic ? { type: "server_vad" } : null };
19
+ const lares = { ...(contextId ? { context_id: contextId } : {}), ...(busyTurn ? { busy_turn: busyTurn } : {}) };
20
+ send({ type: "session.update", session: { type, ...(type === "realtime" ? { output_modalities: audio ? ["audio"] : ["text"], audio: { input, output: { format: { type: "audio/pcm", rate: 24000 } } } } : { audio: { input } }), ...(Object.keys(lares).length ? { lares } : {}) } });
21
+ send({ type: "lares.playback.progress", item_id: null, played_ms: 0 });
22
+ },
17
23
  append(audio) { if (typeof audio !== "string" || audio.length > 64000) throw new Error("voice_audio_limit"); send({ type: "input_audio_buffer.append", audio }); },
18
24
  commit() { send({ type: "input_audio_buffer.commit" }); },
19
25
  discard() { send({ type: "input_audio_buffer.clear" }); },
20
- playbackCompleted() { send({ type: "voice.playback.completed" }); },
26
+ say(text) { send({ type: "conversation.item.create", item: { type: "message", role: "user", content: [{ type: "input_text", text }] } }); },
27
+ playbackProgress(itemId, playedMs) { send({ type: "lares.playback.progress", item_id: itemId, played_ms: playedMs }); },
28
+ playbackCompleted(itemId = null) { send({ type: "lares.playback.completed", item_id: itemId }); },
21
29
  stopOutput() { send({ type: "response.cancel" }); },
30
+ truncate(itemId, audioEndMs) { send({ type: "conversation.item.truncate", item_id: itemId, content_index: 0, audio_end_ms: Math.max(0, Math.round(audioEndMs)) }); },
31
+ setOutput(audio) { send({ type: "session.update", session: { output_modalities: [audio ? "audio" : "text"] } }); },
32
+ setBusyTurn(mode) { send({ type: "session.update", session: { lares: { busy_turn: mode === "steer" ? "steer" : "queue" } } }); },
33
+ deviceInput(active) { send({ type: active ? "lares.device.input.resume" : "lares.device.input.pause" }); },
34
+ cancelTask(taskId) { send({ type: "lares.task.cancel", task_id: taskId }); },
22
35
  close() { socket.removeEventListener("message", message); socket.removeEventListener("close", close); socket.close(); },
23
36
  };
24
37
  }
@@ -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;
@@ -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/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) => {
@@ -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
+ }