Files
DemonGatanjieu-Anomalous_Mo…/web/modules/tts_setup_api.js
T
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

290 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invalidateEngineCache } from './audio_engines.js';
/**
* GPT-SoVITS setup and import through the Anomalous_TTS node (interface v13, §5.2–5.3).
* The node owns every file: the storage place, pretrained files and imported
* characters. Where they are is set only in the node's settings file, never through
* the API, and imported files are always uploaded: no call names a path on the
* computer. This module only calls the API and holds the pure rules the import form
* needs; it has no DOM.
*/
export const UPLOAD_CHUNK = 8 * 1024 * 1024; // under ComfyUI's default 100 MB request limit
const IMPORT_KINDS = {
'.ckpt': 'gpt', '.pth': 'sovits',
'.wav': 'audio', '.flac': 'audio', '.ogg': 'audio', '.mp3': 'audio',
'.txt': 'text', '.lab': 'text', '.list': 'text',
};
// GPT-SoVITS annotation line: path|speaker|LANG|text (same rule as the node's characters.read_list).
const LIST_LINE = /^[^|]+\|[^|]*\|([A-Za-z_]+)\|(.+)$/;
const FORBIDDEN_IN_EMOTION = /[{}[\]]/;
// GPT-SoVITS reference clips must be 3–10 s (the node refuses others as a main voice).
export const REF_MIN_SEC = 3;
export const REF_MAX_SEC = 10;
const EPOCH = { gpt: /-e(\d+)/i, sovits: /_e(\d+)(?:_s(\d+))?/i };
/** gpt | sovits | audio | text | null — the node checks again; this only avoids uploading unusable files. */
export function importKind(name) {
const dot = String(name || '').lastIndexOf('.');
return dot < 0 ? null : IMPORT_KINDS[String(name).slice(dot).toLowerCase()] || null;
}
async function request(url, { method = 'POST', body, raw, signal, keepalive } = {}) {
const headers = raw ? { 'Content-Type': 'application/octet-stream' } : body !== undefined ? { 'Content-Type': 'application/json' } : undefined;
const resp = await fetch(url, {
method, signal, keepalive, headers,
body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
});
const text = await resp.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { /* plain-text error message */ }
if (!resp.ok) {
const error = new Error(data?.error || text || `HTTP ${resp.status}`);
error.status = resp.status;
error.data = data;
throw error;
}
return data;
}
export function formatSize(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
// ---------- pretrained files ----------
/** Start background downloads; progress shows up in the status. */
export async function startPretrainedDownload(ids) {
await request('/anomalous_tts/pretrained/download', { body: { ids } });
invalidateEngineCache();
}
// ---------- import ----------
/**
* Upload one browser File in chunks. `onStart(id)` gets the upload id at once so the
* caller can discard it if the form is closed; a 409 resumes from what the node has.
*/
export async function uploadFile(file, { library, signal, onStart, onProgress } = {}) {
const { upload } = await request('/anomalous_tts/import/upload', { body: { name: file.name, size: file.size, library }, signal });
onStart?.(upload);
let offset = 0;
while (offset < file.size) {
try {
const { received } = await request(`/anomalous_tts/import/upload?upload=${upload}&offset=${offset}`, {
raw: file.slice(offset, offset + UPLOAD_CHUNK), signal,
});
offset = received;
} catch (e) {
if (e.status !== 409 || !Number.isInteger(e.data?.received)) throw e;
offset = e.data.received;
}
onProgress?.(offset / file.size);
}
return upload;
}
/** `target`: the character files are added to; each file then says what it meets there (`existing`). */
export function inspectImport(files, signal, target = null) {
return request('/anomalous_tts/import/inspect', { body: target ? { files, target } : { files }, signal });
}
/** Not tied to the form's AbortSignal: closing the form must not pretend to cancel a write. */
export async function commitImport(body) {
const result = await request('/anomalous_tts/import/commit', { body });
invalidateEngineCache();
return result.character;
}
/** Fire and forget; `keepalive` lets it finish while the page is closing. */
export function discardUploads(ids) {
if (!ids.length) return;
request('/anomalous_tts/import/discard', { body: { uploads: ids }, keepalive: true }).catch(() => {});
}
/**
* The commit request for the import form. `rows` are in file order:
* { spec: {upload} (plus `name` when renamed), kind, emotion, text }. `language` '' = let the node decide.
*/
export function buildImportBody({ target = null, library = '', character = '', rows, referenceIndex = null, language = '' }) {
const settings = {};
if (language) settings.language = language;
const main = referenceIndex !== null ? rows[referenceIndex] : null;
if (main?.kind === 'audio') {
settings.reference = { file: referenceIndex };
if (main.text) settings.reference.text = main.text;
if (language) settings.reference.language = language;
}
const emotions = {};
rows.forEach((row, index) => {
if (row.kind !== 'audio' || index === referenceIndex || !row.emotion) return;
emotions[row.emotion] = { file: index };
if (row.text) emotions[row.emotion].text = row.text;
});
if (Object.keys(emotions).length) settings.emotions = emotions;
const body = { files: rows.map(row => row.spec), settings };
if (target) body.target = target;
else Object.assign(body, { library, character });
return body;
}
/** Training epoch in a weight file name (`X-e15.ckpt`, `X_e16_s224.pth`), or -1. */
export function weightEpoch(name, kind) {
const m = EPOCH[kind]?.exec(String(name || ''));
return m ? Number(m[1]) * 1e6 + Number(m[2] || 0) : -1;
}
/**
* A form holds one GPT and one SoVITS weight. From files added together, keep the
* latest epoch of each kind. Returns { skip: Set of indexes, kept: [{ kind, name, others }] }.
*/
export function pickWeights(items) {
const skip = new Set();
const kept = [];
for (const kind of ['gpt', 'sovits']) {
const candidates = items.map((item, index) => ({ ...item, index })).filter(item => item.kind === kind);
if (candidates.length < 2) continue;
const best = candidates.reduce((a, b) => (weightEpoch(b.name, kind) > weightEpoch(a.name, kind) ? b : a));
candidates.forEach(item => { if (item !== best) skip.add(item.index); });
kept.push({ kind, name: best.name, others: candidates.length - 1 });
}
return { skip, kept };
}
/** Does a new character name clash with existing ones? exact | variant (`name/…` versions) | null. */
export function nameConflict(name, existing) {
const wanted = String(name || '').trim();
if (!wanted) return null;
if (existing.includes(wanted)) return { kind: 'exact', name: wanted };
const variants = existing.filter(other => other.startsWith(`${wanted}/`));
return variants.length ? { kind: 'variant', name: wanted, variants } : null;
}
export function usableAsReference(seconds) {
return seconds === undefined || (seconds >= REF_MIN_SEC && seconds <= REF_MAX_SEC);
}
/**
* First reason the form cannot be sent, as [locale key, params], or null. Rows may
* carry the node's inspect result (`supported`, `version`) for SoVITS weights.
*/
export function importProblem({ target = null, character = '', rows, conflict = null }) {
if (!rows.length) return ['ttsImportNoFiles', {}];
if (!target) {
if (!character.trim()) return ['ttsImportNameMissing', {}];
if (conflict?.kind === 'exact') return ['ttsImportNameTaken', { name: conflict.name }];
if (conflict?.kind === 'variant') return ['ttsImportNameTakenVariant', { name: conflict.name, variants: conflict.variants.join('、') }];
if (!rows.some(row => row.kind === 'gpt') || !rows.some(row => row.kind === 'sovits')) return ['ttsImportWeightsMissing', {}];
}
const clash = rows.find(row => row.existing === 'different');
if (clash) return ['ttsImportExistingDifferent', { file: clash.name }];
const unsupported = rows.find(row => row.kind === 'sovits' && row.supported === false);
if (unsupported) {
return unsupported.version ? ['ttsImportUnsupportedBlock', { file: unsupported.name, version: unsupported.version }]
: ['ttsImportUnknownBlock', { file: unsupported.name }];
}
const seen = new Set();
for (const row of rows) {
if (row.kind !== 'audio' || !row.emotion) continue;
if (row.emotion === 'main' || FORBIDDEN_IN_EMOTION.test(row.emotion)) return ['ttsEditorNameInvalid', { name: row.emotion }];
if (seen.has(row.emotion)) return ['ttsEditorNameDuplicate', { name: row.emotion }];
seen.add(row.emotion);
}
return null;
}
/**
* The line for `clipName` from a text file dropped on that clip's text box: a GPT-SoVITS
* annotation file is searched by file name (then without the emotion part, like the node);
* any other text file is the line itself. null = an annotation file without this clip.
*/
export function textFromFile(content, clipName) {
const text = String(content || '').trim(); // trim() also drops a BOM
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (!lines.length || !LIST_LINE.test(lines[0])) return text;
const table = new Map();
for (const line of lines) {
const m = LIST_LINE.exec(line);
const file = line.split('|', 1)[0].replace(/\\/g, '/').split('/').pop().toLowerCase();
if (m && !table.has(file)) table.set(file, m[2].trim());
}
const name = String(clipName || '').toLowerCase();
const dot = name.lastIndexOf('.');
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
return table.get(name) ?? table.get(`${stem.split('.')[0]}${ext}`) ?? null;
}
// `needed_for` of the pretrained items only v3 / v4 / v5 models use (their base model and vocoder).
const MODEL_VERSIONS = new Set(['v3', 'v4', 'v5dev', 'v5turbo']);
/**
* Pretrained items worth listing: the shared ones, and a model version's only when `needs`
* (ids from the characters' or the dropped files' SoVITS models) asks for it, or while it
* downloads or failed. A library without v5 characters never sees the v5 files.
*/
export function shownPretrained(status, needs = []) {
const wanted = new Set(needs);
return (status?.pretrained || []).filter(item => !MODEL_VERSIONS.has(item.needed_for) || wanted.has(item.id)
|| ['queued', 'downloading', 'error'].includes(item.state));
}
/**
* Pretrained files worth a reminder dot: missing ones that the characters in the
* studio will actually load (the shared ones, English for mixed-in words, v2Pro's
* speaker model, plus their languages; a character with no known language counts
* as every language; a v3 / v4 / v5 model's own files when `needs` lists them). No
* characters, no reminder: an empty studio is only about importing. `due` is false
* once every such file was dismissed with "don't remind me"; a newly needed one
* (say, the first Chinese character) brings the dot back.
*/
export function pretrainedReminder(status, languages, dismissed = [], needs = []) {
const langs = new Set(languages || []);
if (!langs.size) return { items: [], size: 0, due: false };
const wanted = new Set(needs);
const needed = (item) => (MODEL_VERSIONS.has(item.needed_for) ? wanted.has(item.id)
: ['all', 'en', 'v2pro'].includes(item.needed_for) || langs.has(item.needed_for) || langs.has(''));
const items = (status?.pretrained || []).filter(item => (item.state === 'missing' || item.state === 'error') && needed(item));
const seen = new Set(dismissed);
return { items, size: items.reduce((sum, item) => sum + (item.size || 0), 0), due: items.some(item => !seen.has(item.id)) };
}
/**
* Required pretrained files still missing for a character in `language` ('' = not chosen yet)
* whose SoVITS model needs the pretrained ids `needs` (a v4 model: its base model and vocoder).
*/
export function missingForLanguage(status, language, needs = []) {
const wanted = new Set(needs);
return (status?.pretrained || []).filter(item => item.required && item.state !== 'ok'
&& (item.needed_for === 'all' || (language && item.needed_for === language) || wanted.has(item.id)));
}
/**
* One import row at a glance: `tone` colours it (busy | ready | skip | merge | error | idle)
* and `key` / `params` are its state text. `existing` comes from inspect with a target.
* `queued` false: a file nobody claims yet, uploaded once it is put into a character.
*/
export function rowState({ error = '', uploaded = false, queued = true, progress = 0, existing = null, unsupported = false }) {
if (error) return { tone: 'error', key: 'ttsImportRowError', params: { error } };
if (!uploaded && !queued) return { tone: 'idle', key: 'ttsImportRowNotSent', params: {} };
if (!uploaded) return { tone: 'busy', key: 'ttsImportRowUploading', params: { percent: Math.floor(100 * progress) } };
if (existing === 'different') return { tone: 'error', key: 'ttsImportRowConflict', params: {} };
if (unsupported) return { tone: 'error', key: 'ttsImportRowUnsupported', params: {} };
if (existing === 'same') return { tone: 'skip', key: 'ttsImportRowSkip', params: {} };
if (existing === 'merge') return { tone: 'merge', key: 'ttsImportRowMerge', params: {} };
return { tone: 'ready', key: 'ttsImportRowReady', params: {} };
}
/** Summary for the setup card: what still needs doing (`needs`: see shownPretrained). */
export function setupSummary(status, needs = []) {
const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0);
const missing = shownPretrained(status, needs).filter(item => item.state !== 'ok');
const packages = Object.values(status?.dependencies || {}).reduce((sum, dep) => sum + (dep.missing?.length || 0), 0);
const downloading = missing.some(item => item.state === 'queued' || item.state === 'downloading');
const requiredMissing = missing.filter(item => item.required).length;
return { characters, missing: missing.length, requiredMissing, packages, downloading,
ready: characters > 0 && requiredMissing === 0 && packages === 0 };
}