Files
DemonGatanjieu-Anomalous_Mo…/web/modules/tts_setup_api.js
T
DemonGatanjieuandClaude Opus 5.5 fd966a7ee3 feat(audio): simpler GPT-SoVITS import form
- Fixed sections for GPT weight, SoVITS weight, reference clips and line
  files; files dropped anywhere on the form go to their section. One GPT
  and one SoVITS: a new one replaces the old, and from a batch the latest
  epoch is kept (the others are named in a note).
- The save location is one line ("Saved to …/gpt_sovits/<name>") with
  "Change location"; the library list only shows when asked for.
- Name clashes are shown while typing; an exact match offers "add to that
  character instead", which switches the form to add mode in place.
- Unsupported SoVITS versions (v3/v4) are marked and block the import;
  clips outside 3-10 s cannot be the main voice.
- After an import the studio scrolls to the new card and highlights it.

Tested: node tests/tts_setup_api.mjs (epoch pick, name clash, version
block, clip range added). Isolated ComfyUI in the browser: name clash and
switch to add mode, three GPT epochs -> e15 kept, SoVITS replaced by a v4
file -> blocked, short clips not selectable as main, add-mode import of a
clip as {平静} with the card highlighted.

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

212 lines
9.4 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', '.list': 'text',
};
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;
}
/** 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 };
}