@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
@@ -0,0 +1,122 @@
1
+ // One-off real end-to-end: plugin Host -> real rvc-server (azusa-test) chain.
2
+ //
3
+ // [DEPRECATED in favor of tests/rvc-server-live.mjs] This one-off test hardcodes
4
+ // Windows paths (E:/AI/.../azusa-test.pth) and is not portable. The standard,
5
+ // environment-aware live scenario + boundary suite is `tests/rvc-server-live.mjs`
6
+ // (npm run test:live). This file is kept for Windows reference only.
7
+
8
+ // Verifies Edge base synth + real RVC conversion + audio route serving.
9
+ import * as plugin from '../lib/index.mjs';
10
+
11
+ const routes = [];
12
+ const ctx = {
13
+ get(name) {
14
+ if (name === 'webServer') {
15
+ return {
16
+ register(route) {
17
+ routes.push(route);
18
+ return () => {
19
+ const i = routes.indexOf(route);
20
+ if (i >= 0) routes.splice(i, 1);
21
+ };
22
+ }
23
+ };
24
+ }
25
+ return undefined;
26
+ },
27
+ effect() {}
28
+ };
29
+ plugin.apply(ctx);
30
+
31
+ const speakRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/speak');
32
+ const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tts-audio');
33
+
34
+ function mockReq(url, body) {
35
+ const chunks = body === undefined ? [] : [body];
36
+ return {
37
+ url,
38
+ [Symbol.asyncIterator]() {
39
+ let i = 0;
40
+ return {
41
+ next: () => (i < chunks.length ? Promise.resolve({ value: chunks[i++], done: false }) : Promise.resolve({ done: true }))
42
+ };
43
+ }
44
+ };
45
+ }
46
+ function mockRes() {
47
+ return { headersSent: false, head: null, body: null, writeHead(c, h) { this.head = { c, h }; }, end(b) { this.body = b; } };
48
+ }
49
+
50
+ const RVC_DIR = 'E:/AI/RVC20240604Nvidia/RVC20240604Nvidia';
51
+ const custom = {
52
+ baseUrl: 'http://127.0.0.1:4892',
53
+ model: `${RVC_DIR}/assets/weights/azusa-test.pth`,
54
+ index: `${RVC_DIR}/assets/indices/azusa-test_IVF3317_Flat_nprobe_1_azusa-test_v2.index`,
55
+ baseVoice: 'zh-CN-YunyangNeural',
56
+ f0Method: 'rmvpe',
57
+ indexRate: 0.75
58
+ };
59
+
60
+ const t0 = Date.now();
61
+ const res = mockRes();
62
+ try {
63
+ await speakRoute.handler(
64
+ mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '这是通过插件链路转换出的 azusa 音色,用来验证端到端流程。', provider: 'rvc', custom })),
65
+ res
66
+ );
67
+ } catch (e) {
68
+ console.error('speak handler threw:', e);
69
+ process.exit(1);
70
+ }
71
+ console.log('res.head:', JSON.stringify(res.head));
72
+ console.log('res.body type:', typeof res.body, res.body === null ? 'null' : String(res.body).slice(0, 200));
73
+ if (!res.body) process.exit(1);
74
+ const parsed = JSON.parse(res.body);
75
+ console.log(`speak -> ${res.head.c} ${parsed.url ?? parsed.error} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
76
+ if (!parsed.url) process.exit(1);
77
+
78
+ const ares = mockRes();
79
+ await audioRoute.handler(mockReq(parsed.url), ares);
80
+ const bytes = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
81
+ console.log(`audio -> ${ares.head.c} ${bytes.length} bytes, head=${bytes.slice(0, 4).toString()}`);
82
+ if (!(bytes.length > 1000 && bytes.slice(0, 4).toString() === 'RIFF')) process.exit(1);
83
+
84
+ // ---- adaptive chunked progressive playback (long text) ----
85
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
86
+ if (!nextRoute) {
87
+ console.error('rvc-next route missing');
88
+ process.exit(1);
89
+ }
90
+ const longText = '这是一段用来验证自适应分块渐进播放的长文本,每一块都会先由 Edge 合成,再交给本地 RVC 服务转换,播放的同时后台继续合成后面的段落,从而做到长文朗读不卡顿。'.repeat(3);
91
+ const t1 = Date.now();
92
+ const longRes = mockRes();
93
+ await speakRoute.handler(
94
+ mockReq('/dsh-tts-api/speak', JSON.stringify({ text: longText, provider: 'rvc', custom })),
95
+ longRes
96
+ );
97
+ const longParsed = JSON.parse(longRes.body);
98
+ console.log(`long speak -> ${longRes.head.c} jobId=${longParsed.jobId ?? ''} chunks=${longParsed.chunks?.length ?? 0} total=${longParsed.total ?? 0} ratio=${longParsed.ratio ?? 'n/a'} chunkSec=${longParsed.chunkSec ?? 'n/a'} (${((Date.now() - t1) / 1000).toFixed(1)}s)`);
99
+ if (!longParsed.jobId || !Array.isArray(longParsed.chunks) || longParsed.chunks.length < 2) process.exit(1);
100
+ let fetched = longParsed.chunks.length;
101
+ let more = true;
102
+ const chunkStarts = [];
103
+ while (more) {
104
+ const nr = mockRes();
105
+ await nextRoute.handler(mockReq(`/dsh-tts-api/rvc-next?job=${longParsed.jobId}`), nr);
106
+ const np = JSON.parse(nr.body);
107
+ if (np && np.url) {
108
+ fetched++;
109
+ chunkStarts.push(np.url);
110
+ }
111
+ more = !!(np && np.more);
112
+ }
113
+ console.log(`rvc-next drained: fetched=${fetched} total=${longParsed.total} (${((Date.now() - t1) / 1000).toFixed(1)}s)`);
114
+ if (fetched !== longParsed.total) process.exit(1);
115
+ // verify one late chunk serves wav
116
+ const lastRes = mockRes();
117
+ await audioRoute.handler(mockReq(chunkStarts[chunkStarts.length - 1]), lastRes);
118
+ const lastBytes = Buffer.isBuffer(lastRes.body) ? lastRes.body : Buffer.from(lastRes.body ?? '');
119
+ console.log(`late chunk audio -> ${lastRes.head.c} ${lastBytes.length} bytes, head=${lastBytes.slice(0, 4).toString()}`);
120
+ if (!(lastBytes.length > 1000 && lastBytes.slice(0, 4).toString() === 'RIFF')) process.exit(1);
121
+
122
+ process.exit(0);
@@ -0,0 +1,82 @@
1
+ // i18n regression: every t("key") used in lib/client.js must exist in both zh
2
+ // and en dictionaries; both dictionaries must be identical in key sets; and
3
+ // no CJK UI literal may remain outside the dictionary.
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import path from 'node:path';
7
+
8
+ const client = readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js'), 'utf8');
9
+ const results = [];
10
+ function check(name, ok, detail) { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
11
+
12
+ function extractDict(lang) {
13
+ const lineMarker = ` ${lang}: {`;
14
+ const start = client.indexOf(lineMarker);
15
+ if (start < 0) throw new Error('no dict.' + lang);
16
+ const bodyStart = client.indexOf('{', start);
17
+ let i = bodyStart, depth = 0;
18
+ for (; i < client.length; i++) {
19
+ if (client[i] === '{') depth++;
20
+ else if (client[i] === '}') { depth--; if (depth === 0) break; }
21
+ }
22
+ const block = client.slice(bodyStart, i + 1);
23
+ const re = /"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/g;
24
+ const out = {};
25
+ let m;
26
+ while ((m = re.exec(block))) out[m[1]] = m[2];
27
+ return out;
28
+ }
29
+ let zh, en, zhKeys, enKeys;
30
+ try {
31
+ zh = extractDict('zh'); en = extractDict('en');
32
+ zhKeys = new Set(Object.keys(zh)); enKeys = new Set(Object.keys(en));
33
+ check('i18n dictionaries exist & non-empty', zhKeys.size > 0 && enKeys.size > 0, `zh=${zhKeys.size} en=${enKeys.size}`);
34
+ const missingEn = [...zhKeys].filter(k => !enKeys.has(k));
35
+ const missingZh = [...enKeys].filter(k => !zhKeys.has(k));
36
+ check('zh and en have identical key sets', missingEn.length === 0 && missingZh.length === 0,
37
+ missingEn.length ? 'en missing: ' + missingEn.join(',') : (missingZh.length ? 'zh missing: ' + missingZh.join(',') : undefined));
38
+ } catch (e) {
39
+ check('i18n dictionaries parse', false, String(e.message));
40
+ zhKeys = new Set(); enKeys = new Set();
41
+ }
42
+
43
+ // every real t("key") usage (word-boundary t call) must be defined in both
44
+ const used = new Set();
45
+ const tRe = /(^|[^A-Za-z0-9_$.])t\(\s*"([^"]+)"\s*(?:\)|,)/g;
46
+ let mm;
47
+ while ((mm = tRe.exec(client))) used.add(mm[2]);
48
+ const missingDef = [...used].filter(k => !zhKeys.has(k) || !enKeys.has(k));
49
+ check('all t("key") usages have zh+en definitions', missingDef.length === 0, missingDef.length ? 'missing: ' + missingDef.join(',') : `${used.size} keys used`);
50
+
51
+ // no CJK literal remains outside the dictionary (UI fully localized)
52
+ const i18nEnd = client.lastIndexOf('// ------------------------------------------------------------ /i18n');
53
+ const render = client.slice(i18nEnd + 1);
54
+ const cjkRe = /"[^"\n]*[\u4e00-\u9fff][^"\n]*"/g;
55
+ const cjk = [];
56
+ let c;
57
+ while ((c = cjkRe.exec(render))) {
58
+ const lineStart = render.lastIndexOf('\n', c.index) + 1;
59
+ const line = render.slice(lineStart, c.index);
60
+ if (!line.trim().startsWith('//')) cjk.push(c[0]);
61
+ }
62
+ check('no CJK UI literal left outside dictionary', cjk.length === 0, cjk.length ? cjk.slice(0, 10).join(' | ') : undefined);
63
+
64
+ // dictionary has no dead keys (excluding voice.*/baseVoice.* data labels and lang labels used only in the switcher)
65
+ // host.* keys are resolved DYNAMICALLY (hostErrText(t(i18n.code)) and
66
+ // t("host.phase." + phaseKey)) so they won't appear as literal t("host.x")
67
+ // calls; they are part of the host<->client i18n contract. Exclude them here.
68
+ // cloud.usage.* tier keys are likewise dynamic (t("cloud.usage." + tier)).
69
+ const dead = [...zhKeys].filter(k =>
70
+ used.has(k) ? false
71
+ : /^voice\.|^baseVoice\.|^host\.|^cloud\.usage\.(standard|wavenet|neural2|chirp3)$/.test(k) ? false
72
+ : true);
73
+ check('dictionary has no dead keys (excluding voice/baseVoice/host data)', dead.length === 0, dead.length ? 'dead: ' + dead.join(',') : undefined);
74
+ // host.* keys form a complete bi-dictionary set (already covered by the
75
+ // identical-key-sets check); also ensure the handful the client references
76
+ // statically are present.
77
+ const hostStatic = ["host.phase.model", "host.phase.index", "host.phase.prepare"];
78
+ check('host.* phase keys present in both langs', hostStatic.every(k => zhKeys.has(k) && enKeys.has(k)));
79
+
80
+ const failed = results.filter(r => !r.ok);
81
+ console.log(`\n${results.length - failed.length}/${results.length} i18n checks passed`);
82
+ process.exit(failed.length === 0 ? 0 : 1);
@@ -0,0 +1,50 @@
1
+ // Mock voice-pack registry — a tiny static file server for local dev/testing.
2
+ // Serves whatever is in <dir> (manifest.json + pack files), so you can build a
3
+ // local voice-pack repo and point the plugin's 音色包 registry URL at it.
4
+ // node tests/mock-registry.mjs <dir> [port]
5
+ // Also exports startMockRegistry(dir, port=0, delayMs=0) for use from tests.
6
+ import { createServer } from 'node:http';
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export function startMockRegistry(dir, port = 0, delayMs = 0) {
11
+ const root = path.resolve(dir);
12
+ const server = createServer((req, res) => {
13
+ const url = new URL(req.url, 'http://x');
14
+ let rel = decodeURIComponent(url.pathname).replace(/^\/+/, '');
15
+ if (!rel) rel = 'manifest.json';
16
+ const file = path.join(root, rel);
17
+ if (!file.startsWith(root) || !existsSync(file)) {
18
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
19
+ res.end('not found');
20
+ return;
21
+ }
22
+ const bytes = readFileSync(file);
23
+ const type = file.endsWith('.json')
24
+ ? 'application/json; charset=utf-8'
25
+ : 'application/octet-stream';
26
+ const send = () => {
27
+ res.writeHead(200, {
28
+ 'Content-Type': type,
29
+ 'Content-Length': bytes.length,
30
+ 'Access-Control-Allow-Origin': '*'
31
+ });
32
+ res.end(bytes);
33
+ };
34
+ if (delayMs > 0) setTimeout(send, delayMs);
35
+ else send();
36
+ });
37
+ return new Promise(resolve => {
38
+ server.listen(port, '127.0.0.1', () =>
39
+ resolve({ server, port: server.address().port, base: `http://127.0.0.1:${server.address().port}` }),
40
+ );
41
+ });
42
+ }
43
+
44
+ // CLI: serve a directory so you can test the plugin UI against a local repo.
45
+ if (process.argv[1] && process.argv[1].endsWith('mock-registry.mjs')) {
46
+ const dir = process.argv[2] || process.cwd();
47
+ const port = Number(process.argv[3] || 8899);
48
+ const { base } = await startMockRegistry(dir, port);
49
+ console.log(`mock registry serving ${dir}\n manifest: ${base}/manifest.json`);
50
+ }
@@ -0,0 +1,88 @@
1
+ // Boundary test for tools/package-runtime.py's macOS-only PyAV patch helper
2
+ // (patch_infer_for_pyav): it must rewrite av.open("rb"/"wb") -> "r"/"w" in the
3
+ // packaged infer/lib/audio.py on darwin, but leave a normal Python `open(
4
+ // file, "rb")` (a real file open) untouched. Verified end-to-end too by
5
+ // node tests/rvc-server-live.mjs (live /convert)
6
+ // Run: node tests/package-runtime-patch.mjs
7
+ import { execFileSync } from 'node:child_process';
8
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const results = [];
14
+ function check(name, ok, detail) {
15
+ results.push({ name, ok, detail });
16
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
17
+ }
18
+
19
+ const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const pkg = path.join(repo, 'tools', 'package-runtime.py');
21
+ // Run the driver with python3 when available, else fall back to `python` and
22
+ // then to the Windows `py -3` launcher. Windows installs often only ship
23
+ // `python`, and a bare `python`/`python3` may be a dead Microsoft Store stub
24
+ // that exits 9009, so the `py` launcher is the reliable last resort.
25
+ function runPython(driverPath) {
26
+ const tries = [
27
+ ['python3', []],
28
+ ['python', []],
29
+ ['py', ['-3']]
30
+ ];
31
+ for (const [cmd, pre] of tries) {
32
+ try {
33
+ return execFileSync(cmd, [...pre, driverPath], { encoding: 'utf8' });
34
+ } catch (e) {
35
+ if (cmd === 'py') throw e; // give up after the last fallback
36
+ }
37
+ }
38
+ }
39
+ // sample = the unpatched (as-released) audio.py
40
+ const sample = [
41
+ 'import av',
42
+ 'import numpy as np',
43
+ 'def wav2(i, o, format):',
44
+ ' inp = av.open(i, "rb")',
45
+ ' out = av.open(o, "wb", format=format)',
46
+ ' return inp, out',
47
+ 'def audio2(i, o, format, sr):',
48
+ ' inp = av.open(i, "rb")',
49
+ ' out = av.open(o, "wb", format=format)',
50
+ ' return inp, out',
51
+ 'def load_audio(file, sr):',
52
+ ' with open(file, "rb") as f:',
53
+ ' pass',
54
+ ''
55
+ ].join('\n');
56
+
57
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'dsh-tts-pyav-'));
58
+ try {
59
+ const libDir = path.join(dir, 'infer', 'lib');
60
+ mkdirSync(libDir, { recursive: true });
61
+ const audioPath = path.join(libDir, 'audio.py');
62
+ writeFileSync(audioPath, sample);
63
+
64
+ const driver = path.join(dir, 'driver.py');
65
+ writeFileSync(driver, `
66
+ import importlib.util
67
+ spec = importlib.util.spec_from_file_location("prt", ${JSON.stringify(pkg)})
68
+ m = importlib.util.module_from_spec(spec)
69
+ spec.loader.exec_module(m)
70
+ m.patch_infer_for_pyav(${JSON.stringify(path.join(dir, 'infer'))})
71
+ `);
72
+ runPython(driver);
73
+
74
+ const out = readFileSync(audioPath, 'utf8');
75
+ check('darwin patch replaces av.open rb/wb', !/av\.open\([^)]*"(rb|wb)"/.test(out), out.split('\n').filter(l => l.includes('av.open')).join(' | '));
76
+ check('darwin patch keeps Python open(file,"rb")', out.includes('with open(file, "rb") as f:'));
77
+ check('darwin patch converts to r/w', out.includes('av.open(i, "r")') && out.includes('av.open(o, "w", format=format)'));
78
+
79
+ // idempotency: running twice changes nothing
80
+ runPython(driver);
81
+ check('patch idempotent (second run unchanged)', readFileSync(audioPath, 'utf8') === out);
82
+ } finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+
86
+ const failed = results.filter(r => !r.ok);
87
+ console.log(`\n${results.length - failed.length}/${results.length} patch checks passed`);
88
+ process.exit(failed.length === 0 ? 0 : 1);
@@ -0,0 +1,230 @@
1
+ // Standard live scenario + boundary suite for the local RVC inference server
2
+ // (rvc-server.py). Unlike the Windows-hardcoded one-off e2e tests, this is
3
+ // environment-aware:
4
+ // * uses RVC_URL (default http://127.0.0.1:4892) if a server is already up;
5
+ // * otherwise tries to spawn one from $RVC_WORK (default ~/rvc-work, the
6
+ // layout the macOS test builds), using venv/bin/python;
7
+ // * if neither is available it prints SKIP and exits 0 (safe in CI).
8
+ //
9
+ // node tests/rvc-server-live.mjs
10
+ // RVC_URL=... node tests/rvc-server-live.mjs
11
+ //
12
+ // Covers: real end-to-end scenario (health/files/load/convert/compact-index)
13
+ // plus protocol boundary cases (malformed payloads, missing model/index,
14
+ // convert-before-load, empty/tiny/invalid base64, bad f0_method, bad
15
+ // compact-index, unknown route, concurrency lock).
16
+ import { spawn } from 'node:child_process';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { existsSync } from 'node:fs';
20
+
21
+ const BASE = process.env.RVC_URL || 'http://127.0.0.1:4892';
22
+ const WORK = process.env.RVC_WORK || path.join(os.homedir(), 'rvc-work');
23
+ const MODEL = path.join(WORK, 'assets', 'weights', 'model.pth');
24
+ const INDEX = path.join(WORK, 'assets', 'weights', 'guanguanV1.index');
25
+ const PY = path.join(WORK, 'venv', 'bin', 'python');
26
+ const SERVER = path.join(WORK, 'rvc-server.py');
27
+
28
+ const results = [];
29
+ const skipped = [];
30
+ function check(name, ok, detail) {
31
+ results.push({ name, ok, detail });
32
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
33
+ }
34
+ function skip(name, reason) {
35
+ skipped.push(name);
36
+ console.log(`SKIP ${name} — ${reason}`);
37
+ }
38
+
39
+ async function ping(url, timeoutMs = 3000) {
40
+ const ctrl = new AbortController();
41
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
42
+ try {
43
+ const r = await fetch(url + '/health', { signal: ctrl.signal });
44
+ clearTimeout(t);
45
+ return r.ok;
46
+ } catch { clearTimeout(t); return false; }
47
+ }
48
+
49
+ async function http(method, route, body, timeoutMs = 300000) {
50
+ const ctrl = new AbortController();
51
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
52
+ const opt = { method, signal: ctrl.signal, headers: {} };
53
+ if (body !== undefined) {
54
+ opt.headers['Content-Type'] = 'application/json';
55
+ opt.body = typeof body === 'string' ? body : JSON.stringify(body);
56
+ }
57
+ try {
58
+ const r = await fetch(BASE + route, opt);
59
+ clearTimeout(t);
60
+ const text = await r.text();
61
+ let data = null;
62
+ try { data = JSON.parse(text); } catch {}
63
+ return { status: r.status, data, text };
64
+ } catch (e) {
65
+ clearTimeout(t);
66
+ return { status: 0, data: null, text: String(e) };
67
+ }
68
+ }
69
+
70
+ // --- availability detection + optional spawn ---
71
+ let serverProc = null;
72
+ let live = await ping(BASE, 2000);
73
+ if (!live && existsSync(PY) && existsSync(SERVER)) {
74
+ console.log(`[live] no server on ${BASE}; spawning from ${WORK}`);
75
+ serverProc = spawn(PY, [SERVER, '--port', new URL(BASE).port], { stdio: 'ignore' });
76
+ // wait up to ~20s for /health
77
+ for (let i = 0; i < 40; i++) {
78
+ await new Promise(r => setTimeout(r, 500));
79
+ if (await ping(BASE, 1000)) { live = true; break; }
80
+ }
81
+ }
82
+ if (!live) {
83
+ console.log('SKIP live+rvc-server unavailable — start rvc-server.py or set RVC_URL/RVC_WORK');
84
+ process.exit(0);
85
+ }
86
+ console.log(`[live] using ${BASE} (work=${existsSync(WORK) ? WORK : 'n/a'})`);
87
+
88
+ try {
89
+ // ---------- SCENARIO: happy path ----------
90
+ {
91
+ const h = await http('GET', '/health');
92
+ // Accept either a CPU or a CUDA device: the health payload must be
93
+ // self-consistent (cuda_available matches the reported device), so this
94
+ // works both on CI (cpu) and on a Windows/macOS GPU regression host (cuda).
95
+ const dev = h.data && h.data.device;
96
+ const cuda = h.data && h.data.cuda_available;
97
+ const consistent = (dev === 'cpu' && cuda === false) || (typeof dev === 'string' && dev.startsWith('cuda') && cuda === true);
98
+ check('scenario /health ok + consistent device', h.status === 200 && h.data.ok === true && consistent,
99
+ `device=${dev} cuda=${cuda}`);
100
+
101
+ const f = await http('GET', '/files?kind=pth');
102
+ check('scenario /files lists pth', f.status === 200 && Array.isArray(f.data && f.data.files), f.text.slice(0, 120));
103
+
104
+ const modelExists = existsSync(MODEL);
105
+ const indexExists = existsSync(INDEX);
106
+ if (!(modelExists && indexExists)) {
107
+ skip('scenario /load + /convert + /compact-index', 'model/index not in ' + WORK);
108
+ } else {
109
+ const ld = await http('POST', '/load', { model: MODEL, index: INDEX });
110
+ check('scenario /load ok', ld.status === 200 && ld.data.ok === true, ld.text.slice(0, 100));
111
+
112
+ const wav = makeWav(1.0, 44100); // ~1s sawtooth speech-like
113
+ const cv = await http('POST', '/convert', {
114
+ audio_base64: wav.toString('base64'),
115
+ params: { f0_method: 'rmvpe', index_rate: 0.75 }
116
+ }, 600000);
117
+ const cvOk = cv.status === 200 && cv.data && typeof cv.data.audio_base64 === 'string'
118
+ && Number.isFinite(cv.data.sample_rate) && cv.data.sample_rate > 0;
119
+ check('scenario /convert returns wav', cvOk, `status=${cv.status} sr=${cv.data && cv.data.sample_rate}`);
120
+ if (cvOk) {
121
+ const out = Buffer.from(cv.data.audio_base64, 'base64');
122
+ check('scenario /convert output is RIFF wav', out.slice(0, 4).toString() === 'RIFF' && out.length > 44,
123
+ `${out.length} bytes`);
124
+ }
125
+
126
+ const ci = await http('POST', '/compact-index', { index: INDEX, target_vectors: 500 }, 600000);
127
+ check('scenario /compact-index ok', ci.status === 200 && ci.data.ok === true
128
+ && typeof ci.data.reduction_pct === 'number', ci.text.slice(0, 120));
129
+ }
130
+ }
131
+
132
+ // ---------- BOUNDARY: protocol edges ----------
133
+ {
134
+ // load with missing model path
135
+ const r1 = await http('POST', '/load', { model: path.join(WORK, 'nope.pth') }, 30000);
136
+ check('boundary /load missing model -> 4xx', r1.status >= 400 && r1.status < 500, `status=${r1.status} ${r1.text.slice(0, 80)}`);
137
+
138
+ // load with missing index path (model valid)
139
+ if (existsSync(MODEL)) {
140
+ const r2 = await http('POST', '/load', { model: MODEL, index: path.join(WORK, 'nope.index') }, 60000);
141
+ check('boundary /load missing index -> 4xx', r2.status >= 400 && r2.status < 500, `status=${r2.status} ${r2.text.slice(0, 80)}`);
142
+ }
143
+
144
+ // malformed JSON body
145
+ const r3 = await http('POST', '/load', '{not json', 30000);
146
+ check('boundary /load malformed json -> 4xx', r3.status >= 400, `status=${r3.status} ${r3.text.slice(0, 80)}`);
147
+
148
+ // convert before load (if we could not load, this still applies when nothing loaded)
149
+ const r4 = await http('POST', '/convert', { audio_base64: makeWav(0.2, 44100).toString('base64'), params: {} }, 30000);
150
+ // 200 if a model happens to be loaded from the happy path, else 4xx — either is acceptable,
151
+ // but a 500 crash is not.
152
+ check('boundary /convert never 5xx-crashes pre/post load', r4.status !== 500, `status=${r4.status} ${r4.text.slice(0, 80)}`);
153
+
154
+ // convert with empty audio_base64
155
+ const r5 = await http('POST', '/convert', { audio_base64: '', params: {} }, 30000);
156
+ check('boundary /convert empty base64 -> 4xx (not 500)', r5.status !== 500 && r5.status !== 0, `status=${r5.status}`);
157
+
158
+ // convert with invalid base64
159
+ const r6 = await http('POST', '/convert', { audio_base64: '!!!not-base64!!!', params: {} }, 30000);
160
+ check('boundary /convert bad base64 -> 4xx (not 500)', r6.status !== 500 && r6.status !== 0, `status=${r6.status}`);
161
+
162
+ // convert with a zero-length (empty) wav
163
+ const empty = Buffer.alloc(44); // 44-byte header, no data
164
+ empty.write('RIFF', 0); empty.write('WAVE', 8); empty.write('fmt ', 12);
165
+ const r7 = await http('POST', '/convert', { audio_base64: empty.toString('base64'), params: {} }, 30000);
166
+ check('boundary /convert empty wav -> 4xx (not 500)', r7.status !== 500 && r7.status !== 0, `status=${r7.status} ${r7.text.slice(0, 80)}`);
167
+
168
+ // convert with a valid wav but unknown f0_method (should not crash)
169
+ const r8 = await http('POST', '/convert', { audio_base64: makeWav(0.3, 44100).toString('base64'), params: { f0_method: 'does-not-exist' } }, 60000);
170
+ check('boundary /convert unknown f0_method -> 4xx (not 500)', r8.status !== 500, `status=${r8.status} ${r8.text.slice(0, 80)}`);
171
+
172
+ // compact-index with missing index
173
+ const r9 = await http('POST', '/compact-index', { index: path.join(WORK, 'nope.index') }, 30000);
174
+ check('boundary /compact-index missing index -> 4xx', r9.status >= 400 && r9.status < 500, `status=${r9.status} ${r9.text.slice(0, 80)}`);
175
+
176
+ // compact-index with empty index
177
+ const r10 = await http('POST', '/compact-index', { index: '' }, 30000);
178
+ check('boundary /compact-index empty index -> 4xx', r10.status >= 400, `status=${r10.status} ${r10.text.slice(0, 80)}`);
179
+
180
+ // unknown route -> 404
181
+ const r11 = await http('GET', '/does-not-exist');
182
+ check('boundary unknown route -> 404', r11.status === 404, `status=${r11.status}`);
183
+ }
184
+
185
+ // ---------- CONCURRENCY: parallel converts must not crash ----------
186
+ if (existsSync(MODEL)) {
187
+ await http('POST', '/load', { model: MODEL, index: existsSync(INDEX) ? INDEX : '' }, 60000);
188
+ const wav = makeWav(0.5, 44100).toString('base64');
189
+ const out = await Promise.allSettled([
190
+ http('POST', '/convert', { audio_base64: wav, params: { f0_method: 'rmvpe' } }, 600000),
191
+ http('POST', '/convert', { audio_base64: wav, params: { f0_method: 'rmvpe' } }, 600000),
192
+ http('POST', '/convert', { audio_base64: wav, params: { f0_method: 'pm' } }, 600000),
193
+ ]);
194
+ const statuses = out.map(x => x.status === 'fulfilled' ? x.value.status : -1);
195
+ const anyOk = statuses.some(s => s === 200);
196
+ const no5xx = statuses.every(s => s !== 500 && s !== -1);
197
+ check('boundary 3 concurrent converts no 5xx + at least one ok', anyOk && no5xx, `statuses=${statuses.join(',')}`);
198
+ }
199
+ } finally {
200
+ if (serverProc) serverProc.kill();
201
+ }
202
+
203
+ const failed = results.filter(r => !r.ok);
204
+ console.log(`\n${results.length - failed.length}/${results.length} live checks passed${skipped.length ? `, ${skipped.length} skipped` : ''}`);
205
+ process.exit(failed.length === 0 ? 0 : 1);
206
+
207
+ // --- helper: tiny valid WAV ---
208
+ function makeWav(seconds, sr) {
209
+ const n = Math.floor(sr * seconds);
210
+ const dataSize = n * 2;
211
+ const buf = Buffer.alloc(44 + dataSize);
212
+ buf.write('RIFF', 0);
213
+ buf.writeUInt32LE(36 + dataSize, 4);
214
+ buf.write('WAVE', 8);
215
+ buf.write('fmt ', 12);
216
+ buf.writeUInt32LE(16, 16);
217
+ buf.writeUInt16LE(1, 20);
218
+ buf.writeUInt16LE(1, 22);
219
+ buf.writeUInt32LE(sr, 24);
220
+ buf.writeUInt32LE(sr * 2, 28);
221
+ buf.writeUInt16LE(2, 32);
222
+ buf.writeUInt16LE(16, 34);
223
+ buf.write('data', 36);
224
+ buf.writeUInt32LE(dataSize, 40);
225
+ for (let i = 0; i < n; i++) {
226
+ const v = Math.round(12000 * Math.sin(2 * Math.PI * 220 * i / sr));
227
+ buf.writeInt16LE(v, 44 + i * 2);
228
+ }
229
+ return buf;
230
+ }