@olares/lares-core 0.15.10 → 0.15.12

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.
Files changed (45) hide show
  1. package/i18n/voice.js +246 -13
  2. package/larepass/host.js +6 -0
  3. package/larepass/mux.js +4 -3
  4. package/larepass/runtime.js +5 -3
  5. package/larepass/settings.js +3 -0
  6. package/larepass/transcript.js +4 -6
  7. package/olares/trusted-host.js +11 -1
  8. package/package.json +27 -2
  9. package/router/tts.js +25 -1
  10. package/tools/http.js +27 -1
  11. package/voice/agent-session.js +133 -0
  12. package/voice/agent.js +9 -1
  13. package/voice/browser-capture.js +274 -0
  14. package/voice/browser-playback.js +30 -0
  15. package/voice/capture.js +12 -2
  16. package/voice/client.js +71 -9
  17. package/voice/composite-gateway.js +156 -0
  18. package/voice/config.js +30 -17
  19. package/voice/controller.js +147 -0
  20. package/voice/conversation.js +131 -0
  21. package/voice/device-input.js +79 -0
  22. package/voice/dialogue.js +71 -0
  23. package/voice/duplex.js +1 -1
  24. package/voice/external.js +26 -0
  25. package/voice/gpu-mode.js +460 -0
  26. package/voice/gpu-problem.js +47 -0
  27. package/voice/host/bluetooth-ready.py +2 -0
  28. package/voice/host-input.js +38 -0
  29. package/voice/host-service.js +171 -0
  30. package/voice/input.js +2 -1
  31. package/voice/install.js +33 -12
  32. package/voice/languages.js +2 -6
  33. package/voice/lease.js +16 -0
  34. package/voice/options.js +23 -0
  35. package/voice/protocol-client.js +24 -0
  36. package/voice/realtime-audio.js +107 -0
  37. package/voice/realtime-conversation.js +122 -0
  38. package/voice/realtime-gateway.js +162 -0
  39. package/voice/realtime.js +63 -0
  40. package/voice/records.js +72 -0
  41. package/voice/router-audio.js +26 -0
  42. package/voice/script.js +85 -15
  43. package/voice/service.js +340 -57
  44. package/voice/session.js +11 -1
  45. package/voice/speech.js +228 -44
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocketServer } from "ws";
3
+ import { verifyVoicePrincipal, authorizeVoicePreset } from "./external.js";
4
+ import { createSpeechEndpoint, frameRms } from "./endpoint.js";
5
+ import { pcmSamples } from "./realtime-audio.js";
6
+
7
+ export const COMPOSITE_CAPABILITIES = Object.freeze({ protocol: "olares-voice-ws-v1", transport: "websocket", input: "pcm16-mono-24000", output: "pcm16-mono-24000", turnDetection: ["manual", "server_vad"], contextResume: true, requiresPlaybackAck: true, clientTools: false, truncation: false, webrtc: false, sip: false, usage: "child-models" });
8
+
9
+ /** Minimal declared Realtime subset. It never accepts arbitrary client tools or business bindings. */
10
+ export function createCompositeGateway({ service, config, transcribe, synthesize, secret, grants = {}, hostOwner = "", enabled = false }) {
11
+ const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false });
12
+ const clients = new Map();
13
+ const nonces = new Map();
14
+ return {
15
+ capabilities: () => ({ ...COMPOSITE_CAPABILITIES, enabled: enabled && typeof secret === "string" && secret.length >= 32 }),
16
+ upgrade(req, socket, head) {
17
+ let identity;
18
+ let grant;
19
+ try {
20
+ if (!enabled) throw new Error("voice_external_disabled");
21
+ identity = verifyVoicePrincipal(req.headers, secret);
22
+ const model = new URL(req.url, "http://voice.invalid").searchParams.get("model");
23
+ grant = authorizeVoicePreset(identity, grants, model);
24
+ if (["assistant", "direct"].includes(grant.preset) && grant.owner !== hostOwner) throw new Error("voice_external_owner_mismatch");
25
+ const dependencies = config();
26
+ if ([dependencies.model, dependencies.chatModel, dependencies.ttsModel, dependencies.realtimeModel].some((id) => id === model || id?.endsWith(`/${model}`))) throw new Error("voice_recursive_model");
27
+ for (const [nonce, expires] of nonces) if (expires < Date.now()) nonces.delete(nonce);
28
+ if (nonces.has(identity.nonce) || nonces.size >= 4096 || clients.size >= 64) throw new Error("voice_external_limit");
29
+ nonces.set(identity.nonce, Date.now() + 120000);
30
+ } catch {
31
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
32
+ }
33
+ server.handleUpgrade(req, socket, head, (client) => {
34
+ clients.set(client, () => close());
35
+ const scope = { id: randomUUID(), owner: grant.owner, authorizedBinding: grant, device: "device" };
36
+ let chunks = [], bytes = 0, automatic = false, closed = false, busy = false, generation = 0, seconds = 0;
37
+ let endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 });
38
+ let lastReply = null;
39
+ let awaitingPlayback = false;
40
+ let audioOutput = true;
41
+ let responseId = null;
42
+ let speaking = false;
43
+ let configuring = false;
44
+ let submittedTurns = 0;
45
+ let alive = true;
46
+ const send = (event) => {
47
+ if (client.readyState !== 1) return;
48
+ if (client.bufferedAmount > 1024 * 1024) { close(); return; }
49
+ client.send(JSON.stringify({ event_id: randomUUID(), ...event }));
50
+ };
51
+ const reset = () => { chunks = []; bytes = 0; speaking = false; endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 }); };
52
+ const done = (id, status = "completed") => send({ type: "response.done", response: { id, status, output: [] }, voice_usage: { input_audio_seconds: seconds, billing: "child-models" } });
53
+ const emit = async (text, id, epoch) => {
54
+ responseId = id;
55
+ send({ type: "response.created", response: { id, status: "in_progress" } });
56
+ send({ type: "response.output_audio_transcript.delta", response_id: id, delta: text });
57
+ if (!audioOutput) { done(id); return; }
58
+ scope.outputAbort = new AbortController();
59
+ const audio = await synthesize(text, scope);
60
+ if (closed || epoch !== generation) return;
61
+ awaitingPlayback = true;
62
+ for (let offset = 0; offset < audio.length; offset += 24000) send({ type: "response.output_audio.delta", response_id: id, item_id: id, delta: audio.subarray(offset, offset + 24000).toString("base64") });
63
+ send({ type: "response.output_audio.done", response_id: id });
64
+ done(id);
65
+ };
66
+ const commit = async () => {
67
+ if (!bytes || busy) return;
68
+ const input = Buffer.concat(chunks); reset(); busy = true;
69
+ const epoch = generation;
70
+ const id = randomUUID(); submittedTurns++;
71
+ try {
72
+ let text = await transcribe(input, scope);
73
+ if (grant.preset === "dictation") text = await service.clean(scope, text);
74
+ if (closed || epoch !== generation || !text.trim()) return;
75
+ send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: text });
76
+ if (grant.preset === "dictation") return;
77
+ const state = await service.prompt(scope, { text, requestId: id });
78
+ if (closed || epoch !== generation) return;
79
+ if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; await emit(state.utterance.text, id, epoch); }
80
+ } catch (cause) { if (!closed && epoch === generation) send({ type: "error", error: { code: cause.code || "voice_failed", message: "Voice operation failed" } }); }
81
+ finally { busy = false; }
82
+ };
83
+ const close = () => {
84
+ if (closed) return;
85
+ closed = true; scope.outputAbort?.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
86
+ clients.delete(client); client.close(); void service.close(scope).catch(() => {});
87
+ };
88
+ let idle;
89
+ const touch = () => { clearTimeout(idle); idle = setTimeout(close, config().idleTimeoutMs || 300000); };
90
+ touch();
91
+ const heartbeat = setInterval(() => { if (!alive) close(); else { alive = false; client.ping(); } }, 15000);
92
+ const results = setInterval(() => {
93
+ if (!scope.active || busy || speaking || awaitingPlayback) return;
94
+ try {
95
+ const state = service.state(scope);
96
+ if (!state.utterance?.text || state.utterance.key === lastReply || state.utterance.silent) return;
97
+ lastReply = state.utterance.key; busy = true;
98
+ void emit(state.utterance.text, randomUUID(), generation).catch(() => close()).finally(() => { busy = false; });
99
+ } catch { close(); }
100
+ }, 250);
101
+ client.on("pong", () => { alive = true; });
102
+ client.on("close", close); client.on("error", close);
103
+ void service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, readAloud: false }).then(() => {
104
+ send({ type: "session.created", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
105
+ client.on("message", async (raw) => {
106
+ try {
107
+ if (configuring) throw new Error("voice_not_ready");
108
+ const event = JSON.parse(raw.toString());
109
+ if (event.type === "session.update") {
110
+ touch();
111
+ const session = event.session || {};
112
+ if (session.type && session.type !== "realtime") throw new Error("voice_session_forbidden");
113
+ if (session.audio?.input && Object.keys(session.audio.input).some((key) => !["format", "turn_detection"].includes(key))) throw new Error("voice_audio_unsupported");
114
+ if (session.audio?.output && Object.keys(session.audio.output).some((key) => key !== "format")) throw new Error("voice_audio_unsupported");
115
+ if (Object.keys(session).some((key) => !["type", "model", "audio", "output_modalities", "voice_context_id"].includes(key)) || (session.model && session.model !== identity.model)) throw new Error("voice_session_forbidden");
116
+ if (session.voice_context_id && session.voice_context_id !== scope.contextId) {
117
+ if (submittedTurns || bytes || busy) throw new Error("voice_context_locked");
118
+ configuring = true;
119
+ try {
120
+ await service.close(scope);
121
+ await service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, contextId: session.voice_context_id, readAloud: false });
122
+ } finally { configuring = false; }
123
+ }
124
+ const detection = session.audio?.input?.turn_detection;
125
+ if (detection && (detection.type !== "server_vad" || Object.keys(detection).some((key) => key !== "type"))) throw new Error("voice_turn_detection_unsupported");
126
+ for (const format of [session.audio?.input?.format, session.audio?.output?.format].filter(Boolean)) if (format.type !== "audio/pcm" || format.rate !== 24000) throw new Error("voice_audio_format");
127
+ if (session.output_modalities && (!Array.isArray(session.output_modalities) || session.output_modalities.length !== 1 || !["audio", "text"].includes(session.output_modalities[0]))) throw new Error("voice_output_unsupported");
128
+ audioOutput = !session.output_modalities || session.output_modalities[0] === "audio";
129
+ automatic = detection?.type === "server_vad";
130
+ send({ type: "session.updated", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
131
+ } else if (event.type === "input_audio_buffer.append") {
132
+ if (awaitingPlayback) throw new Error("voice_waiting_for_playback");
133
+ if (busy) throw new Error("voice_turn_busy");
134
+ if (typeof event.audio !== "string" || event.audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(event.audio)) throw new Error("voice_audio_format");
135
+ const chunk = Buffer.from(event.audio, "base64");
136
+ if (chunk.length % 2 || bytes + chunk.length > 2880000) throw new Error("voice_audio_limit");
137
+ chunks.push(chunk); bytes += chunk.length; seconds += chunk.length / 48000;
138
+ const samples = pcmSamples(chunk);
139
+ if (frameRms(samples) >= 0.004) touch();
140
+ if (frameRms(samples) >= 0.004 && !speaking) { speaking = true; scope.voiceInputActive = true; send({ type: "input_audio_buffer.speech_started" }); }
141
+ if (automatic && endpoint.push(1, frameRms(samples), chunk.length / 48)) {
142
+ scope.voiceInputActive = false; send({ type: "input_audio_buffer.speech_stopped" }); void commit();
143
+ }
144
+ } else if (event.type === "input_audio_buffer.commit") { scope.voiceInputActive = false; void commit(); }
145
+ else if (event.type === "input_audio_buffer.clear") { reset(); scope.voiceInputActive = false; send({ type: "input_audio_buffer.cleared" }); }
146
+ else if (event.type === "voice.playback.completed") { awaitingPlayback = false; }
147
+ else if (event.type === "response.cancel") { scope.outputAbort?.abort(); generation++; reset(); awaitingPlayback = false; done(responseId || randomUUID(), "cancelled"); }
148
+ else throw new Error("voice_event_unsupported");
149
+ } catch (cause) { send({ type: "error", error: { code: cause.message, message: "Unsupported or invalid voice event" } }); }
150
+ });
151
+ }).catch(close);
152
+ });
153
+ },
154
+ close() { for (const close of clients.values()) close(); server.close(); },
155
+ };
156
+ }
package/voice/config.js CHANGED
@@ -4,12 +4,7 @@ import { STT_LANGUAGE_CHOICES } from "./languages.js";
4
4
 
