@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
package/voice/gpu-mode.js
CHANGED
|
@@ -2,8 +2,24 @@ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
|
|
|
2
2
|
import { dshPluginConfigPath } from "../workspace/home.js";
|
|
3
3
|
import { VoiceError } from "../router/stt.js";
|
|
4
4
|
import { readConfig, writeConfig } from "./config.js";
|
|
5
|
+
import { cliProblem, gpuProblem } from "./gpu-problem.js";
|
|
5
6
|
import { chartPhase, currentChartRow, runOlaresCli, VOICE_CHARTS } from "./install.js";
|
|
6
7
|
|
|
8
|
+
/** A GPU failure the settings panel explains; `params` reach the browser beside the code. */
|
|
9
|
+
export class GpuError extends VoiceError {
|
|
10
|
+
/** @param {import('./gpu-problem.js').GpuProblem} problem */
|
|
11
|
+
constructor(problem, status = 502) {
|
|
12
|
+
super(problem.code, status, problem.detail || problem.code);
|
|
13
|
+
this.params = problem.params;
|
|
14
|
+
this.problem = problem;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function problemOf(err) {
|
|
19
|
+
if (err instanceof GpuError) return err.problem;
|
|
20
|
+
return gpuProblem("voice_gpu_failed", {}, err instanceof Error ? err.message : String(err));
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
/**
|
|
8
24
|
* Reading answers while they are written needs the LLM and speech models on
|
|
9
25
|
* the GPU at once, so the card is cut into memory slices. Reading finished
|
|
@@ -71,8 +87,8 @@ export function parseQuantity(text) {
|
|
|
71
87
|
* @returns {Promise<{ app: string, title: string, source: string, phase: string, required: number }[]>}
|
|
72
88
|
*/
|
|
73
89
|
export async function gpuApps(options = {}) {
|
|
74
|
-
const mine = await runOlaresCli(["market", "list", "--mine", "-o", "json"], options.spawnFn);
|
|
75
|
-
if (mine.code !== 0 || !Array.isArray(mine.json)) throw new
|
|
90
|
+
const mine = await runOlaresCli(["market", "list", "--mine", "-o", "json"], options.spawnFn, MANIFEST_LIMIT);
|
|
91
|
+
if (mine.code !== 0 || !Array.isArray(mine.json)) throw new GpuError(cliProblem(mine, "voice_gpu_failed"));
|
|
76
92
|
const apps = await Promise.all(mine.json.map(async (row) => {
|
|
77
93
|
const app = typeof row?.name === "string" ? row.name : "";
|
|
78
94
|
if (!app) return null;
|
|
@@ -110,7 +126,7 @@ function speechDevice(nodes) {
|
|
|
110
126
|
export async function speechGpu(options = {}) {
|
|
111
127
|
const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
|
|
112
128
|
if (result.code !== 0 || !Array.isArray(result.json)) {
|
|
113
|
-
throw new
|
|
129
|
+
throw new GpuError(cliProblem(result, "voice_gpu_failed"));
|
|
114
130
|
}
|
|
115
131
|
const device = speechDevice(result.json);
|
|
116
132
|
if (!device) return null;
|
|
@@ -155,11 +171,48 @@ function sliceFlag(gpu, app, plan) {
|
|
|
155
171
|
return `${gpu.node}:${gpu.device}:${Math.round(Number(plan.slices[app]) / MIB)}Mi`;
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
/** The binding an app had before this switch touched it, to put it back after a failure. */
|
|
175
|
+
function bindingFlag(gpu, binding) {
|
|
176
|
+
return gpu.type === "MemorySlice" && binding.memory > 0
|
|
177
|
+
? `${gpu.node}:${gpu.device}:${Math.round(binding.memory / MIB)}Mi`
|
|
178
|
+
: `${gpu.node}:${gpu.device}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function gib(bytes) {
|
|
182
|
+
return Math.round((bytes / 2 ** 30) * 10) / 10;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Entering MemorySlice with no slice planned would stop every app and start
|
|
187
|
+
* none. The first time, each running app on the card gets its manifest
|
|
188
|
+
* minimum; when that cannot fit, or nothing qualifies, the user plans it.
|
|
189
|
+
*/
|
|
190
|
+
async function ensureSlices(gpu, plan, options) {
|
|
191
|
+
if (slicePlan(null, plan).some((entry) => entry.enabled)) return plan;
|
|
192
|
+
const onCard = new Set(union(plan.apps, gpu.bindings.map((binding) => binding.app)));
|
|
193
|
+
const seeded = (await gpuApps(options)).filter((entry) => (
|
|
194
|
+
onCard.has(entry.app) && entry.phase === "running" && entry.required > 0 && !plan.off.includes(entry.app)
|
|
195
|
+
));
|
|
196
|
+
if (!seeded.length) throw new GpuError(gpuProblem("voice_gpu_plan_empty"), 409);
|
|
197
|
+
const need = seeded.reduce((sum, entry) => sum + entry.required, 0);
|
|
198
|
+
if (gpu.memory > 0 && need > gpu.memory) {
|
|
199
|
+
throw new GpuError(gpuProblem("voice_gpu_plan_over", { need: gib(need), total: gib(gpu.memory) }), 409);
|
|
200
|
+
}
|
|
201
|
+
const slices = { ...plan.slices };
|
|
202
|
+
for (const entry of seeded) slices[entry.app] = Math.ceil(entry.required / MIB) * MIB;
|
|
203
|
+
const next = { ...plan, slices };
|
|
204
|
+
writePlan(next);
|
|
205
|
+
return next;
|
|
206
|
+
}
|
|
207
|
+
|
|
158
208
|
let job = null;
|
|
159
209
|
|
|
210
|
+
const BUSY = new Set(["switching", "restoring", "resuming"]);
|
|
211
|
+
|
|
160
212
|
/** The switch in progress or last finished, for the settings panel to poll. */
|
|
161
213
|
export function gpuSwitchJob() {
|
|
162
|
-
|
|
214
|
+
if (!job) return null;
|
|
215
|
+
return { ...job, apps: job.apps.map((app) => ({ ...app })), restored: job.restored.map((app) => ({ ...app })) };
|
|
163
216
|
}
|
|
164
217
|
|
|
165
218
|
/**
|
|
@@ -167,89 +220,121 @@ export function gpuSwitchJob() {
|
|
|
167
220
|
* the plan. Switching the type stops every app bound to the card; within
|
|
168
221
|
* MemorySlice only apps whose slice changed are stopped and rebound. Apps
|
|
169
222
|
* resume smallest first, so the LLM is not starved of a slice.
|
|
223
|
+
* The reading mode is saved only once the card is where it needs to be; a
|
|
224
|
+
* failure before that puts the stopped apps back on their old bindings.
|
|
170
225
|
* Returns at once; the job runs on and reports through `gpuSwitchJob`.
|
|
171
226
|
*/
|
|
172
227
|
export function startGpuSwitch(streaming, options = {}) {
|
|
173
|
-
if (job?.phase
|
|
174
|
-
throw new
|
|
228
|
+
if (BUSY.has(job?.phase)) {
|
|
229
|
+
throw new GpuError(gpuProblem("voice_gpu_busy"), 409);
|
|
175
230
|
}
|
|
176
231
|
const target = gpuTypeFor(streaming);
|
|
177
|
-
job = { target, phase: "switching", startedAt: Date.now(), apps: [],
|
|
232
|
+
job = { target, phase: "switching", startedAt: Date.now(), apps: [], restored: [], problem: null };
|
|
178
233
|
const current = job;
|
|
179
|
-
void runSwitch(current, options).catch((err) => {
|
|
234
|
+
void runSwitch(current, streaming, options).catch((err) => {
|
|
180
235
|
current.phase = "failed";
|
|
181
|
-
current.
|
|
236
|
+
current.problem = problemOf(err);
|
|
182
237
|
});
|
|
183
238
|
return gpuSwitchJob();
|
|
184
239
|
}
|
|
185
240
|
|
|
186
|
-
async function runSwitch(current, options) {
|
|
241
|
+
async function runSwitch(current, streaming, options) {
|
|
187
242
|
const run = (args) => runOlaresCli(args, options.spawnFn);
|
|
188
|
-
// set-type and unbind return before their stops settle, and a resume against a stopping app is refused.
|
|
189
|
-
const stop = (app) => run(["market", "stop", app, "--watch", "--watch-timeout", "10m", "-o", "json"]);
|
|
190
243
|
const gpu = await speechGpu(options);
|
|
191
|
-
if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
|
|
192
244
|
const sliced = current.target === "MemorySlice";
|
|
245
|
+
if (!gpu) {
|
|
246
|
+
if (sliced) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
|
|
247
|
+
writeConfig({ streaming });
|
|
248
|
+
current.phase = "done";
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
193
251
|
const switching = gpu.type !== current.target;
|
|
252
|
+
if (switching && !gpu.available.includes(current.target)) {
|
|
253
|
+
throw new GpuError(gpuProblem("voice_gpu_unsupported", { model: gpu.model || gpu.device }), 400);
|
|
254
|
+
}
|
|
194
255
|
if (gpu.bindings.length) rememberBound(gpu, switching);
|
|
195
|
-
const plan = readPlan();
|
|
256
|
+
const plan = sliced ? await ensureSlices(gpu, readPlan(), options) : readPlan();
|
|
196
257
|
const kept = new Set();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
258
|
+
const stopped = [];
|
|
259
|
+
// set-type and unbind return before their stops settle, and a resume against a stopping app is refused.
|
|
260
|
+
const stop = async (binding) => {
|
|
261
|
+
const result = await run(["market", "stop", binding.app, "--watch", "--watch-timeout", "10m", "-o", "json"]);
|
|
262
|
+
if (result.code !== 0 && !(await settledAs(binding.app, "stopped", options))) {
|
|
263
|
+
throw new GpuError(cliProblem(result, "voice_gpu_stop_failed", { app: binding.app }));
|
|
200
264
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
265
|
+
stopped.push(binding);
|
|
266
|
+
};
|
|
267
|
+
try {
|
|
268
|
+
if (switching) {
|
|
269
|
+
for (const binding of gpu.bindings) await stop(binding);
|
|
270
|
+
const switched = await run(["settings", "compute", "set-type", gpu.node, gpu.device, "--type", current.target, "--yes", "-o", "json"]);
|
|
271
|
+
if (switched.code !== 0) {
|
|
272
|
+
throw new GpuError(cliProblem(switched, "voice_gpu_failed"));
|
|
273
|
+
}
|
|
274
|
+
} else if (sliced) {
|
|
275
|
+
for (const binding of gpu.bindings) {
|
|
276
|
+
const wanted = plan.off.includes(binding.app) ? 0 : Number(plan.slices[binding.app]) || 0;
|
|
277
|
+
if (wanted === binding.memory) {
|
|
278
|
+
kept.add(binding.app);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
await stop(binding);
|
|
282
|
+
const unbound = await run(["settings", "compute", "unbind", binding.app, "--yes", "-o", "json"]);
|
|
283
|
+
if (unbound.code !== 0) throw new GpuError(cliProblem(unbound, "voice_gpu_failed", { app: binding.app }));
|
|
210
284
|
}
|
|
211
|
-
await stop(binding.app);
|
|
212
|
-
const unbound = await run(["settings", "compute", "unbind", binding.app, "--yes", "-o", "json"]);
|
|
213
|
-
if (unbound.code !== 0) throw new VoiceError("voice_gpu_failed", 502, unbound.message);
|
|
214
285
|
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
current.phase = "restoring";
|
|
288
|
+
for (const binding of stopped) {
|
|
289
|
+
const resumed = await resumeApp(binding.app, bindingFlag(gpu, binding), options);
|
|
290
|
+
current.restored.push({ app: binding.app, ok: !resumed });
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
215
293
|
}
|
|
294
|
+
writeConfig({ streaming });
|
|
216
295
|
current.phase = "resuming";
|
|
217
296
|
const apps = (sliced
|
|
218
297
|
? slicePlan(null, plan).filter((entry) => entry.enabled).map((entry) => entry.app)
|
|
219
298
|
: plan.apps
|
|
220
299
|
).sort((a, b) => (Number(plan.slices[a]) || Infinity) - (Number(plan.slices[b]) || Infinity));
|
|
221
300
|
for (const app of apps) {
|
|
222
|
-
const entry = { app, ok: false,
|
|
301
|
+
const entry = { app, ok: false, problem: null };
|
|
223
302
|
current.apps.push(entry);
|
|
224
303
|
if (kept.has(app)) {
|
|
225
304
|
entry.ok = true;
|
|
226
305
|
continue;
|
|
227
306
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"--compute-binding", sliced ? sliceFlag(gpu, app, plan) : `${gpu.node}:${gpu.device}`,
|
|
231
|
-
"--watch", "--watch-timeout", "15m", "-o", "json",
|
|
232
|
-
]);
|
|
233
|
-
entry.ok = resumed.code === 0;
|
|
234
|
-
if (!entry.ok) entry.message = resumed.message;
|
|
307
|
+
entry.problem = await resumeApp(app, sliced ? sliceFlag(gpu, app, plan) : `${gpu.node}:${gpu.device}`, options);
|
|
308
|
+
entry.ok = !entry.problem;
|
|
235
309
|
}
|
|
236
310
|
current.phase = current.apps.every((app) => app.ok) ? "done" : "partial";
|
|
237
311
|
}
|
|
238
312
|
|
|
313
|
+
/** Resumes one app onto the card; null once it runs, else why it did not. */
|
|
314
|
+
async function resumeApp(app, binding, options) {
|
|
315
|
+
const resumed = await runOlaresCli([
|
|
316
|
+
"market", "resume", app,
|
|
317
|
+
"--compute-binding", binding,
|
|
318
|
+
"--watch", "--watch-timeout", "15m", "-o", "json",
|
|
319
|
+
], options.spawnFn);
|
|
320
|
+
if (resumed.code === 0 || await settledAs(app, "running", options)) return null;
|
|
321
|
+
return cliProblem(resumed, "voice_gpu_resume_failed", { app });
|
|
322
|
+
}
|
|
323
|
+
|
|
239
324
|
/** What the settings panel shows: the reading mode, the card, its slice plan, and any switch in progress. */
|
|
240
325
|
export async function gpuModePayload(options = {}) {
|
|
241
326
|
let gpu = null;
|
|
242
|
-
let
|
|
327
|
+
let problem = null;
|
|
243
328
|
try {
|
|
244
329
|
gpu = await speechGpu(options);
|
|
245
330
|
} catch (err) {
|
|
246
|
-
|
|
331
|
+
problem = problemOf(err);
|
|
247
332
|
}
|
|
248
333
|
let apps = [];
|
|
249
334
|
try {
|
|
250
335
|
apps = await gpuApps(options);
|
|
251
336
|
} catch (err) {
|
|
252
|
-
|
|
337
|
+
problem ||= problemOf(err);
|
|
253
338
|
}
|
|
254
339
|
const streaming = readConfig().streaming;
|
|
255
340
|
const bound = new Set(gpu?.bindings.map((binding) => binding.app) ?? []);
|
|
@@ -262,18 +347,17 @@ export async function gpuModePayload(options = {}) {
|
|
|
262
347
|
...entry,
|
|
263
348
|
bound: bound.has(entry.app),
|
|
264
349
|
pending: pendingApps.get(entry.app) ?? "",
|
|
265
|
-
|
|
350
|
+
problem: appErrors.get(entry.app) ?? null,
|
|
266
351
|
})),
|
|
267
352
|
job: gpuSwitchJob(),
|
|
268
|
-
|
|
353
|
+
problem,
|
|
269
354
|
};
|
|
270
355
|
}
|
|
271
356
|
|
|
272
|
-
/**
|
|
357
|
+
/** Starts moving the card to the type the reading mode needs; the mode is saved once it gets there. */
|
|
273
358
|
export async function switchReadingMode(streaming, options = {}) {
|
|
274
359
|
if (typeof streaming !== "boolean") throw new VoiceError("voice_config_invalid", 400, "streaming must be a boolean");
|
|
275
360
|
startGpuSwitch(streaming, options);
|
|
276
|
-
writeConfig({ streaming });
|
|
277
361
|
return gpuModePayload(options);
|
|
278
362
|
}
|
|
279
363
|
|
|
@@ -283,12 +367,12 @@ export async function switchReadingMode(streaming, options = {}) {
|
|
|
283
367
|
* @param {unknown} entries `[{ app, enabled, memory }]`, memory in bytes
|
|
284
368
|
*/
|
|
285
369
|
export async function saveSlicePlan(entries, options = {}) {
|
|
286
|
-
const invalid = (
|
|
287
|
-
throw new
|
|
370
|
+
const invalid = (code, params, detail) => {
|
|
371
|
+
throw new GpuError(gpuProblem(code, params, detail), 400);
|
|
288
372
|
};
|
|
289
|
-
if (!Array.isArray(entries))
|
|
373
|
+
if (!Array.isArray(entries)) throw new VoiceError("voice_config_invalid", 400, "plan must be a list");
|
|
290
374
|
const gpu = await speechGpu(options);
|
|
291
|
-
if (!gpu) throw new
|
|
375
|
+
if (!gpu) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
|
|
292
376
|
const plan = readPlan();
|
|
293
377
|
const candidates = slicePlan(gpu, plan, await gpuApps(options));
|
|
294
378
|
const known = new Set(candidates.map((entry) => entry.app));
|
|
@@ -298,12 +382,12 @@ export async function saveSlicePlan(entries, options = {}) {
|
|
|
298
382
|
let total = 0;
|
|
299
383
|
for (const entry of entries) {
|
|
300
384
|
const app = entry?.app;
|
|
301
|
-
if (typeof app !== "string" || !known.has(app)) invalid(`unknown app ${String(app)}`);
|
|
302
|
-
if (typeof entry.enabled !== "boolean")
|
|
385
|
+
if (typeof app !== "string" || !known.has(app)) invalid("voice_gpu_unknown_app", { app: String(app) }, `unknown app ${String(app)}`);
|
|
386
|
+
if (typeof entry.enabled !== "boolean") throw new VoiceError("voice_config_invalid", 400, `${app}: enabled must be a boolean`);
|
|
303
387
|
const memory = Number(entry.memory);
|
|
304
388
|
const floor = Math.max(256 * MIB, required.get(app) || 0);
|
|
305
389
|
if (entry.enabled && !(Number.isFinite(memory) && memory >= floor)) {
|
|
306
|
-
invalid(`${app}: memory must be at least ${Math.round(floor / MIB)}Mi`);
|
|
390
|
+
invalid("voice_gpu_plan_floor", { app, size: gib(floor) }, `${app}: memory must be at least ${Math.round(floor / MIB)}Mi`);
|
|
307
391
|
}
|
|
308
392
|
if (Number.isFinite(memory) && memory > 0) slices[app] = Math.round(memory / MIB) * MIB;
|
|
309
393
|
if (entry.enabled) {
|
|
@@ -313,7 +397,9 @@ export async function saveSlicePlan(entries, options = {}) {
|
|
|
313
397
|
off.add(app);
|
|
314
398
|
}
|
|
315
399
|
}
|
|
316
|
-
if (gpu.memory > 0 && total > gpu.memory)
|
|
400
|
+
if (gpu.memory > 0 && total > gpu.memory) {
|
|
401
|
+
invalid("voice_gpu_plan_over", { need: gib(total), total: gib(gpu.memory) }, "slices exceed the GPU memory");
|
|
402
|
+
}
|
|
317
403
|
writePlan({ apps: union(plan.apps, gpu.bindings.map((binding) => binding.app)), slices, off: [...off] });
|
|
318
404
|
if (gpu.type === "MemorySlice") startGpuSwitch(true, options);
|
|
319
405
|
return gpuModePayload(options);
|
|
@@ -337,17 +423,19 @@ const appErrors = new Map();
|
|
|
337
423
|
*/
|
|
338
424
|
export async function gpuAppAction(app, op, options = {}) {
|
|
339
425
|
if (op !== "resume" && op !== "stop") throw new VoiceError("voice_config_invalid", 400, "op must be resume or stop");
|
|
340
|
-
if (job?.phase
|
|
341
|
-
throw new
|
|
426
|
+
if (BUSY.has(job?.phase) || pendingApps.has(app)) {
|
|
427
|
+
throw new GpuError(gpuProblem("voice_gpu_busy"), 409);
|
|
342
428
|
}
|
|
343
429
|
const [gpu, apps] = await Promise.all([speechGpu(options), gpuApps(options)]);
|
|
344
|
-
if (!gpu) throw new
|
|
345
|
-
if (!apps.some((entry) => entry.app === app))
|
|
430
|
+
if (!gpu) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
|
|
431
|
+
if (!apps.some((entry) => entry.app === app)) {
|
|
432
|
+
throw new GpuError(gpuProblem("voice_gpu_unknown_app", { app }, `${app} is not a GPU app`), 400);
|
|
433
|
+
}
|
|
346
434
|
const plan = readPlan();
|
|
347
435
|
let binding = `${gpu.node}:${gpu.device}`;
|
|
348
436
|
if (op === "resume" && gpu.type === "MemorySlice") {
|
|
349
437
|
if (plan.off.includes(app) || !(Number(plan.slices[app]) > 0)) {
|
|
350
|
-
throw new
|
|
438
|
+
throw new GpuError(gpuProblem("voice_gpu_no_slice", { app }, `${app} has no memory slice`), 400);
|
|
351
439
|
}
|
|
352
440
|
binding = sliceFlag(gpu, app, plan);
|
|
353
441
|
}
|
|
@@ -359,14 +447,14 @@ export async function gpuAppAction(app, op, options = {}) {
|
|
|
359
447
|
void runOlaresCli(args, options.spawnFn)
|
|
360
448
|
.then(async (result) => {
|
|
361
449
|
if (result.code !== 0 && !(await settledAs(app, op === "stop" ? "stopped" : "running", options))) {
|
|
362
|
-
appErrors.set(app, result
|
|
450
|
+
appErrors.set(app, cliProblem(result, op === "stop" ? "voice_gpu_stop_failed" : "voice_gpu_resume_failed", { app }));
|
|
363
451
|
return;
|
|
364
452
|
}
|
|
365
453
|
const latest = readPlan();
|
|
366
454
|
const kept = latest.apps.filter((name) => name !== app);
|
|
367
455
|
writePlan({ ...latest, apps: op === "resume" ? [...kept, app] : kept });
|
|
368
456
|
})
|
|
369
|
-
.catch((err) => appErrors.set(app,
|
|
457
|
+
.catch((err) => appErrors.set(app, problemOf(err)))
|
|
370
458
|
.finally(() => pendingApps.delete(app));
|
|
371
459
|
return gpuModePayload(options);
|
|
372
460
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a GPU step failed, as a code the settings panel turns into what to do
|
|
3
|
+
* next. `detail` keeps olares-cli's own words for what a code cannot explain.
|
|
4
|
+
* Shared by the Host and the browser, so it imports nothing.
|
|
5
|
+
*
|
|
6
|
+
* @typedef {{ code: string, params: Record<string, unknown>, detail: string }} GpuProblem
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @returns {GpuProblem} */
|
|
10
|
+
export function gpuProblem(code, params = {}, detail = "") {
|
|
11
|
+
return { code, params, detail: String(detail ?? "") };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const FORBIDDEN = /\b403\b|forbidden|permission denied|not (allowed|permitted)|admin(istrator)? (role )?(is )?required/i;
|
|
15
|
+
const LOGIN = /\b(401|459)\b|unauthori[sz]ed|invalidated|not logged in|profile login|credential (is )?(missing|absent|invalid)/i;
|
|
16
|
+
const BLOCKED = /bound-apps-stop-blocked/i;
|
|
17
|
+
const MEMORY = /out of memory|(insufficient|not enough)[^.\n]*(gpu|memory|vram)|gpu memory[^.\n]*(exceed|insufficient)/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An olares-cli result → the problem the user acts on. Account and login
|
|
21
|
+
* failures are fixed outside Lares, so they win over the step's own code.
|
|
22
|
+
* @param {{ message?: string, text?: string }} result
|
|
23
|
+
* @param {string} fallback code for this step
|
|
24
|
+
*/
|
|
25
|
+
export function cliProblem(result, fallback, params = {}) {
|
|
26
|
+
const detail = String(result?.message ?? "");
|
|
27
|
+
const text = `${detail}\n${result?.text ?? ""}`;
|
|
28
|
+
if (FORBIDDEN.test(text)) return gpuProblem("voice_gpu_forbidden", params, detail);
|
|
29
|
+
if (LOGIN.test(text)) return gpuProblem("voice_gpu_login", params, detail);
|
|
30
|
+
if (BLOCKED.test(text)) return gpuProblem("voice_gpu_blocked", params, detail);
|
|
31
|
+
if (MEMORY.test(text)) return gpuProblem("voice_gpu_memory", params, detail);
|
|
32
|
+
return gpuProblem(fallback, params, detail);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Problem → sentence in the product language. Unknown codes fall back to the
|
|
37
|
+
* generic failure, which carries the CLI detail.
|
|
38
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} translate
|
|
39
|
+
* @param {GpuProblem | null | undefined} problem
|
|
40
|
+
*/
|
|
41
|
+
export function gpuProblemText(translate, problem) {
|
|
42
|
+
if (!problem?.code) return "";
|
|
43
|
+
const params = { ...problem.params, detail: problem.detail || problem.code };
|
|
44
|
+
const key = `error.${problem.code}`;
|
|
45
|
+
const text = translate(key, params);
|
|
46
|
+
return text === key ? translate("error.voice_gpu_failed", params) : text;
|
|
47
|
+
}
|
|
@@ -1131,6 +1131,8 @@ def dispatch(payload, directory):
|
|
|
1131
1131
|
return play_file(directory, payload.get("file"))
|
|
1132
1132
|
if op == "play-stream":
|
|
1133
1133
|
return open_stream(directory, payload.get("file"), payload.get("rate"), payload.get("channels"))
|
|
1134
|
+
if op == "playback-status":
|
|
1135
|
+
return {"op": op, "playing": playback_proc is not None and playback_proc.poll() is None}
|
|
1134
1136
|
if op == "playback-stop":
|
|
1135
1137
|
return stop_playback_request()
|
|
1136
1138
|
if op == "capture-start":
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function createHostInput(port) {
|
|
2
|
+
let abort = null;
|
|
3
|
+
return {
|
|
4
|
+
capabilities: { duplex: false, bargeIn: false, audio: "pcm16-16000" },
|
|
5
|
+
async capture({ signal, onTranscribing, continuous = false } = {}) {
|
|
6
|
+
abort = new AbortController();
|
|
7
|
+
const current = abort;
|
|
8
|
+
const cancel = () => current.abort();
|
|
9
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
10
|
+
try {
|
|
11
|
+
if (signal?.aborted) return "";
|
|
12
|
+
const opened = await port.start(current.signal);
|
|
13
|
+
if (!opened.recording) throw new Error("voice_bluetooth_not_ready");
|
|
14
|
+
while (!current.signal.aborted) {
|
|
15
|
+
const state = await port.status(current.signal);
|
|
16
|
+
if (state.phase === "transcribing") onTranscribing?.();
|
|
17
|
+
if (state.phase === "done") return state.text;
|
|
18
|
+
if (state.phase === "error") {
|
|
19
|
+
if (continuous && ["voice_no_input", "voice_too_short"].includes(state.error)) return "";
|
|
20
|
+
throw new Error(state.error);
|
|
21
|
+
}
|
|
22
|
+
if (state.phase === "idle") return "";
|
|
23
|
+
await new Promise((resolve) => {
|
|
24
|
+
const finish = () => { clearTimeout(timer); current.signal.removeEventListener("abort", finish); resolve(); };
|
|
25
|
+
const timer = setTimeout(finish, 200);
|
|
26
|
+
current.signal.addEventListener("abort", finish, { once: true });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (current.signal.aborted) return "";
|
|
32
|
+
throw error;
|
|
33
|
+
} finally { signal?.removeEventListener("abort", cancel); }
|
|
34
|
+
},
|
|
35
|
+
stop: () => port.stop(),
|
|
36
|
+
async cancel() { if (!abort) return; abort.abort(); abort = null; await port.cancel(); },
|
|
37
|
+
};
|
|
38
|
+
}
|