@olares/lares-core 0.15.10 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/i18n/voice.js +246 -13
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +5 -3
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +4 -6
- package/olares/trusted-host.js +11 -1
- package/package.json +27 -2
- package/router/tts.js +25 -1
- package/tools/http.js +27 -1
- package/voice/agent-session.js +133 -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 +71 -9
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +30 -17
- package/voice/controller.js +147 -0
- package/voice/conversation.js +131 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +71 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +460 -0
- 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 +171 -0
- package/voice/input.js +2 -1
- package/voice/install.js +33 -12
- package/voice/languages.js +2 -6
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +122 -0
- package/voice/realtime-gateway.js +162 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +72 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +85 -15
- package/voice/service.js +340 -57
- package/voice/session.js +11 -1
- package/voice/speech.js +228 -44
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createVoiceAgentSession } from "./agent-session.js";
|
|
2
|
+
import { createVoiceRecords } from "./records.js";
|
|
3
|
+
import { createVoiceDialogue, cleanDictation } from "./dialogue.js";
|
|
4
|
+
import { readConfig } from "./config.js";
|
|
5
|
+
import { VOICE_PRESETS } from "./options.js";
|
|
6
|
+
|
|
7
|
+
const failure = (code, status) => Object.assign(new Error(code), { code, status });
|
|
8
|
+
|
|
9
|
+
/** Presets and task policy stay in core; the Host supplies an authenticated scope. */
|
|
10
|
+
export function createVoiceHostService(api, { env = process.env, metric = () => {}, records = createVoiceRecords(), config = readConfig, chat } = {}) {
|
|
11
|
+
const ownerOf = (scope) => scope.owner || "@local";
|
|
12
|
+
const keyOf = (scope) => scope.session?.snapshot().sessionId || `voice:${scope.contextId || scope.id}`;
|
|
13
|
+
const append = (scope, role, text, turnId, taskId = null) => records.append(ownerOf(scope), keyOf(scope), {
|
|
14
|
+
id: `${scope.id}:${turnId}:${role}`, voiceId: scope.id, clientId: scope.id, turnId, role, text: text.slice(0, 16000), taskId,
|
|
15
|
+
});
|
|
16
|
+
const scopes = new Set();
|
|
17
|
+
const service = {
|
|
18
|
+
async clean(scope, text) {
|
|
19
|
+
if (typeof text !== "string" || text.length > 16000) throw failure("voice_input_invalid", 400);
|
|
20
|
+
return cleanDictation(text, { config: config(), owner: scope.owner, chat });
|
|
21
|
+
},
|
|
22
|
+
async open(scope, body) {
|
|
23
|
+
if (scope.active || scope.opening || scope.closing) throw failure("voice_session_active", 409);
|
|
24
|
+
const epoch = scope.epoch = (scope.epoch || 0) + 1;
|
|
25
|
+
scope.opening = true;
|
|
26
|
+
try {
|
|
27
|
+
if (body.sessionId != null && (typeof body.sessionId !== "string" || !body.sessionId || body.sessionId.length > 256)) throw failure("voice_session_invalid", 400);
|
|
28
|
+
if (body.contextId != null && (typeof body.contextId !== "string" || !/^[a-zA-Z0-9_-]{8,128}$/.test(body.contextId))) throw failure("voice_context_invalid", 400);
|
|
29
|
+
scope.contextId = body.contextId || scope.id;
|
|
30
|
+
const binding = records.read(ownerOf(scope), `voice:${scope.contextId}`).binding;
|
|
31
|
+
const boundSessionId = body.sessionId || binding || null;
|
|
32
|
+
if (boundSessionId) await api.inspect(boundSessionId);
|
|
33
|
+
if (scope.epoch !== epoch) throw failure("voice_session_closed", 409);
|
|
34
|
+
await scope.session?.close();
|
|
35
|
+
const selected = config();
|
|
36
|
+
const preset = body.preset || selected.preset;
|
|
37
|
+
if (!VOICE_PRESETS.includes(preset)) throw failure("voice_preset_invalid", 400);
|
|
38
|
+
scopes.add(scope);
|
|
39
|
+
scope.voiceAbort = new AbortController();
|
|
40
|
+
scope.voiceConfig = { ...selected, preset };
|
|
41
|
+
const history = records.read(ownerOf(scope), boundSessionId || `voice:${scope.contextId || scope.id}`);
|
|
42
|
+
scope.recoveryContext = history.summary || null;
|
|
43
|
+
const restoredRequests = [...new Set(history.records.map((row) => row.taskId).filter(Boolean))].map((requestId) => ({ requestId }));
|
|
44
|
+
scope.session = createVoiceAgentSession(api, { sessionId: boundSessionId,
|
|
45
|
+
preset: scope.authorizedBinding?.agentPreset || env.LARES_VOICE_AGENT_PRESET || undefined, workspaceId: scope.authorizedBinding?.workspaceId || env.LARES_VOICE_WORKSPACE_ID || undefined, restoredRequests,
|
|
46
|
+
metric: (fields) => metric({ voiceId: scope.id, ...fields }),
|
|
47
|
+
});
|
|
48
|
+
await scope.session.open();
|
|
49
|
+
if (scope.epoch !== epoch) throw failure("voice_session_closed", 409);
|
|
50
|
+
scope.recordKey = keyOf(scope);
|
|
51
|
+
scope.voiceResults = []; scope.summaryError = null;
|
|
52
|
+
scope.active = true; scope.readAloud = body.readAloud === true;
|
|
53
|
+
scope.voiceState = { version: 0, running: false, utterance: null, error: null };
|
|
54
|
+
scope.voiceRequests = new Map(); scope.deliveredTasks = new Set(restoredRequests.map((item) => item.requestId));
|
|
55
|
+
scope.dialogue = createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat,
|
|
56
|
+
submit: async (text, requestId, operation) => {
|
|
57
|
+
if (operation === "status") return scope.session.snapshot();
|
|
58
|
+
const id = `${requestId}:delegate`;
|
|
59
|
+
// The source transcript is persisted before any side effect, even for lazy business sessions.
|
|
60
|
+
const before = keyOf(scope);
|
|
61
|
+
append(scope, "task", text, requestId, id);
|
|
62
|
+
let state;
|
|
63
|
+
try { state = await scope.session.submit(text, id, scope.submitMode || "queue"); }
|
|
64
|
+
finally { if (before !== keyOf(scope)) {
|
|
65
|
+
for (const row of records.read(ownerOf(scope), before).records) records.append(ownerOf(scope), keyOf(scope), row);
|
|
66
|
+
records.clear(ownerOf(scope), before);
|
|
67
|
+
records.bind(ownerOf(scope), scope.contextId, keyOf(scope));
|
|
68
|
+
} }
|
|
69
|
+
return { status: "pending", sessionId: state.sessionId, requestId: id };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
return service.state(scope);
|
|
73
|
+
} finally { if (scope.epoch === epoch) scope.opening = false; }
|
|
74
|
+
},
|
|
75
|
+
async prompt(scope, body) {
|
|
76
|
+
if (!scope.active || !scope.session) throw failure("voice_session_closed", 409);
|
|
77
|
+
if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 16000 || typeof body.requestId !== "string" || !body.requestId || body.requestId.length > 128) throw failure("voice_session_invalid", 400);
|
|
78
|
+
if (scope.voiceConfig.preset === "dictation") return { ...service.state(scope), text: body.text };
|
|
79
|
+
if (scope.voiceConfig.preset === "direct") {
|
|
80
|
+
const before = keyOf(scope);
|
|
81
|
+
append(scope, "user", body.text, body.requestId, body.requestId);
|
|
82
|
+
try { return await scope.session.submit(body.text, body.requestId, body.mode); }
|
|
83
|
+
finally {
|
|
84
|
+
if (before !== keyOf(scope)) {
|
|
85
|
+
for (const row of records.read(ownerOf(scope), before).records) records.append(ownerOf(scope), keyOf(scope), row);
|
|
86
|
+
records.clear(ownerOf(scope), before);
|
|
87
|
+
records.bind(ownerOf(scope), scope.contextId, keyOf(scope));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (scope.voiceRequests.has(body.requestId)) return scope.voiceRequests.get(body.requestId);
|
|
92
|
+
if (scope.voiceRequests.size >= 256) throw failure("voice_session_limit", 429);
|
|
93
|
+
const operation = (scope.voiceSerial || Promise.resolve()).catch(() => {}).then(async () => {
|
|
94
|
+
if (!scope.active || scope.voiceAbort.signal.aborted) throw failure("voice_session_closed", 409);
|
|
95
|
+
scope.submitMode = body.mode === "steer" ? "steer" : "queue";
|
|
96
|
+
scope.voiceState.running = true; scope.voiceState.error = null; scope.voiceState.version++;
|
|
97
|
+
append(scope, "user", body.text, body.requestId);
|
|
98
|
+
try {
|
|
99
|
+
const text = await scope.dialogue.reply(body.text, body.requestId, scope.voiceAbort.signal);
|
|
100
|
+
append(scope, "assistant", text, body.requestId);
|
|
101
|
+
const key = keyOf(scope);
|
|
102
|
+
const checkpointVersion = records.read(ownerOf(scope), key).version;
|
|
103
|
+
scope.summaryTask = scope.dialogue.summarize().then((summary) => records.checkpoint(ownerOf(scope), key, summary, checkpointVersion)).catch((cause) => {
|
|
104
|
+
scope.summaryError = cause.code || cause.message || "voice_summary_failed";
|
|
105
|
+
});
|
|
106
|
+
scope.voiceState.utterance = { key: body.requestId, turn: scope.voiceState.version, step: 0, text, done: true, silent: false };
|
|
107
|
+
} catch (cause) { scope.voiceState.error = cause.code || cause.message || "voice_failed"; throw cause; }
|
|
108
|
+
finally { scope.voiceState.running = false; scope.voiceState.version++; }
|
|
109
|
+
return service.state(scope);
|
|
110
|
+
});
|
|
111
|
+
scope.voiceSerial = operation;
|
|
112
|
+
scope.voiceRequests.set(body.requestId, operation);
|
|
113
|
+
return operation;
|
|
114
|
+
},
|
|
115
|
+
state(scope) {
|
|
116
|
+
const state = scope.session?.snapshot() || {};
|
|
117
|
+
if (!scope.voiceState) return { ...state, contextId: scope.contextId, active: scope.active === true };
|
|
118
|
+
// Completion is keyed by durable request/turn, never by the session's aggregate idle flag.
|
|
119
|
+
for (const task of state.tasks || []) {
|
|
120
|
+
if (!["completed", "failed", "cancelled"].includes(task.status) || scope.deliveredTasks.has(task.requestId)) continue;
|
|
121
|
+
append(scope, "task", JSON.stringify(task), `${task.requestId}:result`, task.requestId);
|
|
122
|
+
scope.deliveredTasks.add(task.requestId);
|
|
123
|
+
scope.voiceResults ??= [];
|
|
124
|
+
scope.voiceResults.push(task);
|
|
125
|
+
}
|
|
126
|
+
if (scope.voiceConfig.preset === "direct") return { ...state, contextId: scope.contextId, active: scope.active === true };
|
|
127
|
+
// Pending business work does not block the next speech turn.
|
|
128
|
+
if (scope.active && !scope.voiceState.running && scope.voiceResults?.length && !scope.voiceInputActive) {
|
|
129
|
+
const task = scope.voiceResults.shift();
|
|
130
|
+
scope.dialogue.observeTask(task);
|
|
131
|
+
const text = task.status === "completed" ? task.text : `Task ${task.status}: ${task.error || ""}`;
|
|
132
|
+
scope.voiceState.utterance = { key: `task:${task.requestId}`, turn: ++scope.voiceState.version, step: 0, text, done: true, silent: false };
|
|
133
|
+
append(scope, "assistant", text, `result:${task.requestId}`, task.requestId);
|
|
134
|
+
}
|
|
135
|
+
return { ...state, ...scope.voiceState, contextId: scope.contextId, active: scope.active === true, backgroundRunning: state.running, summaryError: scope.summaryError || null, preset: scope.voiceConfig.preset };
|
|
136
|
+
},
|
|
137
|
+
async remove(sessionId) {
|
|
138
|
+
for (const scope of scopes) if (scope.session?.snapshot().sessionId === sessionId) { await service.close(scope); scopes.delete(scope); }
|
|
139
|
+
records.clearConversation(sessionId);
|
|
140
|
+
},
|
|
141
|
+
record(scope, role, text, turnId) {
|
|
142
|
+
const key = keyOf(scope);
|
|
143
|
+
if (scope.recordKey && scope.recordKey !== key) {
|
|
144
|
+
for (const row of records.read(ownerOf(scope), scope.recordKey).records) records.append(ownerOf(scope), key, row);
|
|
145
|
+
records.clear(ownerOf(scope), scope.recordKey);
|
|
146
|
+
records.bind(ownerOf(scope), scope.contextId, key);
|
|
147
|
+
}
|
|
148
|
+
scope.recordKey = key;
|
|
149
|
+
return append(scope, role, text.slice(0, 16000), String(turnId || scope.voiceState.version), role === "task" ? String(turnId) : null);
|
|
150
|
+
},
|
|
151
|
+
records(scope) { return records.read(ownerOf(scope), keyOf(scope)); },
|
|
152
|
+
async clearRecords(scope) { const key = keyOf(scope); await service.close(scope); records.clear(ownerOf(scope), key); },
|
|
153
|
+
output(scope, value) { if (typeof value !== "boolean") throw failure("voice_output_invalid", 400); scope.readAloud = value; },
|
|
154
|
+
resume: (scope) => scope.session?.resume(),
|
|
155
|
+
cancel: (scope) => scope.session?.cancel(),
|
|
156
|
+
async close(scope) {
|
|
157
|
+
scope.epoch = (scope.epoch || 0) + 1; scope.opening = false; scope.closing = true;
|
|
158
|
+
scope.active = false; scope.voiceAbort?.abort(); scope.realtimeSocket?.close();
|
|
159
|
+
try {
|
|
160
|
+
scope.capture?.stop(true);
|
|
161
|
+
const cleanup = await Promise.allSettled([scope.duplex?.interrupt(), scope.realtimeAudio?.stop(), scope.session?.close()]);
|
|
162
|
+
await scope.voiceSerial?.catch(() => {});
|
|
163
|
+
await scope.summaryTask;
|
|
164
|
+
scopes.delete(scope);
|
|
165
|
+
const failed = cleanup.find((result) => result.status === "rejected");
|
|
166
|
+
if (failed) throw failed.reason;
|
|
167
|
+
} finally { scope.closing = false; }
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
return service;
|
|
171
|
+
}
|
package/voice/input.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Where a take is recorded. The desktop page uses the headset; the phone page uses its own mic. */
|
|
2
|
-
export function captureSource(surface) {
|
|
2
|
+
export function captureSource(surface, selected = "auto") {
|
|
3
|
+
if (selected === "device" || selected === "headset") return selected;
|
|
3
4
|
return surface === "mobile" ? "device" : "headset";
|
|
4
5
|
}
|
package/voice/install.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { VoiceError } from "../router/stt.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Charts the voice panel installs, stops, and resumes through olares-cli market.
|
|
6
|
+
* No version is pinned: install takes whatever the market holds, and status
|
|
7
|
+
* and model matching go by app name, so chart updates need no Lares release.
|
|
8
|
+
*/
|
|
5
9
|
export const VOICE_CHARTS = {
|
|
6
|
-
stt: { app: "audioqwen3asr06v3",
|
|
7
|
-
tts: { app: "audioqwen3tts06v3",
|
|
10
|
+
stt: { app: "audioqwen3asr06v3", model: "Qwen3-ASR-0.6B" },
|
|
11
|
+
tts: { app: "audioqwen3tts06v3", model: "Qwen3-TTS-12Hz-0.6B-CustomVoice" },
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
const OUTPUT_LIMIT = 8000;
|
|
@@ -47,7 +51,11 @@ function describe(chart, row) {
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Runs olares-cli as the app's profile; resolves with exit code, parsed JSON, and a readable message.
|
|
56
|
+
* Output past `limit` characters is cut from the front, so JSON larger than that does not parse.
|
|
57
|
+
*/
|
|
58
|
+
export function runOlaresCli(args, spawnFn = spawn, limit = OUTPUT_LIMIT) {
|
|
51
59
|
return new Promise((resolve, reject) => {
|
|
52
60
|
const child = spawnFn("olares-cli", args, {
|
|
53
61
|
env: process.env,
|
|
@@ -56,8 +64,8 @@ function runMarket(args, spawnFn) {
|
|
|
56
64
|
let stdout = "";
|
|
57
65
|
let stderr = "";
|
|
58
66
|
const take = (bucket) => (chunk) => {
|
|
59
|
-
if (bucket === "stdout") stdout = `${stdout}${chunk}`.slice(-
|
|
60
|
-
else stderr = `${stderr}${chunk}`.slice(-
|
|
67
|
+
if (bucket === "stdout") stdout = `${stdout}${chunk}`.slice(-limit);
|
|
68
|
+
else stderr = `${stderr}${chunk}`.slice(-limit);
|
|
61
69
|
};
|
|
62
70
|
child.stdout?.on("data", take("stdout"));
|
|
63
71
|
child.stderr?.on("data", take("stderr"));
|
|
@@ -96,25 +104,38 @@ function accepted(result) {
|
|
|
96
104
|
export async function installVoiceChart(kind, options = {}) {
|
|
97
105
|
const chart = chartFor(kind);
|
|
98
106
|
const spawnFn = options.spawnFn ?? spawn;
|
|
99
|
-
const result = await
|
|
100
|
-
["market", "install", chart.app, "-s", "market.test", "--
|
|
107
|
+
const result = await runOlaresCli(
|
|
108
|
+
["market", "install", chart.app, "-s", "market.test", "--compute-mode", "nvidia", "-o", "json"],
|
|
101
109
|
spawnFn,
|
|
102
110
|
);
|
|
103
111
|
if (!accepted(result)) {
|
|
104
112
|
throw new VoiceError("voice_install_failed", 502, result.message);
|
|
105
113
|
}
|
|
106
|
-
return { app: chart.app
|
|
114
|
+
return { app: chart.app };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `status -a` returns one row per market source (catalog, test, upload). An
|
|
119
|
+
* app reinstalled from another source keeps its old `uninstalled` row, so the
|
|
120
|
+
* live one is the latest row that is still installed.
|
|
121
|
+
* @param {unknown} json
|
|
122
|
+
*/
|
|
123
|
+
export function currentChartRow(json) {
|
|
124
|
+
const rows = (Array.isArray(json) ? json : [json]).filter((row) => row && typeof row === "object");
|
|
125
|
+
const installed = rows.filter((row) => chartPhase(row) !== "absent");
|
|
126
|
+
const time = (row) => Date.parse(row.statusTime) || 0;
|
|
127
|
+
return [...(installed.length ? installed : rows)].sort((a, b) => time(b) - time(a))[0] ?? null;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
/** @param {"stt" | "tts"} kind */
|
|
110
131
|
export async function voiceChartStatus(kind, options = {}) {
|
|
111
132
|
const chart = chartFor(kind);
|
|
112
|
-
const result = await
|
|
133
|
+
const result = await runOlaresCli(
|
|
113
134
|
["market", "status", chart.app, "-a", "-o", "json"],
|
|
114
135
|
options.spawnFn ?? spawn,
|
|
115
136
|
);
|
|
116
137
|
if (!result.json) throw new VoiceError("voice_install_failed", 502, result.message);
|
|
117
|
-
return describe(chart, result.json);
|
|
138
|
+
return describe(chart, currentChartRow(result.json));
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
export async function voiceChartsStatus(options = {}) {
|
|
@@ -127,7 +148,7 @@ export async function voiceChartsStatus(options = {}) {
|
|
|
127
148
|
|
|
128
149
|
async function settle(kind, verb, code, options) {
|
|
129
150
|
const chart = chartFor(kind);
|
|
130
|
-
const result = await
|
|
151
|
+
const result = await runOlaresCli(
|
|
131
152
|
["market", verb, chart.app, "--watch", "-o", "json"],
|
|
132
153
|
options.spawnFn ?? spawn,
|
|
133
154
|
);
|
package/voice/languages.js
CHANGED
|
@@ -14,7 +14,7 @@ export function voiceValueFromMenu(id) {
|
|
|
14
14
|
return id === VOICE_AUTO ? "" : id;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export
|
|
17
|
+
export { VOICE_DEFAULTS as EMPTY_VOICE_CONFIG } from "./options.js";
|
|
18
18
|
|
|
19
19
|
export function voiceChoiceItems(ids) {
|
|
20
20
|
return ids.map((id) => ({ id, label: id }));
|
|
@@ -32,8 +32,4 @@ export function voiceLanguageItems(autoLabel) {
|
|
|
32
32
|
|
|
33
33
|
export function voiceModelItems(sttIds, autoLabel) {
|
|
34
34
|
return [{ id: VOICE_AUTO, label: autoLabel }, ...sttIds.map((id) => ({ id, label: id }))];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function voiceStatusReady(status) {
|
|
38
|
-
return Boolean(status?.modelAvailable);
|
|
39
|
-
}
|
|
35
|
+
}
|
package/voice/lease.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Physical audio is shared; a new page must not silently evict its owner. */
|
|
2
|
+
export function createDeviceLease({ now = Date.now, ttlMs = 30_000 } = {}) {
|
|
3
|
+
let lease = null;
|
|
4
|
+
const busy = () => Object.assign(new Error("voice_device_busy"), { code: "voice_device_busy", status: 409 });
|
|
5
|
+
return {
|
|
6
|
+
acquire(owner) {
|
|
7
|
+
if (lease && lease.owner !== owner && lease.until > now()) throw busy();
|
|
8
|
+
lease = { owner, until: now() + ttlMs };
|
|
9
|
+
},
|
|
10
|
+
touch(owner) {
|
|
11
|
+
if (lease?.owner === owner) lease.until = now() + ttlMs;
|
|
12
|
+
},
|
|
13
|
+
release(owner) { if (lease?.owner === owner) lease = null; },
|
|
14
|
+
owns: (owner) => lease?.owner === owner && lease.until > now(),
|
|
15
|
+
};
|
|
16
|
+
}
|
package/voice/options.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const VOICE_DEFAULTS = {
|
|
2
|
+
preset: "assistant",
|
|
3
|
+
dictationCleanup: false,
|
|
4
|
+
chatModel: "default-chat",
|
|
5
|
+
instructions: "",
|
|
6
|
+
voice: "",
|
|
7
|
+
mode: "single",
|
|
8
|
+
mobileMode: "continuous",
|
|
9
|
+
endpointSilenceMs: 500,
|
|
10
|
+
idleTimeoutMs: 300_000,
|
|
11
|
+
recoveryTimeoutMs: 120_000,
|
|
12
|
+
device: "auto",
|
|
13
|
+
backend: "pipeline",
|
|
14
|
+
realtimeModel: "",
|
|
15
|
+
model: "",
|
|
16
|
+
ttsModel: "",
|
|
17
|
+
language: "",
|
|
18
|
+
speak: false,
|
|
19
|
+
// Read answers while they are written. Needs the GPU in MemorySlice, see gpu-mode.js.
|
|
20
|
+
streaming: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const VOICE_PRESETS = Object.freeze(["dictation", "chat", "assistant", "direct"]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Transport-neutral client: the caller obtains an authenticated Router socket. */
|
|
2
|
+
export function createVoiceProtocolClient(socket, { onEvent = () => {}, onClose = () => {} } = {}) {
|
|
3
|
+
const send = (event) => {
|
|
4
|
+
if (socket.readyState !== 1) throw new Error("voice_disconnected");
|
|
5
|
+
if (socket.bufferedAmount > 1024 * 1024) throw new Error("voice_audio_backpressure");
|
|
6
|
+
socket.send(JSON.stringify(event));
|
|
7
|
+
};
|
|
8
|
+
const message = (event) => {
|
|
9
|
+
let value;
|
|
10
|
+
try { value = JSON.parse(String(event.data)); } catch { onEvent({ type: "error", error: { code: "voice_protocol" } }); return; }
|
|
11
|
+
if (value && typeof value.type === "string") onEvent(value);
|
|
12
|
+
};
|
|
13
|
+
const close = () => onClose();
|
|
14
|
+
socket.addEventListener("message", message); socket.addEventListener("close", close);
|
|
15
|
+
return {
|
|
16
|
+
configure({ automatic = false, contextId, audio = true } = {}) { send({ type: "session.update", session: { type: "realtime", output_modalities: audio ? ["audio"] : ["text"], ...(contextId ? { voice_context_id: contextId } : {}), audio: { input: { format: { type: "audio/pcm", rate: 24000 }, turn_detection: automatic ? { type: "server_vad" } : null }, output: { format: { type: "audio/pcm", rate: 24000 } } } }); },
|
|
17
|
+
append(audio) { if (typeof audio !== "string" || audio.length > 64000) throw new Error("voice_audio_limit"); send({ type: "input_audio_buffer.append", audio }); },
|
|
18
|
+
commit() { send({ type: "input_audio_buffer.commit" }); },
|
|
19
|
+
discard() { send({ type: "input_audio_buffer.clear" }); },
|
|
20
|
+
playbackCompleted() { send({ type: "voice.playback.completed" }); },
|
|
21
|
+
stopOutput() { send({ type: "response.cancel" }); },
|
|
22
|
+
close() { socket.removeEventListener("message", message); socket.removeEventListener("close", close); socket.close(); },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Streaming rate conversion keeps fractional phase across device frames. */
|
|
2
|
+
export function createPcmResampler(inputRate, outputRate = 24000) {
|
|
3
|
+
if (!(inputRate > 0 && outputRate > 0)) throw new Error("voice_audio_format");
|
|
4
|
+
let position = 0;
|
|
5
|
+
return (samples) => {
|
|
6
|
+
const values = [];
|
|
7
|
+
const step = inputRate / outputRate;
|
|
8
|
+
for (; position < samples.length; position += step) {
|
|
9
|
+
const i = Math.floor(position);
|
|
10
|
+
const fraction = position - i;
|
|
11
|
+
const value = samples[i] * (1 - fraction) + samples[Math.min(i + 1, samples.length - 1)] * fraction;
|
|
12
|
+
values.push(Math.max(-1, Math.min(1, value)));
|
|
13
|
+
}
|
|
14
|
+
position -= samples.length;
|
|
15
|
+
const bytes = new Uint8Array(values.length * 2);
|
|
16
|
+
const view = new DataView(bytes.buffer);
|
|
17
|
+
for (let i = 0; i < values.length; i++) view.setInt16(i * 2, Math.round(values[i] * 32767), true);
|
|
18
|
+
return bytes;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function pcmSamples(bytes) {
|
|
22
|
+
if (bytes.length % 2) throw new Error("voice_audio_format");
|
|
23
|
+
const values = new Float32Array(bytes.length / 2);
|
|
24
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
25
|
+
for (let i = 0; i < values.length; i++) values[i] = view.getInt16(i * 2, true) / 32768;
|
|
26
|
+
return values;
|
|
27
|
+
}
|
|
28
|
+
function base64(bytes) { return btoa(String.fromCharCode(...bytes)); }
|
|
29
|
+
function unbase64(value) { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); }
|
|
30
|
+
|
|
31
|
+
export function createPcmBrowserAudio({ send, onPlayback = () => {}, onError = () => {} }) {
|
|
32
|
+
let context;
|
|
33
|
+
let stream;
|
|
34
|
+
let source;
|
|
35
|
+
let processor;
|
|
36
|
+
let mute;
|
|
37
|
+
let next = 0;
|
|
38
|
+
let first = 0;
|
|
39
|
+
let closed = true;
|
|
40
|
+
let generation = 0;
|
|
41
|
+
const players = new Set();
|
|
42
|
+
const drains = new Set();
|
|
43
|
+
const stopPlayback = () => {
|
|
44
|
+
generation++;
|
|
45
|
+
for (const player of players) { player.onended = null; player.stop(); player.disconnect(); }
|
|
46
|
+
players.clear();
|
|
47
|
+
next = context?.currentTime || 0;
|
|
48
|
+
for (const resolve of drains) resolve();
|
|
49
|
+
drains.clear();
|
|
50
|
+
onPlayback(false);
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
async start() {
|
|
54
|
+
const epoch = ++generation;
|
|
55
|
+
closed = false;
|
|
56
|
+
const AudioCtx = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
57
|
+
if (!AudioCtx || !globalThis.navigator?.mediaDevices?.getUserMedia) throw new Error("voice_unsupported");
|
|
58
|
+
const opened = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
|
59
|
+
if (closed || epoch !== generation) { opened.getTracks().forEach((track) => track.stop()); return; }
|
|
60
|
+
stream = opened;
|
|
61
|
+
for (const track of stream.getTracks()) track.addEventListener("ended", () => { if (!closed) onError(new Error("voice_device_lost")); }, { once: true });
|
|
62
|
+
context ??= new AudioCtx(); await context.resume();
|
|
63
|
+
source = context.createMediaStreamSource(stream);
|
|
64
|
+
processor = context.createScriptProcessor(2048, 1, 1);
|
|
65
|
+
mute = context.createGain(); mute.gain.value = 0;
|
|
66
|
+
const convert = createPcmResampler(context.sampleRate);
|
|
67
|
+
processor.onaudioprocess = (event) => {
|
|
68
|
+
if (closed || players.size) return;
|
|
69
|
+
send({ type: "input_audio_buffer.append", audio: base64(convert(event.inputBuffer.getChannelData(0))) });
|
|
70
|
+
};
|
|
71
|
+
source.connect(processor); processor.connect(mute); mute.connect(context.destination);
|
|
72
|
+
},
|
|
73
|
+
pauseInput() {
|
|
74
|
+
generation++;
|
|
75
|
+
if (processor) processor.onaudioprocess = null;
|
|
76
|
+
source?.disconnect(); processor?.disconnect(); mute?.disconnect();
|
|
77
|
+
source = null; processor = null; mute = null;
|
|
78
|
+
stream?.getTracks().forEach((track) => track.stop()); stream = null;
|
|
79
|
+
},
|
|
80
|
+
write(value) {
|
|
81
|
+
if (!context || closed) return;
|
|
82
|
+
const samples = pcmSamples(unbase64(value));
|
|
83
|
+
if (next - context.currentTime > 15) throw new Error("voice_audio_backpressure");
|
|
84
|
+
const buffer = context.createBuffer(1, samples.length, 24000); buffer.copyToChannel(samples, 0);
|
|
85
|
+
const player = context.createBufferSource(); player.buffer = buffer; player.connect(context.destination);
|
|
86
|
+
if (!players.size) { first = context.currentTime; onPlayback(true); }
|
|
87
|
+
players.add(player);
|
|
88
|
+
player.onended = () => {
|
|
89
|
+
players.delete(player); player.disconnect();
|
|
90
|
+
if (!players.size) {
|
|
91
|
+
for (const resolve of drains) resolve(); drains.clear(); onPlayback(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
next = Math.max(context.currentTime + 0.02, next); player.start(next); next += buffer.duration;
|
|
95
|
+
},
|
|
96
|
+
drain() { return players.size ? new Promise((resolve) => drains.add(resolve)) : Promise.resolve(); },
|
|
97
|
+
playedMs() { return context ? Math.max(0, Math.floor((Math.min(context.currentTime, next) - first) * 1000)) : 0; },
|
|
98
|
+
stopPlayback,
|
|
99
|
+
async stop() {
|
|
100
|
+
closed = true; stopPlayback();
|
|
101
|
+
if (processor) processor.onaudioprocess = null;
|
|
102
|
+
source?.disconnect(); processor?.disconnect(); mute?.disconnect();
|
|
103
|
+
stream?.getTracks().forEach((track) => track.stop()); stream = null;
|
|
104
|
+
await context?.close(); context = null;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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 }) {
|
|
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 });
|
|
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 resume() {
|
|
115
|
+
const state = await call("/session");
|
|
116
|
+
if (!state.active) await call("/session", { sessionId: boundSessionId, readAloud });
|
|
117
|
+
else await call("/session/resume", {});
|
|
118
|
+
await audio?.stop();
|
|
119
|
+
await connect(); await controller.resume(); void watch();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|