Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_audio_sidebar.js
T
DemonGatanjieuandClaude Opus 5.5 89fa1a93bf Voice-over: several characters in one script
Pick lines on Voice-over (click; Ctrl / Cmd adds, Shift a run; Esc lets
go) and click a character in the list on the left to have it read them;
with none picked, the click changes who reads by default. On Voice-over
the list is the cast: narrower, characters with their colours, and a
click never leaves the page. Cards show who reads each line in its
colour, with that character's emotions; the bundle carries [name] where
the speaker changes, so generating, the cards' listen button, pushing,
dragging and copying follow it, and a file voiced again from the audio
gallery keeps its speakers. The character cards' Voice-over button is
gone. The workflow actions move to ui_script_workflow.js and the cast
helpers to ui_script_cast.js, keeping the director under 600 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:53:03 +08:00

361 lines
18 KiB
JavaScript

import { t } from './interface_settings.js';
import { TTS_ENGINE, invalidateEngineCache, loadGptSovitsStatus, loadVoices } from './audio_engines.js';
import { bindVoiceDrag } from './audio_voice_drag.js';
import { bindTtsFileDrop } from './ui_tts_file_drop.js';
import { openTtsImport } from './ui_tts_import.js';
import { openTtsSetup, setupAttention } from './ui_tts_setup.js';
import { speakerColor } from './ui_script_cast.js';
/**
* Audio sidebar: All Voices, the GPT-SoVITS characters (grouped by language; each
* group folds, each character unfolds into its clips) and a settings entry at the
* bottom. On Voice-over it is the cast (CSS: narrow, characters with their colours only):
* a click gives the picked lines to a character, or makes it the one who reads by default
* (ui_script_director.js castScriptCharacter), and never leaves the page. Owns the active audio
* filter. Characters drag onto canvas nodes like the studio cards; files dropped on a
* character open the import form for it.
*/
let activeFilter = { type: 'all', value: null };
const renderTokens = new WeakMap();
const expanded = new Set(); // characters unfolded into their clips, this session only
const COLLAPSED_KEY = 'anomalous_audio_sidebar_collapsed';
const SEARCH_FROM = 6; // the search box shows once there are this many characters
const LANGUAGE_ORDER = ['zh', 'ja', 'en'];
export function getActiveAudioFilter() {
return activeFilter;
}
export function setActiveAudioFilter(filter) {
activeFilter = filter || { type: 'all', value: null };
}
const SIDEBAR_SVG = {
MIC: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
ALL_VOICES: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
USER: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
REFRESH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21h5v-5"/></svg>`,
CHEVRON: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>`,
PLAY: `<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
SEARCH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
SETTINGS: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`,
};
function filterKey(filter) {
return `${filter?.type || 'all'}:${filter?.value || ''}`;
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function icon(svg, className) {
const node = el('span', className);
node.innerHTML = svg; // static markup only
return node;
}
function storedCollapsed() {
try {
const keys = JSON.parse(localStorage.getItem(COLLAPSED_KEY) || '[]');
return new Set(Array.isArray(keys) ? keys : []);
} catch (_) { return new Set(); }
}
function storeCollapsed(keys) {
try { localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...keys])); } catch (_) { /* convenience only */ }
}
/** Re-mark the selected entry without refetching the voice list. */
export function syncAudioSidebarSelection(owner) {
const key = filterKey(activeFilter);
owner?.sidebar?.querySelectorAll('.anomalous-audio-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.filterKey === key);
});
}
/** Redraw the studio for the current filter (unless the history is open) and the sidebar. */
function redraw(owner, { sidebarOnly = false } = {}) {
if (!sidebarOnly && activeFilter.type !== 'gallery') owner.switchAudioTab?.('presets', activeFilter);
renderAudioSidebar(owner);
}
function renderSidebarHeader(owner) {
const topBar = el('div', 'anomalous-audio-sidebar-header');
const title = el('h3', 'anomalous-audio-sidebar-title');
title.append(icon(SIDEBAR_SVG.MIC, 'anomalous-audio-sidebar-title-icon'), el('span', '', t('audioSidebarTitle')));
const refreshBtn = el('button', 'anomalous-audio-sidebar-refresh');
refreshBtn.type = 'button';
refreshBtn.innerHTML = SIDEBAR_SVG.REFRESH;
refreshBtn.title = t('audioRefresh');
refreshBtn.setAttribute('aria-label', t('audioRefresh'));
refreshBtn.onclick = () => {
invalidateEngineCache({ rescan: true });
redraw(owner);
};
topBar.append(title, refreshBtn);
return topBar;
}
function createNavItem(owner, { label, iconSvg, count, filter, tab }) {
const item = el('button', 'anomalous-audio-nav-item');
item.type = 'button';
item.dataset.filterKey = filterKey(filter);
item.classList.toggle('active', item.dataset.filterKey === filterKey(activeFilter));
const left = el('span', 'anomalous-audio-nav-label');
left.append(icon(iconSvg, 'anomalous-audio-nav-icon'), el('span', 'anomalous-audio-nav-text', label));
item.appendChild(left);
if (count !== undefined && count !== null) item.appendChild(el('span', 'anomalous-audio-nav-count', String(count)));
item.onclick = () => {
// On Voice-over a character click casts it (browser.js); the Characters page's filter stays.
if (!(owner.currentShellPage?.() === 'script' && filter.type === 'group')) {
activeFilter = filter;
syncAudioSidebarSelection(owner);
}
owner.switchAudioTab?.(tab, filter);
};
return item;
}
function createSectionLabel(text) {
return el('div', 'anomalous-audio-nav-section', text);
}
/** Characters group by language; unlabeled ones last. */
function bucketOf(group) {
const lang = group.language || '';
const known = LANGUAGE_ORDER.indexOf(lang);
return {
key: `gpt_sovits:${lang}`,
sort: !lang ? 99 : known >= 0 ? known : 50,
label: !lang ? t('audioSidebarLanguageUnknown') : known >= 0 ? t(`ttsNeededFor_${lang}`) : lang.toUpperCase(),
};
}
function bucketsOf(groups) {
const buckets = new Map();
for (const group of groups) {
const bucket = bucketOf(group);
if (!buckets.has(bucket.key)) buckets.set(bucket.key, { ...bucket, groups: [] });
buckets.get(bucket.key).groups.push(group);
}
return [...buckets.values()].sort((a, b) => a.sort - b.sort || a.label.localeCompare(b.label));
}
/** Why a character needs a look (read error, no main voice), or ''. */
function problemOf(group) {
if (group.error) return t('ttsCharacterError', { error: group.error });
return group.has_main ? '' : t('ttsMainMissing');
}
function matches(group, term) {
if (!term) return true;
const words = [group.character, ...(group.aliases || []), ...group.slices.map(slice => slice.emotion)];
return words.some(word => String(word || '').toLowerCase().includes(term));
}
/**
* Play a clip through its row in the studio (same player, same stop rules), switching
* the studio to the character first when the row is not on screen.
*/
async function playClip(owner, group, slice) {
const selector = `[data-slice-id="${CSS.escape(slice.id)}"] .anomalous-audio-play-btn`;
const visible = () => {
const btn = owner.audioStudioPanel?.querySelector(selector);
return btn && btn.offsetParent !== null ? btn : null;
};
let btn = visible();
if (!btn) {
activeFilter = { type: 'group', value: group.group, character: group.character };
syncAudioSidebarSelection(owner);
// The filter is set already; without it as an argument, Voice-over does not take it as "who speaks".
owner.switchAudioTab?.('presets');
const until = performance.now() + 4000;
while (!(btn = visible()) && performance.now() < until) await new Promise(requestAnimationFrame);
}
if (!btn) return;
btn.closest('.anomalous-voice-slice-row')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
btn.click();
}
function renderClipRow(owner, group, slice) {
const row = el('button', 'anomalous-audio-nav-clip');
row.type = 'button';
row.title = slice.text || slice.filename || '';
const tag = slice.is_main ? t('audioSidebarMainClip') : `{${slice.emotion}}`;
row.append(icon(SIDEBAR_SVG.PLAY, 'anomalous-audio-nav-clip-play'), el('span', 'anomalous-audio-nav-clip-tag', tag),
el('span', 'anomalous-audio-nav-clip-text', slice.text || slice.filename || ''));
row.onclick = () => playClip(owner, group, slice);
return row;
}
function renderCharacter(owner, group, { canImport, forceOpen, color }) {
const wrap = el('div', 'anomalous-audio-nav-character');
const filter = { type: 'group', value: group.group, character: group.character };
const item = createNavItem(owner, { label: group.character, iconSvg: SIDEBAR_SVG.USER, count: group.total_slices, filter, tab: 'presets' });
const open = forceOpen || expanded.has(group.group);
const toggle = el('span', `anomalous-audio-nav-chevron${open ? ' is-open' : ''}`);
toggle.innerHTML = SIDEBAR_SVG.CHEVRON;
toggle.setAttribute('role', 'button');
toggle.title = t(open ? 'audioSidebarHideClips' : 'audioSidebarShowClips');
toggle.onclick = (e) => {
e.stopPropagation();
if (expanded.has(group.group)) expanded.delete(group.group);
else expanded.add(group.group);
wrap.replaceWith(renderCharacter(owner, group, { canImport, forceOpen: false, color }));
};
toggle.hidden = !group.slices.length;
item.prepend(toggle);
const dot = el('span', 'anomalous-audio-nav-cast-dot'); // its colour on Voice-over's line cards
dot.style.background = color;
item.querySelector('.anomalous-audio-nav-label').prepend(dot);
const problem = problemOf(group);
if (problem) {
const dot = el('span', 'anomalous-audio-nav-problem');
dot.title = problem;
item.querySelector('.anomalous-audio-nav-label').append(dot);
}
if (group.has_main && !group.error) {
item.title = t('audioDragCharacterTitle', { character: group.character });
bindVoiceDrag(item, group, owner);
}
if (canImport && !group.error) wrap.dataset.ttsCharacter = group.character; // file drop target
wrap.append(item);
if (open) {
const clips = el('div', 'anomalous-audio-nav-clips');
for (const slice of group.slices) clips.append(renderClipRow(owner, group, slice));
wrap.append(clips);
}
return wrap;
}
function renderBucket(owner, bucket, { collapsed, canImport, term, colorOf }) {
const box = el('div', 'anomalous-audio-nav-bucket');
const isCollapsed = !term && collapsed.has(bucket.key);
const head = el('button', `anomalous-audio-nav-bucket-head${isCollapsed ? ' is-collapsed' : ''}`);
head.type = 'button';
head.append(icon(SIDEBAR_SVG.CHEVRON, 'anomalous-audio-nav-chevron is-open'), el('span', 'anomalous-audio-nav-bucket-label', bucket.label),
el('span', 'anomalous-audio-nav-bucket-count', String(bucket.groups.length)));
head.onclick = () => {
const keys = storedCollapsed();
if (keys.has(bucket.key)) keys.delete(bucket.key);
else keys.add(bucket.key);
storeCollapsed(keys);
box.replaceWith(renderBucket(owner, bucket, { collapsed: keys, canImport, term, colorOf }));
};
box.append(head);
if (!isCollapsed) {
for (const group of bucket.groups) box.append(renderCharacter(owner, group, { canImport, forceOpen: false, color: colorOf(group.group) }));
}
return box;
}
function renderCharacters(owner, container, groups, { canImport, term }) {
const shown = groups.filter(group => matches(group, term));
const buckets = bucketsOf(shown);
const collapsed = storedCollapsed();
const colorOf = key => speakerColor(key, groups);
if (!shown.length && term) {
container.append(el('div', 'anomalous-audio-nav-empty', t('audioSidebarNoMatch')));
} else if (buckets.length > 1) {
for (const bucket of buckets) container.append(renderBucket(owner, bucket, { collapsed, canImport, term, colorOf }));
} else {
for (const group of shown) container.append(renderCharacter(owner, group, { canImport, forceOpen: false, color: colorOf(group.group) }));
}
}
/** Bottom entry for GPT-SoVITS settings; the dot says when something wants a look. */
function renderSettingsEntry(owner, status, groups) {
const languages = groups.map(group => group.language);
const needs = groups.flatMap(group => group.needs);
const attention = setupAttention(status, languages, needs);
const entry = el('button', `anomalous-audio-sidebar-settings${attention.level ? ` is-${attention.level}` : ''}`);
entry.type = 'button';
entry.title = attention.title;
const label = el('span', 'anomalous-audio-nav-label');
label.append(icon(SIDEBAR_SVG.SETTINGS, 'anomalous-audio-nav-icon'), el('span', 'anomalous-audio-nav-text', t('ttsSetupTitle')));
if (attention.level) label.append(el('span', 'anomalous-audio-sidebar-settings-dot'));
entry.append(label);
if (attention.busy) entry.append(el('span', 'anomalous-audio-sidebar-settings-busy', attention.busy));
entry.onclick = () => openTtsSetup({ languages, needs, groups, onChanged: (options) => redraw(owner, options) });
return entry;
}
export async function renderAudioSidebar(owner) {
if (!owner?.sidebar) return;
const token = {};
renderTokens.set(owner.sidebar, token);
const listContainer = el('div', 'anomalous-audio-nav-list');
owner.sidebar.replaceChildren(renderSidebarHeader(owner), listContainer);
const [result, status] = await Promise.all([loadVoices(), loadGptSovitsStatus().catch(() => null)]);
if (renderTokens.get(owner.sidebar) !== token) return;
if (result.error) {
const error = createSectionLabel(t('audioLoadFailed', { error: result.error }));
error.classList.add('is-error');
listContainer.appendChild(error);
return;
}
const groups = result.groups;
const canImport = Boolean(status) && status.local !== false;
const totalSlices = groups.reduce((sum, group) => sum + (group.total_slices || 0), 0);
if (activeFilter.type === 'group' && !groups.some(group => group.group === activeFilter.value)) {
activeFilter = { type: 'all', value: null };
}
if (groups.length >= SEARCH_FROM) {
const search = el('label', 'anomalous-audio-sidebar-search');
const input = el('input');
input.type = 'search';
input.placeholder = t('audioSidebarSearch');
search.append(icon(SIDEBAR_SVG.SEARCH, ''), input);
owner.sidebar.insertBefore(search, listContainer);
const characters = el('div', 'anomalous-audio-nav-characters');
input.oninput = () => {
characters.replaceChildren();
renderCharacters(owner, characters, groups, { canImport, term: input.value.trim().toLowerCase() });
};
listContainer.append(castHint(), allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })), characters);
renderCharacters(owner, characters, groups, { canImport, term: '' });
} else {
listContainer.append(castHint(), allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })));
renderCharacters(owner, listContainer, groups, { canImport, term: '' });
}
if (status) {
owner.sidebar.append(renderSettingsEntry(owner, status, groups));
if (canImport) {
bindTtsFileDrop(listContainer, (files, target) => openTtsImport({ files, target, onDone: () => redraw(owner) }));
}
}
}
function allVoices(owner, count) {
const item = createNavItem(owner, {
label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count,
filter: { type: 'all', value: null }, tab: 'presets',
});
item.classList.add('anomalous-audio-nav-all'); // not on Voice-over (CSS): it would leave the page
return item;
}
/** What a click on a character does on Voice-over (shown there only, CSS). */
function castHint() {
return el('div', 'anomalous-audio-nav-cast-hint', t('scriptCastListHint'));
}