- audio_engines.js: F5-TTS and GPT-SoVITS (the separate Anomalous_TTS node
pack), detected through /object_info/<node class>. Both engines' voices are
normalised into one voice-group shape so cards, sidebar and Script Director
share one implementation.
- The audio page shows an engine switch; a missing engine stays listed as
"not installed" with install steps (no popups). The F5-TTS library still
works without the F5-TTS node; GPT-SoVITS is never scanned when missing.
- GPT-SoVITS cards come only from the node's HTTP contract: preview reference
clips, drag a character onto AnomalousTTS_CharacterSpeech, and edit the
emotion references ("Edit emotions"), saved through /anomalous_tts/settings
with unknown fields preserved and the node's validation messages shown.
- Drop targets and the Script Director are per engine: voices of one engine
are refused on the other engine's node with the reason.
- Audio gallery reads the character of GPT-SoVITS generations.
- Usage guide (in-app and docs/guides/audio-studio.md) covers both engines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
113 lines
4.9 KiB
JavaScript
113 lines
4.9 KiB
JavaScript
import { comboValueForPath } from './audio_script.js';
|
|
|
|
/**
|
|
* The only canvas nodes the audio studio writes into, and what each accepts.
|
|
* No guessing by widget name: a node that is not listed here is refused.
|
|
*
|
|
* engine: whose voices the node takes (audio_engines.js); voices of another engine are refused.
|
|
* takes:
|
|
* 'character' — the node picks a character and switches emotions itself through
|
|
* `{emotion}` tags in its text (F5-TTS multi-voice, Anomalous_TTS).
|
|
* 'clip' — the node uses exactly one audio file (ComfyUI's Load Audio; files
|
|
* from the input folder, i.e. the F5-TTS voice library).
|
|
* listedOnly: write only values the node's own dropdown offers (in its spelling).
|
|
* overriddenBy: an input that, when linked, makes the node ignore voiceWidget.
|
|
* speechWidget: where the Script Director writes the tagged script.
|
|
*
|
|
* To support a new node, add one entry and a test; nothing else changes.
|
|
*/
|
|
export const AUDIO_NODE_TARGETS = Object.freeze([
|
|
Object.freeze({
|
|
id: 'f5-tts',
|
|
label: 'F5-TTS',
|
|
engine: 'f5',
|
|
types: Object.freeze(['F5TTSAudio', 'F5TTSAudioAdvanced', 'F5TTSAudioFromModel']),
|
|
typeLabels: Object.freeze(['F5-TTS Audio', 'F5-TTS Audio Advanced', 'F5-TTS Audio From Model']),
|
|
takes: 'character',
|
|
voiceWidget: 'sample',
|
|
listedOnly: true,
|
|
overriddenBy: 'sample_audio',
|
|
speechWidget: 'speech',
|
|
}),
|
|
Object.freeze({
|
|
id: 'anomalous-tts',
|
|
label: 'GPT-SoVITS',
|
|
engine: 'gpt_sovits',
|
|
types: Object.freeze(['AnomalousTTS_CharacterSpeech']),
|
|
typeLabels: Object.freeze(['角色语音 (GPT-SoVITS)']),
|
|
takes: 'character',
|
|
voiceWidget: 'character',
|
|
listedOnly: true,
|
|
speechWidget: 'text',
|
|
}),
|
|
Object.freeze({
|
|
id: 'load-audio',
|
|
label: 'Load Audio',
|
|
engine: 'f5',
|
|
types: Object.freeze(['LoadAudio']),
|
|
typeLabels: Object.freeze(['Load Audio']),
|
|
takes: 'clip',
|
|
voiceWidget: 'audio',
|
|
// Load Audio lists only the top of input/, but validates by file existence, so subfolders work.
|
|
listedOnly: false,
|
|
}),
|
|
]);
|
|
|
|
export const SUPPORTED_TARGET_LABELS = AUDIO_NODE_TARGETS.map(target => target.label);
|
|
|
|
export function targetForNode(node) {
|
|
const type = node?.type || node?.comfyClass;
|
|
return AUDIO_NODE_TARGETS.find(target => target.types.includes(type)) || null;
|
|
}
|
|
|
|
/** Nodes the Script Director can write a tagged script into; `engine` narrows to one engine's nodes. */
|
|
export function isScriptTarget(node, engine = null) {
|
|
const target = targetForNode(node);
|
|
return Boolean(target?.speechWidget && target.takes === 'character' && (!engine || target.engine === engine));
|
|
}
|
|
|
|
function widgetOf(node, name) {
|
|
return name ? node?.widgets?.find(widget => widget.name === name) || null : null;
|
|
}
|
|
|
|
function inputLinked(node, name) {
|
|
return Boolean(name && node?.inputs?.some(input => input?.name === name && input.link != null));
|
|
}
|
|
|
|
const forwardSlashes = value => String(value ?? '').replace(/\\/g, '/');
|
|
|
|
/**
|
|
* Decide what dropping `item` on `node` does, without touching the node.
|
|
* item: { kind: 'character', group } or { kind: 'clip', slice }.
|
|
* Returns { ok: true, target, widget, value, voice } or { ok: false, reason, target?, ... }.
|
|
*/
|
|
export function planVoiceDrop(node, item) {
|
|
const target = targetForNode(node);
|
|
if (!target) return { ok: false, reason: 'unsupported' };
|
|
const engine = item?.kind === 'character' ? item.group?.engine : item?.slice?.engine;
|
|
if ((engine || 'f5') !== target.engine) return { ok: false, reason: 'otherEngine', target };
|
|
if (item?.kind !== target.takes) {
|
|
return { ok: false, reason: target.takes === 'character' ? 'needsCharacter' : 'needsClip', target };
|
|
}
|
|
const path = item.kind === 'character'
|
|
? (item.group?.has_main === false ? null : item.group?.node_value ?? item.group?.main_relative_path)
|
|
: item.slice?.relative_path;
|
|
if (!path) return { ok: false, reason: 'noMainVoice', target };
|
|
if (inputLinked(node, target.overriddenBy)) return { ok: false, reason: 'overridden', target };
|
|
const widget = widgetOf(node, target.voiceWidget);
|
|
if (!widget) return { ok: false, reason: 'unsupported', target };
|
|
const value = target.listedOnly ? comboValueForPath(widget, path) : forwardSlashes(path);
|
|
if (value == null) return { ok: false, reason: 'notListed', target, path };
|
|
return { ok: true, target, widget, value };
|
|
}
|
|
|
|
/** Rewrite template values ("F5-TTS/x.wav") to the node's own spelling where the node lists them. */
|
|
export function alignedVoiceValue(node) {
|
|
const target = targetForNode(node);
|
|
if (!target?.listedOnly) return null;
|
|
const widget = widgetOf(node, target.voiceWidget);
|
|
if (!widget || typeof widget.value !== 'string') return null;
|
|
const value = comboValueForPath(widget, widget.value);
|
|
return value != null && value !== widget.value ? { widget, value } : null;
|
|
}
|