@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.
Files changed (37) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +51 -51
  2. package/.github/ISSUE_TEMPLATE/config.yml +2 -2
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -28
  4. package/.github/pull_request_template.md +7 -7
  5. package/.github/workflows/contrib-triage.yml +66 -66
  6. package/.github/workflows/linked-issue.yml +21 -21
  7. package/.github/workflows/publish.yml +63 -0
  8. package/.github/workflows/stale.yml +30 -30
  9. package/.github/workflows/test.yml +33 -33
  10. package/.github/workflows/welcome.yml +22 -22
  11. package/ANALYSIS.md +205 -205
  12. package/PR-REVIEW.md +74 -74
  13. package/README.md +335 -335
  14. package/TODO.md +34 -34
  15. package/docs/HOST-CONTRACT.md +36 -36
  16. package/docs/RVC-GUIDE.md +390 -390
  17. package/docs/USER-GUIDE.md +389 -389
  18. package/docs/acceptance-checklist.md +147 -147
  19. package/docs/adaptive-chunked-playback.md +194 -194
  20. package/docs/macos-test-prompt.md +230 -230
  21. package/docs/windows-gpu-verify-prompt.md +151 -151
  22. package/lib/client.js +7164 -7164
  23. package/lib/index.mjs +3374 -3374
  24. package/package.json +51 -51
  25. package/rvc-server.py +564 -564
  26. package/tests/README.md +45 -45
  27. package/tests/client-load.mjs +406 -406
  28. package/tests/e2e-compact-index.mjs +86 -86
  29. package/tests/e2e-index-ab.mjs +138 -138
  30. package/tests/e2e-real-rvc.mjs +122 -122
  31. package/tests/i18n-keys.mjs +82 -82
  32. package/tests/package-runtime-patch.mjs +88 -88
  33. package/tests/rvc-server-live.mjs +230 -230
  34. package/tests/smoke.mjs +1284 -1284
  35. package/tools/gen-ui-preview.mjs +174 -174
  36. package/tools/mac-verify.sh +158 -158
  37. 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
+ }