@memef1f1y/dsh-plugin-tts 0.6.0

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.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +33 -0
  2. package/ANALYSIS.md +205 -0
  3. package/LICENSE +21 -0
  4. package/PR-REVIEW.md +75 -0
  5. package/README.md +297 -0
  6. package/README.zh.md +263 -0
  7. package/Snipaste_2026-08-16_13-28-10.png +0 -0
  8. package/Snipaste_2026-08-16_13-28-23.png +0 -0
  9. package/Snipaste_2026-08-16_13-28-37.png +0 -0
  10. package/Snipaste_2026-08-16_13-29-01.png +0 -0
  11. package/Snipaste_2026-08-16_13-29-11.png +0 -0
  12. package/TODO.md +23 -0
  13. package/cordis.patch.yml +4 -0
  14. package/docs/CLOUD-GUIDE.md +216 -0
  15. package/docs/COSYVOICE-GUIDE.md +216 -0
  16. package/docs/EDGE-GUIDE.md +166 -0
  17. package/docs/HOST-CONTRACT.md +36 -0
  18. package/docs/INDEX-TTS2-GUIDE.md +208 -0
  19. package/docs/RVC-GUIDE.md +390 -0
  20. package/docs/USER-GUIDE.md +334 -0
  21. package/docs/acceptance-checklist.md +148 -0
  22. package/docs/adaptive-chunked-playback.md +194 -0
  23. package/docs/macos-test-prompt.md +230 -0
  24. package/docs/windows-gpu-verify-prompt.md +151 -0
  25. package/lib/client.js +7036 -0
  26. package/lib/index.mjs +3288 -0
  27. package/logo.png +0 -0
  28. package/package.json +52 -0
  29. package/pnpm-workspace.yaml +7 -0
  30. package/rvc-server.py +564 -0
  31. package/tests/README.md +45 -0
  32. package/tests/client-load.mjs +393 -0
  33. package/tests/e2e-compact-index.mjs +86 -0
  34. package/tests/e2e-index-ab.mjs +138 -0
  35. package/tests/e2e-real-rvc.mjs +122 -0
  36. package/tests/i18n-keys.mjs +82 -0
  37. package/tests/mock-registry.mjs +50 -0
  38. package/tests/package-runtime-patch.mjs +88 -0
  39. package/tests/rvc-server-live.mjs +230 -0
  40. package/tests/smoke.mjs +1107 -0
  41. package/tools/gen-ui-preview.mjs +174 -0
  42. package/tools/mac-verify.sh +158 -0
  43. package/tools/make-pack.mjs +155 -0
  44. package/tools/package-runtime.py +214 -0
