Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_run.js
T
DemonGatanjieuandClaude Opus 5.5 87aa9a1ac2 Put Voice-over back on the Voices page, with calmer settings and cards
Voices has two views switched at the top, Characters and Voice-over, sharing
the character list (clicking a character on Voice-over makes it speak); the
rail's Voices entry reopens the view used last, and the separate Voice-over
rail entry is gone. Generation settings: language as choices and speed as a
slider in view, the sampling numbers folded under fine-tuning with plain
names, no sideways scroll. A finished take says how long it took and links
to the audio gallery. Line cards show editing buttons on hover, the emotion's
reference clip sits beside its chips, and a character with only its main
voice shows no chips. The audio gallery shows each file's lines once and
can open them on Voice-over for another take; the list no longer repeats
the gallery. Also fixes the audio list staying empty after a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:06:45 +08:00

342 lines
16 KiB
JavaScript

import { t } from './interface_settings.js';
import { anomalousAlert } from './ui_dialog.js';
import { buildTtsPrompt, TTS_NODE_CLASS } from './audio_script.js';
import { startPromptJob } from './audio_tts_run.js';
import { fetchGptSovitsCharacter, loadGptSovitsStatus, saveGptSovitsSettings } from './audio_engines.js';
/**
* The Script Director's "Generate" section for GPT-SoVITS characters: runs the
* script through ComfyUI's queue (audio_tts_run.js) without touching the canvas,
* plays the result, and offers another take of the whole script (new seed) or of
* one line (`[take:N]`, the other lines come from the node's cache). Language,
* speed and the sampling parameters start from the character's `defaults` and a
* setting that worked can be saved back to it.
*
* The director owns the lines; this section owns the job, the options and the
* result, which live for the page session like the director's panel. It has two
* parts that the director places together: `bar` (generate / retake / cancel and the
* result, with a way to the audio gallery) and `settings` (language and speed in
* view, the sampling numbers folded under "fine-tuning").
*/
const LANGUAGES = ['auto', 'zh', 'ja', 'en'];
const MIN_FORMAT = 11; // Anomalous_TTS interface with [take:N] and `defaults`
// Folded under "fine-tuning"; ranges and defaults come from the node's own input spec.
const SAMPLING = ['top_k', 'top_p', 'temperature', 'repetition_penalty'];
const NUMBERS = ['speed', ...SAMPLING];
const OPEN_KEY = 'anomalous_script_run_settings_open';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) btn.title = title;
btn.onclick = onClick;
return btn;
}
function newSeed() {
return Math.floor(Math.random() * 2 ** 31);
}
function audioUrl(audio) {
const query = new URLSearchParams({ filename: audio.filename, subfolder: audio.subfolder || '', type: audio.type || 'output' });
return `/view?${query}`;
}
/** `{ speed: { default, min, max, step }, top_k: … }` from ComfyUI's description of the node. */
async function loadNodeSpec() {
const resp = await fetch(`/object_info/${TTS_NODE_CLASS}`);
const info = (await resp.json())?.[TTS_NODE_CLASS]?.input || {};
const inputs = { ...(info.required || {}), ...(info.optional || {}) };
const spec = {};
for (const name of NUMBERS) {
const options = inputs[name]?.[1];
if (options && typeof options.default === 'number') spec[name] = options;
}
return spec;
}
/**
* `getScript()` -> `{ group, pkg }` for the chosen character (pkg from buildScriptPackage
* with takes); `onPlay()` when the result starts playing;
* `resetTakes()` when a new take of the whole script starts; `onBusyChange()` when a job
* starts or ends (the director re-renders its line cards, whose retake buttons depend on it);
* `openGallery()` for the result's "saved to the audio gallery".
*/
export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange, openGallery }) {
const state = {
seed: newSeed(),
spec: null, // node input ranges/defaults; null until loaded
options: new Map(), // character -> options being edited
saved: new Map(), // character -> options saved in this session
job: null,
status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, seconds? }
result: null, // { audio, url, warnings, character }
supported: null, // TTS interface >= MIN_FORMAT; null while unknown
};
const bar = el('div', 'anomalous-sd-run');
const hint = el('div', 'anomalous-sd-run-hint is-warning', t('scriptRunNeedsUpdate'));
// How it is read: language and speed, always in view, one per row.
const settings = el('div', 'anomalous-sd-run-settings');
const fields = {};
/** A number the user set: clamped to the node's range and kept for this character. */
const commit = (name, raw) => {
const script = getScript();
const range = state.spec?.[name];
let value = Number(raw);
if (!Number.isFinite(value) || !range) value = range?.default ?? 1;
value = Math.min(range?.max ?? value, Math.max(range?.min ?? value, name === 'top_k' ? Math.round(value) : value));
if (script?.group) currentOptions(script.group)[name] = value;
render();
};
const languageRow = el('div', 'anomalous-sd-run-row');
const languageChoices = el('div', 'anomalous-sd-run-segment');
languageChoices.setAttribute('role', 'radiogroup');
const languageButtons = new Map();
for (const code of LANGUAGES) {
const choice = button('anomalous-sd-run-choice', t(`scriptRunLang_${code}`), () => {
const script = getScript();
if (script?.group) currentOptions(script.group).language = code;
render();
});
choice.setAttribute('role', 'radio');
languageButtons.set(code, choice);
languageChoices.append(choice);
}
languageRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunLanguage')), languageChoices);
const speedRow = el('label', 'anomalous-sd-run-row');
const speed = el('input', 'anomalous-sd-run-slider');
speed.type = 'range';
speed.title = t('scriptRunParamHint_speed');
const speedValue = el('span', 'anomalous-sd-run-value');
speed.oninput = () => { speedValue.textContent = speed.value; };
speed.onchange = () => commit('speed', speed.value);
fields.speed = speed;
speedRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunSpeed')), speed, speedValue);
// The sampling numbers, folded: a plain name with the node's own name under it.
const fine = el('details', 'anomalous-sd-run-fine');
const fineSummary = el('summary');
try { fine.open = localStorage.getItem(OPEN_KEY) === '1'; } catch (_) { /* folded by default */ }
fine.addEventListener('toggle', () => {
try { localStorage.setItem(OPEN_KEY, fine.open ? '1' : '0'); } catch (_) { /* convenience only */ }
});
fine.append(fineSummary);
for (const name of SAMPLING) {
const row = el('label', 'anomalous-sd-run-row');
const label = el('span', 'anomalous-sd-run-label');
label.append(el('span', '', t(`scriptRunParamName_${name}`)), el('code', 'anomalous-sd-run-code', name));
const input = el('input', 'anomalous-sd-run-number');
input.type = 'number';
input.title = t(`scriptRunParamHint_${name}`);
input.onchange = () => commit(name, input.value);
fields[name] = input;
row.append(label, input);
fine.append(row);
}
fine.append(el('div', 'anomalous-sd-run-note', t('scriptRunAdvancedNote')));
const saveDefaults = button('anomalous-sd-link-btn', t('scriptRunSaveDefaults'), () => storeDefaults(), t('scriptRunSaveDefaultsHint'));
settings.append(el('div', 'anomalous-sd-run-heading', t('scriptRunHow')), languageRow, speedRow, fine, saveDefaults);
const actions = el('div', 'anomalous-sd-run-actions');
const generateBtn = button('anomalous-sd-btn accent', t('scriptRunGenerate'), () => generate());
const newTakeBtn = button('anomalous-sd-btn', t('scriptRunNewTake'), () => {
state.seed = newSeed();
resetTakes?.();
generate();
}, t('scriptRunNewTakeHint'));
const cancelBtn = button('anomalous-sd-btn', t('scriptRunCancel'), () => state.job?.cancel());
const status = el('span', 'anomalous-sd-run-status');
actions.append(generateBtn, newTakeBtn, cancelBtn, status);
const player = el('audio', 'anomalous-sd-run-player');
player.controls = true;
player.preload = 'auto';
player.addEventListener('play', () => onPlay?.());
const savedTo = button('anomalous-sd-link-btn anomalous-sd-run-saved', t('scriptRunSavedLink'), () => openGallery?.());
const warnings = el('div', 'anomalous-sd-run-warnings');
bar.append(hint, actions, player, savedTo, warnings);
/** The character's saved defaults over the node's own defaults. */
function storedOptions(group) {
if (state.saved.has(group.character)) return state.saved.get(group.character);
const defaults = group?.raw?.settings?.defaults || {};
const out = { language: LANGUAGES.includes(defaults.language) ? defaults.language : 'auto' };
for (const name of NUMBERS) {
out[name] = typeof defaults[name] === 'number' ? defaults[name] : state.spec?.[name]?.default ?? null;
}
return out;
}
function currentOptions(group) {
if (!state.options.has(group.character)) state.options.set(group.character, { ...storedOptions(group) });
return state.options.get(group.character);
}
const sameOptions = (a, b) => ['language', ...NUMBERS].every(name => a[name] === b[name]);
async function checkSupport() {
try {
const [status, spec] = await Promise.all([loadGptSovitsStatus(), loadNodeSpec()]);
state.spec = spec;
state.supported = Number(status?.format) >= MIN_FORMAT && NUMBERS.every(name => spec[name]);
} catch (_) {
state.supported = false;
}
state.options.clear(); // options built before the spec arrived lack the node's defaults
render();
}
async function generate() {
const script = getScript();
if (!script?.group || script.pkg.error || state.job || !state.supported) return;
const character = script.group.character;
const { language: lang, ...numbers } = currentOptions(script.group);
const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, language: lang, ...numbers });
state.status = { state: 'queued' };
const started = Date.now();
state.job = startPromptJob(prompt, {
onStatus: next => {
state.status = next;
renderStatus();
},
});
onBusyChange?.();
try {
const { outputs } = await state.job.result;
const audio = outputs['2']?.audio?.[0];
if (!audio) throw new Error(t('scriptRunNoAudio'));
// The first line is the node's own count (its sentences, batches, cache), not the reader's lines.
const [, ...rest] = String(outputs['1']?.text?.[0] || '').split('\n');
state.result = { audio, url: `${audioUrl(audio)}&t=${Date.now()}`, warnings: rest.filter(Boolean), character };
state.status = { state: 'done', seconds: Math.max(1, Math.round((Date.now() - started) / 1000)) };
state.job = null;
onBusyChange?.();
player.play().catch(() => {});
} catch (error) {
state.job = null;
state.status = error.cancelled ? { state: 'cancelled' } : null;
onBusyChange?.();
if (!error.cancelled) await anomalousAlert(t('scriptRunFailed', { error: error.message }));
}
}
async function storeDefaults() {
const script = getScript();
if (!script?.group) return;
const { character } = script.group;
const opts = { ...currentOptions(script.group) };
saveDefaults.disabled = true;
try {
// Start from the file as it is now, so edits made elsewhere are kept.
const fresh = await fetchGptSovitsCharacter(character);
if (fresh.settings_error) throw new Error(t('scriptRunSettingsBroken', { error: fresh.settings_error }));
const settings = { ...(fresh.settings || {}), format: 1 };
// Only what differs from the node's own defaults, so the file stays short.
const defaults = {};
if (opts.language !== 'auto') defaults.language = opts.language;
for (const name of NUMBERS) {
if (opts[name] !== state.spec[name].default) defaults[name] = opts[name];
}
if (Object.keys(defaults).length) settings.defaults = defaults; else delete settings.defaults;
await saveGptSovitsSettings(character, settings);
state.saved.set(character, opts);
saveDefaults.textContent = t('scriptRunDefaultsSaved');
setTimeout(() => { saveDefaults.textContent = t('scriptRunSaveDefaults'); render(); }, 1600);
} catch (error) {
await anomalousAlert(t('scriptRunDefaultsFailed', { error: error.message }));
} finally {
saveDefaults.disabled = false;
render();
}
}
function renderStatus() {
const s = state.status;
let text = '';
if (s?.state === 'queued') text = t('scriptRunQueued');
else if (s?.state === 'running') text = s.max ? t('scriptRunProgress', { value: s.value, max: s.max }) : t('scriptRunRunning');
else if (s?.state === 'done') text = t('scriptRunDone', { seconds: s.seconds });
else if (s?.state === 'cancelled') text = t('scriptRunCancelled');
status.textContent = text;
status.hidden = !text;
}
function render() {
const script = getScript();
if (state.supported === null) {
state.supported = false;
checkSupport();
}
const { group, pkg } = script;
const busy = Boolean(state.job);
const ready = Boolean(state.supported && group);
hint.hidden = Boolean(state.supported);
generateBtn.title = t('scriptRunHint', { character: group?.character || '' });
settings.hidden = actions.hidden = !ready;
if (ready) {
const opts = currentOptions(group);
const tuned = SAMPLING.some(name => opts[name] !== state.spec[name].default);
fineSummary.textContent = t('scriptRunFineTune') + (tuned ? t('scriptRunFineTuned') : '');
for (const [code, choice] of languageButtons) {
choice.setAttribute('aria-checked', String(opts.language === code));
choice.disabled = busy;
}
for (const name of NUMBERS) {
const range = state.spec[name];
Object.assign(fields[name], { min: String(range.min), max: String(range.max), step: String(range.step ?? 1) });
fields[name].value = String(opts[name]);
fields[name].disabled = busy;
}
speedValue.textContent = String(opts.speed);
saveDefaults.hidden = sameOptions(opts, storedOptions(group));
} else {
saveDefaults.hidden = true;
}
generateBtn.disabled = !ready || Boolean(pkg.error) || busy;
const hasResult = Boolean(state.result && group && state.result.character === group.character);
newTakeBtn.hidden = !hasResult;
newTakeBtn.disabled = generateBtn.disabled;
cancelBtn.hidden = !busy;
renderStatus();
player.hidden = !hasResult;
if (hasResult && player.getAttribute('src') !== state.result.url) player.src = state.result.url;
savedTo.hidden = !hasResult;
if (hasResult) {
const folder = String(state.result.audio.subfolder || '').replace(/\\/g, '/');
savedTo.title = `output/${folder}/${state.result.audio.filename}`;
}
warnings.hidden = !hasResult || !state.result.warnings.length;
if (hasResult) warnings.textContent = state.result.warnings.join('\n');
}
return {
settings,
bar,
render,
generate,
/** A line can be retaken once this character has a result and nothing is running. */
canRetake(group) {
return Boolean(state.supported && !state.job && state.result && group && state.result.character === group.character);
},
stopPlayback() {
player.pause();
},
};
}