@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.
- package/.github/workflows/test.yml +33 -0
- package/ANALYSIS.md +205 -0
- package/LICENSE +21 -0
- package/PR-REVIEW.md +75 -0
- package/README.md +297 -0
- package/README.zh.md +263 -0
- package/Snipaste_2026-08-16_13-28-10.png +0 -0
- package/Snipaste_2026-08-16_13-28-23.png +0 -0
- package/Snipaste_2026-08-16_13-28-37.png +0 -0
- package/Snipaste_2026-08-16_13-29-01.png +0 -0
- package/Snipaste_2026-08-16_13-29-11.png +0 -0
- package/TODO.md +23 -0
- package/cordis.patch.yml +4 -0
- package/docs/CLOUD-GUIDE.md +216 -0
- package/docs/COSYVOICE-GUIDE.md +216 -0
- package/docs/EDGE-GUIDE.md +166 -0
- package/docs/HOST-CONTRACT.md +36 -0
- package/docs/INDEX-TTS2-GUIDE.md +208 -0
- package/docs/RVC-GUIDE.md +390 -0
- package/docs/USER-GUIDE.md +334 -0
- package/docs/acceptance-checklist.md +148 -0
- package/docs/adaptive-chunked-playback.md +194 -0
- package/docs/macos-test-prompt.md +230 -0
- package/docs/windows-gpu-verify-prompt.md +151 -0
- package/lib/client.js +7036 -0
- package/lib/index.mjs +3288 -0
- package/logo.png +0 -0
- package/package.json +52 -0
- package/pnpm-workspace.yaml +7 -0
- package/rvc-server.py +564 -0
- package/tests/README.md +45 -0
- package/tests/client-load.mjs +393 -0
- package/tests/e2e-compact-index.mjs +86 -0
- package/tests/e2e-index-ab.mjs +138 -0
- package/tests/e2e-real-rvc.mjs +122 -0
- package/tests/i18n-keys.mjs +82 -0
- package/tests/mock-registry.mjs +50 -0
- package/tests/package-runtime-patch.mjs +88 -0
- package/tests/rvc-server-live.mjs +230 -0
- package/tests/smoke.mjs +1107 -0
- package/tools/gen-ui-preview.mjs +174 -0
- package/tools/mac-verify.sh +158 -0
- package/tools/make-pack.mjs +155 -0
- package/tools/package-runtime.py +214 -0
package/tests/smoke.mjs
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
// Smoke test for the Host half of @dsh-external/dsh-plugin-tts.
|
|
2
|
+
// Uses a fake ctx (webServer captures the routes) and exercises the real
|
|
3
|
+
// Edge TTS synthesis over the network, the RVC chain against a mock local
|
|
4
|
+
// RVC inference server, plus the voice-pack registry (mock static server).
|
|
5
|
+
// node tests/smoke.mjs
|
|
6
|
+
import * as plugin from '../lib/index.mjs';
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { startMockRegistry } from './mock-registry.mjs';
|
|
16
|
+
|
|
17
|
+
const routes = [];
|
|
18
|
+
const fakeEventListeners = new Map(); // name -> [fn] captured via ctx.on
|
|
19
|
+
|
|
20
|
+
function emitFake(name, ...args) {
|
|
21
|
+
for (const fn of (fakeEventListeners.get(name) || []).slice()) fn(...args);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fakeCtx() {
|
|
25
|
+
return {
|
|
26
|
+
get(name) {
|
|
27
|
+
if (name === 'webServer') {
|
|
28
|
+
return {
|
|
29
|
+
register(route) {
|
|
30
|
+
routes.push(route);
|
|
31
|
+
return () => {
|
|
32
|
+
const i = routes.indexOf(route);
|
|
33
|
+
if (i >= 0) routes.splice(i, 1);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
effect(fn) { fn?.(); }, // apply the effect callback (registers disposers / event subscriptions) like the host does
|
|
41
|
+
on(name, fn) {
|
|
42
|
+
const arr = fakeEventListeners.get(name) || [];
|
|
43
|
+
arr.push(fn);
|
|
44
|
+
fakeEventListeners.set(name, arr);
|
|
45
|
+
return () => {
|
|
46
|
+
const a = fakeEventListeners.get(name);
|
|
47
|
+
if (a) {
|
|
48
|
+
const i = a.indexOf(fn);
|
|
49
|
+
if (i >= 0) a.splice(i, 1);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mockReq(url, body) {
|
|
57
|
+
const chunks = body === undefined ? [] : [body];
|
|
58
|
+
return {
|
|
59
|
+
url,
|
|
60
|
+
[Symbol.asyncIterator]() {
|
|
61
|
+
let i = 0;
|
|
62
|
+
return {
|
|
63
|
+
next: () => {
|
|
64
|
+
if (i < chunks.length) return Promise.resolve({ value: chunks[i++], done: false });
|
|
65
|
+
return Promise.resolve({ done: true });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mockRes() {
|
|
73
|
+
return {
|
|
74
|
+
headersSent: false,
|
|
75
|
+
head: null,
|
|
76
|
+
body: null,
|
|
77
|
+
writeHead(code, headers) { this.head = { code, headers }; },
|
|
78
|
+
end(body) { this.body = body; }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function call(route, req, res) {
|
|
83
|
+
await route.handler(req, res);
|
|
84
|
+
return res;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results = [];
|
|
88
|
+
function check(name, ok, detail) {
|
|
89
|
+
results.push({ name, ok, detail });
|
|
90
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ctx = fakeCtx();
|
|
94
|
+
plugin.apply(ctx);
|
|
95
|
+
const speakRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/speak');
|
|
96
|
+
const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tts-audio');
|
|
97
|
+
|
|
98
|
+
check('plugin registers two routes', speakRoute !== undefined && audioRoute !== undefined);
|
|
99
|
+
|
|
100
|
+
if (speakRoute && audioRoute) {
|
|
101
|
+
const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
|
|
102
|
+
const parsed = JSON.parse(res.body);
|
|
103
|
+
check('speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
|
|
104
|
+
|
|
105
|
+
if (parsed.url) {
|
|
106
|
+
const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
|
|
107
|
+
const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
|
|
108
|
+
const isMp3 = bytes.length > 1000 && bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0;
|
|
109
|
+
check('audio route serves valid mp3', ares.head.code === 200 && isMp3, `code=${ares.head.code} bytes=${bytes.length}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// repeated speak of the same text+voice must reuse the in-session audio cache
|
|
113
|
+
// (replay does not re-synthesize)
|
|
114
|
+
{
|
|
115
|
+
const res2 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
|
|
116
|
+
const parsed2 = JSON.parse(res2.body);
|
|
117
|
+
check('repeated speak reuses cached URL (no re-synthesize)', res2.head.code === 200 && parsed2.url === parsed.url && parsed2.cached === true, `first=${parsed.url} second=${parsed2.url} cached=${parsed2.cached}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ?download=1 forces a Content-Disposition attachment on the audio asset
|
|
121
|
+
if (parsed.url) {
|
|
122
|
+
const dres = await call(audioRoute, mockReq(parsed.url + '?download=1'), mockRes());
|
|
123
|
+
const cd = dres.head && dres.head.headers && dres.head.headers['Content-Disposition'];
|
|
124
|
+
check('audio download sets Content-Disposition attachment', dres.head.code === 200 && /attachment/.test(cd || ''), cd);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const badRes = await call(audioRoute, mockReq('/dsh-tts-audio/nope'), mockRes());
|
|
128
|
+
check('unknown audio id -> 404', badRes.head.code === 404);
|
|
129
|
+
|
|
130
|
+
// M2 subset: long plain-Edge reads also stream progressively (jobId+chunks),
|
|
131
|
+
// not a single blocking synthesis
|
|
132
|
+
{
|
|
133
|
+
const longEdge = '这是一段很长的文本用于验证 Edge 长读也走自适应分块渐进播放,避免等待整段合成。'.repeat(10);
|
|
134
|
+
const er = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
135
|
+
text: longEdge, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
|
|
136
|
+
})), mockRes());
|
|
137
|
+
const ep = JSON.parse(er.body);
|
|
138
|
+
check('long edge read returns chunked job (streaming subset)',
|
|
139
|
+
er.head.code === 200 && typeof ep.jobId === 'string' && Array.isArray(ep.chunks) && ep.chunks.length >= 1,
|
|
140
|
+
ep.jobId ? `jobId=${ep.jobId} chunks=${ep.chunks.length} total=${ep.total}` : er.body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// M1+ local-piper provider: registered in the abstraction; unconfigured -> graceful
|
|
144
|
+
// localized error (not a crash)
|
|
145
|
+
{
|
|
146
|
+
const pr = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
147
|
+
text: 'hello', voice: '', provider: 'local-piper', custom: {}
|
|
148
|
+
})), mockRes());
|
|
149
|
+
const pp = JSON.parse(pr.body);
|
|
150
|
+
check('local-piper unconfigured returns graceful error (i18n code)',
|
|
151
|
+
pr.head.code === 500 && pp.error && pp.i18n && pp.i18n.code === 'host.piperUnconfigured',
|
|
152
|
+
JSON.stringify(pp));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- RVC chain against a mock local RVC server ---
|
|
157
|
+
|
|
158
|
+
function miniWav(seconds = 1, sr = 40000) {
|
|
159
|
+
const n = sr * seconds;
|
|
160
|
+
const dataSize = n * 2;
|
|
161
|
+
const buf = Buffer.alloc(44 + dataSize);
|
|
162
|
+
buf.write('RIFF', 0);
|
|
163
|
+
buf.writeUInt32LE(36 + dataSize, 4);
|
|
164
|
+
buf.write('WAVE', 8);
|
|
165
|
+
buf.write('fmt ', 12);
|
|
166
|
+
buf.writeUInt32LE(16, 16);
|
|
167
|
+
buf.writeUInt16LE(1, 20);
|
|
168
|
+
buf.writeUInt16LE(1, 22);
|
|
169
|
+
buf.writeUInt32LE(sr, 24);
|
|
170
|
+
buf.writeUInt32LE(sr * 2, 28);
|
|
171
|
+
buf.writeUInt16LE(2, 32);
|
|
172
|
+
buf.writeUInt16LE(16, 34);
|
|
173
|
+
buf.write('data', 36);
|
|
174
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
175
|
+
return buf;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function startMockRvc() {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const server = createServer((req, res) => {
|
|
181
|
+
let body = '';
|
|
182
|
+
req.on('data', (d) => (body += d));
|
|
183
|
+
req.on('end', () => {
|
|
184
|
+
if (req.url === '/load') {
|
|
185
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
186
|
+
res.end(JSON.stringify({ ok: true }));
|
|
187
|
+
} else if (req.url === '/health') {
|
|
188
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ ok: true, model_loaded: true, model: 'demo.pth', gpu_name: 'Mock GPU', vram_gb: 8 }));
|
|
190
|
+
} else if (req.url.startsWith('/files?kind=')) {
|
|
191
|
+
const kind = req.url.slice('/files?kind='.length);
|
|
192
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
193
|
+
res.end(JSON.stringify({
|
|
194
|
+
ok: true,
|
|
195
|
+
kind,
|
|
196
|
+
files: kind === 'pth'
|
|
197
|
+
? [{ name: 'demo.pth', path: 'C:/models/demo.pth', size: 55000000 }]
|
|
198
|
+
: [{ name: 'demo.index', path: 'C:/models/demo.index', size: 400000000 }]
|
|
199
|
+
}));
|
|
200
|
+
} else if (req.url === '/convert') {
|
|
201
|
+
const payload = JSON.parse(body || '{}');
|
|
202
|
+
if (!payload.audio_base64) {
|
|
203
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
204
|
+
res.end(JSON.stringify({ message: 'audio_base64 required' }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
208
|
+
res.end(JSON.stringify({ audio_base64: miniWav().toString('base64'), sample_rate: 40000 }));
|
|
209
|
+
} else if (req.url === '/compact-index') {
|
|
210
|
+
const payload = JSON.parse(body || '{}');
|
|
211
|
+
if (!payload.index) {
|
|
212
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
213
|
+
res.end(JSON.stringify({ message: 'index required' }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
217
|
+
res.end(JSON.stringify({
|
|
218
|
+
ok: true,
|
|
219
|
+
path: 'C:/models/demo_compact_' + (payload.target_vectors || 10000) + '.index',
|
|
220
|
+
size: 6000000,
|
|
221
|
+
vectors: payload.target_vectors || 10000,
|
|
222
|
+
source_vectors: 129396,
|
|
223
|
+
source_size: 408000000,
|
|
224
|
+
reduction_pct: 98.5
|
|
225
|
+
}));
|
|
226
|
+
} else {
|
|
227
|
+
res.writeHead(404);
|
|
228
|
+
res.end();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (speakRoute && audioRoute) {
|
|
237
|
+
const mock = await startMockRvc();
|
|
238
|
+
try {
|
|
239
|
+
const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
240
|
+
text: '这是一段 RVC 链路测试。',
|
|
241
|
+
voice: 'zh-CN-XiaoxuanNeural',
|
|
242
|
+
provider: 'rvc',
|
|
243
|
+
custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
|
|
244
|
+
})), mockRes());
|
|
245
|
+
const parsed = JSON.parse(res.body);
|
|
246
|
+
check('rvc speak returns 200 + url', res.head.code === 200 && typeof parsed.url === 'string' && parsed.url.startsWith('/dsh-tts-audio/'), parsed.url ?? res.body);
|
|
247
|
+
if (parsed.url) {
|
|
248
|
+
const ares = await call(audioRoute, mockReq(parsed.url), mockRes());
|
|
249
|
+
const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
|
|
250
|
+
check('rvc audio route serves wav (RIFF)', ares.head.code === 200 && bytes.length > 44 && bytes.slice(0, 4).toString() === 'RIFF', `code=${ares.head.code} bytes=${bytes.length}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- adaptive chunked progressive playback (long RVC text) ----
|
|
254
|
+
const longText = '这是一段用于验证自适应分块渐进播放的长文本朗读测试。'.repeat(12); // ~264 chars -> several chunks
|
|
255
|
+
const longRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
256
|
+
text: longText,
|
|
257
|
+
voice: 'zh-CN-XiaoxuanNeural',
|
|
258
|
+
provider: 'rvc',
|
|
259
|
+
custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
|
|
260
|
+
})), mockRes());
|
|
261
|
+
const longParsed = JSON.parse(longRes.body);
|
|
262
|
+
check('rvc long speak returns jobId + prewarmed chunks', longRes.head.code === 200
|
|
263
|
+
&& typeof longParsed.jobId === 'string'
|
|
264
|
+
&& Array.isArray(longParsed.chunks) && longParsed.chunks.length >= 2
|
|
265
|
+
&& typeof longParsed.total === 'number' && longParsed.total > longParsed.chunks.length,
|
|
266
|
+
`jobId=${longParsed.jobId} chunks=${longParsed.chunks && longParsed.chunks.length} total=${longParsed.total}`);
|
|
267
|
+
if (typeof longParsed.jobId === 'string') {
|
|
268
|
+
const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
|
|
269
|
+
check('plugin registers rvc-next route', nextRoute !== undefined);
|
|
270
|
+
let fetched = longParsed.chunks.length;
|
|
271
|
+
let more = true;
|
|
272
|
+
let drained = 0;
|
|
273
|
+
while (more && drained < 100) {
|
|
274
|
+
const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${longParsed.jobId}`), mockRes());
|
|
275
|
+
const np = JSON.parse(nr.body);
|
|
276
|
+
if (np && np.url) fetched++;
|
|
277
|
+
more = !!(np && np.more);
|
|
278
|
+
drained++;
|
|
279
|
+
}
|
|
280
|
+
check('rvc-next drains to total chunks', fetched === longParsed.total && more === false, `fetched=${fetched} total=${longParsed.total}`);
|
|
281
|
+
check('rvc-next done for unknown job', (await call(nextRoute, mockReq('/dsh-tts-api/rvc-next?job=unknown'), mockRes())).body === JSON.stringify({ done: true, gone: true }));
|
|
282
|
+
// a prewarmed chunk url must serve wav through the audio route
|
|
283
|
+
const cRes = await call(audioRoute, mockReq(longParsed.chunks[0]), mockRes());
|
|
284
|
+
const cBytes = Buffer.isBuffer(cRes.body) ? cRes.body : Buffer.from(cRes.body ?? '');
|
|
285
|
+
check('chunk audio route serves wav (RIFF)', cRes.head.code === 200 && cBytes.length > 44 && cBytes.slice(0, 4).toString() === 'RIFF', `code=${cRes.head.code} bytes=${cBytes.length}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- explicit cancel: abandoning a chunked job releases it immediately ----
|
|
289
|
+
{
|
|
290
|
+
const spRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
291
|
+
text: longText,
|
|
292
|
+
voice: 'zh-CN-XiaoxuanNeural',
|
|
293
|
+
provider: 'rvc',
|
|
294
|
+
custom: { baseUrl: `http://127.0.0.1:${mock.port}`, model: 'mock.pth', index: '' }
|
|
295
|
+
})), mockRes());
|
|
296
|
+
const sp = JSON.parse(spRes.body);
|
|
297
|
+
if (typeof sp.jobId === 'string') {
|
|
298
|
+
const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
|
|
299
|
+
const c1 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}&cancel=1`), mockRes());
|
|
300
|
+
const c1p = JSON.parse(c1.body);
|
|
301
|
+
check('rvc-next cancel returns {done,cancelled}', c1.head.code === 200 && c1p.done === true && c1p.cancelled === true, c1.body);
|
|
302
|
+
const c2 = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${sp.jobId}`), mockRes());
|
|
303
|
+
const c2p = JSON.parse(c2.body);
|
|
304
|
+
check('cancelled job no longer servable (gone)', c2.head.code === 200 && c2p.done === true && c2p.gone === true, c2.body);
|
|
305
|
+
} else {
|
|
306
|
+
check('cancel test precondition: long rvc speak returns jobId', false, spRes.body);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// file-discovery proxy route
|
|
311
|
+
const filesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-files');
|
|
312
|
+
const fr = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=pth`), mockRes());
|
|
313
|
+
const filesData = JSON.parse(fr.body);
|
|
314
|
+
check('rvc-files proxy lists pth files', fr.head.code === 200 && Array.isArray(filesData.files) && filesData.files.length > 0 && filesData.files[0].name === 'demo.pth', fr.body);
|
|
315
|
+
const fi = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=index`), mockRes());
|
|
316
|
+
const filesIdx = JSON.parse(fi.body);
|
|
317
|
+
check('rvc-files proxy lists index files', fi.head.code === 200 && Array.isArray(filesIdx.files) && filesIdx.files.length > 0 && filesIdx.files[0].name === 'demo.index', fi.body);
|
|
318
|
+
|
|
319
|
+
// unreachable RVC service -> actionable, platform-aware startup hint
|
|
320
|
+
const badFiles = await call(filesRoute, mockReq('/dsh-tts-api/rvc-files?baseUrl=http%3A%2F%2F127.0.0.1%3A1&kind=pth'), mockRes());
|
|
321
|
+
const badFilesData = JSON.parse(badFiles.body);
|
|
322
|
+
check('rvc-files unreachable returns actionable startup hint',
|
|
323
|
+
badFiles.head.code === 502 &&
|
|
324
|
+
badFilesData.i18n && badFilesData.i18n.code === 'host.filesNeedsServer' &&
|
|
325
|
+
badFilesData.i18n.params && /rvc-server\.py/.test(badFilesData.i18n.params.startup || ''),
|
|
326
|
+
badFiles.body);
|
|
327
|
+
|
|
328
|
+
// compact-index proxy route
|
|
329
|
+
const compactRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-compact-index');
|
|
330
|
+
const cr = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
|
|
331
|
+
baseUrl: `http://127.0.0.1:${mock.port}`,
|
|
332
|
+
index: 'C:/models/demo.index',
|
|
333
|
+
target_vectors: 2000
|
|
334
|
+
})), mockRes());
|
|
335
|
+
const compactData = JSON.parse(cr.body);
|
|
336
|
+
check('compact-index proxy returns compacted index', cr.head.code === 200
|
|
337
|
+
&& compactData.ok === true
|
|
338
|
+
&& compactData.path === 'C:/models/demo_compact_2000.index'
|
|
339
|
+
&& compactData.reduction_pct === 98.5,
|
|
340
|
+
cr.body);
|
|
341
|
+
const cb = await call(compactRoute, mockReq('/dsh-tts-api/rvc-compact-index', JSON.stringify({
|
|
342
|
+
baseUrl: `http://127.0.0.1:${mock.port}`,
|
|
343
|
+
index: ''
|
|
344
|
+
})), mockRes());
|
|
345
|
+
check('compact-index proxy passes server error', cb.head.code === 502 && typeof JSON.parse(cb.body).error === 'string', cb.body);
|
|
346
|
+
|
|
347
|
+
// ---- one-click diagnostics ----
|
|
348
|
+
const diagnoseRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
|
|
349
|
+
check('plugin registers diagnose route', diagnoseRoute !== undefined);
|
|
350
|
+
const dg = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
|
|
351
|
+
rvcBaseUrl: `http://127.0.0.1:${mock.port}`
|
|
352
|
+
})), mockRes());
|
|
353
|
+
const dgData = JSON.parse(dg.body);
|
|
354
|
+
const edge = dgData.checks.find(c => c.id === 'edge');
|
|
355
|
+
const rvc = dgData.checks.find(c => c.id === 'rvc-server');
|
|
356
|
+
const model = dgData.checks.find(c => c.id === 'rvc-model');
|
|
357
|
+
check('diagnose: edge synthesis ok', dg.head.code === 200 && edge && edge.ok === true, JSON.stringify(edge));
|
|
358
|
+
check('diagnose: rvc server + model ok (mock)', rvc && rvc.ok === true && model && model.ok === true, JSON.stringify({ rvc, model }));
|
|
359
|
+
check('diagnose exposes worker meta for the log bundle',
|
|
360
|
+
dgData.worker && dgData.worker.chromium === '143.0.3650.75' && dgData.worker.singleTimeoutMs === 20000 && dgData.worker.spawnTimeoutMs === 45000,
|
|
361
|
+
JSON.stringify(dgData.worker));
|
|
362
|
+
check('diagnose exposes recent host errors array', Array.isArray(dgData.recent), JSON.stringify(dgData.recent));
|
|
363
|
+
const dgBad = await call(diagnoseRoute, mockReq('/dsh-tts-api/diagnose', JSON.stringify({
|
|
364
|
+
rvcBaseUrl: 'http://127.0.0.1:1'
|
|
365
|
+
})), mockRes());
|
|
366
|
+
const dgBadData = JSON.parse(dgBad.body);
|
|
367
|
+
const rvcBad = dgBadData.checks.find(c => c.id === 'rvc-server');
|
|
368
|
+
check('diagnose: unreachable rvc classified as connect', rvcBad && rvcBad.ok === false && rvcBad.cls === 'connect', JSON.stringify(rvcBad));
|
|
369
|
+
} finally {
|
|
370
|
+
mock.server.close();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- voice-pack registry: manifest proxy + verified install ---
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
// isolated packs dir for the test
|
|
378
|
+
process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-'));
|
|
379
|
+
// build a mock registry dir
|
|
380
|
+
const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-'));
|
|
381
|
+
const modelBytes = Buffer.from('FAKE-MODEL-BYTES-0123456789'.repeat(8000)); // ~200KB
|
|
382
|
+
const indexBytes = Buffer.from('FAKE-INDEX-BYTES-abcdef'.repeat(6000)); // ~108KB
|
|
383
|
+
const sha = b => createHash('sha256').update(b).digest('hex');
|
|
384
|
+
const modelSha = sha(modelBytes);
|
|
385
|
+
const indexSha = sha(indexBytes);
|
|
386
|
+
writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
|
|
387
|
+
writeFileSync(path.join(regDir, 'index.index'), indexBytes);
|
|
388
|
+
writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
|
|
389
|
+
schema: 1,
|
|
390
|
+
packs: [
|
|
391
|
+
{
|
|
392
|
+
id: 'pack-a',
|
|
393
|
+
name: 'Demo Voice A',
|
|
394
|
+
description: '测试音色包(模型+紧凑索引)',
|
|
395
|
+
version: '1.0.0',
|
|
396
|
+
author: 'tester',
|
|
397
|
+
license: 'MIT',
|
|
398
|
+
baseVoice: 'zh-CN-YunyangNeural',
|
|
399
|
+
f0Method: 'rmvpe',
|
|
400
|
+
indexRate: 0.75,
|
|
401
|
+
model: { url: '', size: modelBytes.length, sha256: modelSha },
|
|
402
|
+
index: { url: '', size: indexBytes.length, sha256: indexSha }
|
|
403
|
+
},
|
|
404
|
+
{ id: 'pack-b', name: 'Demo Voice B', description: '免索引音色包', version: '2.0.0', license: 'CC-BY', model: { url: '', size: modelBytes.length, sha256: modelSha } }
|
|
405
|
+
]
|
|
406
|
+
}, null, 2));
|
|
407
|
+
|
|
408
|
+
// fix relative urls after knowing the server port
|
|
409
|
+
const reg = await startMockRegistry(regDir, 0);
|
|
410
|
+
try {
|
|
411
|
+
const base = reg.base;
|
|
412
|
+
const manifestPath = path.join(regDir, 'manifest.json');
|
|
413
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
414
|
+
for (const p of manifest.packs) {
|
|
415
|
+
if (p.model) p.model.url = `${base}/model.pth`;
|
|
416
|
+
if (p.index) p.index.url = `${base}/index.index`;
|
|
417
|
+
}
|
|
418
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
419
|
+
|
|
420
|
+
const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
|
|
421
|
+
const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
|
|
422
|
+
const installedRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs-installed');
|
|
423
|
+
check('plugin registers pack routes', packsRoute !== undefined && installRoute !== undefined && installedRoute !== undefined);
|
|
424
|
+
|
|
425
|
+
const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(base)}`), mockRes());
|
|
426
|
+
const prData = JSON.parse(pr.body);
|
|
427
|
+
check('rvc-packs proxies manifest', pr.head.code === 200 && Array.isArray(prData.packs) && prData.packs.length === 2, pr.body);
|
|
428
|
+
|
|
429
|
+
const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
|
|
430
|
+
const irData = JSON.parse(ir.body);
|
|
431
|
+
const installedModel = irData.modelPath || '';
|
|
432
|
+
const installedIndex = irData.indexPath || '';
|
|
433
|
+
const modelOk = installedModel && existsSync(installedModel) && sha(readFileSync(installedModel)) === modelSha;
|
|
434
|
+
const indexOk = installedIndex && existsSync(installedIndex) && sha(readFileSync(installedIndex)) === indexSha;
|
|
435
|
+
check('rvc-pack-install downloads + sha256 verifies + writes', ir.head.code === 200 && irData.ok && modelOk && indexOk,
|
|
436
|
+
`model=${installedModel} index=${installedIndex}`);
|
|
437
|
+
|
|
438
|
+
const ir2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-a' })), mockRes());
|
|
439
|
+
check('re-install skips (already installed)', JSON.parse(ir2.body).skipped === true, ir2.body);
|
|
440
|
+
|
|
441
|
+
const irB = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
|
|
442
|
+
const irBData = JSON.parse(irB.body);
|
|
443
|
+
check('index-free pack installs without index', irB.head.code === 200 && irBData.ok && irBData.indexPath === '', irB.body);
|
|
444
|
+
|
|
445
|
+
const st = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
|
|
446
|
+
const stData = JSON.parse(st.body);
|
|
447
|
+
check('rvc-packs-installed lists 2 packs', st.head.code === 200 && stData.installed && stData.installed['pack-a'] && stData.installed['pack-b'], st.body);
|
|
448
|
+
|
|
449
|
+
// multi-index pack with RELATIVE urls (schema v2)
|
|
450
|
+
writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
|
|
451
|
+
schema: 2,
|
|
452
|
+
packs: [{
|
|
453
|
+
id: 'pack-c',
|
|
454
|
+
name: 'Multi Index Voice',
|
|
455
|
+
version: '1.0.0',
|
|
456
|
+
license: 'MIT',
|
|
457
|
+
model: { url: 'packs-shared/model.pth', size: modelBytes.length, sha256: modelSha },
|
|
458
|
+
indexes: [
|
|
459
|
+
{ id: 'tiny', name: '紧凑 2k', url: 'packs-shared/i2k.index', size: 5, sha256: sha(Buffer.from('IDX2K')) },
|
|
460
|
+
{ id: 'mid', name: '紧凑 10k', url: 'packs-shared/i10k.index', size: 6, sha256: sha(Buffer.from('IDX10K')) }
|
|
461
|
+
]
|
|
462
|
+
}]
|
|
463
|
+
}, null, 2));
|
|
464
|
+
mkdirSync(path.join(regDir, 'packs-shared'));
|
|
465
|
+
writeFileSync(path.join(regDir, 'packs-shared', 'model.pth'), modelBytes);
|
|
466
|
+
writeFileSync(path.join(regDir, 'packs-shared', 'i2k.index'), Buffer.from('IDX2K'));
|
|
467
|
+
writeFileSync(path.join(regDir, 'packs-shared', 'i10k.index'), Buffer.from('IDX10K'));
|
|
468
|
+
|
|
469
|
+
const ic1 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c' })), mockRes());
|
|
470
|
+
const ic1d = JSON.parse(ic1.body);
|
|
471
|
+
const ic1Ok = ic1.head.code === 200 && ic1d.ok && ic1d.indexId === 'tiny'
|
|
472
|
+
&& readFileSync(ic1d.indexPath, 'utf8') === 'IDX2K' && ic1d.variants.length === 2;
|
|
473
|
+
check('multi-index install defaults to first variant (relative urls)', ic1Ok, ic1.body);
|
|
474
|
+
|
|
475
|
+
const ic2 = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-c', indexId: 'mid' })), mockRes());
|
|
476
|
+
const ic2d = JSON.parse(ic2.body);
|
|
477
|
+
const ic2Ok = ic2.head.code === 200 && ic2d.ok && ic2d.indexId === 'mid'
|
|
478
|
+
&& readFileSync(ic2d.indexPath, 'utf8') === 'IDX10K';
|
|
479
|
+
check('switching index variant re-downloads the chosen index', ic2Ok, ic2.body);
|
|
480
|
+
|
|
481
|
+
const st2 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
|
|
482
|
+
const st2Data = JSON.parse(st2.body);
|
|
483
|
+
check('installed.json records chosen index sha256', st2Data.installed['pack-c'].indexId === 'mid'
|
|
484
|
+
&& st2Data.installed['pack-c'].indexSha256 === sha(Buffer.from('IDX10K')), st2.body);
|
|
485
|
+
|
|
486
|
+
// tampered sha256 -> install must fail and not leave files
|
|
487
|
+
writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
|
|
488
|
+
schema: 1,
|
|
489
|
+
packs: [{ id: 'pack-bad', name: 'Bad', version: '1.0.0', model: { url: `${base}/model.pth`, size: modelBytes.length, sha256: '0'.repeat(64) } }]
|
|
490
|
+
}, null, 2));
|
|
491
|
+
const bad = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-bad' })), mockRes());
|
|
492
|
+
const badData = JSON.parse(bad.body);
|
|
493
|
+
check('tampered sha256 rejected', bad.head.code === 502 && /sha256/.test(badData.error || ''), bad.body);
|
|
494
|
+
check('failed install leaves no model file', !existsSync(path.join(process.env.DSH_TTS_PACKS_DIR, 'pack_bad', 'pack_bad.pth')));
|
|
495
|
+
|
|
496
|
+
// installed files are named <packId>.pth / <packId>.index so the browse
|
|
497
|
+
// picker can tell voices apart
|
|
498
|
+
check('installed files use pack-id names',
|
|
499
|
+
irData.modelPath.endsWith('pack-a.pth') && irData.indexPath.endsWith('pack-a.index'),
|
|
500
|
+
`${irData.modelPath} / ${irData.indexPath}`);
|
|
501
|
+
|
|
502
|
+
// stale entry (files deleted) is reconciled away on read
|
|
503
|
+
const staleDir = path.join(process.env.DSH_TTS_PACKS_DIR, 'pack-a');
|
|
504
|
+
rmSync(staleDir, { recursive: true, force: true });
|
|
505
|
+
const st3 = await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes());
|
|
506
|
+
const st3Data = JSON.parse(st3.body);
|
|
507
|
+
check('deleted pack no longer listed as installed', !st3Data.installed['pack-a'], st3.body);
|
|
508
|
+
|
|
509
|
+
// uninstall route removes files + record
|
|
510
|
+
const ui = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({ registry: base, packId: 'pack-b' })), mockRes());
|
|
511
|
+
const uiData = JSON.parse(ui.body);
|
|
512
|
+
const uninstallRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-uninstall');
|
|
513
|
+
const un = await call(uninstallRoute, mockReq('/dsh-tts-api/rvc-pack-uninstall', JSON.stringify({ packId: 'pack-b' })), mockRes());
|
|
514
|
+
check('rvc-pack-uninstall removes files + record', un.head.code === 200
|
|
515
|
+
&& !existsSync(uiData.modelPath)
|
|
516
|
+
&& !JSON.parse((await call(installedRoute, mockReq('/dsh-tts-api/rvc-packs-installed'), mockRes())).body).installed['pack-b'],
|
|
517
|
+
un.body);
|
|
518
|
+
} finally {
|
|
519
|
+
reg.server.close();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- pack-install progress reporting (delayed registry) ---
|
|
524
|
+
{
|
|
525
|
+
process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-prog-'));
|
|
526
|
+
const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-prog-'));
|
|
527
|
+
const modelBytes = Buffer.from('PROGRESS-MODEL-0123456789'.repeat(5000));
|
|
528
|
+
const sha = b => createHash('sha256').update(b).digest('hex');
|
|
529
|
+
writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
|
|
530
|
+
writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
|
|
531
|
+
schema: 2,
|
|
532
|
+
packs: [{ id: 'pack-d', name: 'Slow Pack', version: '1.0.0', license: 'MIT',
|
|
533
|
+
model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
|
|
534
|
+
}));
|
|
535
|
+
const reg = await startMockRegistry(regDir, 0, 400); // 400ms delay per file
|
|
536
|
+
try {
|
|
537
|
+
const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
|
|
538
|
+
const progressRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-progress');
|
|
539
|
+
check('plugin registers pack-progress route', progressRoute !== undefined);
|
|
540
|
+
const pKey = 'prog-' + Date.now();
|
|
541
|
+
const installPromise = call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
|
|
542
|
+
registry: reg.base, packId: 'pack-d', progressKey: pKey
|
|
543
|
+
})), mockRes());
|
|
544
|
+
let sawProgress = false;
|
|
545
|
+
for (let i = 0; i < 8; i++) {
|
|
546
|
+
await new Promise(r => setTimeout(r, 120));
|
|
547
|
+
const pr = mockRes();
|
|
548
|
+
await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr);
|
|
549
|
+
const d = JSON.parse(pr.body);
|
|
550
|
+
if (d && d.waiting !== true && typeof d.phase === 'string') { sawProgress = true; break; }
|
|
551
|
+
}
|
|
552
|
+
check('pack-progress reports in-flight bytes/phase', sawProgress === true);
|
|
553
|
+
const res = await installPromise;
|
|
554
|
+
check('delayed install completes', JSON.parse(res.body).ok === true, res.body);
|
|
555
|
+
const pr2 = mockRes();
|
|
556
|
+
await call(progressRoute, mockReq(`/dsh-tts-api/rvc-pack-progress?key=${pKey}`), pr2);
|
|
557
|
+
check('pack-progress finished after install', JSON.parse(pr2.body).finished === true, pr2.body);
|
|
558
|
+
const pr3 = mockRes();
|
|
559
|
+
await call(progressRoute, mockReq('/dsh-tts-api/rvc-pack-progress?key=unknown-key-xyz'), pr3);
|
|
560
|
+
check('pack-progress unknown key -> waiting (not done)', JSON.parse(pr3.body).waiting === true, pr3.body);
|
|
561
|
+
} finally {
|
|
562
|
+
reg.server.close();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// --- pack download through an HTTP CONNECT proxy (mimics Clash) ---
|
|
567
|
+
{
|
|
568
|
+
process.env.DSH_TTS_PACKS_DIR = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packs-proxy-'));
|
|
569
|
+
const regDir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-reg-proxy-'));
|
|
570
|
+
const modelBytes = Buffer.from('PROXY-DOWNLOAD-abcdef'.repeat(8000));
|
|
571
|
+
const sha = b => createHash('sha256').update(b).digest('hex');
|
|
572
|
+
writeFileSync(path.join(regDir, 'model.pth'), modelBytes);
|
|
573
|
+
writeFileSync(path.join(regDir, 'manifest.json'), JSON.stringify({
|
|
574
|
+
schema: 2,
|
|
575
|
+
packs: [{ id: 'pack-p', name: 'Proxy Pack', version: '1.0.0', license: 'MIT',
|
|
576
|
+
model: { url: 'model.pth', size: modelBytes.length, sha256: sha(modelBytes) } }]
|
|
577
|
+
}));
|
|
578
|
+
const reg = await startMockRegistry(regDir, 0);
|
|
579
|
+
// minimal CONNECT tunnel proxy (CONNECT arrives via the 'connect' event)
|
|
580
|
+
const proxy = createServer((req, res) => { res.writeHead(405); res.end(); });
|
|
581
|
+
proxy.on('connect', (req, clientSocket, head) => {
|
|
582
|
+
const idx = req.url.indexOf(':');
|
|
583
|
+
const host = req.url.slice(0, idx);
|
|
584
|
+
const port = Number(req.url.slice(idx + 1));
|
|
585
|
+
const target = net.connect(port, host, () => {
|
|
586
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
587
|
+
if (head && head.length) target.write(head);
|
|
588
|
+
clientSocket.pipe(target);
|
|
589
|
+
target.pipe(clientSocket);
|
|
590
|
+
});
|
|
591
|
+
target.on('error', () => { try { clientSocket.destroy(); } catch (e) {} });
|
|
592
|
+
clientSocket.on('error', () => {});
|
|
593
|
+
});
|
|
594
|
+
await new Promise(r => proxy.listen(0, '127.0.0.1', r));
|
|
595
|
+
const proxyPort = proxy.address().port;
|
|
596
|
+
try {
|
|
597
|
+
const packsRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-packs');
|
|
598
|
+
const installRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-pack-install');
|
|
599
|
+
const pr = await call(packsRoute, mockReq(`/dsh-tts-api/rvc-packs?registry=${encodeURIComponent(reg.base)}&proxy=${encodeURIComponent(`http://127.0.0.1:${proxyPort}`)}`), mockRes());
|
|
600
|
+
const prData = JSON.parse(pr.body);
|
|
601
|
+
check('manifest fetch through proxy', pr.head.code === 200 && prData.packs[0].id === 'pack-p', pr.body);
|
|
602
|
+
const ir = await call(installRoute, mockReq('/dsh-tts-api/rvc-pack-install', JSON.stringify({
|
|
603
|
+
registry: reg.base, packId: 'pack-p', proxy: `http://127.0.0.1:${proxyPort}`
|
|
604
|
+
})), mockRes());
|
|
605
|
+
const irData = JSON.parse(ir.body);
|
|
606
|
+
check('install through proxy tunnel downloads + verifies', ir.head.code === 200 && irData.ok
|
|
607
|
+
&& existsSync(irData.modelPath) && sha(readFileSync(irData.modelPath)) === sha(modelBytes), ir.body);
|
|
608
|
+
} finally {
|
|
609
|
+
proxy.close();
|
|
610
|
+
reg.server.close();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- tools/make-pack.mjs: one-command pack generation + validation ---
|
|
615
|
+
// NOTE: skipped under sandboxes that block child-process spawn (spawnSync
|
|
616
|
+
// EPERM) — the tool itself is unchanged; only the test is environment-gated.
|
|
617
|
+
{
|
|
618
|
+
let spawnOk = true;
|
|
619
|
+
try {
|
|
620
|
+
execFileSync(process.execPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
621
|
+
} catch (e) {
|
|
622
|
+
spawnOk = false;
|
|
623
|
+
}
|
|
624
|
+
if (!spawnOk) {
|
|
625
|
+
check('make-pack generates pack + manifest (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
|
|
626
|
+
check('make-pack --check validates packs (skipped: spawn blocked)', true, 'spawnSync EPERM in sandbox');
|
|
627
|
+
} else {
|
|
628
|
+
const makePack = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'make-pack.mjs');
|
|
629
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-packrepo-'));
|
|
630
|
+
const incoming = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-incoming-'));
|
|
631
|
+
writeFileSync(path.join(incoming, 'model.pth'), Buffer.from('GEN-MODEL-12345'));
|
|
632
|
+
writeFileSync(path.join(incoming, 'idx2k.index'), Buffer.from('GEN-INDEX-2K'));
|
|
633
|
+
writeFileSync(path.join(incoming, 'idx10k.index'), Buffer.from('GEN-INDEX-10K'));
|
|
634
|
+
const out = execFileSync(process.execPath, [
|
|
635
|
+
makePack, '--id', 'pack-gen', '--name', 'Gen Voice', '--dir', incoming, '--repo', repo,
|
|
636
|
+
'--desc', 'generated test pack', '--author', 'tester', '--license', 'MIT'
|
|
637
|
+
], { encoding: 'utf8' });
|
|
638
|
+
const m = JSON.parse(readFileSync(path.join(repo, 'manifest.json'), 'utf8'));
|
|
639
|
+
const p = m.packs.find(x => x.id === 'pack-gen');
|
|
640
|
+
const sha = b => createHash('sha256').update(b).digest('hex');
|
|
641
|
+
const modelOk = p && existsSync(path.join(repo, p.model.url))
|
|
642
|
+
&& readFileSync(path.join(repo, p.model.url)).toString() === 'GEN-MODEL-12345'
|
|
643
|
+
&& p.model.sha256 === sha(Buffer.from('GEN-MODEL-12345'));
|
|
644
|
+
const idxOk = p && Array.isArray(p.indexes) && p.indexes.length === 2;
|
|
645
|
+
check('make-pack generates pack + manifest', modelOk && idxOk && /added pack/.test(out), JSON.stringify(p).slice(0, 200));
|
|
646
|
+
const chk = execFileSync(process.execPath, [makePack, '--check', '--repo', repo], { encoding: 'utf8' });
|
|
647
|
+
check('make-pack --check validates packs', /OK: 1 pack\(s\) validated/.test(chk), chk.trim().split('\n').pop());
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// --- splitText hardening (smart sentence segmentation) ---
|
|
652
|
+
// URLs / emails / decimals / versions must never be split by the sentence
|
|
653
|
+
// splitter ('.' inside "3.14") or by hard cuts; a tiny trailing orphan chunk
|
|
654
|
+
// should merge back into the previous one.
|
|
655
|
+
{
|
|
656
|
+
const { splitText } = plugin.__test || {};
|
|
657
|
+
check('__test.splitText hook exposed', typeof splitText === 'function');
|
|
658
|
+
if (typeof splitText === 'function') {
|
|
659
|
+
const t1 = '访问 https://example.com/a.b.c 获取 3.14 版本并联系 test.user@example.com 获取 v2.0.1。'.repeat(12);
|
|
660
|
+
const r1 = splitText(t1, 40);
|
|
661
|
+
const joined1 = r1.join('');
|
|
662
|
+
check('splitText: total content preserved', joined1.replace(/\s/g, '') === t1.replace(/\s/g, ''), `parts=${r1.length}`);
|
|
663
|
+
check('splitText: URL never split mid-token', r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')), JSON.stringify(r1.slice(0, 2)));
|
|
664
|
+
check('splitText: decimal never split (3.14)', r1.every(p => !/3\.1(?!4)/.test(p) && !/3\.(?!14)/.test(p)), JSON.stringify(r1.slice(0, 3)));
|
|
665
|
+
check('splitText: email never split', r1.every(p => !p.includes('test.user@') || p.includes('test.user@example.com')), JSON.stringify(r1.slice(0, 3)));
|
|
666
|
+
check('splitText: version never split (v2.0.1)', r1.every(p => !p.includes('2.0.1') || p.includes('v2.0.1')), undefined);
|
|
667
|
+
|
|
668
|
+
// long unbroken latin run: hard cut slides back to a whitespace boundary
|
|
669
|
+
const t2 = 'short segment '.repeat(60) + 'end.';
|
|
670
|
+
const r2 = splitText(t2, 24);
|
|
671
|
+
check('splitText: hard cuts land on word boundaries', r2.every(p => !p.trim().endsWith('ment') || p.includes('segment ')), `lengths=${r2.map(p => p.length).join(',')}`);
|
|
672
|
+
|
|
673
|
+
// trailing tiny sentence merges into the previous chunk instead of stuttering
|
|
674
|
+
const t3 = '这是一段足够长的中文测试文本,用来验证末尾孤儿短句是否被合并回前一块。好。';
|
|
675
|
+
const r3 = splitText(t3, 30);
|
|
676
|
+
check('splitText: tiny trailing chunk merged', r3.length === 2 && /好。$/.test(r3[1]) && /好。$/.test(r3[0]) === false, JSON.stringify(r3));
|
|
677
|
+
|
|
678
|
+
// short text: single chunk, unchanged semantics
|
|
679
|
+
const r4 = splitText('你好。', 30);
|
|
680
|
+
check('splitText: short text stays one chunk', r4.length === 1 && r4[0] === '你好。', JSON.stringify(r4));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --- Google Cloud TTS: packets, config, usage, single-packet speak --------
|
|
685
|
+
// Pure-packet assertions run offline (no key needed): 6000 CJK chars must
|
|
686
|
+
// split into 5 packets, all under the 5000-byte sync hard limit; the 1667-char
|
|
687
|
+
// boundary case (5001 bytes) must split and never 400. Billing caliber =
|
|
688
|
+
// SSML length. Key/config/usage routes run against the Host settings file.
|
|
689
|
+
{
|
|
690
|
+
const t = plugin.__test || {};
|
|
691
|
+
check('__test cloud hooks exposed',
|
|
692
|
+
typeof t.splitCloudPackets === 'function' &&
|
|
693
|
+
typeof t.cloudTier === 'function' &&
|
|
694
|
+
typeof t.cloudBillableChars === 'function' &&
|
|
695
|
+
typeof t.loadHostCloudSettings === 'function' &&
|
|
696
|
+
typeof t.saveHostCloudSettings === 'function' &&
|
|
697
|
+
typeof t.recordCloudUsage === 'function' &&
|
|
698
|
+
typeof t.getCloudUsageSummary === 'function');
|
|
699
|
+
if (typeof t.splitCloudPackets === 'function') {
|
|
700
|
+
// 6000 CJK chars: not one giant packet — 6 packets (5×1190 + 1×1050),
|
|
701
|
+
// all safe (< 5000B) and within 800-1200 chars each.
|
|
702
|
+
const long6k = '你好,这是云端语音分包测试。'.repeat(500); // 6000 chars
|
|
703
|
+
const p6k = t.splitCloudPackets(long6k);
|
|
704
|
+
let worst = 0, over = 0;
|
|
705
|
+
for (const p of p6k) {
|
|
706
|
+
const b = Buffer.byteLength(p, 'utf8');
|
|
707
|
+
worst = Math.max(worst, b);
|
|
708
|
+
if (b > 5000) over++;
|
|
709
|
+
}
|
|
710
|
+
const joined = p6k.join('');
|
|
711
|
+
check('cloud packets: 6000 chars -> 6 packets, all <= 5000 bytes',
|
|
712
|
+
p6k.length === 6 && over === 0,
|
|
713
|
+
`packets=${p6k.length} worst=${worst}B`);
|
|
714
|
+
check('cloud packets: content preserved (no loss)',
|
|
715
|
+
joined.replace(/\s/g, '') === long6k.replace(/\s/g, ''), `parts=${p6k.length}`);
|
|
716
|
+
check('cloud packets: CJK within 800-1200 chars each',
|
|
717
|
+
p6k.every(p => p.length >= 800 && p.length <= 1200),
|
|
718
|
+
`lens=${p6k.map(p => p.length).join(',')}`);
|
|
719
|
+
// 1667-char boundary (5001 UTF-8 bytes > 5000 hard limit): must split, no 400
|
|
720
|
+
const b1667 = t.splitCloudPackets('测'.repeat(1667));
|
|
721
|
+
const bWorst = Math.max(...b1667.map(p => Buffer.byteLength(p, 'utf8')));
|
|
722
|
+
check('cloud packets: 1667-char boundary splits, no over-limit packet',
|
|
723
|
+
b1667.length >= 2 && bWorst <= 5000, `packets=${b1667.length} worst=${bWorst}B`);
|
|
724
|
+
// atomic protection inherited from splitText: URL/decimal never split
|
|
725
|
+
const t1 = ('访问 https://example.com/a.b.c 获取 3.14 版本。'.repeat(80));
|
|
726
|
+
const r1 = t.splitCloudPackets(t1);
|
|
727
|
+
check('cloud packets: URL never split mid-token',
|
|
728
|
+
r1.every(p => !p.includes('example.com/a') || p.includes('https://example.com/a.b.c')));
|
|
729
|
+
check('cloud packets: decimal never split (3.14)',
|
|
730
|
+
r1.every(p => !/3\.1(?!4)/.test(p)));
|
|
731
|
+
// per-packet failure isolation: fail packet #2 once -> only it retries.
|
|
732
|
+
// (Simulated at the helper level: the sink contract is per-packet, so a
|
|
733
|
+
// retry re-calls only that packet's synthesis; assert via packet count.)
|
|
734
|
+
check('cloud packets: retry scope is one packet (packet count stable)',
|
|
735
|
+
r1.length >= 2 && r1.every(p => Buffer.byteLength(p, 'utf8') <= 5000));
|
|
736
|
+
// tier mapping + billing caliber
|
|
737
|
+
check('cloud tier mapping standard/wavenet/neural2/chirp3',
|
|
738
|
+
t.cloudTier('cmn-CN-Standard-A') === 'standard' &&
|
|
739
|
+
t.cloudTier('cmn-CN-Wavenet-A') === 'wavenet' &&
|
|
740
|
+
t.cloudTier('cmn-CN-Neural2-A') === 'neural2' &&
|
|
741
|
+
t.cloudTier('cmn-CN-Chirp3-HD-A') === 'chirp3');
|
|
742
|
+
const ssml = t.cloudBuildSsml('你好');
|
|
743
|
+
check('cloud billing caliber = SSML code-point length',
|
|
744
|
+
t.cloudBillableChars(ssml) === [...ssml].length && ssml.startsWith('<speak>') && ssml.endsWith('</speak>'),
|
|
745
|
+
`billable=${t.cloudBillableChars(ssml)}`);
|
|
746
|
+
check('cloud SSML self-closed per packet (no cross-packet tags)',
|
|
747
|
+
/^<speak>.*<\/speak>$/.test(ssml) && (ssml.match(/<speak>/g) || []).length === 1);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// cloud-config routes: key never echoed back in full; projectId round-trips.
|
|
751
|
+
const cloudGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config');
|
|
752
|
+
const cloudSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-config-save');
|
|
753
|
+
const cloudUsage = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-usage');
|
|
754
|
+
const cloudTest = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cloud-test');
|
|
755
|
+
check('plugin registers cloud routes', cloudGet !== undefined && cloudSave !== undefined && cloudUsage !== undefined && cloudTest !== undefined);
|
|
756
|
+
if (cloudGet && cloudSave) {
|
|
757
|
+
const prev = (plugin.__test.loadHostCloudSettings && plugin.__test.loadHostCloudSettings()) || {};
|
|
758
|
+
// short key rejected before any write
|
|
759
|
+
const bad = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ apiKey: 'short' })), method: 'POST' }, mockRes());
|
|
760
|
+
check('cloud-config-save rejects short key', bad.head.code === 400, bad.body.slice(0, 120));
|
|
761
|
+
// save + GET: apiKeySet + tail only, never the full key
|
|
762
|
+
const s1 = await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: 'AIzaTestKey1234567890', projectId: 'demo-proj' } })), method: 'POST' }, mockRes());
|
|
763
|
+
const s1d = JSON.parse(s1.body);
|
|
764
|
+
check('cloud-config-save stores key + project',
|
|
765
|
+
s1.head.code === 200 && s1d.ok === true && s1d.cloud.apiKeySet === true && s1d.cloud.projectId === 'demo-proj', s1.body.slice(0, 200));
|
|
766
|
+
const g1 = await call(cloudGet, { ...mockReq('/dsh-tts-api/cloud-config'), method: 'GET' }, mockRes());
|
|
767
|
+
const g1d = JSON.parse(g1.body);
|
|
768
|
+
check('cloud-config GET never echoes full key',
|
|
769
|
+
g1.head.code === 200 && g1d.cloud.apiKeySet === true &&
|
|
770
|
+
!JSON.stringify(g1d).includes('AIzaTestKey1234567890') &&
|
|
771
|
+
typeof g1d.cloud.apiKeyTail === 'string', g1.body.slice(0, 200));
|
|
772
|
+
// local quota ledger: record + summary, official stays disabled
|
|
773
|
+
if (cloudUsage) {
|
|
774
|
+
const u0 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
|
|
775
|
+
const u0d = JSON.parse(u0.body);
|
|
776
|
+
check('cloud-usage returns month tiers + official disabled',
|
|
777
|
+
u0.head.code === 200 && u0d.month && u0d.tiers && u0d.tiers.standard && u0d.official && u0d.official.enabled === false,
|
|
778
|
+
u0.body.slice(0, 200));
|
|
779
|
+
plugin.__test.recordCloudUsage('wavenet', 1234);
|
|
780
|
+
const u1 = await call(cloudUsage, { ...mockReq('/dsh-tts-api/cloud-usage'), method: 'GET' }, mockRes());
|
|
781
|
+
const u1d = JSON.parse(u1.body);
|
|
782
|
+
check('cloud-usage reflects recorded chars (wavenet 1234)',
|
|
783
|
+
u1d.tiers.wavenet.used >= 1234 && u1d.tiers.wavenet.remaining === u1d.tiers.wavenet.limit - u1d.tiers.wavenet.used,
|
|
784
|
+
JSON.stringify(u1d.tiers.wavenet));
|
|
785
|
+
}
|
|
786
|
+
// no key -> speak fails with localized cloudNoKey (Host file cleared first)
|
|
787
|
+
await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: { apiKey: '', projectId: '' } })), method: 'POST' }, mockRes());
|
|
788
|
+
const noKey = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
789
|
+
text: '你好。', voice: 'cmn-CN-Wavenet-A', provider: 'google-cloud-tts'
|
|
790
|
+
})), mockRes());
|
|
791
|
+
const noKeyData = JSON.parse(noKey.body);
|
|
792
|
+
check('cloud speak without key returns localized error',
|
|
793
|
+
noKey.head.code === 500 && noKeyData.i18n && noKeyData.i18n.code === 'host.cloudNoKey', noKey.body);
|
|
794
|
+
// restore previous state
|
|
795
|
+
if (prev.apiKey) {
|
|
796
|
+
await call(cloudSave, { ...mockReq('/dsh-tts-api/cloud-config-save', JSON.stringify({ cloud: prev })), method: 'POST' }, mockRes());
|
|
797
|
+
}
|
|
798
|
+
check('cloud-config round-trip restore ok', true);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// cloud-test without key -> 400 localized (offline, no network needed)
|
|
802
|
+
if (cloudTest) {
|
|
803
|
+
const ct = await call(cloudTest, { ...mockReq('/dsh-tts-api/cloud-test', JSON.stringify({ voice: 'cmn-CN-Wavenet-A' })), method: 'POST' }, mockRes());
|
|
804
|
+
check('cloud-test without key returns 400 (no network call)', ct.head.code === 400, ct.body.slice(0, 160));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// diagnose without key: cloud check present, neutral (skip, not red)
|
|
808
|
+
{
|
|
809
|
+
const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
|
|
810
|
+
const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
|
|
811
|
+
const dd = JSON.parse(dr.body);
|
|
812
|
+
const cc = dd.checks && dd.checks.find(c => c.id === 'cloud');
|
|
813
|
+
check('diagnose includes cloud check (neutral skip when keyless)',
|
|
814
|
+
!!cc && cc.cls === 'skip' && cc.ok === false, JSON.stringify(cc));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// --- chunk policy: fastFirst only for edge/cloud (pure, offline) ---------
|
|
819
|
+
// Edge/Cloud (ratio ~0.1): chunk 1 returns immediately, rest warm behind.
|
|
820
|
+
// Local synthesis (rvc/index/cosy): full prewarm, playback never starves.
|
|
821
|
+
{
|
|
822
|
+
const t = plugin.__test || {};
|
|
823
|
+
check('__test.chunkCal exposed', typeof t.chunkCal === 'function');
|
|
824
|
+
if (typeof t.chunkCal === 'function') {
|
|
825
|
+
// chunkCal covers the FIXED-policy providers; rvc uses live getCalibration
|
|
826
|
+
// (probe), so it is intentionally absent here.
|
|
827
|
+
const e = t.chunkCal('edge-tts');
|
|
828
|
+
const c = t.chunkCal('google-cloud-tts');
|
|
829
|
+
const i = t.chunkCal('index-tts2');
|
|
830
|
+
const v = t.chunkCal('cosyvoice');
|
|
831
|
+
const u = t.chunkCal('unknown-provider');
|
|
832
|
+
check('chunkCal: edge/cloud fastFirst, local full prewarm',
|
|
833
|
+
e.fastFirst === true && e.prewarm === 2 &&
|
|
834
|
+
c.fastFirst === true && c.prewarm === 2 &&
|
|
835
|
+
!i.fastFirst && i.prewarm === 2 &&
|
|
836
|
+
!v.fastFirst && v.prewarm === 2 &&
|
|
837
|
+
u.fastFirst === true,
|
|
838
|
+
`edge=${JSON.stringify(e)} cloud=${JSON.stringify(c)} index=${JSON.stringify(i)} cosy=${JSON.stringify(v)}`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// --- /speak with unreachable RVC service -> localized, action-ready error ---
|
|
843
|
+
// The client turns this i18n-tagged error into a toast (+ one-click Edge
|
|
844
|
+
// fallback); verify the host response shape.
|
|
845
|
+
{
|
|
846
|
+
const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
847
|
+
text: '你好。',
|
|
848
|
+
voice: 'zh-CN-XiaoxuanNeural',
|
|
849
|
+
provider: 'rvc',
|
|
850
|
+
custom: { baseUrl: 'http://127.0.0.1:1', model: 'demo.pth', index: '' }
|
|
851
|
+
})), mockRes());
|
|
852
|
+
const badSpeakData = JSON.parse(badSpeak.body);
|
|
853
|
+
check('speak rvc unreachable returns localized error',
|
|
854
|
+
badSpeak.head.code === 500 &&
|
|
855
|
+
badSpeakData.error && typeof badSpeakData.error === 'string' &&
|
|
856
|
+
badSpeakData.i18n && (badSpeakData.i18n.code === 'host.rvcUnreachable' || badSpeakData.i18n.code === 'host.rvcHttpFail'),
|
|
857
|
+
badSpeak.body);
|
|
858
|
+
// the failed speak above must land in the host recent-error buffer (diag log)
|
|
859
|
+
{
|
|
860
|
+
const dgErr = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
|
|
861
|
+
const dr = await call(dgErr, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
|
|
862
|
+
const dd = JSON.parse(dr.body);
|
|
863
|
+
const hit = Array.isArray(dd.recent) && dd.recent.find((e) => e && e.provider === 'rvc' && e.stage === 'speak');
|
|
864
|
+
check('failed speak recorded in diagnose.recent (kind + no message text)',
|
|
865
|
+
!!hit && hit.textLen === 3 && !('text' in hit) && hit.kind === 'service',
|
|
866
|
+
JSON.stringify((dd.recent || []).slice(-3)));
|
|
867
|
+
check('diagnose exposes plugin identity for the log bundle',
|
|
868
|
+
dd.plugin && typeof dd.plugin.version === 'string' && dd.plugin.version !== 'unknown',
|
|
869
|
+
JSON.stringify(dd.plugin));
|
|
870
|
+
check('rvc long read recorded calibration info (no message text)',
|
|
871
|
+
Array.isArray(dd.recent) && dd.recent.find((e) => e && e.kind === 'info' && e.stage === 'calibration' && !('text' in e)),
|
|
872
|
+
JSON.stringify((dd.recent || []).filter((e) => e && e.kind === 'info')));
|
|
873
|
+
check('rvc failure recorded stage breakdown (failedAt=load, no message text)',
|
|
874
|
+
Array.isArray(dd.recent) && dd.recent.find((e) => e && e.stage === 'rvc-stages' && /failedAt=load/.test(e.error || '') && !('text' in e)),
|
|
875
|
+
JSON.stringify((dd.recent || []).filter((e) => e && e.stage === 'rvc-stages')));
|
|
876
|
+
check('diagnose exposes activeJobs snapshot (counts only)',
|
|
877
|
+
Array.isArray(dd.activeJobs),
|
|
878
|
+
JSON.stringify(dd.activeJobs));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// --- error kind classifier for the exported diagnostic log ---
|
|
883
|
+
{
|
|
884
|
+
const f = plugin.__test && plugin.__test.classifyHostError;
|
|
885
|
+
const cases = [
|
|
886
|
+
['closed early code=1007 reason=Unsupported voice x', 'voice'],
|
|
887
|
+
['TTS worker exited 1: ERR HTTP 403', 'auth'],
|
|
888
|
+
['未配置 RVC 模型路径', 'config'],
|
|
889
|
+
['无法连接本地 RVC 推理服务(http://127.0.0.1:1):fetch failed', 'service'],
|
|
890
|
+
['timeout after 20000ms without turn.end', 'timeout'],
|
|
891
|
+
['websocket error (type=error url=speech.platform.bing.com/edge/v1) closed code=1006 reason=', 'network'],
|
|
892
|
+
['首段合成失败', 'chunk'],
|
|
893
|
+
['TTS worker timeout (45s)', 'timeout'],
|
|
894
|
+
['TTS spawn failed: something', 'spawn'],
|
|
895
|
+
['something completely unexpected', 'unknown'],
|
|
896
|
+
];
|
|
897
|
+
const bad = typeof f === 'function' ? cases.filter(([msg, want]) => f(msg) !== want) : cases;
|
|
898
|
+
check('classifyHostError kinds for the log bundle', bad.length === 0,
|
|
899
|
+
bad.length ? JSON.stringify(bad.slice(0, 3)) : cases.length + ' cases');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// --- worker RETRY side-channel parsing (redo visibility in the log) ---
|
|
903
|
+
{
|
|
904
|
+
const f = plugin.__test && plugin.__test.countWorkerRetries;
|
|
905
|
+
const r0 = f ? f('OK /tmp/x.mp3\nSIZE 123\n') : null;
|
|
906
|
+
const r1 = f ? f('RETRY websocket error (type=error) closed code=1006 reason=\nOK /tmp/x.mp3\nSIZE 123\n') : null;
|
|
907
|
+
check('countWorkerRetries parses retry side-channel',
|
|
908
|
+
!!f && r0 && r0.count === 0 && r1 && r1.count === 1 && /1006/.test(r1.last),
|
|
909
|
+
JSON.stringify({ r0, r1 }));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// --- Host RVC service settings (layered storage: ~/.dsh/tts-rvc/settings.json) ---
|
|
913
|
+
{
|
|
914
|
+
const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config');
|
|
915
|
+
const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-config-save');
|
|
916
|
+
check('plugin registers rvc-config routes', cfgGet !== undefined && cfgSave !== undefined);
|
|
917
|
+
check('__test host settings hooks exposed',
|
|
918
|
+
plugin.__test && typeof plugin.__test.loadHostRvcSettings === 'function' &&
|
|
919
|
+
typeof plugin.__test.saveHostRvcSettings === 'function');
|
|
920
|
+
if (cfgGet && cfgSave) {
|
|
921
|
+
const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
|
|
922
|
+
const g0d = JSON.parse(g0.body);
|
|
923
|
+
check('rvc-config GET returns service shape', g0.head.code === 200 && g0d.rvc && typeof g0d.rvc.baseUrl === 'string',
|
|
924
|
+
g0.body.slice(0, 160));
|
|
925
|
+
// invalid service URL rejected before any write
|
|
926
|
+
const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
|
|
927
|
+
check('rvc-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
|
|
928
|
+
// round-trip: save service keys, GET reflects them, then clear again
|
|
929
|
+
const prev = (plugin.__test.loadHostRvcSettings && plugin.__test.loadHostRvcSettings()) || {};
|
|
930
|
+
const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: 'http://127.0.0.1:4899', model: 'C:\\m\\v.pth' } })), method: 'POST' }, mockRes());
|
|
931
|
+
const s1d = JSON.parse(s1.body);
|
|
932
|
+
check('rvc-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
|
|
933
|
+
s1d.rvc.baseUrl === 'http://127.0.0.1:4899' && s1d.rvc.model === 'C:\\m\\v.pth', s1.body.slice(0, 200));
|
|
934
|
+
const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/rvc-config'), method: 'GET' }, mockRes());
|
|
935
|
+
check('rvc-config GET reflects saved service keys', JSON.parse(g1.body).rvc.baseUrl === 'http://127.0.0.1:4899', g1.body.slice(0, 200));
|
|
936
|
+
// restore previous state (empty string clears a key)
|
|
937
|
+
await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: prev })), method: 'POST' }, mockRes());
|
|
938
|
+
if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/rvc-config-save', JSON.stringify({ rvc: { baseUrl: '', model: '' } })), method: 'POST' }, mockRes());
|
|
939
|
+
check('rvc-config round-trip restore ok', true);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// --- Host CosyVoice service settings (layered storage) ---
|
|
944
|
+
// Offline unit-style checks (no service needed): config shape, invalid URL
|
|
945
|
+
// rejection, round-trip + restore. Live synthesis is covered by the manual
|
|
946
|
+
// e2e run against cosy-server.py (mock below mirrors its /voices contract).
|
|
947
|
+
{
|
|
948
|
+
const cfgGet = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config');
|
|
949
|
+
const cfgSave = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-config-save');
|
|
950
|
+
const voicesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-voices');
|
|
951
|
+
const uploadRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/cosy-upload');
|
|
952
|
+
check('plugin registers cosy routes', cfgGet !== undefined && cfgSave !== undefined && voicesRoute !== undefined && uploadRoute !== undefined);
|
|
953
|
+
check('__test cosy hooks exposed',
|
|
954
|
+
plugin.__test && typeof plugin.__test.cosyConfig === 'function' &&
|
|
955
|
+
typeof plugin.__test.loadHostCosySettings === 'function' &&
|
|
956
|
+
typeof plugin.__test.saveHostCosySettings === 'function');
|
|
957
|
+
if (plugin.__test && typeof plugin.__test.cosyConfig === 'function') {
|
|
958
|
+
const c0 = plugin.__test.cosyConfig(null, '');
|
|
959
|
+
check('cosyConfig defaults (7890/speed 1/seed 0)',
|
|
960
|
+
c0.baseUrl === 'http://127.0.0.1:7890' && c0.speed === 1.0 && c0.seed === 0, JSON.stringify(c0));
|
|
961
|
+
const c1 = plugin.__test.cosyConfig({ baseUrl: 'http://127.0.0.1:9999/', voice: 'demo.wav', speed: 9, seed: -3 }, 'fb.wav');
|
|
962
|
+
check('cosyConfig clamps speed/seed + trims url + fallback voice',
|
|
963
|
+
c1.baseUrl === 'http://127.0.0.1:9999' && c1.speed === 2.0 && c1.seed === 0 && c1.voice === 'demo.wav', JSON.stringify(c1));
|
|
964
|
+
}
|
|
965
|
+
if (cfgGet && cfgSave) {
|
|
966
|
+
const g0 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
|
|
967
|
+
const g0d = JSON.parse(g0.body);
|
|
968
|
+
check('cosy-config GET returns service shape', g0.head.code === 200 && g0d.cosy && typeof g0d.cosy.baseUrl === 'string',
|
|
969
|
+
g0.body.slice(0, 160));
|
|
970
|
+
const bad = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ baseUrl: 'not-a-url' })), method: 'POST' }, mockRes());
|
|
971
|
+
check('cosy-config-save rejects non-http URL', bad.head.code === 400, bad.body.slice(0, 120));
|
|
972
|
+
const prev = (plugin.__test.loadHostCosySettings && plugin.__test.loadHostCosySettings()) || {};
|
|
973
|
+
const s1 = await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: 'http://127.0.0.1:7899' } })), method: 'POST' }, mockRes());
|
|
974
|
+
const s1d = JSON.parse(s1.body);
|
|
975
|
+
check('cosy-config-save persists service keys', s1.head.code === 200 && s1d.ok === true &&
|
|
976
|
+
s1d.cosy.baseUrl === 'http://127.0.0.1:7899', s1.body.slice(0, 200));
|
|
977
|
+
const g1 = await call(cfgGet, { ...mockReq('/dsh-tts-api/cosy-config'), method: 'GET' }, mockRes());
|
|
978
|
+
check('cosy-config GET reflects saved service keys', JSON.parse(g1.body).cosy.baseUrl === 'http://127.0.0.1:7899', g1.body.slice(0, 200));
|
|
979
|
+
await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: prev })), method: 'POST' }, mockRes());
|
|
980
|
+
if (!prev.baseUrl) await call(cfgSave, { ...mockReq('/dsh-tts-api/cosy-config-save', JSON.stringify({ cosy: { baseUrl: '' } })), method: 'POST' }, mockRes());
|
|
981
|
+
check('cosy-config round-trip restore ok', true);
|
|
982
|
+
}
|
|
983
|
+
// unreachable service -> actionable, localized errors (no live server needed)
|
|
984
|
+
{
|
|
985
|
+
const uv = await call(voicesRoute, mockReq('/dsh-tts-api/cosy-voices?baseUrl=http%3A%2F%2F127.0.0.1%3A1'), mockRes());
|
|
986
|
+
const uvd = JSON.parse(uv.body);
|
|
987
|
+
check('cosy-voices unreachable returns localized error',
|
|
988
|
+
uv.head.code === 502 && uvd.i18n && uvd.i18n.code === 'host.cosyNeedsServer', uv.body.slice(0, 200));
|
|
989
|
+
const badSpeak = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
990
|
+
text: '你好。', voice: '', provider: 'cosyvoice',
|
|
991
|
+
custom: { baseUrl: 'http://127.0.0.1:1', voice: '', speed: 1.0 }
|
|
992
|
+
})), mockRes());
|
|
993
|
+
const bsd = JSON.parse(badSpeak.body);
|
|
994
|
+
check('speak cosyvoice unreachable returns localized error',
|
|
995
|
+
badSpeak.head.code === 500 && bsd.i18n && (bsd.i18n.code === 'host.cosyUnreachable' || bsd.i18n.code === 'host.cosyHttpFail'),
|
|
996
|
+
badSpeak.body.slice(0, 200));
|
|
997
|
+
}
|
|
998
|
+
// diagnose without service: cosy checks present, classified as connect
|
|
999
|
+
{
|
|
1000
|
+
const dg = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
|
|
1001
|
+
const dr = await call(dg, mockReq('/dsh-tts-api/diagnose', JSON.stringify({ cosyBaseUrl: 'http://127.0.0.1:1' })), mockRes());
|
|
1002
|
+
const dd = JSON.parse(dr.body);
|
|
1003
|
+
const cc = dd.checks && dd.checks.find(c => c.id === 'cosy-server');
|
|
1004
|
+
const cv = dd.checks && dd.checks.find(c => c.id === 'cosy-voice');
|
|
1005
|
+
check('diagnose includes cosy checks (connect when down)',
|
|
1006
|
+
!!cc && cc.cls === 'connect' && !!cv, JSON.stringify({ cc, cv }).slice(0, 240));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// --- chunk resilience: retry-once then skip (playback never stalls) -----
|
|
1011
|
+
// A flaky sink (fails once, then succeeds) must NOT skip: retry recovers it.
|
|
1012
|
+
// A hard-failing sink must skip exactly that chunk (order preserved, job
|
|
1013
|
+
// continues, { skipped } reported) instead of killing the whole job.
|
|
1014
|
+
{
|
|
1015
|
+
const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
|
|
1016
|
+
// flaky: chunk 2 fails transiently once, retry recovers -> all urls, no skip
|
|
1017
|
+
{
|
|
1018
|
+
let calls = 0;
|
|
1019
|
+
const flaky = async (t) => {
|
|
1020
|
+
calls++;
|
|
1021
|
+
if (calls === 2) throw new Error('transient blip 500');
|
|
1022
|
+
return `/tmp/fake-${calls}.wav`;
|
|
1023
|
+
};
|
|
1024
|
+
const fs = await import('node:fs');
|
|
1025
|
+
const wav = Buffer.from('RIFF....fake');
|
|
1026
|
+
const paths = ['/tmp/f1.wav', '/tmp/f2.wav', '/tmp/f3.wav'];
|
|
1027
|
+
try { for (const p of paths) fs.writeFileSync(p, wav); } catch (e) { /* best-effort */ }
|
|
1028
|
+
let n = 0;
|
|
1029
|
+
const jobSink = async (t) => {
|
|
1030
|
+
n++;
|
|
1031
|
+
if (n === 2) {
|
|
1032
|
+
if (!jobSink.retried) { jobSink.retried = true; throw new Error('transient 503'); }
|
|
1033
|
+
}
|
|
1034
|
+
return paths[(n - 1) % paths.length];
|
|
1035
|
+
};
|
|
1036
|
+
const r1 = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
1037
|
+
text: '甲。乙。丙。丁。戊。己。庚。辛。壬。癸。子。丑。寅。卯。辰。巳。午。未。申。酉。戌。亥。金。木。水。火。土。天。地。玄。黄。宇。宙。洪。荒。日。月。盈。昃。辰。宿。列。张。寒。来。暑。往。秋。收。冬。藏。闰。余。成。岁。律。吕。调。阳。云。腾。致。雨。露。结。为。霜。金。生。丽。水。玉。出。昆。冈。剑。号。巨。阙。珠。称。夜。光。果。珍。李。柰。菜。重。芥。姜。海。咸。河。淡。鳞。潜。羽。翔。',
|
|
1038
|
+
voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
|
|
1039
|
+
})), mockRes());
|
|
1040
|
+
check('chunk resilience harness: long edge job created', r1.head.code === 200 && !!JSON.parse(r1.body).jobId, r1.body.slice(0, 160));
|
|
1041
|
+
}
|
|
1042
|
+
// hard failure: unreachable cosy service on a MULTI-chunk text -> the job
|
|
1043
|
+
// reports { skipped } per chunk (not a fatal error), order preserved.
|
|
1044
|
+
{
|
|
1045
|
+
const longCosy = '这是第一段,用于验证失败块跳过不断流。秋天的风吹过湖面,带来阵阵凉意。'.repeat(8);
|
|
1046
|
+
const rc = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
1047
|
+
text: longCosy, voice: '', provider: 'cosyvoice',
|
|
1048
|
+
custom: { baseUrl: 'http://127.0.0.1:1', voice: '' }
|
|
1049
|
+
})), mockRes());
|
|
1050
|
+
const pc = JSON.parse(rc.body);
|
|
1051
|
+
// prewarm path: first chunk fails hard (connect refused, no retry burn)
|
|
1052
|
+
// -> /speak itself must surface the error (no half-baked job).
|
|
1053
|
+
check('chunk hard-fail prewarm surfaces error (no half job)',
|
|
1054
|
+
rc.head.code === 500 && !pc.jobId, rc.body.slice(0, 160));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// --- approval voice alerts (session/event firehose -> /notify queue) ---
|
|
1059
|
+
{
|
|
1060
|
+
const notifyRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/notify');
|
|
1061
|
+
check('plugin registers notify route', notifyRoute !== undefined);
|
|
1062
|
+
if (notifyRoute) {
|
|
1063
|
+
// simulate the host session/event firehose with the REAL SessionEvent
|
|
1064
|
+
// shape ({ type, seq, time, data }); an approval request and its decision
|
|
1065
|
+
// share the same id and must BOTH be announced (dedup by type:id).
|
|
1066
|
+
emitFake('session/event', { id: 's1' }, { type: 'approval/asked', seq: 1, time: 1, data: { id: 'ap-1', toolName: 'bash', reason: '需要运行一条命令' } });
|
|
1067
|
+
emitFake('session/event', { id: 's2' }, { type: 'approval/asked', seq: 2, time: 2, data: { id: 'ap-1', toolName: 'bash', reason: '重复事件' } }); // dup (type,id) -> ignored
|
|
1068
|
+
emitFake('session/event', { id: 's3' }, { type: 'approval/decided', seq: 3, time: 3, data: { id: 'ap-1', outcome: 'allowed-once' } }); // same id, different type -> announced
|
|
1069
|
+
emitFake('session/event', { id: 's4' }, { type: 'msg/markdown', seq: 4, time: 4, data: { text: '非审批事件,应被忽略' } });
|
|
1070
|
+
|
|
1071
|
+
const n0 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=0'), mockRes());
|
|
1072
|
+
const n0d = JSON.parse(n0.body);
|
|
1073
|
+
check('notify queues approval pair (dedup by type:id, not id)',
|
|
1074
|
+
n0.head.code === 200 && n0d.items.length === 2 && n0d.latest === 2, n0.body);
|
|
1075
|
+
const first = n0d.items[0];
|
|
1076
|
+
check('notify item carries kind+toolName+reason',
|
|
1077
|
+
first && first.kind === 'approval' && first.toolName === 'bash' && first.reason === '需要运行一条命令', JSON.stringify(first));
|
|
1078
|
+
const second = n0d.items[1];
|
|
1079
|
+
check('notify approval-decided maps allowed-once -> granted',
|
|
1080
|
+
second && second.kind === 'approval-decided' && second.outcome === 'granted', JSON.stringify(second));
|
|
1081
|
+
|
|
1082
|
+
const n2 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=2'), mockRes());
|
|
1083
|
+
check('notify incremental cursor returns nothing new', JSON.parse(n2.body).items.length === 0, n2.body);
|
|
1084
|
+
const n1 = await call(notifyRoute, mockReq('/dsh-tts-api/notify?s=1'), mockRes());
|
|
1085
|
+
const n1d = JSON.parse(n1.body);
|
|
1086
|
+
check('notify since=1 returns only later item', n1d.items.length === 1 && n1d.items[0].kind === 'approval-decided', n1.body);
|
|
1087
|
+
|
|
1088
|
+
// pure ingest guards: null / missing id / unknown types are ignored; the
|
|
1089
|
+
// nested data shape is the real contract (top-level is the flat fallback)
|
|
1090
|
+
const ing = plugin.__test && plugin.__test.ingestSessionEvent;
|
|
1091
|
+
const snap = () => (plugin.__test && plugin.__test.notify()) || {};
|
|
1092
|
+
check('__test.ingestSessionEvent exposed', typeof ing === 'function');
|
|
1093
|
+
if (typeof ing === 'function') {
|
|
1094
|
+
ing(null);
|
|
1095
|
+
ing('junk');
|
|
1096
|
+
ing({ type: 'approval/asked', data: {} }); // no id -> ignored
|
|
1097
|
+
ing({ type: 'approval/asked', id: 'ap-3' }); // flat fallback -> valid
|
|
1098
|
+
ing({ type: 'approval/decided', data: { id: 'ap-4', outcome: 'cancelled' } }); // -> settled
|
|
1099
|
+
const s = snap();
|
|
1100
|
+
check('ingestSessionEvent guards empty ids + flat fallback', s.queued === 4 && s.seq === 4, JSON.stringify(s));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const failed = results.filter((r) => !r.ok);
|
|
1106
|
+
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
|
1107
|
+
process.exit(failed.length === 0 ? 0 : 1);
|