@olares/lares-core 0.15.11 → 0.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/i18n/voice.js +180 -10
- package/larepass/runtime.js +2 -0
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +7 -0
- package/package.json +28 -2
- package/router/tts.js +1 -1
- package/tools/http.js +3 -2
- package/voice/agent-session.js +134 -0
- package/voice/agent.js +9 -1
- package/voice/browser-capture.js +274 -0
- package/voice/browser-playback.js +30 -0
- package/voice/capture.js +12 -2
- package/voice/client.js +29 -14
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +28 -18
- package/voice/controller.js +154 -0
- package/voice/conversation.js +135 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +99 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +147 -59
- package/voice/gpu-problem.js +47 -0
- package/voice/host/bluetooth-ready.py +2 -0
- package/voice/host-input.js +38 -0
- package/voice/host-service.js +216 -0
- package/voice/input.js +2 -1
- package/voice/install.js +9 -5
- package/voice/languages.js +1 -1
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/panel.js +88 -0
- package/voice/projection.js +67 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +126 -0
- package/voice/realtime-gateway.js +168 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +96 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +33 -12
- package/voice/service.js +185 -38
- package/voice/session.js +11 -1
- package/voice/speech.js +9 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatElapsed,
|
|
3
|
+
isRecordingTooShort,
|
|
4
|
+
pickRecordingMime,
|
|
5
|
+
} from "./recorder.js";
|
|
6
|
+
|
|
7
|
+
export { formatElapsed };
|
|
8
|
+
|
|
9
|
+
/** Phone mic. Bare booleans are exact and iOS rejects the headset profile. */
|
|
10
|
+
const DEVICE_AUDIO = {
|
|
11
|
+
channelCount: { ideal: 1 },
|
|
12
|
+
echoCancellation: { ideal: true },
|
|
13
|
+
noiseSuppression: { ideal: true },
|
|
14
|
+
autoGainControl: { ideal: true },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CHUNK_MS = 250;
|
|
18
|
+
|
|
19
|
+
function micFailure(err) {
|
|
20
|
+
const name = err?.name || "";
|
|
21
|
+
if (name === "NotAllowedError" || name === "PermissionDeniedError") return "voice_permission_denied";
|
|
22
|
+
if (name === "SecurityError") return "voice_unsupported";
|
|
23
|
+
return "voice_failed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function openDeviceMic(mediaDevices) {
|
|
27
|
+
try {
|
|
28
|
+
return await mediaDevices.getUserMedia({ audio: DEVICE_AUDIO });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (micFailure(err) !== "voice_failed") throw err;
|
|
31
|
+
return mediaDevices.getUserMedia({ audio: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createVoiceCapture({
|
|
36
|
+
transcribe,
|
|
37
|
+
submit,
|
|
38
|
+
onStream,
|
|
39
|
+
onPhase,
|
|
40
|
+
onTrace,
|
|
41
|
+
now = Date.now,
|
|
42
|
+
media,
|
|
43
|
+
}) {
|
|
44
|
+
const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
45
|
+
const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
|
|
46
|
+
let recorder = null;
|
|
47
|
+
let detector = null;
|
|
48
|
+
let stream = null;
|
|
49
|
+
let chunks = [];
|
|
50
|
+
let started = 0;
|
|
51
|
+
let timer = 0;
|
|
52
|
+
let errorTimer = 0;
|
|
53
|
+
let finishTimer = 0;
|
|
54
|
+
let cancel = false;
|
|
55
|
+
let released = false;
|
|
56
|
+
let finished = false;
|
|
57
|
+
let dead = false;
|
|
58
|
+
let abort = null;
|
|
59
|
+
let phase = "idle";
|
|
60
|
+
|
|
61
|
+
const emit = (next, extra = {}) => {
|
|
62
|
+
phase = next;
|
|
63
|
+
onPhase?.(next, extra);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const trace = (event, fields) => {
|
|
67
|
+
try {
|
|
68
|
+
onTrace?.(event, fields);
|
|
69
|
+
} catch {
|
|
70
|
+
/* a trace must not change the take */
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const stopTimer = () => {
|
|
75
|
+
if (timer) {
|
|
76
|
+
clearInterval(timer);
|
|
77
|
+
timer = 0;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const release = () => {
|
|
82
|
+
detector?.stop?.();
|
|
83
|
+
detector = null;
|
|
84
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
85
|
+
stream = null;
|
|
86
|
+
recorder = null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const fail = (code) => {
|
|
90
|
+
if (dead) return;
|
|
91
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
92
|
+
emit("error", { error: code, elapsed: 0 });
|
|
93
|
+
errorTimer = setTimeout(() => {
|
|
94
|
+
errorTimer = 0;
|
|
95
|
+
if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
|
|
96
|
+
}, 3200);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const finish = async () => {
|
|
100
|
+
if (finished || dead) return;
|
|
101
|
+
finished = true;
|
|
102
|
+
stopTimer();
|
|
103
|
+
const took = now() - started;
|
|
104
|
+
const type = recorder?.mimeType || chunks[0]?.type || "audio/webm";
|
|
105
|
+
const blobChunks = chunks;
|
|
106
|
+
chunks = [];
|
|
107
|
+
release();
|
|
108
|
+
if (cancel) {
|
|
109
|
+
emit("idle", { error: "", elapsed: 0 });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const blob = new Blob(blobChunks, { type });
|
|
113
|
+
trace("blob", { bytes: blob.size, type, ms: took, chunks: blobChunks.length });
|
|
114
|
+
if (isRecordingTooShort(took, blob.size)) {
|
|
115
|
+
trace("too-short", { bytes: blob.size, ms: took });
|
|
116
|
+
fail("voice_too_short");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
emit("transcribing", { elapsed: took, error: "" });
|
|
120
|
+
abort = new AbortController();
|
|
121
|
+
try {
|
|
122
|
+
const text = String(await transcribe(blob, abort.signal) ?? "").trim();
|
|
123
|
+
if (dead || cancel) return;
|
|
124
|
+
if (!text) {
|
|
125
|
+
trace("no-input", {});
|
|
126
|
+
fail("voice_no_input");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
trace("transcript", { chars: text.length });
|
|
130
|
+
emit("idle", { error: "", elapsed: 0 });
|
|
131
|
+
await submit?.(text);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (dead || abort.signal.aborted || cancel) return;
|
|
134
|
+
const code = err instanceof Error ? err.message : "voice_failed";
|
|
135
|
+
trace("transcribe-error", { code });
|
|
136
|
+
fail(code);
|
|
137
|
+
} finally {
|
|
138
|
+
abort = null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const scheduleFinish = () => {
|
|
143
|
+
if (finishTimer) clearTimeout(finishTimer);
|
|
144
|
+
finishTimer = setTimeout(() => {
|
|
145
|
+
finishTimer = 0;
|
|
146
|
+
void finish();
|
|
147
|
+
}, 30);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const endRecorder = () => {
|
|
151
|
+
if (!recorder || recorder.state === "inactive") {
|
|
152
|
+
scheduleFinish();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
recorder.requestData?.();
|
|
157
|
+
} catch {
|
|
158
|
+
/* the final dataavailable still arrives from stop() */
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
recorder.stop();
|
|
162
|
+
} catch {
|
|
163
|
+
scheduleFinish();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
get phase() {
|
|
169
|
+
return phase;
|
|
170
|
+
},
|
|
171
|
+
async start() {
|
|
172
|
+
if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
|
|
173
|
+
if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
|
|
174
|
+
fail("voice_unsupported");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
cancel = false;
|
|
178
|
+
released = false;
|
|
179
|
+
finished = false;
|
|
180
|
+
trace("open", {});
|
|
181
|
+
emit("preparing", { elapsed: 0, error: "" });
|
|
182
|
+
let next;
|
|
183
|
+
try {
|
|
184
|
+
next = await openDeviceMic(mediaDevices);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const code = micFailure(err);
|
|
187
|
+
trace("mic-fail", { code, name: err?.name || "" });
|
|
188
|
+
if (!dead) fail(code);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (dead || cancel) {
|
|
192
|
+
next.getTracks().forEach((track) => track.stop());
|
|
193
|
+
if (!dead && phase === "preparing") emit("idle", { error: "", elapsed: 0 });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
stream = next;
|
|
197
|
+
chunks = [];
|
|
198
|
+
const mime = pickRecordingMime(Recorder);
|
|
199
|
+
try {
|
|
200
|
+
recorder = new Recorder(stream, mime ? { mimeType: mime } : undefined);
|
|
201
|
+
} catch {
|
|
202
|
+
release();
|
|
203
|
+
fail("voice_unsupported");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
recorder.ondataavailable = (event) => {
|
|
207
|
+
if (event.data?.size) chunks.push(event.data);
|
|
208
|
+
if (recorder?.state === "inactive") scheduleFinish();
|
|
209
|
+
};
|
|
210
|
+
recorder.onstop = () => scheduleFinish();
|
|
211
|
+
try { detector = await onStream?.(stream); } catch { release(); fail("voice_unsupported"); return; }
|
|
212
|
+
started = now();
|
|
213
|
+
try {
|
|
214
|
+
recorder.start(CHUNK_MS);
|
|
215
|
+
} catch {
|
|
216
|
+
trace("record-fail", { mime });
|
|
217
|
+
release();
|
|
218
|
+
fail("voice_unsupported");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
trace("recording", { mime: recorder.mimeType || mime || "" });
|
|
222
|
+
if (released || cancel) {
|
|
223
|
+
endRecorder();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
emit("recording", { elapsed: 0, error: "" });
|
|
227
|
+
timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
|
|
228
|
+
},
|
|
229
|
+
/**
|
|
230
|
+
* Finger-up finishes the take. The mic opens asynchronously, so the finger
|
|
231
|
+
* often lifts while still preparing — that is not a cancel.
|
|
232
|
+
*/
|
|
233
|
+
stop(aborting = false) {
|
|
234
|
+
if (phase === "transcribing") {
|
|
235
|
+
if (aborting) {
|
|
236
|
+
cancel = true;
|
|
237
|
+
abort?.abort();
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (phase !== "preparing" && phase !== "recording") {
|
|
242
|
+
trace("stop-ignored", { aborting: Boolean(aborting), phase });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
trace("stop", {
|
|
246
|
+
aborting: Boolean(aborting),
|
|
247
|
+
phase,
|
|
248
|
+
recorder: recorder?.state || "none",
|
|
249
|
+
});
|
|
250
|
+
if (aborting) {
|
|
251
|
+
cancel = true;
|
|
252
|
+
released = false;
|
|
253
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
released = true;
|
|
257
|
+
stopTimer();
|
|
258
|
+
if (recorder && recorder.state !== "inactive") {
|
|
259
|
+
emit("transcribing", { elapsed: now() - started, error: "" });
|
|
260
|
+
endRecorder();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
dispose() {
|
|
264
|
+
dead = true;
|
|
265
|
+
cancel = true;
|
|
266
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
267
|
+
if (finishTimer) clearTimeout(finishTimer);
|
|
268
|
+
stopTimer();
|
|
269
|
+
abort?.abort();
|
|
270
|
+
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
271
|
+
release();
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function createBrowserPlayer() {
|
|
2
|
+
let element = null;
|
|
3
|
+
let finish = null;
|
|
4
|
+
const stop = () => {
|
|
5
|
+
element?.pause();
|
|
6
|
+
finish?.();
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
play(blob) {
|
|
10
|
+
stop();
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const url = URL.createObjectURL(blob);
|
|
13
|
+
const audio = new Audio(url);
|
|
14
|
+
const done = () => {
|
|
15
|
+
if (finish !== done) return;
|
|
16
|
+
finish = null;
|
|
17
|
+
element = null;
|
|
18
|
+
URL.revokeObjectURL(url);
|
|
19
|
+
resolve();
|
|
20
|
+
};
|
|
21
|
+
element = audio;
|
|
22
|
+
finish = done;
|
|
23
|
+
audio.addEventListener("ended", done, { once: true });
|
|
24
|
+
audio.addEventListener("error", done, { once: true });
|
|
25
|
+
audio.play().catch(done);
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
stop,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/voice/capture.js
CHANGED
|
@@ -7,6 +7,8 @@ const FRAME_MS = 32;
|
|
|
7
7
|
const NO_INPUT_MS = 10_000;
|
|
8
8
|
const POLL_MS = 40;
|
|
9
9
|
const CLIENT_LOST_MS = 5000;
|
|
10
|
+
const MAX_CAPTURE_MS = 60_000;
|
|
11
|
+
const MAX_CAPTURE_BYTES = 16_000 * 2 * MAX_CAPTURE_MS / 1000;
|
|
10
12
|
const HOST_RATE = 16_000;
|
|
11
13
|
const PLAYBACK_SETTLE_MS = 320;
|
|
12
14
|
const PLAYBACK_LEADING_MS = 2500;
|
|
@@ -125,7 +127,13 @@ export function createCaptureSession(io) {
|
|
|
125
127
|
controller: new AbortController(),
|
|
126
128
|
};
|
|
127
129
|
job = run;
|
|
128
|
-
|
|
130
|
+
let opened;
|
|
131
|
+
try { opened = await io.open(); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
run.phase = "error";
|
|
134
|
+
run.error = error?.code || "voice_failed";
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
129
137
|
if (run.cancel) {
|
|
130
138
|
await io.close?.();
|
|
131
139
|
run.phase = "idle";
|
|
@@ -161,7 +169,9 @@ export function createCaptureSession(io) {
|
|
|
161
169
|
try {
|
|
162
170
|
while (run === job && run.phase !== "done" && run.phase !== "error" && run.phase !== "idle") {
|
|
163
171
|
if (run.phase === "recording" && now() - run.seen > CLIENT_LOST_MS) run.cancel = true;
|
|
164
|
-
const
|
|
172
|
+
const available = await io.read(offset);
|
|
173
|
+
const fresh = available.subarray(0, Math.max(0, MAX_CAPTURE_BYTES - pcm.length));
|
|
174
|
+
if (fresh.length < available.length || now() - started >= MAX_CAPTURE_MS) run.stop = true;
|
|
165
175
|
offset += fresh.length;
|
|
166
176
|
if (fresh.length) {
|
|
167
177
|
pcm = Buffer.concat([pcm, fresh]);
|
package/voice/client.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { mintRpcId } from "../larepass/rpc.js";
|
|
1
2
|
import { createSnapshotStore } from "../tools/async.js";
|
|
2
3
|
|
|
3
4
|
export const API = "/api/lares/voice";
|
|
4
5
|
|
|
5
6
|
const settings = createSnapshotStore();
|
|
7
|
+
export const voiceClientId = mintRpcId();
|
|
8
|
+
export function voiceHeaders(device) {
|
|
9
|
+
const selected = device ?? settings.peek()?.config?.device;
|
|
10
|
+
return { "x-lares-voice-id": voiceClientId, "x-lares-voice-device": selected === "device" ? "device" : "headset" };
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
export function rememberedVoiceSettings() {
|
|
8
14
|
return settings.peek();
|
|
@@ -21,6 +27,9 @@ export async function loadVoiceSettings(options = {}) {
|
|
|
21
27
|
status,
|
|
22
28
|
sttModels: Array.isArray(models.stt) ? models.stt : [],
|
|
23
29
|
ttsModels: Array.isArray(models.tts) ? models.tts : [],
|
|
30
|
+
chatModels: Array.isArray(models.chat) ? models.chat : [],
|
|
31
|
+
realtimeModels: Array.isArray(models.realtime) ? models.realtime : [],
|
|
32
|
+
realtimeReady: Array.isArray(models.realtimeReady) ? models.realtimeReady : [],
|
|
24
33
|
charts: {
|
|
25
34
|
stt: chartView(models.charts?.stt),
|
|
26
35
|
tts: chartView(models.charts?.tts),
|
|
@@ -67,7 +76,7 @@ export function wavBlob(base64) {
|
|
|
67
76
|
export async function postSpeak({ key, text, done, part }) {
|
|
68
77
|
const res = await fetch(`${API}/speak`, {
|
|
69
78
|
method: "POST",
|
|
70
|
-
headers: { "content-type": "application/json" },
|
|
79
|
+
headers: { "content-type": "application/json", ...voiceHeaders() },
|
|
71
80
|
body: JSON.stringify(Number.isInteger(part) ? { key, text, done, part } : { key, text, done }),
|
|
72
81
|
});
|
|
73
82
|
let body = null;
|
|
@@ -88,7 +97,7 @@ export async function postSpeak({ key, text, done, part }) {
|
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export async function postPlaybackStop(signal) {
|
|
91
|
-
const res = await fetch(`${API}/playback/stop`, { method: "POST", signal });
|
|
100
|
+
const res = await fetch(`${API}/playback/stop`, { method: "POST", signal, headers: voiceHeaders() });
|
|
92
101
|
if (!res.ok) throw new Error(String(res.status));
|
|
93
102
|
return res.json();
|
|
94
103
|
}
|
|
@@ -110,14 +119,24 @@ export async function loadGpuMode() {
|
|
|
110
119
|
return res.json();
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
/** A failed GPU request keeps the Host's code and params for gpuProblemText. */
|
|
123
|
+
async function gpuResponse(res) {
|
|
124
|
+
const payload = await res.json().catch(() => null);
|
|
125
|
+
if (res.ok) return payload;
|
|
126
|
+
const error = payload?.error;
|
|
127
|
+
throw Object.assign(new Error(error?.message || String(res.status)), {
|
|
128
|
+
code: typeof error?.code === "string" ? error.code : "voice_gpu_failed",
|
|
129
|
+
params: error?.params && typeof error.params === "object" ? error.params : {},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
export async function switchGpuMode(streaming) {
|
|
114
134
|
const res = await fetch(`${API}/gpu-mode`, {
|
|
115
135
|
method: "POST",
|
|
116
136
|
headers: { "content-type": "application/json" },
|
|
117
137
|
body: JSON.stringify({ streaming }),
|
|
118
138
|
});
|
|
119
|
-
|
|
120
|
-
return res.json();
|
|
139
|
+
return gpuResponse(res);
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
/** @param {{ app: string, enabled: boolean, memory: number }[]} apps memory in bytes */
|
|
@@ -127,9 +146,7 @@ export async function saveSlicePlan(apps) {
|
|
|
127
146
|
headers: { "content-type": "application/json" },
|
|
128
147
|
body: JSON.stringify({ apps }),
|
|
129
148
|
});
|
|
130
|
-
|
|
131
|
-
if (!res.ok) throw Object.assign(new Error(payload?.error?.message || String(res.status)), { code: payload?.error?.code });
|
|
132
|
-
return payload;
|
|
149
|
+
return gpuResponse(res);
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
/** @param {string} app @param {"resume" | "stop"} op */
|
|
@@ -139,9 +156,7 @@ export async function postGpuApp(app, op) {
|
|
|
139
156
|
headers: { "content-type": "application/json" },
|
|
140
157
|
body: JSON.stringify({ app, op }),
|
|
141
158
|
});
|
|
142
|
-
|
|
143
|
-
if (!res.ok) throw new Error(payload?.error?.message || String(res.status));
|
|
144
|
-
return payload;
|
|
159
|
+
return gpuResponse(res);
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
function transcribeErrorCode(payload) {
|
|
@@ -187,19 +202,19 @@ async function readVoiceJson(res) {
|
|
|
187
202
|
|
|
188
203
|
/** Host recording. `skipped` means the headset microphone is not available. */
|
|
189
204
|
export async function postCaptureStart(signal) {
|
|
190
|
-
return readVoiceJson(await fetch(`${API}/capture`, { method: "POST", signal }));
|
|
205
|
+
return readVoiceJson(await fetch(`${API}/capture`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
191
206
|
}
|
|
192
207
|
|
|
193
208
|
export async function getCapture(signal) {
|
|
194
|
-
return readVoiceJson(await fetch(`${API}/capture`, { signal }));
|
|
209
|
+
return readVoiceJson(await fetch(`${API}/capture`, { signal, headers: voiceHeaders() }));
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
export async function postCaptureStop(signal) {
|
|
198
|
-
return readVoiceJson(await fetch(`${API}/capture/stop`, { method: "POST", signal }));
|
|
213
|
+
return readVoiceJson(await fetch(`${API}/capture/stop`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
199
214
|
}
|
|
200
215
|
|
|
201
216
|
export async function postCaptureCancel(signal) {
|
|
202
|
-
return readVoiceJson(await fetch(`${API}/capture/cancel`, { method: "POST", signal }));
|
|
217
|
+
return readVoiceJson(await fetch(`${API}/capture/cancel`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
export async function postBluetoothReady(signal) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { verifyVoicePrincipal, authorizeVoicePreset } from "./external.js";
|
|
4
|
+
import { createSpeechEndpoint, frameRms } from "./endpoint.js";
|
|
5
|
+
import { pcmSamples } from "./realtime-audio.js";
|
|
6
|
+
|
|
7
|
+
export const COMPOSITE_CAPABILITIES = Object.freeze({ protocol: "olares-voice-ws-v1", transport: "websocket", input: "pcm16-mono-24000", output: "pcm16-mono-24000", turnDetection: ["manual", "server_vad"], contextResume: true, requiresPlaybackAck: true, clientTools: false, truncation: false, webrtc: false, sip: false, usage: "child-models" });
|
|
8
|
+
|
|
9
|
+
/** Minimal declared Realtime subset. It never accepts arbitrary client tools or business bindings. */
|
|
10
|
+
export function createCompositeGateway({ service, config, transcribe, synthesize, secret, grants = {}, hostOwner = "", enabled = false }) {
|
|
11
|
+
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false });
|
|
12
|
+
const clients = new Map();
|
|
13
|
+
const nonces = new Map();
|
|
14
|
+
return {
|
|
15
|
+
capabilities: () => ({ ...COMPOSITE_CAPABILITIES, enabled: enabled && typeof secret === "string" && secret.length >= 32 }),
|
|
16
|
+
upgrade(req, socket, head) {
|
|
17
|
+
let identity;
|
|
18
|
+
let grant;
|
|
19
|
+
try {
|
|
20
|
+
if (!enabled) throw new Error("voice_external_disabled");
|
|
21
|
+
identity = verifyVoicePrincipal(req.headers, secret);
|
|
22
|
+
const model = new URL(req.url, "http://voice.invalid").searchParams.get("model");
|
|
23
|
+
grant = authorizeVoicePreset(identity, grants, model);
|
|
24
|
+
if (["assistant", "direct"].includes(grant.preset) && grant.owner !== hostOwner) throw new Error("voice_external_owner_mismatch");
|
|
25
|
+
const dependencies = config();
|
|
26
|
+
if ([dependencies.model, dependencies.chatModel, dependencies.ttsModel, dependencies.realtimeModel].some((id) => id === model || id?.endsWith(`/${model}`))) throw new Error("voice_recursive_model");
|
|
27
|
+
for (const [nonce, expires] of nonces) if (expires < Date.now()) nonces.delete(nonce);
|
|
28
|
+
if (nonces.has(identity.nonce) || nonces.size >= 4096 || clients.size >= 64) throw new Error("voice_external_limit");
|
|
29
|
+
nonces.set(identity.nonce, Date.now() + 120000);
|
|
30
|
+
} catch {
|
|
31
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
|
|
32
|
+
}
|
|
33
|
+
server.handleUpgrade(req, socket, head, (client) => {
|
|
34
|
+
clients.set(client, () => close());
|
|
35
|
+
const scope = { id: randomUUID(), owner: grant.owner, authorizedBinding: grant, device: "device" };
|
|
36
|
+
let chunks = [], bytes = 0, automatic = false, closed = false, busy = false, generation = 0, seconds = 0;
|
|
37
|
+
let endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 });
|
|
38
|
+
let lastReply = null;
|
|
39
|
+
let awaitingPlayback = false;
|
|
40
|
+
let audioOutput = true;
|
|
41
|
+
let responseId = null;
|
|
42
|
+
let speaking = false;
|
|
43
|
+
let configuring = false;
|
|
44
|
+
let submittedTurns = 0;
|
|
45
|
+
let alive = true;
|
|
46
|
+
const send = (event) => {
|
|
47
|
+
if (client.readyState !== 1) return;
|
|
48
|
+
if (client.bufferedAmount > 1024 * 1024) { close(); return; }
|
|
49
|
+
client.send(JSON.stringify({ event_id: randomUUID(), ...event }));
|
|
50
|
+
};
|
|
51
|
+
const reset = () => { chunks = []; bytes = 0; speaking = false; endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 }); };
|
|
52
|
+
const done = (id, status = "completed") => send({ type: "response.done", response: { id, status, output: [] }, voice_usage: { input_audio_seconds: seconds, billing: "child-models" } });
|
|
53
|
+
const emit = async (text, id, epoch) => {
|
|
54
|
+
responseId = id;
|
|
55
|
+
send({ type: "response.created", response: { id, status: "in_progress" } });
|
|
56
|
+
send({ type: "response.output_audio_transcript.delta", response_id: id, delta: text });
|
|
57
|
+
if (!audioOutput) { done(id); return; }
|
|
58
|
+
scope.outputAbort = new AbortController();
|
|
59
|
+
const audio = await synthesize(text, scope);
|
|
60
|
+
if (closed || epoch !== generation) return;
|
|
61
|
+
awaitingPlayback = true;
|
|
62
|
+
for (let offset = 0; offset < audio.length; offset += 24000) send({ type: "response.output_audio.delta", response_id: id, item_id: id, delta: audio.subarray(offset, offset + 24000).toString("base64") });
|
|
63
|
+
send({ type: "response.output_audio.done", response_id: id });
|
|
64
|
+
done(id);
|
|
65
|
+
};
|
|
66
|
+
const commit = async () => {
|
|
67
|
+
if (!bytes || busy) return;
|
|
68
|
+
const input = Buffer.concat(chunks); reset(); busy = true;
|
|
69
|
+
const epoch = generation;
|
|
70
|
+
const id = randomUUID(); submittedTurns++;
|
|
71
|
+
try {
|
|
72
|
+
let text = await transcribe(input, scope);
|
|
73
|
+
if (grant.preset === "dictation") text = await service.clean(scope, text);
|
|
74
|
+
if (closed || epoch !== generation || !text.trim()) return;
|
|
75
|
+
send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: text });
|
|
76
|
+
if (grant.preset === "dictation") return;
|
|
77
|
+
const state = await service.prompt(scope, { text, requestId: id });
|
|
78
|
+
if (closed || epoch !== generation) return;
|
|
79
|
+
if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; await emit(state.utterance.text, id, epoch); }
|
|
80
|
+
} catch (cause) { if (!closed && epoch === generation) send({ type: "error", error: { code: cause.code || "voice_failed", message: "Voice operation failed" } }); }
|
|
81
|
+
finally { busy = false; }
|
|
82
|
+
};
|
|
83
|
+
const close = () => {
|
|
84
|
+
if (closed) return;
|
|
85
|
+
closed = true; scope.outputAbort?.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
|
|
86
|
+
clients.delete(client); client.close(); void service.close(scope).catch(() => {});
|
|
87
|
+
};
|
|
88
|
+
let idle;
|
|
89
|
+
const touch = () => { clearTimeout(idle); idle = setTimeout(close, config().idleTimeoutMs || 300000); };
|
|
90
|
+
touch();
|
|
91
|
+
const heartbeat = setInterval(() => { if (!alive) close(); else { alive = false; client.ping(); } }, 15000);
|
|
92
|
+
const results = setInterval(() => {
|
|
93
|
+
if (!scope.active || busy || speaking || awaitingPlayback) return;
|
|
94
|
+
try {
|
|
95
|
+
const state = service.state(scope);
|
|
96
|
+
if (!state.utterance?.text || state.utterance.key === lastReply || state.utterance.silent) return;
|
|
97
|
+
lastReply = state.utterance.key; busy = true;
|
|
98
|
+
void emit(state.utterance.text, randomUUID(), generation).catch(() => close()).finally(() => { busy = false; });
|
|
99
|
+
} catch { close(); }
|
|
100
|
+
}, 250);
|
|
101
|
+
client.on("pong", () => { alive = true; });
|
|
102
|
+
client.on("close", close); client.on("error", close);
|
|
103
|
+
void service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, readAloud: false }).then(() => {
|
|
104
|
+
send({ type: "session.created", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
|
|
105
|
+
client.on("message", async (raw) => {
|
|
106
|
+
try {
|
|
107
|
+
if (configuring) throw new Error("voice_not_ready");
|
|
108
|
+
const event = JSON.parse(raw.toString());
|
|
109
|
+
if (event.type === "session.update") {
|
|
110
|
+
touch();
|
|
111
|
+
const session = event.session || {};
|
|
112
|
+
if (session.type && session.type !== "realtime") throw new Error("voice_session_forbidden");
|
|
113
|
+
if (session.audio?.input && Object.keys(session.audio.input).some((key) => !["format", "turn_detection"].includes(key))) throw new Error("voice_audio_unsupported");
|
|
114
|
+
if (session.audio?.output && Object.keys(session.audio.output).some((key) => key !== "format")) throw new Error("voice_audio_unsupported");
|
|
115
|
+
if (Object.keys(session).some((key) => !["type", "model", "audio", "output_modalities", "voice_context_id"].includes(key)) || (session.model && session.model !== identity.model)) throw new Error("voice_session_forbidden");
|
|
116
|
+
if (session.voice_context_id && session.voice_context_id !== scope.contextId) {
|
|
117
|
+
if (submittedTurns || bytes || busy) throw new Error("voice_context_locked");
|
|
118
|
+
configuring = true;
|
|
119
|
+
try {
|
|
120
|
+
await service.close(scope);
|
|
121
|
+
await service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, contextId: session.voice_context_id, readAloud: false });
|
|
122
|
+
} finally { configuring = false; }
|
|
123
|
+
}
|
|
124
|
+
const detection = session.audio?.input?.turn_detection;
|
|
125
|
+
if (detection && (detection.type !== "server_vad" || Object.keys(detection).some((key) => key !== "type"))) throw new Error("voice_turn_detection_unsupported");
|
|
126
|
+
for (const format of [session.audio?.input?.format, session.audio?.output?.format].filter(Boolean)) if (format.type !== "audio/pcm" || format.rate !== 24000) throw new Error("voice_audio_format");
|
|
127
|
+
if (session.output_modalities && (!Array.isArray(session.output_modalities) || session.output_modalities.length !== 1 || !["audio", "text"].includes(session.output_modalities[0]))) throw new Error("voice_output_unsupported");
|
|
128
|
+
audioOutput = !session.output_modalities || session.output_modalities[0] === "audio";
|
|
129
|
+
automatic = detection?.type === "server_vad";
|
|
130
|
+
send({ type: "session.updated", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
|
|
131
|
+
} else if (event.type === "input_audio_buffer.append") {
|
|
132
|
+
if (awaitingPlayback) throw new Error("voice_waiting_for_playback");
|
|
133
|
+
if (busy) throw new Error("voice_turn_busy");
|
|
134
|
+
if (typeof event.audio !== "string" || event.audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(event.audio)) throw new Error("voice_audio_format");
|
|
135
|
+
const chunk = Buffer.from(event.audio, "base64");
|
|
136
|
+
if (chunk.length % 2 || bytes + chunk.length > 2880000) throw new Error("voice_audio_limit");
|
|
137
|
+
chunks.push(chunk); bytes += chunk.length; seconds += chunk.length / 48000;
|
|
138
|
+
const samples = pcmSamples(chunk);
|
|
139
|
+
if (frameRms(samples) >= 0.004) touch();
|
|
140
|
+
if (frameRms(samples) >= 0.004 && !speaking) { speaking = true; scope.voiceInputActive = true; send({ type: "input_audio_buffer.speech_started" }); }
|
|
141
|
+
if (automatic && endpoint.push(1, frameRms(samples), chunk.length / 48)) {
|
|
142
|
+
scope.voiceInputActive = false; send({ type: "input_audio_buffer.speech_stopped" }); void commit();
|
|
143
|
+
}
|
|
144
|
+
} else if (event.type === "input_audio_buffer.commit") { scope.voiceInputActive = false; void commit(); }
|
|
145
|
+
else if (event.type === "input_audio_buffer.clear") { reset(); scope.voiceInputActive = false; send({ type: "input_audio_buffer.cleared" }); }
|
|
146
|
+
else if (event.type === "voice.playback.completed") { awaitingPlayback = false; }
|
|
147
|
+
else if (event.type === "response.cancel") { scope.outputAbort?.abort(); generation++; reset(); awaitingPlayback = false; done(responseId || randomUUID(), "cancelled"); }
|
|
148
|
+
else throw new Error("voice_event_unsupported");
|
|
149
|
+
} catch (cause) { send({ type: "error", error: { code: cause.message, message: "Unsupported or invalid voice event" } }); }
|
|
150
|
+
});
|
|
151
|
+
}).catch(close);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
close() { for (const close of clients.values()) close(); server.close(); },
|
|
155
|
+
};
|
|
156
|
+
}
|