Files
DemonGatanjieu-Anomalous_Mo…/web/modules/audio_script.js
T
DemonGatanjieuandClaude Opus 5.5 e341b8a26d feat(audio): sampling parameters in the Script Director, roomier drawer, no audio workflow tab
- Generate section: top_k / top_p / temperature / repetition_penalty folded
  under "Advanced", ranges and defaults read from the node's /object_info;
  "save as this character's default" keeps language, speed and these (only
  values that differ from the node's defaults). The seed is not saved.
- Drawer 560px wide, line cards keep 180px, footer scrolls; for GPT-SoVITS
  the bundle preview is hidden and the canvas buttons are compact.
- The audio domain no longer shows the "Audio Workflows" header tab (the
  recipe library filtered to "audio"); a `hidden` header button now really
  hides despite the header's display !important rule.

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

175 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure script rules for the audio engines (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.
* GPT-SoVITS (Anomalous_TTS) reads the same `{emotion}` tags, plus `[take:N]`
* for another take of one line; `buildTtsPrompt` runs a script without a canvas.
*/
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;
}
const SENTENCE_END = new Set(['。', '!', '?', '!', '?']);
// Closing quotes/brackets and trailing marks that belong to the sentence they follow.
const TRAILING = new Set(['。', '!', '?', '!', '?', '」', '』', '”', '’', '"', "'", ')', ')', '】', '》', '〉', '~', '~']);
const SPEAKABLE = /[\p{L}\p{N}]/u;
function splitSentences(line) {
const chars = [...line];
const segments = [];
let current = '';
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
current += char;
// A single '.' ends an English sentence only before whitespace; '3.5', '...' and '……' do not split.
const isPeriod = char === '.' && chars[i - 1] !== '.' && chars[i + 1] !== '.' && (i + 1 >= chars.length || /\s/.test(chars[i + 1]));
if (!SENTENCE_END.has(char) && !isPeriod) continue;
while (i + 1 < chars.length && TRAILING.has(chars[i + 1])) current += chars[++i];
segments.push(current);
current = '';
}
segments.push(current);
return segments;
}
/** Punctuation-only pieces ("……", "!」") cannot be spoken on their own: attach them to a neighbour. */
function attachUnspeakable(segments) {
const result = [];
let pending = '';
for (const segment of segments) {
if (SPEAKABLE.test(segment)) {
result.push(pending + segment);
pending = '';
} else if (result.length) {
result[result.length - 1] += segment;
} else {
pending += segment;
}
}
if (pending) result.push(pending);
return result;
}
/**
* Split a script into segments. `sentence`: at 。!?!? (with trailing quotes or
* brackets) and newlines; ellipses never split. `line`: one segment per line.
*/
export function splitScriptLines(text, mode = 'sentence') {
const lines = String(text || '').split(/\n+/).map(line => line.trim()).filter(Boolean);
const segments = mode === 'line' ? lines : lines.flatMap(splitSentences);
return attachUnspeakable(segments.map(segment => segment.trim()).filter(Boolean));
}
/** Split one segment at a caret position; null when either side would be empty. */
export function splitSegmentAt(text, position) {
const value = String(text || '');
const before = value.slice(0, position).trim();
const after = value.slice(position).trim();
return before && after ? [before, after] : null;
}
/** Join two segments: a space only between Latin words/digits, none for CJK text. */
export function joinSegments(first, second) {
const a = String(first || '').trim();
const b = String(second || '').trim();
if (!a || !b) return a || b;
return /[A-Za-z0-9,.;:!?]$/.test(a) && /^[A-Za-z0-9]/.test(b) ? `${a} ${b}` : `${a}${b}`;
}
/**
* Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice.
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text.
*/
export function composeScript(lines) {
const anyTagged = lines.some(line => line.voice?.tag);
return lines
.map(line => {
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
const text = line.take > 1 ? `[take:${line.take}]${line.text}` : line.text;
return tag ? `${tag} ${text}` : 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 TTS node. `lines` are `{ text, emotion }`; the node's
* voice widget gets the group's `node_value` (F5-TTS: main voice file; GPT-SoVITS:
* character name) and every emotion must be usable for that group.
* `takes` (GPT-SoVITS runs only) writes each line's `take` as `[take:N]`.
* Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group, { takes = false } = {}) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
const usable = new Set(usableEmotions(group));
const voice = group.node_value ?? group.main_relative_path;
if (!voice || group.has_main === false || !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}}` }, take: takes ? line.take || 1 : 1 });
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: voice, speech: composeScript(voiced), lineCount: voiced.length };
}
/** Save Audio prefix for a character: `output/audio/<character>/…`, keeping file names Windows accepts. */
export function ttsOutputPrefix(character) {
const parts = String(character || '').split('/')
.map(part => part.replace(/[<>:"\\|?*\u0000-\u001f]/g, '_').trim().replace(/^\.+$/, '_') || '_');
return `audio/${parts.join('/')}/${parts[parts.length - 1]}`;
}
export const TTS_NODE_CLASS = 'AnomalousTTS_CharacterSpeech';
/**
* ComfyUI API prompt for one GPT-SoVITS script: the character node, then Save Audio.
* `inputs` are more node inputs by name (speed, top_k …); left out, the node's defaults apply.
*/
export function buildTtsPrompt({ character, speech, seed, language = 'auto', ...inputs }) {
return {
1: {
class_type: TTS_NODE_CLASS,
inputs: { character, text: speech, seed, language, ...inputs },
},
2: {
class_type: 'SaveAudio',
inputs: { audio: ['1', 0], filename_prefix: ttsOutputPrefix(character) },
},
};
}
/**
* Read an F5-TTS speech text back into segments, splitting at tags like F5-TTS
* does. Text before the first tag belongs to the main voice.
*/
export function parseTaggedSpeech(speech) {
return String(speech || '')
.split(/(?=\{[^}]+\})/)
.map(part => {
const match = /^\{([^}]+)\}/.exec(part);
return { emotion: match ? match[1].trim() : 'main', text: (match ? part.slice(match[0].length) : part).trim() };
})
.filter(segment => segment.text);
}