Files
DemonGatanjieu-Anomalous_Mo…/web/modules/audio_node_targets.js
T
DemonGatanjieuandClaude Opus 5.5 ff726db2f6 feat(audio): generate GPT-SoVITS scripts in the Script Director, drop the Arona template
The Script Director gets a Generate section for GPT-SoVITS characters
(ui_script_run.js): it queues the script as an API prompt with this
page's client id (audio_tts_run.js), shows queued/running/progress,
plays the result and saves it to output/audio/<character>/. The canvas
is never touched. A line card's retake button adds [take:N] to that
line so only it is regenerated; "New take of all" changes the seed.
Cancel deletes a queued job or interrupts it by prompt id when running
(checked against /queue, since the start event can arrive first).
Events that arrive before /prompt answers are buffered and replayed.
Language/speed start from the character's `defaults` and can be saved
back (read-modify-write, unknown fields kept). Needs Anomalous_TTS
interface 11; older versions get a hint and keep push-to-node.

The Arona multi-voice template read a file only one machine has; the
button, route, helper (alignedVoiceValue) and strings are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:25:05 +08:00

103 lines
4.5 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 };
}