@memef1f1y/dsh-plugin-tts 0.6.4 → 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 -23
  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 -3371
  24. package/package.json +51 -52
  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,122 +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);
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);
@@ -1,82 +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);
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);
@@ -1,88 +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);
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);