package/lib/index.mjs ADDED
@@ -0,0 +1,3288 @@
1
+ // @dsh-external/dsh-plugin-tts — Host half.
2
+ // Registers webServer routes:
3
+ // POST /dsh-tts-api/speak { text, voice } -> { url | jobId+chunks } | { error }
4
+ // GET /dsh-tts-audio/<id> -> audio bytes
5
+ // GET /dsh-tts-api/rvc-next?job=<id> -> { url, more } | { done }
6
+ // GET /dsh-tts-api/rvc-files?baseUrl=&kind= -> { files }
7
+ // POST /dsh-tts-api/rvc-compact-index -> compact .index
8
+ // GET /dsh-tts-api/rvc-packs?registry=<url> -> { packs } (voice-pack manifest)
9
+ // GET /dsh-tts-api/rvc-packs-installed -> { installed }
10
+ // POST /dsh-tts-api/rvc-pack-install {registry, packId} -> download+sha256-verify+install
11
+ // Synthesis runs a zero-dependency Edge TTS worker via `node -e`
12
+ // (Sec-MS-GEC query-param protocol, mirroring node-edge-tts@1.2.10).
13
+ import { spawn } from 'node:child_process';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
15
+ import { createHash } from 'node:crypto';
16
+ import { request as httpRequest } from 'node:http';
17
+ import { request as httpsRequest } from 'node:https';
18
+ import tls from 'node:tls';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ export const name = 'tts';
23
+ export const inject = ['webServer'];
24
+
25
+ const WORKER_SRC = `// edge-tts-worker — zero-dependency Edge TTS synthesis (Node >= 22).
26
+ // Transient failures are retried once with backoff; auth/voice errors fail fast.
27
+ const crypto = require('node:crypto');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
32
+ const CHROMIUM_FULL_VERSION = '143.0.3650.75';
33
+ const WINDOWS_FILE_TIME_EPOCH = 11644473600n;
34
+ const WSS_BASE = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
35
+ const CRLF = String.fromCharCode(13, 10);
36
+ function generateSecMsGecToken() {
37
+ const ticks = BigInt(Math.floor(Date.now() / 1000) + Number(WINDOWS_FILE_TIME_EPOCH)) * 10000000n;
38
+ const roundedTicks = ticks - (ticks % 3000000000n);
39
+ const hash = crypto.createHash('sha256');
40
+ hash.update(String(roundedTicks) + TRUSTED_CLIENT_TOKEN, 'ascii');
41
+ return hash.digest('hex').toUpperCase();
42
+ }
43
+ function readStdin() {
44
+ return new Promise(function (resolve, reject) {
45
+ let data = '';
46
+ process.stdin.setEncoding('utf8');
47
+ process.stdin.on('data', function (c) { data += c; });
48
+ process.stdin.on('end', function () { resolve(data); });
49
+ process.stdin.on('error', reject);
50
+ });
51
+ }
52
+ function xmlEscape(s) {
53
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
54
+ }
55
+ function fail(msg) {
56
+ console.error('ERR ' + msg);
57
+ process.exit(1);
58
+ }
59
+ function isHardWorkerError(msg) {
60
+ return /40[013]|1007|unsupported voice|empty text/i.test(String(msg || ''));
61
+ }
62
+ function synthesizeOnce(voice, text, outPath, lang, rate, pitch, volume) {
63
+ return new Promise(function (resolve, reject) {
64
+ const secMsGec = generateSecMsGecToken();
65
+ const url = WSS_BASE + '?TrustedClientToken=' + TRUSTED_CLIENT_TOKEN + '&Sec-MS-GEC=' + secMsGec + '&Sec-MS-GEC-Version=1-' + CHROMIUM_FULL_VERSION;
66
+ const ws = new WebSocket(url, {
67
+ headers: {
68
+ 'Pragma': 'no-cache',
69
+ 'Cache-Control': 'no-cache',
70
+ 'Origin': 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold',
71
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0',
72
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
73
+ 'Accept-Language': 'en-US,en;q=0.9'
74
+ }
75
+ });
76
+ ws.binaryType = 'arraybuffer';
77
+ const chunks = [];
78
+ let settled = false;
79
+ let pendingError = null;
80
+ let errorTimer = null;
81
+ function describeWsError(e) {
82
+ try {
83
+ if (!e || typeof e !== 'object') return 'websocket error: ' + String(e);
84
+ const parts = [];
85
+ if (e.type) parts.push('type=' + e.type);
86
+ if (e.code !== undefined && e.code !== null && e.code !== '') parts.push('code=' + e.code);
87
+ if (e.reason) parts.push('reason=' + String(e.reason).slice(0, 120));
88
+ if (e.message) parts.push('message=' + String(e.message).slice(0, 200));
89
+ else {
90
+ const s = String(e);
91
+ if (s && s !== '[object Event]' && s !== '[object ErrorEvent]' && s !== '[object Object]') {
92
+ parts.push('detail=' + s.slice(0, 200));
93
+ }
94
+ }
95
+ parts.push('url=speech.platform.bing.com/edge/v1');
96
+ return 'websocket error (' + parts.join(' ') + ')';
97
+ } catch (err) {
98
+ return 'websocket error: ' + String(e);
99
+ }
100
+ }
101
+ function finish() {
102
+ if (settled) return;
103
+ settled = true;
104
+ if (errorTimer) { clearTimeout(errorTimer); errorTimer = null; }
105
+ const buf = Buffer.concat(chunks);
106
+ if (buf.length < 100) {
107
+ try { ws.close(); } catch (e) {}
108
+ reject('audio too small: ' + buf.length);
109
+ return;
110
+ }
111
+ try {
112
+ fs.writeFileSync(outPath, buf);
113
+ try { ws.close(); } catch (e) {}
114
+ resolve(buf.length);
115
+ } catch (e) {
116
+ reject('write ' + e.message);
117
+ }
118
+ }
119
+ ws.onerror = function (e) {
120
+ pendingError = describeWsError(e);
121
+ if (errorTimer) clearTimeout(errorTimer);
122
+ errorTimer = setTimeout(function () {
123
+ if (!settled) {
124
+ settled = true;
125
+ errorTimer = null;
126
+ reject(pendingError);
127
+ }
128
+ }, 1500);
129
+ };
130
+ ws.onclose = function (e) {
131
+ if (settled) return;
132
+ const code = e && e.code;
133
+ const reason = e && e.reason;
134
+ if (pendingError) {
135
+ if (errorTimer) { clearTimeout(errorTimer); errorTimer = null; }
136
+ settled = true;
137
+ reject(pendingError + ' closed code=' + code + ' reason=' + String(reason || '').slice(0, 120));
138
+ return;
139
+ }
140
+ reject('closed early code=' + code + ' reason=' + reason);
141
+ };
142
+ ws.onmessage = function (event) {
143
+ if (settled) return;
144
+ if (typeof event.data === 'string') {
145
+ if (event.data.indexOf('Path:turn.end') >= 0) { finish(); return; }
146
+ return;
147
+ }
148
+ const raw = Buffer.from(event.data);
149
+ const marker = Buffer.from('Path:audio' + CRLF);
150
+ const idx = raw.indexOf(marker);
151
+ if (idx >= 0) {
152
+ const body = raw.subarray(idx + marker.length);
153
+ if (body.length > 0) chunks.push(body);
154
+ } else if (raw.length > 0) {
155
+ chunks.push(raw);
156
+ }
157
+ };
158
+ ws.onopen = function () {
159
+ const requestId = crypto.randomBytes(16).toString('hex');
160
+ const speechConfig = { context: { synthesis: { audio: { metadataoptions: { sentenceBoundaryEnabled: 'false', wordBoundaryEnabled: 'true' }, outputFormat: 'audio-24khz-48kbitrate-mono-mp3' } } } };
161
+ const ssml = '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="' + lang + '">' + '<voice name="' + xmlEscape(voice) + '">' + '<prosody rate="' + xmlEscape(rate) + '" pitch="' + xmlEscape(pitch) + '" volume="' + xmlEscape(volume) + '">' + xmlEscape(text) + '</prosody></voice></speak>';
162
+ ws.send('Content-Type:application/json; charset=utf-8' + CRLF + 'Path:speech.config' + CRLF + CRLF + JSON.stringify(speechConfig));
163
+ ws.send('X-RequestId:' + requestId + CRLF + 'Content-Type:application/ssml+xml' + CRLF + 'Path:ssml' + CRLF + CRLF + ssml);
164
+ };
165
+ setTimeout(function () {
166
+ if (!settled) {
167
+ settled = true;
168
+ if (errorTimer) { clearTimeout(errorTimer); errorTimer = null; }
169
+ try { ws.close(); } catch (e) {}
170
+ reject('timeout after 20000ms without turn.end (endpoint slow or blocked)');
171
+ }
172
+ }, 20000);
173
+ });
174
+ }
175
+ function findVoiceArgs() {
176
+ const args = process.argv;
177
+ const out = [];
178
+ for (let i = 1; i < args.length; i++) {
179
+ const a = args[i];
180
+ if (a === '--' || a === '-e') continue;
181
+ if (a.indexOf('.cjs') >= 0 || a.indexOf('.mjs') >= 0 || a.indexOf('edge-tts-worker') >= 0) continue;
182
+ out.push(a);
183
+ }
184
+ return out;
185
+ }
186
+ async function main() {
187
+ const args = findVoiceArgs();
188
+ const voice = args[0] || 'zh-CN-XiaoxuanNeural';
189
+ const rate = args[1] || 'default';
190
+ const pitch = args[2] || 'default';
191
+ const volume = args[3] || 'default';
192
+ const text = (await readStdin()).trim();
193
+ if (!text) return fail('empty text');
194
+ const vparts = String(voice).split('-');
195
+ const lang = (vparts.length >= 2 ? vparts[0] + '-' + vparts[1] : 'zh-CN');
196
+ const outPath = path.join(os.tmpdir(), 'dsh-tts-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.mp3');
197
+ let lastErr = null;
198
+ for (let attempt = 0; attempt < 2; attempt++) {
199
+ try {
200
+ const size = await synthesizeOnce(voice, text, outPath, lang, rate, pitch, volume);
201
+ console.log('OK ' + outPath);
202
+ console.log('SIZE ' + size);
203
+ process.exit(0);
204
+ } catch (e) {
205
+ lastErr = e;
206
+ if (isHardWorkerError(String((e && e.message) || e))) break;
207
+ if (attempt === 0) console.log('RETRY ' + String((e && e.message) || e).slice(0, 200));
208
+ if (attempt === 0) await new Promise(function (r) { setTimeout(r, 800); });
209
+ }
210
+ }
211
+ return fail(String(lastErr));
212
+ }
213
+ main().catch(function (e) {
214
+ console.error('ERR fatal: ' + (e && e.stack ? e.stack : String(e)));
215
+ process.exit(1);
216
+ });`;
217
+
218
+ /** Synthesize one text/voice pair; resolves with the absolute MP3 path. */
219
+ function synthesize(text, voice, prosody) {
220
+ return new Promise((resolve, reject) => {
221
+ const outPath = path.join(os.tmpdir(), `dsh-tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp3`);
222
+ const p = prosody || {};
223
+ const argv = ['-e', WORKER_SRC, '--', voice,
224
+ String(p.rate || 'default'),
225
+ String(p.pitch || 'default'),
226
+ String(p.volume || 'default')];
227
+ const child = spawn(process.execPath, argv, {
228
+ stdio: ['pipe', 'pipe', 'pipe']
229
+ });
230
+ let stdout = '';
231
+ let stderr = '';
232
+ let timedOut = false;
233
+ child.stdout.on('data', (d) => { stdout += String(d); });
234
+ child.stderr.on('data', (d) => { stderr += String(d); });
235
+ // Worker worst case: 20s attempt + 0.8s backoff + 20s retry ≈ 41s.
236
+ // Kill above that so a blackholed endpoint can't hang /speak for a minute.
237
+ // Other providers (rvc/index/cosy/cloud) keep their own long timeouts.
238
+ const timer = setTimeout(() => {
239
+ timedOut = true;
240
+ try { child.kill(); } catch (e) { /* already gone */ }
241
+ }, 45000);
242
+ child.on('error', (e) => {
243
+ clearTimeout(timer);
244
+ reject(new Error('TTS spawn failed: ' + e.message));
245
+ });
246
+ child.on('close', (code) => {
247
+ clearTimeout(timer);
248
+ if (timedOut) {
249
+ reject(new Error('TTS worker timeout (45s): Edge 端点无响应,已重试一次;请检查网络/代理后重试'));
250
+ return;
251
+ }
252
+ if (code !== 0) {
253
+ reject(new Error('TTS worker exited ' + code + ': ' + (stderr.trim() || stdout.trim() || 'no output')));
254
+ return;
255
+ }
256
+ const m = stdout.match(/^OK (.+)$/m);
257
+ if (!m) {
258
+ reject(new Error('TTS worker output unexpected: ' + stdout));
259
+ return;
260
+ }
261
+ const rt = countWorkerRetries(stdout);
262
+ if (rt.count > 0) {
263
+ // Success-after-retry: worth one info line so "slow but recovered"
264
+ // is distinguishable from plain "slow" in the exported log.
265
+ recordHostError({
266
+ provider: 'edge-tts', voice,
267
+ textLen: String(text).length,
268
+ stage: 'edge-retry', kind: 'info',
269
+ error: 'retries=' + rt.count + ' lastErr=' + rt.last
270
+ });
271
+ }
272
+ resolve(m[1]);
273
+ });
274
+ child.stdin.end(text);
275
+ });
276
+ }
277
+
278
+ // Retry gate for Edge synthesis errors (mirrors isHardWorkerError inside
279
+ // WORKER_SRC, which runs in a separate `node -e` process and can't be
280
+ // imported — keep the two regexes in sync). Fail fast on auth/voice errors;
281
+ // everything else is transient and worth a retry.
282
+ function isHardEdgeWorkerError(msg) {
283
+ return /40[013]|1007|unsupported voice|empty text/i.test(String(msg || ''));
284
+ }
285
+
286
+ // The worker announces transient-failure retries as `RETRY <err>` stdout
287
+ // lines (side-channel: the OK-path parser ignores them). Counting them tells
288
+ // "slow but recovered" apart from "slow" in latency triage.
289
+ function countWorkerRetries(stdout) {
290
+ const lines = String(stdout || '').match(/^RETRY (.*)$/gm) || [];
291
+ return {
292
+ count: lines.length,
293
+ last: lines.length ? lines[lines.length - 1].replace(/^RETRY /, '').slice(0, 200) : ''
294
+ };
295
+ }
296
+
297
+ function writeJson(res, code, value) {
298
+ const body = JSON.stringify(value);
299
+ res.writeHead(code, {
300
+ 'Content-Type': 'application/json; charset=utf-8',
301
+ 'Content-Length': Buffer.byteLength(body)
302
+ });
303
+ res.end(body);
304
+ }
305
+
306
+ // --- host-side i18n (focused, backward-compatible) ---
307
+ // The host runs in Node and doesn't know the UI language. Instead of baking a
308
+ // language in, errors/progress carry a stable `i18n: { code, params }` tag
309
+ // alongside the (zh) fallback text. The client renders `code` through its t()
310
+ // dictionary (zh/en); callers that don't understand the tag fall back to the
311
+ // plain message.
312
+ function hostErr(msg, code, params) {
313
+ const e = new Error(msg);
314
+ e.i18n = { code, params: params || {} };
315
+ return e;
316
+ }
317
+ // write a localized error response: body keeps `error` (zh fallback) and adds
318
+ // optional `i18n` metadata for the client.
319
+ function writeErr(res, code, msg, i18n) {
320
+ const value = { error: msg };
321
+ if (i18n) value.i18n = i18n;
322
+ writeJson(res, code, value);
323
+ }
324
+ // serialize a thrown route error (possibly a localized hostErr) to the client,
325
+ // carrying optional `i18n: { code, params }` metadata if present.
326
+ function writeRouteError(res, err) {
327
+ const msg = String((err && err.message) || err);
328
+ const value = { error: msg };
329
+ if (err && err.i18n) value.i18n = err.i18n;
330
+ writeJson(res, 500, value);
331
+ }
332
+
333
+ // Return a short, user-owned-path-aware "how to start the local RVC service"
334
+ // hint. We intentionally avoid hardcoding any machine-specific folder name:
335
+ // every user replaces <你的...> with their own path. Both Windows and
336
+ // macOS/Linux are shown so it is clear the feature is cross-platform.
337
+ function rvcStartupHint() {
338
+ return [
339
+ '便携运行时(免装 RVC WebUI):',
340
+ ' Windows:解压后双击「启动服务.bat」',
341
+ ' macOS/Linux:解压后在终端运行 <你解压的目录>/start-rvc-server.sh',
342
+ '已有 RVC WebUI:',
343
+ ' Windows(PowerShell):',
344
+ ' <你的RVC目录>\\runtime\\python.exe <你的RVC目录>\\rvc-server.py --port 4892',
345
+ ' macOS/Linux(终端):',
346
+ ' <你的RVC目录>/runtime/bin/python <你的RVC目录>/rvc-server.py --port 4892',
347
+ '详细步骤见《RVC 指南》→「启动本地 RVC 服务」。'
348
+ ].join('\n');
349
+ }
350
+
351
+ function hashText(s) {
352
+ let h = 5381;
353
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
354
+ return String(h >>> 0).toString(36);
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // RVC provider: Edge TTS base audio -> local RVC inference server -> voice.
359
+ // The RVC server (rvc-server.py) exposes:
360
+ // GET /health POST /load {model,index} POST /convert {audio_base64, params}
361
+ // ---------------------------------------------------------------------------
362
+
363
+ const RVC_DEFAULTS = {
364
+ baseUrl: 'http://127.0.0.1:4892',
365
+ model: '',
366
+ index: '',
367
+ baseVoice: 'zh-CN-YunyangNeural',
368
+ baseRate: 'default',
369
+ basePitch: 'default',
370
+ baseVolume: 'default',
371
+ spkId: 0,
372
+ f0File: '',
373
+ f0Method: 'rmvpe',
374
+ indexRate: 0.75,
375
+ f0UpKey: 0,
376
+ resampleSr: 40000,
377
+ rmsMixRate: 0.25,
378
+ protect: 0.33,
379
+ filterRadius: 3
380
+ };
381
+
382
+ let rvcLoadedKey = ''; // model|index fingerprint of the server-side loaded voice
383
+
384
+ function rvcConfig(custom) {
385
+ const c = custom || {};
386
+ return {
387
+ baseUrl: String(c.baseUrl || RVC_DEFAULTS.baseUrl).replace(/\/+$/, ''),
388
+ model: String(c.model || '').trim(),
389
+ index: String(c.index || '').trim(),
390
+ baseVoice: String(c.baseVoice || RVC_DEFAULTS.baseVoice),
391
+ baseRate: String(c.baseRate || RVC_DEFAULTS.baseRate),
392
+ basePitch: String(c.basePitch || RVC_DEFAULTS.basePitch),
393
+ baseVolume: String(c.baseVolume || RVC_DEFAULTS.baseVolume),
394
+ spkId: Number(c.spkId ?? RVC_DEFAULTS.spkId),
395
+ f0File: String(c.f0File || '').trim(),
396
+ f0Method: String(c.f0Method || RVC_DEFAULTS.f0Method),
397
+ indexRate: Number(c.indexRate ?? RVC_DEFAULTS.indexRate),
398
+ f0UpKey: Number(c.f0UpKey ?? RVC_DEFAULTS.f0UpKey),
399
+ resampleSr: Number(c.resampleSr ?? RVC_DEFAULTS.resampleSr),
400
+ rmsMixRate: Number(c.rmsMixRate ?? RVC_DEFAULTS.rmsMixRate),
401
+ protect: Number(c.protect ?? RVC_DEFAULTS.protect),
402
+ filterRadius: Number(c.filterRadius ?? RVC_DEFAULTS.filterRadius)
403
+ };
404
+ }
405
+
406
+ function rvcFingerprint(cfg) {
407
+ return [
408
+ cfg.model, cfg.index,
409
+ cfg.baseVoice, cfg.baseRate, cfg.basePitch, cfg.baseVolume,
410
+ cfg.spkId, cfg.f0File, cfg.f0Method, cfg.indexRate, cfg.f0UpKey,
411
+ cfg.resampleSr, cfg.rmsMixRate, cfg.protect, cfg.filterRadius
412
+ ].join('|');
413
+ }
414
+
415
+ async function rvcJson(baseUrl, route, payload, timeoutMs) {
416
+ let res;
417
+ try {
418
+ res = await fetch(baseUrl + route, {
419
+ method: 'POST',
420
+ headers: { 'Content-Type': 'application/json' },
421
+ body: JSON.stringify(payload),
422
+ signal: AbortSignal.timeout(timeoutMs)
423
+ });
424
+ } catch (e) {
425
+ throw hostErr(`无法连接本地 RVC 推理服务(${baseUrl}):${String((e && e.message) || e)}`, "host.rvcUnreachable", { baseUrl });
426
+ }
427
+ let data = null;
428
+ try { data = await res.json(); } catch (e) { /* non-json body */ }
429
+ if (!res.ok) {
430
+ throw hostErr(`RVC ${route} 失败(HTTP ${res.status}):${(data && data.message) || res.statusText}`, "host.rvcHttpFail", { route, status: res.status });
431
+ }
432
+ return data;
433
+ }
434
+
435
+ async function ensureRvcLoaded(cfg) {
436
+ const key = cfg.model + '|' + cfg.index;
437
+ if (key === rvcLoadedKey) return;
438
+ await rvcJson(cfg.baseUrl, '/load', { model: cfg.model, index: cfg.index }, 180000);
439
+ rvcLoadedKey = key;
440
+ }
441
+
442
+ async function rvcConvertBytes(cfg, audioBytes) {
443
+ const data = await rvcJson(cfg.baseUrl, '/convert', {
444
+ audio_base64: audioBytes.toString('base64'),
445
+ params: {
446
+ spk_id: cfg.spkId,
447
+ f0_file: cfg.f0File || null,
448
+ f0_method: cfg.f0Method,
449
+ index_rate: cfg.indexRate,
450
+ f0_up_key: cfg.f0UpKey,
451
+ resample_sr: cfg.resampleSr,
452
+ rms_mix_rate: cfg.rmsMixRate,
453
+ protect: cfg.protect,
454
+ filter_radius: cfg.filterRadius
455
+ }
456
+ }, 180000);
457
+ if (!data || !data.audio_base64) throw hostErr('RVC /convert 返回异常', 'host.rvcConvertNoAudio');
458
+ return Buffer.from(data.audio_base64, 'base64');
459
+ }
460
+
461
+ /** RVC chain: Edge TTS base audio of the message text -> local RVC conversion -> wav file path. */
462
+ async function synthesizeRvc(text, voice, custom) {
463
+ const cfg = rvcConfig(custom);
464
+ if (!cfg.model) throw hostErr('未配置 RVC 模型路径(设置 → 插件 → 语音 → RVC 配置)', 'host.noModelConfigured');
465
+ const t0 = Date.now();
466
+ let stage = 'base';
467
+ const mark = {};
468
+ try {
469
+ const baseVoice = cfg.baseVoice || voice;
470
+ const mp3Path = await synthesize(text, baseVoice, {
471
+ rate: cfg.baseRate,
472
+ pitch: cfg.basePitch,
473
+ volume: cfg.baseVolume
474
+ });
475
+ mark.base = Date.now() - t0;
476
+ const audioBytes = readFileSync(mp3Path);
477
+ stage = 'load';
478
+ const tLoad = Date.now();
479
+ await ensureRvcLoaded(cfg);
480
+ mark.load = Date.now() - tLoad;
481
+ stage = 'convert';
482
+ const tConv = Date.now();
483
+ let wavBytes;
484
+ try {
485
+ wavBytes = await rvcConvertBytes(cfg, audioBytes);
486
+ } catch (e) {
487
+ // one retry with a fresh /load (the server may have unloaded the model)
488
+ rvcLoadedKey = '';
489
+ await ensureRvcLoaded(cfg);
490
+ wavBytes = await rvcConvertBytes(cfg, audioBytes);
491
+ }
492
+ mark.convert = Date.now() - tConv;
493
+ const wavPath = path.join(os.tmpdir(), `dsh-tts-rvc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.wav`);
494
+ writeFileSync(wavPath, wavBytes);
495
+ return wavPath;
496
+ } catch (e) {
497
+ // Stage breakdown for triage ("stuck in base / load / convert?").
498
+ // The original error is rethrown unchanged for the user-facing path.
499
+ recordHostError({
500
+ provider: 'rvc', voice,
501
+ textLen: String(text || '').length,
502
+ stage: 'rvc-stages', kind: 'info',
503
+ error: 'failedAt=' + stage +
504
+ ' baseMs=' + (mark.base === undefined ? -1 : mark.base) +
505
+ ' loadMs=' + (mark.load === undefined ? -1 : mark.load) +
506
+ ' convertMs=' + (mark.convert === undefined ? -1 : mark.convert) +
507
+ ' totalMs=' + (Date.now() - t0)
508
+ });
509
+ throw e;
510
+ }
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // TTS provider abstraction (M1, foundation).
515
+ // Each provider implements synthesizeShort(text, voice, prosody, custom) -> absPath
516
+ // (a single audio file). The /speak handler dispatches the "short, single-URL"
517
+ // path through the registry. Long RVC reads keep their dedicated chunked job path
518
+ // (provider-'rvc'); future providers (e.g. a local Piper backend) just register.
519
+ // ---------------------------------------------------------------------------
520
+ const providers = Object.create(null);
521
+ function registerProvider(name, impl) {
522
+ providers[name] = impl;
523
+ }
524
+ registerProvider('edge-tts', {
525
+ synthesizeShort(text, voice, prosody, custom) {
526
+ return synthesize(text, voice, prosody || undefined);
527
+ }
528
+ });
529
+ registerProvider('rvc', {
530
+ synthesizeShort(text, voice, prosody, custom) {
531
+ return synthesizeRvc(text, voice, custom);
532
+ }
533
+ });
534
+ // Local Piper backend (M1+, optional). Needs a Piper binary + a .onnx model to be
535
+ // configured by the user; the /speak "short" path dispatches here through the same
536
+ // provider abstraction. Without a configured binary/model it fails with a clear,
537
+ // localized "not configured" error instead of crashing.
538
+ function piperConfig(custom) {
539
+ const c = custom || {};
540
+ return {
541
+ binary: String(c.piperBinary || '').trim(),
542
+ model: String(c.piperModel || '').trim(),
543
+ voice: String(c.piperVoice || 'en_US-lessac-medium')
544
+ };
545
+ }
546
+ async function synthesizePiper(text, voice, prosody, custom) {
547
+ const cfg = piperConfig(custom);
548
+ if (!cfg.binary || !cfg.model) {
549
+ throw hostErr(
550
+ '未配置本地 Piper(音色提供者 local-piper):请在设置中填写 Piper 可执行文件与 .onnx 模型路径',
551
+ 'host.piperUnconfigured'
552
+ );
553
+ }
554
+ const wavPath = path.join(os.tmpdir(), `dsh-tts-piper-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.wav`);
555
+ await new Promise((resolve, reject) => {
556
+ const p = spawn(cfg.binary, ['--model', cfg.model, '--output_file', wavPath], {
557
+ stdio: ['pipe', 'ignore', 'pipe']
558
+ });
559
+ let stderr = '';
560
+ p.stderr.on('data', d => { stderr += String(d); });
561
+ p.stdin.on('error', () => {});
562
+ p.on('error', err => reject(hostErr(`Piper 启动失败:${err.message}`, 'host.piperSpawnFail')));
563
+ p.on('close', code => {
564
+ if (code === 0) resolve();
565
+ else reject(hostErr(`Piper 转换失败(exit ${code}):${stderr.slice(0, 300) || '未知错误'}`, 'host.piperFail'));
566
+ });
567
+ try { p.stdin.end(String(text || '')); } catch (e) { reject(hostErr('Piper stdin 失败', 'host.piperFail')); }
568
+ });
569
+ return wavPath;
570
+ }
571
+ registerProvider('local-piper', {
572
+ synthesizeShort(text, voice, prosody, custom) {
573
+ return synthesizePiper(text, voice, prosody, custom);
574
+ }
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Local Index-TTS2 provider (e.g. a local `index-tts2-nvidia` bundle).
579
+ // Talks to the bundle's FastAPI service (default http://127.0.0.1:7880):
580
+ // GET /health GET /api/v1/voices POST /api/v1/tts/tasks {text,...} -> wav
581
+ // Single-call synthesis: timbre comes from the reference audio (prompt_audio),
582
+ // so no Edge TTS base voice is needed.
583
+ // ---------------------------------------------------------------------------
584
+
585
+ const INDEX_TTS2_DEFAULTS = {
586
+ baseUrl: 'http://127.0.0.1:7880',
587
+ voice: '',
588
+ cleanText: true,
589
+ emoControlMethod: 0,
590
+ emoRefPath: '',
591
+ emoWeight: 0.65,
592
+ emoText: '',
593
+ emoRandom: false,
594
+ maxTextTokensPerSegment: 120,
595
+ doSample: true,
596
+ topP: 0.8,
597
+ topK: 30,
598
+ temperature: 0.8,
599
+ lengthPenalty: 0.0,
600
+ numBeams: 3,
601
+ repetitionPenalty: 10.0,
602
+ maxMelTokens: 1500
603
+ };
604
+
605
+ function indexStartupHint() {
606
+ return [
607
+ '先双击本地包里的「启动api服务.bat」(保持窗口运行,默认端口 7880);',
608
+ '然后在本页下方点「刷新音色」确认能连上,再选参考音色试听。'
609
+ ].join('\n');
610
+ }
611
+
612
+ function indexTts2Config(custom, fallbackVoice) {
613
+ const c = custom || {};
614
+ const num = (v, d) => {
615
+ const n = Number(v);
616
+ return Number.isFinite(n) ? n : d;
617
+ };
618
+ return {
619
+ baseUrl: String(c.baseUrl || INDEX_TTS2_DEFAULTS.baseUrl).replace(/\/+$/, ''),
620
+ voice: String(c.voice || fallbackVoice || '').trim(),
621
+ cleanText: c.cleanText === undefined ? true : !!c.cleanText,
622
+ emoControlMethod: [0, 1, 2, 3].includes(num(c.emoControlMethod, 0))
623
+ ? num(c.emoControlMethod, 0) : 0,
624
+ emoRefPath: String(c.emoRefPath || ''),
625
+ emoWeight: num(c.emoWeight, INDEX_TTS2_DEFAULTS.emoWeight),
626
+ emoText: String(c.emoText || ''),
627
+ emoRandom: !!c.emoRandom,
628
+ maxTextTokensPerSegment: Math.max(20, Math.min(500, Math.round(num(c.maxTextTokensPerSegment, 120)))),
629
+ doSample: c.doSample === undefined ? true : !!c.doSample,
630
+ topP: num(c.topP, 0.8),
631
+ topK: Math.max(0, Math.round(num(c.topK, 30))),
632
+ temperature: num(c.temperature, 0.8),
633
+ lengthPenalty: num(c.lengthPenalty, 0.0),
634
+ numBeams: Math.max(1, Math.round(num(c.numBeams, 3))),
635
+ repetitionPenalty: num(c.repetitionPenalty, 10.0),
636
+ maxMelTokens: Math.max(100, Math.round(num(c.maxMelTokens, 1500)))
637
+ };
638
+ }
639
+
640
+ async function indexTts2Post(cfg, route, payload, timeoutMs) {
641
+ let res;
642
+ try {
643
+ res = await fetch(cfg.baseUrl + route, {
644
+ method: 'POST',
645
+ headers: { 'Content-Type': 'application/json' },
646
+ body: JSON.stringify(payload),
647
+ signal: AbortSignal.timeout(timeoutMs)
648
+ });
649
+ } catch (e) {
650
+ throw hostErr(`无法连接本地 Index-TTS2 服务(${cfg.baseUrl}):${String((e && e.message) || e)}\n${indexStartupHint()}`, 'host.indexUnreachable', { baseUrl: cfg.baseUrl });
651
+ }
652
+ if (!res.ok) {
653
+ const text = await res.text().catch(() => '');
654
+ let detail = '';
655
+ try {
656
+ const data = JSON.parse(text);
657
+ detail = String(data.detail || data.message || text).slice(0, 300);
658
+ } catch (e) {
659
+ detail = String(text).slice(0, 300) || res.statusText;
660
+ }
661
+ throw hostErr(`Index-TTS2 ${route} 失败(HTTP ${res.status}):${detail}`, 'host.indexHttpFail', { route, status: res.status });
662
+ }
663
+ return res;
664
+ }
665
+
666
+ /** Index-TTS2 chain: text -> local FastAPI service -> wav file path. */
667
+ async function synthesizeIndexTts2(text, voice, custom) {
668
+ const cfg = indexTts2Config(custom, voice);
669
+ const payload = {
670
+ text: String(text || ''),
671
+ prompt_audio: cfg.voice,
672
+ clean_text: cfg.cleanText,
673
+ emo_control_method: cfg.emoControlMethod,
674
+ emo_ref_path: cfg.emoRefPath || null,
675
+ emo_weight: cfg.emoWeight,
676
+ emo_text: cfg.emoText || null,
677
+ emo_random: cfg.emoRandom,
678
+ max_text_tokens_per_segment: cfg.maxTextTokensPerSegment,
679
+ do_sample: cfg.doSample,
680
+ top_p: cfg.topP,
681
+ top_k: cfg.topK,
682
+ temperature: cfg.temperature,
683
+ length_penalty: cfg.lengthPenalty,
684
+ num_beams: cfg.numBeams,
685
+ repetition_penalty: cfg.repetitionPenalty,
686
+ max_mel_tokens: cfg.maxMelTokens
687
+ };
688
+ const res = await indexTts2Post(cfg, '/api/v1/tts/tasks', payload, 300000);
689
+ const buf = Buffer.from(await res.arrayBuffer());
690
+ if (buf.length < 1000) throw hostErr('Index-TTS2 返回的音频过小(可能合成失败)', 'host.indexNoAudio');
691
+ const wavPath = path.join(os.tmpdir(), `dsh-tts-index-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.wav`);
692
+ writeFileSync(wavPath, buf);
693
+ return wavPath;
694
+ }
695
+
696
+ registerProvider('index-tts2', {
697
+ synthesizeShort(text, voice, prosody, custom) {
698
+ return synthesizeIndexTts2(text, voice, custom);
699
+ }
700
+ });
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Local CosyVoice2 provider (e.g. an aigcpanel CosyVoice-2.0-0.5B bundle).
704
+ // Talks to cosy-server.py (default http://127.0.0.1:7890):
705
+ // GET /health GET /voices POST /load {prompt} POST /upload {…}
706
+ // POST /tts {text, prompt?, speed?, seed?} -> { audio_base64, sample_rate }
707
+ // Single-call synthesis: timbre comes from the prompt audio (zero-shot), so
708
+ // no Edge TTS base voice is needed. Same shape as the index-tts2 provider.
709
+ // ---------------------------------------------------------------------------
710
+
711
+ const COSY_DEFAULTS = {
712
+ baseUrl: 'http://127.0.0.1:7890',
713
+ voice: '',
714
+ promptText: '',
715
+ speed: 1.0,
716
+ seed: 0
717
+ };
718
+
719
+ function cosyStartupHint() {
720
+ return [
721
+ '先双击本地包里的「启动cosy服务.bat」(保持窗口运行,默认端口 7890);',
722
+ '然后在本页下方点「刷新音色」确认能连上,再选提示音频试听。'
723
+ ].join('\n');
724
+ }
725
+
726
+ function cosyConfig(custom, fallbackVoice) {
727
+ const c = custom || {};
728
+ const num = (v, d) => {
729
+ const n = Number(v);
730
+ return Number.isFinite(n) ? n : d;
731
+ };
732
+ return {
733
+ baseUrl: String(c.baseUrl || COSY_DEFAULTS.baseUrl).replace(/\/+$/, ''),
734
+ voice: String(c.voice || fallbackVoice || '').trim(),
735
+ // prompt_text: the transcript of the prompt audio, in order. Multilingual
736
+ // prompts must include the FULL transcript, otherwise the model re-reads
737
+ // the missing part before the new text.
738
+ promptText: String(c.promptText || '').slice(0, 2000),
739
+ speed: Math.min(2, Math.max(0.5, num(c.speed, 1.0))),
740
+ seed: Math.max(0, Math.round(num(c.seed, 0)))
741
+ };
742
+ }
743
+
744
+ async function cosyPost(cfg, route, payload, timeoutMs) {
745
+ let res;
746
+ try {
747
+ res = await fetch(cfg.baseUrl + route, {
748
+ method: 'POST',
749
+ headers: { 'Content-Type': 'application/json' },
750
+ body: JSON.stringify(payload),
751
+ signal: AbortSignal.timeout(timeoutMs)
752
+ });
753
+ } catch (e) {
754
+ throw hostErr(`无法连接本地 CosyVoice 服务(${cfg.baseUrl}):${String((e && e.message) || e)}\n${cosyStartupHint()}`, 'host.cosyUnreachable', { baseUrl: cfg.baseUrl });
755
+ }
756
+ if (!res.ok) {
757
+ const text = await res.text().catch(() => '');
758
+ let detail = '';
759
+ try {
760
+ const data = JSON.parse(text);
761
+ detail = String(data.detail || data.message || text).slice(0, 300);
762
+ } catch (e) {
763
+ detail = String(text).slice(0, 300) || res.statusText;
764
+ }
765
+ throw hostErr(`CosyVoice ${route} 失败(HTTP ${res.status}):${detail}`, 'host.cosyHttpFail', { route, status: res.status });
766
+ }
767
+ return res;
768
+ }
769
+
770
+ /** CosyVoice chain: text -> local cosy-server.py -> wav file path. */
771
+ async function synthesizeCosy(text, voice, custom) {
772
+ const cfg = cosyConfig(custom, voice);
773
+ const res = await cosyPost(cfg, '/tts', {
774
+ text: String(text || ''),
775
+ prompt: cfg.voice || undefined,
776
+ prompt_text: cfg.promptText || undefined,
777
+ speed: cfg.speed,
778
+ seed: cfg.seed
779
+ }, 300000);
780
+ const data = await res.json().catch(() => null);
781
+ const b64 = data && data.audio_base64;
782
+ if (!b64) throw hostErr('CosyVoice 返回异常(无音频内容)', 'host.cosyNoAudio');
783
+ const buf = Buffer.from(String(b64), 'base64');
784
+ if (buf.length < 1000) throw hostErr('CosyVoice 返回的音频过小(可能合成失败)', 'host.cosyNoAudio');
785
+ const wavPath = path.join(os.tmpdir(), `dsh-tts-cosy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.wav`);
786
+ writeFileSync(wavPath, buf);
787
+ return wavPath;
788
+ }
789
+
790
+ registerProvider('cosyvoice', {
791
+ synthesizeShort(text, voice, prosody, custom) {
792
+ return synthesizeCosy(text, voice, custom);
793
+ }
794
+ });
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // Google Cloud TTS provider (paid, API-Key).
798
+ // Reuses the SAME /speak job queue + /dsh-tts-audio/<id> + /rvc-next chain as
799
+ // edge/rvc/index: short text -> single URL, long text -> chunked job whose
800
+ // sink is cloud synthesis per packet. No new playback chain.
801
+ // Key lives in the Host file (~/.dsh/tts-rvc/settings.json, cloud.apiKey) and
802
+ // NEVER touches localStorage; the client only sends provider + voice names.
803
+ // Quota: local per-month char counting by voice tier (standard 4M,
804
+ // wavenet/neural2/chirp3 1M). Official serviceusage/monitoring queries are
805
+ // intentionally NOT enabled by default (needs SA JSON; 99% users won't have
806
+ // it) — the usage endpoint reports local counts only.
807
+ // ---------------------------------------------------------------------------
808
+
809
+ const CLOUD_TTS_DEFAULTS = {
810
+ voice: 'cmn-CN-Wavenet-A'
811
+ };
812
+ const CLOUD_QUOTA_LIMITS = {
813
+ standard: 4000000,
814
+ wavenet: 1000000,
815
+ neural2: 1000000,
816
+ chirp3: 1000000
817
+ };
818
+ // Cloud synchronous synthesize hard limit is 5000 UTF-8 bytes; stay under it
819
+ // with a safety margin. CJK packets are packed to ~1200 chars max
820
+ // (≈3600 bytes), inside the required 800–1200 chars per packet.
821
+ const CLOUD_MAX_BYTES = 4500;
822
+ const CLOUD_PACK_CHARS = 1200;
823
+ const CLOUD_PACK_CHARS_HARD = 1200;
824
+
825
+ function cloudTier(voiceName) {
826
+ const v = String(voiceName || '');
827
+ if (/chirp/i.test(v)) return 'chirp3';
828
+ if (/neural2/i.test(v)) return 'neural2';
829
+ if (/wavenet/i.test(v)) return 'wavenet';
830
+ return 'standard';
831
+ }
832
+
833
+ function cloudLanguage(voiceName) {
834
+ const parts = String(voiceName || '').split('-');
835
+ if (parts.length >= 2 && parts[0] && parts[1]) return parts[0] + '-' + parts[1];
836
+ return 'cmn-CN';
837
+ }
838
+
839
+ function cloudXmlEscape(s) {
840
+ return String(s || '')
841
+ .replace(/&/g, '&amp;')
842
+ .replace(/</g, '&lt;')
843
+ .replace(/>/g, '&gt;')
844
+ .replace(/"/g, '&quot;')
845
+ .replace(/'/g, '&apos;');
846
+ }
847
+
848
+ // Every packet is self-closed <speak>…</speak>; tags never span packets.
849
+ function cloudBuildSsml(text) {
850
+ return '<speak>' + cloudXmlEscape(String(text || '')) + '</speak>';
851
+ }
852
+
853
+ // Billing caliber: plain-text length + spaces/newlines + SSML tags, i.e. the
854
+ // length of the SSML payload we actually send (code points).
855
+ function cloudBillableChars(ssml) {
856
+ return [...String(ssml || '')].length;
857
+ }
858
+
859
+ /**
860
+ * Pack text into Cloud-safe packets: reuse splitText (sentence split + atomic
861
+ * URL/email/decimal protection + orphan merge) at ~1000 chars, then merge
862
+ * small parts while BOTH caps hold (utf8 bytes <= 4500, chars <= 1200).
863
+ * Guarantees every returned packet is under the 5000-byte hard limit.
864
+ */
865
+ function splitCloudPackets(text, maxBytes) {
866
+ const cap = Number(maxBytes) > 0 ? Math.min(Number(maxBytes), 5000) : CLOUD_MAX_BYTES;
867
+ const base = splitText(String(text || ''), CLOUD_PACK_CHARS);
868
+ const packets = [];
869
+ let cur = '';
870
+ let curBytes = 0;
871
+ const push = () => {
872
+ if (cur) packets.push(cur);
873
+ cur = '';
874
+ curBytes = 0;
875
+ };
876
+ const join2 = (a, b) => {
877
+ if (!a) return b;
878
+ if (!b) return a;
879
+ const gap = /[A-Za-z0-9]$/.test(a) && /^[A-Za-z0-9]/.test(b) ? ' ' : '';
880
+ return a + gap + b;
881
+ };
882
+ for (const part of base) {
883
+ const b = Buffer.byteLength(part, 'utf8');
884
+ if (b > cap) {
885
+ // Defensive: a single sentence part over the cap (shouldn't happen with
886
+ // the 1000-char split, but never emit an over-limit packet). Hard-split
887
+ // on char boundaries so UTF-8 sequences stay intact.
888
+ push();
889
+ let sub = '';
890
+ let subBytes = 0;
891
+ for (const ch of part) {
892
+ const cb = Buffer.byteLength(ch, 'utf8');
893
+ if (subBytes + cb > cap) {
894
+ packets.push(sub);
895
+ sub = ch;
896
+ subBytes = cb;
897
+ } else {
898
+ sub += ch;
899
+ subBytes += cb;
900
+ }
901
+ }
902
+ cur = sub;
903
+ curBytes = subBytes;
904
+ continue;
905
+ }
906
+ const merged = join2(cur, part);
907
+ const mergedBytes = curBytes + b + (merged.length > cur.length + part.length ? 1 : 0);
908
+ if (!cur || (mergedBytes <= cap && merged.length <= CLOUD_PACK_CHARS_HARD)) {
909
+ cur = merged;
910
+ curBytes = Buffer.byteLength(cur, 'utf8');
911
+ } else {
912
+ push();
913
+ cur = part;
914
+ curBytes = b;
915
+ }
916
+ }
917
+ push();
918
+ return packets.filter((p) => p.length > 0);
919
+ }
920
+
921
+ // --- host Cloud service settings (~/.dsh/tts-rvc/settings.json) -----------
922
+ // Shape: { version: 1, rvc: {...}, index: {...}, cloud: { apiKey, projectId } }.
923
+ // The API key is service-class (survives browser-data clears) and is NEVER
924
+ // echoed back in full by any GET route (only apiKeySet + tail hint).
925
+ const HOST_CLOUD_KEYS = ['apiKey', 'projectId'];
926
+ function loadHostCloudSettings() {
927
+ try {
928
+ if (!existsSync(SETTINGS_FILE)) return {};
929
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
930
+ const src = (data && data.cloud && typeof data.cloud === 'object') ? data.cloud : {};
931
+ const out = {};
932
+ if (typeof src.apiKey === 'string' && src.apiKey.trim() !== '') out.apiKey = src.apiKey.trim();
933
+ if (typeof src.projectId === 'string' && src.projectId.trim() !== '') out.projectId = src.projectId.trim().slice(0, 200);
934
+ return out;
935
+ } catch (e) {
936
+ return {};
937
+ }
938
+ }
939
+ function saveHostCloudSettings(patch) {
940
+ let data = { version: 1 };
941
+ try {
942
+ if (existsSync(SETTINGS_FILE)) data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) || data;
943
+ } catch (e) { /* start fresh */ }
944
+ const prev = (data && data.cloud && typeof data.cloud === 'object') ? data.cloud : {};
945
+ const next = { ...prev };
946
+ const src = (patch && typeof patch === 'object') ? patch : {};
947
+ for (const k of HOST_CLOUD_KEYS) {
948
+ if (src[k] !== undefined) {
949
+ const v = typeof src[k] === 'string' ? src[k].trim() : '';
950
+ if (v === '') delete next[k];
951
+ else next[k] = k === 'projectId' ? v.slice(0, 200) : v;
952
+ }
953
+ }
954
+ data.version = 1;
955
+ data.cloud = next;
956
+ try {
957
+ mkdirSync(CAL_DIR, { recursive: true });
958
+ writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2));
959
+ } catch (e) { /* best-effort */ }
960
+ return next;
961
+ }
962
+
963
+ // --- local quota ledger (~/.dsh/tts-rvc/cloud-usage.json) ------------------
964
+ // Shape: { version: 1, months: { "YYYY-MM": { standard, wavenet, neural2, chirp3 } } }.
965
+ // Month keys rotate automatically (a new month starts at zero); only the last
966
+ // 6 months are kept.
967
+ // Lazily resolved (CAL_DIR is defined further below; keep module load order safe).
968
+ // Honors DSH_TTS_HOME like CAL_DIR so tests can isolate the usage ledger.
969
+ function cloudUsageFile() {
970
+ return path.join(ttsHomeDir(), 'cloud-usage.json');
971
+ }
972
+ function cloudMonthKey(d) {
973
+ try {
974
+ return (d || new Date()).toISOString().slice(0, 7);
975
+ } catch (e) {
976
+ return new Date().toISOString().slice(0, 7);
977
+ }
978
+ }
979
+ function loadCloudUsage() {
980
+ try {
981
+ if (!existsSync(cloudUsageFile())) return { version: 1, months: {} };
982
+ const data = JSON.parse(readFileSync(cloudUsageFile(), 'utf8'));
983
+ if (!data || typeof data !== 'object' || !data.months || typeof data.months !== 'object') {
984
+ return { version: 1, months: {} };
985
+ }
986
+ return { version: 1, months: data.months };
987
+ } catch (e) {
988
+ return { version: 1, months: {} };
989
+ }
990
+ }
991
+ function saveCloudUsage(data) {
992
+ try {
993
+ mkdirSync(CAL_DIR, { recursive: true });
994
+ writeFileSync(cloudUsageFile(), JSON.stringify(data, null, 2));
995
+ } catch (e) { /* best-effort */ }
996
+ }
997
+ function recordCloudUsage(tier, chars) {
998
+ const t = CLOUD_QUOTA_LIMITS[tier] !== undefined ? tier : 'standard';
999
+ const n = Math.max(0, Math.round(Number(chars) || 0));
1000
+ if (!n) return null;
1001
+ const data = loadCloudUsage();
1002
+ const month = cloudMonthKey();
1003
+ if (!data.months[month] || typeof data.months[month] !== 'object') {
1004
+ data.months[month] = { standard: 0, wavenet: 0, neural2: 0, chirp3: 0 };
1005
+ }
1006
+ const entry = data.months[month];
1007
+ for (const k of Object.keys(CLOUD_QUOTA_LIMITS)) {
1008
+ if (typeof entry[k] !== 'number' || !Number.isFinite(entry[k])) entry[k] = 0;
1009
+ }
1010
+ entry[t] += n;
1011
+ const keys = Object.keys(data.months).sort();
1012
+ while (keys.length > 6) delete data.months[keys.shift()];
1013
+ saveCloudUsage(data);
1014
+ return { month, tier: t, chars: n };
1015
+ }
1016
+ function getCloudUsageSummary() {
1017
+ const data = loadCloudUsage();
1018
+ const month = cloudMonthKey();
1019
+ const used = data.months[month] && typeof data.months[month] === 'object' ? data.months[month] : {};
1020
+ const tiers = {};
1021
+ for (const k of Object.keys(CLOUD_QUOTA_LIMITS)) {
1022
+ const u = typeof used[k] === 'number' && Number.isFinite(used[k]) ? Math.max(0, Math.round(used[k])) : 0;
1023
+ const limit = CLOUD_QUOTA_LIMITS[k];
1024
+ tiers[k] = { used: u, limit, remaining: Math.max(0, limit - u) };
1025
+ }
1026
+ return { month, tiers, official: { enabled: false } };
1027
+ }
1028
+
1029
+ // Map client pct prosody ('default' | '+10%' | '-20%') to Cloud audioConfig.
1030
+ function cloudProsodyToAudioConfig(prosody) {
1031
+ const pct = (v) => {
1032
+ if (v === undefined || v === null || v === 'default') return 0;
1033
+ const m = String(v).match(/([+-]?\d+(?:\.\d+)?)/);
1034
+ return m ? Number(m[1]) : 0;
1035
+ };
1036
+ const p = prosody || {};
1037
+ const speakingRate = Math.min(4, Math.max(0.25, 1 + pct(p.rate) / 100));
1038
+ const pitch = Math.min(20, Math.max(-20, pct(p.pitch) / 5));
1039
+ const volumeGainDb = Math.min(16, Math.max(-96, pct(p.volume) / 5));
1040
+ return { audioEncoding: 'MP3', speakingRate, pitch, volumeGainDb };
1041
+ }
1042
+
1043
+ // Retry helper: retry once on network/5xx; 400/401/403 fail fast (retrying a
1044
+ // rejected packet is pointless and burns quota).
1045
+ async function cloudWithRetry(fn, attempts) {
1046
+ const n = attempts || 2;
1047
+ let lastErr = null;
1048
+ for (let i = 0; i < n; i++) {
1049
+ try {
1050
+ return await fn();
1051
+ } catch (e) {
1052
+ lastErr = e;
1053
+ const msg = String((e && e.message) || e);
1054
+ if (/HTTP 40[013]|host\.cloud(NoKey|KeyInvalid|BadRequest)/.test(msg)) throw e;
1055
+ if (i < n - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
1056
+ }
1057
+ }
1058
+ throw lastErr;
1059
+ }
1060
+
1061
+ /** One Cloud packet -> MP3 bytes (+ billable chars). Caller retries per packet. */
1062
+ async function synthesizeCloudPacket(packetText, voice, prosody, apiKey, timeoutMs) {
1063
+ const ssml = cloudBuildSsml(packetText);
1064
+ const billable = cloudBillableChars(ssml);
1065
+ const body = {
1066
+ input: { ssml },
1067
+ voice: { languageCode: cloudLanguage(voice), name: voice },
1068
+ audioConfig: cloudProsodyToAudioConfig(prosody)
1069
+ };
1070
+ const url = 'https://texttospeech.googleapis.com/v1/text:synthesize?key=' + encodeURIComponent(apiKey);
1071
+ let res;
1072
+ try {
1073
+ res = await fetch(url, {
1074
+ method: 'POST',
1075
+ headers: { 'Content-Type': 'application/json' },
1076
+ body: JSON.stringify(body),
1077
+ signal: AbortSignal.timeout(timeoutMs || 60000)
1078
+ });
1079
+ } catch (e) {
1080
+ throw hostErr(`Cloud TTS 请求失败:${String((e && e.message) || e)}`, 'host.cloudHttpFail');
1081
+ }
1082
+ if (!res.ok) {
1083
+ const text = await res.text().catch(() => '');
1084
+ const detail = String(text).slice(0, 200) || res.statusText;
1085
+ if (res.status === 400) {
1086
+ throw hostErr(`Cloud TTS 拒绝请求(HTTP 400,单包可能超限或 voice 无效):${detail}`, 'host.cloudBadRequest', { status: res.status });
1087
+ }
1088
+ if (res.status === 401 || res.status === 403) {
1089
+ throw hostErr(`Cloud TTS Key 无效或未启用 Text-to-Speech API(HTTP ${res.status}):${detail}`, 'host.cloudKeyInvalid', { status: res.status });
1090
+ }
1091
+ throw hostErr(`Cloud TTS 合成失败(HTTP ${res.status}):${detail}`, 'host.cloudHttpFail', { status: res.status });
1092
+ }
1093
+ const data = await res.json().catch(() => null);
1094
+ const b64 = data && data.audioContent;
1095
+ if (!b64) throw hostErr('Cloud TTS 返回异常(无音频内容)', 'host.cloudNoAudio');
1096
+ const buf = Buffer.from(String(b64), 'base64');
1097
+ if (buf.length < 100) throw hostErr('Cloud TTS 返回的音频过小(可能合成失败)', 'host.cloudNoAudio');
1098
+ return { bytes: buf, billable };
1099
+ }
1100
+
1101
+ /**
1102
+ * Cloud chain: text -> Google Cloud TTS -> mp3 file path.
1103
+ * Short input (one packet) synthesizes once; longer input concatenates packet
1104
+ * MP3s (frame-level concat plays sequentially). The /speak chunked path keeps
1105
+ * each job chunk to one packet, so this multi-packet branch is only a safety
1106
+ * net for direct calls.
1107
+ */
1108
+ async function synthesizeCloud(text, voice, prosody, custom) {
1109
+ const stored = loadHostCloudSettings();
1110
+ const apiKey = String((stored && stored.apiKey) || '').trim();
1111
+ if (!apiKey) throw hostErr('未配置 Google Cloud TTS API Key(设置 → 插件 → 语音 → Cloud 配置)', 'host.cloudNoKey');
1112
+ const v = String((custom && custom.voice) || voice || CLOUD_TTS_DEFAULTS.voice).trim() || CLOUD_TTS_DEFAULTS.voice;
1113
+ const tier = cloudTier(v);
1114
+ const packets = splitCloudPackets(text);
1115
+ if (packets.length <= 1) {
1116
+ const { bytes, billable } = await cloudWithRetry(
1117
+ () => synthesizeCloudPacket(packets[0] || String(text || ''), v, prosody, apiKey)
1118
+ );
1119
+ try { recordCloudUsage(tier, billable); } catch (e) { /* non-fatal */ }
1120
+ const outPath = path.join(os.tmpdir(), `dsh-tts-cloud-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp3`);
1121
+ writeFileSync(outPath, bytes);
1122
+ return outPath;
1123
+ }
1124
+ const bufs = [];
1125
+ for (const pkt of packets) {
1126
+ // Per-packet retry only: a failed packet retries itself, never the whole job.
1127
+ const { bytes, billable } = await cloudWithRetry(
1128
+ () => synthesizeCloudPacket(pkt, v, prosody, apiKey)
1129
+ );
1130
+ try { recordCloudUsage(tier, billable); } catch (e) { /* non-fatal */ }
1131
+ bufs.push(bytes);
1132
+ }
1133
+ const outPath = path.join(os.tmpdir(), `dsh-tts-cloud-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp3`);
1134
+ writeFileSync(outPath, Buffer.concat(bufs));
1135
+ return outPath;
1136
+ }
1137
+
1138
+ registerProvider('google-cloud-tts', {
1139
+ synthesizeShort(text, voice, prosody, custom) {
1140
+ return synthesizeCloud(text, voice, prosody, custom);
1141
+ }
1142
+ });
1143
+ function getProvider(name) {
1144
+ return providers[name] || providers['edge-tts'];
1145
+ }
1146
+
1147
+ // ---------------------------------------------------------------------------
1148
+ // Adaptive chunked progressive playback.
1149
+ // Long RVC reads are split into sentence-sized blocks and converted one by one
1150
+ // into a queue. A one-shot probe calibrates chunk size + prewarm count from the
1151
+ // local machine's convert/play speed ratio (convert_time / audio_seconds).
1152
+ // ---------------------------------------------------------------------------
1153
+
1154
+ const PROBE_TEXT = '你好,这是语音合成性能检测。';
1155
+
1156
+ /** Heuristic speech duration in seconds (zh ~3.6 chars/s, latin ~12 chars/s). */
1157
+ function estimateSpeechSeconds(text) {
1158
+ let cjk = 0, latin = 0, other = 0;
1159
+ for (const ch of String(text || '')) {
1160
+ const cp = ch.codePointAt(0);
1161
+ if (cp >= 0x4e00 && cp <= 0x9fff) cjk++;
1162
+ else if (/[A-Za-z0-9]/.test(ch)) latin++;
1163
+ else other++;
1164
+ }
1165
+ return cjk / 3.6 + latin / 12 + other / 4;
1166
+ }
1167
+
1168
+ function isLatinHeavy(text) {
1169
+ let latin = 0, cjk = 0;
1170
+ for (const ch of String(text || '')) {
1171
+ const cp = ch.codePointAt(0);
1172
+ if (cp >= 0x4e00 && cp <= 0x9fff) cjk++;
1173
+ else if (/[A-Za-z]/.test(ch)) latin++;
1174
+ }
1175
+ return latin > 0 && latin > cjk * 2;
1176
+ }
1177
+
1178
+ /**
1179
+ * Atomic tokens that must never be split: URLs, emails and decimals/versions.
1180
+ * They contain '.' which the sentence splitter would otherwise treat as a
1181
+ * sentence end (splitting "3.14" into "3"/"14"), and hard cuts could break
1182
+ * them mid-token. Each atom is swapped for a placeholder without sentence
1183
+ * punctuation before splitting, then restored on the final parts.
1184
+ */
1185
+ const ATOM_RE = /https?:\/\/[^\s,。!?;,!?;]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\d+(?:\.\d+)+/g;
1186
+
1187
+ /** Split text into chunks of <= maxChars, preferring sentence/segment boundaries. */
1188
+ function splitText(text, maxChars) {
1189
+ const atoms = [];
1190
+ const guarded = String(text).replace(ATOM_RE, (tok) => {
1191
+ atoms.push(tok);
1192
+ return '\u0001' + (atoms.length - 1) + '\u0002';
1193
+ });
1194
+ const parts = [];
1195
+ const sentences = [];
1196
+ let m;
1197
+ const sre = /[^。!?;.!?;]+[。!?;.!?;]?/g;
1198
+ while ((m = sre.exec(guarded)) !== null) {
1199
+ const s = m[0].trim();
1200
+ if (s) sentences.push(s);
1201
+ }
1202
+ if (!sentences.length) sentences.push(guarded.trim());
1203
+ const segRe = /[^,、,]+[,、,]?/g;
1204
+ let cur = '';
1205
+ const flush = () => {
1206
+ if (cur.trim()) parts.push(cur.trim());
1207
+ cur = '';
1208
+ };
1209
+ const CUT_MIN = Math.max(4, Math.floor(maxChars * 0.5));
1210
+ // Boundary-aware hard cut: prefer the last whitespace / CJK punctuation in
1211
+ // the window, and never split an atomic placeholder (its tail \u0002 is a
1212
+ // good boundary; if we're inside one, cut before its \u0001 head).
1213
+ function safeEnd(seg, from) {
1214
+ const end = Math.min(seg.length, from + maxChars);
1215
+ for (let p = end; p > from + CUT_MIN; p--) {
1216
+ const ch = seg[p - 1];
1217
+ if (/\s/.test(ch) || ',、。!?;,!?;'.includes(ch) || ch === '\u0002') return p;
1218
+ }
1219
+ for (let p = end - 1; p > from; p--) {
1220
+ if (seg[p] === '\u0002') return p + 1;
1221
+ if (seg[p] === '\u0001') return p;
1222
+ }
1223
+ return end;
1224
+ }
1225
+ for (const s of sentences) {
1226
+ if (cur && cur.length + s.length > maxChars) flush();
1227
+ if (s.length > maxChars) {
1228
+ const segs = [];
1229
+ while ((m = segRe.exec(s)) !== null) {
1230
+ const g = m[0].trim();
1231
+ if (g) segs.push(g);
1232
+ }
1233
+ for (const seg of segs) {
1234
+ if (cur && cur.length + seg.length > maxChars) flush();
1235
+ if (seg.length > maxChars) {
1236
+ for (let i = 0; i < seg.length; ) {
1237
+ flush();
1238
+ const end = safeEnd(seg, i);
1239
+ if (end <= i) break; // defensive: never loop forever on zero progress
1240
+ cur = seg.slice(i, end);
1241
+ flush();
1242
+ i = end;
1243
+ }
1244
+ } else {
1245
+ cur += seg;
1246
+ }
1247
+ }
1248
+ } else {
1249
+ cur += s;
1250
+ }
1251
+ }
1252
+ flush();
1253
+ const restored = parts.map((p) =>
1254
+ p.replace(/\u0001(\d+)\u0002/g, (_, idx) => atoms[Number(idx)] ?? ''),
1255
+ );
1256
+ // Orphan-tail fix: a tiny trailing chunk ("好。"/"OK.") reads like a stutter
1257
+ // on top of the previous chunk; merge it back when it fits.
1258
+ if (restored.length >= 2) {
1259
+ const last = restored[restored.length - 1];
1260
+ const prev = restored[restored.length - 2];
1261
+ if (last.length < 8 && prev.length + last.length <= maxChars) {
1262
+ const gap =
1263
+ /[A-Za-z0-9]$/.test(prev) && /^[A-Za-z0-9]/.test(last) ? ' ' : '';
1264
+ restored[restored.length - 2] = prev + gap + last;
1265
+ restored.pop();
1266
+ }
1267
+ }
1268
+ return restored.filter((p) => p.length > 0);
1269
+ }
1270
+
1271
+ function chunkTier(ratio) {
1272
+ if (ratio <= 0.4) return { chunkSec: 20, prewarm: 2 };
1273
+ if (ratio <= 0.6) return { chunkSec: 15, prewarm: 2 };
1274
+ if (ratio <= 0.9) return { chunkSec: 10, prewarm: 3 };
1275
+ return { chunkSec: 6, prewarm: 4 };
1276
+ }
1277
+
1278
+ // Chunk policy per provider (pure, unit-tested):
1279
+ // fastFirst (edge/cloud only, ratio ~0.1): TTFB = 1 block — chunk 1 returns
1280
+ // immediately, the rest warm in the background at their own indexes (served
1281
+ // strictly in order). Local synthesis (rvc/index/cosy, ratio 0.5-1.1) keeps
1282
+ // full prewarm so playback never starves.
1283
+ function chunkCal(provider) {
1284
+ if (provider === 'index-tts2') return { chunkSec: 15, prewarm: 2, ratio: 0 };
1285
+ if (provider === 'cosyvoice') return { chunkSec: 15, prewarm: 2, ratio: 0 };
1286
+ if (provider === 'google-cloud-tts') return { chunkSec: 120, prewarm: 2, ratio: 0, fastFirst: true };
1287
+ return { chunkSec: 20, prewarm: 2, ratio: 0, fastFirst: true };
1288
+ }
1289
+
1290
+ const FALLBACK_CAL = { ratio: null, chunkSec: 10, prewarm: 3, probeFailed: true, at: 0 };
1291
+ const calibCache = new Map(); // rvcFingerprint -> { ratio, chunkSec, prewarm, at }
1292
+ const CAL_TTL_MS = 10 * 60 * 1000;
1293
+
1294
+ // --- calibration persistence (~/.dsh/tts-rvc/calibration.json) -------------
1295
+ // Probe results survive across dsh restarts, so the one-shot ~7s probe is paid
1296
+ // only once per config+device (7-day validity; re-probes when the GPU changes).
1297
+ // DSH_TTS_HOME overrides the base dir (tests isolate the settings/usage files).
1298
+ function ttsHomeDir() {
1299
+ const o = process.env.DSH_TTS_HOME;
1300
+ if (typeof o === 'string' && o.trim() !== '') return o.trim();
1301
+ return path.join(os.homedir(), '.dsh', 'tts-rvc');
1302
+ }
1303
+ const CAL_DIR = ttsHomeDir();
1304
+ const CAL_FILE = path.join(CAL_DIR, 'calibration.json');
1305
+ const CAL_FILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1306
+ let calFileData = null; // parsed file, lazily loaded
1307
+ const deviceByUrl = new Map(); // baseUrl -> { gpu, at } (5-min session cache)
1308
+
1309
+ function loadCalFile() {
1310
+ if (calFileData !== null) return calFileData;
1311
+ try {
1312
+ if (existsSync(CAL_FILE)) calFileData = JSON.parse(readFileSync(CAL_FILE, 'utf8'));
1313
+ else calFileData = { version: 1, entries: {} };
1314
+ } catch (e) {
1315
+ calFileData = { version: 1, entries: {} };
1316
+ }
1317
+ if (!calFileData.entries || typeof calFileData.entries !== 'object') calFileData.entries = {};
1318
+ return calFileData;
1319
+ }
1320
+
1321
+ function saveCalFile() {
1322
+ try {
1323
+ mkdirSync(CAL_DIR, { recursive: true });
1324
+ writeFileSync(CAL_FILE, JSON.stringify(calFileData, null, 2));
1325
+ } catch (e) { /* best-effort persistence */ }
1326
+ }
1327
+
1328
+ // --- host RVC service settings (~/.dsh/tts-rvc/settings.json) -------------
1329
+ // Service-class config (service URL + model/index paths) lives on the Host so
1330
+ // it survives browser-data clears, incognito and browser switches. UI prefs
1331
+ // (voice, rate, provider, rawMarkdown, notify, …) stay in localStorage.
1332
+ // Shape: { version: 1, rvc: { baseUrl, model, index } }. Unknown keys are
1333
+ // ignored on load; writes merge (never drop) and are best-effort.
1334
+ const SETTINGS_FILE = path.join(CAL_DIR, 'settings.json');
1335
+ const HOST_RVC_KEYS = ['baseUrl', 'model', 'index'];
1336
+ function loadHostRvcSettings() {
1337
+ try {
1338
+ if (!existsSync(SETTINGS_FILE)) return {};
1339
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
1340
+ const src = (data && data.rvc && typeof data.rvc === 'object') ? data.rvc : {};
1341
+ const out = {};
1342
+ for (const k of HOST_RVC_KEYS) {
1343
+ if (typeof src[k] === 'string' && src[k].trim() !== '') out[k] = src[k];
1344
+ }
1345
+ if (typeof src.baseUrl === 'string' && src.baseUrl.trim() !== '') {
1346
+ out.baseUrl = src.baseUrl.trim().replace(/\/+$/, '');
1347
+ }
1348
+ return out;
1349
+ } catch (e) {
1350
+ return {};
1351
+ }
1352
+ }
1353
+ function saveHostRvcSettings(patch) {
1354
+ let data = { version: 1 };
1355
+ try {
1356
+ if (existsSync(SETTINGS_FILE)) data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) || data;
1357
+ } catch (e) { /* start fresh */ }
1358
+ const prev = (data && data.rvc && typeof data.rvc === 'object') ? data.rvc : {};
1359
+ const next = { ...prev };
1360
+ const src = (patch && typeof patch === 'object') ? patch : {};
1361
+ for (const k of HOST_RVC_KEYS) {
1362
+ if (typeof src[k] === 'string') {
1363
+ const v = k === 'baseUrl' ? src[k].trim().replace(/\/+$/, '') : src[k].trim();
1364
+ // empty string clears the key (falls back to RVC_DEFAULTS)
1365
+ if (v === '') delete next[k];
1366
+ else next[k] = v;
1367
+ }
1368
+ }
1369
+ data.version = 1;
1370
+ data.rvc = next;
1371
+ try {
1372
+ mkdirSync(CAL_DIR, { recursive: true });
1373
+ writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2));
1374
+ } catch (e) { /* best-effort: memory still updated for this session */ }
1375
+ return next;
1376
+ }
1377
+
1378
+ // --- host CosyVoice service settings (~/.dsh/tts-rvc/settings.json) -------
1379
+ // Only the service address is service-class (survives browser-data clears);
1380
+ // voice + synthesis params stay in localStorage as UI prefs.
1381
+ // Shape: { version: 1, rvc: {...}, index: {...}, cosy: { baseUrl } }.
1382
+ const HOST_COSY_KEYS = ['baseUrl'];
1383
+ function loadHostCosySettings() {
1384
+ try {
1385
+ if (!existsSync(SETTINGS_FILE)) return {};
1386
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
1387
+ const src = (data && data.cosy && typeof data.cosy === 'object') ? data.cosy : {};
1388
+ const out = {};
1389
+ if (typeof src.baseUrl === 'string' && src.baseUrl.trim() !== '') {
1390
+ out.baseUrl = src.baseUrl.trim().replace(/\/+$/, '');
1391
+ }
1392
+ return out;
1393
+ } catch (e) {
1394
+ return {};
1395
+ }
1396
+ }
1397
+ function saveHostCosySettings(patch) {
1398
+ let data = { version: 1 };
1399
+ try {
1400
+ if (existsSync(SETTINGS_FILE)) data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) || data;
1401
+ } catch (e) { /* start fresh */ }
1402
+ const prev = (data && data.cosy && typeof data.cosy === 'object') ? data.cosy : {};
1403
+ const next = { ...prev };
1404
+ const src = (patch && typeof patch === 'object') ? patch : {};
1405
+ for (const k of HOST_COSY_KEYS) {
1406
+ if (typeof src[k] === 'string') {
1407
+ const v = src[k].trim().replace(/\/+$/, '');
1408
+ if (v === '') delete next[k];
1409
+ else next[k] = v;
1410
+ }
1411
+ }
1412
+ data.version = 1;
1413
+ data.cosy = next;
1414
+ try {
1415
+ mkdirSync(CAL_DIR, { recursive: true });
1416
+ writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2));
1417
+ } catch (e) { /* best-effort */ }
1418
+ return next;
1419
+ }
1420
+
1421
+ // --- host Index-TTS2 service settings (~/.dsh/tts-rvc/settings.json) -------
1422
+ // Only the service address is service-class (survives browser-data clears);
1423
+ // voice + synthesis params stay in localStorage as UI prefs.
1424
+ // Shape: { version: 1, rvc: {...}, index: { baseUrl } }.
1425
+ const HOST_INDEX_KEYS = ['baseUrl'];
1426
+ function loadHostIndexSettings() {
1427
+ try {
1428
+ if (!existsSync(SETTINGS_FILE)) return {};
1429
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
1430
+ const src = (data && data.index && typeof data.index === 'object') ? data.index : {};
1431
+ const out = {};
1432
+ if (typeof src.baseUrl === 'string' && src.baseUrl.trim() !== '') {
1433
+ out.baseUrl = src.baseUrl.trim().replace(/\/+$/, '');
1434
+ }
1435
+ return out;
1436
+ } catch (e) {
1437
+ return {};
1438
+ }
1439
+ }
1440
+ function saveHostIndexSettings(patch) {
1441
+ let data = { version: 1 };
1442
+ try {
1443
+ if (existsSync(SETTINGS_FILE)) data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) || data;
1444
+ } catch (e) { /* start fresh */ }
1445
+ const prev = (data && data.index && typeof data.index === 'object') ? data.index : {};
1446
+ const next = { ...prev };
1447
+ const src = (patch && typeof patch === 'object') ? patch : {};
1448
+ for (const k of HOST_INDEX_KEYS) {
1449
+ if (typeof src[k] === 'string') {
1450
+ const v = src[k].trim().replace(/\/+$/, '');
1451
+ if (v === '') delete next[k];
1452
+ else next[k] = v;
1453
+ }
1454
+ }
1455
+ data.version = 1;
1456
+ data.index = next;
1457
+ try {
1458
+ mkdirSync(CAL_DIR, { recursive: true });
1459
+ writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2));
1460
+ } catch (e) { /* best-effort */ }
1461
+ return next;
1462
+ }
1463
+
1464
+ /** GPU name from the RVC server /health, cached 5 min per baseUrl (null = unknown). */
1465
+ async function getRvcDevice(cfg) {
1466
+ const now = Date.now();
1467
+ const hit = deviceByUrl.get(cfg.baseUrl);
1468
+ if (hit && now - hit.at < 5 * 60 * 1000) return hit.gpu;
1469
+ let gpu = null;
1470
+ try {
1471
+ const r = await fetch(cfg.baseUrl + '/health', { signal: AbortSignal.timeout(8000) });
1472
+ const d = await r.json().catch(() => null);
1473
+ gpu = (d && typeof d.gpu_name === 'string' && d.gpu_name) || null;
1474
+ } catch (e) { /* offline/old server -> unknown device */ }
1475
+ deviceByUrl.set(cfg.baseUrl, { gpu, at: now });
1476
+ return gpu;
1477
+ }
1478
+
1479
+ /**
1480
+ * Calibrate the local convert speed for a config: in-memory hit first, then the
1481
+ * persisted calibration file (skips the probe across restarts), then a live
1482
+ * probe. Probe results are persisted with the current GPU name so a device
1483
+ * change triggers a re-probe instead of reusing stale numbers.
1484
+ */
1485
+ async function getCalibration(cfg) {
1486
+ const key = rvcFingerprint(cfg);
1487
+ const now = Date.now();
1488
+ const hit = calibCache.get(key);
1489
+ if (hit && (hit.probeFailed ? now - hit.at < 120000 : now - hit.at < CAL_TTL_MS)) return hit;
1490
+
1491
+ // persisted entry -> adopt without probing (unless the device changed)
1492
+ const file = loadCalFile();
1493
+ const fileHit = file.entries[key];
1494
+ if (fileHit && !fileHit.probeFailed && now - fileHit.at < CAL_FILE_TTL_MS) {
1495
+ const dev = await getRvcDevice(cfg);
1496
+ if (!dev || !fileHit.device || fileHit.device === dev) {
1497
+ const cal = {
1498
+ ratio: fileHit.ratio, chunkSec: fileHit.chunkSec, prewarm: fileHit.prewarm,
1499
+ probeFailed: false, at: fileHit.at, fromDisk: true
1500
+ };
1501
+ calibCache.set(key, cal);
1502
+ return cal;
1503
+ }
1504
+ }
1505
+
1506
+ // live probe
1507
+ const t0 = now;
1508
+ let ratio = null;
1509
+ try {
1510
+ const mp3Path = await synthesize(PROBE_TEXT, cfg.baseVoice || RVC_DEFAULTS.baseVoice, {
1511
+ rate: cfg.baseRate, pitch: cfg.basePitch, volume: cfg.baseVolume
1512
+ });
1513
+ const audioBytes = readFileSync(mp3Path);
1514
+ const estSec = estimateSpeechSeconds(PROBE_TEXT) || 1;
1515
+ await ensureRvcLoaded(cfg);
1516
+ await rvcConvertBytes(cfg, audioBytes);
1517
+ ratio = (Date.now() - t0) / 1000 / estSec;
1518
+ } catch (e) {
1519
+ ratio = null; // probe failure -> conservative defaults
1520
+ }
1521
+ const cal = ratio === null
1522
+ ? { ...FALLBACK_CAL, at: Date.now() }
1523
+ : { ratio, ...chunkTier(ratio), probeFailed: false, at: Date.now() };
1524
+ if (ratio !== null) {
1525
+ // persist only successful probes; keep any older good entry on failure
1526
+ cal.device = (await getRvcDevice(cfg)) || undefined;
1527
+ file.entries[key] = {
1528
+ ratio: cal.ratio, chunkSec: cal.chunkSec, prewarm: cal.prewarm,
1529
+ at: cal.at, device: cal.device || null
1530
+ };
1531
+ const keys = Object.keys(file.entries);
1532
+ if (keys.length > 100) {
1533
+ keys.sort((a, b) => (file.entries[a].at || 0) - (file.entries[b].at || 0));
1534
+ for (const k of keys.slice(0, keys.length - 100)) delete file.entries[k];
1535
+ }
1536
+ saveCalFile();
1537
+ }
1538
+ calibCache.set(key, cal);
1539
+ if (calibCache.size > 40) {
1540
+ const first = calibCache.keys().next().value;
1541
+ if (first !== undefined) calibCache.delete(first);
1542
+ }
1543
+ return cal;
1544
+ }
1545
+
1546
+ // ---------------------------------------------------------------------------
1547
+ // Voice packs: registry manifest + download + sha256-verified install.
1548
+ // Packs install into ~/.dsh/tts-rvc/packs/<packId>/ (<packId>.pth + <packId>.index).
1549
+ // ---------------------------------------------------------------------------
1550
+
1551
+ const PACK_FILE = 'installed.json';
1552
+
1553
+ function packsDir() {
1554
+ return process.env.DSH_TTS_PACKS_DIR || path.join(os.homedir(), '.dsh', 'tts-rvc', 'packs');
1555
+ }
1556
+
1557
+ function installedPacks() {
1558
+ let data = {};
1559
+ try {
1560
+ data = JSON.parse(readFileSync(path.join(packsDir(), PACK_FILE), 'utf8'));
1561
+ } catch (e) {
1562
+ return {};
1563
+ }
1564
+ // reconcile: drop entries whose model file no longer exists (user deleted it)
1565
+ let changed = false;
1566
+ for (const [id, e] of Object.entries(data)) {
1567
+ if (!e || !e.modelPath || !existsSync(e.modelPath)) {
1568
+ delete data[id];
1569
+ changed = true;
1570
+ }
1571
+ }
1572
+ if (changed) saveInstalledPacks(data);
1573
+ return data;
1574
+ }
1575
+
1576
+ function saveInstalledPacks(data) {
1577
+ try {
1578
+ mkdirSync(packsDir(), { recursive: true });
1579
+ writeFileSync(path.join(packsDir(), PACK_FILE), JSON.stringify(data, null, 2));
1580
+ } catch (e) { /* best-effort */ }
1581
+ }
1582
+
1583
+ async function fetchManifest(registry, proxy) {
1584
+ const url = String(registry || '').trim().replace(/\/+$/, '');
1585
+ if (!/^https?:\/\//i.test(url)) throw hostErr('仓库地址必须是 http(s) URL', 'host.registryNotUrl', { url });
1586
+ const manifestUrl = url + '/manifest.json';
1587
+ const raw = await withRetry(
1588
+ () => proxy ? downloadViaProxy(manifestUrl, proxy, { timeoutMs: 60000 }) : fetchBytes(manifestUrl, 60000),
1589
+ '清单',
1590
+ );
1591
+ let data = null;
1592
+ try { data = JSON.parse(raw.toString('utf8')); } catch (e) { /* fall through */ }
1593
+ if (!data) throw hostErr('清单格式无效', 'host.manifestInvalid');
1594
+ if (!Array.isArray(data.packs)) throw hostErr('清单格式无效(缺少 packs 数组)', 'host.manifestNoPacks');
1595
+ return { url, data };
1596
+ }
1597
+
1598
+ /** Minimal HTTP(S) download through an HTTP proxy (CONNECT tunnel), no deps. */
1599
+ function downloadViaProxy(targetUrl, proxyUrl, opts = {}) {
1600
+ const { timeoutMs = 900000, onChunk = null } = opts;
1601
+ return new Promise((resolve, reject) => {
1602
+ const u = new URL(targetUrl);
1603
+ const p = new URL(proxyUrl);
1604
+ const isHttps = u.protocol === 'https:';
1605
+ const port = u.port || (isHttps ? 443 : 80);
1606
+ const connectPath = u.hostname + ':' + port;
1607
+ let settled = false;
1608
+ const fail = (e) => { if (!settled) { settled = true; reject(e); } };
1609
+ const creq = httpRequest({
1610
+ host: p.hostname,
1611
+ port: p.port || 80,
1612
+ method: 'CONNECT',
1613
+ path: connectPath,
1614
+ headers: { Host: connectPath, 'Proxy-Connection': 'Keep-Alive' }
1615
+ });
1616
+ const timer = setTimeout(() => { try { creq.destroy(); } catch (e) {} fail(new Error('代理连接超时')); }, 30000);
1617
+ creq.on('connect', (res, socket) => {
1618
+ clearTimeout(timer);
1619
+ if (res.statusCode !== 200) { try { socket.destroy(); } catch (e) {} fail(new Error('代理 CONNECT ' + res.statusCode)); return; }
1620
+ const wire = isHttps ? tls.connect({ socket, servername: u.hostname }) : socket;
1621
+ wire.on('error', fail);
1622
+ const req = (isHttps ? httpsRequest : httpRequest)({
1623
+ createConnection: () => wire,
1624
+ host: u.hostname,
1625
+ port,
1626
+ path: u.pathname + u.search,
1627
+ method: 'GET',
1628
+ headers: { Host: u.host, Connection: 'close' }
1629
+ }, (r) => {
1630
+ if (r.statusCode < 200 || r.statusCode >= 300) {
1631
+ r.resume();
1632
+ fail(new Error('HTTP ' + r.statusCode));
1633
+ return;
1634
+ }
1635
+ const chunks = [];
1636
+ const total = Number(r.headers['content-length']) || 0;
1637
+ let done = 0;
1638
+ r.on('data', (c) => {
1639
+ chunks.push(c);
1640
+ done += c.length;
1641
+ if (onChunk) onChunk(c, done, total);
1642
+ });
1643
+ r.on('end', () => { settled = true; resolve(Buffer.concat(chunks)); });
1644
+ r.on('error', fail);
1645
+ });
1646
+ req.on('error', fail);
1647
+ req.end();
1648
+ });
1649
+ creq.on('error', (e) => { clearTimeout(timer); fail(e); });
1650
+ creq.end();
1651
+ });
1652
+ }
1653
+
1654
+ /** Simple fetch-body helper with AbortSignal timeout (direct connection). */
1655
+ async function fetchBytes(url, timeoutMs) {
1656
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
1657
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
1658
+ return Buffer.from(await r.arrayBuffer());
1659
+ }
1660
+
1661
+ /** Retry a network call a few times (slow/flaky connections self-heal). */
1662
+ async function withRetry(fn, label, attempts = 3) {
1663
+ let lastErr;
1664
+ for (let i = 0; i < attempts; i++) {
1665
+ try {
1666
+ return await fn();
1667
+ } catch (e) {
1668
+ lastErr = e;
1669
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 600 * (i + 1)));
1670
+ }
1671
+ }
1672
+ throw hostErr(`${label} 下载失败:${String((lastErr && lastErr.message) || lastErr)}`, "host.downloadFailed", { label });
1673
+ }
1674
+
1675
+ /** Resolve a possibly-relative manifest URL against the registry base. */
1676
+ function resolvePackUrl(base, u) {
1677
+ const s = String(u || '').trim();
1678
+ if (!s) return '';
1679
+ return /^https?:\/\//i.test(s) ? s : base + '/' + s.replace(/^\/+/, '');
1680
+ }
1681
+
1682
+ function sha256Hex(bytes) {
1683
+ return createHash('sha256').update(bytes).digest('hex');
1684
+ }
1685
+
1686
+ // --- pack-install progress (client polls GET /rvc-pack-progress?key=) -------
1687
+ const packProgress = new Map(); // key -> { phase, done, total, startedAt, finished? }
1688
+
1689
+ function setPackProgress(key, patch) {
1690
+ if (!key) return;
1691
+ const p = packProgress.get(key) || { phase: '准备', phaseKey: 'prepare', done: 0, total: 0, startedAt: Date.now() };
1692
+ Object.assign(p, patch);
1693
+ packProgress.set(key, p);
1694
+ }
1695
+
1696
+ /** Mark the install finished (entry stays a while so the client sees 100%). */
1697
+ function finishPackProgress(key) {
1698
+ if (!key) return;
1699
+ const p = packProgress.get(key);
1700
+ if (p) p.finished = true;
1701
+ }
1702
+
1703
+ /** Stream-download with incremental sha256 + progress; returns the full bytes. */
1704
+ async function downloadStream(url, expectedSha256, expectedSize, label, onProgress, proxy) {
1705
+ return withRetry(async () => {
1706
+ const hash = createHash('sha256');
1707
+ let bytes;
1708
+ if (proxy) {
1709
+ bytes = await downloadViaProxy(url, proxy, {
1710
+ timeoutMs: 1200000,
1711
+ onChunk: (c, done, total) => {
1712
+ hash.update(c);
1713
+ if (onProgress) onProgress(done, total || done);
1714
+ },
1715
+ });
1716
+ } else {
1717
+ const r = await fetch(url, { signal: AbortSignal.timeout(1200000) });
1718
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
1719
+ const total = Number(r.headers.get('content-length')) || 0;
1720
+ if (!r.body) throw hostErr('下载流不可用', 'host.downloadStreamUnavailable');
1721
+ const reader = r.body.getReader();
1722
+ const chunks = [];
1723
+ let done = 0;
1724
+ for (;;) {
1725
+ const { done: d, value } = await reader.read();
1726
+ if (d) break;
1727
+ chunks.push(Buffer.from(value));
1728
+ hash.update(value);
1729
+ done += value.length;
1730
+ if (onProgress) onProgress(done, total || done);
1731
+ }
1732
+ bytes = Buffer.concat(chunks);
1733
+ }
1734
+ if (expectedSize && bytes.length !== expectedSize) {
1735
+ throw hostErr(`大小不符(期望 ${expectedSize},实际 ${bytes.length})`, "host.sizeMismatch", { expected: expectedSize, actual: bytes.length });
1736
+ }
1737
+ if (expectedSha256) {
1738
+ const actual = hash.digest('hex');
1739
+ if (actual !== String(expectedSha256).toLowerCase()) {
1740
+ throw hostErr(`sha256 校验失败(期望 ${expectedSha256},实际 ${actual})`, "host.sha256Mismatch");
1741
+ }
1742
+ }
1743
+ return bytes;
1744
+ }, label);
1745
+ }
1746
+
1747
+ async function installPack(registry, packId, indexId, progressKey, proxy) {
1748
+ const { url: base, data } = await fetchManifest(registry);
1749
+ const pack = data.packs.find((p) => p && p.id === packId);
1750
+ if (!pack) throw hostErr(`仓库中没有音色包 ${packId}`, "host.packNotFound", { packId });
1751
+ const stem = String(packId).replace(/[^\w.-]/g, '_');
1752
+ const dir = path.join(packsDir(), stem);
1753
+ const model = pack.model;
1754
+ if (!model || !model.url) throw hostErr('音色包缺少模型文件', 'host.packNoModel');
1755
+ // index variants: `indexes` array (preferred) or legacy single `index`
1756
+ let indexes = Array.isArray(pack.indexes) && pack.indexes.length
1757
+ ? pack.indexes.slice()
1758
+ : (pack.index ? [{ id: 'default', name: '默认索引', ...pack.index }] : []);
1759
+ const chosen = indexes.find((i) => i.id === indexId) || indexes[0] || null;
1760
+ const modelUrl = resolvePackUrl(base, model.url);
1761
+ const indexUrl = chosen ? resolvePackUrl(base, chosen.url) : '';
1762
+ const installed = installedPacks();
1763
+ const current = installed[packId] || {};
1764
+ // installed files keep the pack id in their name so the browse picker can
1765
+ // tell voices apart (e.g. guanguanV1.pth / guanguanV1.index)
1766
+ const modelPath = path.join(dir, stem + '.pth');
1767
+ const indexPath = indexUrl ? path.join(dir, stem + '.index') : '';
1768
+ const variants = indexes.map((i) => ({ id: i.id, name: i.name || i.id, size: i.size || 0 }));
1769
+ const modelUpToDate = current.version === pack.version && current.modelSha256 === model.sha256 && existsSync(modelPath);
1770
+ const indexUpToDate = !indexUrl || (indexPath && existsSync(indexPath) && current.indexSha256 === chosen.sha256);
1771
+ if (modelUpToDate && indexUpToDate) {
1772
+ return { ok: true, skipped: true, name: pack.name, modelPath, indexPath, indexId: chosen ? chosen.id : '', variants };
1773
+ }
1774
+ mkdirSync(dir, { recursive: true });
1775
+ const totalBytes = (model.size || 0) + (chosen && indexUrl ? chosen.size || 0 : 0);
1776
+ let downloaded = 0;
1777
+ try {
1778
+ if (!modelUpToDate) {
1779
+ setPackProgress(progressKey, { phase: '模型', phaseKey: 'model', done: 0, total: totalBytes });
1780
+ const modelBytes = await downloadStream(modelUrl, model.sha256, model.size, '模型', (d) => {
1781
+ downloaded = d;
1782
+ setPackProgress(progressKey, { phase: '模型', phaseKey: 'model', done: downloaded });
1783
+ }, proxy);
1784
+ writeFileSync(modelPath, modelBytes);
1785
+ downloaded = model.size || downloaded;
1786
+ }
1787
+ if (indexUrl && !indexUpToDate) {
1788
+ setPackProgress(progressKey, { phase: '索引', phaseKey: 'index', done: downloaded });
1789
+ const indexBytes = await downloadStream(indexUrl, chosen.sha256, chosen.size, '索引', (d) => {
1790
+ setPackProgress(progressKey, { phase: '索引', phaseKey: 'index', done: downloaded + d });
1791
+ }, proxy);
1792
+ writeFileSync(indexPath, indexBytes);
1793
+ }
1794
+ } catch (e) {
1795
+ finishPackProgress(progressKey);
1796
+ // remove partial install so a retry starts clean
1797
+ try {
1798
+ rmSync(modelPath, { force: true });
1799
+ rmSync(indexPath, { force: true });
1800
+ } catch (e2) { /* best-effort cleanup */ }
1801
+ throw e;
1802
+ }
1803
+ finishPackProgress(progressKey);
1804
+ installed[packId] = {
1805
+ id: packId,
1806
+ name: pack.name || packId,
1807
+ version: pack.version || '1.0.0',
1808
+ license: pack.license || 'unknown',
1809
+ modelPath,
1810
+ indexPath,
1811
+ modelSha256: model.sha256 || '',
1812
+ indexSha256: chosen && chosen.sha256 ? String(chosen.sha256).toLowerCase() : '',
1813
+ indexId: chosen ? chosen.id : '',
1814
+ installedAt: Date.now()
1815
+ };
1816
+ saveInstalledPacks(installed);
1817
+ const size = (model.size || 0) + (chosen ? chosen.size || 0 : 0);
1818
+ return {
1819
+ ok: true, skipped: false, name: pack.name, modelPath, indexPath,
1820
+ indexId: chosen ? chosen.id : '', variants, size
1821
+ };
1822
+ }
1823
+
1824
+ // ---------------------------------------------------------------------------
1825
+ // Agent-event voice alerts (approval reminders).
1826
+ // Approval requests/decisions are session events on the `session/event`
1827
+ // firehose ('approval/asked' / 'approval/decided' in SessionEventMap —
1828
+ // dsh-user-approval). The plugin Host subscribes via the sandbox-exposed
1829
+ // `ctx.on`, buffers a small queue, and the client polls it incrementally
1830
+ // (GET /dsh-tts-api/notify?s=N) to read the alert aloud. Dedup by (type, id)
1831
+ // so a request is never announced twice, while its paired decision still is.
1832
+ // ---------------------------------------------------------------------------
1833
+ const notifyState = {
1834
+ queue: [], // [{ seq, kind: 'approval'|'approval-decided', id, toolName, reason, outcome }]
1835
+ seen: new Set(), // per-(type,id) keys already ingested (dedup)
1836
+ seq: 0,
1837
+ cap: 20,
1838
+ seenCap: 200
1839
+ };
1840
+
1841
+ function truncateForSpeech(s, max) {
1842
+ const t = String(s == null ? '' : s).trim();
1843
+ return t.length > max ? t.slice(0, max) + '…' : t;
1844
+ }
1845
+
1846
+ /** Ingest one session event into the notification queue (pure, testable). */
1847
+ function ingestSessionEvent(event) {
1848
+ if (!event || typeof event !== 'object') return;
1849
+ const type = String(event.type || '');
1850
+ if (type !== 'approval/asked' && type !== 'approval/decided') return;
1851
+ // Real SessionEvent shape: { type, seq, time, data: {...} } — the payload
1852
+ // (id/toolName/reason/outcome) lives under `data`; fall back to the top
1853
+ // level so flat test fixtures keep working.
1854
+ const d = event.data && typeof event.data === 'object' ? event.data : event;
1855
+ const id = String(d.id || '');
1856
+ // An approval request and its decision are PAIRED by the same id, so dedup
1857
+ // per (type, id), not per id — both deserve an announcement.
1858
+ const seenKey = type + ':' + id;
1859
+ if (!id || notifyState.seen.has(seenKey)) return;
1860
+ notifyState.seen.add(seenKey);
1861
+ if (type === 'approval/asked') {
1862
+ notifyState.queue.push({
1863
+ seq: ++notifyState.seq,
1864
+ kind: 'approval',
1865
+ id,
1866
+ toolName: truncateForSpeech(d.toolName, 40),
1867
+ reason: truncateForSpeech(d.reason, 80)
1868
+ });
1869
+ } else {
1870
+ // dsh-user-approval outcome vocabulary: allowed-once | rejected |
1871
+ // cancelled | unavailable — collapsed to granted/rejected/settled for the
1872
+ // localized client announcements.
1873
+ const outcome = String(d.outcome || '');
1874
+ notifyState.queue.push({
1875
+ seq: ++notifyState.seq,
1876
+ kind: 'approval-decided',
1877
+ id,
1878
+ outcome: outcome === 'allowed-once' || outcome === 'granted'
1879
+ ? 'granted'
1880
+ : outcome === 'rejected'
1881
+ ? 'rejected'
1882
+ : 'settled'
1883
+ });
1884
+ }
1885
+ if (notifyState.seen.size > notifyState.seenCap) notifyState.seen.clear(); // coarse reset; client also dedups by seq
1886
+ while (notifyState.queue.length > notifyState.cap) notifyState.queue.shift();
1887
+ }
1888
+
1889
+ // ---------------------------------------------------------------------------
1890
+ // Recent-error ring buffer for the one-click diagnostic log.
1891
+ // In-memory, capped at 20 entries. Only metadata is kept: timestamps,
1892
+ // provider/voice, text length, stage, latency and trimmed error text.
1893
+ // NEVER the message text itself, and NEVER API keys.
1894
+ // ---------------------------------------------------------------------------
1895
+ const recentErrors = [];
1896
+ // Persisted alongside calibration.json/settings.json so the failure history
1897
+ // survives dsh restarts (a fresh update erases in-memory history otherwise).
1898
+ // Same schema as the in-memory entries; no message text, no keys.
1899
+ const RECENT_FILE = path.join(CAL_DIR, 'diag-recent.json');
1900
+ // Error kind for triage (kept in sync with the client-side copy in
1901
+ // lib/client.js classifyDiagError — same codes, same order).
1902
+ function classifyHostError(msg) {
1903
+ const s = String(msg || '');
1904
+ if (/1007|unsupported voice/i.test(s)) return 'voice';
1905
+ if (/403|401|Sec-MS-GEC/i.test(s)) return 'auth';
1906
+ if (/未配置|not configured|unconfigured|NoKey|NoModel|no key|no model|缺少/i.test(s)) return 'config';
1907
+ if (/无法连接|unreachable|ECONNREFUSED|fetch failed/i.test(s)) return 'service';
1908
+ if (/timeout|超时/i.test(s)) return 'timeout';
1909
+ if (/1006|websocket|closed early|closed code|reset|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i.test(s)) return 'network';
1910
+ if (/首段合成|chunk/i.test(s)) return 'chunk';
1911
+ if (/spawn|worker/i.test(s)) return 'spawn';
1912
+ return 'unknown';
1913
+ }
1914
+ function recordHostError(entry) {
1915
+ try {
1916
+ const e = Object.assign({ at: Date.now() }, entry);
1917
+ if (!e.kind) e.kind = classifyHostError(e.error);
1918
+ recentErrors.push(e);
1919
+ while (recentErrors.length > 20) recentErrors.shift();
1920
+ saveRecentErrors();
1921
+ } catch (err) { /* logging must never break synthesis */ }
1922
+ }
1923
+ function saveRecentErrors() {
1924
+ try {
1925
+ mkdirSync(CAL_DIR, { recursive: true });
1926
+ writeFileSync(RECENT_FILE, JSON.stringify(recentErrors.slice(-20)));
1927
+ } catch (e) { /* best-effort */ }
1928
+ }
1929
+ function loadRecentErrors() {
1930
+ try {
1931
+ if (!existsSync(RECENT_FILE)) return;
1932
+ const data = JSON.parse(readFileSync(RECENT_FILE, 'utf8'));
1933
+ if (Array.isArray(data)) {
1934
+ for (const e of data.slice(-20)) {
1935
+ if (e && typeof e === 'object') recentErrors.push(e);
1936
+ }
1937
+ }
1938
+ } catch (e) { /* start empty */ }
1939
+ }
1940
+ loadRecentErrors();
1941
+ // Plugin build identity for the exported log (best-effort; never throws).
1942
+ let PLUGIN_META = { name: '@dsh-external/dsh-plugin-tts', version: 'unknown' };
1943
+ try {
1944
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1945
+ if (pkg && pkg.version) PLUGIN_META = { name: pkg.name || PLUGIN_META.name, version: String(pkg.version) };
1946
+ } catch (e) { /* keep fallback */ }
1947
+ // Edge worker build meta surfaced in /diagnose so a pasted log identifies the
1948
+ // retry/timeout behaviour the user ran with. KEEP IN SYNC with WORKER_SRC
1949
+ // (single-attempt timeout) and synthesize() (spawn kill timeout).
1950
+ const WORKER_META = {
1951
+ chromium: '143.0.3650.75',
1952
+ singleTimeoutMs: 20000,
1953
+ spawnTimeoutMs: 45000,
1954
+ retry: 'transient-once-800ms-backoff'
1955
+ };
1956
+
1957
+ export function apply(ctx) {
1958
+ const webServer = ctx.get('webServer');
1959
+ if (!webServer) return;
1960
+
1961
+ // Approval voice alerts: subscribe to the session/event firehose (the
1962
+ // sandbox exposes ctx.on; feature-detect so the smoke fake-ctx still works).
1963
+ if (typeof ctx.on === 'function') {
1964
+ ctx.effect(
1965
+ () =>
1966
+ ctx.on('session/event', (_session, event) => {
1967
+ try {
1968
+ ingestSessionEvent(event);
1969
+ } catch (e) { /* observer failures are logged-and-contained by design */ }
1970
+ }),
1971
+ 'dsh-plugin-tts: session events (approval alerts)',
1972
+ );
1973
+ }
1974
+
1975
+ const files = new Map(); // id -> absolute mp3 path
1976
+ const cache = new Map(); // voice|text -> url
1977
+ let seq = 0;
1978
+
1979
+ // --- adaptive chunk job registry (rvc long reads) ---
1980
+ // Indexed serve model: each part has a fixed index. Synthesis fills
1981
+ // job.urls[idx] (url string, false = skipped after retry, undefined =
1982
+ // pending) strictly through the serial job.tail chain; serving always
1983
+ // advances job.serveIdx in order and awaits that index's own synthesis.
1984
+ // This closes the fastFirst race where a background-prewarmed chunk and an
1985
+ // on-demand /rvc-next request shared one nextIdx cursor: the on-demand
1986
+ // request could synthesize parts[2] while parts[1] was still warming, and
1987
+ // the client (which fills queue[want] in request order) would play 1,3,2.
1988
+ const jobs = new Map(); // jobId -> { id, voice, custom, parts, serveIdx, synthIdx, urls, pendings, tail, done, finishedAt, created }
1989
+ let jobSeq = 0;
1990
+
1991
+ // Per-chunk retry budget: a transient blip (network/5xx/GPU hiccup) retries
1992
+ // the SAME chunk once after a short backoff; a hard failure (400/401/403,
1993
+ // missing key/model) fails fast without burning the retry. Exhausted chunks
1994
+ // are SKIPPED (not fatal): playback continues with the next chunk and the
1995
+ // client toasts which segment was skipped. serveIdx only advances when its
1996
+ // own index resolves — order is never broken.
1997
+ function isHardChunkError(e) {
1998
+ const msg = String((e && e.message) || e);
1999
+ return /HTTP 40[013]|host\.(cloud(NoKey|KeyInvalid|BadRequest)|noModelConfigured|piperUnconfigured)/.test(msg);
2000
+ }
2001
+
2002
+ function storeChunkFile(job, idx, audioPath) {
2003
+ const id = 'c' + (++seq).toString(36) + '-' + hashText(job.parts[idx]).slice(0, 6);
2004
+ files.set(id, audioPath);
2005
+ if (files.size > 300) {
2006
+ const first = files.keys().next().value;
2007
+ if (first !== undefined) files.delete(first);
2008
+ }
2009
+ return '/dsh-tts-audio/' + id;
2010
+ }
2011
+
2012
+ // Synthesize parts[idx], store the result in job.urls[idx], resolve with the
2013
+ // serve payload for that index. Serialized on job.tail; concurrent callers
2014
+ // awaiting the same idx share the one synthesis via job.pendings.
2015
+ function synthChunkAt(job, idx) {
2016
+ const existing = job.pendings.get(idx);
2017
+ if (existing) return existing;
2018
+ const run = async () => {
2019
+ if (job.done) return { done: true };
2020
+ const text = job.parts[idx];
2021
+ let audioPath = null;
2022
+ let lastErr = null;
2023
+ for (let attempt = 0; attempt < 2; attempt++) {
2024
+ try {
2025
+ audioPath = await job.sink(text);
2026
+ lastErr = null;
2027
+ break;
2028
+ } catch (e) {
2029
+ lastErr = e;
2030
+ if (isHardChunkError(e)) break;
2031
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 600));
2032
+ }
2033
+ }
2034
+ if (lastErr) {
2035
+ // Skip, don't kill the job: record the hole at its own index so the
2036
+ // serve cursor still walks it in order; the client surfaces { skipped }
2037
+ // as a toast.
2038
+ job.urls[idx] = false;
2039
+ const skipErr = String((lastErr && lastErr.message) || lastErr);
2040
+ try { (job.skipErrors || (job.skipErrors = {}))[idx] = skipErr.slice(0, 300); } catch (e) { /* best-effort */ }
2041
+ recordHostError({
2042
+ provider: job.provider || '?',
2043
+ voice: job.voice,
2044
+ textLen: String(job.parts[idx] || '').length,
2045
+ stage: 'chunk ' + (idx + 1) + '/' + job.parts.length,
2046
+ error: skipErr.slice(0, 300)
2047
+ });
2048
+ return { skipped: idx + 1, error: skipErr };
2049
+ }
2050
+ const url = storeChunkFile(job, idx, audioPath);
2051
+ job.urls[idx] = url;
2052
+ return { url };
2053
+ };
2054
+ const p = job.tail.then(run, run);
2055
+ job.tail = p.catch(() => {}); // keep the chain alive even on per-chunk failure
2056
+ job.pendings.set(idx, p);
2057
+ const forget = () => { if (job.pendings.get(idx) === p) job.pendings.delete(idx); };
2058
+ p.then(forget, forget);
2059
+ return p;
2060
+ }
2061
+
2062
+ // Serve the next index in order, awaiting its own synthesis when needed.
2063
+ // Never synthesizes serveIdx+1 before serveIdx resolves, so the client —
2064
+ // which fills queue[want] in request order — always gets parts in order.
2065
+ async function nextJobChunk(jobId) {
2066
+ const job = jobs.get(jobId);
2067
+ if (!job) return { done: true, gone: true };
2068
+ if (job.done) return { done: true };
2069
+ const idx = job.serveIdx;
2070
+ if (idx >= job.parts.length) {
2071
+ job.finishedAt = job.finishedAt || Date.now();
2072
+ return { done: true };
2073
+ }
2074
+ const known = job.urls[idx];
2075
+ if (typeof known === 'string' || known === false) {
2076
+ job.serveIdx++;
2077
+ const more = job.serveIdx < job.parts.length;
2078
+ if (!more) job.finishedAt = job.finishedAt || Date.now();
2079
+ if (known === false) {
2080
+ return { skipped: idx + 1, error: job.skipErrors ? job.skipErrors[idx] || '' : '', more, index: idx };
2081
+ }
2082
+ return { url: known, more, index: idx };
2083
+ }
2084
+ const r = await synthChunkAt(job, idx);
2085
+ if (!jobs.get(jobId) || job.done) return { done: true };
2086
+ // synthChunkAt stored urls[idx]; serve it now (skip entries included).
2087
+ if (job.serveIdx !== idx) {
2088
+ // A cancel/retry moved the cursor while we awaited; re-enter once.
2089
+ return nextJobChunk(jobId);
2090
+ }
2091
+ job.serveIdx++;
2092
+ const more = job.serveIdx < job.parts.length;
2093
+ if (!more) job.finishedAt = job.finishedAt || Date.now();
2094
+ if (r && r.url) return { url: r.url, more, index: idx };
2095
+ if (r && r.skipped) return { skipped: idx + 1, error: r.error || '', more, index: idx };
2096
+ return { done: true };
2097
+ }
2098
+
2099
+ // Explicit cancel: the client abandons a chunked job early (user stops /
2100
+ // switches message). Stop scheduling further chunks and drop the job from the
2101
+ // registry immediately instead of waiting for the lazy 2/10-min GC, so the
2102
+ // local RVC GPU/memory is released promptly. The in-flight chunk (if any)
2103
+ // finishes writing a harmless temp file; no new chunks are started.
2104
+ function cancelJob(jobId) {
2105
+ const job = jobs.get(jobId);
2106
+ if (!job) return false;
2107
+ job.done = true;
2108
+ job.finishedAt = job.finishedAt || Date.now();
2109
+ jobs.delete(jobId);
2110
+ return true;
2111
+ }
2112
+
2113
+ function cleanupJobs() {
2114
+ const now = Date.now();
2115
+ for (const [id, job] of jobs) {
2116
+ if (job.finishedAt && now - job.finishedAt > 120000) jobs.delete(id);
2117
+ else if (!job.finishedAt && now - job.created > 600000) jobs.delete(id);
2118
+ }
2119
+ if (jobs.size > 50) {
2120
+ const entries = [...jobs.entries()].sort((x, y) => {
2121
+ const a = x[1].finishedAt || Number.MAX_SAFE_INTEGER;
2122
+ const b = y[1].finishedAt || Number.MAX_SAFE_INTEGER;
2123
+ return a - b;
2124
+ });
2125
+ while (jobs.size > 50) jobs.delete(entries.shift()[0]);
2126
+ }
2127
+ }
2128
+
2129
+ const speakDisposer = webServer.register({
2130
+ kind: 'exact',
2131
+ path: '/dsh-tts-api/speak',
2132
+ async handler(req, res) {
2133
+ const t0 = Date.now();
2134
+ let dbg = null; // { provider, voice, textLen } for the error log below
2135
+ try {
2136
+ let body = '';
2137
+ for await (const chunk of req) body += chunk;
2138
+ let parsed = {};
2139
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2140
+ const text = String((parsed && parsed.text) || '').trim();
2141
+ if (!text) {
2142
+ writeJson(res, 400, { error: 'empty text' });
2143
+ return;
2144
+ }
2145
+ const voice = String((parsed && parsed.voice) || 'zh-CN-XiaoxuanNeural');
2146
+ const provider = String((parsed && parsed.provider) || 'edge-tts');
2147
+ const custom = (parsed && parsed.custom) || null;
2148
+ const prosody = (parsed && parsed.prosody) || null;
2149
+ dbg = { provider, voice, textLen: text.length };
2150
+ const cfg = provider === 'rvc' ? rvcConfig(custom) : null;
2151
+ const indexCfg = provider === 'index-tts2' ? indexTts2Config(custom, voice) : null;
2152
+ const cosyCfg = provider === 'cosyvoice' ? cosyConfig(custom, voice) : null;
2153
+ {
2154
+ // Model/reference identity for triage (basenames only — never full
2155
+ // paths, never key material).
2156
+ const base = (p) => String(p || '').split(/[\\/]/).pop().slice(0, 80);
2157
+ if (provider === 'rvc' && cfg) {
2158
+ if (cfg.model) dbg.model = base(cfg.model);
2159
+ if (cfg.index) dbg.index = base(cfg.index);
2160
+ } else if ((provider === 'index-tts2' || provider === 'cosyvoice') && custom && custom.voice) {
2161
+ dbg.ref = base(custom.voice);
2162
+ } else if (provider === 'local-piper' && custom && custom.piperModel) {
2163
+ dbg.model = base(custom.piperModel);
2164
+ }
2165
+ }
2166
+ const cloudVoice = provider === 'google-cloud-tts'
2167
+ ? (String((custom && custom.voice) || voice || CLOUD_TTS_DEFAULTS.voice).trim() || CLOUD_TTS_DEFAULTS.voice)
2168
+ : null;
2169
+ const providerKey = provider === 'rvc'
2170
+ ? 'rvc|' + rvcFingerprint(cfg)
2171
+ : provider === 'index-tts2'
2172
+ ? 'index-tts2|' + JSON.stringify(indexCfg || {})
2173
+ : provider === 'cosyvoice'
2174
+ ? 'cosyvoice|' + JSON.stringify(cosyCfg || {})
2175
+ : provider === 'google-cloud-tts'
2176
+ ? 'google-cloud-tts|' + (cloudVoice || '') + '|' + JSON.stringify(prosody || {})
2177
+ : provider + '|' + JSON.stringify(prosody || {});
2178
+ const key = providerKey + '|' + voice + '|' + text;
2179
+ const hit = cache.get(key);
2180
+ if (hit) {
2181
+ // Harden: the cached URL may point to a file already evicted from the
2182
+ // `files` map or cleaned from the OS temp dir. Only reuse it if the
2183
+ // backing file still exists; otherwise re-synthesize (avoids a stale
2184
+ // URL that would 404 at play time).
2185
+ const hitId = String(hit).split('/').pop();
2186
+ const hitPath = files.get(hitId);
2187
+ if (hitPath && existsSync(hitPath)) {
2188
+ writeJson(res, 200, { url: hit, cached: true });
2189
+ return;
2190
+ }
2191
+ cache.delete(key);
2192
+ }
2193
+
2194
+ // ---- adaptive chunked progressive playback ----
2195
+ // Long reads stream progressively ("first chunk plays while the rest
2196
+ // synthesize") for RVC / Index-TTS2 / CosyVoice / Cloud / plain Edge
2197
+ // TTS, reusing one pipeline: RVC uses the calibration-probed chunk
2198
+ // size; Index-TTS2, CosyVoice and Edge use a fixed size (remote/local
2199
+ // synthesis is the bottleneck, no local speed to probe). Cloud uses
2200
+ // splitCloudPackets (sentence split + atomic protection from splitText,
2201
+ // then byte packing under the 5000-byte sync hard limit); short Cloud
2202
+ // text stays in the single-URL branch below as long as it fits one packet.
2203
+ const chunkable = estimateSpeechSeconds(text) > 12;
2204
+ if (chunkable) {
2205
+ cleanupJobs();
2206
+ const cal = provider === 'rvc'
2207
+ ? await getCalibration(cfg)
2208
+ : chunkCal(provider);
2209
+ if (provider === 'rvc') {
2210
+ // Calibration outcome explains chunked-playback starvation
2211
+ // (wrong chunkSec/prewarm, stale disk entry, failed probe).
2212
+ recordHostError({
2213
+ provider, voice, textLen: text.length,
2214
+ stage: 'calibration', kind: 'info',
2215
+ error: 'ratio=' + (cal.ratio === null || cal.ratio === undefined ? '?' : Number(cal.ratio).toFixed(2)) +
2216
+ ' chunkSec=' + cal.chunkSec + ' prewarm=' + cal.prewarm +
2217
+ ' probeFailed=' + (!!cal.probeFailed) + ' fromDisk=' + (!!cal.fromDisk)
2218
+ });
2219
+ }
2220
+ // fastFirst (edge/cloud only): TTFB = 1 block. Synthesize chunk 0,
2221
+ // return immediately, warm the rest in the background through the
2222
+ // same serial tail chain. Serving is strictly by index (serveIdx),
2223
+ // so a still-warming chunk is awaited — never overtaken by a later
2224
+ // one. Local synthesis (rvc/index/cosy, ratio 0.5-1.1) keeps full
2225
+ // prewarm (no fastFirst) so playback never starves.
2226
+ const maxChars = Math.max(16, Math.round(cal.chunkSec * (isLatinHeavy(text) ? 12 : 3.6)));
2227
+ const parts = provider === 'google-cloud-tts'
2228
+ ? splitCloudPackets(text)
2229
+ : splitText(text, maxChars);
2230
+ if (parts.length > 1) {
2231
+ const jobId = 'j' + (++jobSeq).toString(36);
2232
+ const job = {
2233
+ id: jobId,
2234
+ provider,
2235
+ voice,
2236
+ custom,
2237
+ sink: provider === 'rvc'
2238
+ ? t => synthesizeRvc(t, voice, custom)
2239
+ : provider === 'index-tts2'
2240
+ ? t => synthesizeIndexTts2(t, voice, custom)
2241
+ : provider === 'cosyvoice'
2242
+ ? t => synthesizeCosy(t, voice, custom)
2243
+ : provider === 'google-cloud-tts'
2244
+ ? t => synthesizeCloud(t, cloudVoice || voice, prosody, custom)
2245
+ : t => synthesize(t, voice, prosody || undefined),
2246
+ parts,
2247
+ serveIdx: 0,
2248
+ synthIdx: 0,
2249
+ urls: new Array(parts.length),
2250
+ skipErrors: {},
2251
+ pendings: new Map(),
2252
+ tail: Promise.resolve(),
2253
+ done: false,
2254
+ finishedAt: null,
2255
+ created: Date.now()
2256
+ };
2257
+ jobs.set(jobId, job);
2258
+ const prewarm = Math.min(cal.prewarm, parts.length);
2259
+ if (cal.fastFirst) {
2260
+ // First chunk awaited (failure -> 500 like before, never a
2261
+ // half-baked job); the rest warm in the background, each bound
2262
+ // to its own index. A background failure is recorded at its
2263
+ // index and served as { skipped } in order — never overtaken.
2264
+ const first = await synthChunkAt(job, 0);
2265
+ job.synthIdx = 1;
2266
+ if (!first || !first.url) {
2267
+ jobs.delete(jobId);
2268
+ throw hostErr('首段合成失败', 'host.chunkFail');
2269
+ }
2270
+ job.serveIdx = 1;
2271
+ for (let i = 1; i < prewarm; i++) {
2272
+ job.synthIdx = i + 1;
2273
+ synthChunkAt(job, i).catch(() => { /* served as skip in order */ });
2274
+ }
2275
+ writeJson(res, 200, {
2276
+ jobId,
2277
+ chunks: [first.url],
2278
+ total: parts.length,
2279
+ ratio: cal.ratio,
2280
+ chunkSec: cal.chunkSec
2281
+ });
2282
+ return;
2283
+ }
2284
+ const urls = [];
2285
+ let playable = 0;
2286
+ for (let i = 0; i < prewarm; i++) {
2287
+ job.synthIdx = i + 1;
2288
+ const r = await synthChunkAt(job, i);
2289
+ // Keep index alignment (a skipped prewarm chunk stays a `false`
2290
+ // hole the client walks over in order) instead of compacting.
2291
+ if (r && r.url) { urls.push(r.url); playable++; }
2292
+ else urls.push(false);
2293
+ }
2294
+ job.serveIdx = prewarm;
2295
+ if (!playable) {
2296
+ // all prewarm chunks skipped/failed (e.g. service down): never
2297
+ // hand out a half-baked job with zero playable audio.
2298
+ jobs.delete(jobId);
2299
+ throw hostErr('首段合成失败', 'host.chunkFail');
2300
+ }
2301
+ writeJson(res, 200, {
2302
+ jobId,
2303
+ chunks: urls,
2304
+ total: parts.length,
2305
+ ratio: cal.ratio,
2306
+ chunkSec: cal.chunkSec
2307
+ });
2308
+ return;
2309
+ }
2310
+ }
2311
+
2312
+ const absPath = await getProvider(provider).synthesizeShort(
2313
+ text, voice, prosody, custom,
2314
+ );
2315
+ const id = 'a' + (++seq).toString(36) + '-' + hashText(text).slice(0, 6);
2316
+ files.set(id, absPath);
2317
+ if (files.size > 300) {
2318
+ const first = files.keys().next().value;
2319
+ if (first !== undefined) files.delete(first);
2320
+ }
2321
+ const url = '/dsh-tts-audio/' + id;
2322
+ cache.set(key, url);
2323
+ if (cache.size > 60) {
2324
+ const first = cache.keys().next().value;
2325
+ if (first !== undefined) cache.delete(first);
2326
+ }
2327
+ writeJson(res, 200, { url });
2328
+ } catch (e) {
2329
+ if (dbg) {
2330
+ recordHostError(Object.assign({}, dbg, {
2331
+ stage: 'speak',
2332
+ ms: Date.now() - t0,
2333
+ error: String((e && e.message) || e).slice(0, 300)
2334
+ }));
2335
+ }
2336
+ writeRouteError(res, e);
2337
+ }
2338
+ }
2339
+ });
2340
+
2341
+ const audioDisposer = webServer.register({
2342
+ kind: 'prefix',
2343
+ path: '/dsh-tts-audio',
2344
+ async handler(req, res) {
2345
+ try {
2346
+ const url = req.url || '/';
2347
+ const segs = url.split('?')[0].split('/').filter(Boolean);
2348
+ const id = segs[segs.length - 1] || '';
2349
+ const download = new URL(url, 'http://x').searchParams.get('download') === '1';
2350
+ const absPath = files.get(id);
2351
+ if (!absPath || !existsSync(absPath)) {
2352
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
2353
+ res.end('not found');
2354
+ return;
2355
+ }
2356
+ const bytes = readFileSync(absPath);
2357
+ const isWav = absPath.toLowerCase().endsWith('.wav');
2358
+ const headers = {
2359
+ 'Content-Type': isWav ? 'audio/wav' : 'audio/mpeg',
2360
+ 'Content-Length': bytes.length,
2361
+ 'Cache-Control': 'private, max-age=300',
2362
+ 'X-Content-Type-Options': 'nosniff'
2363
+ };
2364
+ if (download) {
2365
+ // send as a downloadable attachment so users can keep the synthesized
2366
+ // audio (Edge base, RVC-converted or Index-TTS2) instead of only streaming it
2367
+ const filename = 'dsh-tts-' + id + (isWav ? '.wav' : '.mp3');
2368
+ headers['Content-Disposition'] = "attachment; filename=\"" + filename + "\"";
2369
+ }
2370
+ res.writeHead(200, headers);
2371
+ res.end(bytes);
2372
+ } catch (e) {
2373
+ try {
2374
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
2375
+ res.end('tts audio error');
2376
+ } catch (e2) { /* socket gone */ }
2377
+ }
2378
+ }
2379
+ });
2380
+
2381
+ const filesDisposer = webServer.register({
2382
+ kind: 'exact',
2383
+ path: '/dsh-tts-api/rvc-files',
2384
+ async handler(req, res) {
2385
+ const q = new URL(req.url || '', 'http://x').searchParams;
2386
+ const baseUrl = String(q.get('baseUrl') || RVC_DEFAULTS.baseUrl).replace(/\/+$/, '');
2387
+ try {
2388
+ const kind = q.get('kind') === 'index' ? 'index' : 'pth';
2389
+ const r = await fetch(baseUrl + '/files?kind=' + kind, {
2390
+ signal: AbortSignal.timeout(15000)
2391
+ });
2392
+ const data = await r.json().catch(() => null);
2393
+ if (!r.ok) {
2394
+ writeErr(res, 502, `RVC /files 接口异常(HTTP ${r.status}):${(data && data.message) || r.statusText}`, { code: "host.filesHttpFail", params: { status: r.status } });
2395
+ return;
2396
+ }
2397
+ writeJson(res, 200, data || { ok: false, files: [] });
2398
+ } catch (e) {
2399
+ const startup = rvcStartupHint();
2400
+ const rawError = String((e && e.message) || e);
2401
+ writeErr(res, 502,
2402
+ `无法连接本地 RVC 服务(${baseUrl})——浏览本地文件需要它先运行。\n` +
2403
+ startup +
2404
+ `\n并确认设置里的「服务地址」与端口一致。原始错误:${rawError}`,
2405
+ { code: "host.filesNeedsServer", params: { baseUrl, startup, error: rawError } }
2406
+ );
2407
+ }
2408
+ }
2409
+ });
2410
+
2411
+ const nextDisposer = webServer.register({
2412
+ kind: 'exact',
2413
+ path: '/dsh-tts-api/rvc-next',
2414
+ async handler(req, res) {
2415
+ try {
2416
+ const q = new URL(req.url || '', 'http://x').searchParams;
2417
+ const jobId = q.get('job') || '';
2418
+ const cancel = q.get('cancel') === '1';
2419
+ cleanupJobs();
2420
+ if (cancel) {
2421
+ // client abandon: release the job now (see cancelJob)
2422
+ const was = cancelJob(jobId);
2423
+ writeJson(res, 200, { done: true, cancelled: was });
2424
+ return;
2425
+ }
2426
+ const r = await nextJobChunk(jobId);
2427
+ writeJson(res, 200, r);
2428
+ } catch (e) {
2429
+ writeErr(res, 500, `后续段落合成失败:${String((e && e.message) || e)}`, { code: "host.chunkFail" });
2430
+ }
2431
+ }
2432
+ });
2433
+
2434
+ const compactDisposer = webServer.register({
2435
+ kind: 'exact',
2436
+ path: '/dsh-tts-api/rvc-compact-index',
2437
+ async handler(req, res) {
2438
+ try {
2439
+ let body = '';
2440
+ for await (const chunk of req) body += chunk;
2441
+ let parsed = {};
2442
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2443
+ const baseUrl = String(parsed.baseUrl || RVC_DEFAULTS.baseUrl).replace(/\/+$/, '');
2444
+ const data = await rvcJson(baseUrl, '/compact-index', {
2445
+ index: String(parsed.index || ''),
2446
+ target_vectors: Number(parsed.target_vectors || 10000),
2447
+ out_dir: String(parsed.out_dir || '')
2448
+ }, 300000);
2449
+ writeJson(res, 200, data);
2450
+ } catch (e) {
2451
+ const msg = String((e && e.message) || e);
2452
+ const needsServer = /无法连接本地 RVC 推理服务/.test(msg);
2453
+ const startup = rvcStartupHint();
2454
+ writeErr(res, 502,
2455
+ needsServer
2456
+ ? `无法连接本地 RVC 服务——生成紧凑索引需要它先运行。\n` +
2457
+ startup +
2458
+ `\n原始错误:${msg}`
2459
+ : `紧凑索引生成失败:${msg}`,
2460
+ {
2461
+ code: needsServer ? "host.compactNeedsServer" : "host.compactFail",
2462
+ params: needsServer ? { baseUrl, startup, error: msg } : { error: msg }
2463
+ }
2464
+ );
2465
+ }
2466
+ }
2467
+ });
2468
+
2469
+ const packsDisposer = webServer.register({
2470
+ kind: 'exact',
2471
+ path: '/dsh-tts-api/rvc-packs',
2472
+ async handler(req, res) {
2473
+ try {
2474
+ const q = new URL(req.url || '', 'http://x').searchParams;
2475
+ const registry = q.get('registry') || '';
2476
+ const proxy = q.get('proxy') || '';
2477
+ const { data } = await fetchManifest(registry, proxy);
2478
+ writeJson(res, 200, data);
2479
+ } catch (e) {
2480
+ writeErr(res, 502, `获取音色包列表失败:${String((e && e.message) || e)}`, { code: "host.packsListFail" });
2481
+ }
2482
+ }
2483
+ });
2484
+
2485
+ const packsInstalledDisposer = webServer.register({
2486
+ kind: 'exact',
2487
+ path: '/dsh-tts-api/rvc-packs-installed',
2488
+ async handler(req, res) {
2489
+ try {
2490
+ writeJson(res, 200, { installed: installedPacks(), packsDir: packsDir() });
2491
+ } catch (e) {
2492
+ writeRouteError(res, e);
2493
+ }
2494
+ }
2495
+ });
2496
+
2497
+ const packInstallDisposer = webServer.register({
2498
+ kind: 'exact',
2499
+ path: '/dsh-tts-api/rvc-pack-install',
2500
+ async handler(req, res) {
2501
+ try {
2502
+ let body = '';
2503
+ for await (const chunk of req) body += chunk;
2504
+ let parsed = {};
2505
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2506
+ const registry = String(parsed.registry || '');
2507
+ const packId = String(parsed.packId || '').trim();
2508
+ const indexId = parsed.indexId ? String(parsed.indexId) : '';
2509
+ const progressKey = parsed.progressKey ? String(parsed.progressKey).slice(0, 80) : '';
2510
+ const proxy = parsed.proxy ? String(parsed.proxy).slice(0, 300) : '';
2511
+ if (!registry || !packId) {
2512
+ writeErr(res, 400, 'registry 与 packId 必填', { code: 'host.registryPackRequired' });
2513
+ return;
2514
+ }
2515
+ // create the progress entry BEFORE the manifest fetch so the client's
2516
+ // poll never sees an empty gap and mistakes it for "finished"
2517
+ setPackProgress(progressKey, { phase: '准备', phaseKey: 'prepare', done: 0, total: 0 });
2518
+ try {
2519
+ const result = await installPack(registry, packId, indexId, progressKey, proxy);
2520
+ writeJson(res, 200, result);
2521
+ } catch (e) {
2522
+ finishPackProgress(progressKey);
2523
+ writeErr(res, 502, `音色包安装失败:${String((e && e.message) || e)}`, { code: "host.packInstallFail" });
2524
+ }
2525
+ } catch (e) {
2526
+ writeRouteError(res, e);
2527
+ }
2528
+ }
2529
+ });
2530
+
2531
+ const packUninstallDisposer = webServer.register({
2532
+ kind: 'exact',
2533
+ path: '/dsh-tts-api/rvc-pack-uninstall',
2534
+ async handler(req, res) {
2535
+ try {
2536
+ let body = '';
2537
+ for await (const chunk of req) body += chunk;
2538
+ let parsed = {};
2539
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2540
+ const packId = String(parsed.packId || '').trim();
2541
+ if (!packId) {
2542
+ writeErr(res, 400, 'packId 必填', { code: 'host.packIdRequired' });
2543
+ return;
2544
+ }
2545
+ const stem = packId.replace(/[^\w.-]/g, '_');
2546
+ const dir = path.join(packsDir(), stem);
2547
+ try {
2548
+ rmSync(dir, { recursive: true, force: true });
2549
+ } catch (e) { /* best-effort */ }
2550
+ const installed = installedPacks();
2551
+ delete installed[packId];
2552
+ saveInstalledPacks(installed);
2553
+ writeJson(res, 200, { ok: true });
2554
+ } catch (e) {
2555
+ writeErr(res, 502, `卸载失败:${String((e && e.message) || e)}`, { code: "host.packUninstallFail" });
2556
+ }
2557
+ }
2558
+ });
2559
+
2560
+ const diagnoseDisposer = webServer.register({
2561
+ kind: 'exact',
2562
+ path: '/dsh-tts-api/diagnose',
2563
+ async handler(req, res) {
2564
+ try {
2565
+ let body = '';
2566
+ for await (const chunk of req) body += chunk;
2567
+ let parsed = {};
2568
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2569
+ const baseUrl = String(parsed.rvcBaseUrl || RVC_DEFAULTS.baseUrl).replace(/\/+$/, '');
2570
+ const checks = [];
2571
+
2572
+ // 1) Edge TTS online synthesis
2573
+ const edgeCheck = { id: 'edge', name: 'Edge TTS 在线合成', ok: false, cls: '', detail: '' };
2574
+ try {
2575
+ const t0 = Date.now();
2576
+ await synthesize('测试一下。', 'zh-CN-XiaoxuanNeural', {});
2577
+ edgeCheck.ok = true;
2578
+ edgeCheck.cls = 'ok';
2579
+ edgeCheck.detail = '正常(' + (Date.now() - t0) + 'ms)';
2580
+ } catch (e) {
2581
+ const msg = String((e && e.message) || e);
2582
+ if (msg.includes('1007')) {
2583
+ edgeCheck.cls = 'voice';
2584
+ edgeCheck.detail = '音色被端点移除(1007):请换用列表内的音色';
2585
+ } else if (/403|Sec-MS-GEC|1006|timeout|websocket|closed early/i.test(msg)) {
2586
+ edgeCheck.cls = 'protocol';
2587
+ edgeCheck.detail = 'Edge 端点协议/网络异常:' + msg.slice(0, 140);
2588
+ } else {
2589
+ edgeCheck.cls = 'connect';
2590
+ edgeCheck.detail = '网络/连接异常:' + msg.slice(0, 140);
2591
+ }
2592
+ }
2593
+ checks.push(edgeCheck);
2594
+
2595
+ // 2) RVC server reachability + loaded model status
2596
+ const rvcCheck = { id: 'rvc-server', name: '本地 RVC 服务', ok: false, cls: '', detail: '' };
2597
+ const modelCheck = { id: 'rvc-model', name: 'RVC 模型', ok: false, cls: '', detail: '' };
2598
+ try {
2599
+ const r = await fetch(baseUrl + '/health', { signal: AbortSignal.timeout(6000) });
2600
+ const d = await r.json().catch(() => null);
2601
+ if (!r.ok || !d || d.ok !== true) {
2602
+ rvcCheck.cls = 'protocol';
2603
+ rvcCheck.detail = '服务响应异常(HTTP ' + r.status + ')';
2604
+ } else {
2605
+ rvcCheck.ok = true;
2606
+ rvcCheck.cls = 'ok';
2607
+ rvcCheck.detail = '在线' + (d.gpu_name ? ' · ' + d.gpu_name + ' · ' + (d.vram_gb || '?') + 'GB' : '');
2608
+ if (d.model_loaded) {
2609
+ modelCheck.ok = true;
2610
+ modelCheck.cls = 'ok';
2611
+ modelCheck.detail = '已加载:' + (d.model || '');
2612
+ } else {
2613
+ modelCheck.cls = 'warn';
2614
+ modelCheck.detail = '服务在线但未加载模型——填好模型路径后,首次朗读会自动加载';
2615
+ }
2616
+ }
2617
+ } catch (e) {
2618
+ rvcCheck.cls = 'connect';
2619
+ rvcCheck.detail = '无法连接(' + baseUrl + ')——请先启动 rvc-server.py(见使用手册 §4.2),并确认「服务地址」';
2620
+ modelCheck.cls = 'connect';
2621
+ modelCheck.detail = '服务未连上,跳过模型检查';
2622
+ }
2623
+ checks.push(rvcCheck, modelCheck);
2624
+
2625
+ // 3) Local Index-TTS2 service (reference-audio voice engine)
2626
+ const indexBaseUrl = String(parsed.indexBaseUrl || INDEX_TTS2_DEFAULTS.baseUrl).replace(/\/+$/, '');
2627
+ const indexCheck = { id: 'index-server', name: '本地 Index-TTS2 服务', ok: false, cls: '', detail: '' };
2628
+ const indexVoiceCheck = { id: 'index-voice', name: 'Index-TTS2 参考音色', ok: false, cls: '', detail: '' };
2629
+ try {
2630
+ const r = await fetch(indexBaseUrl + '/health', { signal: AbortSignal.timeout(6000) });
2631
+ const d = await r.json().catch(() => null);
2632
+ if (!r.ok || !d) {
2633
+ indexCheck.cls = 'protocol';
2634
+ indexCheck.detail = '服务响应异常(HTTP ' + r.status + ')';
2635
+ } else {
2636
+ indexCheck.ok = true;
2637
+ indexCheck.cls = 'ok';
2638
+ indexCheck.detail = '在线' + (d.model_loaded === false ? '(模型未加载)' : '');
2639
+ try {
2640
+ const vr = await fetch(indexBaseUrl + '/api/v1/voices', { signal: AbortSignal.timeout(6000) });
2641
+ const vd = await vr.json().catch(() => null);
2642
+ const voices = (vd && Array.isArray(vd.voices)) ? vd.voices : [];
2643
+ if (!vr.ok) {
2644
+ indexVoiceCheck.cls = 'protocol';
2645
+ indexVoiceCheck.detail = '音色列表异常(HTTP ' + vr.status + ')';
2646
+ } else if (!voices.length) {
2647
+ indexVoiceCheck.cls = 'warn';
2648
+ indexVoiceCheck.detail = '服务在线但 api/ckyp 为空——请放参考音频再刷新';
2649
+ } else {
2650
+ indexVoiceCheck.ok = true;
2651
+ indexVoiceCheck.cls = 'ok';
2652
+ indexVoiceCheck.detail = voices.length + ' 个参考音色:' + voices.slice(0, 3).join('、') + (voices.length > 3 ? '…' : '');
2653
+ }
2654
+ } catch (e2) {
2655
+ indexVoiceCheck.cls = 'connect';
2656
+ indexVoiceCheck.detail = '音色列表读取失败:' + String((e2 && e2.message) || e2).slice(0, 120);
2657
+ }
2658
+ }
2659
+ } catch (e) {
2660
+ indexCheck.cls = 'connect';
2661
+ indexCheck.detail = '无法连接(' + indexBaseUrl + ')——请先双击本地包里的「启动api服务.bat」,并确认「服务地址」';
2662
+ indexVoiceCheck.cls = 'connect';
2663
+ indexVoiceCheck.detail = '服务未连上,跳过音色检查';
2664
+ }
2665
+ checks.push(indexCheck, indexVoiceCheck);
2666
+
2667
+ // 4) Local CosyVoice service (zero-shot prompt voice engine)
2668
+ const cosyBaseUrl = String(parsed.cosyBaseUrl || COSY_DEFAULTS.baseUrl).replace(/\/+$/, '');
2669
+ const cosyCheck = { id: 'cosy-server', name: '本地 CosyVoice 服务', ok: false, cls: '', detail: '' };
2670
+ const cosyVoiceCheck = { id: 'cosy-voice', name: 'CosyVoice 提示音频', ok: false, cls: '', detail: '' };
2671
+ try {
2672
+ const r = await fetch(cosyBaseUrl + '/health', { signal: AbortSignal.timeout(6000) });
2673
+ const d = await r.json().catch(() => null);
2674
+ if (!r.ok || !d) {
2675
+ cosyCheck.cls = 'protocol';
2676
+ cosyCheck.detail = '服务响应异常(HTTP ' + r.status + ')';
2677
+ } else {
2678
+ cosyCheck.ok = true;
2679
+ cosyCheck.cls = 'ok';
2680
+ cosyCheck.detail = '在线' + (d.gpu_name ? ' · ' + d.gpu_name + ' · ' + (d.vram_gb || '?') + 'GB' : '');
2681
+ try {
2682
+ const vr = await fetch(cosyBaseUrl + '/voices', { signal: AbortSignal.timeout(6000) });
2683
+ const vd = await vr.json().catch(() => null);
2684
+ const voices = (vd && Array.isArray(vd.voices)) ? vd.voices : [];
2685
+ if (!vr.ok) {
2686
+ cosyVoiceCheck.cls = 'protocol';
2687
+ cosyVoiceCheck.detail = '提示音频列表异常(HTTP ' + vr.status + ')';
2688
+ } else if (!voices.length) {
2689
+ cosyVoiceCheck.cls = 'warn';
2690
+ cosyVoiceCheck.detail = '服务在线但 prompts 为空——请放提示音频再刷新';
2691
+ } else {
2692
+ cosyVoiceCheck.ok = true;
2693
+ cosyVoiceCheck.cls = 'ok';
2694
+ cosyVoiceCheck.detail = voices.length + ' 个提示音频:' + voices.slice(0, 3).join('、') + (voices.length > 3 ? '…' : '');
2695
+ }
2696
+ } catch (e2) {
2697
+ cosyVoiceCheck.cls = 'connect';
2698
+ cosyVoiceCheck.detail = '提示音频列表读取失败:' + String((e2 && e2.message) || e2).slice(0, 120);
2699
+ }
2700
+ }
2701
+ } catch (e) {
2702
+ cosyCheck.cls = 'connect';
2703
+ cosyCheck.detail = '无法连接(' + cosyBaseUrl + ')——请先双击本地包里的「启动cosy服务.bat」,并确认「服务地址」';
2704
+ cosyVoiceCheck.cls = 'connect';
2705
+ cosyVoiceCheck.detail = '服务未连上,跳过提示音频检查';
2706
+ }
2707
+ checks.push(cosyCheck, cosyVoiceCheck);
2708
+
2709
+ // 5) Google Cloud TTS: key presence + optional live probe.
2710
+ // Skipped by default (no key configured -> neutral state, not red);
2711
+ // when probe=true and a key exists, synthesize a short test clip.
2712
+ const cloudStored = loadHostCloudSettings();
2713
+ const cloudCheck = { id: 'cloud', name: 'Google Cloud TTS', ok: false, cls: '', detail: '' };
2714
+ if (!cloudStored.apiKey) {
2715
+ cloudCheck.cls = 'skip';
2716
+ cloudCheck.detail = '未配置 API Key(设置 → 插件 → 语音 → Cloud 配置填写后可测)';
2717
+ } else if (String(parsed.cloudProbe || '') === '1') {
2718
+ const cloudVoice = String(parsed.cloudVoice || CLOUD_TTS_DEFAULTS.voice);
2719
+ try {
2720
+ const t0 = Date.now();
2721
+ await synthesizeCloudPacket('测试一下。', cloudVoice, null, cloudStored.apiKey, 30000);
2722
+ cloudCheck.ok = true;
2723
+ cloudCheck.cls = 'ok';
2724
+ cloudCheck.detail = '正常(' + (Date.now() - t0) + 'ms · ' + cloudTier(cloudVoice) + ')';
2725
+ } catch (e) {
2726
+ const msg = String((e && e.message) || e);
2727
+ if (/Key 无效|API Key/i.test(msg)) {
2728
+ cloudCheck.cls = 'voice';
2729
+ cloudCheck.detail = 'Key 无效或未启用 Text-to-Speech API';
2730
+ } else {
2731
+ cloudCheck.cls = 'connect';
2732
+ cloudCheck.detail = '合成异常:' + msg.slice(0, 140);
2733
+ }
2734
+ }
2735
+ } else {
2736
+ cloudCheck.ok = true;
2737
+ cloudCheck.cls = 'ok';
2738
+ cloudCheck.detail = '已配置 API Key(勾选探测可实测合成)';
2739
+ }
2740
+ checks.push(cloudCheck);
2741
+ // In-flight chunked jobs (counts only — never text) for "stuck at
2742
+ // segment x/y" triage when the user exports mid-read.
2743
+ const activeJobs = [...jobs.values()].map((j) => ({
2744
+ provider: j.provider || '?',
2745
+ total: j.parts.length,
2746
+ done: j.serveIdx,
2747
+ ready: (j.urls || []).filter((u) => typeof u === 'string').length
2748
+ }));
2749
+ writeJson(res, 200, {
2750
+ ok: checks.every((c) => c.ok),
2751
+ checks,
2752
+ plugin: PLUGIN_META,
2753
+ worker: WORKER_META,
2754
+ activeJobs,
2755
+ recent: recentErrors.slice(-20)
2756
+ });
2757
+ } catch (e) {
2758
+ writeRouteError(res, e);
2759
+ }
2760
+ }
2761
+ });
2762
+
2763
+ const packProgressDisposer = webServer.register({
2764
+ kind: 'exact',
2765
+ path: '/dsh-tts-api/rvc-pack-progress',
2766
+ async handler(req, res) {
2767
+ try {
2768
+ const q = new URL(req.url || '', 'http://x').searchParams;
2769
+ const key = q.get('key') || '';
2770
+ const p = key ? packProgress.get(key) : undefined;
2771
+ if (p) {
2772
+ const elapsed = Math.max(1, (Date.now() - p.startedAt) / 1000);
2773
+ writeJson(res, 200, {
2774
+ phase: p.phase,
2775
+ phaseKey: p.phaseKey || (p.phase === '模型' ? 'model' : p.phase === '索引' ? 'index' : 'prepare'),
2776
+ done: p.done,
2777
+ total: p.total,
2778
+ speed: Math.round(p.done / elapsed),
2779
+ finished: !!p.finished
2780
+ });
2781
+ if (Date.now() - p.startedAt > 600000) packProgress.delete(key);
2782
+ return;
2783
+ }
2784
+ writeJson(res, 200, { waiting: true }); // entry not created yet — keep polling
2785
+ } catch (e) {
2786
+ writeRouteError(res, e);
2787
+ }
2788
+ }
2789
+ });
2790
+
2791
+ // Host RVC service settings: GET returns persisted service config merged
2792
+ // over defaults; POST merges the given keys (empty string clears a key).
2793
+ // localStorage keeps UI prefs only; this file is the source of truth for
2794
+ // baseUrl/model/index across browsers and browser-data clears.
2795
+ const rvcConfigGetDisposer = webServer.register({
2796
+ kind: 'exact',
2797
+ path: '/dsh-tts-api/rvc-config',
2798
+ async handler(req, res) {
2799
+ try {
2800
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
2801
+ writeJson(res, 405, { error: 'method not allowed' });
2802
+ return;
2803
+ }
2804
+ const stored = loadHostRvcSettings();
2805
+ writeJson(res, 200, {
2806
+ rvc: {
2807
+ baseUrl: stored.baseUrl || RVC_DEFAULTS.baseUrl,
2808
+ model: stored.model || '',
2809
+ index: stored.index || ''
2810
+ }
2811
+ });
2812
+ } catch (e) {
2813
+ writeRouteError(res, e);
2814
+ }
2815
+ }
2816
+ });
2817
+ const rvcConfigPostDisposer = webServer.register({
2818
+ kind: 'exact',
2819
+ path: '/dsh-tts-api/rvc-config-save',
2820
+ async handler(req, res) {
2821
+ try {
2822
+ let body = '';
2823
+ for await (const chunk of req) body += chunk;
2824
+ let parsed = {};
2825
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2826
+ const patch = (parsed && parsed.rvc && typeof parsed.rvc === 'object') ? parsed.rvc : parsed;
2827
+ if (patch.baseUrl !== undefined && typeof patch.baseUrl === 'string' && patch.baseUrl.trim() !== '') {
2828
+ const u = patch.baseUrl.trim();
2829
+ if (!/^https?:\/\//i.test(u)) {
2830
+ writeJson(res, 400, { error: '服务地址必须是 http(s) URL', i18n: { code: 'host.registryNotUrl', params: {} } });
2831
+ return;
2832
+ }
2833
+ }
2834
+ const next = saveHostRvcSettings(patch);
2835
+ writeJson(res, 200, {
2836
+ ok: true,
2837
+ rvc: {
2838
+ baseUrl: next.baseUrl || RVC_DEFAULTS.baseUrl,
2839
+ model: next.model || '',
2840
+ index: next.index || ''
2841
+ }
2842
+ });
2843
+ } catch (e) {
2844
+ writeRouteError(res, e);
2845
+ }
2846
+ }
2847
+ });
2848
+
2849
+ // Local Index-TTS2 service proxy: voices list, reference-audio upload and
2850
+ // service-address persistence. Synthesis itself goes through /speak with
2851
+ // provider 'index-tts2' (custom = shared.index), so chunking/cache stay unified.
2852
+ const indexVoicesDisposer = webServer.register({
2853
+ kind: 'exact',
2854
+ path: '/dsh-tts-api/index-voices',
2855
+ async handler(req, res) {
2856
+ try {
2857
+ const q = new URL(req.url || '', 'http://x').searchParams;
2858
+ const baseUrl = String(q.get('baseUrl') || INDEX_TTS2_DEFAULTS.baseUrl).replace(/\/+$/, '');
2859
+ let r;
2860
+ try {
2861
+ r = await fetch(baseUrl + '/api/v1/voices', { signal: AbortSignal.timeout(15000) });
2862
+ } catch (e) {
2863
+ writeErr(res, 502,
2864
+ `无法连接本地 Index-TTS2 服务(${baseUrl})。\n${indexStartupHint()}\n原始错误:${String((e && e.message) || e)}`,
2865
+ { code: 'host.indexNeedsServer', params: { baseUrl } });
2866
+ return;
2867
+ }
2868
+ const data = await r.json().catch(() => null);
2869
+ if (!r.ok) {
2870
+ writeErr(res, 502, `Index-TTS2 /api/v1/voices 异常(HTTP ${r.status})`, { code: 'host.indexVoicesFail', params: { status: r.status } });
2871
+ return;
2872
+ }
2873
+ writeJson(res, 200, data || { voices: [], count: 0 });
2874
+ } catch (e) {
2875
+ writeRouteError(res, e);
2876
+ }
2877
+ }
2878
+ });
2879
+ const indexUploadDisposer = webServer.register({
2880
+ kind: 'exact',
2881
+ path: '/dsh-tts-api/index-upload',
2882
+ async handler(req, res) {
2883
+ try {
2884
+ let body = '';
2885
+ for await (const chunk of req) body += chunk;
2886
+ let parsed = {};
2887
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2888
+ const baseUrl = String(parsed.baseUrl || INDEX_TTS2_DEFAULTS.baseUrl).replace(/\/+$/, '');
2889
+ const filename = String(parsed.filename || '').split(/[\\/]/).pop();
2890
+ const audioBase64 = String(parsed.audioBase64 || '');
2891
+ if (!filename || !audioBase64) {
2892
+ writeErr(res, 400, 'filename 与 audioBase64 必填', { code: 'host.uploadRequired' });
2893
+ return;
2894
+ }
2895
+ const bytes = Buffer.from(audioBase64, 'base64');
2896
+ if (!bytes.length) {
2897
+ writeErr(res, 400, '音频内容为空', { code: 'host.uploadEmpty' });
2898
+ return;
2899
+ }
2900
+ // Forward as multipart to the Index-TTS2 service (no extra deps:
2901
+ // build the multipart body by hand).
2902
+ const boundary = '----dsh-index-' + Date.now().toString(36);
2903
+ const head = Buffer.from(
2904
+ '--' + boundary + '\r\n' +
2905
+ 'Content-Disposition: form-data; name="file"; filename="' + filename.replace(/"/g, '_') + '"\r\n' +
2906
+ 'Content-Type: application/octet-stream\r\n\r\n', 'utf8');
2907
+ const tail = Buffer.from('\r\n--' + boundary + '--\r\n', 'utf8');
2908
+ let r;
2909
+ try {
2910
+ r = await fetch(baseUrl + '/api/v1/upload', {
2911
+ method: 'POST',
2912
+ headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary },
2913
+ body: Buffer.concat([head, bytes, tail]),
2914
+ signal: AbortSignal.timeout(120000)
2915
+ });
2916
+ } catch (e) {
2917
+ writeErr(res, 502, `无法连接本地 Index-TTS2 服务(${baseUrl})`, { code: 'host.indexNeedsServer', params: { baseUrl } });
2918
+ return;
2919
+ }
2920
+ const data = await r.json().catch(() => null);
2921
+ if (!r.ok) {
2922
+ writeErr(res, 502, `参考音频上传失败(HTTP ${r.status}):${(data && (data.detail || data.message)) || r.statusText}`, { code: 'host.indexUploadFail', params: { status: r.status } });
2923
+ return;
2924
+ }
2925
+ writeJson(res, 200, data || { ok: true });
2926
+ } catch (e) {
2927
+ writeRouteError(res, e);
2928
+ }
2929
+ }
2930
+ });
2931
+ const indexConfigGetDisposer = webServer.register({
2932
+ kind: 'exact',
2933
+ path: '/dsh-tts-api/index-config',
2934
+ async handler(req, res) {
2935
+ try {
2936
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
2937
+ writeJson(res, 405, { error: 'method not allowed' });
2938
+ return;
2939
+ }
2940
+ const stored = loadHostIndexSettings();
2941
+ writeJson(res, 200, { index: { baseUrl: stored.baseUrl || INDEX_TTS2_DEFAULTS.baseUrl } });
2942
+ } catch (e) {
2943
+ writeRouteError(res, e);
2944
+ }
2945
+ }
2946
+ });
2947
+ const indexConfigPostDisposer = webServer.register({
2948
+ kind: 'exact',
2949
+ path: '/dsh-tts-api/index-config-save',
2950
+ async handler(req, res) {
2951
+ try {
2952
+ let body = '';
2953
+ for await (const chunk of req) body += chunk;
2954
+ let parsed = {};
2955
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
2956
+ const patch = (parsed && parsed.index && typeof parsed.index === 'object') ? parsed.index : parsed;
2957
+ if (patch.baseUrl !== undefined && typeof patch.baseUrl === 'string' && patch.baseUrl.trim() !== '') {
2958
+ const u = patch.baseUrl.trim();
2959
+ if (!/^https?:\/\//i.test(u)) {
2960
+ writeJson(res, 400, { error: '服务地址必须是 http(s) URL', i18n: { code: 'host.registryNotUrl', params: {} } });
2961
+ return;
2962
+ }
2963
+ }
2964
+ const next = saveHostIndexSettings(patch);
2965
+ writeJson(res, 200, { ok: true, index: { baseUrl: next.baseUrl || INDEX_TTS2_DEFAULTS.baseUrl } });
2966
+ } catch (e) {
2967
+ writeRouteError(res, e);
2968
+ }
2969
+ }
2970
+ });
2971
+
2972
+ // Local CosyVoice service proxy: voices list, prompt-audio upload and
2973
+ // service-address persistence. Synthesis itself goes through /speak with
2974
+ // provider 'cosyvoice' (custom = shared.cosy), so chunking/cache stay unified.
2975
+ const cosyVoicesDisposer = webServer.register({
2976
+ kind: 'exact',
2977
+ path: '/dsh-tts-api/cosy-voices',
2978
+ async handler(req, res) {
2979
+ try {
2980
+ const q = new URL(req.url || '', 'http://x').searchParams;
2981
+ const baseUrl = String(q.get('baseUrl') || COSY_DEFAULTS.baseUrl).replace(/\/+$/, '');
2982
+ let r;
2983
+ try {
2984
+ r = await fetch(baseUrl + '/voices', { signal: AbortSignal.timeout(15000) });
2985
+ } catch (e) {
2986
+ writeErr(res, 502,
2987
+ `无法连接本地 CosyVoice 服务(${baseUrl})。\n${cosyStartupHint()}\n原始错误:${String((e && e.message) || e)}`,
2988
+ { code: 'host.cosyNeedsServer', params: { baseUrl } });
2989
+ return;
2990
+ }
2991
+ const data = await r.json().catch(() => null);
2992
+ if (!r.ok) {
2993
+ writeErr(res, 502, `CosyVoice /voices 异常(HTTP ${r.status})`, { code: 'host.cosyVoicesFail', params: { status: r.status } });
2994
+ return;
2995
+ }
2996
+ writeJson(res, 200, data || { voices: [], count: 0 });
2997
+ } catch (e) {
2998
+ writeRouteError(res, e);
2999
+ }
3000
+ }
3001
+ });
3002
+ const cosyUploadDisposer = webServer.register({
3003
+ kind: 'exact',
3004
+ path: '/dsh-tts-api/cosy-upload',
3005
+ async handler(req, res) {
3006
+ try {
3007
+ let body = '';
3008
+ for await (const chunk of req) body += chunk;
3009
+ let parsed = {};
3010
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
3011
+ const baseUrl = String(parsed.baseUrl || COSY_DEFAULTS.baseUrl).replace(/\/+$/, '');
3012
+ const filename = String(parsed.filename || '').split(/[\\/]/).pop();
3013
+ const audioBase64 = String(parsed.audioBase64 || '');
3014
+ if (!filename || !audioBase64) {
3015
+ writeErr(res, 400, 'filename 与 audioBase64 必填', { code: 'host.uploadRequired' });
3016
+ return;
3017
+ }
3018
+ const bytes = Buffer.from(audioBase64, 'base64');
3019
+ if (!bytes.length) {
3020
+ writeErr(res, 400, '音频内容为空', { code: 'host.uploadEmpty' });
3021
+ return;
3022
+ }
3023
+ // cosy-server.py takes JSON + base64 directly (no multipart needed).
3024
+ let r;
3025
+ try {
3026
+ r = await fetch(baseUrl + '/upload', {
3027
+ method: 'POST',
3028
+ headers: { 'Content-Type': 'application/json' },
3029
+ body: JSON.stringify({ filename, audio_base64: bytes.toString('base64') }),
3030
+ signal: AbortSignal.timeout(120000)
3031
+ });
3032
+ } catch (e) {
3033
+ writeErr(res, 502, `无法连接本地 CosyVoice 服务(${baseUrl})`, { code: 'host.cosyNeedsServer', params: { baseUrl } });
3034
+ return;
3035
+ }
3036
+ const data = await r.json().catch(() => null);
3037
+ if (!r.ok) {
3038
+ writeErr(res, 502, `提示音频上传失败(HTTP ${r.status}):${(data && (data.detail || data.message)) || r.statusText}`, { code: 'host.cosyUploadFail', params: { status: r.status } });
3039
+ return;
3040
+ }
3041
+ writeJson(res, 200, data || { ok: true });
3042
+ } catch (e) {
3043
+ writeRouteError(res, e);
3044
+ }
3045
+ }
3046
+ });
3047
+ const cosyConfigGetDisposer = webServer.register({
3048
+ kind: 'exact',
3049
+ path: '/dsh-tts-api/cosy-config',
3050
+ async handler(req, res) {
3051
+ try {
3052
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
3053
+ writeJson(res, 405, { error: 'method not allowed' });
3054
+ return;
3055
+ }
3056
+ const stored = loadHostCosySettings();
3057
+ writeJson(res, 200, { cosy: { baseUrl: stored.baseUrl || COSY_DEFAULTS.baseUrl } });
3058
+ } catch (e) {
3059
+ writeRouteError(res, e);
3060
+ }
3061
+ }
3062
+ });
3063
+ const cosyConfigPostDisposer = webServer.register({
3064
+ kind: 'exact',
3065
+ path: '/dsh-tts-api/cosy-config-save',
3066
+ async handler(req, res) {
3067
+ try {
3068
+ let body = '';
3069
+ for await (const chunk of req) body += chunk;
3070
+ let parsed = {};
3071
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
3072
+ const patch = (parsed && parsed.cosy && typeof parsed.cosy === 'object') ? parsed.cosy : parsed;
3073
+ if (patch.baseUrl !== undefined && typeof patch.baseUrl === 'string' && patch.baseUrl.trim() !== '') {
3074
+ const u = patch.baseUrl.trim();
3075
+ if (!/^https?:\/\//i.test(u)) {
3076
+ writeJson(res, 400, { error: '服务地址必须是 http(s) URL', i18n: { code: 'host.registryNotUrl', params: {} } });
3077
+ return;
3078
+ }
3079
+ }
3080
+ const next = saveHostCosySettings(patch);
3081
+ writeJson(res, 200, { ok: true, cosy: { baseUrl: next.baseUrl || COSY_DEFAULTS.baseUrl } });
3082
+ } catch (e) {
3083
+ writeRouteError(res, e);
3084
+ }
3085
+ }
3086
+ });
3087
+
3088
+ // Google Cloud TTS service settings: GET never echoes the key in full
3089
+ // (only apiKeySet + a tail hint); POST sets keys (empty string clears).
3090
+ // Project ID is optional and only stored for future official-quota queries.
3091
+ const cloudConfigGetDisposer = webServer.register({
3092
+ kind: 'exact',
3093
+ path: '/dsh-tts-api/cloud-config',
3094
+ async handler(req, res) {
3095
+ try {
3096
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
3097
+ writeJson(res, 405, { error: 'method not allowed' });
3098
+ return;
3099
+ }
3100
+ const stored = loadHostCloudSettings();
3101
+ const tail = stored.apiKey ? stored.apiKey.slice(-4) : '';
3102
+ writeJson(res, 200, {
3103
+ cloud: {
3104
+ apiKeySet: !!stored.apiKey,
3105
+ apiKeyTail: stored.apiKey ? '…' + tail : '',
3106
+ projectId: stored.projectId || ''
3107
+ }
3108
+ });
3109
+ } catch (e) {
3110
+ writeRouteError(res, e);
3111
+ }
3112
+ }
3113
+ });
3114
+ const cloudConfigPostDisposer = webServer.register({
3115
+ kind: 'exact',
3116
+ path: '/dsh-tts-api/cloud-config-save',
3117
+ async handler(req, res) {
3118
+ try {
3119
+ let body = '';
3120
+ for await (const chunk of req) body += chunk;
3121
+ let parsed = {};
3122
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
3123
+ const patch = (parsed && parsed.cloud && typeof parsed.cloud === 'object') ? parsed.cloud : parsed;
3124
+ if (patch.apiKey !== undefined && typeof patch.apiKey === 'string' && patch.apiKey.trim() !== '') {
3125
+ if (patch.apiKey.trim().length < 10) {
3126
+ writeJson(res, 400, { error: 'API Key 看起来太短,请检查是否粘贴完整', i18n: { code: 'host.cloudKeyBad', params: {} } });
3127
+ return;
3128
+ }
3129
+ }
3130
+ const next = saveHostCloudSettings(patch);
3131
+ writeJson(res, 200, {
3132
+ ok: true,
3133
+ cloud: {
3134
+ apiKeySet: !!next.apiKey,
3135
+ apiKeyTail: next.apiKey ? '…' + next.apiKey.slice(-4) : '',
3136
+ projectId: next.projectId || ''
3137
+ }
3138
+ });
3139
+ } catch (e) {
3140
+ writeRouteError(res, e);
3141
+ }
3142
+ }
3143
+ });
3144
+
3145
+ // Local quota ledger: this month's used chars + remaining per tier.
3146
+ // Official serviceusage/monitoring queries are opt-in later (needs SA JSON);
3147
+ // until then `official.enabled` is false and only local counts are shown.
3148
+ const cloudUsageDisposer = webServer.register({
3149
+ kind: 'exact',
3150
+ path: '/dsh-tts-api/cloud-usage',
3151
+ async handler(req, res) {
3152
+ try {
3153
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
3154
+ writeJson(res, 405, { error: 'method not allowed' });
3155
+ return;
3156
+ }
3157
+ writeJson(res, 200, { ok: true, ...getCloudUsageSummary() });
3158
+ } catch (e) {
3159
+ writeRouteError(res, e);
3160
+ }
3161
+ }
3162
+ });
3163
+
3164
+ // Cloud connectivity test: synthesizes a short probe with the stored key.
3165
+ // Does NOT consume the returned audio; counts the probe chars like any read.
3166
+ const cloudTestDisposer = webServer.register({
3167
+ kind: 'exact',
3168
+ path: '/dsh-tts-api/cloud-test',
3169
+ async handler(req, res) {
3170
+ try {
3171
+ let body = '';
3172
+ for await (const chunk of req) body += chunk;
3173
+ let parsed = {};
3174
+ try { parsed = JSON.parse(body); } catch (e) { /* fall through */ }
3175
+ const stored = loadHostCloudSettings();
3176
+ const apiKey = stored.apiKey || '';
3177
+ if (!apiKey) {
3178
+ writeErr(res, 400, '未配置 API Key(请先粘贴并保存)', { code: 'host.cloudNoKey' });
3179
+ return;
3180
+ }
3181
+ const voice = String((parsed && parsed.voice) || CLOUD_TTS_DEFAULTS.voice);
3182
+ const tier = cloudTier(voice);
3183
+ const t0 = Date.now();
3184
+ try {
3185
+ const { bytes, billable } = await cloudWithRetry(
3186
+ () => synthesizeCloudPacket('你好,这是一个语音测试。', voice, null, apiKey, 30000)
3187
+ );
3188
+ try { recordCloudUsage(tier, billable); } catch (e) { /* non-fatal */ }
3189
+ writeJson(res, 200, {
3190
+ ok: true,
3191
+ ms: Date.now() - t0,
3192
+ bytes: bytes.length,
3193
+ voice,
3194
+ tier
3195
+ });
3196
+ } catch (e) {
3197
+ writeRouteError(res, e);
3198
+ }
3199
+ } catch (e) {
3200
+ writeRouteError(res, e);
3201
+ }
3202
+ }
3203
+ });
3204
+
3205
+ // Approval-alert queue: the client polls for new items since its cursor and
3206
+ // reads them aloud (approval requests interrupt, results announce when idle).
3207
+ const notifyDisposer = webServer.register({
3208
+ kind: 'exact',
3209
+ path: '/dsh-tts-api/notify',
3210
+ async handler(req, res) {
3211
+ try {
3212
+ const q = new URL(req.url || '', 'http://x').searchParams;
3213
+ const since = Number(q.get('s') || 0) || 0;
3214
+ const items = notifyState.queue
3215
+ .filter((i) => i.seq > since)
3216
+ .map((i) => ({
3217
+ seq: i.seq,
3218
+ kind: i.kind,
3219
+ toolName: i.toolName,
3220
+ reason: i.reason,
3221
+ outcome: i.outcome
3222
+ }));
3223
+ writeJson(res, 200, { items, latest: notifyState.seq });
3224
+ } catch (e) {
3225
+ writeRouteError(res, e);
3226
+ }
3227
+ }
3228
+ });
3229
+
3230
+ ctx.effect(() => speakDisposer, 'dsh-plugin-tts: speak route');
3231
+ ctx.effect(() => audioDisposer, 'dsh-plugin-tts: audio route');
3232
+ ctx.effect(() => filesDisposer, 'dsh-plugin-tts: rvc files route');
3233
+ ctx.effect(() => nextDisposer, 'dsh-plugin-tts: rvc next route');
3234
+ ctx.effect(() => compactDisposer, 'dsh-plugin-tts: rvc compact-index route');
3235
+ ctx.effect(() => packsDisposer, 'dsh-plugin-tts: rvc packs route');
3236
+ ctx.effect(() => packsInstalledDisposer, 'dsh-plugin-tts: rvc packs-installed route');
3237
+ ctx.effect(() => packInstallDisposer, 'dsh-plugin-tts: rvc pack-install route');
3238
+ ctx.effect(() => packUninstallDisposer, 'dsh-plugin-tts: rvc pack-uninstall route');
3239
+ ctx.effect(() => packProgressDisposer, 'dsh-plugin-tts: rvc pack-progress route');
3240
+ ctx.effect(() => rvcConfigGetDisposer, 'dsh-plugin-tts: rvc config route');
3241
+ ctx.effect(() => rvcConfigPostDisposer, 'dsh-plugin-tts: rvc config save route');
3242
+ ctx.effect(() => indexVoicesDisposer, 'dsh-plugin-tts: index voices route');
3243
+ ctx.effect(() => indexUploadDisposer, 'dsh-plugin-tts: index upload route');
3244
+ ctx.effect(() => indexConfigGetDisposer, 'dsh-plugin-tts: index config route');
3245
+ ctx.effect(() => indexConfigPostDisposer, 'dsh-plugin-tts: index config save route');
3246
+ ctx.effect(() => cosyVoicesDisposer, 'dsh-plugin-tts: cosy voices route');
3247
+ ctx.effect(() => cosyUploadDisposer, 'dsh-plugin-tts: cosy upload route');
3248
+ ctx.effect(() => cosyConfigGetDisposer, 'dsh-plugin-tts: cosy config route');
3249
+ ctx.effect(() => cosyConfigPostDisposer, 'dsh-plugin-tts: cosy config save route');
3250
+ ctx.effect(() => cloudConfigGetDisposer, 'dsh-plugin-tts: cloud config route');
3251
+ ctx.effect(() => cloudConfigPostDisposer, 'dsh-plugin-tts: cloud config save route');
3252
+ ctx.effect(() => cloudUsageDisposer, 'dsh-plugin-tts: cloud usage route');
3253
+ ctx.effect(() => cloudTestDisposer, 'dsh-plugin-tts: cloud test route');
3254
+ ctx.effect(() => notifyDisposer, 'dsh-plugin-tts: notify route');
3255
+ ctx.effect(() => diagnoseDisposer, 'dsh-plugin-tts: diagnose route');
3256
+ }
3257
+
3258
+ // Test hook: expose pure helpers for unit tests without a real server.
3259
+ // (e.g. tests/smoke.mjs asserts splitText never cuts URLs/decimals mid-token,
3260
+ // and drives ingestSessionEvent + the notify queue directly.)
3261
+ export const __test = {
3262
+ isHardEdgeWorkerError,
3263
+ classifyHostError,
3264
+ countWorkerRetries,
3265
+ splitText,
3266
+ splitCloudPackets,
3267
+ chunkCal,
3268
+ cloudTier,
3269
+ cloudBillableChars,
3270
+ cloudBuildSsml,
3271
+ ingestSessionEvent,
3272
+ loadHostRvcSettings,
3273
+ saveHostRvcSettings,
3274
+ indexTts2Config,
3275
+ loadHostIndexSettings,
3276
+ saveHostIndexSettings,
3277
+ cosyConfig,
3278
+ loadHostCosySettings,
3279
+ saveHostCosySettings,
3280
+ loadHostCloudSettings,
3281
+ saveHostCloudSettings,
3282
+ recordCloudUsage,
3283
+ getCloudUsageSummary,
3284
+ notify: () => ({
3285
+ seq: notifyState.seq,
3286
+ queued: notifyState.queue.length
3287
+ })
3288
+ };