From 1.60.1 (6996f07): character cards show the version Anomalous_TTS reports
(interface 15); v3/v4 base models and vocoders are listed and reminded of only
when a character or a dropped SoVITS file needs them, with their own labels;
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>
290 lines
14 KiB
JavaScript
290 lines
14 KiB
JavaScript
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 };
|
||
}
|