@olares/lares-core 0.15.24 → 0.15.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/i18n/voice.js +50 -10
- package/larepass/queue.js +79 -6
- package/larepass/runtime.js +4 -3
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +5 -1
- package/package.json +7 -3
- package/router/session-alias.js +17 -0
- package/router/shim.js +2 -0
- package/router/stt.js +2 -2
- package/router/tts.js +15 -4
- package/voice/agent-session.js +28 -0
- package/voice/client.js +12 -1
- package/voice/config.js +2 -2
- package/voice/dialogue.js +11 -3
- package/voice/gpu-mode.js +160 -8
- package/voice/host-service.js +40 -8
- package/voice/internal-upgrade.js +20 -0
- package/voice/key-models.js +1 -0
- package/voice/keys-client.js +30 -0
- package/voice/keys.js +63 -0
- package/voice/lares-realtime-gateway.js +491 -0
- package/voice/options.js +3 -1
- package/voice/pcm-player.js +4 -1
- package/voice/pcm-worklet.js +6 -1
- package/voice/presentation.js +15 -8
- package/voice/protocol-client.js +16 -3
- package/voice/realtime-gateway.js +17 -4
- package/voice/records.js +1 -1
- package/voice/reply-stream.js +8 -4
- package/voice/router-audio.js +48 -5
- package/voice/service.js +6 -0
- package/voice/speech.js +7 -1
- package/voice/spoken-text.js +13 -3
- package/voice/stream-client.js +3 -2
- package/voice/unified-conversation.js +256 -0
- package/voice/composite-gateway.js +0 -160
- package/voice/external.js +0 -26
|
@@ -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 === "
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
package/voice/reply-stream.js
CHANGED
|
@@ -6,13 +6,17 @@ const STATUS = { voice_reply_invalid: 400, voice_reply_forbidden: 403 };
|
|
|
6
6
|
const fault = code => new VoiceError(code, STATUS[code] ?? 409, code);
|
|
7
7
|
const identity = source => JSON.stringify([source.sessionId, source.turn, source.step]);
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Trusted Agent text only; a client supplies an address, never the words.
|
|
11
|
+
* Read-while-writing releases each closed sentence as it arrives. Reading the
|
|
12
|
+
* finished answer waits until that text has settled. A tool call ends either readout.
|
|
13
|
+
*/
|
|
14
|
+
export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }), streaming = () => false } = {}) {
|
|
11
15
|
const rows = new Map();
|
|
12
16
|
const notify = row => { row.version = (row.version || 0) + 1; for (const wake of row.listeners) wake(); };
|
|
13
17
|
const fail = (row, code) => { row.segmenter.reset(); row.ready = []; row.error = code; row.done = true; row.at = now(); notify(row); };
|
|
14
18
|
const advance = row => {
|
|
15
|
-
if (!row.subscribers || row.error ||
|
|
19
|
+
if (!row.subscribers || row.error || streaming() !== true && !row.done) return;
|
|
16
20
|
try { row.ready.push(...row.segmenter.update(row.utterance?.text || "", { done: row.done })); }
|
|
17
21
|
catch (error) { fail(row, error.message === "voice_reply_rewritten" ? error.message : "voice_reply_invalid"); }
|
|
18
22
|
};
|
|
@@ -28,7 +32,7 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
|
|
|
28
32
|
}
|
|
29
33
|
if (rows.size >= 128) return;
|
|
30
34
|
rows.set(key, { sessionId, turn: frame.turn, step: frame.step, attemptId: frame.attemptId,
|
|
31
|
-
segmenter: segmenterFactory(), ready: [],
|
|
35
|
+
segmenter: segmenterFactory(), ready: [], utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
|
|
32
36
|
return;
|
|
33
37
|
}
|
|
34
38
|
for (const row of rows.values()) {
|
package/voice/router-audio.js
CHANGED
|
@@ -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
|
-
|
|
24
|
+
// Same resolution as the streaming path: a configured model that is not installed falls back to one that is.
|
|
25
|
+
transcribe: async (pcm, scope) => transcribe({ bytes: pcmToWav(pcm, 24000), filename: "voice.wav", contentType: "audio/wav", model: pickSttModelId(await listModels(), config().model) || config().model || "default-stt", language: scope.voiceOverrides?.language || config().language || undefined, signal: scope.voiceAbort?.signal, once: true, headers: routerSession(scope) }),
|
|
26
|
+
/**
|
|
27
|
+
* Streaming ASR for one turn when the selected model supports it: 24 kHz PCM in,
|
|
28
|
+
* partial transcripts out. Returns null so the caller keeps the batch path otherwise.
|
|
29
|
+
*/
|
|
30
|
+
async openTranscription(scope, onPartial, { env = process.env } = {}) {
|
|
31
|
+
const rows = await listModels();
|
|
32
|
+
const settings = config();
|
|
33
|
+
const model = pickSttModelId(rows, settings.model);
|
|
34
|
+
if (!model || !rows.find((row) => row.id === model)?.supportsSttStream) return null;
|
|
35
|
+
const socket = new WebSocket(`${routerGatewayUrl(env).replace(/^http/, "ws")}/audio/stream?model=${encodeURIComponent(model)}`, {
|
|
36
|
+
headers: { ...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, scope.owner), ...routerSession(scope) }, handshakeTimeout: 15_000, maxPayload: 96 * 1024 });
|
|
37
|
+
const convert = createPcmResampler(24000, 16000);
|
|
38
|
+
let text = "", settle;
|
|
39
|
+
const final = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
40
|
+
final.catch(() => {});
|
|
41
|
+
const opened = new Promise((resolve, reject) => {
|
|
42
|
+
socket.once("open", () => { socket.send(JSON.stringify({ type: "start", sample_rate: 16000, language: scope.voiceOverrides?.language || settings.language || undefined })); resolve(); });
|
|
43
|
+
socket.once("error", reject);
|
|
44
|
+
});
|
|
45
|
+
opened.catch(() => {});
|
|
46
|
+
socket.on("message", (raw, binary) => {
|
|
47
|
+
if (binary) return;
|
|
48
|
+
let value;
|
|
49
|
+
try { value = JSON.parse(String(raw)); } catch { return; }
|
|
50
|
+
if (value.type === "partial") { text = String(value.text || ""); onPartial(text); }
|
|
51
|
+
if (value.type === "final") { text = String(value.text || ""); settle.resolve(text); }
|
|
52
|
+
if (value.type === "error") settle.reject(new Error(value.code || "voice_stream_failed"));
|
|
53
|
+
});
|
|
54
|
+
socket.on("close", () => settle.reject(new Error("voice_stream_closed")));
|
|
55
|
+
socket.on("error", () => settle.reject(new Error("voice_stream_failed")));
|
|
56
|
+
let chain = opened;
|
|
57
|
+
return {
|
|
58
|
+
write(pcm) { chain = chain.then(() => { if (socket.readyState === 1) socket.send(Buffer.from(convert(pcmSamples(pcm)))); }); chain.catch(() => {}); },
|
|
59
|
+
async finish() { await chain; socket.send(JSON.stringify({ type: "finish" })); try { return await final; } finally { socket.close(); } },
|
|
60
|
+
cancel() { socket.terminate(); },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
20
63
|
async synthesize(text, scope, onChunk) {
|
|
21
|
-
const settings = structuredClone(config());
|
|
22
|
-
const model = settings.ttsModel || "default-tts";
|
|
64
|
+
const settings = { ...structuredClone(config()), ...scope.voiceOverrides?.voice && { voice: scope.voiceOverrides.voice } };
|
|
65
|
+
const model = pickTtsModelId(await listModels(), settings.ttsModel) || settings.ttsModel || "default-tts";
|
|
23
66
|
const chunks = [];
|
|
24
67
|
let convert = null;
|
|
25
68
|
const write = async (pcm) => { if (onChunk) await onChunk(pcm); else chunks.push(pcm); };
|
|
26
69
|
let pending = Buffer.alloc(0);
|
|
27
|
-
const result = await synthesizeStream({ text, model, voice: settings.voice || ttsVoiceForModel(settings, model), signal: scope.outputAbort?.signal,
|
|
70
|
+
const result = await synthesizeStream({ text, model, voice: settings.voice || ttsVoiceForModel(settings, model), signal: scope.outputAbort?.signal, headers: routerSession(scope),
|
|
28
71
|
onFormat: (format) => { convert = createPcmResampler(format.sampleRate, 24000); },
|
|
29
72
|
}, async (chunk) => {
|
|
30
73
|
const joined = Buffer.concat([pending, chunk]);
|
package/voice/service.js
CHANGED
|
@@ -158,6 +158,12 @@ export async function saveVoiceConfig(body) {
|
|
|
158
158
|
throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
if (patch.streaming === true) {
|
|
162
|
+
const { speechGpu } = await import("./gpu-mode.js");
|
|
163
|
+
const gpu = await speechGpu();
|
|
164
|
+
if (!gpu) throw new VoiceError("voice_gpu_no_gpu", 409, "no NVIDIA GPU");
|
|
165
|
+
if (gpu.type !== "MemorySlice") throw new VoiceError("voice_streaming_time_slice", 409, gpu.type || "TimeSlice");
|
|
166
|
+
}
|
|
161
167
|
if (patch.ttsVoiceByModel) {
|
|
162
168
|
const current = readConfig().ttsVoiceByModel;
|
|
163
169
|
const changed = Object.entries(patch.ttsVoiceByModel).filter(([model, voice]) => voice && current[model] !== voice);
|
package/voice/speech.js
CHANGED
|
@@ -197,6 +197,9 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
197
197
|
const emitOutput = value => {
|
|
198
198
|
if (value || readoutState.getSnapshot()?.key === lastKey || onOutput !== readoutState.publish) onOutput(value);
|
|
199
199
|
};
|
|
200
|
+
// A dropped socket is the same quiet ending as a cancel: the next utterance
|
|
201
|
+
// opens a new stream. It is not a broken voice setting.
|
|
202
|
+
const quietPlayback = new Set(["voice_cancelled", "voice_output_owned", "voice_disconnected"]);
|
|
200
203
|
const failed = (run, err) => {
|
|
201
204
|
if (run.gen !== gen) return;
|
|
202
205
|
run.abort?.abort();
|
|
@@ -204,7 +207,10 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
204
207
|
run.wake?.();
|
|
205
208
|
current = null;
|
|
206
209
|
onState(false); emitOutput(null);
|
|
207
|
-
|
|
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) => {
|
package/voice/spoken-text.js
CHANGED
|
@@ -100,6 +100,14 @@ function withoutEmoji(text) {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// A longer unbroken identifier is not speech. Synthesizing it stalls the one
|
|
104
|
+
// shared speech path, so this answer and the next one stay silent. Dots stay
|
|
105
|
+
// outside the run so "First one." is still a sentence; a following short
|
|
106
|
+
// extension (`name.jpg`) leaves with the run.
|
|
107
|
+
function withoutUnsayable(text) {
|
|
108
|
+
return text.replace(/[A-Za-z0-9][A-Za-z0-9_~:/?#@!$&*+=%-]{31,}(?:\.[A-Za-z0-9]{1,8})?/g, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
/** The assistant's spoken copy; never apply this to stored messages or raw TTS API input. */
|
|
104
112
|
function prepareSource(text, done, structural = false) {
|
|
105
113
|
let pending = false;
|
|
@@ -143,9 +151,10 @@ export function spokenLines(text, { done = true } = {}) {
|
|
|
143
151
|
plain = plain.slice(0, lineStart) + end.slice(0, tail.index);
|
|
144
152
|
pending = true;
|
|
145
153
|
}
|
|
146
|
-
if (pending || inline.pending) plain += "\uFFFC";
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
plain = withoutUnsayable(withoutEmoji(plain));
|
|
156
|
+
if (!done && (pending || inline.pending)) plain += "\uFFFC";
|
|
157
|
+
return plain.split("\n").map((line) => line.replace(/\s+/g, " ").trim());
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
|
|
@@ -183,8 +192,9 @@ export function spokenBlocks(text, { done = true } = {}) {
|
|
|
183
192
|
const tail = [...graphemes.segment(plain.trimEnd())].at(-1);
|
|
184
193
|
if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) { plain = plain.slice(0, tail.index); opaque = true; }
|
|
185
194
|
}
|
|
195
|
+
plain = withoutUnsayable(withoutEmoji(plain));
|
|
186
196
|
if (!done && opaque) plain += "\uFFFC";
|
|
187
|
-
plain =
|
|
197
|
+
plain = plain.replace(/\s+/g, " ").trim();
|
|
188
198
|
const continuation = lists.length && paragraphs.has(items.at(-1));
|
|
189
199
|
if (lists.length) paragraphs.add(items.at(-1));
|
|
190
200
|
if (plain) result.push({ id, text: plain, closed,
|
package/voice/stream-client.js
CHANGED
|
@@ -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
|
+
}
|