Files
DemonGatanjieuandClaude Opus 5.5 09ae7c6b33 Voice-over: pick on the left, change it on the right; settings per character and per line
The side panel follows what is picked: the whole script, one character in
it (its language, speed and mode for this script), or the picked lines (who
reads them, and their own values, each following the character until set).
A line set for itself carries a tag on its card. The values reach
Anomalous_TTS interface 17 as [lang:…][speed:…][preset:…] on each line;
an older node reads the whole script as before. Clicking a character in the
cast now shows it instead of giving it the picked lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:22 +08:00

226 lines
9.8 KiB
JavaScript
Raw Permalink 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 GPT-SoVITS (Anomalous_TTS) scripts (no DOM, no canvas access).
*
* The node takes a character and a script in which `{happy}` switches to that
* emotion's reference, `{main}` back to the main one, and `[take:N]` asks for
* another take of one line; `[lang:…]` `[speed:…]` `[preset:…]` set how one line is
* read (script_tune.js). `buildTtsPrompt` runs a script without a canvas.
*/
import { readTuneTag } from './script_tune.js';
export const MAIN_TAG = '{main}';
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
/** Return the widget's own spelling of `relativePath` (slashes may differ on Windows), 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's `tune` (its own `[lang:…][speed:…][preset:…]`) and, with `take` > 1 (GPT-SoVITS only),
* `[take:N]` go right before its text; a line's `speaker` (`[name]`, where another character
* takes over) goes first.
*/
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.tune || ''}${line.take > 1 ? `[take:${line.take}]` : ''}${line.text}`;
return `${line.speaker || ''}${tag ? `${tag} ${text}` : text}`;
})
.join('\n')
.trim();
}
/** Emotions of a voice group the node can switch to with `{emotion}` (main first). */
export function usableEmotions(group) {
return (group?.slices || []).map(slice => slice.emotion);
}
const speaks = group => Boolean(group?.node_value) && group.has_main !== false && usableEmotions(group).includes('main');
/**
* Bundle script lines for one TTS node. `lines` are `{ text, emotion, speaker? }`; the node's
* `character` widget gets the group's `node_value`. A line's `speaker` (a voice group key from
* `groups`) is read by that character instead: `[name]` goes in where the speaker changes
* (Anomalous_TTS's script syntax). Every emotion must be usable for its line's character.
* `takes` (runs in the director) writes each line's `take` as `[take:N]`; `tagsOf(line, speaker)`
* gives the tags for how a line is read (the director's run section).
* Returns `{ sample, speech, lineCount, speakers }` (the characters heard, the node's first)
* or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group, { takes = false, groups = [], tagsOf = null } = {}) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
if (!speaks(group)) return { error: 'scriptDirectorMainMissing' };
const byKey = new Map(groups.map(item => [item.group, item]));
const voiced = [];
const speakers = [group.node_value];
let current = group;
for (const line of lines || []) {
const text = String(line?.text || '').trim();
if (!text) continue;
const speaker = line.speaker && line.speaker !== group.group ? byKey.get(line.speaker) : group;
if (!speaks(speaker)) return { error: 'scriptDirectorSpeakerUnusable' };
const emotion = line.emotion || 'main';
if (!usableEmotions(speaker).includes(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
voiced.push({
text,
voice: emotion === 'main' ? null : { tag: `{${emotion}}` },
take: takes ? line.take || 1 : 1,
tune: tagsOf?.(line, speaker) || '',
speaker: speaker === current ? '' : `[${speaker.node_value}]`,
});
current = speaker;
if (!speakers.includes(speaker.node_value)) speakers.push(speaker.node_value);
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: group.node_value, speech: composeScript(voiced), lineCount: voiced.length, speakers };
}
/** 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
* (`preview: true`: Preview Audio, a temp file that stays out of the audio gallery).
* `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', preview = false, ...inputs }) {
return {
1: {
class_type: TTS_NODE_CLASS,
inputs: { character, text: speech, seed, language, ...inputs },
},
2: preview
? { class_type: 'PreviewAudio', inputs: { audio: ['1', 0] } }
: { class_type: 'SaveAudio', inputs: { audio: ['1', 0], filename_prefix: ttsOutputPrefix(character) } },
};
}
const TAKE_TAG = /^\s*(?:take|版本)\s*[::]\s*\d+\s*$/i;
const PAUSE_TAG = /^\s*(?:pause|wait|stop|停顿|暂停)\s*[::]/i;
/**
* Read a tagged script back into segments, splitting at `{emotion}` and `[name]` tags
* (`speaker`: the name, only on segments after one; the emotion goes back to main there).
* Text before the first tag belongs to the main voice; `[take:N]` is left out, pauses stay.
* A line's own settings start a segment of their own and come back as its `tune` (they cover
* its first line only).
*/
export function parseTaggedSpeech(speech) {
const segments = [];
let speaker = null;
let emotion = 'main';
let text = '';
let tune = null;
const flush = () => {
const words = text.trim();
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}), ...(tune ? { tune } : {}) });
if (words) tune = null;
text = '';
};
for (const part of String(speech || '').split(/(\{[^{}]+\}|\[[^[\]]+\])/)) {
const tag = /^\{([^{}]+)\}$/.exec(part);
const bracket = /^\[([^[\]]+)\]$/.exec(part);
if (tag) {
flush();
emotion = tag[1].trim();
} else if (bracket && TAKE_TAG.test(bracket[1])) {
continue; // another take only meant something for that run
} else if (bracket && readTuneTag(bracket[1])) {
flush();
const { field, value } = readTuneTag(bracket[1]);
if (value !== undefined) tune = { ...(tune || {}), [field]: value };
} else if (bracket && !PAUSE_TAG.test(bracket[1])) {
flush();
speaker = bracket[1].trim();
emotion = 'main';
} else {
text += part;
}
}
flush();
return segments;
}