Files
DemonGatanjieu-Anomalous_Mo…/web/modules/tts_setup_api.js
T
DemonGatanjieuandClaude Opus 5.5 c0a6beff04 feat(audio): more ways to find a clip's reference line on import
- Each clip shows where its line came from (same-name .txt / .lab,
  annotation file, or the file name, marked as a guess to check).
- Dropping a .txt / .lab / .list on a clip's line box fills it: a plain
  file is the line, an annotation file is searched for that clip (same
  rule as the node, including the name without the emotion part).
- .lab is an importable line file.

Tested: node tests/tts_setup_api.mjs (textFromFile added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:16:26 +08:00

235 lines
11 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 v3, §5.2–5.3).
* The node owns every file: character libraries, pretrained files and imported
* characters. This module only calls its 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;
}
// ---------- libraries, pretrained files ----------
/** Add (or remove) a character library. Returns the new status. */
export async function changeLibrary(path, remove = false) {
const status = await request('/anomalous_tts/libraries', { body: { path, remove } });
invalidateEngineCache();
return status;
}
/** Use (or stop using) a GPT-SoVITS package as a pretrained source. Returns the new status. */
export async function changePretrainedSource(path, remove = false) {
const status = await request('/anomalous_tts/pretrained/source', { body: { path, remove } });
invalidateEngineCache();
return status;
}
/** Start background downloads; progress shows up in the status. */
export async function startPretrainedDownload(ids) {
await request('/anomalous_tts/pretrained/download', { body: { ids } });
invalidateEngineCache();
}
export function browseFolder(path, signal) {
return request(`/anomalous_tts/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`, { method: 'GET', signal });
}
// ---------- 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;
}
export function inspectImport(files, signal) {
return request('/anomalous_tts/import/inspect', { body: { 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}|{path}, 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, uploading = 0, conflict = null }) {
if (!rows.length) return ['ttsImportNoFiles', {}];
if (uploading) return ['ttsImportStillUploading', { count: uploading }];
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 unsupported = rows.find(row => row.kind === 'sovits' && row.supported === false);
if (unsupported) return ['ttsImportUnsupportedBlock', { file: unsupported.name, version: unsupported.version || '?' }];
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;
}
/** Summary for the setup card: what still needs doing. */
export function setupSummary(status) {
const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0);
const missing = (status?.pretrained || []).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 };
}