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>
361 lines
18 KiB
JavaScript
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 shows the character in the side panel and makes it the one who reads new lines
|
|
* (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 shows it in the side panel (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'));
|
|
}
|