@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 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.voice_gpu_unsupported": "{model} 不支持显存分片,不能边写边读,请使用「写完再读」",
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": "边写边读用显存分片,写完再读用时间分片。切换约 2–3 分钟,期间模型不可用",
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 reading while writing is unavailable. Use \"When done\"",
455
- "error.voice_gpu_unsupported": "{model} does not support memory slicing, so reading while writing is unavailable. Use \"When done\"",
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": "While writing uses memory slicing; when done uses time slicing. Switching takes 2–3 minutes; models are unavailable meanwhile",
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": "While writing",
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 reading while writing",
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",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.25",
3
+ "version": "0.15.27",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
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 text = request.text.trim();
58
- if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
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 text = request.text.trim();
96
- if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
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) throw new Error(String(res.status));
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
- * The NVIDIA card the speech models are bound to.
124
- * @returns {Promise<{ node: string, device: string, model: string, type: string, memory: number, available: string[], bindings: { app: string, memory: number }[] } | null>}
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 async function speechGpu(options = {}) {
127
- const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
128
- if (result.code !== 0 || !Array.isArray(result.json)) {
129
- throw new GpuError(cliProblem(result, "voice_gpu_failed"));
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: false,
27
+ streaming: true,
28
28
  };
29
29
 
30
30
  export const VOICE_PRESETS = Object.freeze(["dictation", "chat", "assistant", "direct"]);
@@ -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
- /** Only trusted Agent events may authorize live text; client input supplies an address, never text. */
10
- export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }) } = {}) {
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 || !row.eligible && !row.done) return;
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: [], eligible: frame.speechEligible === true, utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set(), subscribers: 0 });
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 sentences(text, closed) {
24
- const { positions, balanced } = boundaries(text);
25
- const ends = new Set();
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 = text.indexOf(parts[i], from); if (at < 0) continue;
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 || closed) ends.add(from);
44
+ if (i < parts.length - 1) ends.push(from);
45
+ else if (sliceClosed) ends.push(slice.length);
32
46
  }
33
- for (let i = 0; i < text.length; i++) {
34
- if (/[。!?]/.test(text[i])) {
35
- let end = i + 1;
36
- while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
37
- ends.add(end);
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
- if (closed) ends.add(text.length);
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 };
@@ -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
- return withoutEmoji(plain).split("\n").map((line) => line.replace(/\s+/g, " ").trim());
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 = withoutEmoji(plain).replace(/\s+/g, " ").trim();
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,
@@ -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
  });
@@ -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
- void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => { unsub(); close(); });
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
+ }
@@ -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();