@memef1f1y/dsh-plugin-tts 0.6.5 → 0.6.7
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/ISSUE_TEMPLATE/bug_report.yml +51 -51
- package/.github/ISSUE_TEMPLATE/config.yml +2 -2
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -28
- package/.github/pull_request_template.md +7 -7
- package/.github/workflows/contrib-triage.yml +66 -66
- package/.github/workflows/linked-issue.yml +21 -21
- package/.github/workflows/publish.yml +63 -0
- package/.github/workflows/stale.yml +30 -30
- package/.github/workflows/test.yml +33 -33
- package/.github/workflows/welcome.yml +22 -22
- package/ANALYSIS.md +205 -205
- package/PR-REVIEW.md +74 -74
- package/README.md +335 -335
- package/TODO.md +34 -34
- package/docs/HOST-CONTRACT.md +36 -36
- package/docs/RVC-GUIDE.md +390 -390
- package/docs/USER-GUIDE.md +389 -389
- package/docs/acceptance-checklist.md +147 -147
- package/docs/adaptive-chunked-playback.md +194 -194
- package/docs/macos-test-prompt.md +230 -230
- package/docs/windows-gpu-verify-prompt.md +151 -151
- package/lib/client.js +7164 -7164
- package/lib/index.mjs +3374 -3374
- package/package.json +51 -51
- package/rvc-server.py +564 -564
- package/tests/README.md +45 -45
- package/tests/client-load.mjs +406 -406
- package/tests/e2e-compact-index.mjs +86 -86
- package/tests/e2e-index-ab.mjs +138 -138
- package/tests/e2e-real-rvc.mjs +122 -122
- package/tests/i18n-keys.mjs +82 -82
- package/tests/package-runtime-patch.mjs +88 -88
- package/tests/rvc-server-live.mjs +230 -230
- package/tests/smoke.mjs +1284 -1284
- package/tools/gen-ui-preview.mjs +174 -174
- package/tools/mac-verify.sh +158 -158
- package/tools/package-runtime.py +214 -214
|
@@ -1,230 +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
|
-
}
|
|
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
|
+
}
|