Files
DemonGatanjieuandClaude Opus 5.5 6996f07063 Show the GPT-SoVITS model version on character cards; v3/v4/v5 imports
Character cards show the version Anomalous_TTS reports (interface 15). The
v3/v4/v5 base models and vocoders are listed, counted and reminded of only
when a character or a dropped SoVITS file needs them; the import tells an
unreadable SoVITS file from a version an older node cannot use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:29:25 +08:00

195 lines
12 KiB
JavaScript

// Run: node tests/tts_setup_api.mjs (fetch is stubbed; no ComfyUI needed)
import assert from 'node:assert/strict';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const calls = [];
let statusCalls = 0;
let failNextChunk = false;
let received = 0;
globalThis.fetch = async (url, opts = {}) => {
calls.push({ url, opts });
const reply = (data, status = 200) => ({ ok: status < 400, status, text: async () => JSON.stringify(data), json: async () => data });
if (url === '/anomalous_tts/status') {
statusCalls += 1;
return reply({ format: 3, local: true, libraries: [{ path: 'D:/v', characters: 2, writable: true }],
pretrained: [{ id: 'hubert', state: 'ok', required: true }, { id: 'g2pw', state: 'missing', required: false }],
dependencies: { ja: { ok: true, missing: [] } } });
}
if (url === '/anomalous_tts/import/upload' && opts.body && !url.includes('?')) { received = 0; return reply({ upload: 'u1' }); }
if (url.startsWith('/anomalous_tts/import/upload?')) {
const offset = Number(new URL(url, 'http://x').searchParams.get('offset'));
if (failNextChunk) { failNextChunk = false; received = offset + 3; return reply({ error: 'offset', received }, 409); }
received = offset + (await opts.body.arrayBuffer()).byteLength;
return reply({ received });
}
if (url === '/anomalous_tts/import/commit') return reply({ ok: true, character: { name: 'X' } });
if (url === '/anomalous_tts/pretrained/download') {
const body = JSON.parse(opts.body);
return body.ids.includes('nope') ? reply({ error: '不认识的底模:nope' }, 400) : reply({ ok: true });
}
return reply(null, 404);
};
const engines = await import(pathToFileURL(path.join(root, 'web/modules/audio_engines.js')).href);
const api = await import(pathToFileURL(path.join(root, 'web/modules/tts_setup_api.js')).href);
// Status is cached across re-renders; any change drops the cache; `force` skips it.
await engines.loadGptSovitsStatus();
await engines.loadGptSovitsStatus();
assert.equal(statusCalls, 1);
await engines.loadGptSovitsStatus({ force: true });
assert.equal(statusCalls, 2);
await api.commitImport({ files: [] });
await engines.loadGptSovitsStatus();
assert.equal(statusCalls, 3);
// File kinds match the node's importer.kind_of.
assert.deepEqual(['a.CKPT', 'b.pth', 'c.Wav', 'd.list', 'e.txt', 'f.md', 'noext'].map(api.importKind),
['gpt', 'sovits', 'audio', 'text', 'text', null, null]);
// Chunked upload: 8 MB pieces, resumes from the node's count after a 409.
const size = api.UPLOAD_CHUNK * 2 + 10;
const file = new Blob([new Uint8Array(size)]);
file.name = 'x.ckpt';
const progress = [];
let started = null;
failNextChunk = true;
const id = await api.uploadFile(file, { onStart: u => { started = u; }, onProgress: p => progress.push(p) });
assert.equal(id, 'u1');
assert.equal(started, 'u1');
assert.equal(received, size);
assert.equal(progress.at(-1), 1);
const offsets = calls.filter(c => c.url.includes('offset=')).map(c => Number(new URL(c.url, 'http://x').searchParams.get('offset')));
assert.deepEqual(offsets, [0, 3, 3 + api.UPLOAD_CHUNK, 3 + 2 * api.UPLOAD_CHUNK]);
// Errors carry the node's message.
await assert.rejects(api.startPretrainedDownload(['nope']), /不认识的底模:nope/);
const before = statusCalls;
await api.startPretrainedDownload(['g2pw']);
await engines.loadGptSovitsStatus();
assert.equal(statusCalls, before + 1); // a download drops the cached status
assert.deepEqual(JSON.parse(calls.findLast(c => c.url === '/anomalous_tts/pretrained/download').opts.body), { ids: ['g2pw'] });
// Interface 13: nothing here sends a folder or file path on the computer to the node.
for (const name of ['changeStorage', 'forgetLibrary', 'changePretrainedSource', 'browseFolder', 'scanFolder', 'previewUrl']) {
assert.equal(name in api, false, name);
}
assert.ok(calls.every(c => !/\/anomalous_tts\/(storage|libraries|browse|pretrained\/source|import\/preview)/.test(c.url)));
assert.deepEqual([1536, 5 * 1024 ** 2, 3 * 1024 ** 3].map(api.formatSize), ['2 KB', '5.0 MB', '3.0 GB']);
// Commit body: file indexes, main voice, emotions; the main row's emotion is ignored.
const rows = [
{ spec: { upload: 'g' }, kind: 'gpt', emotion: '', text: '' },
{ spec: { upload: 's' }, kind: 'sovits', emotion: '', text: '' },
{ spec: { upload: 'a' }, kind: 'audio', emotion: 'ignored', text: '台词' },
{ spec: { upload: 'b' }, kind: 'audio', emotion: '开心', text: '' },
{ spec: { upload: 'c' }, kind: 'audio', emotion: '', text: 'x' },
];
assert.deepEqual(api.buildImportBody({ library: 'D:/v', character: '阿罗娜', rows, referenceIndex: 2, language: 'ja' }), {
files: rows.map(r => r.spec), library: 'D:/v', character: '阿罗娜',
settings: { language: 'ja', reference: { file: 2, text: '台词', language: 'ja' }, emotions: { 开心: { file: 3 } } },
});
assert.deepEqual(api.buildImportBody({ target: '普拉娜', rows: rows.slice(3, 4), referenceIndex: null }),
{ files: [{ upload: 'b' }], target: '普拉娜', settings: { emotions: { 开心: { file: 0 } } } });
// Form checks, in the order the user meets them.
assert.deepEqual(api.importProblem({ rows: [] }), ['ttsImportNoFiles', {}]);
assert.deepEqual(api.importProblem({ rows }), ['ttsImportNameMissing', {}]);
assert.deepEqual(api.importProblem({ character: 'X', rows: rows.slice(1) }), ['ttsImportWeightsMissing', {}]);
assert.equal(api.importProblem({ target: 'Y', rows: rows.slice(3) }), null); // adding needs no weights
const dup = [...rows, { ...rows[3], spec: { upload: 'd' } }];
assert.deepEqual(api.importProblem({ character: 'X', rows: dup }), ['ttsEditorNameDuplicate', { name: '开心' }]);
assert.deepEqual(api.importProblem({ character: 'X', rows: [rows[0], rows[1], { ...rows[3], emotion: 'main' }] }),
['ttsEditorNameInvalid', { name: 'main' }]);
// One GPT and one SoVITS per form: the latest epoch of a batch wins.
assert.ok(api.weightEpoch('A-e15.ckpt', 'gpt') > api.weightEpoch('A-e5.ckpt', 'gpt'));
assert.ok(api.weightEpoch('A_e16_s224.pth', 'sovits') > api.weightEpoch('A_e16_s100.pth', 'sovits'));
assert.equal(api.weightEpoch('plain.ckpt', 'gpt'), -1);
const batch = [{ name: 'A-e5.ckpt', kind: 'gpt' }, { name: 'A-e15.ckpt', kind: 'gpt' }, { name: 'A-e10.ckpt', kind: 'gpt' },
{ name: 'A_e8_s112.pth', kind: 'sovits' }, { name: 'r.wav', kind: 'audio' }];
const picked = api.pickWeights(batch);
assert.deepEqual([...picked.skip].sort(), [0, 2]);
assert.deepEqual(picked.kept, [{ kind: 'gpt', name: 'A-e15.ckpt', others: 2 }]);
// Name clashes: an exact character, or versions stored under that name.
const names = ['阿罗娜/日配', '阿罗娜/中配', '普拉娜'];
assert.deepEqual(api.nameConflict(' 普拉娜 ', names), { kind: 'exact', name: '普拉娜' });
assert.deepEqual(api.nameConflict('阿罗娜', names), { kind: 'variant', name: '阿罗娜', variants: ['阿罗娜/日配', '阿罗娜/中配'] });
assert.equal(api.nameConflict('阿', names), null);
assert.equal(api.nameConflict('', names), null);
const weights = rows.slice(0, 2);
assert.deepEqual(api.importProblem({ character: '普拉娜', rows: weights, conflict: api.nameConflict('普拉娜', names) }),
['ttsImportNameTaken', { name: '普拉娜' }]);
assert.equal(api.importProblem({ character: 'X', rows: weights }), null);
assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'v4.pth', supported: false, version: 'v4' }] }),
['ttsImportUnsupportedBlock', { file: 'v4.pth', version: 'v4' }]); // an Anomalous_TTS before v4 support
assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'odd.pth', supported: false }] }),
['ttsImportUnknownBlock', { file: 'odd.pth' }]);
assert.deepEqual([undefined, 2.9, 3, 10, 10.1].map(api.usableAsReference), [true, false, true, true, false]);
// A text file dropped on a clip's text box: a plain file is the line; an annotation file is searched.
assert.equal(api.importKind('x.LAB'), 'text');
assert.equal(api.textFromFile('\uFEFF 先生、おはよう \r\n', 'a.wav'), '先生、おはよう');
const bs = String.fromCharCode(92); // a Windows path in the annotation file
const list = `D:${bs}data${bs}Talk_1.wav|arona|JA|最初の台詞
D:/data/Happy.wav|arona|ja|嬉しい
`;
assert.equal(api.textFromFile(list, 'talk_1.WAV'), '最初の台詞');
assert.equal(api.textFromFile(list, 'Happy.开心.wav'), '嬉しい'); // without the emotion part, like the node
assert.equal(api.textFromFile(list, 'missing.wav'), null);
// Setup summary: ready needs characters, required pretrained files and packages.
const status = await engines.loadGptSovitsStatus();
assert.deepEqual(api.setupSummary(status), { characters: 2, missing: 1, requiredMissing: 0, packages: 0, downloading: false, ready: true });
assert.equal(api.setupSummary({ ...status, libraries: [] }).ready, false);
assert.equal(api.setupSummary({ ...status, pretrained: [{ id: 'hubert', state: 'downloading', required: true }] }).downloading, true);
// Reminder dot: nothing for an empty studio; only files the characters will load.
const pre = { pretrained: [
{ id: 'hubert', needed_for: 'all', state: 'missing', size: 10, required: true },
{ id: 'roberta', needed_for: 'zh', state: 'missing', size: 20, required: true },
{ id: 'ja_userdic', needed_for: 'ja', state: 'missing', size: 5, required: false },
{ id: 'english', needed_for: 'en', state: 'ok', size: 3, required: true },
] };
assert.deepEqual(api.pretrainedReminder(pre, []), { items: [], size: 0, due: false });
const ja = api.pretrainedReminder(pre, ['ja']);
assert.deepEqual(ja.items.map(i => i.id), ['hubert', 'ja_userdic']);
assert.equal(ja.size, 15);
assert.equal(ja.due, true);
assert.equal(api.pretrainedReminder(pre, ['ja'], ['hubert', 'ja_userdic']).due, false);
assert.equal(api.pretrainedReminder(pre, ['ja', 'zh'], ['hubert', 'ja_userdic']).due, true); // first Chinese character
assert.deepEqual(api.pretrainedReminder(pre, ['']).items.map(i => i.id), ['hubert', 'roberta', 'ja_userdic']);
assert.deepEqual(api.missingForLanguage(pre, 'zh').map(i => i.id), ['hubert', 'roberta']);
assert.deepEqual(api.missingForLanguage(pre, 'ja').map(i => i.id), ['hubert']);
// v3 / v4 / v5 files: listed, counted and reminded of only for characters (or dropped files) whose model needs them.
const versions = { pretrained: [...pre.pretrained,
{ id: 'v4', needed_for: 'v4', state: 'missing', size: 800, required: true },
{ id: 'v5dev', needed_for: 'v5dev', state: 'downloading', size: 800, required: true },
{ id: 'v5turbo', needed_for: 'v5turbo', state: 'missing', size: 800, required: true },
] };
assert.deepEqual(api.shownPretrained(versions).map(i => i.id), ['hubert', 'roberta', 'ja_userdic', 'english', 'v5dev']);
assert.deepEqual(api.shownPretrained(versions, ['v4']).map(i => i.id).slice(-2), ['v4', 'v5dev']);
assert.equal(api.setupSummary(versions).missing, 4); // v4 and v5turbo not counted while nobody uses them
assert.deepEqual(api.pretrainedReminder(versions, ['']).items.map(i => i.id), ['hubert', 'roberta', 'ja_userdic']);
assert.deepEqual(api.pretrainedReminder(versions, ['ja'], [], ['v4']).items.map(i => i.id), ['hubert', 'ja_userdic', 'v4']);
assert.deepEqual(api.missingForLanguage(versions, '', ['v4']).map(i => i.id), ['hubert', 'v4']);
// Row tones and section marks in the import form.
assert.equal(api.rowState({ progress: 0.42 }).tone, 'busy');
assert.deepEqual(api.rowState({ progress: 0.42 }).params, { percent: 42 });
assert.equal(api.rowState({ uploaded: true }).tone, 'ready');
assert.equal(api.rowState({ uploaded: true, existing: 'same' }).tone, 'skip');
assert.equal(api.rowState({ uploaded: true, existing: 'merge' }).tone, 'merge');
assert.equal(api.rowState({ uploaded: true, existing: 'different' }).tone, 'error');
assert.equal(api.rowState({ uploaded: true, unsupported: true }).tone, 'error');
assert.equal(api.rowState({ error: 'x', uploaded: true }).key, 'ttsImportRowError');
assert.deepEqual(api.importProblem({ target: 'A', rows: [{ kind: 'audio', name: 'a.wav', existing: 'different' }] }),
['ttsImportExistingDifferent', { file: 'a.wav' }]);
assert.equal(api.importProblem({ target: 'A', rows: [{ kind: 'audio', name: 'a.wav', existing: 'same' }] }), null);
console.log('tts_setup_api: all checks passed');