@olares/lares-core 0.15.25 → 0.15.27
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 +12 -10
- package/larepass/settings.js +3 -0
- package/package.json +1 -1
- package/router/tts.js +7 -4
- package/voice/client.js +12 -1
- package/voice/gpu-mode.js +160 -8
- package/voice/options.js +1 -1
- package/voice/reply-stream.js +8 -4
- package/voice/service.js +6 -0
- package/voice/speech-segmenter.js +33 -14
- package/voice/spoken-text.js +13 -3
- package/voice/stream-client.js +7 -3
- package/voice/stream-gateway.js +3 -1
- package/voice/tts-input.js +10 -0
- package/voice/tts-preview.js +3 -1
package/i18n/voice.js
CHANGED
|
@@ -146,8 +146,9 @@ export const ZH = {
|
|
|
146
146
|
"error.voice_speak_unready": "当前不能保存语音播报开关",
|
|
147
147
|
"error.voice_gpu_failed": "切换显卡模式失败({detail})。可以点重试;仍然失败,请到 Olares「设置 → GPU」查看显卡状态",
|
|
148
148
|
"error.voice_gpu_busy": "显卡正在切换,请等它完成后再操作",
|
|
149
|
-
"error.voice_gpu_no_gpu": "这台机器没有可用的 NVIDIA
|
|
150
|
-
"error.
|
|
149
|
+
"error.voice_gpu_no_gpu": "这台机器没有可用的 NVIDIA 显卡,不能流式播报,请使用「写完再读」",
|
|
150
|
+
"error.voice_streaming_time_slice": "当前是时间分片,不能流式播报,请使用「写完再读」",
|
|
151
|
+
"error.voice_gpu_unsupported": "{model} 不支持显存分片,不能流式播报,请使用「写完再读」",
|
|
151
152
|
"error.voice_gpu_plan_empty": "还没有应用分到显存,显卡模式没有改变。请先在下方「显存分片」里开启要用的应用并填好显存,保存后点重试",
|
|
152
153
|
"error.voice_gpu_plan_over": "这些应用至少需要 {need} GiB 显存,超过了显卡的 {total} GiB,显卡模式没有改变。请在下方「显存分片」里关掉一些应用或调小显存,保存后点重试",
|
|
153
154
|
"error.voice_gpu_plan_floor": "{app}的显存不能低于 {size} GiB",
|
|
@@ -214,10 +215,10 @@ export const ZH = {
|
|
|
214
215
|
"settings.phase.failed": "异常",
|
|
215
216
|
"settings.phase.absent": "未安装",
|
|
216
217
|
"settings.mode.title": "播报方式",
|
|
217
|
-
"settings.mode.hint": "
|
|
218
|
+
"settings.mode.hint": "流式播报只在显存分片下可用。时间分片只能写完再读",
|
|
218
219
|
"settings.mode.gpu": "显卡:{type}",
|
|
219
220
|
"settings.mode.turns": "写完再读",
|
|
220
|
-
"settings.mode.streaming": "
|
|
221
|
+
"settings.mode.streaming": "流式播报",
|
|
221
222
|
"settings.mode.switching": "正在切换显卡模式…",
|
|
222
223
|
"settings.mode.resumingApp": "正在恢复 {app}…",
|
|
223
224
|
"settings.mode.restoring": "切换没成功,正在恢复原来的应用…",
|
|
@@ -242,7 +243,7 @@ export const ZH = {
|
|
|
242
243
|
"settings.slices.gpu": "显卡",
|
|
243
244
|
"settings.slices.used": "已分配 {used} / {total} GiB",
|
|
244
245
|
"settings.slices.hint": "选择哪些应用常驻显卡、各分多少显存。保存后只重启有改动的应用",
|
|
245
|
-
"settings.slices.hintPending": "
|
|
246
|
+
"settings.slices.hintPending": "切到流式播报时,按这里的配置分配显存",
|
|
246
247
|
"settings.slices.size": "{app}的显存",
|
|
247
248
|
"settings.slices.enable": "{app}参与显存分片",
|
|
248
249
|
"settings.slices.empty": "还没有应用绑定到这张显卡",
|
|
@@ -451,8 +452,9 @@ export const EN = {
|
|
|
451
452
|
"error.voice_gpu_failed":
|
|
452
453
|
"Couldn't switch the GPU mode ({detail}). Retry; if it keeps failing, check the GPU in Olares Settings → GPU",
|
|
453
454
|
"error.voice_gpu_busy": "The GPU is switching. Wait for it to finish",
|
|
454
|
-
"error.voice_gpu_no_gpu": "This machine has no usable NVIDIA GPU, so
|
|
455
|
-
"error.
|
|
455
|
+
"error.voice_gpu_no_gpu": "This machine has no usable NVIDIA GPU, so streaming readout is unavailable. Use \"When done\"",
|
|
456
|
+
"error.voice_streaming_time_slice": "Time slicing cannot use streaming readout. Use \"When done\"",
|
|
457
|
+
"error.voice_gpu_unsupported": "{model} does not support memory slicing, so streaming readout is unavailable. Use \"When done\"",
|
|
456
458
|
"error.voice_gpu_plan_empty":
|
|
457
459
|
"No app has a memory slice yet, so the GPU mode was left unchanged. Turn on the apps you need under Memory slices below, set their memory, save, then retry",
|
|
458
460
|
"error.voice_gpu_plan_over":
|
|
@@ -525,10 +527,10 @@ export const EN = {
|
|
|
525
527
|
"settings.phase.failed": "Failed",
|
|
526
528
|
"settings.phase.absent": "Not installed",
|
|
527
529
|
"settings.mode.title": "Reading mode",
|
|
528
|
-
"settings.mode.hint": "
|
|
530
|
+
"settings.mode.hint": "Streaming readout is available only with memory slicing. Time slicing reads the finished answer",
|
|
529
531
|
"settings.mode.gpu": "GPU: {type}",
|
|
530
532
|
"settings.mode.turns": "When done",
|
|
531
|
-
"settings.mode.streaming": "
|
|
533
|
+
"settings.mode.streaming": "Streaming readout",
|
|
532
534
|
"settings.mode.switching": "Switching GPU mode…",
|
|
533
535
|
"settings.mode.resumingApp": "Resuming {app}…",
|
|
534
536
|
"settings.mode.restoring": "The switch failed. Restoring the apps that were running…",
|
|
@@ -553,7 +555,7 @@ export const EN = {
|
|
|
553
555
|
"settings.slices.gpu": "GPU",
|
|
554
556
|
"settings.slices.used": "{used} / {total} GiB allocated",
|
|
555
557
|
"settings.slices.hint": "Choose which apps stay on the GPU and how much memory each gets. Saving restarts only the apps that changed",
|
|
556
|
-
"settings.slices.hintPending": "Applied when switching to
|
|
558
|
+
"settings.slices.hintPending": "Applied when switching to streaming readout",
|
|
557
559
|
"settings.slices.size": "Memory for {app}",
|
|
558
560
|
"settings.slices.enable": "{app} uses a memory slice",
|
|
559
561
|
"settings.slices.empty": "No apps are bound to this GPU yet",
|
package/larepass/settings.js
CHANGED
|
@@ -111,6 +111,9 @@ export function createHostSettings(request) {
|
|
|
111
111
|
};
|
|
112
112
|
}, { force: Boolean(force) });
|
|
113
113
|
},
|
|
114
|
+
async readingMode() {
|
|
115
|
+
return payload(await request("/api/lares/voice/reading-mode"), "/api/lares/voice/reading-mode");
|
|
116
|
+
},
|
|
114
117
|
async saveVoice(patch, { diagnostics = true } = {}) {
|
|
115
118
|
const config = payload(
|
|
116
119
|
await request("/api/lares/voice/config", { method: "POST", body: patch }),
|
package/package.json
CHANGED
package/router/tts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { speechInput } from "../voice/tts-input.js";
|
|
1
2
|
import { fetchTtsVoices } from "../voice/tts-voice.js";
|
|
2
3
|
import { VoiceError } from "./stt.js";
|
|
3
4
|
import { routerShimBaseUrl } from "./gateway.js";
|
|
@@ -54,8 +55,9 @@ export function pcmToWav(pcm, sampleRate = PCM_RATE) {
|
|
|
54
55
|
* @returns {Promise<Buffer>}
|
|
55
56
|
*/
|
|
56
57
|
export async function synthesize(request) {
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
58
|
+
const spoken = request.text.trim();
|
|
59
|
+
if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
60
|
+
const text = speechInput(request.model, spoken);
|
|
59
61
|
let res;
|
|
60
62
|
try {
|
|
61
63
|
res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
|
|
@@ -92,8 +94,9 @@ export async function synthesize(request) {
|
|
|
92
94
|
* @param {(chunk: Buffer) => Promise<void> | void} onChunk
|
|
93
95
|
*/
|
|
94
96
|
export async function synthesizeStream(request, onChunk) {
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
97
|
+
const spoken = request.text.trim();
|
|
98
|
+
if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
99
|
+
const text = speechInput(request.model, spoken);
|
|
97
100
|
let res;
|
|
98
101
|
try {
|
|
99
102
|
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
package/voice/client.js
CHANGED
|
@@ -125,7 +125,11 @@ export async function saveVoiceSettings(patch) {
|
|
|
125
125
|
headers: { "content-type": "application/json" },
|
|
126
126
|
body: JSON.stringify(patch),
|
|
127
127
|
});
|
|
128
|
-
if (!res.ok)
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
const failed = await res.json().catch(() => null);
|
|
130
|
+
const code = failed?.error?.code;
|
|
131
|
+
throw Object.assign(new Error(code || String(res.status)), code ? { code } : {});
|
|
132
|
+
}
|
|
129
133
|
const config = await res.json();
|
|
130
134
|
configRevision++;
|
|
131
135
|
confirmedConfig = config;
|
|
@@ -142,6 +146,13 @@ export async function loadGpuMode() {
|
|
|
142
146
|
return res.json();
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/** Reading mode and whether this card can read while writing. */
|
|
150
|
+
export async function loadReadingMode() {
|
|
151
|
+
const res = await fetch(`${API}/reading-mode`);
|
|
152
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
153
|
+
return res.json();
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
/** A failed GPU request keeps the Host's code and params for gpuProblemText. */
|
|
146
157
|
async function gpuResponse(res) {
|
|
147
158
|
const payload = await res.json().catch(() => null);
|
package/voice/gpu-mode.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import https from "node:https";
|
|
1
3
|
import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
|
|
2
4
|
import { dshPluginConfigPath } from "../workspace/home.js";
|
|
3
5
|
import { VoiceError } from "../router/stt.js";
|
|
@@ -119,18 +121,41 @@ function speechDevice(nodes) {
|
|
|
119
121
|
?? null;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
let seenGpu = undefined;
|
|
125
|
+
|
|
126
|
+
/** Last card `speechGpu` observed. `undefined` means it has not been asked yet. */
|
|
127
|
+
export function noteSpeechGpu(gpu) {
|
|
128
|
+
seenGpu = gpu ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
132
|
+
* Read-while-writing only while the saved mode asks for it and the card is
|
|
133
|
+
* memory-sliced. Time slicing cannot keep the language model and speech model
|
|
134
|
+
* resident together, so a saved streaming flag does not override that.
|
|
125
135
|
*/
|
|
126
|
-
export
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
export function streamingEnabled() {
|
|
137
|
+
return readConfig().streaming === true && seenGpu?.type === "MemorySlice";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the reading-mode control needs, without listing every GPU app. */
|
|
141
|
+
export async function readingModePayload(options = {}) {
|
|
142
|
+
let gpu = null;
|
|
143
|
+
let problem = null;
|
|
144
|
+
try {
|
|
145
|
+
gpu = await speechGpu(options);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
problem = problemOf(err);
|
|
130
148
|
}
|
|
131
|
-
const device = speechDevice(result.json);
|
|
132
|
-
if (!device) return null;
|
|
133
149
|
return {
|
|
150
|
+
streaming: readConfig().streaming === true,
|
|
151
|
+
allowed: gpu?.type === "MemorySlice",
|
|
152
|
+
gpu: gpu ? { type: gpu.type, model: gpu.model } : null,
|
|
153
|
+
problem,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function gpuFromDevice(device) {
|
|
158
|
+
const gpu = {
|
|
134
159
|
node: String(device.nodeName ?? ""),
|
|
135
160
|
device: String(device.id ?? ""),
|
|
136
161
|
model: String(device.cardModel ?? ""),
|
|
@@ -142,6 +167,133 @@ export async function speechGpu(options = {}) {
|
|
|
142
167
|
memory: Number(binding?.memory) || 0,
|
|
143
168
|
})).filter((binding) => binding.app),
|
|
144
169
|
};
|
|
170
|
+
noteSpeechGpu(gpu);
|
|
171
|
+
return gpu;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SHARE_MODE_PREFIX = "sharemode.gpu.bytetrade.io/";
|
|
175
|
+
const NVIDIA_REGISTER = "hami.io/node-nvidia-register";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Node annotation `sharemode.gpu.bytetrade.io/<uuid>` uses the platform's
|
|
179
|
+
* legacy codes: 0 exclusive, 1 memory slice, anything else time slice.
|
|
180
|
+
*/
|
|
181
|
+
export function supportTypeFromShareMode(mode) {
|
|
182
|
+
if (mode === "0") return "Exclusive";
|
|
183
|
+
if (mode === "1") return "MemorySlice";
|
|
184
|
+
return "TimeSlice";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The card the speech models use, from the node's share-mode annotation and
|
|
189
|
+
* the GPUBinding objects. Memory in those objects is MiB.
|
|
190
|
+
* @returns {ReturnType<typeof gpuFromDevice> | null}
|
|
191
|
+
*/
|
|
192
|
+
export function gpuFromClusterState(nodeList, bindingList) {
|
|
193
|
+
const nodes = Array.isArray(nodeList?.items) ? nodeList.items : [];
|
|
194
|
+
const bindings = Array.isArray(bindingList?.items) ? bindingList.items : [];
|
|
195
|
+
const speechApps = new Set(Object.values(VOICE_CHARTS).map((chart) => chart.app));
|
|
196
|
+
const cards = [];
|
|
197
|
+
for (const node of nodes) {
|
|
198
|
+
const annotations = node?.metadata?.annotations ?? {};
|
|
199
|
+
const register = String(annotations[NVIDIA_REGISTER] ?? "");
|
|
200
|
+
const rows = register.split(":").map((row) => row.split(",")).filter((fields) => fields[0]);
|
|
201
|
+
for (const [key, value] of Object.entries(annotations)) {
|
|
202
|
+
if (!key.startsWith(SHARE_MODE_PREFIX)) continue;
|
|
203
|
+
const device = key.slice(SHARE_MODE_PREFIX.length);
|
|
204
|
+
if (!device) continue;
|
|
205
|
+
const card = rows.find((fields) => fields[0] === device);
|
|
206
|
+
const bound = bindings.filter((item) => item?.spec?.uuid === device);
|
|
207
|
+
cards.push({
|
|
208
|
+
node: String(node?.metadata?.name ?? ""),
|
|
209
|
+
device,
|
|
210
|
+
model: card?.[4] ? String(card[4]) : "",
|
|
211
|
+
type: supportTypeFromShareMode(String(value ?? "").trim()),
|
|
212
|
+
memory: card ? (Number(card[2]) || 0) * MIB : 0,
|
|
213
|
+
available: ["TimeSlice", "MemorySlice", "Exclusive"],
|
|
214
|
+
bindings: bound.map((item) => ({
|
|
215
|
+
app: String(item?.spec?.appName ?? ""),
|
|
216
|
+
memory: (Number(item?.spec?.memory) || 0) * MIB,
|
|
217
|
+
})).filter((binding) => binding.app),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const chosen = cards.find((card) => card.bindings.some((binding) => speechApps.has(binding.app))) ?? cards[0] ?? null;
|
|
222
|
+
if (!chosen) return null;
|
|
223
|
+
noteSpeechGpu(chosen);
|
|
224
|
+
return chosen;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function kubeGet(path) {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
Promise.all([
|
|
230
|
+
readFile("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"),
|
|
231
|
+
readFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
|
|
232
|
+
]).then(([token, ca]) => {
|
|
233
|
+
const req = https.request({
|
|
234
|
+
hostname: "kubernetes.default.svc",
|
|
235
|
+
path,
|
|
236
|
+
method: "GET",
|
|
237
|
+
ca,
|
|
238
|
+
headers: { Authorization: `Bearer ${token.trim()}` },
|
|
239
|
+
timeout: 8000,
|
|
240
|
+
}, (res) => {
|
|
241
|
+
let body = "";
|
|
242
|
+
res.setEncoding("utf8");
|
|
243
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
244
|
+
res.on("end", () => {
|
|
245
|
+
if (res.statusCode !== 200) {
|
|
246
|
+
reject(new Error(`kubernetes ${path} returned ${res.statusCode}`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
resolve(JSON.parse(body));
|
|
251
|
+
} catch (err) {
|
|
252
|
+
reject(err);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
req.on("error", reject);
|
|
257
|
+
req.on("timeout", () => req.destroy(new Error("kubernetes API timed out")));
|
|
258
|
+
req.end();
|
|
259
|
+
}).catch(reject);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function speechGpuFromCluster(options) {
|
|
264
|
+
const get = options.kubeGet ?? kubeGet;
|
|
265
|
+
const [nodes, bindings] = await Promise.all([
|
|
266
|
+
get("/api/v1/nodes"),
|
|
267
|
+
get("/apis/gpu.bytetrade.io/v1alpha1/gpubindings"),
|
|
268
|
+
]);
|
|
269
|
+
return gpuFromClusterState(nodes, bindings);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The NVIDIA card the speech models are bound to.
|
|
274
|
+
* The pod CLI profile is the first source. When that profile is not mounted,
|
|
275
|
+
* the node's share-mode annotation is the same fact the settings page shows.
|
|
276
|
+
* @returns {Promise<{ node: string, device: string, model: string, type: string, memory: number, available: string[], bindings: { app: string, memory: number }[] } | null>}
|
|
277
|
+
*/
|
|
278
|
+
export async function speechGpu(options = {}) {
|
|
279
|
+
const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
|
|
280
|
+
if (result.code === 0 && Array.isArray(result.json)) {
|
|
281
|
+
const device = speechDevice(result.json);
|
|
282
|
+
if (!device) {
|
|
283
|
+
noteSpeechGpu(null);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
return gpuFromDevice(device);
|
|
287
|
+
}
|
|
288
|
+
const problem = cliProblem(result, "voice_gpu_failed");
|
|
289
|
+
if (problem.code === "voice_gpu_login") {
|
|
290
|
+
try {
|
|
291
|
+
return await speechGpuFromCluster(options);
|
|
292
|
+
} catch {
|
|
293
|
+
/* The annotation is unreadable; the CLI failure is what we can report. */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw new GpuError(problem);
|
|
145
297
|
}
|
|
146
298
|
|
|
147
299
|
/**
|
package/voice/options.js
CHANGED
|
@@ -24,7 +24,7 @@ export const VOICE_DEFAULTS = {
|
|
|
24
24
|
language: "",
|
|
25
25
|
speak: false,
|
|
26
26
|
// Read answers while they are written. Needs the GPU in MemorySlice, see gpu-mode.js.
|
|
27
|
-
streaming:
|
|
27
|
+
streaming: true,
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
export const VOICE_PRESETS = Object.freeze(["dictation", "chat", "assistant", "direct"]);
|
package/voice/reply-stream.js
CHANGED
|
@@ -6,13 +6,17 @@ const STATUS = { voice_reply_invalid: 400, voice_reply_forbidden: 403 };
|
|
|
6
6
|
const fault = code => new VoiceError(code, STATUS[code] ?? 409, code);
|
|
7
7
|
const identity = source => JSON.stringify([source.sessionId, source.turn, source.step]);
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Trusted Agent text only; a client supplies an address, never the words.
|
|
11
|
+
* Read-while-writing releases each closed sentence as it arrives. Reading the
|
|
12
|
+
* finished answer waits until that text has settled. A tool call ends either readout.
|
|
13
|
+
*/
|
|
14
|
+
export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }), streaming = () => false } = {}) {
|
|
11
15
|
const rows = new Map();
|
|
12
16
|
const notify = row => { row.version = (row.version || 0) + 1; for (const wake of row.listeners) wake(); };
|
|
13
17
|
const fail = (row, code) => { row.segmenter.reset(); row.ready = []; row.error = code; row.done = true; row.at = now(); notify(row); };
|
|
14
18
|
const advance = row => {
|
|
15
|
-
if (!row.subscribers || row.error ||
|
|
19
|
+
if (!row.subscribers || row.error || streaming() !== true && !row.done) return;
|
|
16
20
|
try { row.ready.push(...row.segmenter.update(row.utterance?.text || "", { done: row.done })); }
|
|
17
21
|
catch (error) { fail(row, error.message === "voice_reply_rewritten" ? error.message : "voice_reply_invalid"); }
|
|
18
22
|
};
|
|
@@ -28,7 +32,7 @@ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segm
|
|
|
28
32
|
}
|
|
29
33
|
if (rows.size >= 128) return;
|
|
30
34
|
rows.set(key, { sessionId, turn: frame.turn, step: frame.step, attemptId: frame.attemptId,
|
|
31
|
-
segmenter: segmenterFactory(), ready: [],
|
|
35
|
+
segmenter: segmenterFactory(), ready: [], utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
|
|
32
36
|
return;
|
|
33
37
|
}
|
|
34
38
|
for (const row of rows.values()) {
|
package/voice/service.js
CHANGED
|
@@ -158,6 +158,12 @@ export async function saveVoiceConfig(body) {
|
|
|
158
158
|
throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
if (patch.streaming === true) {
|
|
162
|
+
const { speechGpu } = await import("./gpu-mode.js");
|
|
163
|
+
const gpu = await speechGpu();
|
|
164
|
+
if (!gpu) throw new VoiceError("voice_gpu_no_gpu", 409, "no NVIDIA GPU");
|
|
165
|
+
if (gpu.type !== "MemorySlice") throw new VoiceError("voice_streaming_time_slice", 409, gpu.type || "TimeSlice");
|
|
166
|
+
}
|
|
161
167
|
if (patch.ttsVoiceByModel) {
|
|
162
168
|
const current = readConfig().ttsVoiceByModel;
|
|
163
169
|
const changed = Object.entries(patch.ttsVoiceByModel).filter(([model, voice]) => voice && current[model] !== voice);
|
|
@@ -20,30 +20,49 @@ function boundaries(text) {
|
|
|
20
20
|
return { positions, balanced: !stack.length };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
23
|
+
function cjkCuts(text, positions) {
|
|
24
|
+
const cuts = [];
|
|
25
|
+
for (let i = 0; i < text.length; i++) {
|
|
26
|
+
if (!/[。!?]/.test(text[i])) continue;
|
|
27
|
+
let end = i + 1;
|
|
28
|
+
while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
|
|
29
|
+
if (positions.has(end)) cuts.push(end);
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(cuts)].sort((a, b) => a - b);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// sentencex is an English segmenter. A Chinese 。!? already closed the previous
|
|
35
|
+
// sentence; feeding that terminator back in makes it treat "Mr." as another end.
|
|
36
|
+
function sentenceCuts(slice, sliceClosed) {
|
|
37
|
+
const parts = segment("en", slice);
|
|
38
|
+
const ends = [];
|
|
26
39
|
let from = 0;
|
|
27
|
-
const parts = segment("en", text);
|
|
28
40
|
for (let i = 0; i < parts.length; i++) {
|
|
29
|
-
const at =
|
|
41
|
+
const at = slice.indexOf(parts[i], from);
|
|
42
|
+
if (at < 0) continue;
|
|
30
43
|
from = at + parts[i].length;
|
|
31
|
-
if (i < parts.length - 1
|
|
44
|
+
if (i < parts.length - 1) ends.push(from);
|
|
45
|
+
else if (sliceClosed) ends.push(slice.length);
|
|
32
46
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
return ends;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sentences(text, closed) {
|
|
51
|
+
const { positions, balanced } = boundaries(text);
|
|
52
|
+
const ends = new Set();
|
|
53
|
+
const cuts = cjkCuts(text, positions);
|
|
54
|
+
let start = 0;
|
|
55
|
+
for (const cut of cuts) {
|
|
56
|
+
for (const end of sentenceCuts(text.slice(start, cut), true)) ends.add(start + end);
|
|
57
|
+
start = cut;
|
|
39
58
|
}
|
|
40
|
-
|
|
59
|
+
for (const end of sentenceCuts(text.slice(start), closed)) ends.add(start + end);
|
|
41
60
|
const out = []; let consumed = 0;
|
|
42
61
|
for (const end of [...ends].sort((a, b) => a - b)) {
|
|
43
62
|
if (!positions.has(end) && !(closed && end === text.length)) continue;
|
|
44
63
|
const sentence = text.slice(consumed, end).trim();
|
|
45
64
|
if (sentence.includes("\uFFFC")) break;
|
|
46
|
-
if (sentence) out.push(sentence);
|
|
65
|
+
if (/[\p{L}\p{N}]/u.test(sentence)) out.push(sentence);
|
|
47
66
|
consumed = end;
|
|
48
67
|
}
|
|
49
68
|
return { out, consumed, balanced };
|
package/voice/spoken-text.js
CHANGED
|
@@ -100,6 +100,14 @@ function withoutEmoji(text) {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// A longer unbroken identifier is not speech. Synthesizing it stalls the one
|
|
104
|
+
// shared speech path, so this answer and the next one stay silent. Dots stay
|
|
105
|
+
// outside the run so "First one." is still a sentence; a following short
|
|
106
|
+
// extension (`name.jpg`) leaves with the run.
|
|
107
|
+
function withoutUnsayable(text) {
|
|
108
|
+
return text.replace(/[A-Za-z0-9][A-Za-z0-9_~:/?#@!$&*+=%-]{31,}(?:\.[A-Za-z0-9]{1,8})?/g, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
/** The assistant's spoken copy; never apply this to stored messages or raw TTS API input. */
|
|
104
112
|
function prepareSource(text, done, structural = false) {
|
|
105
113
|
let pending = false;
|
|
@@ -143,9 +151,10 @@ export function spokenLines(text, { done = true } = {}) {
|
|
|
143
151
|
plain = plain.slice(0, lineStart) + end.slice(0, tail.index);
|
|
144
152
|
pending = true;
|
|
145
153
|
}
|
|
146
|
-
if (pending || inline.pending) plain += "\uFFFC";
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
plain = withoutUnsayable(withoutEmoji(plain));
|
|
156
|
+
if (!done && (pending || inline.pending)) plain += "\uFFFC";
|
|
157
|
+
return plain.split("\n").map((line) => line.replace(/\s+/g, " ").trim());
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
|
|
@@ -183,8 +192,9 @@ export function spokenBlocks(text, { done = true } = {}) {
|
|
|
183
192
|
const tail = [...graphemes.segment(plain.trimEnd())].at(-1);
|
|
184
193
|
if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) { plain = plain.slice(0, tail.index); opaque = true; }
|
|
185
194
|
}
|
|
195
|
+
plain = withoutUnsayable(withoutEmoji(plain));
|
|
186
196
|
if (!done && opaque) plain += "\uFFFC";
|
|
187
|
-
plain =
|
|
197
|
+
plain = plain.replace(/\s+/g, " ").trim();
|
|
188
198
|
const continuation = lists.length && paragraphs.has(items.at(-1));
|
|
189
199
|
if (lists.length) paragraphs.add(items.at(-1));
|
|
190
200
|
if (plain) result.push({ id, text: plain, closed,
|
package/voice/stream-client.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function playAudioStream(options) {
|
|
|
49
49
|
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
50
50
|
},
|
|
51
51
|
onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
|
|
52
|
-
let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
52
|
+
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
53
53
|
options.controls?.({
|
|
54
54
|
get canPause() { return ready; },
|
|
55
55
|
pause() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
|
|
@@ -69,10 +69,14 @@ export async function playAudioStream(options) {
|
|
|
69
69
|
options.signal?.addEventListener("abort", cancelled, { once: true });
|
|
70
70
|
connection.addEventListener("message", ({ data }) => {
|
|
71
71
|
if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
|
|
72
|
+
let event = null;
|
|
73
|
+
if (typeof data === "string") {
|
|
74
|
+
try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
|
|
75
|
+
if (event?.type === "done") ending = true;
|
|
76
|
+
}
|
|
72
77
|
chain = chain.then(async () => {
|
|
73
78
|
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
74
79
|
if (typeof data !== "string") { player.write(new Uint8Array(data)); return; }
|
|
75
|
-
const event = JSON.parse(data);
|
|
76
80
|
if (event.type === "segment") {
|
|
77
81
|
if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
|
|
78
82
|
segmentIndex = event.index;
|
|
@@ -88,7 +92,7 @@ export async function playAudioStream(options) {
|
|
|
88
92
|
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
89
93
|
connection.addEventListener("close", () => {
|
|
90
94
|
options.signal?.removeEventListener("abort", cancelled);
|
|
91
|
-
if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
|
|
95
|
+
if (!complete && !ending) { const error = dropped(); noteFailure(error); reject(error); }
|
|
92
96
|
}, { once: true });
|
|
93
97
|
if (options.signal?.aborted) cancelled();
|
|
94
98
|
});
|
package/voice/stream-gateway.js
CHANGED
|
@@ -159,7 +159,9 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
159
159
|
else if (event.type === "error") throw fault(event.code || "voice_stream_failed");
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
|
-
|
|
162
|
+
// `done` means the bytes have been handed over. The browser still has up to
|
|
163
|
+
// the two-second send-ahead queued. Closing now is a disconnect and stops that tail.
|
|
164
|
+
void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => unsub());
|
|
163
165
|
}
|
|
164
166
|
});
|
|
165
167
|
},
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const QWEN_12HZ = /qwen3-tts|audioqwen3tts/i;
|
|
2
|
+
|
|
3
|
+
/** 12Hz Qwen drops its last codec frames. This tail is not spoken, so those frames land on it. */
|
|
4
|
+
export const QWEN_SPEECH_TAIL = " ^.";
|
|
5
|
+
|
|
6
|
+
export function speechInput(model, text) {
|
|
7
|
+
const body = String(text ?? "");
|
|
8
|
+
if (!QWEN_12HZ.test(String(model ?? ""))) return body;
|
|
9
|
+
return body.endsWith(QWEN_SPEECH_TAIL) ? body : `${body}${QWEN_SPEECH_TAIL}`;
|
|
10
|
+
}
|
package/voice/tts-preview.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { speechInput } from "./tts-input.js";
|
|
2
|
+
|
|
1
3
|
/** Explicit audition is independent of automatic read-aloud and business sessions. */
|
|
2
4
|
export async function previewTts({ model, voice, text, signal }, { fetcher = fetch, createAudio = url => new Audio(url) } = {}) {
|
|
3
5
|
const response = await fetcher("/llm/v1/audio/speech", {
|
|
4
6
|
method: "POST", headers: { "content-type": "application/json" }, signal,
|
|
5
|
-
body: JSON.stringify({ model, input: text, response_format: "wav", ...(voice ? { voice } : {}) }),
|
|
7
|
+
body: JSON.stringify({ model, input: speechInput(model, text), response_format: "wav", ...(voice ? { voice } : {}) }),
|
|
6
8
|
});
|
|
7
9
|
if (!response.ok) throw new Error("tts_preview_failed");
|
|
8
10
|
const blob = await response.blob(); signal.throwIfAborted();
|