@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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { validateConfigPatch } from "./config.js";
|
|
4
|
+
import { createSpeechEndpoint, frameRms } from "./endpoint.js";
|
|
5
|
+
import { pcmSamples } from "./realtime-audio.js";
|
|
6
|
+
import { speechSegments } from "./script.js";
|
|
7
|
+
import { bindRouterSession } from "../router/session-alias.js";
|
|
8
|
+
|
|
9
|
+
const PRESETS = Object.freeze({ "voice-dictation": "dictation", "voice-chat": "chat", "voice-assistant": "assistant", "voice-direct": "direct" });
|
|
10
|
+
const FORMAT = Object.freeze({ type: "audio/pcm", rate: 24000 });
|
|
11
|
+
const BYTES_PER_MS = 48;
|
|
12
|
+
const TURN_BYTES = 60_000 * BYTES_PER_MS;
|
|
13
|
+
const CHUNK_BYTES = 500 * BYTES_PER_MS;
|
|
14
|
+
const SPEECH_RMS = 0.004;
|
|
15
|
+
// Interruption policy (docs/realtime-protocol.md): speech must last this long to suspect barge-in,
|
|
16
|
+
// and a suspected interruption that never resolves stops the reply.
|
|
17
|
+
const BARGE_IN_MS = 500;
|
|
18
|
+
const BARGE_IN_LIMIT_MS = 10_000;
|
|
19
|
+
const TICKET_MS = 30_000;
|
|
20
|
+
// Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
|
|
21
|
+
const ZERO_USAGE = Object.freeze({ total_tokens: 0, input_tokens: 0, output_tokens: 0, input_token_details: { text_tokens: 0, audio_tokens: 0, cached_tokens: 0 }, output_token_details: { text_tokens: 0, audio_tokens: 0 } });
|
|
22
|
+
const TASK_STATUS = Object.freeze({ accepted: "pending", waiting: "pending", running: "running", completed: "completed", failed: "failed", cancelled: "cancelled" });
|
|
23
|
+
|
|
24
|
+
export const REALTIME_CAPABILITIES = Object.freeze({ protocol: "lares-realtime-v1", transport: "websocket", barge_in: false, playback_ack: true, message_truncation: "played", turn_detection: ["manual", "server_vad"], user_transcription: true, context_resume: true, client_tools: false });
|
|
25
|
+
|
|
26
|
+
class ProtocolError extends Error {
|
|
27
|
+
constructor(code, type = "invalid_request_error") { super(code); this.code = code; this.type = type; }
|
|
28
|
+
}
|
|
29
|
+
const fail = (code, type) => { throw new ProtocolError(code, type); };
|
|
30
|
+
const only = (value, keys) => {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) fail("unsupported_parameter");
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
const refuse = (socket, status, text) => { socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); };
|
|
35
|
+
const routerSession = (value) => typeof value === "string" && value.trim() && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value) ? value.trim() : null;
|
|
36
|
+
const ticketOf = (req) => String(req.headers["sec-websocket-protocol"] || "").split(",").map((value) => value.trim()).find((value) => value.startsWith("lares-ticket."))?.slice(13);
|
|
37
|
+
const CJK_END = /[ -〿㐀-鿿-]$/;
|
|
38
|
+
const CJK_START = /^[ -〿㐀-鿿-]/;
|
|
39
|
+
const separator = (before, after) => !before || CJK_END.test(before) || CJK_START.test(after) ? "" : " ";
|
|
40
|
+
/** Spoken segments are cleaned clauses; rejoin them without inventing spaces inside CJK text. */
|
|
41
|
+
export function joinSpoken(parts) {
|
|
42
|
+
return parts.reduce((text, part) => `${text}${separator(text, part)}${part}`, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `lares-realtime-v1` (docs/realtime-protocol.md) on one endpoint. Bearer voice-key callers get a
|
|
47
|
+
* scope owned by the socket; ticket sockets attach to the Lares client's open voice scope.
|
|
48
|
+
*/
|
|
49
|
+
export function createLaresRealtimeGateway({ service, config, transcribe, synthesize, openTranscription, keys, hostOwner = "", hostAudio, busyDefault = () => "queue", bargeIn = () => process.env.LARES_VOICE_BARGE_IN === "1", segment = speechSegments, onError = () => {}, onOpen = () => {}, limit = 64 }) {
|
|
50
|
+
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false, handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
|
|
51
|
+
const clients = new Map();
|
|
52
|
+
const tickets = new Map();
|
|
53
|
+
|
|
54
|
+
function run(client, { scope, model, preset, owned, sessionId }) {
|
|
55
|
+
const type = preset === "dictation" ? "transcription" : "realtime";
|
|
56
|
+
const headset = !owned && scope.device === "headset";
|
|
57
|
+
scope.voiceOverrides ??= {};
|
|
58
|
+
let chunks = [], bytes = 0, turnMs = 0, totalMs = 0, speaking = false, speechMs = 0, itemId = null, partial = "", stream = null;
|
|
59
|
+
let automatic = false, silenceMs = config().endpointSilenceMs || 500, endpoint = createSpeechEndpoint({ silenceMs });
|
|
60
|
+
let audioOutput = type === "realtime" && (owned || scope.readAloud === true), busyTurn = null, echoCancellation = true;
|
|
61
|
+
let closed = false, busy = false, pendingCommit = false, configuring = false, alive = true, generation = 0, submittedTurns = 0;
|
|
62
|
+
let response = null, awaitingPlayback = false, acknowledges = false, lastReply = null, lastSpoken = null, turn = null, port = null, unbind = () => {};
|
|
63
|
+
let hostOutput = Promise.resolve();
|
|
64
|
+
const tasks = new Map();
|
|
65
|
+
const spoken = new Map();
|
|
66
|
+
const played = new Map();
|
|
67
|
+
|
|
68
|
+
const bargeActive = () => type === "realtime" && !headset && echoCancellation && bargeIn() === true;
|
|
69
|
+
const send = (event) => {
|
|
70
|
+
if (client.readyState !== 1) return;
|
|
71
|
+
if (client.bufferedAmount > 1024 * 1024) { close(); return; }
|
|
72
|
+
client.send(JSON.stringify({ event_id: `event_${randomUUID()}`, ...event }));
|
|
73
|
+
};
|
|
74
|
+
const report = (cause, eventId) => {
|
|
75
|
+
const known = cause instanceof ProtocolError;
|
|
76
|
+
if (!known) { try { onError(cause, scope); } catch {} }
|
|
77
|
+
send({ type: "error", error: { type: known ? cause.type : "server_error", code: known ? cause.code : "upstream_failed", message: known ? "Unsupported or invalid realtime event" : "Voice operation failed", ...(eventId ? { event_id: eventId } : {}) } });
|
|
78
|
+
};
|
|
79
|
+
const view = () => ({
|
|
80
|
+
id: scope.id, object: "realtime.session", type, model,
|
|
81
|
+
...(type === "realtime" ? { output_modalities: [audioOutput ? "audio" : "text"] } : {}),
|
|
82
|
+
audio: {
|
|
83
|
+
input: { format: FORMAT, turn_detection: automatic ? { type: "server_vad", silence_duration_ms: silenceMs } : null, ...(scope.voiceOverrides.language ? { transcription: { language: scope.voiceOverrides.language } } : {}) },
|
|
84
|
+
...(type === "realtime" ? { output: { format: FORMAT, ...(scope.voiceOverrides.voice ? { voice: scope.voiceOverrides.voice } : {}) } } : {}),
|
|
85
|
+
},
|
|
86
|
+
lares: { capabilities: { ...REALTIME_CAPABILITIES, barge_in: bargeActive() }, context_id: scope.contextId, device: headset ? "headset" : "device" },
|
|
87
|
+
});
|
|
88
|
+
const reset = () => {
|
|
89
|
+
chunks = []; bytes = 0; turnMs = 0; pendingCommit = false; speaking = false; speechMs = 0; itemId = null; partial = "";
|
|
90
|
+
stream?.ready.then((value) => value?.cancel()); stream = null;
|
|
91
|
+
endpoint = createSpeechEndpoint({ silenceMs });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Responses: one reply at a time, synthesized sentence by sentence so truncate lands on what was heard.
|
|
95
|
+
const remember = (current) => {
|
|
96
|
+
spoken.set(current.itemId, current);
|
|
97
|
+
while (spoken.size > 8) spoken.delete(spoken.keys().next().value);
|
|
98
|
+
};
|
|
99
|
+
const finish = (current, status) => {
|
|
100
|
+
if (response === current) response = null;
|
|
101
|
+
if (current.done) return;
|
|
102
|
+
current.done = true;
|
|
103
|
+
remember(current);
|
|
104
|
+
const text = joinSpoken(current.sentences.map((sentence) => sentence.text));
|
|
105
|
+
const item = { id: current.itemId, object: "realtime.item", type: "message", role: "assistant", status: status === "completed" ? "completed" : "incomplete", content: [audioOutput ? { type: "output_audio", transcript: text } : { type: "output_text", text }] };
|
|
106
|
+
send({ type: "response.output_item.done", response_id: current.id, output_index: 0, item });
|
|
107
|
+
send({ type: "conversation.item.done", item });
|
|
108
|
+
send({ type: "response.done", response: { id: current.id, object: "realtime.response", status, output: [], usage: ZERO_USAGE, metadata: { lares: { origin: current.origin, usage: { billing: "child-models", input_audio_ms: Math.round(current.inputMs), output_audio_ms: Math.round(current.outputMs) } } } } });
|
|
109
|
+
};
|
|
110
|
+
const truncateTo = (current, audioEndMs) => {
|
|
111
|
+
const heard = Math.max(0, Math.min(audioEndMs, current.outputMs));
|
|
112
|
+
current.sentences = current.sentences.filter((sentence) => sentence.endMs <= heard + 1);
|
|
113
|
+
const kept = current.sentences.map((sentence) => sentence.text);
|
|
114
|
+
service.truncate?.(scope, current.key, joinSpoken(kept));
|
|
115
|
+
send({ type: "conversation.item.truncated", item_id: current.itemId, content_index: 0, audio_end_ms: Math.round(heard) });
|
|
116
|
+
};
|
|
117
|
+
const cancel = (status = "cancelled", { truncate = false } = {}) => {
|
|
118
|
+
const current = response;
|
|
119
|
+
if (!current) return;
|
|
120
|
+
current.abort.abort(); generation++;
|
|
121
|
+
current.suspended?.settle("stop");
|
|
122
|
+
if (headset) { hostOutput = Promise.resolve(); void Promise.resolve(port?.interrupt()).catch(() => close()); send({ type: "lares.device.playback", playing: false }); }
|
|
123
|
+
awaitingPlayback = false;
|
|
124
|
+
finish(current, status);
|
|
125
|
+
if (truncate) truncateTo(current, played.get(current.itemId) ?? 0);
|
|
126
|
+
};
|
|
127
|
+
const playOut = async (current, audio) => {
|
|
128
|
+
if (headset) {
|
|
129
|
+
if (!current.devicePlaying) { current.devicePlaying = true; send({ type: "lares.device.playback", playing: true }); }
|
|
130
|
+
hostOutput = hostOutput.then(() => current.abort.signal.aborted ? undefined : port?.write(audio));
|
|
131
|
+
current.outputMs += audio.length / BYTES_PER_MS;
|
|
132
|
+
return hostOutput;
|
|
133
|
+
}
|
|
134
|
+
if (acknowledges) awaitingPlayback = true;
|
|
135
|
+
for (let offset = 0; offset < audio.length; offset += CHUNK_BYTES) {
|
|
136
|
+
const chunk = audio.subarray(offset, offset + CHUNK_BYTES);
|
|
137
|
+
current.outputMs += chunk.length / BYTES_PER_MS;
|
|
138
|
+
send({ type: "response.output_audio.delta", response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0, delta: chunk.toString("base64") });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const respond = async (text, origin, key, input) => {
|
|
142
|
+
if (response) cancel("cancelled");
|
|
143
|
+
const current = { id: `resp_${randomUUID()}`, itemId: `item_${randomUUID()}`, origin, key, inputMs: input, outputMs: 0, sentences: [], abort: new AbortController(), suspended: null, done: false };
|
|
144
|
+
response = current; lastSpoken = { text, origin, key };
|
|
145
|
+
const part = { response_id: current.id, item_id: current.itemId, output_index: 0, content_index: 0 };
|
|
146
|
+
send({ type: "response.created", response: { id: current.id, object: "realtime.response", status: "in_progress", output: [], metadata: { lares: { origin } } } });
|
|
147
|
+
send({ type: "response.output_item.added", response_id: current.id, output_index: 0, item: { id: current.itemId, object: "realtime.item", type: "message", role: "assistant", status: "in_progress", content: [] } });
|
|
148
|
+
if (!audioOutput) {
|
|
149
|
+
current.sentences = [{ text, endMs: 0 }];
|
|
150
|
+
send({ type: "response.output_text.delta", ...part, delta: text });
|
|
151
|
+
send({ type: "response.output_text.done", ...part, text });
|
|
152
|
+
finish(current, "completed");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const sentences = segment(text).filter((value) => value.trim());
|
|
156
|
+
scope.outputAbort = current.abort;
|
|
157
|
+
try {
|
|
158
|
+
for (const sentence of sentences.length ? sentences : [text]) {
|
|
159
|
+
if (current.suspended && await current.suspended.wait === "stop") return;
|
|
160
|
+
if (current.abort.signal.aborted || closed) return;
|
|
161
|
+
send({ type: "response.output_audio_transcript.delta", ...part, delta: `${separator(current.sentences.at(-1)?.text, sentence)}${sentence}` });
|
|
162
|
+
const audio = await synthesize(sentence, scope, (pcm) => playOut(current, pcm));
|
|
163
|
+
if (current.abort.signal.aborted || closed) return;
|
|
164
|
+
if (audio) await playOut(current, audio);
|
|
165
|
+
current.sentences.push({ text: sentence, endMs: current.outputMs });
|
|
166
|
+
}
|
|
167
|
+
if (headset) {
|
|
168
|
+
await hostOutput; await port?.finish();
|
|
169
|
+
send({ type: "lares.device.playback", playing: false });
|
|
170
|
+
}
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
if (current.abort.signal.aborted || closed) return;
|
|
173
|
+
finish(current, "failed");
|
|
174
|
+
throw cause;
|
|
175
|
+
}
|
|
176
|
+
if (current.abort.signal.aborted || closed) return;
|
|
177
|
+
send({ type: "response.output_audio_transcript.done", ...part, transcript: joinSpoken(current.sentences.map((sentence) => sentence.text)) });
|
|
178
|
+
send({ type: "response.output_audio.done", ...part });
|
|
179
|
+
finish(current, "completed");
|
|
180
|
+
};
|
|
181
|
+
const speak = (text, origin, key, input) => { void respond(text, origin, key, input).catch((cause) => report(cause)); };
|
|
182
|
+
|
|
183
|
+
// Turns.
|
|
184
|
+
const observeTasks = () => {
|
|
185
|
+
const snapshot = scope.session?.snapshot();
|
|
186
|
+
if (owned && scope.routerSessionId && snapshot?.sessionId && !scope.aliasBound) { scope.aliasBound = true; unbind = bindRouterSession(snapshot.sessionId, scope.routerSessionId); }
|
|
187
|
+
for (const task of snapshot?.tasks || []) {
|
|
188
|
+
const status = TASK_STATUS[task.status] || "unknown";
|
|
189
|
+
if (turn && task.requestId.startsWith(turn.id)) turn.accepted = true;
|
|
190
|
+
if (tasks.get(task.requestId) === status) continue;
|
|
191
|
+
tasks.set(task.requestId, status);
|
|
192
|
+
send({ type: "lares.task.updated", task_id: task.requestId, status, session_id: snapshot.sessionId || null });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const submit = async (text, requestId, epoch, input) => {
|
|
196
|
+
submittedTurns++;
|
|
197
|
+
turn = { id: requestId, accepted: false };
|
|
198
|
+
const running = scope.session?.snapshot().running === true;
|
|
199
|
+
const mode = running ? busyTurn || busyDefault() : undefined;
|
|
200
|
+
let state;
|
|
201
|
+
try { state = await service.prompt(scope, { text, requestId, ...(mode ? { mode } : {}) }); }
|
|
202
|
+
finally { observeTasks(); }
|
|
203
|
+
if (closed) return;
|
|
204
|
+
if (epoch !== generation) {
|
|
205
|
+
// Interrupted before the task was confirmed: take the submission back when dsh still allows it.
|
|
206
|
+
if (!turn.accepted) await service.withdraw?.(scope, requestId).catch(() => {});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; speak(state.utterance.text, "turn", state.utterance.key, input); }
|
|
210
|
+
};
|
|
211
|
+
const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
|
|
212
|
+
const commit = async ({ endpointed = false } = {}) => {
|
|
213
|
+
if (!bytes) return;
|
|
214
|
+
// Server VAD keeps listening while a turn is processed; the next endpoint waits for it.
|
|
215
|
+
if (busy && endpointed) { pendingCommit = true; return; }
|
|
216
|
+
if (busy) fail("turn_busy");
|
|
217
|
+
const input = Buffer.concat(chunks), id = itemId || `item_${randomUUID()}`, ms = input.length / BYTES_PER_MS, opened = stream;
|
|
218
|
+
stream = null; reset(); scope.voiceInputActive = false; busy = true;
|
|
219
|
+
send({ type: "input_audio_buffer.committed", item_id: id, previous_item_id: null });
|
|
220
|
+
let epoch = generation;
|
|
221
|
+
try {
|
|
222
|
+
let text = null;
|
|
223
|
+
const live = opened && await opened.ready;
|
|
224
|
+
if (live) text = await live.finish().catch(() => null);
|
|
225
|
+
text ??= await transcribe(input, scope);
|
|
226
|
+
if (preset === "dictation" && owned) text = await service.clean(scope, text);
|
|
227
|
+
if (closed || epoch !== generation) return;
|
|
228
|
+
if (response?.suspended) {
|
|
229
|
+
// Barge-in resolves on the interrupting turn: silence resumes the reply, words stop it.
|
|
230
|
+
if (!text.trim()) { response.suspended.settle("resume"); return; }
|
|
231
|
+
cancel("cancelled", { truncate: true });
|
|
232
|
+
epoch = generation;
|
|
233
|
+
}
|
|
234
|
+
// An empty transcript still completes the item, so a client waiting on its take can settle.
|
|
235
|
+
send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: text });
|
|
236
|
+
if (!text.trim()) return;
|
|
237
|
+
userItem(id, { type: "input_audio", transcript: text });
|
|
238
|
+
if (preset !== "dictation") await submit(text, id, epoch, ms);
|
|
239
|
+
} catch (cause) { if (!closed && epoch === generation) report(cause); }
|
|
240
|
+
finally {
|
|
241
|
+
busy = false;
|
|
242
|
+
if (pendingCommit) { pendingCommit = false; void commit({ endpointed: true }).catch(report); }
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const typed = async (event) => {
|
|
246
|
+
if (type !== "realtime") fail("unsupported_parameter");
|
|
247
|
+
const item = only(event.item, ["id", "type", "role", "content"]);
|
|
248
|
+
const content = Array.isArray(item.content) && item.content.length === 1 ? only(item.content[0], ["type", "text"]) : fail("unsupported_parameter");
|
|
249
|
+
if (item.type !== "message" || item.role !== "user" || content.type !== "input_text" || typeof content.text !== "string" || !content.text.trim() || content.text.length > 16000) fail("unsupported_parameter");
|
|
250
|
+
if (busy) fail("turn_busy");
|
|
251
|
+
busy = true;
|
|
252
|
+
const id = typeof item.id === "string" && item.id ? item.id.slice(0, 128) : `item_${randomUUID()}`;
|
|
253
|
+
userItem(id, { type: "input_text", text: content.text });
|
|
254
|
+
try { await submit(content.text, id, generation, 0); }
|
|
255
|
+
finally { busy = false; }
|
|
256
|
+
};
|
|
257
|
+
const startStream = () => {
|
|
258
|
+
const current = { queue: [], live: null, id: itemId };
|
|
259
|
+
current.write = (chunk) => { if (current.live) current.live.write(chunk); else current.queue.push(chunk); };
|
|
260
|
+
current.ready = Promise.resolve(openTranscription?.(scope, (text) => {
|
|
261
|
+
if (text.startsWith(partial) && text.length > partial.length && itemId === current.id) send({ type: "conversation.item.input_audio_transcription.delta", item_id: current.id, content_index: 0, delta: text.slice(partial.length) });
|
|
262
|
+
partial = text;
|
|
263
|
+
})).catch(() => null).then((live) => {
|
|
264
|
+
current.live = live;
|
|
265
|
+
for (const chunk of live ? current.queue : []) live.write(chunk);
|
|
266
|
+
current.queue = [];
|
|
267
|
+
return live;
|
|
268
|
+
});
|
|
269
|
+
return current;
|
|
270
|
+
};
|
|
271
|
+
const append = (audio) => {
|
|
272
|
+
if (awaitingPlayback && !bargeActive()) fail("waiting_for_playback");
|
|
273
|
+
if (busy && !automatic) fail("turn_busy");
|
|
274
|
+
if (typeof audio !== "string" || audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(audio)) fail("unsupported_audio_format");
|
|
275
|
+
const chunk = Buffer.from(audio, "base64");
|
|
276
|
+
if (chunk.length % 2) fail("unsupported_audio_format");
|
|
277
|
+
if (bytes + chunk.length > TURN_BYTES) fail("audio_limit_exceeded");
|
|
278
|
+
itemId ??= `item_${randomUUID()}`;
|
|
279
|
+
if (openTranscription && !stream) stream = startStream();
|
|
280
|
+
chunks.push(chunk); bytes += chunk.length; turnMs += chunk.length / BYTES_PER_MS; totalMs += chunk.length / BYTES_PER_MS;
|
|
281
|
+
stream?.write(chunk);
|
|
282
|
+
const rms = frameRms(pcmSamples(chunk));
|
|
283
|
+
if (rms >= SPEECH_RMS) { touch(); speechMs += chunk.length / BYTES_PER_MS; } else speechMs = 0;
|
|
284
|
+
if (rms >= SPEECH_RMS && !speaking) { speaking = true; scope.voiceInputActive = true; send({ type: "input_audio_buffer.speech_started", audio_start_ms: Math.round(totalMs), item_id: itemId }); }
|
|
285
|
+
if (automatic && bargeActive() && response && !response.suspended && speechMs >= BARGE_IN_MS) suspend(response);
|
|
286
|
+
if (automatic && endpoint.push(1, rms, chunk.length / BYTES_PER_MS)) {
|
|
287
|
+
send({ type: "input_audio_buffer.speech_stopped", audio_end_ms: Math.round(totalMs), item_id: itemId });
|
|
288
|
+
void commit({ endpointed: true }).catch(report);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const suspend = (current) => {
|
|
292
|
+
let settle;
|
|
293
|
+
const wait = new Promise((resolve) => { settle = resolve; });
|
|
294
|
+
const timer = setTimeout(() => { if (response === current) cancel("cancelled", { truncate: true }); }, BARGE_IN_LIMIT_MS);
|
|
295
|
+
current.suspended = { wait, settle: (value) => { clearTimeout(timer); if (current.suspended?.wait === wait) current.suspended = null; settle(value); } };
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const configure = async (event) => {
|
|
299
|
+
const session = only(event.session, ["type", "model", "audio", "output_modalities", "instructions", "lares"]);
|
|
300
|
+
if ((session.type && session.type !== type) || (session.model && session.model !== model)) fail("unsupported_parameter");
|
|
301
|
+
const next = { automatic, silenceMs, audioOutput, busyTurn, echoCancellation, overrides: { ...scope.voiceOverrides } };
|
|
302
|
+
let contextId = null, instructions = null;
|
|
303
|
+
if (session.audio !== undefined) {
|
|
304
|
+
const audio = only(session.audio, type === "realtime" ? ["input", "output"] : ["input"]);
|
|
305
|
+
if (audio.input !== undefined) {
|
|
306
|
+
const input = only(audio.input, ["format", "turn_detection", "transcription"]);
|
|
307
|
+
if (input.format !== undefined && (input.format?.type !== FORMAT.type || input.format?.rate !== FORMAT.rate)) fail("unsupported_audio_format");
|
|
308
|
+
if (input.turn_detection === null) next.automatic = false;
|
|
309
|
+
else if (input.turn_detection !== undefined) {
|
|
310
|
+
const detection = only(input.turn_detection, ["type", "silence_duration_ms"]);
|
|
311
|
+
if (detection.type !== "server_vad") fail("unsupported_parameter");
|
|
312
|
+
if (detection.silence_duration_ms !== undefined && (!Number.isInteger(detection.silence_duration_ms) || detection.silence_duration_ms < 200 || detection.silence_duration_ms > 3000)) fail("unsupported_parameter");
|
|
313
|
+
next.automatic = true;
|
|
314
|
+
next.silenceMs = detection.silence_duration_ms ?? next.silenceMs;
|
|
315
|
+
}
|
|
316
|
+
if (input.transcription !== undefined) {
|
|
317
|
+
const { language } = only(input.transcription, ["language"]);
|
|
318
|
+
try { next.overrides.language = validateConfigPatch({ language: language ?? "" }).language || undefined; } catch { fail("unsupported_parameter"); }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (audio.output !== undefined) {
|
|
322
|
+
const output = only(audio.output, ["format", "voice"]);
|
|
323
|
+
if (output.format !== undefined && (output.format?.type !== FORMAT.type || output.format?.rate !== FORMAT.rate)) fail("unsupported_audio_format");
|
|
324
|
+
if (output.voice !== undefined) {
|
|
325
|
+
try { next.overrides.voice = validateConfigPatch({ voice: output.voice ?? "" }).voice || undefined; } catch { fail("unsupported_parameter"); }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (session.output_modalities !== undefined) {
|
|
330
|
+
if (type !== "realtime" || !Array.isArray(session.output_modalities) || session.output_modalities.length !== 1 || !["audio", "text"].includes(session.output_modalities[0])) fail("unsupported_parameter");
|
|
331
|
+
next.audioOutput = session.output_modalities[0] === "audio";
|
|
332
|
+
}
|
|
333
|
+
if (session.instructions !== undefined) {
|
|
334
|
+
if (preset !== "chat" || typeof session.instructions !== "string" || session.instructions.length > 2000) fail("unsupported_parameter");
|
|
335
|
+
instructions = session.instructions;
|
|
336
|
+
}
|
|
337
|
+
if (session.lares !== undefined) {
|
|
338
|
+
const lares = only(session.lares, ["context_id", "echo_cancellation", "busy_turn"]);
|
|
339
|
+
if (lares.echo_cancellation !== undefined && typeof lares.echo_cancellation !== "boolean") fail("unsupported_parameter");
|
|
340
|
+
if (lares.busy_turn !== undefined && lares.busy_turn !== null && !["queue", "steer"].includes(lares.busy_turn)) fail("unsupported_parameter");
|
|
341
|
+
if (lares.busy_turn !== undefined) next.busyTurn = lares.busy_turn;
|
|
342
|
+
if (lares.echo_cancellation !== undefined) next.echoCancellation = lares.echo_cancellation;
|
|
343
|
+
if (lares.context_id !== undefined && lares.context_id !== scope.contextId) {
|
|
344
|
+
if (!owned || typeof lares.context_id !== "string") fail("unsupported_parameter");
|
|
345
|
+
if (submittedTurns || bytes || busy) fail("context_locked");
|
|
346
|
+
contextId = lares.context_id;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (contextId) {
|
|
350
|
+
configuring = true;
|
|
351
|
+
try {
|
|
352
|
+
await service.close(scope);
|
|
353
|
+
await service.open(scope, { preset, sessionId, contextId, readAloud: false });
|
|
354
|
+
} catch { fail("unsupported_parameter"); }
|
|
355
|
+
finally { configuring = false; }
|
|
356
|
+
}
|
|
357
|
+
if (instructions !== null) service.instructions(scope, instructions);
|
|
358
|
+
if (!owned && next.audioOutput !== audioOutput) scope.readAloud = next.audioOutput;
|
|
359
|
+
({ automatic, silenceMs, audioOutput, busyTurn, echoCancellation } = next);
|
|
360
|
+
scope.voiceOverrides = next.overrides;
|
|
361
|
+
endpoint = createSpeechEndpoint({ silenceMs });
|
|
362
|
+
send({ type: "session.updated", session: view() });
|
|
363
|
+
};
|
|
364
|
+
const truncate = (event) => {
|
|
365
|
+
const current = spoken.get(event.item_id) || (response?.itemId === event.item_id ? response : null);
|
|
366
|
+
if (!current || event.content_index !== 0 || !Number.isInteger(event.audio_end_ms) || event.audio_end_ms < 0) fail("unsupported_parameter");
|
|
367
|
+
if (response === current) cancel("cancelled");
|
|
368
|
+
truncateTo(current, event.audio_end_ms);
|
|
369
|
+
};
|
|
370
|
+
const handle = async (event) => {
|
|
371
|
+
switch (event.type) {
|
|
372
|
+
case "session.update": return configure(event);
|
|
373
|
+
case "input_audio_buffer.append": return append(event.audio);
|
|
374
|
+
case "input_audio_buffer.commit": return commit();
|
|
375
|
+
case "input_audio_buffer.clear": reset(); scope.voiceInputActive = false; send({ type: "input_audio_buffer.cleared" }); return;
|
|
376
|
+
case "conversation.item.create": return typed(event);
|
|
377
|
+
case "conversation.item.truncate": return truncate(event);
|
|
378
|
+
case "response.create":
|
|
379
|
+
if (event.response !== undefined && (typeof event.response !== "object" || event.response === null || Object.keys(event.response).length)) fail("unsupported_parameter");
|
|
380
|
+
if (busy || response) fail("turn_busy");
|
|
381
|
+
if (!lastSpoken) fail("unsupported_parameter");
|
|
382
|
+
speak(lastSpoken.text, lastSpoken.origin, lastSpoken.key, 0);
|
|
383
|
+
return;
|
|
384
|
+
case "response.cancel":
|
|
385
|
+
if (busy && turn) generation++;
|
|
386
|
+
cancel("cancelled");
|
|
387
|
+
return;
|
|
388
|
+
case "lares.playback.progress":
|
|
389
|
+
acknowledges = true;
|
|
390
|
+
if (typeof event.item_id === "string" && Number.isFinite(event.played_ms) && event.played_ms >= 0) played.set(event.item_id, event.played_ms);
|
|
391
|
+
return;
|
|
392
|
+
case "lares.playback.completed": acknowledges = true; awaitingPlayback = false; return;
|
|
393
|
+
case "lares.task.cancel": {
|
|
394
|
+
if (typeof event.task_id !== "string" || !tasks.has(event.task_id)) fail("task_not_cancellable");
|
|
395
|
+
if (await service.withdraw(scope, event.task_id) === "not_cancellable") fail("task_not_cancellable");
|
|
396
|
+
observeTasks();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
case "lares.device.input.pause":
|
|
400
|
+
if (!headset) fail("unsupported_event");
|
|
401
|
+
await port?.pauseInput(); reset(); return;
|
|
402
|
+
case "lares.device.input.resume":
|
|
403
|
+
if (!headset) fail("unsupported_event");
|
|
404
|
+
await port?.resumeInput(); return;
|
|
405
|
+
default: fail("unsupported_event");
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const close = () => {
|
|
410
|
+
if (closed) return;
|
|
411
|
+
closed = true; response?.abort.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
|
|
412
|
+
unbind(); clients.delete(client); client.close();
|
|
413
|
+
void Promise.resolve(port?.stop()).catch(() => {});
|
|
414
|
+
if (scope.realtimeSocket === client) scope.realtimeSocket = null;
|
|
415
|
+
if (owned) void service.close(scope).catch(() => {});
|
|
416
|
+
};
|
|
417
|
+
let idle;
|
|
418
|
+
const touch = () => {
|
|
419
|
+
clearTimeout(idle);
|
|
420
|
+
idle = setTimeout(() => { send({ type: "error", error: { type: "invalid_request_error", code: "session_expired", message: "Voice session idle" } }); close(); }, config().idleTimeoutMs || 300000);
|
|
421
|
+
};
|
|
422
|
+
touch();
|
|
423
|
+
clients.set(client, close);
|
|
424
|
+
if (!owned) scope.realtimeSocket = client;
|
|
425
|
+
const heartbeat = setInterval(() => { if (!alive) close(); else { alive = false; client.ping(); } }, 15000);
|
|
426
|
+
const results = setInterval(() => {
|
|
427
|
+
if (!scope.active) return;
|
|
428
|
+
try {
|
|
429
|
+
observeTasks();
|
|
430
|
+
if (busy || speaking || awaitingPlayback || response) return;
|
|
431
|
+
const state = service.state(scope);
|
|
432
|
+
if (!state.utterance?.text || state.utterance.key === lastReply || state.utterance.silent) return;
|
|
433
|
+
lastReply = state.utterance.key;
|
|
434
|
+
speak(state.utterance.text, String(state.utterance.key).startsWith("task:") ? "task" : "turn", state.utterance.key, 0);
|
|
435
|
+
} catch { close(); }
|
|
436
|
+
}, 250);
|
|
437
|
+
client.on("pong", () => { alive = true; });
|
|
438
|
+
client.on("close", close); client.on("error", close);
|
|
439
|
+
const opening = owned ? service.open(scope, { preset, sessionId, readAloud: false }) : Promise.resolve();
|
|
440
|
+
void opening.then(async () => {
|
|
441
|
+
if (headset) port = await hostAudio(scope, (event) => { if (event.type === "input_audio_buffer.append") { try { append(event.audio); } catch {} } });
|
|
442
|
+
if (closed) { void port?.stop(); return; }
|
|
443
|
+
send({ type: "session.created", session: view() });
|
|
444
|
+
client.on("message", async (raw, binary) => {
|
|
445
|
+
let event;
|
|
446
|
+
try {
|
|
447
|
+
if (configuring) fail("turn_busy");
|
|
448
|
+
if (binary) fail("unsupported_event");
|
|
449
|
+
try { event = JSON.parse(raw.toString()); } catch { fail("unsupported_event"); }
|
|
450
|
+
if (!event || typeof event.type !== "string") fail("unsupported_event");
|
|
451
|
+
await handle(event);
|
|
452
|
+
} catch (cause) { report(cause, typeof event?.event_id === "string" ? event.event_id : undefined); }
|
|
453
|
+
});
|
|
454
|
+
}).catch(() => { send({ type: "error", error: { type: "server_error", code: "upstream_failed", message: "Voice session failed to open" } }); close(); });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
/** Ticket for a Lares client's already open pipeline scope; single use, 30 s. */
|
|
459
|
+
ticket(scope) {
|
|
460
|
+
if (!scope.active || !scope.session) throw Object.assign(new Error("voice session closed"), { status: 409, code: "voice_session_closed" });
|
|
461
|
+
for (const [key, value] of tickets) if (value.expires < Date.now() || value.scope === scope) tickets.delete(key);
|
|
462
|
+
const ticket = randomUUID();
|
|
463
|
+
tickets.set(ticket, { scope, expires: Date.now() + TICKET_MS });
|
|
464
|
+
return { ticket };
|
|
465
|
+
},
|
|
466
|
+
accepts(req) { return Boolean(req.headers.authorization) || tickets.has(ticketOf(req)); },
|
|
467
|
+
upgrade(req, socket, head) {
|
|
468
|
+
if (clients.size >= limit) return refuse(socket, 429, "Too Many Requests");
|
|
469
|
+
if (!req.headers.authorization) {
|
|
470
|
+
const token = ticketOf(req), grant = tickets.get(token);
|
|
471
|
+
tickets.delete(token);
|
|
472
|
+
if (!grant || grant.expires < Date.now()) return refuse(socket, 401, "Unauthorized");
|
|
473
|
+
if (!grant.scope.active || grant.scope.realtimeSocket) return refuse(socket, 403, "Forbidden");
|
|
474
|
+
const preset = grant.scope.voiceConfig?.preset || "chat";
|
|
475
|
+
server.handleUpgrade(req, socket, head, (client) => run(client, { scope: grant.scope, model: `voice-${preset}`, preset, owned: false }));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const key = keys.verify(req.headers.authorization);
|
|
479
|
+
if (!key) return refuse(socket, 401, "Unauthorized");
|
|
480
|
+
const model = new URL(req.url, "http://voice.invalid").searchParams.get("model");
|
|
481
|
+
const preset = PRESETS[model];
|
|
482
|
+
if (!preset || !key.models.includes(model)) return refuse(socket, 403, "Forbidden");
|
|
483
|
+
const dependencies = config();
|
|
484
|
+
if ([dependencies.model, dependencies.chatModel, dependencies.ttsModel, dependencies.realtimeModel].some((id) => id === model || id?.endsWith(`/${model}`))) return refuse(socket, 503, "Service Unavailable");
|
|
485
|
+
const scope = { id: randomUUID(), owner: hostOwner, authorizedBinding: { sessionId: key.sessionId, workspaceId: key.workspaceId }, device: "device", routerSessionId: routerSession(req.headers["x-session-id"]), voiceOverrides: {} };
|
|
486
|
+
try { onOpen({ voiceId: scope.id, keyId: key.id, model, routerSession: scope.routerSessionId, remote: req.socket?.remoteAddress }); } catch {}
|
|
487
|
+
server.handleUpgrade(req, socket, head, (client) => run(client, { scope, model, preset, owned: true, sessionId: key.sessionId }));
|
|
488
|
+
},
|
|
489
|
+
close() { for (const close of clients.values()) close(); server.close(); tickets.clear(); },
|
|
490
|
+
};
|
|
491
|
+
}
|
package/voice/options.js
CHANGED
|
@@ -16,13 +16,15 @@ export const VOICE_DEFAULTS = {
|
|
|
16
16
|
recoveryTimeoutMs: 120_000,
|
|
17
17
|
device: "auto",
|
|
18
18
|
backend: "pipeline",
|
|
19
|
+
// Lares clients speak lares-realtime-v1 by default; "legacy" keeps the pipeline endpoints until device acceptance.
|
|
20
|
+
transport: "unified",
|
|
19
21
|
realtimeModel: "",
|
|
20
22
|
model: "",
|
|
21
23
|
ttsModel: "",
|
|
22
24
|
language: "",
|
|
23
25
|
speak: false,
|
|
24
26
|
// Read answers while they are written. Needs the GPU in MemorySlice, see gpu-mode.js.
|
|
25
|
-
streaming:
|
|
27
|
+
streaming: true,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export const VOICE_PRESETS = Object.freeze(["dictation", "chat", "assistant", "direct"]);
|
package/voice/pcm-player.js
CHANGED
|
@@ -56,7 +56,10 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
|
|
|
56
56
|
owned = false; context = null;
|
|
57
57
|
throw error;
|
|
58
58
|
}
|
|
59
|
-
node = new AudioWorkletNode(opened, "lares-pcm-player", {
|
|
59
|
+
node = new AudioWorkletNode(opened, "lares-pcm-player", {
|
|
60
|
+
processorOptions: { inputRate: sampleRate, outputRate: opened.sampleRate || sampleRate, preroll: Math.round(sampleRate * 0.2) },
|
|
61
|
+
numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1],
|
|
62
|
+
});
|
|
60
63
|
node.port.onmessage = ({ data }) => {
|
|
61
64
|
if (epoch !== generation) return;
|
|
62
65
|
if (data.type === "progress") { played = data.frames; onProgress(played); }
|
package/voice/pcm-worklet.js
CHANGED
|
@@ -3,7 +3,10 @@ export const PCM_PLAYER_WORKLET = `
|
|
|
3
3
|
class LaresPcmPlayer extends AudioWorkletProcessor {
|
|
4
4
|
constructor(options) {
|
|
5
5
|
super(); this.queue = []; this.offset = 0; this.frames = 0; this.reported = 0;
|
|
6
|
-
this.rate = options.processorOptions.inputRate;
|
|
6
|
+
this.rate = options.processorOptions.inputRate;
|
|
7
|
+
const outputRate = options.processorOptions.outputRate || sampleRate;
|
|
8
|
+
this.step = this.rate / outputRate;
|
|
9
|
+
this.preroll = options.processorOptions.preroll || 0;
|
|
7
10
|
this.paused = false; this.queued = 0; this.finished = false; this.started = false; this.starved = false;
|
|
8
11
|
this.port.onmessage = ({ data }) => {
|
|
9
12
|
if (data.type === 'audio') {
|
|
@@ -18,6 +21,8 @@ class LaresPcmPlayer extends AudioWorkletProcessor {
|
|
|
18
21
|
const out = outputs[0][0];
|
|
19
22
|
out.fill(0);
|
|
20
23
|
if (this.paused) return true;
|
|
24
|
+
// A late first packet must not open a hole inside the first syllable.
|
|
25
|
+
if (!this.started && !this.finished && this.queued < this.preroll) return true;
|
|
21
26
|
for (let i = 0; i < out.length; i++) {
|
|
22
27
|
while (this.queue.length && this.offset >= this.queue[0].length) {
|
|
23
28
|
const head = this.queue.shift(); this.offset -= head.length; this.queued -= head.length;
|
package/voice/presentation.js
CHANGED
|
@@ -6,10 +6,7 @@ export function voicePanelPresentation(view) {
|
|
|
6
6
|
const preview = view.mode === "single" && ["preview", "processing"].includes(view.stage);
|
|
7
7
|
const preparing = view.busy || capture === "recording" && view.snapshot?.inputReady === false;
|
|
8
8
|
const recording = capture === "recording" && !preparing;
|
|
9
|
-
const
|
|
10
|
-
// Recovery and idle endings are not failures. Hold and send open readout again.
|
|
11
|
-
// A muted call is not a listen session. "Resume to listen again" is the readout copy.
|
|
12
|
-
const error = QUIET_ENDINGS.has(reported) ? null : reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
|
|
9
|
+
const error = visibleCallError(view);
|
|
13
10
|
const phase = view.cancelArmed ? "cancel" : error ? "error" : processing ? "processing" : transcribing ? "transcribing" : preparing ? "preparing" : recording ? "recording" : preview ? "preview" : view.snapshot && view.snapshot.playback !== "idle" ? "speaking" : view.snapshot?.agent === "running" ? "working" : "waiting";
|
|
14
11
|
const status = error ? `error.${error}` : ({ cancel: "gesture.cancelRelease", processing: "dictation.processing", transcribing: "mic.transcribing", preparing: "mic.preparing", recording: "call.listening", preview: "dictation.preview", speaking: "dictation.speaking", working: "call.working", waiting: view.mode === "continuous" ? "gesture.waiting" : "dictation.preview" })[phase];
|
|
15
12
|
return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
|
|
@@ -17,10 +14,21 @@ export function voicePanelPresentation(view) {
|
|
|
17
14
|
|
|
18
15
|
const QUIET_ENDINGS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
|
|
19
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Recovery, idle, and a dropped continuous socket are not failures.
|
|
19
|
+
* Hold-to-speak and send open the call again, so the composer stays quiet.
|
|
20
|
+
* A muted dictation is not a listen session.
|
|
21
|
+
*/
|
|
22
|
+
function visibleCallError(view) {
|
|
23
|
+
const reported = view?.error || view?.snapshot?.error;
|
|
24
|
+
if (!reported || QUIET_ENDINGS.has(reported)) return null;
|
|
25
|
+
if (view.mode === "continuous" && reported === "voice_disconnected") return null;
|
|
26
|
+
return reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/**
|
|
21
30
|
* A continuous call that has failed is one composer banner.
|
|
22
|
-
* A paused session can still be resumed.
|
|
23
|
-
* hold-to-speak and send open readout again, without a banner.
|
|
31
|
+
* A paused session can still be resumed. Quiet endings have no banner.
|
|
24
32
|
*/
|
|
25
33
|
export function voiceCallFailure(view) {
|
|
26
34
|
if (!view?.visible || view.mode !== "continuous") return null;
|
|
@@ -46,8 +54,7 @@ export function voiceDictationPresentation(view, now = Date.now()) {
|
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export function voiceIndicatorPresentation(view, settingsError = null) {
|
|
49
|
-
const
|
|
50
|
-
const error = (QUIET_ENDINGS.has(callError) ? null : callError) || view.readAloudError || settingsError;
|
|
57
|
+
const error = visibleCallError(view) || view.readAloudError || settingsError;
|
|
51
58
|
const continuous = view.mode === "continuous";
|
|
52
59
|
const active = view.visible && (view.snapshot?.connection === "open" || view.busy && !view.snapshot);
|
|
53
60
|
return {
|
package/voice/protocol-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Transport-neutral client: the caller obtains an authenticated
|
|
1
|
+
/** Transport-neutral `lares-realtime-v1` client: the caller obtains an authenticated socket. */
|
|
2
2
|
export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose = () => {} } = {}) {
|
|
3
3
|
const send = (event) => {
|
|
4
4
|
if (socket.readyState !== 1) throw new Error("voice_disconnected");
|
|
@@ -13,12 +13,25 @@ export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose
|
|
|
13
13
|
const close = () => onClose();
|
|
14
14
|
socket.addEventListener("message", message); socket.addEventListener("close", close);
|
|
15
15
|
return {
|
|
16
|
-
|
|
16
|
+
// Announcing playback acknowledgement up front keeps the first reply half-duplex too.
|
|
17
|
+
configure({ type = "realtime", automatic = false, contextId, audio = true, busyTurn } = {}) {
|
|
18
|
+
const input = { format: { type: "audio/pcm", rate: 24000 }, turn_detection: automatic ? { type: "server_vad" } : null };
|
|
19
|
+
const lares = { ...(contextId ? { context_id: contextId } : {}), ...(busyTurn ? { busy_turn: busyTurn } : {}) };
|
|
20
|
+
send({ type: "session.update", session: { type, ...(type === "realtime" ? { output_modalities: audio ? ["audio"] : ["text"], audio: { input, output: { format: { type: "audio/pcm", rate: 24000 } } } } : { audio: { input } }), ...(Object.keys(lares).length ? { lares } : {}) } });
|
|
21
|
+
send({ type: "lares.playback.progress", item_id: null, played_ms: 0 });
|
|
22
|
+
},
|
|
17
23
|
append(audio) { if (typeof audio !== "string" || audio.length > 64000) throw new Error("voice_audio_limit"); send({ type: "input_audio_buffer.append", audio }); },
|
|
18
24
|
commit() { send({ type: "input_audio_buffer.commit" }); },
|
|
19
25
|
discard() { send({ type: "input_audio_buffer.clear" }); },
|
|
20
|
-
|
|
26
|
+
say(text) { send({ type: "conversation.item.create", item: { type: "message", role: "user", content: [{ type: "input_text", text }] } }); },
|
|
27
|
+
playbackProgress(itemId, playedMs) { send({ type: "lares.playback.progress", item_id: itemId, played_ms: playedMs }); },
|
|
28
|
+
playbackCompleted(itemId = null) { send({ type: "lares.playback.completed", item_id: itemId }); },
|
|
21
29
|
stopOutput() { send({ type: "response.cancel" }); },
|
|
30
|
+
truncate(itemId, audioEndMs) { send({ type: "conversation.item.truncate", item_id: itemId, content_index: 0, audio_end_ms: Math.max(0, Math.round(audioEndMs)) }); },
|
|
31
|
+
setOutput(audio) { send({ type: "session.update", session: { output_modalities: [audio ? "audio" : "text"] } }); },
|
|
32
|
+
setBusyTurn(mode) { send({ type: "session.update", session: { lares: { busy_turn: mode === "steer" ? "steer" : "queue" } } }); },
|
|
33
|
+
deviceInput(active) { send({ type: active ? "lares.device.input.resume" : "lares.device.input.pause" }); },
|
|
34
|
+
cancelTask(taskId) { send({ type: "lares.task.cancel", task_id: taskId }); },
|
|
22
35
|
close() { socket.removeEventListener("message", message); socket.removeEventListener("close", close); socket.close(); },
|
|
23
36
|
};
|
|
24
37
|
}
|