@olares/lares-core 0.15.9 → 0.15.11
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 +98 -9
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +3 -3
- package/larepass/transcript.js +4 -6
- package/log/report.js +46 -0
- package/olares/trusted-host.js +11 -1
- package/package.json +3 -1
- package/router/tts.js +24 -0
- package/tools/http.js +25 -0
- package/voice/client.js +50 -3
- package/voice/config.js +6 -3
- package/voice/gpu-mode.js +372 -0
- package/voice/install.js +24 -7
- package/voice/languages.js +2 -6
- package/voice/script.js +56 -7
- package/voice/service.js +158 -22
- package/voice/speech.js +222 -44
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
|
|
2
|
+
import { dshPluginConfigPath } from "../workspace/home.js";
|
|
3
|
+
import { VoiceError } from "../router/stt.js";
|
|
4
|
+
import { readConfig, writeConfig } from "./config.js";
|
|
5
|
+
import { chartPhase, currentChartRow, runOlaresCli, VOICE_CHARTS } from "./install.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reading answers while they are written needs the LLM and speech models on
|
|
9
|
+
* the GPU at once, so the card is cut into memory slices. Reading finished
|
|
10
|
+
* answers lets the card time-slice instead, and each model gets the whole
|
|
11
|
+
* card while it runs.
|
|
12
|
+
*/
|
|
13
|
+
export function gpuTypeFor(streaming) {
|
|
14
|
+
return streaming ? "MemorySlice" : "TimeSlice";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MIB = 2 ** 20;
|
|
18
|
+
// `market get` prints the whole catalog entry, tens of kilobytes with locales.
|
|
19
|
+
const MANIFEST_LIMIT = 1_000_000;
|
|
20
|
+
|
|
21
|
+
function planPath() {
|
|
22
|
+
return dshPluginConfigPath("voice-gpu");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Switching the type drops the card's bindings, so the apps that use the card
|
|
27
|
+
* are kept here to resume them, also after a failed switch.
|
|
28
|
+
* `apps`: every app seen bound to the card; `slices`: app → bytes it gets
|
|
29
|
+
* while memory-sliced; `off`: apps left stopped while memory-sliced.
|
|
30
|
+
*/
|
|
31
|
+
function readPlan() {
|
|
32
|
+
const stored = readJsonFile(planPath());
|
|
33
|
+
const names = (list) => (Array.isArray(list) ? list.filter((app) => typeof app === "string" && app) : []);
|
|
34
|
+
const slices = stored?.slices && typeof stored.slices === "object" && !Array.isArray(stored.slices) ? stored.slices : {};
|
|
35
|
+
return { apps: names(stored?.apps), slices, off: names(stored?.off) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writePlan(plan) {
|
|
39
|
+
writeJsonFile(planPath(), plan);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function union(...lists) {
|
|
43
|
+
return [...new Set(lists.flat())];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Adds the card's current bindings to the plan; their slice sizes too when leaving MemorySlice. */
|
|
47
|
+
function rememberBound(gpu, leaving) {
|
|
48
|
+
const plan = readPlan();
|
|
49
|
+
const slices = { ...plan.slices };
|
|
50
|
+
if (leaving && gpu.type === "MemorySlice") {
|
|
51
|
+
for (const binding of gpu.bindings) {
|
|
52
|
+
if (binding.memory > 0) slices[binding.app] = binding.memory;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
writePlan({ ...plan, apps: union(plan.apps, gpu.bindings.map((binding) => binding.app)), slices });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const QUANTITY_UNITS = { Ki: 2 ** 10, Mi: 2 ** 20, Gi: 2 ** 30, Ti: 2 ** 40, K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
|
59
|
+
|
|
60
|
+
/** Kubernetes quantity ("6Gi", "512Mi") → bytes; 0 when absent or unreadable. */
|
|
61
|
+
export function parseQuantity(text) {
|
|
62
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*([KMGT]i?)?\s*$/.exec(String(text ?? ""));
|
|
63
|
+
if (!match) return 0;
|
|
64
|
+
return Math.round(Number(match[1]) * (match[2] ? QUANTITY_UNITS[match[2]] : 1));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The user's apps that need an NVIDIA card, across every market source.
|
|
69
|
+
* Each manifest is read from the source the app was installed from, since
|
|
70
|
+
* catalog and uploaded charts can ask for different memory.
|
|
71
|
+
* @returns {Promise<{ app: string, title: string, source: string, phase: string, required: number }[]>}
|
|
72
|
+
*/
|
|
73
|
+
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 VoiceError("voice_gpu_failed", 502, mine.message);
|
|
76
|
+
const apps = await Promise.all(mine.json.map(async (row) => {
|
|
77
|
+
const app = typeof row?.name === "string" ? row.name : "";
|
|
78
|
+
if (!app) return null;
|
|
79
|
+
const args = ["market", "get", app, "-o", "json"];
|
|
80
|
+
if (typeof row.source === "string" && row.source) args.splice(3, 0, "-s", row.source);
|
|
81
|
+
const detail = await runOlaresCli(args, options.spawnFn, MANIFEST_LIMIT);
|
|
82
|
+
const accelerators = detail.json?.app_info?.app_entry?.accelerator;
|
|
83
|
+
const nvidia = Array.isArray(accelerators) ? accelerators.find((item) => item?.mode === "nvidia") : null;
|
|
84
|
+
if (!nvidia) return null;
|
|
85
|
+
return {
|
|
86
|
+
app,
|
|
87
|
+
title: typeof row.title === "string" && row.title ? row.title : app,
|
|
88
|
+
source: String(row.source ?? ""),
|
|
89
|
+
phase: chartPhase(row),
|
|
90
|
+
required: parseQuantity(nvidia.requiredGPUMemory),
|
|
91
|
+
};
|
|
92
|
+
}));
|
|
93
|
+
return apps.filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function speechDevice(nodes) {
|
|
97
|
+
const devices = (Array.isArray(nodes) ? nodes : [])
|
|
98
|
+
.flatMap((node) => (Array.isArray(node?.devices) ? node.devices : []))
|
|
99
|
+
.filter((device) => device?.mode === "nvidia");
|
|
100
|
+
const speechApps = new Set(Object.values(VOICE_CHARTS).map((chart) => chart.app));
|
|
101
|
+
return devices.find((device) => (device.bindings ?? []).some((binding) => speechApps.has(binding?.appName)))
|
|
102
|
+
?? devices[0]
|
|
103
|
+
?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The NVIDIA card the speech models are bound to.
|
|
108
|
+
* @returns {Promise<{ node: string, device: string, model: string, type: string, memory: number, available: string[], bindings: { app: string, memory: number }[] } | null>}
|
|
109
|
+
*/
|
|
110
|
+
export async function speechGpu(options = {}) {
|
|
111
|
+
const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
|
|
112
|
+
if (result.code !== 0 || !Array.isArray(result.json)) {
|
|
113
|
+
throw new VoiceError("voice_gpu_failed", 502, result.message);
|
|
114
|
+
}
|
|
115
|
+
const device = speechDevice(result.json);
|
|
116
|
+
if (!device) return null;
|
|
117
|
+
return {
|
|
118
|
+
node: String(device.nodeName ?? ""),
|
|
119
|
+
device: String(device.id ?? ""),
|
|
120
|
+
model: String(device.cardModel ?? ""),
|
|
121
|
+
type: String(device.supportType ?? ""),
|
|
122
|
+
memory: Number(device.memory) || 0,
|
|
123
|
+
available: Array.isArray(device.availableSupportTypes) ? device.availableSupportTypes.map(String) : [],
|
|
124
|
+
bindings: (device.bindings ?? []).map((binding) => ({
|
|
125
|
+
app: String(binding?.appName ?? ""),
|
|
126
|
+
memory: Number(binding?.memory) || 0,
|
|
127
|
+
})).filter((binding) => binding.app),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The memory-slice plan the settings panel edits: every app known to use the
|
|
133
|
+
* card, whether it takes part, and its slice in bytes. A running slice shows
|
|
134
|
+
* its live size. `kind` names the speech models; other apps have none.
|
|
135
|
+
*/
|
|
136
|
+
function slicePlan(gpu, plan, apps = []) {
|
|
137
|
+
const kinds = new Map(Object.entries(VOICE_CHARTS).map(([kind, chart]) => [chart.app, kind]));
|
|
138
|
+
const known = new Map(apps.map((entry) => [entry.app, entry]));
|
|
139
|
+
const live = gpu?.type === "MemorySlice" ? new Map(gpu.bindings.map((binding) => [binding.app, binding.memory])) : new Map();
|
|
140
|
+
const names = union(plan.apps, gpu?.bindings.map((binding) => binding.app) ?? [], Object.keys(plan.slices), apps.map((entry) => entry.app));
|
|
141
|
+
return names.map((app) => {
|
|
142
|
+
const memory = live.get(app) || Number(plan.slices[app]) || 0;
|
|
143
|
+
return {
|
|
144
|
+
app,
|
|
145
|
+
kind: kinds.get(app) ?? "",
|
|
146
|
+
title: known.get(app)?.title ?? app,
|
|
147
|
+
required: known.get(app)?.required ?? 0,
|
|
148
|
+
enabled: memory > 0 && !plan.off.includes(app),
|
|
149
|
+
memory,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sliceFlag(gpu, app, plan) {
|
|
155
|
+
return `${gpu.node}:${gpu.device}:${Math.round(Number(plan.slices[app]) / MIB)}Mi`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let job = null;
|
|
159
|
+
|
|
160
|
+
/** The switch in progress or last finished, for the settings panel to poll. */
|
|
161
|
+
export function gpuSwitchJob() {
|
|
162
|
+
return job ? { ...job, apps: job.apps.map((app) => ({ ...app })) } : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Moves the speech card to the type the reading mode needs and its apps to
|
|
167
|
+
* the plan. Switching the type stops every app bound to the card; within
|
|
168
|
+
* MemorySlice only apps whose slice changed are stopped and rebound. Apps
|
|
169
|
+
* resume smallest first, so the LLM is not starved of a slice.
|
|
170
|
+
* Returns at once; the job runs on and reports through `gpuSwitchJob`.
|
|
171
|
+
*/
|
|
172
|
+
export function startGpuSwitch(streaming, options = {}) {
|
|
173
|
+
if (job?.phase === "switching" || job?.phase === "resuming") {
|
|
174
|
+
throw new VoiceError("voice_gpu_busy", 409, "the GPU is already switching");
|
|
175
|
+
}
|
|
176
|
+
const target = gpuTypeFor(streaming);
|
|
177
|
+
job = { target, phase: "switching", startedAt: Date.now(), apps: [], error: "" };
|
|
178
|
+
const current = job;
|
|
179
|
+
void runSwitch(current, options).catch((err) => {
|
|
180
|
+
current.phase = "failed";
|
|
181
|
+
current.error = err instanceof Error ? err.message : String(err);
|
|
182
|
+
});
|
|
183
|
+
return gpuSwitchJob();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runSwitch(current, options) {
|
|
187
|
+
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
|
+
const gpu = await speechGpu(options);
|
|
191
|
+
if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
|
|
192
|
+
const sliced = current.target === "MemorySlice";
|
|
193
|
+
const switching = gpu.type !== current.target;
|
|
194
|
+
if (gpu.bindings.length) rememberBound(gpu, switching);
|
|
195
|
+
const plan = readPlan();
|
|
196
|
+
const kept = new Set();
|
|
197
|
+
if (switching) {
|
|
198
|
+
if (!gpu.available.includes(current.target)) {
|
|
199
|
+
throw new VoiceError("voice_gpu_failed", 400, `this GPU does not support ${current.target}`);
|
|
200
|
+
}
|
|
201
|
+
for (const binding of gpu.bindings) await stop(binding.app);
|
|
202
|
+
const switched = await run(["settings", "compute", "set-type", gpu.node, gpu.device, "--type", current.target, "--yes", "-o", "json"]);
|
|
203
|
+
if (switched.code !== 0) throw new VoiceError("voice_gpu_failed", 502, switched.message);
|
|
204
|
+
} else if (sliced) {
|
|
205
|
+
for (const binding of gpu.bindings) {
|
|
206
|
+
const wanted = plan.off.includes(binding.app) ? 0 : Number(plan.slices[binding.app]) || 0;
|
|
207
|
+
if (wanted === binding.memory) {
|
|
208
|
+
kept.add(binding.app);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
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
|
+
}
|
|
215
|
+
}
|
|
216
|
+
current.phase = "resuming";
|
|
217
|
+
const apps = (sliced
|
|
218
|
+
? slicePlan(null, plan).filter((entry) => entry.enabled).map((entry) => entry.app)
|
|
219
|
+
: plan.apps
|
|
220
|
+
).sort((a, b) => (Number(plan.slices[a]) || Infinity) - (Number(plan.slices[b]) || Infinity));
|
|
221
|
+
for (const app of apps) {
|
|
222
|
+
const entry = { app, ok: false, message: "" };
|
|
223
|
+
current.apps.push(entry);
|
|
224
|
+
if (kept.has(app)) {
|
|
225
|
+
entry.ok = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const resumed = await run([
|
|
229
|
+
"market", "resume", app,
|
|
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;
|
|
235
|
+
}
|
|
236
|
+
current.phase = current.apps.every((app) => app.ok) ? "done" : "partial";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What the settings panel shows: the reading mode, the card, its slice plan, and any switch in progress. */
|
|
240
|
+
export async function gpuModePayload(options = {}) {
|
|
241
|
+
let gpu = null;
|
|
242
|
+
let error = "";
|
|
243
|
+
try {
|
|
244
|
+
gpu = await speechGpu(options);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
error = err instanceof Error ? err.message : String(err);
|
|
247
|
+
}
|
|
248
|
+
let apps = [];
|
|
249
|
+
try {
|
|
250
|
+
apps = await gpuApps(options);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
error ||= err instanceof Error ? err.message : String(err);
|
|
253
|
+
}
|
|
254
|
+
const streaming = readConfig().streaming;
|
|
255
|
+
const bound = new Set(gpu?.bindings.map((binding) => binding.app) ?? []);
|
|
256
|
+
return {
|
|
257
|
+
streaming,
|
|
258
|
+
target: gpuTypeFor(streaming),
|
|
259
|
+
gpu: gpu ? { type: gpu.type, model: gpu.model, memory: gpu.memory, available: gpu.available, apps: [...bound] } : null,
|
|
260
|
+
plan: slicePlan(gpu, readPlan(), apps),
|
|
261
|
+
apps: apps.map((entry) => ({
|
|
262
|
+
...entry,
|
|
263
|
+
bound: bound.has(entry.app),
|
|
264
|
+
pending: pendingApps.get(entry.app) ?? "",
|
|
265
|
+
error: appErrors.get(entry.app) ?? "",
|
|
266
|
+
})),
|
|
267
|
+
job: gpuSwitchJob(),
|
|
268
|
+
error,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Saves the reading mode and starts moving the card to the type it needs. */
|
|
273
|
+
export async function switchReadingMode(streaming, options = {}) {
|
|
274
|
+
if (typeof streaming !== "boolean") throw new VoiceError("voice_config_invalid", 400, "streaming must be a boolean");
|
|
275
|
+
startGpuSwitch(streaming, options);
|
|
276
|
+
writeConfig({ streaming });
|
|
277
|
+
return gpuModePayload(options);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Saves which apps take part in memory slicing and how much each gets.
|
|
282
|
+
* Applied at once while the card is memory-sliced, else at the next switch.
|
|
283
|
+
* @param {unknown} entries `[{ app, enabled, memory }]`, memory in bytes
|
|
284
|
+
*/
|
|
285
|
+
export async function saveSlicePlan(entries, options = {}) {
|
|
286
|
+
const invalid = (message) => {
|
|
287
|
+
throw new VoiceError("voice_config_invalid", 400, message);
|
|
288
|
+
};
|
|
289
|
+
if (!Array.isArray(entries)) invalid("plan must be a list");
|
|
290
|
+
const gpu = await speechGpu(options);
|
|
291
|
+
if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
|
|
292
|
+
const plan = readPlan();
|
|
293
|
+
const candidates = slicePlan(gpu, plan, await gpuApps(options));
|
|
294
|
+
const known = new Set(candidates.map((entry) => entry.app));
|
|
295
|
+
const required = new Map(candidates.map((entry) => [entry.app, entry.required]));
|
|
296
|
+
const slices = { ...plan.slices };
|
|
297
|
+
const off = new Set(plan.off);
|
|
298
|
+
let total = 0;
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const app = entry?.app;
|
|
301
|
+
if (typeof app !== "string" || !known.has(app)) invalid(`unknown app ${String(app)}`);
|
|
302
|
+
if (typeof entry.enabled !== "boolean") invalid(`${app}: enabled must be a boolean`);
|
|
303
|
+
const memory = Number(entry.memory);
|
|
304
|
+
const floor = Math.max(256 * MIB, required.get(app) || 0);
|
|
305
|
+
if (entry.enabled && !(Number.isFinite(memory) && memory >= floor)) {
|
|
306
|
+
invalid(`${app}: memory must be at least ${Math.round(floor / MIB)}Mi`);
|
|
307
|
+
}
|
|
308
|
+
if (Number.isFinite(memory) && memory > 0) slices[app] = Math.round(memory / MIB) * MIB;
|
|
309
|
+
if (entry.enabled) {
|
|
310
|
+
off.delete(app);
|
|
311
|
+
total += slices[app];
|
|
312
|
+
} else {
|
|
313
|
+
off.add(app);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (gpu.memory > 0 && total > gpu.memory) invalid("slices exceed the GPU memory");
|
|
317
|
+
writePlan({ apps: union(plan.apps, gpu.bindings.map((binding) => binding.app)), slices, off: [...off] });
|
|
318
|
+
if (gpu.type === "MemorySlice") startGpuSwitch(true, options);
|
|
319
|
+
return gpuModePayload(options);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const pendingApps = new Map();
|
|
323
|
+
|
|
324
|
+
/** Market refuses an op on an app already where the op would take it; that still counts. */
|
|
325
|
+
async function settledAs(app, phase, options) {
|
|
326
|
+
const status = await runOlaresCli(["market", "status", app, "-a", "-o", "json"], options.spawnFn);
|
|
327
|
+
return chartPhase(currentChartRow(status.json)) === phase;
|
|
328
|
+
}
|
|
329
|
+
const appErrors = new Map();
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Starts or stops one GPU app on the speech card, in the background. A started
|
|
333
|
+
* app is resumed again whenever the card goes back to time slicing; a stopped
|
|
334
|
+
* one is not. While memory-sliced an app can only start into its planned slice.
|
|
335
|
+
* @param {string} app
|
|
336
|
+
* @param {"resume" | "stop"} op
|
|
337
|
+
*/
|
|
338
|
+
export async function gpuAppAction(app, op, options = {}) {
|
|
339
|
+
if (op !== "resume" && op !== "stop") throw new VoiceError("voice_config_invalid", 400, "op must be resume or stop");
|
|
340
|
+
if (job?.phase === "switching" || job?.phase === "resuming" || pendingApps.has(app)) {
|
|
341
|
+
throw new VoiceError("voice_gpu_busy", 409, "the GPU is busy");
|
|
342
|
+
}
|
|
343
|
+
const [gpu, apps] = await Promise.all([speechGpu(options), gpuApps(options)]);
|
|
344
|
+
if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
|
|
345
|
+
if (!apps.some((entry) => entry.app === app)) throw new VoiceError("voice_config_invalid", 400, `${app} is not a GPU app`);
|
|
346
|
+
const plan = readPlan();
|
|
347
|
+
let binding = `${gpu.node}:${gpu.device}`;
|
|
348
|
+
if (op === "resume" && gpu.type === "MemorySlice") {
|
|
349
|
+
if (plan.off.includes(app) || !(Number(plan.slices[app]) > 0)) {
|
|
350
|
+
throw new VoiceError("voice_config_invalid", 400, `${app} has no memory slice`);
|
|
351
|
+
}
|
|
352
|
+
binding = sliceFlag(gpu, app, plan);
|
|
353
|
+
}
|
|
354
|
+
const args = op === "stop"
|
|
355
|
+
? ["market", "stop", app, "--watch", "--watch-timeout", "10m", "-o", "json"]
|
|
356
|
+
: ["market", "resume", app, "--compute-binding", binding, "--watch", "--watch-timeout", "15m", "-o", "json"];
|
|
357
|
+
pendingApps.set(app, op);
|
|
358
|
+
appErrors.delete(app);
|
|
359
|
+
void runOlaresCli(args, options.spawnFn)
|
|
360
|
+
.then(async (result) => {
|
|
361
|
+
if (result.code !== 0 && !(await settledAs(app, op === "stop" ? "stopped" : "running", options))) {
|
|
362
|
+
appErrors.set(app, result.message);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const latest = readPlan();
|
|
366
|
+
const kept = latest.apps.filter((name) => name !== app);
|
|
367
|
+
writePlan({ ...latest, apps: op === "resume" ? [...kept, app] : kept });
|
|
368
|
+
})
|
|
369
|
+
.catch((err) => appErrors.set(app, err instanceof Error ? err.message : String(err)))
|
|
370
|
+
.finally(() => pendingApps.delete(app));
|
|
371
|
+
return gpuModePayload(options);
|
|
372
|
+
}
|
package/voice/install.js
CHANGED
|
@@ -47,7 +47,11 @@ function describe(chart, row) {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Runs olares-cli as the app's profile; resolves with exit code, parsed JSON, and a readable message.
|
|
52
|
+
* Output past `limit` characters is cut from the front, so JSON larger than that does not parse.
|
|
53
|
+
*/
|
|
54
|
+
export function runOlaresCli(args, spawnFn = spawn, limit = OUTPUT_LIMIT) {
|
|
51
55
|
return new Promise((resolve, reject) => {
|
|
52
56
|
const child = spawnFn("olares-cli", args, {
|
|
53
57
|
env: process.env,
|
|
@@ -56,8 +60,8 @@ function runMarket(args, spawnFn) {
|
|
|
56
60
|
let stdout = "";
|
|
57
61
|
let stderr = "";
|
|
58
62
|
const take = (bucket) => (chunk) => {
|
|
59
|
-
if (bucket === "stdout") stdout = `${stdout}${chunk}`.slice(-
|
|
60
|
-
else stderr = `${stderr}${chunk}`.slice(-
|
|
63
|
+
if (bucket === "stdout") stdout = `${stdout}${chunk}`.slice(-limit);
|
|
64
|
+
else stderr = `${stderr}${chunk}`.slice(-limit);
|
|
61
65
|
};
|
|
62
66
|
child.stdout?.on("data", take("stdout"));
|
|
63
67
|
child.stderr?.on("data", take("stderr"));
|
|
@@ -96,7 +100,7 @@ function accepted(result) {
|
|
|
96
100
|
export async function installVoiceChart(kind, options = {}) {
|
|
97
101
|
const chart = chartFor(kind);
|
|
98
102
|
const spawnFn = options.spawnFn ?? spawn;
|
|
99
|
-
const result = await
|
|
103
|
+
const result = await runOlaresCli(
|
|
100
104
|
["market", "install", chart.app, "-s", "market.test", "--version", chart.version, "--compute-mode", "nvidia", "-o", "json"],
|
|
101
105
|
spawnFn,
|
|
102
106
|
);
|
|
@@ -106,15 +110,28 @@ export async function installVoiceChart(kind, options = {}) {
|
|
|
106
110
|
return { app: chart.app, version: chart.version };
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `status -a` returns one row per market source (catalog, test, upload). An
|
|
115
|
+
* app reinstalled from another source keeps its old `uninstalled` row, so the
|
|
116
|
+
* live one is the latest row that is still installed.
|
|
117
|
+
* @param {unknown} json
|
|
118
|
+
*/
|
|
119
|
+
export function currentChartRow(json) {
|
|
120
|
+
const rows = (Array.isArray(json) ? json : [json]).filter((row) => row && typeof row === "object");
|
|
121
|
+
const installed = rows.filter((row) => chartPhase(row) !== "absent");
|
|
122
|
+
const time = (row) => Date.parse(row.statusTime) || 0;
|
|
123
|
+
return [...(installed.length ? installed : rows)].sort((a, b) => time(b) - time(a))[0] ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
/** @param {"stt" | "tts"} kind */
|
|
110
127
|
export async function voiceChartStatus(kind, options = {}) {
|
|
111
128
|
const chart = chartFor(kind);
|
|
112
|
-
const result = await
|
|
129
|
+
const result = await runOlaresCli(
|
|
113
130
|
["market", "status", chart.app, "-a", "-o", "json"],
|
|
114
131
|
options.spawnFn ?? spawn,
|
|
115
132
|
);
|
|
116
133
|
if (!result.json) throw new VoiceError("voice_install_failed", 502, result.message);
|
|
117
|
-
return describe(chart, result.json);
|
|
134
|
+
return describe(chart, currentChartRow(result.json));
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
export async function voiceChartsStatus(options = {}) {
|
|
@@ -127,7 +144,7 @@ export async function voiceChartsStatus(options = {}) {
|
|
|
127
144
|
|
|
128
145
|
async function settle(kind, verb, code, options) {
|
|
129
146
|
const chart = chartFor(kind);
|
|
130
|
-
const result = await
|
|
147
|
+
const result = await runOlaresCli(
|
|
131
148
|
["market", verb, chart.app, "--watch", "-o", "json"],
|
|
132
149
|
options.spawnFn ?? spawn,
|
|
133
150
|
);
|
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 const EMPTY_VOICE_CONFIG = { model: "", ttsModel: "", language: "", speak: false };
|
|
17
|
+
export const EMPTY_VOICE_CONFIG = { model: "", ttsModel: "", language: "", speak: false, streaming: false };
|
|
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/script.js
CHANGED
|
@@ -8,6 +8,12 @@ import { segment } from "sentencex";
|
|
|
8
8
|
*/
|
|
9
9
|
export const SENTENCE_GAP_MS = 300;
|
|
10
10
|
|
|
11
|
+
/** Text still streaming may hold a code fence that has not closed yet. */
|
|
12
|
+
function closedPart(text) {
|
|
13
|
+
const fences = text.match(/```/g)?.length ?? 0;
|
|
14
|
+
return fences % 2 === 0 ? text : text.slice(0, text.lastIndexOf("```"));
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
function plain(text) {
|
|
12
18
|
return String(text ?? "")
|
|
13
19
|
.replace(/```[\s\S]*?```/g, " ")
|
|
@@ -20,16 +26,59 @@ function plain(text) {
|
|
|
20
26
|
.slice(0, 2000);
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Synthesis time grows with the segment, and the first sound waits for the
|
|
31
|
+
* whole first segment. A long sentence is read clause by clause instead,
|
|
32
|
+
* clauses merged back up to this length. A CJK character counts as one, other
|
|
33
|
+
* characters as a third (roughly a syllable each way).
|
|
34
|
+
*/
|
|
35
|
+
const MAX_SEGMENT_WEIGHT = 24;
|
|
36
|
+
const CJK = /[\u3000-\u9fff\uff00-\uffef]/g;
|
|
37
|
+
// ASCII separators only when a space follows, so 1,000 and 10:30 stay whole.
|
|
38
|
+
const CLAUSE_SPLIT = /(?<=[,;:]|[,;:](?=\s))/;
|
|
39
|
+
const CLAUSE_END = /[,,;;::]$/;
|
|
40
|
+
|
|
41
|
+
function weight(text) {
|
|
42
|
+
const cjk = text.match(CJK)?.length ?? 0;
|
|
43
|
+
return cjk + (text.length - cjk) / 3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function joinClauses(left, right) {
|
|
47
|
+
return /[\u3000-\u9fff\uff00-\uffef]$/.test(left) ? `${left}${right}` : `${left} ${right}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clauses(sentence) {
|
|
51
|
+
if (weight(sentence) <= MAX_SEGMENT_WEIGHT) return [sentence];
|
|
52
|
+
const merged = [];
|
|
53
|
+
for (const part of sentence.split(CLAUSE_SPLIT).map((piece) => piece.trim()).filter(Boolean)) {
|
|
54
|
+
const last = merged.at(-1);
|
|
55
|
+
if (last !== undefined && weight(joinClauses(last, part)) <= MAX_SEGMENT_WEIGHT) {
|
|
56
|
+
merged[merged.length - 1] = joinClauses(last, part);
|
|
57
|
+
} else {
|
|
58
|
+
merged.push(part);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return merged;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A clause break is half a sentence break. */
|
|
65
|
+
export function gapAfter(segment) {
|
|
66
|
+
return CLAUSE_END.test(segment) ? SENTENCE_GAP_MS / 2 : SENTENCE_GAP_MS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The segments to read: sentences, long ones cut into clauses. While the
|
|
71
|
+
* answer is still streaming (`done: false`) its last sentence may be cut
|
|
72
|
+
* mid-word, so only the ones before it count.
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function speechSegments(text, { done = true } = {}) {
|
|
76
|
+
const source = plain(done ? text : closedPart(String(text ?? "")));
|
|
25
77
|
if (!source) return [];
|
|
26
78
|
const sentences = segment("en", source).map((part) => part.trim()).filter(Boolean);
|
|
27
|
-
return sentences.
|
|
28
|
-
text: sentence,
|
|
29
|
-
gapMs: index === sentences.length - 1 ? 0 : SENTENCE_GAP_MS,
|
|
30
|
-
}));
|
|
79
|
+
return (done ? sentences : sentences.slice(0, -1)).flatMap(clauses);
|
|
31
80
|
}
|
|
32
81
|
|
|
33
82
|
export function speechScript(text) {
|
|
34
|
-
return speechSegments(text).
|
|
83
|
+
return speechSegments(text).join("\n");
|
|
35
84
|
}
|