Files
DemonGatanjieu-Anomalous_Mo…/web/modules/audio_engines.js
T
DemonGatanjieuandClaude Opus 5.5 a3ce3cae94 Reference audio editor: every clip listed, main and auxiliary references
"Reference audio and emotions" replaces Edit emotions: a large window with
the voices on top (main voice, emotions, new emotion) and every clip of the
character below, one row each (listen, file and length, its line editable
in place, where else it is used, Main / Aux for the voice picked), with
filters, search and paging. Auxiliary references (timbre averaged in, v1 /
v2 / v2Pro), fixed lines (`lines`) and lengths need Anomalous_TTS
interface 18. tts_voice_refs.js holds the rules; the old clip search and
merge helper go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:52:19 +08:00

230 lines
9.2 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',
repoUrl: 'https://github.com/DemonGatanjieu/Anomalous_TTS',
managerSearch: 'Anomalous TTS',
});
/** The voice group (and audio-page filter value) of a GPT-SoVITS character. */
export function voiceGroupKey(name) {
return `gpt_sovits:${name}`;
}
// ---------- 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: the node repo's docs/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: voiceGroupKey(name),
character: name,
language: item.language || '',
model_version: item.version || '', // GPT-SoVITS v2 / v4 / v5dev… (interface 15)
needs: Array.isArray(item.needs) ? item.needs : [], // pretrained ids its model needs
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 };
}
/**
* Each clip's line as the node finds it (a `Map` path -> line; clips without one are left
* out), or null when the node is older than interface 14 and cannot tell.
*/
/**
* Each clip's line as the node finds it and its length in seconds (`seconds`: interface 18,
* empty before): `{ lines: Map, seconds: Map }`, or null when the node cannot tell.
*/
export async function fetchReferenceClips(name, signal) {
const { ok, data } = await getJson(`/anomalous_tts/reference_lines?character=${encodeURIComponent(name)}`, signal);
if (!ok || !data?.lines || typeof data.lines !== 'object') return null;
const seconds = data.seconds && typeof data.seconds === 'object'
? Object.entries(data.seconds).filter(([, value]) => Number.isFinite(value)) : [];
return { lines: new Map(Object.entries(data.lines)), seconds: new Map(seconds) };
}
/** 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) {
return (await fetchGptSovitsDetail(name, signal)).character;
}
/** One character's detail and the node's interface version: `{ character, format }`. */
export async function fetchGptSovitsDetail(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 { character: data.character, format: Number(data.format) || 0 };
}
// ---------- 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;
}