@memef1f1y/dsh-plugin-tts 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +33 -0
- package/ANALYSIS.md +205 -0
- package/LICENSE +21 -0
- package/PR-REVIEW.md +75 -0
- package/README.md +297 -0
- package/README.zh.md +263 -0
- package/Snipaste_2026-08-16_13-28-10.png +0 -0
- package/Snipaste_2026-08-16_13-28-23.png +0 -0
- package/Snipaste_2026-08-16_13-28-37.png +0 -0
- package/Snipaste_2026-08-16_13-29-01.png +0 -0
- package/Snipaste_2026-08-16_13-29-11.png +0 -0
- package/TODO.md +23 -0
- package/cordis.patch.yml +4 -0
- package/docs/CLOUD-GUIDE.md +216 -0
- package/docs/COSYVOICE-GUIDE.md +216 -0
- package/docs/EDGE-GUIDE.md +166 -0
- package/docs/HOST-CONTRACT.md +36 -0
- package/docs/INDEX-TTS2-GUIDE.md +208 -0
- package/docs/RVC-GUIDE.md +390 -0
- package/docs/USER-GUIDE.md +334 -0
- package/docs/acceptance-checklist.md +148 -0
- package/docs/adaptive-chunked-playback.md +194 -0
- package/docs/macos-test-prompt.md +230 -0
- package/docs/windows-gpu-verify-prompt.md +151 -0
- package/lib/client.js +7036 -0
- package/lib/index.mjs +3288 -0
- package/logo.png +0 -0
- package/package.json +52 -0
- package/pnpm-workspace.yaml +7 -0
- package/rvc-server.py +564 -0
- package/tests/README.md +45 -0
- package/tests/client-load.mjs +393 -0
- package/tests/e2e-compact-index.mjs +86 -0
- package/tests/e2e-index-ab.mjs +138 -0
- package/tests/e2e-real-rvc.mjs +122 -0
- package/tests/i18n-keys.mjs +82 -0
- package/tests/mock-registry.mjs +50 -0
- package/tests/package-runtime-patch.mjs +88 -0
- package/tests/rvc-server-live.mjs +230 -0
- package/tests/smoke.mjs +1107 -0
- package/tools/gen-ui-preview.mjs +174 -0
- package/tools/mac-verify.sh +158 -0
- package/tools/make-pack.mjs +155 -0
- package/tools/package-runtime.py +214 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Loads lib/client.js in a mocked browser env, runs apply(), then renders each
|
|
2
|
+
// injected component with a minimal React shim to confirm the i18n refactor
|
|
3
|
+
// (t() everywhere + language switcher) does not crash at load/render time.
|
|
4
|
+
// node tests/client-load.mjs
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const results = [];
|
|
10
|
+
function check(name, ok, detail) { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
|
11
|
+
|
|
12
|
+
// ---- minimal React shim (enough for client.js's createElement + hooks) ----
|
|
13
|
+
const states = new Map(); // hook memory keyed by componentId
|
|
14
|
+
function makeHookCtx() {
|
|
15
|
+
let id = Math.random().toString(36).slice(2);
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
const arr = states.get(id) || { mem: [] };
|
|
18
|
+
states.set(id, arr);
|
|
19
|
+
function useState(init) {
|
|
20
|
+
const i = cursor++;
|
|
21
|
+
if (!(i in arr.mem)) arr.mem[i] = typeof init === 'function' ? init() : init;
|
|
22
|
+
const set = v => { arr.mem[i] = typeof v === 'function' ? v(arr.mem[i]) : v; };
|
|
23
|
+
return [arr.mem[i], set];
|
|
24
|
+
}
|
|
25
|
+
const useEffect = (fn, deps) => { cursor += 1; return fn; }; // run nothing on demand
|
|
26
|
+
const useRef = init => ({ current: init });
|
|
27
|
+
const useMemo = (fn) => fn();
|
|
28
|
+
const useStateForce = () => useState(0);
|
|
29
|
+
return { id, useState, useEffect, useRef, useMemo, reset() { cursor = 0; } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const react = {
|
|
33
|
+
createElement(type, props, ...children) {
|
|
34
|
+
// normalize children
|
|
35
|
+
if (type === react.Fragment) return { type, props: props || {}, children };
|
|
36
|
+
return { type, props: props || {}, children, $$node: true };
|
|
37
|
+
},
|
|
38
|
+
Fragment: Symbol('Fragment'),
|
|
39
|
+
useState: () => { throw new Error('useState outside shim'); },
|
|
40
|
+
useEffect: () => {},
|
|
41
|
+
useRef: () => ({ current: undefined }),
|
|
42
|
+
useMemo: f => f(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---- browser globals ----
|
|
46
|
+
const injectedComponents = [];
|
|
47
|
+
const slots = {
|
|
48
|
+
inject(slot, fn) { injectedComponents.push({ slot, fn }); },
|
|
49
|
+
register(spec, component) { return component; }, // real host returns the registered component
|
|
50
|
+
};
|
|
51
|
+
const ctx = {
|
|
52
|
+
get(name) { if (name === 'slots') return slots; return undefined; },
|
|
53
|
+
effect() {},
|
|
54
|
+
};
|
|
55
|
+
try { Object.defineProperty(globalThis, 'navigator', { value: { language: 'zh-CN' }, configurable: true }); }
|
|
56
|
+
catch (e) { /* already settable */ }
|
|
57
|
+
globalThis.window = {
|
|
58
|
+
addEventListener() {}, removeEventListener() {},
|
|
59
|
+
AudioContext: function(){}, webkitAudioContext: function(){},
|
|
60
|
+
confirm: () => true,
|
|
61
|
+
};
|
|
62
|
+
globalThis.document = {
|
|
63
|
+
createElement: () => ({ style: {}, setAttribute(){}, appendChild(){}, removeChild(){}, pause(){}, removeAttribute(){}, play: () => Promise.resolve(), addEventListener(){}, removeEventListener(){} }),
|
|
64
|
+
body: { appendChild() {}, removeChild() {} },
|
|
65
|
+
head: { appendChild() {} },
|
|
66
|
+
};
|
|
67
|
+
globalThis.Audio = function(){ this.play = () => Promise.resolve(); this.pause=()=>{}; this.setAttribute=()=>{}; this.removeAttribute=()=>{}; };
|
|
68
|
+
// in-memory localStorage so the i18n persistence (loadPersistedLang/setLang) works
|
|
69
|
+
const memStore = new Map();
|
|
70
|
+
globalThis.localStorage = {
|
|
71
|
+
getItem: k => (memStore.has(k) ? memStore.get(k) : null),
|
|
72
|
+
setItem: (k, v) => memStore.set(k, String(v)),
|
|
73
|
+
removeItem: k => memStore.delete(k),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ---- load the bundle ----
|
|
77
|
+
let failed = false;
|
|
78
|
+
const clientSrc = readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js'), 'utf8');
|
|
79
|
+
try {
|
|
80
|
+
globalThis.window.__ModuleLoader__ = {
|
|
81
|
+
load({ id, factory }) {
|
|
82
|
+
// run factory with a require that returns our react shim
|
|
83
|
+
const require_ = name => { if (name === 'react') return react; throw new Error('unknown require: ' + name); };
|
|
84
|
+
const mod = factory(require_);
|
|
85
|
+
if (!mod || !mod.apply) throw new Error('factory did not export apply');
|
|
86
|
+
if (!Array.isArray(mod.inject) || !mod.inject.includes('slots'))
|
|
87
|
+
throw new Error('factory must export inject including "slots" (fiber inject waiting)');
|
|
88
|
+
globalThis.__dshTtsClientMod = mod;
|
|
89
|
+
mod.apply(ctx);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
// evaluate client.js in this context
|
|
93
|
+
const fn = new Function('window', 'navigator', 'document', 'Audio', clientSrc + '\n;return window.__ModuleLoader__;');
|
|
94
|
+
globalThis.__dshTtsClientSrc = clientSrc;
|
|
95
|
+
const ml = fn(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
|
|
96
|
+
check('client.js loads + apply() runs', injectedComponents.length > 0, `${injectedComponents.length} slot(s) injected`);
|
|
97
|
+
check('client bundle exports inject with slots', Array.isArray(globalThis.__dshTtsClientMod?.inject) && globalThis.__dshTtsClientMod.inject.includes('slots'));
|
|
98
|
+
// ---- i18n preference persistence (round-trip) ----
|
|
99
|
+
try {
|
|
100
|
+
const hooks = globalThis.window.__dshTtsI18n;
|
|
101
|
+
check('i18n hook exposed for tests', !!hooks && typeof hooks.setLang === 'function');
|
|
102
|
+
// switch to English -> must persist
|
|
103
|
+
hooks.setLang('en');
|
|
104
|
+
check('setLang("en") persists to localStorage', memStore.get('dsh-tts-lang') === 'en',
|
|
105
|
+
'stored=' + memStore.get('dsh-tts-lang'));
|
|
106
|
+
check('active locale resolves to en', hooks.current() === 'en', 'current()=' + hooks.current());
|
|
107
|
+
// simulate reload by re-invoking the loader factory in a fresh module eval that
|
|
108
|
+
// calls loadPersistedLang() from the same localStorage
|
|
109
|
+
const src2 = globalThis.__dshTtsClientSrc;
|
|
110
|
+
const fn2 = new Function('window', 'navigator', 'document', 'Audio', src2 + '\n;return window.__ModuleLoader__;');
|
|
111
|
+
const ml2 = fn2(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
|
|
112
|
+
const I18N2 = globalThis.window.__dshTtsI18n;
|
|
113
|
+
check('persisted language survives reload (lang=en)', I18N2.lang === 'en', 'lang=' + I18N2.lang);
|
|
114
|
+
check('persisted language resolves to en after reload', I18N2.current() === 'en');
|
|
115
|
+
// reset to auto for isolation
|
|
116
|
+
hooks.setLang('auto');
|
|
117
|
+
} catch (e) {
|
|
118
|
+
check('i18n preference persistence round-trip', false, String(e && e.message || e));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- settings persistence round-trip (voice/auto-read/provider/rvc) ----
|
|
122
|
+
try {
|
|
123
|
+
const S1 = globalThis.window.__dshTtsSettings;
|
|
124
|
+
check('settings hook exposed for tests', !!S1 && typeof S1.get === 'function' && typeof S1.reset === 'function');
|
|
125
|
+
// seed a "user-changed" snapshot into localStorage, then simulate a reload by
|
|
126
|
+
// re-running the factory so loadSettings() applies it
|
|
127
|
+
memStore.set('dsh-tts-settings', JSON.stringify({
|
|
128
|
+
autoRead: true, voice: 'zh-CN-YunyangNeural', provider: 'rvc', rvcAutoFallback: true, rawMarkdown: true,
|
|
129
|
+
notify: { enabled: true, approval: false, approvalResult: true, voice: 'zh-CN-YunxiNeural' },
|
|
130
|
+
rvc: { baseUrl: 'http://127.0.0.1:9999', model: '/x.pth', indexRate: 0.5 },
|
|
131
|
+
}));
|
|
132
|
+
const srcS = globalThis.__dshTtsClientSrc;
|
|
133
|
+
const fnS = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
|
|
134
|
+
const mlS = fnS(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
|
|
135
|
+
const S2 = globalThis.window.__dshTtsSettings;
|
|
136
|
+
const s2 = S2.get();
|
|
137
|
+
check('settings loaded from localStorage', s2.autoRead === true && s2.voice === 'zh-CN-YunyangNeural' && s2.provider === 'rvc', JSON.stringify(s2));
|
|
138
|
+
check('rvcAutoFallback loaded from localStorage', s2.rvcAutoFallback === true, 'rvcAutoFallback=' + s2.rvcAutoFallback);
|
|
139
|
+
check('notify settings loaded from localStorage', s2.notify.enabled === true && s2.notify.approval === false && s2.notify.approvalResult === true && s2.notify.voice === 'zh-CN-YunxiNeural', JSON.stringify(s2.notify));
|
|
140
|
+
check('rvc settings loaded from localStorage', s2.rvc.baseUrl === 'http://127.0.0.1:9999' && s2.rvc.model === '/x.pth' && s2.rvc.indexRate === 0.5, JSON.stringify(s2.rvc));
|
|
141
|
+
check('rawMarkdown loaded from localStorage', s2.rawMarkdown === true, 'rawMarkdown=' + s2.rawMarkdown);
|
|
142
|
+
// layered storage: save() must NOT persist service-class keys to localStorage
|
|
143
|
+
S2.save();
|
|
144
|
+
try {
|
|
145
|
+
const persisted = JSON.parse(memStore.get('dsh-tts-settings'));
|
|
146
|
+
const hasService = persisted && persisted.rvc &&
|
|
147
|
+
('baseUrl' in persisted.rvc || 'model' in persisted.rvc || 'index' in persisted.rvc);
|
|
148
|
+
check('save() strips Host service keys from localStorage', hasService === false, JSON.stringify(persisted.rvc));
|
|
149
|
+
check('save() keeps UI prefs in localStorage', persisted.rawMarkdown === true && persisted.rvc.indexRate === 0.5, JSON.stringify(persisted));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
check('save() strips Host service keys from localStorage', false, String(e && e.message || e));
|
|
152
|
+
}
|
|
153
|
+
// reset: restore defaults + drop stored settings
|
|
154
|
+
S2.reset();
|
|
155
|
+
const r = S2.get();
|
|
156
|
+
check('reset restores defaults', r.autoRead === false && r.voice === 'zh-CN-XiaoxuanNeural' && r.provider === 'edge-tts' && r.rvcAutoFallback === false, JSON.stringify(r));
|
|
157
|
+
check('reset restores notify defaults', r.notify.enabled === false && r.notify.approval === true && r.notify.approvalResult === false && r.notify.voice === 'zh-CN-XiaoxuanNeural', JSON.stringify(r.notify));
|
|
158
|
+
check('reset clears stored settings', !memStore.has('dsh-tts-settings'));
|
|
159
|
+
// corrupt stored JSON must not crash; falls back to defaults
|
|
160
|
+
memStore.set('dsh-tts-settings', '{not json');
|
|
161
|
+
const fnC = new Function('window', 'navigator', 'document', 'Audio', srcS + '\n;return window.__ModuleLoader__;');
|
|
162
|
+
fnC(globalThis.window, globalThis.navigator, globalThis.document, globalThis.Audio);
|
|
163
|
+
const S3 = globalThis.window.__dshTtsSettings;
|
|
164
|
+
check('corrupt stored settings ignored (defaults)', S3.get().voice === 'zh-CN-XiaoxuanNeural');
|
|
165
|
+
memStore.delete('dsh-tts-settings');
|
|
166
|
+
} catch (e) {
|
|
167
|
+
check('settings persistence round-trip', false, String(e && e.stack || e));
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
check('client.js loads + apply() runs', false, String(e && e.stack || e));
|
|
171
|
+
failed = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- render each injected component with the shim to catch t()/render errors ----
|
|
175
|
+
if (!failed) {
|
|
176
|
+
for (const { slot, fn } of injectedComponents) {
|
|
177
|
+
try {
|
|
178
|
+
const Component = fn(); // fn returns a component function
|
|
179
|
+
if (typeof Component !== 'function') { check(`render ${slot}`, false, 'slot fn did not return a component'); continue; }
|
|
180
|
+
const h = makeHookCtx();
|
|
181
|
+
let prev = react;
|
|
182
|
+
// temporarily bind react hooks to the shim's hooks
|
|
183
|
+
const originalReact = globalThis.__reactShim || react;
|
|
184
|
+
// The component was defined against `react` from the factory closure. We can't
|
|
185
|
+
// easily intercept that closure's react, but we CAN mock react's hooks via the
|
|
186
|
+
// module-level `react` object we passed. Since useState threw by default, patch it:
|
|
187
|
+
const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
|
|
188
|
+
react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
|
|
189
|
+
const node = Component({
|
|
190
|
+
useSession: sel => sel({ nodes: [] }),
|
|
191
|
+
messageId: 'm1',
|
|
192
|
+
});
|
|
193
|
+
react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
|
|
194
|
+
// `null` is a legitimate conditional render (e.g. the toast host when no
|
|
195
|
+
// toast is active); only `undefined`/throw counts as broken.
|
|
196
|
+
check(`render ${slot}`, node !== undefined, node === null ? '(conditional null)' : undefined);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
check(`render ${slot}`, false, String(e && e.stack || e).slice(0, 200));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// P2-1: auto-read toggle is now a labeled pill (headphones + dot), distinct
|
|
204
|
+
// from a mic/speaker — verifies the reworked element structure renders.
|
|
205
|
+
{
|
|
206
|
+
const comp = injectedComponents.find(c => c.slot === 'conversation.input.left');
|
|
207
|
+
if (comp) {
|
|
208
|
+
try {
|
|
209
|
+
const h = makeHookCtx();
|
|
210
|
+
const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
|
|
211
|
+
react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
|
|
212
|
+
const node = comp.fn()({});
|
|
213
|
+
react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
|
|
214
|
+
const hasClass = (n, cls) => {
|
|
215
|
+
if (!n) return false;
|
|
216
|
+
if (n.props && n.props.className === cls) return true;
|
|
217
|
+
const ch = n.children;
|
|
218
|
+
if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
|
|
219
|
+
return false;
|
|
220
|
+
};
|
|
221
|
+
check('auto-read rendered as labeled pill', hasClass(node, 'dsh-tts-auto-pill') && hasClass(node, 'dsh-tts-auto-label'), undefined);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
check('auto-read rendered as labeled pill', false, String(e && e.stack || e).slice(0, 200));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Toast: showToast() must render a visible toast node in shell.overlay with the
|
|
229
|
+
// message; dismissToast() must remove it. (This is the error-surfacing channel
|
|
230
|
+
// added for message read-aloud / auto-read failures + RVC fallback notices.)
|
|
231
|
+
{
|
|
232
|
+
const toastApi = globalThis.window.__dshTtsToast;
|
|
233
|
+
if (toastApi && typeof toastApi.show === 'function') {
|
|
234
|
+
const hasClass = (n, cls) => {
|
|
235
|
+
if (!n) return false;
|
|
236
|
+
if (n.props && typeof n.props.className === "string" &&
|
|
237
|
+
n.props.className.split(/\s+/).includes(cls)) return true;
|
|
238
|
+
const ch = n.children;
|
|
239
|
+
if (Array.isArray(ch)) { for (const c of ch) { if (hasClass(c, cls)) return true; } }
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
const allText = n => {
|
|
243
|
+
if (!n) return '';
|
|
244
|
+
if (typeof n === 'string' || typeof n === 'number') return String(n);
|
|
245
|
+
const ch = n.children;
|
|
246
|
+
if (Array.isArray(ch)) return ch.map(allText).join('');
|
|
247
|
+
return '';
|
|
248
|
+
};
|
|
249
|
+
const overlayComps = injectedComponents.filter(c => c.slot === 'shell.overlay');
|
|
250
|
+
try {
|
|
251
|
+
toastApi.show('语音合成失败:boom', 'error');
|
|
252
|
+
// render with the shim hooks
|
|
253
|
+
const h = makeHookCtx();
|
|
254
|
+
const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
|
|
255
|
+
react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
|
|
256
|
+
const rendered = overlayComps.map(({ fn }) => fn()({}));
|
|
257
|
+
react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
|
|
258
|
+
const toastNodes = rendered.filter(n => hasClass(n, 'dsh-tts-toast'));
|
|
259
|
+
const withText = toastNodes.filter(n => allText(n).includes('语音合成失败:boom'));
|
|
260
|
+
check('toast renders in shell.overlay with message', toastNodes.length >= 1 && withText.length >= 1,
|
|
261
|
+
`toastNodes=${toastNodes.length} textFound=${withText.length}`);
|
|
262
|
+
// dismiss -> no toast rendered anymore
|
|
263
|
+
toastApi.dismiss();
|
|
264
|
+
const h2 = makeHookCtx();
|
|
265
|
+
const orig2 = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
|
|
266
|
+
react.useState = h2.useState; react.useEffect = h2.useEffect; react.useRef = h2.useRef; react.useMemo = h2.useMemo;
|
|
267
|
+
const rendered2 = overlayComps.map(({ fn }) => fn()({}));
|
|
268
|
+
react.useState = orig2.useState; react.useEffect = orig2.useEffect; react.useRef = orig2.useRef; react.useMemo = orig2.useMemo;
|
|
269
|
+
check('toast dismissed removes toast node', rendered2.filter(n => hasClass(n, 'dsh-tts-toast')).length === 0);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
check('toast renders + dismisses', false, String(e && e.stack || e).slice(0, 200));
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
check('toast hook exposed (__dshTtsToast)', false, 'hook missing');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Settings tab renders the approval voice-alert module (title visible).
|
|
279
|
+
{
|
|
280
|
+
const comp = injectedComponents.find(c => c.slot === 'settings.plugins.tab');
|
|
281
|
+
if (comp) {
|
|
282
|
+
try {
|
|
283
|
+
const allText = n => {
|
|
284
|
+
if (!n) return '';
|
|
285
|
+
if (typeof n === 'string' || typeof n === 'number') return String(n);
|
|
286
|
+
const ch = n.children;
|
|
287
|
+
if (Array.isArray(ch)) return ch.map(allText).join('');
|
|
288
|
+
return '';
|
|
289
|
+
};
|
|
290
|
+
const h = makeHookCtx();
|
|
291
|
+
const orig = { useState: react.useState, useEffect: react.useEffect, useRef: react.useRef, useMemo: react.useMemo };
|
|
292
|
+
react.useState = h.useState; react.useEffect = h.useEffect; react.useRef = h.useRef; react.useMemo = h.useMemo;
|
|
293
|
+
const node = comp.fn()({ useSession: sel => sel({ nodes: [] }), messageId: 'm1' });
|
|
294
|
+
react.useState = orig.useState; react.useEffect = orig.useEffect; react.useRef = orig.useRef; react.useMemo = orig.useMemo;
|
|
295
|
+
check('settings renders approval voice-alert module', allText(node).includes('事件语音提醒') && allText(node).includes('启用审批语音提醒'), undefined);
|
|
296
|
+
check('settings renders export-diagnostic-log button', allText(node).includes('导出诊断日志') && allText(node).includes('运行诊断'), undefined);
|
|
297
|
+
// Entry CTAs removed: provider switch is the only entry (no shortcut cards)
|
|
298
|
+
check('settings hides removed entry CTAs (edge mode)', !allText(node).includes('需要克隆音色') && !allText(node).includes('Index-TTS2 音色'), undefined);
|
|
299
|
+
check('settings shows rawMarkdown toggle', allText(node).includes('朗读原始'), undefined);
|
|
300
|
+
check('settings bottom shows bug-report hint', allText(node).includes('导出诊断日志') && allText(node).includes('红色报错') && allText(node).includes('https://github.com/1624318455/dsh-plugin-tts/issues'), undefined);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
check('settings renders approval voice-alert module', false, String(e && e.stack || e).slice(0, 200));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Single-file (short-text) reads must seed chunkProgress/readingIndex so the
|
|
308
|
+
// overlay, scroll-follow and click-to-pause activate without the chunked path.
|
|
309
|
+
// Regression test for "朗读高度跟随有时不生效 / 点击朗读中按钮无反应".
|
|
310
|
+
{
|
|
311
|
+
try {
|
|
312
|
+
const src = globalThis.__dshTtsClientSrc || '';
|
|
313
|
+
// 1) speakText seeds 1/1 progress right after readingSentenceIdx reset
|
|
314
|
+
const seedOk = /shared\.readingSentenceIdx\s*=\s*0;[\s\S]{0,1200}?shared\.chunkProgress\s*=\s*\{\s*index:\s*1,\s*total:\s*1\s*\}[\s\S]{0,200}?shared\.readingIndex\s*=\s*1;/.test(src);
|
|
315
|
+
check('speakText seeds 1/1 progress for single-file reads', seedOk);
|
|
316
|
+
// 2) overlay visible gate accepts total >= 1 (not > 1)
|
|
317
|
+
check('overlay visible accepts total >= 1', /cp\s*&&\s*cp\.total\s*>=\s*1/.test(src));
|
|
318
|
+
// 3) hidden bar must not intercept clicks: pointer-events:none by default,
|
|
319
|
+
// auto only under [data-visible]
|
|
320
|
+
const barDefaultNone = /\.dsh-tts-reading-bar\{[^}]*pointer-events:none/.test(src);
|
|
321
|
+
const barVisibleAuto = /\.dsh-tts-reading-overlay\[data-visible\][^{]*\.dsh-tts-reading-bar\{[^}]*pointer-events:auto/.test(src);
|
|
322
|
+
check('hidden bar does not intercept clicks', barDefaultNone && barVisibleAuto,
|
|
323
|
+
`defaultNone=${barDefaultNone} visibleAuto=${barVisibleAuto}`);
|
|
324
|
+
// 4) onBarClick never throws on missing event + reads live shared.speaking
|
|
325
|
+
const clickOk = /const onBarClick[\s\S]{0,600}?typeof e\.stopPropagation[\s\S]{0,200}?shared\.speaking\) togglePause\(\)/.test(src);
|
|
326
|
+
check('onBarClick hardened (no-throw + live speaking)', clickOk);
|
|
327
|
+
// 5) live scroll-follow: continuous ratio + 250ms heartbeat + user yield
|
|
328
|
+
const liveOk = /ttsLiveRatio/.test(src) && /el\.currentTime \/ el\.duration/.test(src) && /chunkStartedAt/.test(src);
|
|
329
|
+
const beatOk = /setInterval\(keepVisible,\s*250\)/.test(src);
|
|
330
|
+
const yieldOk = /ttsUserScrollAt\.t\s*=\s*Date\.now\(\)/.test(src) && /Date\.now\(\) - ttsUserScrollAt\.t < 5000/.test(src);
|
|
331
|
+
check('scroll-follow tracks live ratio', liveOk && beatOk && yieldOk,
|
|
332
|
+
`live=${liveOk} beat=${beatOk} yield=${yieldOk}`);
|
|
333
|
+
// 5b) self-scroll guard: our own scrollTo() must not refresh the user
|
|
334
|
+
// yield window (that froze follow after the first nudge), and the needle
|
|
335
|
+
// must track the live ratio (not the fixed text head).
|
|
336
|
+
const selfOk = /ttsSelfScroll\.on = true/.test(src) && /if \(ttsSelfScroll\.on\) return/.test(src);
|
|
337
|
+
const needleOk = /fullNeedle\.length \* Math\.min\(0\.99/.test(src);
|
|
338
|
+
check('follow not frozen by own scroll + live needle', selfOk && needleOk,
|
|
339
|
+
`self=${selfOk} needle=${needleOk}`);
|
|
340
|
+
// 5c) message anchor: button click climbs to the message root (WeakRef),
|
|
341
|
+
// text-resolved anchors are cached for auto-read reuse.
|
|
342
|
+
const anchorOk = /shared\.readingMsgEl = new WeakRef\(best\)/.test(src) &&
|
|
343
|
+
/shared\.readingMsgEl \? shared\.readingMsgEl\.deref\(\) : null/.test(src);
|
|
344
|
+
check('follow uses anchored message element', anchorOk);
|
|
345
|
+
// 5d) anchor survives re-render (fingerprint relocate) + wrong-element
|
|
346
|
+
// guard (never rubber-band to top on a fragment hit).
|
|
347
|
+
const relocOk = /shared\.readingMsgKey = \{ head/.test(src) && /mark\("relocated"\)/.test(src);
|
|
348
|
+
const guardOk = /mark\("bad-target"\)/.test(src);
|
|
349
|
+
check('anchor relocates + bad target skipped', relocOk && guardOk,
|
|
350
|
+
`reloc=${relocOk} guard=${guardOk}`);
|
|
351
|
+
// 6) compact bar: no "n / total" counter in bar text (tooltip only)
|
|
352
|
+
const noCounter = !/overlay\.reading"\)\) \+ " " \+ idx/.test(src) && !/\("overlay\.paused"\) : t\("overlay\.reading"\)\) \+/.test(src);
|
|
353
|
+
const pausedOk = /shared\.paused \? t\("overlay\.paused"\) : t\("overlay\.reading"\)(?!\) \+)/.test(src);
|
|
354
|
+
check('bar compact: paused/reading text only, no counter', noCounter && pausedOk,
|
|
355
|
+
`noCounter=${noCounter} pausedOk=${pausedOk}`);
|
|
356
|
+
// 7) box-shadow sentence highlight removed (ugly)
|
|
357
|
+
const noShadowCss = !/\.dsh-tts-sentence-active\{[^}]*box-shadow/.test(src);
|
|
358
|
+
const noAddActive = !/classList\.add\("dsh-tts-sentence-active"\)/.test(src);
|
|
359
|
+
check('sentence shadow highlight removed', noShadowCss && noAddActive,
|
|
360
|
+
`noShadowCss=${noShadowCss} noAddActive=${noAddActive}`);
|
|
361
|
+
// 8) playback mutual exclusion: playSeq generation guards all audio
|
|
362
|
+
// callbacks; stop hard-aborts decode via load(); chunk queue is indexed
|
|
363
|
+
// (no push-on-resolve reorder).
|
|
364
|
+
const seqBump = /shared\.playSeq = \(shared\.playSeq \|\| 0\) \+ 1/.test(src);
|
|
365
|
+
const seqGuard = (src.match(/shared\.playSeq === mySeq/g) || []).length >= 4;
|
|
366
|
+
const loadAbort = /el\.load\(\);/.test(src);
|
|
367
|
+
const indexedQ = /const queue = new Array\(total\)/.test(src) && /queue\[want\] = r\.url/.test(src) && /queue\[cursor\]/.test(src);
|
|
368
|
+
const noPush = !/queue\.push\(r\.url\)/.test(src);
|
|
369
|
+
check('playback generation guards callbacks (no overlap)', seqBump && seqGuard,
|
|
370
|
+
`bump=${seqBump} guards=${(src.match(/shared\.playSeq === mySeq/g) || []).length}`);
|
|
371
|
+
check('stop aborts decode via load()', loadAbort);
|
|
372
|
+
check('chunk queue indexed by chunk idx (no reorder)', indexedQ && noPush,
|
|
373
|
+
`indexed=${indexedQ} noPush=${noPush}`);
|
|
374
|
+
// 9) fluency trio: skipped-slot advance, stall watchdog (text-only,
|
|
375
|
+
// never touches playbackRate — varispeed would deepen the timbre).
|
|
376
|
+
const skipSlot = /queue\[want\] = false/.test(src) && /queue\[cursor\] === false/.test(src);
|
|
377
|
+
const stallDog = /shared\.stalling = true/.test(src) && /chunk\.stalling/.test(src)
|
|
378
|
+
&& !/stallStretch/.test(src) && !/\*\s*0\.85/.test(src);
|
|
379
|
+
check('skipped chunks advance without gap', skipSlot);
|
|
380
|
+
check('stall watchdog shows synthesizing, rate untouched', stallDog);
|
|
381
|
+
// 10) starvation clamp: a chunk decoded late must start at now, never in
|
|
382
|
+
// the past (past start plays immediately while bookkeeping lags, so the
|
|
383
|
+
// next chunk overlaps the tail — sustained RTF > 1, e.g. local CosyVoice).
|
|
384
|
+
const starveClamp = /Math\.max\(nextStart,\s*ctx\.currentTime/.test(src);
|
|
385
|
+
check('starved chunk clamps to now (no tail overlap)', starveClamp);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
check('single-file progress regression checks', false, String(e && e.stack || e).slice(0, 200));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const failedCount = results.filter(r => !r.ok).length;
|
|
392
|
+
console.log(`\n${results.length - failedCount}/${results.length} client-load checks passed`);
|
|
393
|
+
process.exit(failedCount === 0 ? 0 : 1);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// One-off real end-to-end for the compact-index tool.
|
|
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
|
+
// Requires the local rvc-server (NEW code with /compact-index) on 4892.
|
|
9
|
+
// node tests/e2e-compact-index.mjs
|
|
10
|
+
import { createServer } from 'node:http';
|
|
11
|
+
|
|
12
|
+
const BASE = 'http://127.0.0.1:4892';
|
|
13
|
+
const RVC_DIR = 'E:/AI/RVC20240604Nvidia/RVC20240604Nvidia';
|
|
14
|
+
const MODEL = `${RVC_DIR}/assets/weights/azusa-test.pth`;
|
|
15
|
+
const INDEX = `${RVC_DIR}/assets/indices/azusa-test_IVF3317_Flat_nprobe_1_azusa-test_v2.index`;
|
|
16
|
+
const OUT_DIR = `${RVC_DIR}/assets/indices`;
|
|
17
|
+
|
|
18
|
+
function post(route, payload, timeoutMs = 300000) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const ctrl = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
22
|
+
fetch(BASE + route, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify(payload),
|
|
26
|
+
signal: ctrl.signal
|
|
27
|
+
}).then(async r => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
const data = await r.json().catch(() => null);
|
|
30
|
+
if (!r.ok) reject(new Error(`HTTP ${r.status}: ${(data && (data.message || data.error)) || r.statusText}`));
|
|
31
|
+
else resolve(data);
|
|
32
|
+
}).catch(e => { clearTimeout(timer); reject(e); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function miniWav(seconds = 2, sr = 40000) {
|
|
37
|
+
const n = sr * seconds;
|
|
38
|
+
const dataSize = n * 2;
|
|
39
|
+
const buf = Buffer.alloc(44 + dataSize);
|
|
40
|
+
buf.write('RIFF', 0);
|
|
41
|
+
buf.writeUInt32LE(36 + dataSize, 4);
|
|
42
|
+
buf.write('WAVE', 8);
|
|
43
|
+
buf.write('fmt ', 12);
|
|
44
|
+
buf.writeUInt32LE(16, 16);
|
|
45
|
+
buf.writeUInt16LE(1, 20);
|
|
46
|
+
buf.writeUInt16LE(1, 22);
|
|
47
|
+
buf.writeUInt32LE(sr, 24);
|
|
48
|
+
buf.writeUInt32LE(sr * 2, 28);
|
|
49
|
+
buf.writeUInt16LE(2, 32);
|
|
50
|
+
buf.writeUInt16LE(16, 34);
|
|
51
|
+
buf.write('data', 36);
|
|
52
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
53
|
+
return buf;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const results = [];
|
|
57
|
+
function check(name, ok, detail) {
|
|
58
|
+
results.push({ name, ok });
|
|
59
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 1) build compact indexes
|
|
63
|
+
const t0 = Date.now();
|
|
64
|
+
const c2k = await post('/compact-index', { index: INDEX, target_vectors: 2000 });
|
|
65
|
+
console.log(`compact 2k -> ${c2k.path.split(/[\\/]/).pop()} ${(c2k.size / 1048576).toFixed(1)}MB (${c2k.reduction_pct}% smaller, ${((Date.now() - t0) / 1000).toFixed(1)}s)`);
|
|
66
|
+
check('compact 2k built', c2k.ok && c2k.vectors === 2000 && c2k.size > 1000000 && c2k.size < 10000000, `size=${c2k.size}`);
|
|
67
|
+
const c10k = await post('/compact-index', { index: INDEX, target_vectors: 10000 });
|
|
68
|
+
console.log(`compact 10k -> ${c10k.path.split(/[\\/]/).pop()} ${(c10k.size / 1048576).toFixed(1)}MB (${c10k.reduction_pct}% smaller)`);
|
|
69
|
+
check('compact 10k built', c10k.ok && c10k.vectors === 10000 && c10k.size > 20000000 && c10k.size < 50000000, `size=${c10k.size}`);
|
|
70
|
+
|
|
71
|
+
// 2) load the model + compact index, convert a clip -> must yield wav
|
|
72
|
+
await post('/load', { model: MODEL, index: c2k.path });
|
|
73
|
+
const cv = await post('/convert', {
|
|
74
|
+
audio_base64: miniWav(2).toString('base64'),
|
|
75
|
+
params: { spk_id: 0, f0_method: 'rmvpe', index_rate: 0.75, resample_sr: 40000 }
|
|
76
|
+
}, 180000);
|
|
77
|
+
const wav = Buffer.from(cv.audio_base64 || '', 'base64');
|
|
78
|
+
check('convert with compact index works', cv.audio_base64 && wav.length > 1000 && wav.slice(0, 4).toString() === 'RIFF', `bytes=${wav.length} sr=${cv.sample_rate}`);
|
|
79
|
+
|
|
80
|
+
// 3) restore the original index on the server
|
|
81
|
+
await post('/load', { model: MODEL, index: INDEX });
|
|
82
|
+
console.log('restored original index on server');
|
|
83
|
+
|
|
84
|
+
const failed = results.filter(r => !r.ok);
|
|
85
|
+
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
|
86
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// One-off real A/B: how much does the index actually change the output?
|
|
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
|
+
// Same real Edge-TTS base audio converted under three configs on the local
|
|
9
|
+
// rvc-server (4892, NEW code): index-free vs full index vs compact-2k index.
|
|
10
|
+
// node tests/e2e-index-ab.mjs
|
|
11
|
+
import * as plugin from '../lib/index.mjs';
|
|
12
|
+
|
|
13
|
+
const routes = [];
|
|
14
|
+
const ctx = {
|
|
15
|
+
get(name) {
|
|
16
|
+
if (name === 'webServer') {
|
|
17
|
+
return {
|
|
18
|
+
register(route) {
|
|
19
|
+
routes.push(route);
|
|
20
|
+
return () => {
|
|
21
|
+
const i = routes.indexOf(route);
|
|
22
|
+
if (i >= 0) routes.splice(i, 1);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
},
|
|
29
|
+
effect() {}
|
|
30
|
+
};
|
|
31
|
+
plugin.apply(ctx);
|
|
32
|
+
const speakRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/speak');
|
|
33
|
+
const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tts-audio');
|
|
34
|
+
|
|
35
|
+
function mockReq(url, body) {
|
|
36
|
+
const chunks = body === undefined ? [] : [body];
|
|
37
|
+
return {
|
|
38
|
+
url,
|
|
39
|
+
[Symbol.asyncIterator]() {
|
|
40
|
+
let i = 0;
|
|
41
|
+
return {
|
|
42
|
+
next: () => (i < chunks.length ? Promise.resolve({ value: chunks[i++], done: false }) : Promise.resolve({ done: true }))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function mockRes() {
|
|
48
|
+
return { headersSent: false, head: null, body: null, writeHead(c, h) { this.head = { c, h }; }, end(b) { this.body = b; } };
|
|
49
|
+
}
|
|
50
|
+
async function call(route, req, res) { await route.handler(req, res); return res; }
|
|
51
|
+
|
|
52
|
+
const BASE = 'http://127.0.0.1:4892';
|
|
53
|
+
const RVC_DIR = 'E:/AI/RVC20240604Nvidia/RVC20240604Nvidia';
|
|
54
|
+
const MODEL = `${RVC_DIR}/assets/weights/azusa-test.pth`;
|
|
55
|
+
const FULL = `${RVC_DIR}/assets/indices/azusa-test_IVF3317_Flat_nprobe_1_azusa-test_v2.index`;
|
|
56
|
+
const COMPACT = process.env.AB_INDEX
|
|
57
|
+
|| `${RVC_DIR}/assets/indices/azusa-test_IVF3317_Flat_nprobe_1_azusa-test_v2_compact_2000.index`;
|
|
58
|
+
|
|
59
|
+
function post(route, payload, timeoutMs = 300000) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const ctrl = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
63
|
+
fetch(BASE + route, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify(payload),
|
|
67
|
+
signal: ctrl.signal
|
|
68
|
+
}).then(async r => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
const data = await r.json().catch(() => null);
|
|
71
|
+
if (!r.ok) reject(new Error(`HTTP ${r.status}: ${(data && (data.message || data.error)) || r.statusText}`));
|
|
72
|
+
else resolve(data);
|
|
73
|
+
}).catch(e => { clearTimeout(timer); reject(e); });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toSamples(buf) {
|
|
78
|
+
// wav -> int16 mono samples (best effort: skip 44-byte header)
|
|
79
|
+
const data = buf.subarray(44);
|
|
80
|
+
const n = Math.floor(data.length / 2);
|
|
81
|
+
const out = new Float32Array(n);
|
|
82
|
+
for (let i = 0; i < n; i++) out[i] = data.readInt16LE(i * 2);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function compare(name, a, b) {
|
|
86
|
+
const na = a.length, nb = b.length;
|
|
87
|
+
if (na !== nb) {
|
|
88
|
+
console.log(` ${name}: LENGTH DIFFERS ${na} vs ${nb} (${Math.abs(na - nb)} samples)`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let sum = 0, max = 0;
|
|
92
|
+
for (let i = 0; i < na; i++) {
|
|
93
|
+
const d = Math.abs(a[i] - b[i]);
|
|
94
|
+
sum += d;
|
|
95
|
+
if (d > max) max = d;
|
|
96
|
+
}
|
|
97
|
+
const mean = sum / na;
|
|
98
|
+
let rms = 0;
|
|
99
|
+
for (let i = 0; i < na; i++) rms += a[i] * a[i];
|
|
100
|
+
rms = Math.sqrt(rms / na);
|
|
101
|
+
console.log(` ${name}: meanAbsDiff=${mean.toFixed(1)} (${((mean / (rms || 1)) * 100).toFixed(1)}% of RMS), max=${max}, rms=${rms.toFixed(0)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 1) real speech base audio via the plugin's Edge provider
|
|
105
|
+
const res = mockRes();
|
|
106
|
+
await speakRoute.handler(mockReq('/dsh-tts-api/speak', JSON.stringify({
|
|
107
|
+
text: '这是一段用来测试不同索引对音色还原度影响的真实语音,请你仔细听。',
|
|
108
|
+
voice: 'zh-CN-XiaoxuanNeural'
|
|
109
|
+
})), res);
|
|
110
|
+
const parsed = JSON.parse(res.body);
|
|
111
|
+
if (!parsed.url) { console.error('edge speak failed:', parsed); process.exit(1); }
|
|
112
|
+
const ares = mockRes();
|
|
113
|
+
await audioRoute.handler(mockReq(parsed.url), ares);
|
|
114
|
+
const baseMp3 = Buffer.isBuffer(ares.body) ? ares.body : Buffer.from(ares.body ?? '');
|
|
115
|
+
console.log(`base audio: ${(baseMp3.length / 1024).toFixed(0)} KB mp3`);
|
|
116
|
+
|
|
117
|
+
// 2) three conversions of the SAME base
|
|
118
|
+
await post('/load', { model: MODEL, index: FULL });
|
|
119
|
+
const z = await post('/convert', { audio_base64: baseMp3.toString('base64'), params: { index_rate: 0 } }, 180000);
|
|
120
|
+
console.log('config A (index-free): converted');
|
|
121
|
+
const f = await post('/convert', { audio_base64: baseMp3.toString('base64'), params: { index_rate: 0.75 } }, 180000);
|
|
122
|
+
console.log('config B (full index, 0.75): converted');
|
|
123
|
+
await post('/load', { model: MODEL, index: COMPACT });
|
|
124
|
+
const c = await post('/convert', { audio_base64: baseMp3.toString('base64'), params: { index_rate: 0.75 } }, 180000);
|
|
125
|
+
console.log('config C (compact 2k, 0.75): converted');
|
|
126
|
+
await post('/load', { model: MODEL, index: FULL }); // restore
|
|
127
|
+
|
|
128
|
+
const za = toSamples(Buffer.from(z.audio_base64, 'base64'));
|
|
129
|
+
const fa = toSamples(Buffer.from(f.audio_base64, 'base64'));
|
|
130
|
+
const ca = toSamples(Buffer.from(c.audio_base64, 'base64'));
|
|
131
|
+
console.log(`lengths: A=${za.length} B=${fa.length} C=${ca.length}`);
|
|
132
|
+
console.log('A vs B (index-free vs full index):');
|
|
133
|
+
compare(' ', za, fa);
|
|
134
|
+
console.log('B vs C (full index vs compact 2k):');
|
|
135
|
+
compare(' ', fa, ca);
|
|
136
|
+
console.log('A vs C (index-free vs compact 2k):');
|
|
137
|
+
compare(' ', za, ca);
|
|
138
|
+
process.exit(0);
|