Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_tune.js
T
DemonGatanjieuandClaude Opus 5.5 db045b8f57 Voice pages: tabs instead of folds
GPT-SoVITS Settings gets a status chip by the title and tabs (tones, other
languages, base files with a dot when they need the user, where things are,
version); the tones have a Simple / Numbers switch. Voice-over's side panel
gets How it reads / Numbers / Settings tabs for a character or picked lines.
The More fold is gone; ui_tab_strip.js replaces ui_tts_fold.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:39:53 +08:00

377 lines
20 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.
import { t } from './interface_settings.js';
import { button, el } from './usage_view.js';
import { isBuiltin, presetLabel } from './tts_presets_api.js';
import { AUTO_MODE, hasOwnTune, lineNumbers, ownTune, sharedValue } from './script_tune.js';
import { speakerColor } from './ui_script_cast.js';
import { openSpeakerMenu } from './ui_script_cards.js';
import { renderPresetSwitch } from './ui_tts_preset_switch.js';
import { remember, remembered, tabStrip } from './ui_tab_strip.js';
/**
* The Script Director's side panel, which follows what is picked on the left: nothing, the whole
* script (each character and how it reads); a character in the cast, how it reads in this script
* (every line of it follows); picked lines, who reads them and how, each value following the
* character until it is set here (then the card shows a tag and ↺ lets it follow again).
* A character or picked lines have three tabs: How it reads (who reads, speed, the language as one
* line while it is detected, the tone with its listen button), Numbers and Settings.
* ui_script_run.js keeps the state and draws this again after each change (`ctx`, see
* renderTunePanel); the lines are the director's.
*/
export const LANGUAGES = ['auto', 'zh', 'ja', 'en'];
const FOLLOW = '__follow'; // a line's mode choice: its character's
const ALL_WHO_UP_TO = 6; // more characters than this: "Who reads" lists the script's own, the rest in a menu
const TABS = ['how', 'numbers', 'settings'];
const TAB_KEY = 'anomalous_sd_tune_tab';
/** "1.2×" */
export const speedText = speed => `${Math.round(Number(speed) * 100) / 100}×`;
/** A mode's name: Auto, a built-in preset or one made in Anomalous. */
export function modeName(view, id) {
return id === AUTO_MODE || !view ? t('scriptRunModeAuto') : presetLabel(view.labels, id, t);
}
/** "中文 · 1.2× · 有起伏": a set of values in a few words (`skip`: leave out the auto language). */
export function valuesText(view, values, { skip = false } = {}) {
const parts = [];
if (!(skip && values.language === 'auto')) parts.push(t(`scriptRunLang_${values.language || 'auto'}`));
if (typeof values.speed === 'number') parts.push(speedText(values.speed));
parts.push(modeName(view, values.preset ?? AUTO_MODE));
return parts.join(' · ');
}
/** What a line set for itself, for the tag on its card; '' when it follows its character. */
export function ownTuneText(view, line) {
if (!hasOwnTune(line)) return '';
const own = ownTune(line);
const parts = [];
if (own.language !== undefined) parts.push(t(`scriptRunLang_${own.language}`));
if (own.speed !== undefined) parts.push(speedText(own.speed));
if (own.preset !== undefined) parts.push(modeName(view, own.preset));
return parts.join(' · ');
}
function heading(title, meta, back, hint = '') {
const head = el('div', 'anomalous-sd-tune-head');
if (hint) head.title = hint;
if (back) head.append(button('anomalous-sd-link-btn anomalous-sd-tune-back', t('scriptTuneBack'), back));
head.append(el('div', 'anomalous-sd-tune-title', title));
if (meta) head.append(el('div', 'anomalous-sd-tune-meta', meta));
return head;
}
function row(label, ...content) {
const line = el('div', 'anomalous-sd-run-row');
line.append(el('span', 'anomalous-sd-run-label', label), ...content);
return line;
}
/** Choices in a segment; `checked(id)`; a click on the checked one does nothing. */
function segment(choices, checked, onPick, disabled) {
const box = el('div', 'anomalous-sd-run-segment');
box.setAttribute('role', 'radiogroup');
for (const choice of choices) {
const chip = button(`anomalous-sd-run-choice${choice.className ? ` ${choice.className}` : ''}`, choice.label, () => {
if (!checked(choice.id)) onPick(choice.id);
});
chip.setAttribute('role', 'radio');
chip.setAttribute('aria-checked', String(checked(choice.id)));
chip.disabled = disabled;
if (choice.title) chip.title = choice.title;
if (choice.color) {
const dot = el('span', 'anomalous-sd-speaker-dot');
dot.style.background = choice.color;
chip.prepend(dot);
chip.style.setProperty('--speaker', choice.color);
}
box.append(chip);
}
return box;
}
/** The speed slider: `value`, `onChange(number)`; `after`: elements after the number. */
function speedSlider(ctx, value, onChange, after = []) {
const range = ctx.spec.speed;
const slider = el('input', 'anomalous-sd-run-slider');
Object.assign(slider, { type: 'range', min: String(range.min), max: String(range.max), step: String(range.step ?? 1) });
slider.value = String(value ?? range.default);
slider.title = t('scriptRunParamHint_speed');
slider.disabled = ctx.busy;
const shown = el('span', 'anomalous-sd-run-value', String(value ?? range.default));
slider.oninput = () => { shown.textContent = slider.value; };
slider.onchange = () => {
const number = Math.min(range.max, Math.max(range.min, Number(slider.value)));
onChange(Number.isFinite(number) ? number : range.default);
};
return row(t('scriptRunSpeed'), slider, shown, ...after);
}
/**
* The language: one line ("Language: detected · Change") while it is the detected one and nothing
* set it, the choices once "Change" is pressed or another one is in use. `choices` and `checked`
* as segment; `shown`: the language in use, in words.
*/
function languageRow(ctx, { choices, checked, onPick, open, shown }) {
const full = () => row(t('scriptRunLanguage'), segment(choices, checked, onPick, ctx.busy));
if (open) return full();
const line = el('div', 'anomalous-sd-tune-language');
const change = button('anomalous-sd-link-btn', t('scriptTuneLanguageChange'), () => line.replaceWith(full()));
change.disabled = ctx.busy;
line.append(el('span', '', t('scriptTuneLanguageIs', { value: shown })), change);
return line;
}
/**
* The tone: its choices and ▶ for the line heard (null: a sample) read by `speaker` with `values`
* (null: the line's own); with `detail` (the Numbers tab) also how it compares and its numbers.
* `choices` and `base` as renderPresetSwitch.
*/
function modeBox(ctx, { speaker, line, values = null, selected, choices, base, baseName, baseText, describe, numbers, onPick, detail = false }) {
const box = el('div', 'anomalous-sd-run-mode');
if (!ctx.view) return box;
const view = ctx.view;
const take = id => ctx.takeFor(line, id === FOLLOW ? ctx.options(speaker).preset : id, speaker, values);
const switcher = renderPresetSwitch({
choices,
selected,
base,
baseName,
baseText,
values: numbers,
describe,
builtin: id => (isBuiltin(id) ? view.builtin[id] : null),
editable: id => id !== AUTO_MODE && id !== FOLLOW && ctx.local,
onPick,
onEdit: ctx.editPreset,
listen: ctx.preview.row({ takeFor: take, selected, modes: choices, onPick, disabled: ctx.busy, label: 'scriptTuneListen' }),
detail,
disabled: ctx.busy,
});
box.append(el('span', 'anomalous-sd-run-label', t('scriptRunMode')), switcher);
if (detail && ctx.presetNote) box.append(el('div', 'anomalous-sd-run-note', ctx.presetNote));
return box;
}
const presetChoices = view => [{ id: AUTO_MODE, label: t('scriptRunModeAuto') },
...Object.keys(view?.presets || {}).map(name => ({ id: name, label: presetLabel(view.labels, name, t), badge: isBuiltin(name) ? '' : t('ttsPresetCustom') }))];
const presetAbout = id => (isBuiltin(id) ? t(`ttsPresetDesc_${id}`) : t('ttsPresetDescCustom'));
/** The Settings tab's ways out: the tones for every character, and this one's own settings. */
function links(ctx, speaker) {
const box = el('div', 'anomalous-sd-tune-links');
const item = (label, about, onClick) => {
const entry = button('anomalous-sd-tune-link', '', onClick);
entry.append(el('span', 'anomalous-sd-tune-link-name', label), el('span', 'anomalous-sd-tune-link-about', about));
box.append(entry);
};
item(t('scriptRunOpenPresets'), t('scriptTuneLinkPresets'), ctx.openPresets);
if (speaker) item(t('scriptRunOpenTuning', { name: speaker.character }), t('scriptTuneLinkTuning'), () => ctx.openTuning(speaker));
return box;
}
/** Each character reading in this script, a click opens it. */
function renderScriptView(ctx) {
const { script } = ctx;
const spoken = script.lines.filter(line => line.text.trim());
const cast = script.groups.filter(group => spoken.some(line => script.speakerOf(line) === group));
const list = el('div', 'anomalous-sd-tune-cast');
for (const group of cast) {
const lines = spoken.filter(line => script.speakerOf(line) === group);
const own = lines.filter(hasOwnTune).length;
const item = button('anomalous-sd-tune-person', '', () => script.focusCharacter(group.group));
item.style.setProperty('--speaker', speakerColor(group.group, script.groups));
const name = el('span', 'anomalous-sd-tune-person-name');
name.append(el('span', 'anomalous-sd-speaker-dot'), el('span', '', group.character));
name.firstChild.style.background = speakerColor(group.group, script.groups);
const about = el('span', 'anomalous-sd-tune-person-about', valuesText(ctx.view, ctx.options(group)));
const count = el('span', 'anomalous-sd-tune-person-count', t('scriptTuneLineCount', { count: lines.length }));
item.append(name, count, about);
if (own) item.append(el('span', 'anomalous-sd-tune-person-own', t('scriptTuneOwnCount', { count: own })));
list.append(item);
}
return {
head: heading(t('scriptTuneScript'), t('scriptTuneScriptMeta', { count: spoken.length, speakers: cast.length })),
body: [list, el('div', 'anomalous-sd-run-note', t('scriptTuneScriptHint')), links(ctx, null)],
};
}
/** One character in this script, for every line of it that follows: speed, language and tone, its numbers, its settings. */
function renderCharacterView(ctx, group, tab) {
const { script } = ctx;
const values = ctx.options(group);
const indexes = script.lines.map((line, index) => [line, index]).filter(([line]) => line.text.trim() && script.speakerOf(line) === group).map(([, index]) => index);
const meta = indexes.length ? t('scriptTuneCharacterMeta', { lines: lineNumbers(indexes), count: indexes.length }) : t('scriptTuneCharacterNone');
const head = heading(t('scriptTuneCharacter', { name: group.character }), meta, script.focusScript, t('scriptTuneCharacterHint'));
const set = (field, value) => ctx.setCharacter(group, field, value);
// The line heard: the one listened to on the left when it is this character's, else its first.
const listened = script.lines[script.listen];
const line = listened && script.speakerOf(listened) === group ? listened : (script.lines[indexes[0]] || null);
const lang = ctx.autoLanguage(group, values);
const mode = detail => modeBox(ctx, {
speaker: group,
line,
values,
selected: values.preset,
choices: presetChoices(ctx.view),
base: AUTO_MODE,
baseName: t('scriptRunModeAutoBase'),
baseText: t('scriptRunModeIsAuto'),
describe: id => (id === AUTO_MODE ? t('scriptRunModeAutoShort', { lang }) : t('scriptRunModeWhole', { about: presetAbout(id) })),
numbers: id => ctx.modeNumbers(group, values, id),
onPick: id => set('preset', id),
detail,
});
if (tab === 'numbers') return { head, body: [mode(true)] };
if (tab === 'settings') {
const own = indexes.filter(index => hasOwnTune(script.lines[index]));
const keep = el('div', 'anomalous-sd-run-links');
keep.append(ctx.canSave(group) ? ctx.saveButton(group) : el('span', 'anomalous-sd-tune-follow', t('scriptTuneIsDefault')));
if (own.length) keep.append(button('anomalous-sd-link-btn', t('scriptTuneResetLines', { count: own.length }), () => ctx.clearLines(own)));
return { head, body: [keep, el('div', 'anomalous-sd-run-note', t('scriptRunSaveDefaultsHint')), links(ctx, group)] };
}
return {
head,
body: [
speedSlider(ctx, values.speed, value => set('speed', value)),
languageRow(ctx, {
choices: LANGUAGES.map(code => ({ id: code, label: t(`scriptRunLang_${code}`) })),
checked: code => values.language === code,
onPick: code => set('language', code),
open: values.language !== 'auto',
shown: t(`scriptRunLang_${values.language}`),
}),
mode(false),
],
};
}
/**
* Who reads the picked lines: every character when there are a few; with many, the ones reading
* in this script (and the picked lines' own), the others in a menu with a search box.
*/
function whoReads(ctx, indexes, speakers) {
const { script } = ctx;
const reading = new Set([...script.lines.filter(line => line.text.trim()).map(line => script.speakerOf(line)), ...speakers]);
const many = script.groups.length > ALL_WHO_UP_TO;
const listed = many ? script.groups.filter(group => reading.has(group)) : script.groups;
const others = many ? script.groups.filter(group => !reading.has(group)) : [];
const assign = key => script.assign(indexes, key);
const box = segment(listed.map(group => ({
id: group.group, label: group.character, className: 'anomalous-sd-tune-who', color: speakerColor(group.group, script.groups),
})), key => speakers.length === 1 && speakers[0].group === key, assign, ctx.busy);
if (others.length) {
const more = button('anomalous-sd-run-choice anomalous-sd-tune-more', t('scriptTuneOtherCharacters', { count: others.length }),
() => openSpeakerMenu(more, script.groups, null, assign, others));
more.disabled = ctx.busy;
box.append(more);
}
return box;
}
/** Picked lines: who reads them, then each value following its character until set here; the tone's numbers; the settings. */
function renderLinesView(ctx, indexes, tab) {
const { script } = ctx;
const lines = indexes.map(index => script.lines[index]).filter(Boolean);
const speakers = [...new Set(lines.map(line => script.speakerOf(line)).filter(Boolean))];
const single = speakers.length === 1 ? speakers[0] : null;
const title = lines.length === 1 ? t('scriptTuneLine', { n: indexes[0] + 1 }) : t('scriptTuneLines', { count: lines.length });
const meta = single ? t('scriptTuneReadBy', { name: single.character })
: speakers.map(group => `${group.character} ${lines.filter(line => script.speakerOf(line) === group).length}`).join(' · ');
const head = heading(title, meta, script.focusScript);
const valuesOf = line => ctx.lineValues(line);
const set = (field, value) => ctx.setLines(indexes, field, value);
const followText = field => {
if (!single) return t('scriptTuneFollowEach');
const value = ctx.options(single)[field];
return field === 'speed' ? speedText(value) : field === 'language' ? t(`scriptRunLang_${value}`) : modeName(ctx.view, value);
};
const resetAll = () => {
if (!lines.some(hasOwnTune)) return [];
const foot = el('div', 'anomalous-sd-run-links');
foot.append(button('anomalous-sd-link-btn', t('scriptTuneResetAll'), () => ctx.clearLines(indexes)));
return [foot];
};
const mode = (detail) => {
if (!ctx.view) return el('div');
const shared = sharedValue(lines, 'preset', valuesOf);
const selected = !shared.some ? FOLLOW : (shared.own && !shared.mixed ? shared.value : FOLLOW);
const speaker = single || speakers[0];
const heard = lines.includes(script.lines[script.listen]) ? script.lines[script.listen] : lines[0];
const followName = single ? single.character : t('scriptTuneFollowEach');
return modeBox(ctx, {
speaker: script.speakerOf(heard) || speaker,
line: heard,
selected,
// "Usual" would read like "As the character" here: a line picks a tone or follows.
choices: [{ id: FOLLOW, label: t('scriptTuneFollow'), title: t('scriptTuneFollowHint', { value: followText('preset') }) },
...presetChoices(ctx.view).filter(choice => choice.id !== AUTO_MODE || selected === AUTO_MODE)],
base: FOLLOW,
baseName: t('scriptTuneFollowBase', { name: followName }),
baseText: t('scriptTuneModeFollow', { name: followName }),
describe: id => (id === FOLLOW ? '' : id === AUTO_MODE ? t('scriptRunModeAutoShort', { lang: ctx.autoLanguage(speaker, valuesOf(heard)) })
: t('scriptTuneModeLines', { about: presetAbout(id) })),
numbers: id => {
const owner = script.speakerOf(heard) || speaker;
return ctx.modeNumbers(owner, valuesOf(heard), id === FOLLOW ? ctx.options(owner).preset : id);
},
onPick: id => set('preset', id === FOLLOW ? undefined : id),
detail,
});
};
if (tab === 'numbers') return { head, body: [mode(true)] };
if (tab === 'settings') return { head, body: [...resetAll(), links(ctx, single)] };
const body = [];
if (script.groups.length > 1) body.push(row(t('scriptTuneWho'), whoReads(ctx, indexes, speakers)));
const speed = sharedValue(lines, 'speed', valuesOf);
const speedAfter = speed.some
? [button('anomalous-sd-icon-btn anomalous-sd-tune-reset', '↺', () => set('speed', undefined), t('scriptTuneReset'))]
: [el('span', 'anomalous-sd-tune-follow', t('scriptTuneFollowing'))];
body.push(speedSlider(ctx, speed.mixed ? null : speed.value, value => set('speed', value), speedAfter));
const language = sharedValue(lines, 'language', valuesOf);
const follow = { id: FOLLOW, label: t('scriptTuneFollow'), title: t('scriptTuneFollowHint', { value: followText('language') }) };
body.push(languageRow(ctx, {
choices: [follow, ...LANGUAGES.map(code => ({ id: code, label: t(`scriptRunLang_${code}`) }))],
checked: id => (id === FOLLOW ? !language.some : language.own && !language.mixed && language.value === id),
onPick: id => set('language', id === FOLLOW ? undefined : id),
open: language.some || language.mixed || language.value !== 'auto',
shown: t('scriptTuneFollowValue', { value: followText('language') }),
}));
body.push(mode(false), ...resetAll());
return { head, body };
}
/**
* Draws the panel into `box`: the heading, then for a character or picked lines three tabs, How
* it reads, Numbers (the tone compared, its numbers editable) and Settings (keeping it, the ways
* to the tones and the character's own settings); the tab is remembered. `ctx`: `script` (the
* director's: lines, groups, speakerOf, listen, picked indexes, focus `{ character }` or null,
* assign / focusCharacter / focusScript), `view` (the presets or null), `spec`, `busy`, `local`,
* `canTag` (the node reads a line's own values), `presetNote`, `preview`, `redraw()`, and the run
* section's calls: `options(group)` (its values in this script), `lineValues(line)`,
* `setCharacter(group, field, value)`, `setLines(indexes, field, value)`, `clearLines(indexes)`,
* `modeNumbers(group, values, id)`, `autoLanguage(group, values)`, `takeFor(line, mode, speaker,
* values)`, `editPreset`, `canSave(group)`, `saveButton(group)`, `openPresets()`, `openTuning(group)`.
*/
export function renderTunePanel(box, ctx) {
const { script } = ctx;
const group = script.focus?.character ? script.groups.find(item => item.group === script.focus.character) : null;
const tab = remembered(TAB_KEY, TABS, 'how');
let view;
if (script.picked.length) view = renderLinesView(ctx, script.picked, tab);
else if (group) view = renderCharacterView(ctx, group, tab);
else view = renderScriptView(ctx);
const parts = [view.head];
if (!ctx.canTag && (script.picked.length || group)) parts.push(el('div', 'anomalous-sd-run-hint is-warning', t('scriptTuneOldTts')));
if (script.picked.length || group) {
parts.push(tabStrip(TABS.map(id => ({ id, label: t(`scriptTuneTab_${id}`) })), tab, (id) => {
remember(TAB_KEY, id);
ctx.redraw();
}));
}
box.replaceChildren(...parts, ...view.body);
}