@olares/lares-core 0.15.11 → 0.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/i18n/voice.js +180 -10
- package/larepass/runtime.js +2 -0
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +7 -0
- package/package.json +28 -2
- package/router/tts.js +1 -1
- package/tools/http.js +3 -2
- package/voice/agent-session.js +134 -0
- package/voice/agent.js +9 -1
- package/voice/browser-capture.js +274 -0
- package/voice/browser-playback.js +30 -0
- package/voice/capture.js +12 -2
- package/voice/client.js +29 -14
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +28 -18
- package/voice/controller.js +154 -0
- package/voice/conversation.js +135 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +99 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +147 -59
- package/voice/gpu-problem.js +47 -0
- package/voice/host/bluetooth-ready.py +2 -0
- package/voice/host-input.js +38 -0
- package/voice/host-service.js +216 -0
- package/voice/input.js +2 -1
- package/voice/install.js +9 -5
- package/voice/languages.js +1 -1
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/panel.js +88 -0
- package/voice/projection.js +67 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +126 -0
- package/voice/realtime-gateway.js +168 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +96 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +33 -12
- package/voice/service.js +185 -38
- package/voice/session.js +11 -1
- package/voice/speech.js +9 -3
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createVoiceController } from "./controller.js";
|
|
2
|
+
import { createPcmBrowserAudio } from "./realtime-audio.js";
|
|
3
|
+
import { mintRpcId } from "../larepass/rpc.js";
|
|
4
|
+
|
|
5
|
+
export function createRealtimeConversation({ request, socket: openSocket, device = "device", playback, onCaption = () => {}, idleTimeoutMs, recoveryTimeoutMs, readAloud = true, presentationSession = false }) {
|
|
6
|
+
const voiceId = mintRpcId();
|
|
7
|
+
const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
|
|
8
|
+
let socket;
|
|
9
|
+
let intentional = false;
|
|
10
|
+
let currentItem = null;
|
|
11
|
+
let responseActive = false;
|
|
12
|
+
let receivedMs = 0;
|
|
13
|
+
let boundSessionId = null;
|
|
14
|
+
let pollTimer;
|
|
15
|
+
let audio;
|
|
16
|
+
const send = (value) => {
|
|
17
|
+
if (socket?.readyState !== 1) return;
|
|
18
|
+
if (socket.bufferedAmount > 1024 * 1024) { void controller.disconnect("voice_audio_backpressure"); return; }
|
|
19
|
+
socket.send(JSON.stringify(value));
|
|
20
|
+
};
|
|
21
|
+
const call = async (path, body) => {
|
|
22
|
+
const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers, ...(body === undefined ? {} : { body }) });
|
|
23
|
+
if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
|
|
24
|
+
return res.body;
|
|
25
|
+
};
|
|
26
|
+
const stopPlayback = async () => {
|
|
27
|
+
const played = Math.min(receivedMs, audio?.playedMs() || 0);
|
|
28
|
+
audio?.stopPlayback(); playback?.stop?.();
|
|
29
|
+
if (responseActive) send({ type: "response.cancel" });
|
|
30
|
+
responseActive = false;
|
|
31
|
+
if (currentItem && device === "device") send({ type: "conversation.item.truncate", item_id: currentItem, content_index: 0, audio_end_ms: played });
|
|
32
|
+
controller.observePlayback(false);
|
|
33
|
+
};
|
|
34
|
+
const controller = createVoiceController({ voiceId, idleTimeoutMs, recoveryTimeoutMs,
|
|
35
|
+
onExpired: async () => { intentional = true; clearTimeout(pollTimer); socket?.close(); await audio?.stop(); await call("/session/close", {}); },
|
|
36
|
+
input: { capabilities: { duplex: device === "device", bargeIn: false, listenWhileWorking: true, audio: "pcm16-24000" },
|
|
37
|
+
capture: ({ signal }) => new Promise((resolve, reject) => {
|
|
38
|
+
send({ type: "lares.input.resume" });
|
|
39
|
+
if (device === "device") void audio.start().catch(reject);
|
|
40
|
+
signal.addEventListener("abort", () => resolve(""), { once: true });
|
|
41
|
+
}), cancel: async () => { send({ type: "lares.input.pause" }); audio?.pauseInput(); },
|
|
42
|
+
}, output: { stop: stopPlayback }, agent: {
|
|
43
|
+
create: async () => boundSessionId,
|
|
44
|
+
submit: async () => {},
|
|
45
|
+
cancel: () => call("/session/cancel", {}),
|
|
46
|
+
running: async () => (await call("/session")).running === true,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
audio = createPcmBrowserAudio({ send, onPlayback: (playing) => controller.observePlayback(playing), onError: (error) => { void controller.disconnect(error.message); } });
|
|
50
|
+
const connect = async () => {
|
|
51
|
+
intentional = false;
|
|
52
|
+
const grant = await call("/realtime/ticket", {});
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const timeout = setTimeout(() => { socket?.close(); reject(new Error("voice_realtime_timeout")); }, 20_000);
|
|
55
|
+
socket = openSocket(grant.ticket);
|
|
56
|
+
const connectedSocket = socket;
|
|
57
|
+
socket.addEventListener("message", (message) => {
|
|
58
|
+
if (socket !== connectedSocket) return;
|
|
59
|
+
let event;
|
|
60
|
+
try { event = JSON.parse(message.data); } catch { return; }
|
|
61
|
+
if (!event || typeof event !== "object") return;
|
|
62
|
+
if (event.type === "input_audio_buffer.speech_started") controller.noteActivity();
|
|
63
|
+
if (event.type === "lares.ready") { clearTimeout(timeout); resolve(); }
|
|
64
|
+
else if (event.type === "lares.error") { clearTimeout(timeout); reject(new Error(event.code)); void controller.disconnect(event.code); }
|
|
65
|
+
else if (["response.output_audio.delta", "response.audio.delta"].includes(event.type)) {
|
|
66
|
+
currentItem = event.item_id || currentItem;
|
|
67
|
+
receivedMs += atob(event.delta).length / 48;
|
|
68
|
+
try { audio.write(event.delta); } catch (error) { void controller.disconnect(error.message); }
|
|
69
|
+
} else if (event.type === "response.created") { responseActive = true; receivedMs = 0; currentItem = null; }
|
|
70
|
+
else if (event.type === "response.done") responseActive = false;
|
|
71
|
+
else if (event.type === "lares.playback") controller.observePlayback(event.playing === true);
|
|
72
|
+
else if (event.type === "lares.task") {
|
|
73
|
+
if (event.sessionId) { boundSessionId = event.sessionId; controller.bindSession(boundSessionId); }
|
|
74
|
+
controller.observeAgent(event.status === "pending");
|
|
75
|
+
} else if (["response.output_audio_transcript.delta", "response.audio_transcript.delta", "response.output_text.delta", "response.text.delta"].includes(event.type)) onCaption(event.delta, "assistant");
|
|
76
|
+
});
|
|
77
|
+
socket.addEventListener("error", () => { if (socket !== connectedSocket) return; clearTimeout(timeout); reject(new Error("voice_disconnected")); });
|
|
78
|
+
socket.addEventListener("close", () => {
|
|
79
|
+
if (socket !== connectedSocket) return;
|
|
80
|
+
clearTimeout(timeout); reject(new Error("voice_disconnected"));
|
|
81
|
+
if (!intentional) void controller.disconnect("voice_disconnected");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const watch = async () => {
|
|
86
|
+
if (intentional || controller.getSnapshot().connection !== "open") return;
|
|
87
|
+
try {
|
|
88
|
+
const state = await call("/session");
|
|
89
|
+
if (state.sessionId) { boundSessionId = state.sessionId; controller.bindSession(boundSessionId); }
|
|
90
|
+
controller.observeAgent(Boolean(state.running));
|
|
91
|
+
} catch { await controller.disconnect("voice_disconnected"); return; }
|
|
92
|
+
pollTimer = setTimeout(watch, 1000);
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
controller, headers,
|
|
96
|
+
async start({ sessionId = null } = {}) {
|
|
97
|
+
boundSessionId = sessionId;
|
|
98
|
+
const initial = await call("/session", { sessionId, readAloud, presentationSession });
|
|
99
|
+
await connect();
|
|
100
|
+
await controller.start({ sessionId, continuous: true, running: initial.running });
|
|
101
|
+
controller.setOutputMuted(!readAloud);
|
|
102
|
+
void watch();
|
|
103
|
+
},
|
|
104
|
+
async setReadAloud(value) {
|
|
105
|
+
readAloud = value === true; controller.setOutputMuted(!readAloud);
|
|
106
|
+
if (!readAloud) await stopPlayback();
|
|
107
|
+
send({ type: "lares.output", enabled: readAloud });
|
|
108
|
+
},
|
|
109
|
+
async stop() {
|
|
110
|
+
intentional = true; clearTimeout(pollTimer);
|
|
111
|
+
await controller.disconnect(); await audio?.stop(); socket?.close();
|
|
112
|
+
await call("/session/close", {});
|
|
113
|
+
},
|
|
114
|
+
async pause() {
|
|
115
|
+
intentional = true; clearTimeout(pollTimer);
|
|
116
|
+
await controller.pause(); await audio?.stop(); socket?.close();
|
|
117
|
+
},
|
|
118
|
+
async resume() {
|
|
119
|
+
const state = await call("/session");
|
|
120
|
+
if (!state.active) await call("/session", { sessionId: boundSessionId, readAloud });
|
|
121
|
+
else await call("/session/resume", {});
|
|
122
|
+
await audio?.stop();
|
|
123
|
+
await connect(); await controller.resume(); void watch();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
3
|
+
import { routerAuthHeaders, routerGatewayUrl } from "../router/gateway.js";
|
|
4
|
+
import { createRealtimeDelegate, LARES_TOOL, REALTIME_INSTRUCTIONS, validatedRealtimeModels } from "./realtime.js";
|
|
5
|
+
|
|
6
|
+
/** HTTP-issued, single-use tickets keep upstream credentials and tools on the Host. */
|
|
7
|
+
export function createRealtimeGateway({ models, config, audio, record = () => {}, env = process.env }) {
|
|
8
|
+
const tickets = new Map();
|
|
9
|
+
const connections = new Set();
|
|
10
|
+
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
|
|
11
|
+
return {
|
|
12
|
+
async ticket(scope) {
|
|
13
|
+
if (!scope.active || !scope.session) throw Object.assign(new Error("voice session closed"), { status: 409, code: "voice_session_closed" });
|
|
14
|
+
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" });
|
|
15
|
+
const selected = config().realtimeModel;
|
|
16
|
+
if (!validatedRealtimeModels(await models(), env).includes(selected)) throw Object.assign(new Error("Realtime model has not been validated"), { status: 409, code: "voice_realtime_unavailable" });
|
|
17
|
+
for (const [key, value] of tickets) if (value.expires < Date.now() || value.scope === scope) tickets.delete(key);
|
|
18
|
+
const ticket = randomUUID();
|
|
19
|
+
tickets.set(ticket, { scope, model: selected, expires: Date.now() + 30_000 });
|
|
20
|
+
return { ticket };
|
|
21
|
+
},
|
|
22
|
+
upgrade(req, socket, head) {
|
|
23
|
+
const token = String(req.headers["sec-websocket-protocol"] || "").split(",").map((s) => s.trim()).find((s) => s.startsWith("lares-ticket."))?.slice(13);
|
|
24
|
+
const grant = tickets.get(token);
|
|
25
|
+
tickets.delete(token);
|
|
26
|
+
if (!grant || grant.expires < Date.now() || !grant.scope.active || grant.scope.realtimeSocket) {
|
|
27
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
|
|
28
|
+
}
|
|
29
|
+
server.handleUpgrade(req, socket, head, (client) => {
|
|
30
|
+
const { scope } = grant;
|
|
31
|
+
scope.realtimeSocket = client;
|
|
32
|
+
connections.add(client);
|
|
33
|
+
const base = routerGatewayUrl(env).replace(/^http/, "ws");
|
|
34
|
+
const upstream = new WebSocket(`${base}/realtime?model=${encodeURIComponent(grant.model)}`, {
|
|
35
|
+
headers: routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, scope.owner), handshakeTimeout: 15_000, maxPayload: 2 * 1024 * 1024,
|
|
36
|
+
});
|
|
37
|
+
let closed = false;
|
|
38
|
+
const lifetime = new AbortController();
|
|
39
|
+
let ready = false;
|
|
40
|
+
let audioPort = null;
|
|
41
|
+
let output = Promise.resolve();
|
|
42
|
+
let queued = 0;
|
|
43
|
+
let alive = true;
|
|
44
|
+
let currentItem = null;
|
|
45
|
+
let receivedMs = 0;
|
|
46
|
+
let audioGeneration = 0;
|
|
47
|
+
let transcript = "";
|
|
48
|
+
let responseId = null;
|
|
49
|
+
let recordSerial = Promise.resolve();
|
|
50
|
+
const persist = (...args) => { recordSerial = recordSerial.then(() => closed ? undefined : record(...args)); recordSerial.catch(() => close("voice_records_failed")); };
|
|
51
|
+
let responding = false;
|
|
52
|
+
let userSpeaking = false;
|
|
53
|
+
const completed = [];
|
|
54
|
+
const sendClient = (value) => {
|
|
55
|
+
if (client.readyState !== WebSocket.OPEN) return;
|
|
56
|
+
if (client.bufferedAmount > 1024 * 1024) { close("voice_audio_backpressure"); return; }
|
|
57
|
+
client.send(JSON.stringify(value));
|
|
58
|
+
};
|
|
59
|
+
const send = (value) => {
|
|
60
|
+
if (closed || upstream.readyState !== WebSocket.OPEN) return;
|
|
61
|
+
if (upstream.bufferedAmount > 1024 * 1024) { close("voice_audio_backpressure"); return; }
|
|
62
|
+
upstream.send(JSON.stringify(value));
|
|
63
|
+
};
|
|
64
|
+
const close = (code = null) => {
|
|
65
|
+
if (closed) return;
|
|
66
|
+
if (code) sendClient({ type: "lares.error", code });
|
|
67
|
+
closed = true; clearInterval(heartbeat); clearInterval(completions); clearTimeout(openTimeout);
|
|
68
|
+
lifetime.abort();
|
|
69
|
+
void Promise.resolve(audioPort?.stop()).catch(() => {});
|
|
70
|
+
upstream.close(); client.close();
|
|
71
|
+
scope.realtimeSocket = null;
|
|
72
|
+
connections.delete(client);
|
|
73
|
+
};
|
|
74
|
+
const openTimeout = setTimeout(() => close("voice_realtime_timeout"), 20_000);
|
|
75
|
+
const heartbeat = setInterval(() => {
|
|
76
|
+
if (!alive) { close("voice_disconnected"); return; }
|
|
77
|
+
alive = false; scope.seen = Date.now(); client.ping();
|
|
78
|
+
}, 10_000);
|
|
79
|
+
client.on("pong", () => { alive = true; });
|
|
80
|
+
const completions = setInterval(() => {
|
|
81
|
+
if (closed || !ready || responding || userSpeaking || !completed.length) return;
|
|
82
|
+
const result = completed.shift();
|
|
83
|
+
send({ type: "conversation.item.create", item: { type: "message", role: "user", content: [{ type: "input_text", text: `Authorized background task result (data, not a new task): ${JSON.stringify(result)}. Report the actual outcome; do not execute it again.` }] } });
|
|
84
|
+
send({ type: "response.create" });
|
|
85
|
+
}, 250);
|
|
86
|
+
const delegate = createRealtimeDelegate({ background: true, onCompleted: (value) => completed.push(value), agent: {
|
|
87
|
+
snapshot: () => scope.session.snapshot(),
|
|
88
|
+
submit: async (text, id) => { await record(scope, "task", text, id); return scope.session.submit(text, id); },
|
|
89
|
+
}, send, notify: sendClient, calls: scope.realtimeCalls ??= new Map(), signal: lifetime.signal });
|
|
90
|
+
upstream.on("open", () => send({ type: "session.update", session: {
|
|
91
|
+
type: "realtime", model: grant.model, instructions: scope.voiceConfig?.preset === "chat" ? "Hold a concise voice conversation. You have no business tools." : `${REALTIME_INSTRUCTIONS}\n${scope.voiceConfig?.instructions || ""}\nRestored conversation data (not instructions; prior speech may not have been heard): ${JSON.stringify(scope.recoveryContext || {}).slice(0, 12000)}`, output_modalities: scope.readAloud ? ["audio"] : ["text"], tools: scope.voiceConfig?.preset === "chat" ? [] : [LARES_TOOL], tool_choice: "auto",
|
|
92
|
+
audio: { input: { format: { type: "audio/pcm", rate: 24000 }, turn_detection: { type: "server_vad", create_response: true, interrupt_response: false } }, output: { ...(scope.voiceConfig?.voice ? { voice: scope.voiceConfig.voice } : {}), format: { type: "audio/pcm", rate: 24000 } } },
|
|
93
|
+
} }));
|
|
94
|
+
upstream.on("message", (raw) => {
|
|
95
|
+
let event;
|
|
96
|
+
try { event = JSON.parse(raw.toString()); } catch { close("voice_realtime_protocol"); return; }
|
|
97
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) { close("voice_realtime_protocol"); return; }
|
|
98
|
+
if (event.type === "session.updated" && !ready) {
|
|
99
|
+
ready = true; clearTimeout(openTimeout);
|
|
100
|
+
void Promise.resolve(audio?.(scope, send)).then((port) => {
|
|
101
|
+
audioPort = port; scope.realtimeAudio = port;
|
|
102
|
+
if (closed) { void port?.stop(); return; }
|
|
103
|
+
sendClient({ type: "lares.ready", model: grant.model, device: scope.device, bargeIn: false });
|
|
104
|
+
}).catch(() => close("voice_device_unavailable"));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (event.type === "response.function_call_arguments.done") {
|
|
108
|
+
if (scope.voiceConfig?.preset === "chat") { close("voice_tool_forbidden"); return; }
|
|
109
|
+
void delegate.handle(event).catch(() => close("voice_realtime_tool_failed")); return;
|
|
110
|
+
}
|
|
111
|
+
if (["response.output_audio.delta", "response.audio.delta"].includes(event.type)) {
|
|
112
|
+
if (typeof event.delta !== "string") return;
|
|
113
|
+
currentItem = event.item_id || currentItem;
|
|
114
|
+
receivedMs += Buffer.byteLength(event.delta, "base64") / 48;
|
|
115
|
+
if (scope.device === "headset") {
|
|
116
|
+
sendClient({ type: "lares.playback", playing: true });
|
|
117
|
+
const bytes = Buffer.from(event.delta, "base64"); queued += bytes.length;
|
|
118
|
+
if (queued > 512 * 1024) { close("voice_audio_backpressure"); return; }
|
|
119
|
+
const epoch = audioGeneration;
|
|
120
|
+
output = output.then(() => epoch === audioGeneration ? audioPort?.write(bytes) : undefined).finally(() => { queued -= bytes.length; });
|
|
121
|
+
output.catch(() => close("voice_device_unavailable"));
|
|
122
|
+
} else sendClient(event);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (["response.output_audio.done", "response.audio.done"].includes(event.type)) {
|
|
126
|
+
output = output.then(() => audioPort?.finish());
|
|
127
|
+
output.then(() => { sendClient({ type: "lares.playback", playing: false }); sendClient(event); }).catch(() => close("voice_device_unavailable")); return;
|
|
128
|
+
}
|
|
129
|
+
if (event.type === "input_audio_buffer.speech_started") userSpeaking = true;
|
|
130
|
+
if (event.type === "input_audio_buffer.speech_stopped") userSpeaking = false;
|
|
131
|
+
if (event.type === "response.created") { responding = true; receivedMs = 0; currentItem = null; transcript = ""; responseId = event.response?.id; }
|
|
132
|
+
if (["response.output_audio_transcript.delta", "response.audio_transcript.delta", "response.output_text.delta", "response.text.delta"].includes(event.type)) {
|
|
133
|
+
transcript += typeof event.delta === "string" ? event.delta : "";
|
|
134
|
+
if (responseId && transcript) persist(scope, "assistant", transcript, responseId, { status: "streaming" });
|
|
135
|
+
}
|
|
136
|
+
if (event.type === "conversation.item.input_audio_transcription.completed" && typeof event.transcript === "string") {
|
|
137
|
+
persist(scope, "user", event.transcript, event.item_id);
|
|
138
|
+
}
|
|
139
|
+
if (event.type === "response.done") responding = false;
|
|
140
|
+
if (event.type === "response.done" && transcript) {
|
|
141
|
+
persist(scope, "assistant", transcript, event.response?.id || responseId);
|
|
142
|
+
}
|
|
143
|
+
if (event.type === "error" && event.error?.code === "response_cancel_not_active") return;
|
|
144
|
+
if (event.type === "error") { close("voice_realtime_upstream"); return; }
|
|
145
|
+
if (["response.created", "response.done", "response.output_audio_transcript.delta", "response.audio_transcript.delta", "response.output_text.delta", "response.text.delta", "conversation.item.input_audio_transcription.completed", "input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"].includes(event.type)) sendClient(event);
|
|
146
|
+
});
|
|
147
|
+
client.on("message", (raw) => {
|
|
148
|
+
if (!ready) return;
|
|
149
|
+
scope.seen = Date.now();
|
|
150
|
+
let event;
|
|
151
|
+
try { event = JSON.parse(raw.toString()); } catch { close("voice_realtime_protocol"); return; }
|
|
152
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) { close("voice_realtime_protocol"); return; }
|
|
153
|
+
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"));
|
|
157
|
+
else if (event.type === "response.cancel") { audioGeneration++; send({ type: "response.cancel" }); void Promise.resolve(audioPort?.interrupt()).catch(() => close("voice_device_unavailable")); }
|
|
158
|
+
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
|
+
else close("voice_realtime_event_forbidden");
|
|
160
|
+
});
|
|
161
|
+
upstream.on("error", () => close("voice_realtime_unavailable"));
|
|
162
|
+
upstream.on("close", () => close("voice_disconnected"));
|
|
163
|
+
client.on("error", () => close()); client.on("close", () => close());
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
close() { for (const socket of connections) socket.close(); server.close(); tickets.clear(); },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mintRpcId } from "../larepass/rpc.js";
|
|
2
|
+
|
|
3
|
+
export const REALTIME_INSTRUCTIONS = "You are Lares's voice interface. Speak naturally and briefly in the user's language. Delegate every business task, lookup or system action to ask_lares. Never claim a task succeeded without its result. Do not expose reasoning or raw tool payloads. If a task is pending or requires approval, tell the user to check the Lares conversation; never approve on their behalf. Use operation=status to check an existing task, never submit it again to check progress.";
|
|
4
|
+
export const LARES_TOOL = {
|
|
5
|
+
type: "function", name: "ask_lares", description: "Run a user task in the bound Lares session or check its current result. The Host owns all session and workspace selection.",
|
|
6
|
+
parameters: { type: "object", properties: { operation: { type: "string", enum: ["run", "status"] }, task: { type: "string", maxLength: 16000 } }, required: ["operation"], additionalProperties: false },
|
|
7
|
+
};
|
|
8
|
+
export function realtimeModelIds(models) { return models.filter((model) => model.mode === "realtime").map((model) => model.id); }
|
|
9
|
+
export function validatedRealtimeModels(models, env = process.env) {
|
|
10
|
+
if (env.LARES_VOICE_REALTIME_ENABLED !== "1") return [];
|
|
11
|
+
const validated = new Set(String(env.LARES_VOICE_REALTIME_VALIDATED_MODELS || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
12
|
+
return realtimeModelIds(models).filter((id) => validated.has(id));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One tool execution per provider call id, including unknown submission outcomes. */
|
|
16
|
+
export function createRealtimeDelegate({ agent, send, notify = () => {}, calls = new Map(), timeoutMs = 120_000, signal, background = false, onCompleted = () => {}, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
17
|
+
let active = false;
|
|
18
|
+
const result = (requestId) => {
|
|
19
|
+
const state = agent.snapshot();
|
|
20
|
+
const task = requestId && state.tasks?.find((value) => value.requestId === requestId);
|
|
21
|
+
if (task) return { ...task, status: ["accepted", "running", "waiting"].includes(task.status) ? "pending" : task.status, sessionId: state.sessionId };
|
|
22
|
+
return { status: state.error ? "failed" : state.running ? "pending" : "completed", sessionId: state.sessionId, text: state.utterance?.silent ? "" : (state.utterance?.text || "").slice(0, 16000), error: state.error || undefined };
|
|
23
|
+
};
|
|
24
|
+
const run = async (event) => {
|
|
25
|
+
let args;
|
|
26
|
+
try { args = JSON.parse(event.arguments); } catch { return { status: "failed", error: "invalid_arguments" }; }
|
|
27
|
+
if (event.name !== "ask_lares" || !args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some((key) => !["operation", "task"].includes(key))) return { status: "failed", error: "invalid_tool" };
|
|
28
|
+
if (args.operation === "status") return result();
|
|
29
|
+
if (args.operation !== "run" || typeof args.task !== "string" || !args.task.trim() || args.task.length > 16000) return { status: "failed", error: "invalid_task" };
|
|
30
|
+
if (!background && (active || agent.snapshot().running)) return { ...result(), status: "pending" };
|
|
31
|
+
active = true;
|
|
32
|
+
try {
|
|
33
|
+
const requestId = `voice-${mintRpcId()}`;
|
|
34
|
+
await agent.submit(args.task, requestId);
|
|
35
|
+
notify({ type: "lares.task", ...result(requestId) });
|
|
36
|
+
if (background && result(requestId).status === "pending") {
|
|
37
|
+
void (async () => {
|
|
38
|
+
while (!signal?.aborted && result(requestId).status === "pending") await sleep(250);
|
|
39
|
+
if (!signal?.aborted) { const value = result(requestId); notify({ type: "lares.task", ...value }); onCompleted(value); }
|
|
40
|
+
})().catch(() => notify({ type: "lares.task", status: "unknown", requestId, sessionId: agent.snapshot().sessionId }));
|
|
41
|
+
return result(requestId);
|
|
42
|
+
}
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
while (agent.snapshot().running && !signal?.aborted && Date.now() < deadline) await sleep(100);
|
|
45
|
+
const value = result(requestId);
|
|
46
|
+
notify({ type: "lares.task", ...value });
|
|
47
|
+
return value;
|
|
48
|
+
} catch {
|
|
49
|
+
return { status: "unknown", sessionId: agent.snapshot().sessionId, error: "submission_outcome_unknown", instruction: "Check status; do not submit again." };
|
|
50
|
+
} finally { active = false; }
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
async handle(event) {
|
|
54
|
+
if (!event.call_id || calls.has(event.call_id)) return;
|
|
55
|
+
if (calls.size >= 256) throw new Error("voice_session_limit");
|
|
56
|
+
const promise = run(event);
|
|
57
|
+
calls.set(event.call_id, promise);
|
|
58
|
+
const value = await promise;
|
|
59
|
+
send({ type: "conversation.item.create", item: { type: "function_call_output", call_id: event.call_id, output: JSON.stringify(value) } });
|
|
60
|
+
send({ type: "response.create" });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/voice/records.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeJsonFile } from "../tools/json-file.js";
|
|
5
|
+
import { dshHome } from "../workspace/home.js";
|
|
6
|
+
|
|
7
|
+
const failure = (code, status = 400) => Object.assign(new Error(code), { code, status });
|
|
8
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
9
|
+
|
|
10
|
+
/** Synchronous atomic transactions serialize append/version updates within the Host process. */
|
|
11
|
+
export function createVoiceRecords({ directory = join(dshHome(), "voice-records"), now = Date.now } = {}) {
|
|
12
|
+
const path = (owner, conversation) => {
|
|
13
|
+
if (typeof owner !== "string" || !owner || typeof conversation !== "string" || !conversation || conversation.length > 256) throw failure("voice_records_invalid");
|
|
14
|
+
return join(directory, digest(owner), `${digest(conversation)}.json`);
|
|
15
|
+
};
|
|
16
|
+
const read = (owner, conversation) => {
|
|
17
|
+
const file = path(owner, conversation);
|
|
18
|
+
if (!existsSync(file)) return { version: 0, records: [], summary: null };
|
|
19
|
+
// Corrupt history is explicit; never overwrite it with an empty conversation.
|
|
20
|
+
let value;
|
|
21
|
+
try { value = JSON.parse(readFileSync(file, "utf8")); } catch { throw failure("voice_records_corrupt", 500); }
|
|
22
|
+
if (!Number.isInteger(value.version) || !Array.isArray(value.records)) throw failure("voice_records_corrupt", 500);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const listeners = new Map();
|
|
26
|
+
const publish = (owner, conversation) => { for (const notify of listeners.get(path(owner, conversation)) || []) notify(); };
|
|
27
|
+
return {
|
|
28
|
+
read,
|
|
29
|
+
subscribe(owner, conversation, notify) {
|
|
30
|
+
const key = path(owner, conversation);
|
|
31
|
+
if (!listeners.has(key)) listeners.set(key, new Set());
|
|
32
|
+
listeners.get(key).add(notify);
|
|
33
|
+
return () => { const set = listeners.get(key); set?.delete(notify); if (!set?.size) listeners.delete(key); };
|
|
34
|
+
},
|
|
35
|
+
page(owner, conversation, { after = 0, limit = 100 } = {}) {
|
|
36
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isInteger(limit) || limit < 1 || limit > 200) throw failure("voice_records_invalid");
|
|
37
|
+
const value = read(owner, conversation);
|
|
38
|
+
const rows = value.records.map((row, index) => ({ ...row, sequence: row.sequence || index + 1 })).filter((row) => row.sequence > after);
|
|
39
|
+
const page = rows.slice(0, limit);
|
|
40
|
+
return { version: value.version, records: page, next: rows.length > limit ? page.at(-1).sequence : null };
|
|
41
|
+
},
|
|
42
|
+
bind(owner, contextId, sessionId) {
|
|
43
|
+
const key = `voice:${contextId}`;
|
|
44
|
+
const value = read(owner, key);
|
|
45
|
+
value.binding = sessionId;
|
|
46
|
+
writeJsonFile(path(owner, key), value);
|
|
47
|
+
},
|
|
48
|
+
append(owner, conversation, record) {
|
|
49
|
+
if (!record || !["user", "assistant", "task"].includes(record.role) || typeof record.text !== "string" || record.text.length > 16000 || typeof record.voiceId !== "string" || typeof record.turnId !== "string") throw failure("voice_records_invalid");
|
|
50
|
+
const value = read(owner, conversation);
|
|
51
|
+
const index = value.records.findIndex((item) => item.id === record.id);
|
|
52
|
+
if (typeof record.id !== "string" || !record.id || (index < 0 && value.records.length >= 10000)) throw failure("voice_records_limit", 409);
|
|
53
|
+
// Copy only public record fields; audio, credentials and arbitrary tool payloads cannot enter storage.
|
|
54
|
+
const previous = value.records[index];
|
|
55
|
+
const next = { id: record.id, voiceId: record.voiceId, clientId: String(record.clientId || "").slice(0, 128), turnId: record.turnId, role: record.role, text: record.text,
|
|
56
|
+
taskId: record.taskId || null, requestId: record.requestId || record.taskId || record.turnId,
|
|
57
|
+
taskTurn: Number.isInteger(record.taskTurn) ? record.taskTurn : null,
|
|
58
|
+
anchorSeq: previous?.anchorSeq ?? (Number.isInteger(record.anchorSeq) ? record.anchorSeq : -1),
|
|
59
|
+
status: ["streaming", "failed"].includes(record.status) ? record.status : "completed",
|
|
60
|
+
sequence: previous?.sequence || (index >= 0 ? index + 1 : value.records.length + 1), at: previous?.at || now() };
|
|
61
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(next)) return value;
|
|
62
|
+
if (index < 0) value.records.push(next); else value.records[index] = next;
|
|
63
|
+
value.version++;
|
|
64
|
+
value.summary = { text: value.summary?.text, textVersion: value.summary?.textVersion, version: value.version, source: record.voiceId,
|
|
65
|
+
recent: value.records.slice(-12).map(({ role, text, taskId }) => ({ role, text, taskId })),
|
|
66
|
+
tasks: [...new Set(value.records.map((item) => item.taskId).filter(Boolean))].slice(-64) };
|
|
67
|
+
writeJsonFile(path(owner, conversation), value);
|
|
68
|
+
publish(owner, conversation);
|
|
69
|
+
return value;
|
|
70
|
+
},
|
|
71
|
+
checkpoint(owner, conversation, summary, expectedVersion) {
|
|
72
|
+
const value = read(owner, conversation);
|
|
73
|
+
if (value.version !== expectedVersion) throw failure("voice_summary_conflict", 409);
|
|
74
|
+
if (typeof summary !== "string" || summary.length > 16000) throw failure("voice_records_invalid");
|
|
75
|
+
value.summary = { ...value.summary, text: summary, textVersion: value.version, version: value.version };
|
|
76
|
+
writeJsonFile(path(owner, conversation), value);
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
clearConversation(conversation) {
|
|
80
|
+
if (!existsSync(directory)) return;
|
|
81
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
82
|
+
if (entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name)) {
|
|
83
|
+
const folder = join(directory, entry.name);
|
|
84
|
+
rmSync(join(folder, `${digest(conversation)}.json`), { force: true });
|
|
85
|
+
for (const file of readdirSync(folder)) {
|
|
86
|
+
if (!/^[a-f0-9]{64}\.json$/.test(file)) continue;
|
|
87
|
+
const candidate = join(folder, file);
|
|
88
|
+
let value; try { value = JSON.parse(readFileSync(candidate, "utf8")); } catch { continue; }
|
|
89
|
+
if (value.binding === conversation) rmSync(candidate, { force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
clear(owner, conversation) { rmSync(path(owner, conversation), { force: true }); publish(owner, conversation); },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { transcribe } from "../router/stt.js";
|
|
2
|
+
import { pcmToWav, synthesize } from "../router/tts.js";
|
|
3
|
+
import { normalizeSttPcm } from "./pcm.js";
|
|
4
|
+
import { createPcmResampler, pcmSamples } from "./realtime-audio.js";
|
|
5
|
+
|
|
6
|
+
export function wavData(wav) {
|
|
7
|
+
for (let offset = 12; offset + 8 <= wav.length;) {
|
|
8
|
+
const length = wav.readUInt32LE(offset + 4);
|
|
9
|
+
if (offset + 8 + length > wav.length) throw new Error("voice_audio_format");
|
|
10
|
+
if (wav.toString("ascii", offset, offset + 4) === "data") return wav.subarray(offset + 8, offset + 8 + length);
|
|
11
|
+
offset += 8 + length + length % 2;
|
|
12
|
+
}
|
|
13
|
+
throw new Error("voice_audio_format");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createRouterAudio(config) {
|
|
17
|
+
return {
|
|
18
|
+
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 }),
|
|
19
|
+
async synthesize(text, scope) {
|
|
20
|
+
const settings = config();
|
|
21
|
+
const wav = await synthesize({ text, model: settings.ttsModel || "default-tts", voice: settings.voice || undefined, signal: scope.outputAbort?.signal });
|
|
22
|
+
const normalized = await normalizeSttPcm(wav, "audio/wav");
|
|
23
|
+
return Buffer.from(createPcmResampler(16000)(pcmSamples(wavData(normalized))));
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
package/voice/script.js
CHANGED
|
@@ -14,16 +14,36 @@ function closedPart(text) {
|
|
|
14
14
|
return fences % 2 === 0 ? text : text.slice(0, text.lastIndexOf("```"));
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/** Table rules and horizontal rules carry nothing to read. */
|
|
18
|
+
const RULE_LINE = /^\s*(?:\|?\s*:?-{3,}:?\s*)+\|?\s*$|^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
|
19
|
+
const BLOCK_MARK = /^\s*(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]]\s+)?|\d+[.)]\s+)?/;
|
|
20
|
+
|
|
21
|
+
function plainLine(line) {
|
|
22
|
+
if (RULE_LINE.test(line)) return "";
|
|
23
|
+
return line
|
|
24
|
+
.replace(BLOCK_MARK, "")
|
|
20
25
|
.replace(/`([^`]+)`/g, "$1")
|
|
21
26
|
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
|
|
22
27
|
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
|
|
23
|
-
.replace(/[
|
|
28
|
+
.replace(/[|#>*_]+/g, " ")
|
|
24
29
|
.replace(/\s+/g, " ")
|
|
25
|
-
.trim()
|
|
26
|
-
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Model answers are Markdown, and a heading, list item or table row often ends
|
|
35
|
+
* without punctuation, so every line is a sentence boundary of its own.
|
|
36
|
+
*/
|
|
37
|
+
function plainLines(text) {
|
|
38
|
+
return String(text ?? "")
|
|
39
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
40
|
+
.slice(0, 2000)
|
|
41
|
+
.split("\n")
|
|
42
|
+
.map(plainLine);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sentencesOf(line) {
|
|
46
|
+
return line ? segment("en", line).map((part) => part.trim()).filter(Boolean) : [];
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
/**
|
|
@@ -68,15 +88,16 @@ export function gapAfter(segment) {
|
|
|
68
88
|
|
|
69
89
|
/**
|
|
70
90
|
* The segments to read: sentences, long ones cut into clauses. While the
|
|
71
|
-
* answer is still streaming (`done: false`) its
|
|
72
|
-
* mid-word, so only the
|
|
91
|
+
* answer is still streaming (`done: false`) its open line may be cut
|
|
92
|
+
* mid-word, so only the sentences before its last one count.
|
|
73
93
|
* @returns {string[]}
|
|
74
94
|
*/
|
|
75
95
|
export function speechSegments(text, { done = true } = {}) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const sentences =
|
|
79
|
-
|
|
96
|
+
const lines = plainLines(done ? text : closedPart(String(text ?? "")));
|
|
97
|
+
const open = done ? "" : lines.pop();
|
|
98
|
+
const sentences = lines.flatMap(sentencesOf);
|
|
99
|
+
sentences.push(...sentencesOf(open).slice(0, -1));
|
|
100
|
+
return sentences.flatMap(clauses);
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
export function speechScript(text) {
|