@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,133 @@
|
|
|
1
|
+
import { mintRpcId } from "../larepass/rpc.js";
|
|
2
|
+
import { createSessionFollow } from "../larepass/mux.js";
|
|
3
|
+
import { reduceUtterance } from "./speech.js";
|
|
4
|
+
|
|
5
|
+
export const SPOKEN_PROMPT = "This is a voice conversation. Keep user-facing answers concise and natural for speech. Do not read reasoning or tool payloads aloud. Preserve the task's factual results and use the user's language.";
|
|
6
|
+
|
|
7
|
+
/** dsh opens a turn before claiming input; rpcId associates input with that open turn. */
|
|
8
|
+
export function createVoiceAgentSession(api, { sessionId = null, preset, workspaceId, restoredRequests = [], metric = () => {} } = {}) {
|
|
9
|
+
let creating;
|
|
10
|
+
let following;
|
|
11
|
+
let closed = false;
|
|
12
|
+
let utterance = null;
|
|
13
|
+
let error = null;
|
|
14
|
+
let version = 0;
|
|
15
|
+
let cursor = -1;
|
|
16
|
+
let openTurn = null;
|
|
17
|
+
let latestRequest = null;
|
|
18
|
+
let followTask = null;
|
|
19
|
+
let ready = null;
|
|
20
|
+
let readyTimer;
|
|
21
|
+
let observedRunning = false;
|
|
22
|
+
const requests = new Map();
|
|
23
|
+
for (const restored of restoredRequests.slice(-256)) {
|
|
24
|
+
requests.set(restored.requestId, { requestId: restored.requestId, turn: null, status: "unknown", utterance: null, error: null, at: Date.now(), firstTextAt: null });
|
|
25
|
+
}
|
|
26
|
+
const decoder = createSessionFollow();
|
|
27
|
+
const unfinished = (task) => ["accepted", "running", "waiting", "unknown"].includes(task.status);
|
|
28
|
+
const snapshot = () => ({ sessionId, running: observedRunning || [...requests.values()].some(unfinished), utterance, error, version,
|
|
29
|
+
requestId: latestRequest,
|
|
30
|
+
tasks: [...requests.values()].map(({ requestId, turn, status, utterance: reply, error: taskError }) => ({ requestId, turn, status, text: reply?.silent ? "" : reply?.text || "", error: taskError })),
|
|
31
|
+
});
|
|
32
|
+
const ensure = async () => {
|
|
33
|
+
if (sessionId) return sessionId;
|
|
34
|
+
creating ??= Promise.resolve(api.create({ agentPreset: preset, workspaceId })).then((value) => {
|
|
35
|
+
if (!value?.sessionId) throw new Error("voice_session_failed");
|
|
36
|
+
sessionId = value.sessionId;
|
|
37
|
+
return sessionId;
|
|
38
|
+
});
|
|
39
|
+
return creating;
|
|
40
|
+
};
|
|
41
|
+
const publish = (event, replay = false) => {
|
|
42
|
+
if (!event || typeof event !== "object") return;
|
|
43
|
+
if (typeof event.seq === "number") {
|
|
44
|
+
if (event.seq <= cursor) return;
|
|
45
|
+
cursor = event.seq;
|
|
46
|
+
}
|
|
47
|
+
if (event.type === "turn/start") { openTurn = event.data.turn; observedRunning = true; }
|
|
48
|
+
if (event.type === "user/message") {
|
|
49
|
+
const id = event.data?.requestId ?? event.data?.source?.rpcId;
|
|
50
|
+
const task = requests.get(id);
|
|
51
|
+
if (task && openTurn !== null) { task.turn = openTurn; task.status = "running"; }
|
|
52
|
+
}
|
|
53
|
+
const turn = event.data?.turn ?? openTurn;
|
|
54
|
+
const owned = [...requests.values()].filter((task) => task.turn != null && task.turn === turn && unfinished(task));
|
|
55
|
+
for (const task of owned) {
|
|
56
|
+
task.utterance = reduceUtterance(task.utterance, event);
|
|
57
|
+
if (event.type === "approval/asked") task.status = "waiting";
|
|
58
|
+
if (event.type === "approval/decided") task.status = "running";
|
|
59
|
+
if (event.type === "turn/end") {
|
|
60
|
+
const reason = event.data?.reason?.kind;
|
|
61
|
+
task.status = reason === "error" ? "failed" : reason === "cancelled" ? "cancelled" : "completed";
|
|
62
|
+
task.error = reason === "error" ? "voice_agent_failed" : reason === "cancelled" ? "voice_cancelled" : null;
|
|
63
|
+
}
|
|
64
|
+
if (!replay && task.utterance?.text && !task.firstTextAt) {
|
|
65
|
+
task.firstTextAt = Date.now();
|
|
66
|
+
metric({ stage: "agent_first_text", requestId: task.requestId, durationMs: task.firstTextAt - task.at });
|
|
67
|
+
}
|
|
68
|
+
if (!replay && task.requestId === latestRequest) { utterance = task.utterance; error = task.error; }
|
|
69
|
+
}
|
|
70
|
+
if (event.type === "turn/end" && openTurn === event.data?.turn) { openTurn = null; observedRunning = false; }
|
|
71
|
+
version++;
|
|
72
|
+
};
|
|
73
|
+
const follow = () => {
|
|
74
|
+
if (following && !following.signal.aborted) return ready;
|
|
75
|
+
const abort = new AbortController();
|
|
76
|
+
following = abort;
|
|
77
|
+
let resolveReady;
|
|
78
|
+
let rejectReady;
|
|
79
|
+
ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
|
80
|
+
readyTimer = setTimeout(() => { rejectReady(new Error("voice_agent_timeout")); abort.abort(); }, 15_000);
|
|
81
|
+
ready.catch(() => {});
|
|
82
|
+
abort.signal.addEventListener("abort", () => rejectReady(new Error("voice_session_closed")), { once: true });
|
|
83
|
+
followTask = (async () => {
|
|
84
|
+
try {
|
|
85
|
+
for await (const frame of api.follow({ address: { kind: "session", sessionId }, assistantStream: true }, abort.signal)) {
|
|
86
|
+
const decoded = decoder.push(frame);
|
|
87
|
+
if (frame.type === "snapshot") {
|
|
88
|
+
for (const event of decoded?.events ?? []) publish(event, true);
|
|
89
|
+
clearTimeout(readyTimer); resolveReady();
|
|
90
|
+
} else for (const event of decoded?.events ?? []) publish(event);
|
|
91
|
+
}
|
|
92
|
+
if (!abort.signal.aborted) throw new Error("voice_agent_disconnected");
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
clearTimeout(readyTimer); rejectReady(cause);
|
|
95
|
+
if (!abort.signal.aborted) { error = "voice_agent_disconnected"; version++; abort.abort(); }
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
followTask.catch(() => {});
|
|
99
|
+
return ready;
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
snapshot,
|
|
103
|
+
async open() { if (sessionId) await follow(); },
|
|
104
|
+
async resume() {
|
|
105
|
+
following?.abort(); await followTask;
|
|
106
|
+
closed = false; error = null; utterance = null; cursor = -1; openTurn = null; observedRunning = false; decoder.reset();
|
|
107
|
+
if (sessionId) await follow();
|
|
108
|
+
},
|
|
109
|
+
async submit(text, requestId = mintRpcId(), mode = "queue") {
|
|
110
|
+
if (closed) throw new Error("voice_session_closed");
|
|
111
|
+
if (!String(text).trim()) return snapshot();
|
|
112
|
+
if (requests.has(requestId)) return requests.get(requestId).operation || snapshot();
|
|
113
|
+
if (requests.size >= 256) throw new Error("voice_session_limit");
|
|
114
|
+
const task = { requestId, turn: null, status: "accepted", utterance: null, error: null, at: Date.now(), firstTextAt: null };
|
|
115
|
+
requests.set(requestId, task);
|
|
116
|
+
latestRequest = requestId; utterance = null; error = null; version++;
|
|
117
|
+
task.operation = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
await ensure();
|
|
120
|
+
await follow();
|
|
121
|
+
await api.prompt({ sessionId, requestId, mode: mode === "steer" ? "steer" : "queue", content: [{ type: "text", text }] }, following.signal);
|
|
122
|
+
return snapshot();
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
task.status = "unknown"; task.error = "submission_outcome_unknown"; version++;
|
|
125
|
+
throw cause;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return task.operation;
|
|
129
|
+
},
|
|
130
|
+
async cancel() { if (sessionId) await api.cancel({ sessionId }); },
|
|
131
|
+
async close() { closed = true; clearTimeout(readyTimer); following?.abort(); await followTask; },
|
|
132
|
+
};
|
|
133
|
+
}
|
package/voice/agent.js
CHANGED
|
@@ -29,7 +29,15 @@ export async function liveAgent(options = {}) {
|
|
|
29
29
|
return agentIsLive(info, now()) ? info : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
const requests = new Map();
|
|
33
|
+
export function askAgent(op, fields, options = {}) {
|
|
34
|
+
const key = voiceDir(options);
|
|
35
|
+
const result = (requests.get(key) ?? Promise.resolve()).catch(() => {}).then(() => performAgentRequest(op, fields, options));
|
|
36
|
+
requests.set(key, result);
|
|
37
|
+
void result.finally(() => { if (requests.get(key) === result) requests.delete(key); }).catch(() => {});
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
async function performAgentRequest(op, fields, options) {
|
|
33
41
|
const dir = voiceDir(options);
|
|
34
42
|
const now = options.now ?? Date.now;
|
|
35
43
|
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -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),
|
|
@@ -32,6 +41,7 @@ export async function loadVoiceSettings(options = {}) {
|
|
|
32
41
|
function chartView(row) {
|
|
33
42
|
const phase = row?.phase;
|
|
34
43
|
return {
|
|
44
|
+
app: typeof row?.app === "string" ? row.app : "",
|
|
35
45
|
phase: phase === "running" || phase === "stopped" || phase === "busy" || phase === "failed" ? phase : "absent",
|
|
36
46
|
state: typeof row?.state === "string" ? row.state : "",
|
|
37
47
|
};
|
|
@@ -62,11 +72,12 @@ export function wavBlob(base64) {
|
|
|
62
72
|
return new Blob([bytes], { type: "audio/wav" });
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
|
|
75
|
+
/** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
|
|
76
|
+
export async function postSpeak({ key, text, done, part }) {
|
|
66
77
|
const res = await fetch(`${API}/speak`, {
|
|
67
78
|
method: "POST",
|
|
68
|
-
headers: { "content-type": "application/json" },
|
|
69
|
-
body: JSON.stringify({ text }),
|
|
79
|
+
headers: { "content-type": "application/json", ...voiceHeaders() },
|
|
80
|
+
body: JSON.stringify(Number.isInteger(part) ? { key, text, done, part } : { key, text, done }),
|
|
70
81
|
});
|
|
71
82
|
let body = null;
|
|
72
83
|
try {
|
|
@@ -74,7 +85,11 @@ export async function postSpeak(text) {
|
|
|
74
85
|
} catch {
|
|
75
86
|
body = null;
|
|
76
87
|
}
|
|
77
|
-
if (
|
|
88
|
+
if (res.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
|
|
89
|
+
if (!res.ok || typeof body?.audio !== "string") {
|
|
90
|
+
const code = transcribeErrorCode(body);
|
|
91
|
+
throw Object.assign(new Error(code), { code });
|
|
92
|
+
}
|
|
78
93
|
return {
|
|
79
94
|
bluetooth: body.bluetooth === "played" ? "played" : "skipped",
|
|
80
95
|
blob: wavBlob(body.audio),
|
|
@@ -82,7 +97,7 @@ export async function postSpeak(text) {
|
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
export async function postPlaybackStop(signal) {
|
|
85
|
-
const res = await fetch(`${API}/playback/stop`, { method: "POST", signal });
|
|
100
|
+
const res = await fetch(`${API}/playback/stop`, { method: "POST", signal, headers: voiceHeaders() });
|
|
86
101
|
if (!res.ok) throw new Error(String(res.status));
|
|
87
102
|
return res.json();
|
|
88
103
|
}
|
|
@@ -97,6 +112,53 @@ export async function saveVoiceSettings(patch) {
|
|
|
97
112
|
return loadVoiceSettings({ force: true });
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
/** Reading mode and the GPU slicing it needs; see core/voice/gpu-mode.js. */
|
|
116
|
+
export async function loadGpuMode() {
|
|
117
|
+
const res = await fetch(`${API}/gpu-mode`);
|
|
118
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
119
|
+
return res.json();
|
|
120
|
+
}
|
|
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
|
+
|
|
133
|
+
export async function switchGpuMode(streaming) {
|
|
134
|
+
const res = await fetch(`${API}/gpu-mode`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: JSON.stringify({ streaming }),
|
|
138
|
+
});
|
|
139
|
+
return gpuResponse(res);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {{ app: string, enabled: boolean, memory: number }[]} apps memory in bytes */
|
|
143
|
+
export async function saveSlicePlan(apps) {
|
|
144
|
+
const res = await fetch(`${API}/gpu-mode/slices`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ apps }),
|
|
148
|
+
});
|
|
149
|
+
return gpuResponse(res);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {string} app @param {"resume" | "stop"} op */
|
|
153
|
+
export async function postGpuApp(app, op) {
|
|
154
|
+
const res = await fetch(`${API}/gpu-mode/app`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "content-type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ app, op }),
|
|
158
|
+
});
|
|
159
|
+
return gpuResponse(res);
|
|
160
|
+
}
|
|
161
|
+
|
|
100
162
|
function transcribeErrorCode(payload) {
|
|
101
163
|
const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
|
|
102
164
|
return typeof code === "string" ? code : "voice_failed";
|
|
@@ -140,19 +202,19 @@ async function readVoiceJson(res) {
|
|
|
140
202
|
|
|
141
203
|
/** Host recording. `skipped` means the headset microphone is not available. */
|
|
142
204
|
export async function postCaptureStart(signal) {
|
|
143
|
-
return readVoiceJson(await fetch(`${API}/capture`, { method: "POST", signal }));
|
|
205
|
+
return readVoiceJson(await fetch(`${API}/capture`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
144
206
|
}
|
|
145
207
|
|
|
146
208
|
export async function getCapture(signal) {
|
|
147
|
-
return readVoiceJson(await fetch(`${API}/capture`, { signal }));
|
|
209
|
+
return readVoiceJson(await fetch(`${API}/capture`, { signal, headers: voiceHeaders() }));
|
|
148
210
|
}
|
|
149
211
|
|
|
150
212
|
export async function postCaptureStop(signal) {
|
|
151
|
-
return readVoiceJson(await fetch(`${API}/capture/stop`, { method: "POST", signal }));
|
|
213
|
+
return readVoiceJson(await fetch(`${API}/capture/stop`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
152
214
|
}
|
|
153
215
|
|
|
154
216
|
export async function postCaptureCancel(signal) {
|
|
155
|
-
return readVoiceJson(await fetch(`${API}/capture/cancel`, { method: "POST", signal }));
|
|
217
|
+
return readVoiceJson(await fetch(`${API}/capture/cancel`, { method: "POST", signal, headers: voiceHeaders() }));
|
|
156
218
|
}
|
|
157
219
|
|
|
158
220
|
export async function postBluetoothReady(signal) {
|