Files
DemonGatanjieu-Anomalous_Mo…/web/modules/audio_engines.js
T
DemonGatanjieuandClaude Opus 5.5 721a006b16 refactor(audio): drop F5-TTS, the audio page manages GPT-SoVITS only
The F5-TTS voice library (input/F5-TTS scanning, voice upload with ffmpeg
conversion, romanization, Load Audio clip drags) and the engine switch are
removed. The audio page now works with one engine, Anomalous_TTS
(GPT-SoVITS): characters, drags onto its node, the Script Director with
direct generation, pronunciation and settings. The audio gallery and its
stream route stay (output and temp folders only). The root PATH hack that
existed for F5-TTS's ffmpeg lookup is gone. Recorded as AD-016.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:53:07 +08:00

228 lines
9.0 KiB
JavaScript

import { TTS_NODE_CLASS } from './audio_script.js';
/**
* The speech engine the audio page manages: GPT-SoVITS through the Anomalous_TTS
* node pack. Anomalous never bundles it; it is detected at runtime, and a missing
* pack only changes what the audio page shows.
*
* Characters are normalised into "voice groups" that the studio cards, sidebar
* and Script Director share:
* { group, character, language, has_main, node_value, total_slices, raw,
* slices: [{ emotion, is_main, text, audio_url, syntax_tag, relative_path }] }
* `node_value` is what goes into the node's `character` widget.
*/
export const TTS_ENGINE = Object.freeze({
label: 'GPT-SoVITS',
pack: 'Anomalous_TTS',
// Not published yet: the install card explains instead of linking.
repoUrl: '',
managerSearch: 'Anomalous TTS',
});
// ---------- normalisation ----------
export function ttsAudioUrl(name, path) {
return `/anomalous_tts/audio?character=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`;
}
function ttsSlice(name, emotion, ref, isMain) {
return {
id: `${name}:${emotion}`,
character: name,
emotion,
is_main: isMain,
filename: String(ref.audio || '').split('/').pop(),
relative_path: ref.audio || '',
text: ref.text || '',
synthesis_text: ref.text || '',
language: ref.language || '',
source: ref.source || '',
syntax_tag: `{${emotion}}`,
audio_url: ref.audio ? ttsAudioUrl(name, ref.audio) : '',
};
}
/** Anomalous_TTS `GET /anomalous_tts/characters` → voice groups (docs: claude/anomalous-tts-interface.md). */
export function gptSovitsGroups(payload) {
return (payload?.characters || []).filter(item => item && typeof item.name === 'string').map(item => {
const name = item.name;
const slices = [];
if (item.reference?.audio) slices.push(ttsSlice(name, 'main', item.reference, true));
for (const [emotion, ref] of Object.entries(item.emotions || {})) {
if (emotion !== 'main' && ref?.audio) slices.push(ttsSlice(name, emotion, ref, false));
}
return {
group: `gpt_sovits:${name}`,
character: name,
language: item.language || '',
aliases: Array.isArray(item.aliases) ? item.aliases : [],
error: item.error || item.settings_error || '',
has_main: Boolean(item.reference?.audio) && !item.error,
node_value: name,
total_slices: slices.length,
slices,
raw: item,
};
}).sort((a, b) => (b.has_main - a.has_main) || a.character.localeCompare(b.character));
}
// ---------- detection + loading ----------
//
// Switching to the audio domain, clicking a sidebar entry or saving a setting all
// re-render the studio and the sidebar. They must not hit the server each time:
// `/object_info/<class>` makes ComfyUI rebuild that node's inputs (a folder scan),
// and a character list can be large. So both results are kept until something
// says they are stale: the Refresh button, a save, an added voice, or MAX_AGE_MS.
const MAX_AGE_MS = 60_000;
let installCache = null; // { at, promise } — node pack presence
let loadCache = null; // { at, promise } — the character list
let rescanNext = false;
async function getJson(url, signal) {
const resp = await fetch(url, { signal });
const data = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, data };
}
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
async function isNodeInstalled(nodeClass) {
try {
const { ok, data } = await getJson(`/object_info/${encodeURIComponent(nodeClass)}`);
return ok && Boolean(data && data[nodeClass]);
} catch (_) {
return false;
}
}
function fresh(entry) {
return entry && Date.now() - entry.at < MAX_AGE_MS;
}
async function loadGroups() {
// The node caches its folder scan; `refresh=1` makes it look at the disk again.
const url = rescanNext ? '/anomalous_tts/characters?refresh=1' : '/anomalous_tts/characters';
rescanNext = false;
const { ok, status, data } = await getJson(url);
if (!ok || !data) throw new Error(`HTTP ${status}`);
return gptSovitsGroups(data);
}
/**
* Forget cached engine data. `rescan: true` (the Refresh button) also asks the
* node to re-read its folders instead of answering from its own cache.
*/
export function invalidateEngineCache({ rescan = false } = {}) {
loadCache = null;
installCache = null;
statusCache = null;
if (rescan) rescanNext = true;
}
let statusCache = null; // { at, promise } — GPT-SoVITS setup status
/**
* The GPT-SoVITS node's setup status (libraries, pretrained files, packages; interface v3),
* or null for a node that predates it (no `/anomalous_tts/status`). Cached like loadVoices;
* `force` skips the cache (download progress polling).
*/
export function loadGptSovitsStatus({ force = false } = {}) {
if (!force && fresh(statusCache)) return statusCache.promise;
const promise = getJson('/anomalous_tts/status').then(({ ok, status, data }) => {
if (status === 404) return null;
if (!ok || !data) throw new Error(`HTTP ${status}`);
return data;
});
statusCache = { at: Date.now(), promise };
promise.catch(() => { if (statusCache?.promise === promise) statusCache = null; });
return promise;
}
/**
* { installed, groups, error }: the GPT-SoVITS characters, none while the node pack
* is missing. Concurrent callers (studio + sidebar) share one request.
*/
export function loadVoices() {
if (fresh(loadCache)) return loadCache.promise;
const promise = loadVoicesNow();
loadCache = { at: Date.now(), promise };
// A failure must not stick for a minute.
promise.then(result => { if (result.error && loadCache?.promise === promise) loadCache = null; });
return promise;
}
async function loadVoicesNow() {
const installed = await isTtsInstalled();
if (!installed) return { installed, groups: [], error: '' };
try {
return { installed, groups: await loadGroups(), error: '' };
} catch (e) {
return { installed, groups: [], error: e.message || String(e) };
}
}
/** Whether ComfyUI has the Anomalous_TTS node. Cached like loadVoices. */
export function isTtsInstalled() {
if (fresh(installCache)) return installCache.promise;
const promise = isNodeInstalled(TTS_NODE_CLASS);
installCache = { at: Date.now(), promise };
return promise;
}
/**
* One GPT-SoVITS character with its file lists (`gpt`, `sovits`, `audio`), which the
* summary list leaves out because a folder can hold thousands of clips.
*/
export async function fetchGptSovitsCharacter(name, signal) {
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
return data.character;
}
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
/**
* Replace a character's anomalous_tts.json through the node's API. `settings` must be the
* full object (unknown fields preserved by the caller). Returns the refreshed group.
*/
export async function saveGptSovitsSettings(name, settings) {
const resp = await fetch('/anomalous_tts/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ character: name, settings }),
});
if (!resp.ok) {
const message = await resp.text().catch(() => '');
throw new Error(message || `HTTP ${resp.status}`);
}
const data = await resp.json().catch(() => ({}));
invalidateEngineCache();
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
}
/**
* Build the new settings for an emotion editor result without dropping fields
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
*/
export function mergeGptSovitsSettings(previous, { main, emotions }) {
const next = { ...(previous || {}), format: 1 };
if (main?.audio) {
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
next.reference = { ...kept, audio: main.audio };
if (main.text) next.reference.text = main.text; else delete next.reference.text;
} else {
delete next.reference;
}
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
const out = {};
for (const row of emotions || []) {
const name = String(row?.name || '').trim();
if (!name || name === 'main' || !row.audio) continue;
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
out[name] = { ...kept, audio: row.audio };
if (row.text) out[name].text = row.text; else delete out[name].text;
}
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
return next;
}