Files
DemonGatanjieu-Anomalous_Mo…/web/modules/audio_script.js
T
DemonGatanjieuandClaude Opus 5.5 d69507724e feat(audio): rework Script Director around per-line emotion cards and a draggable bundle
Assigning a voice used to mean selecting a line card and then clicking a voice
row in the studio behind the drawer, which was not discoverable.

- pick one character at the top (F5-TTS needs one main voice per script); the
  hint lists the emotions that character can voice
- after parsing, each line is a card with editable text, a preview button,
  delete, and a row of emotion chips (main / happy / ...); a choice the current
  character cannot voice stays visible and blocks the bundle instead of being
  silently reset
- the footer shows the bundled speech and offers copy, push to the selected or
  only F5TTSAudio node, and a handle to drag the bundle onto a node
- voice rows and the bundle both use the shared bindMaterialDrag (the browser
  hides while dragging, the target node is highlighted); the studio's own
  window drop listener and the row-click assignment path are removed
- bundling is pure (buildScriptPackage/usableEmotions in audio_script.js)

Verified: tests/audio_script_rules.mjs, jsdom walkthrough of parse -> chips ->
push -> character switch -> drag onto a node, domain navigation walkthrough;
other JS tests unchanged. Not yet checked in a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:55:37 +00:00

67 lines
2.8 KiB
JavaScript

/**
* Pure rules for ComfyUI-F5-TTS voices (no DOM, no canvas access).
*
* F5-TTS multi-voice layout: the node's `sample` is a main voice such as
* `F5-TTS/Arona.wav`; `{happy}` in the speech loads `F5-TTS/Arona.happy.wav`
* beside it, and `{main}` is the sample itself. Speech is split only at tags.
*/
export const MAIN_TAG = '{main}';
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
/** Return the widget's own spelling of `relativePath` (Windows combos use "F5-TTS\\x.wav"), or null when not offered. */
export function comboValueForPath(widget, relativePath) {
const wanted = normalisePath(relativePath);
if (!wanted) return null;
const values = Array.isArray(widget?.options?.values) ? widget.options.values : null;
if (!values) return wanted;
return values.find(value => normalisePath(value) === wanted) ?? null;
}
/** Split on sentence punctuation and newlines; '.' only ends a line before whitespace (keeps 3.5). */
export function splitScriptLines(text) {
return String(text || '')
.split(/\n+|(?<=[。!?!?…])|(?<=\.)(?=\s)/)
.map(line => line.trim())
.filter(Boolean);
}
/** Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice. */
export function composeScript(lines) {
const anyTagged = lines.some(line => line.voice?.tag);
return lines
.map(line => {
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
return tag ? `${tag} ${line.text}` : line.text;
})
.join('\n')
.trim();
}
/** Emotions of a voice group that F5-TTS can load as `{emotion}` (main first). */
export function usableEmotions(group) {
return (group?.slices || []).filter(slice => slice.tag_usable).map(slice => slice.emotion);
}
/**
* Bundle script lines for one F5TTSAudio node. `lines` are `{ text, emotion }`;
* the node's sample becomes the group's main voice and every emotion must be a
* usable variant of that group. Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
const usable = new Set(usableEmotions(group));
if (!group.main_relative_path || !usable.has('main')) return { error: 'scriptDirectorMainMissing' };
const voiced = [];
for (const line of lines || []) {
const text = String(line?.text || '').trim();
if (!text) continue;
const emotion = line.emotion || 'main';
if (!usable.has(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
voiced.push({ text, voice: emotion === 'main' ? null : { tag: `{${emotion}}` } });
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: group.main_relative_path, speech: composeScript(voiced), lineCount: voiced.length };
}