5
5
  export { STT_LANGUAGE_CHOICES, VOICE_AUTO, voiceMenuValue, voiceValueFromMenu } from "./languages.js";
6
6
 
7
- const DEFAULTS = {
8
- model: "",
9
- ttsModel: "",
10
- language: "",
11
- speak: false,
12
- };
7
+ import { VOICE_DEFAULTS as DEFAULTS, VOICE_PRESETS } from "./options.js";
13
8
 
14
9
  const WRITABLE = new Set(Object.keys(DEFAULTS));
15
10
  const SUPPORTED_LANGUAGES = new Set(["", ...STT_LANGUAGE_CHOICES.map((item) => item.id)]);
@@ -25,14 +20,34 @@ export function validateConfigPatch(raw) {
25
20
  const patch = {};
26
21
  for (const [key, value] of Object.entries(raw)) {
27
22
  if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
28
- if (key === "speak") {
29
- if (typeof value !== "boolean") invalid("speak must be a boolean");
30
- patch.speak = value;
23
+ if (key === "preset") {
24
+ if (!VOICE_PRESETS.includes(value)) invalid("invalid voice preset");
25
+ patch[key] = value; continue;
26
+ }
27
+ if (key === "endpointSilenceMs") {
28
+ if (!Number.isInteger(value) || value < 200 || value > 3000) invalid("invalid endpoint delay");
29
+ patch[key] = value; continue;
30
+ }
31
+ if (key === "idleTimeoutMs" || key === "recoveryTimeoutMs") {
32
+ if (!Number.isInteger(value) || value < 10_000 || value > 3_600_000) invalid(`invalid ${key}`);
33
+ patch[key] = value;
34
+ continue;
35
+ }
36
+ if (key === "speak" || key === "streaming" || key === "dictationCleanup") {
37
+ if (typeof value !== "boolean") invalid(`${key} must be a boolean`);
38
+ patch[key] = value;
39
+ continue;
40
+ }
41
+ if (["mode", "mobileMode", "device", "backend"].includes(key)) {
42
+ const choices = { mobileMode: ["single", "continuous"], mode: ["single", "continuous"], device: ["auto", "device", "headset"], backend: ["pipeline", "realtime"] };
43
+ if (!choices[key].includes(value)) invalid(`invalid ${key}`);
44
+ patch[key] = value;
31
45
  continue;
32
46
  }
33
47
  if (typeof value !== "string") invalid(`${key} must be a string`);
34
48
  const normalized = value.trim();
35
- if ((key === "model" || key === "ttsModel") && !SAFE_MODEL.test(normalized)) invalid("invalid voice model");
49
+ if ((key === "model" || key === "ttsModel" || key === "realtimeModel" || key === "chatModel" || key === "voice") && !SAFE_MODEL.test(normalized)) invalid("invalid voice model");
50
+ if (key === "instructions" && normalized.length > 8000) invalid("voice instructions too long");
36
51
  if (key === "language" && !SUPPORTED_LANGUAGES.has(normalized)) invalid("unsupported recognition language");
37
52
  patch[key] = normalized;
38
53
  }
@@ -47,13 +62,11 @@ function configPath() {
47
62
  export function readConfig() {
48
63
  const stored = readJsonFile(configPath());
49
64
  const merged = { ...DEFAULTS };
50
- const model = typeof stored?.model === "string" ? stored.model.trim() : "";
51
- const ttsModel = typeof stored?.ttsModel === "string" ? stored.ttsModel.trim() : "";
52
- const language = typeof stored?.language === "string" ? stored.language.trim() : "";
53
- if (SAFE_MODEL.test(model)) merged.model = model;
54
- if (SAFE_MODEL.test(ttsModel)) merged.ttsModel = ttsModel;
55
- if (SUPPORTED_LANGUAGES.has(language)) merged.language = language;
56
- if (stored?.speak === true) merged.speak = true;
65
+ for (const key of Object.keys(DEFAULTS)) {
66
+ if (stored?.[key] !== undefined) {
67
+ try { Object.assign(merged, validateConfigPatch({ [key]: stored[key] })); } catch {}
68
+ }
69
+ }
57
70
  return merged;
58
71
  }
59
72
 
@@ -0,0 +1,147 @@
1
+ import { mintRpcId } from "../larepass/rpc.js";
2
+
3
+ /** Device, agent and playback ports are injected; UI frameworks own no turn state. */
4
+ export function createVoiceController({ input, agent, output, voiceId = mintRpcId(), metric = () => {}, onTranscript = null, idleTimeoutMs = 300_000, recoveryTimeoutMs = 120_000, onExpired = () => {}, now = Date.now, schedule = setTimeout, unschedule = clearTimeout }) {
5
+ let epoch = 0;
6
+ let take = null;
7
+ let idleTimer = null;
8
+ let recoveryTimer = null;
9
+ const clearTimers = () => { unschedule(idleTimer); unschedule(recoveryTimer); };
10
+ const expire = async (code) => {
11
+ await controller.disconnect();
12
+ await onExpired(code);
13
+ change({ error: code });
14
+ };
15
+ const touch = () => {
16
+ unschedule(idleTimer);
17
+ if (state.connection !== "open") return;
18
+ idleTimer = schedule(() => { void expire("voice_idle_timeout"); }, idleTimeoutMs);
19
+ idleTimer.unref?.();
20
+ };
21
+ let creating = null;
22
+ let state = Object.freeze({ voiceId, sessionId: null, connection: "closed", capture: "idle", agent: "idle", playback: "idle", continuous: false, inputPaused: false, userSpeaking: false, inputLevel: null, outputMuted: false, error: null });
23
+ const listeners = new Set();
24
+ const change = (patch) => {
25
+ state = Object.freeze({ ...state, ...patch });
26
+ for (const listener of listeners) listener();
27
+ };
28
+ const report = (stage, started, extra = {}) => {
29
+ try { metric({ voiceId: state.voiceId, sessionId: state.sessionId, stage, durationMs: Math.max(0, now() - started), ...extra }); } catch {}
30
+ };
31
+ const live = (run) => run.epoch === epoch && !run.abort.signal.aborted && state.connection === "open";
32
+ const ensureSession = async () => {
33
+ if (state.sessionId) return state.sessionId;
34
+ creating ??= Promise.resolve(agent.create()).then((sessionId) => {
35
+ if (!sessionId && !agent.lazy) throw new Error("voice_session_failed");
36
+ return sessionId;
37
+ });
38
+ return creating;
39
+ };
40
+ const cancelInput = async () => {
41
+ const old = take;
42
+ take = null;
43
+ old?.abort.abort();
44
+ try { if (old) await input.cancel?.(); } finally { change({ capture: "idle", userSpeaking: false, inputLevel: null }); }
45
+ };
46
+ const stopPlayback = async () => {
47
+ const started = now();
48
+ await output.stop();
49
+ change({ playback: "idle" });
50
+ report("interrupt", started);
51
+ };
52
+ const listen = async () => {
53
+ if (state.connection !== "open" || state.inputPaused || take || state.capture !== "idle") return;
54
+ if (state.continuous && (
55
+ (state.agent !== "idle" && !input.capabilities?.listenWhileWorking)
56
+ || (state.playback !== "idle" && !input.capabilities?.bargeIn)
57
+ )) return;
58
+ const run = { epoch, abort: new AbortController(), id: mintRpcId(), started: now() };
59
+ take = run;
60
+ change({ capture: "recording", error: null });
61
+ try {
62
+ const text = String(await input.capture({ signal: run.abort.signal, continuous: state.continuous, onLevel: (level) => { if (live(run)) change({ inputLevel: Math.max(0, Math.min(1, level)) }); }, onSpeech: () => {
63
+ if (live(run)) { touch(); change({ userSpeaking: true }); if (input.capabilities?.bargeIn) void stopPlayback(); }
64
+ }, onTranscribing: () => { if (live(run)) change({ capture: "transcribing", userSpeaking: false }); } }) ?? "").trim();
65
+ if (!live(run)) return;
66
+ report("recognition", run.started, { turnId: run.id });
67
+ if (!text) return;
68
+ touch();
69
+ if (onTranscript) {
70
+ await onTranscript({ text, turnId: run.id, voiceId: state.voiceId, sessionId: state.sessionId, signal: run.abort.signal });
71
+ return;
72
+ }
73
+ const sessionId = await ensureSession();
74
+ if (!live(run)) return;
75
+ change({ sessionId, capture: "idle", agent: "running" });
76
+ await agent.submit({ sessionId, text, requestId: run.id, signal: run.abort.signal });
77
+ // Acceptance is not completion; observeAgent receives the durable turn lifecycle.
78
+ } catch (error) {
79
+ if (live(run)) {
80
+ await controller.disconnect(error.code || error.message || "voice_failed");
81
+ }
82
+ } finally {
83
+ if (take === run) {
84
+ take = null;
85
+ change({ capture: "idle", userSpeaking: false, inputLevel: null });
86
+ if (state.continuous && state.connection === "open" && (state.agent === "idle" || input.capabilities?.listenWhileWorking) && state.playback === "idle") {
87
+ queueMicrotask(() => { void listen(); });
88
+ }
89
+ }
90
+ }
91
+ };
92
+ const controller = {
93
+ getSnapshot: () => state,
94
+ noteActivity: touch,
95
+ setOutputMuted(outputMuted) { change({ outputMuted }); },
96
+ bindSession(sessionId) { if (sessionId) change({ sessionId }); },
97
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
98
+ async start({ sessionId = null, continuous = false, running = false } = {}) {
99
+ const startedEpoch = ++epoch;
100
+ creating = null;
101
+ await cancelInput();
102
+ if (startedEpoch !== epoch) return;
103
+ await stopPlayback();
104
+ if (startedEpoch !== epoch) return;
105
+ change({ voiceId, sessionId, continuous, inputPaused: false, connection: "open", agent: running ? "running" : "idle", error: null });
106
+ clearTimers(); touch();
107
+ void listen();
108
+ },
109
+ listen,
110
+ stopRecording: () => input.stop?.(),
111
+ cancelRecording: cancelInput,
112
+ async pauseInput() { change({ inputPaused: true }); await cancelInput(); },
113
+ resumeInput() { change({ inputPaused: false }); void listen(); },
114
+ stopPlayback,
115
+ async cancelTask() {
116
+ if (state.sessionId) await agent.cancel(state.sessionId);
117
+ },
118
+ observeAgent(running, playbackExpected = false) {
119
+ if (playbackExpected && take && !input.capabilities?.bargeIn) void cancelInput();
120
+ change({ agent: running ? "running" : "idle", ...(playbackExpected ? { playback: "pending" } : {}) });
121
+ if (!running && state.continuous) void listen();
122
+ },
123
+ observePlayback(playing) {
124
+ if (playing !== (state.playback === "playing")) touch();
125
+ change({ playback: playing ? "playing" : "idle" });
126
+ if (!playing && state.continuous) void listen();
127
+ },
128
+ async disconnect(code = null) {
129
+ clearTimers();
130
+ if (code) {
131
+ recoveryTimer = schedule(() => { void expire("voice_recovery_expired"); }, recoveryTimeoutMs);
132
+ recoveryTimer.unref?.();
133
+ }
134
+ epoch += 1;
135
+ change({ connection: code ? "paused" : "closed", error: code });
136
+ await Promise.allSettled([cancelInput(), stopPlayback()]);
137
+ },
138
+ async resume() {
139
+ if (state.connection !== "paused") return;
140
+ const running = state.sessionId ? await agent.running(state.sessionId) : false;
141
+ clearTimers();
142
+ change({ connection: "open", agent: running ? "running" : "idle", error: null });
143
+ touch(); void listen();
144
+ },
145
+ };
146
+ return controller;
147
+ }
@@ -0,0 +1,131 @@
1
+ import { createVoiceController } from "./controller.js";
2
+ import { createDeviceInput } from "./device-input.js";
3
+ import { createHostInput } from "./host-input.js";
4
+ import { mintRpcId } from "../larepass/rpc.js";
5
+
6
+ export function createVoiceConversation({ request, transcribe, playback, onReply = () => {}, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, input: inputPort }) {
7
+ const voiceId = mintRpcId();
8
+ const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
9
+ let timer = null;
10
+ let disposed = false;
11
+ let version = -1;
12
+ let lastSessionId = null;
13
+ let spokenKey = null;
14
+ let lastReplyKey = null;
15
+ let mutedKey = null;
16
+ let submitted = false;
17
+ let finalSeen = false;
18
+ let continuous = false;
19
+ let reading = false;
20
+ const call = async (path, body, signal) => {
21
+ const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers: { ...headers, "x-lares-voice-speaking": controller.getSnapshot().userSpeaking ? "true" : "false" }, ...(body === undefined ? {} : { body }), signal });
22
+ if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
23
+ return res.body;
24
+ };
25
+ const input = inputPort ?? (device === "headset" ? createHostInput({
26
+ start: (signal) => call("/capture", {}, signal), status: (signal) => call("/capture", undefined, signal),
27
+ stop: () => call("/capture/stop", {}), cancel: () => call("/capture/cancel", {}),
28
+ }) : createDeviceInput({ transcribe, onPhase, endpointSilenceMs }));
29
+ const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { const cleaned = await call("/dictation/clean", { text: value.text }, value.signal); value.signal.throwIfAborted(); if (typeof cleaned.text !== "string") throw new Error("voice_cleanup_failed"); await onTranscript({ ...value, text: cleaned.text }); if (!continuous) await stop(); } : null, metric: (event) => {
30
+ metric(event);
31
+ void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {});
32
+ }, output: { async stop() { playback.stop(); await call("/playback/stop", {}); } }, agent: {
33
+ lazy: true,
34
+ create: async () => null,
35
+ async submit({ text, requestId, signal }) {
36
+ const state = await call("/session/prompt", { text, requestId, mode: mode() }, signal);
37
+ submitted = true;
38
+ lastSessionId = state.sessionId;
39
+ await poll();
40
+ },
41
+ cancel: () => call("/session/cancel", {}),
42
+ running: async () => (await call("/session")).running === true,
43
+ } });
44
+ const poll = async () => {
45
+ if (disposed) return;
46
+ const state = await call("/session");
47
+ if (disposed || controller.getSnapshot().connection !== "open") return;
48
+ lastSessionId = state.sessionId;
49
+ if (lastSessionId) controller.bindSession(lastSessionId);
50
+ if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
51
+ if (state.version !== version) {
52
+ version = state.version;
53
+ // Reserve playback before releasing the completed Agent turn.
54
+ const reply = state.utterance;
55
+ if (reply?.text && !reply.silent) onText(reply.text);
56
+ const key = reply ? `${state.sessionId}:${reply.key}` : null;
57
+ lastReplyKey = key;
58
+ const reserve = readAloud && key !== mutedKey && reply?.text && !reply.silent && key !== spokenKey;
59
+ if (reserve) spokenKey = key;
60
+ controller.observeAgent(Boolean(state.running), Boolean(reserve));
61
+ if (!readAloud || reply?.silent) controller.observePlayback(false);
62
+ if (readAloud && key !== mutedKey && reply) onReply({ ...reply, key: `${voiceId}:${state.sessionId}:${reply.key}` }, headers);
63
+ }
64
+ if (device === "headset" && !state.running && !reading) {
65
+ const audio = await call("/playback/status");
66
+ controller.observePlayback(Boolean(audio.playing));
67
+ }
68
+ finalSeen = submitted && !state.running;
69
+ return state;
70
+ };
71
+ const watch = async () => {
72
+ if (disposed || controller.getSnapshot().connection !== "open") return;
73
+ try { await poll(); }
74
+ catch (error) { await controller.disconnect(error.code || error.message || "voice_disconnected"); return; }
75
+ if (!continuous && finalSeen && controller.getSnapshot().playback === "idle") {
76
+ try { await stop(); } catch { await controller.disconnect("voice_disconnected"); }
77
+ return;
78
+ }
79
+ if (!disposed) timer = setTimeout(watch, 250);
80
+ };
81
+ const stop = async () => {
82
+ disposed = true; clearTimeout(timer);
83
+ await controller.disconnect();
84
+ if (!onTranscript) await call("/session/close", {});
85
+ };
86
+ return {
87
+ controller, headers,
88
+ playbackState(playing) {
89
+ reading = playing;
90
+ if (!playing && device === "headset" && !disposed) controller.observePlayback(true);
91
+ else controller.observePlayback(playing);
92
+ },
93
+ get sessionId() { return lastSessionId; },
94
+ async start({ sessionId = null, continuous: nextContinuous = false } = {}) {
95
+ continuous = nextContinuous; submitted = false; finalSeen = false; spokenKey = null;
96
+ disposed = false; version = -1;
97
+ clearTimeout(timer);
98
+ const initial = onTranscript ? {} : await call("/session", { sessionId, readAloud, ...(preset ? { preset } : {}) });
99
+ await controller.start({ sessionId, continuous, running: initial.running });
100
+ controller.setOutputMuted(!readAloud);
101
+ if (!onTranscript) void watch();
102
+ },
103
+ stop,
104
+ async resume() {
105
+ if (onTranscript) { await controller.resume(); return; }
106
+ const current = await call("/session");
107
+ if (!current.active) await call("/session", { sessionId: lastSessionId, readAloud });
108
+ else await call("/session/resume", {});
109
+ disposed = false;
110
+ await controller.resume();
111
+ void watch();
112
+ },
113
+ async setReadAloud(value) {
114
+ readAloud = value === true; mutedKey = lastReplyKey;
115
+ controller.setOutputMuted(!readAloud);
116
+ if (!readAloud) await controller.stopPlayback();
117
+ await call("/session/output", { readAloud });
118
+ },
119
+ records: async () => {
120
+ const res = await request("/api/lares/voice/records", { method: "GET", headers });
121
+ if (!res?.ok) throw new Error("voice_records_failed");
122
+ return res.body;
123
+ },
124
+ clearRecords: async () => {
125
+ await stop();
126
+ const res = await request("/api/lares/voice/records", { method: "DELETE", headers });
127
+ if (!res?.ok) throw new Error("voice_records_failed");
128
+ },
129
+ speak: (request) => call("/speak", request),
130
+ };
131
+ }
@@ -0,0 +1,79 @@
1
+ import { createVoiceCapture } from "./browser-capture.js";
2
+ import { createSpeechEndpoint, frameRms } from "./endpoint.js";
3
+
4
+ export function createEnergyEndpoint(stream, end, onSpeech = () => {}, onLevel = () => {}, options = {}) {
5
+ const AudioCtx = globalThis.AudioContext || globalThis.webkitAudioContext;
6
+ if (!AudioCtx) throw new Error("voice_unsupported");
7
+ const context = new AudioCtx();
8
+ const source = context.createMediaStreamSource(stream);
9
+ const processor = context.createScriptProcessor(2048, 1, 1);
10
+ const gain = context.createGain();
11
+ gain.gain.value = 0;
12
+ const endpoint = createSpeechEndpoint(options);
13
+ let heard = false;
14
+ const silenceTimer = setTimeout(() => { if (!heard) end(false); }, 10_000);
15
+ processor.onaudioprocess = (event) => {
16
+ const samples = event.inputBuffer.getChannelData(0);
17
+ const rms = frameRms(samples);
18
+ onLevel(rms);
19
+ if (rms >= 0.004 && !heard) { heard = true; onSpeech(); }
20
+ if (endpoint.push(1, rms, samples.length * 1000 / context.sampleRate)) end(true);
21
+ };
22
+ source.connect(processor); processor.connect(gain); gain.connect(context.destination);
23
+ void context.resume();
24
+ return { stop() { clearTimeout(silenceTimer); processor.onaudioprocess = null; source.disconnect(); processor.disconnect(); gain.disconnect(); void context.close(); } };
25
+ }
26
+
27
+ function createInputMeter(stream, onLevel) {
28
+ const AudioCtx = globalThis.AudioContext || globalThis.webkitAudioContext;
29
+ if (!AudioCtx) return { stop() {} };
30
+ const context = new AudioCtx();
31
+ const source = context.createMediaStreamSource(stream);
32
+ const analyser = context.createAnalyser(); analyser.fftSize = 512;
33
+ source.connect(analyser);
34
+ const samples = new Float32Array(analyser.fftSize);
35
+ const timer = setInterval(() => { analyser.getFloatTimeDomainData(samples); onLevel(frameRms(samples)); }, 80);
36
+ void context.resume();
37
+ return { stop() { clearInterval(timer); source.disconnect(); analyser.disconnect(); void context.close(); } };
38
+ }
39
+
40
+ export function createDeviceInput({ transcribe, onPhase = () => {}, endpoint = createEnergyEndpoint, endpointSilenceMs = 500, media } = {}) {
41
+ let recorder = null;
42
+ let settle = null;
43
+ let timer = null;
44
+ const cancel = () => {
45
+ clearTimeout(timer);
46
+ recorder?.dispose(); recorder = null;
47
+ settle?.(""); settle = null;
48
+ };
49
+ return {
50
+ capabilities: { duplex: true, bargeIn: false, listenWhileWorking: true, audio: "encoded" },
51
+ capture({ signal, onTranscribing, onSpeech, onLevel = () => {}, continuous = false } = {}) {
52
+ cancel();
53
+ return new Promise((resolve, reject) => {
54
+ settle = resolve;
55
+ const done = (text) => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); settle = null; resolve(text); };
56
+ recorder = createVoiceCapture({ media, transcribe, submit: done, onStream: (stream) => {
57
+ for (const track of stream.getTracks()) track.addEventListener?.("ended", () => { settle = null; cancel(); reject(new Error("voice_device_lost")); }, { once: true });
58
+ if (!continuous) return createInputMeter(stream, onLevel);
59
+ return endpoint(stream, (heard = true) => { if (heard) recorder?.stop(); else {
60
+ if (continuous) { cancel(); done(""); }
61
+ else { settle = null; cancel(); reject(new Error("voice_no_input")); }
62
+ } }, onSpeech, onLevel, { silenceMs: endpointSilenceMs });
63
+ }, onPhase(phase, extra) {
64
+ onPhase(phase, extra);
65
+ if (phase === "transcribing") onTranscribing?.();
66
+ if (phase === "error") {
67
+ if (continuous && ["voice_no_input", "voice_too_short"].includes(extra.error)) { done(""); return; }
68
+ clearTimeout(timer); signal?.removeEventListener("abort", cancel); settle = null; reject(new Error(extra.error)); }
69
+ } });
70
+ signal?.addEventListener("abort", cancel, { once: true });
71
+ if (signal?.aborted) { cancel(); return; }
72
+ timer = setTimeout(() => recorder?.stop(), 60_000);
73
+ void recorder.start();
74
+ });
75
+ },
76
+ stop: () => recorder?.stop(),
77
+ cancel,
78
+ };
79
+ }