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'; /** * 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, picking a character makes it the one who speaks. 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: ``, ALL_VOICES: ``, USER: ``, REFRESH: ``, CHEVRON: ``, PLAY: ``, SEARCH: ``, SETTINGS: ``, }; 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 = () => { 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 }) { 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 })); }; toggle.hidden = !group.slices.length; item.prepend(toggle); 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 }) { 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 })); }; box.append(head); if (!isCollapsed) { for (const group of bucket.groups) box.append(renderCharacter(owner, group, { canImport, forceOpen: false })); } return box; } function renderCharacters(owner, container, groups, { canImport, term }) { const shown = groups.filter(group => matches(group, term)); const buckets = bucketsOf(shown); const collapsed = storedCollapsed(); 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 })); } else { for (const group of shown) container.append(renderCharacter(owner, group, { canImport, forceOpen: false })); } } /** Bottom entry for GPT-SoVITS settings; the dot says when something wants a look. */ function renderSettingsEntry(owner, status, languages) { const attention = setupAttention(status, languages); 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, 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(allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })), characters); renderCharacters(owner, characters, groups, { canImport, term: '' }); } else { listContainer.append(allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label }))); renderCharacters(owner, listContainer, groups, { canImport, term: '' }); } if (status) { const languages = groups.map(group => group.language); owner.sidebar.append(renderSettingsEntry(owner, status, languages)); if (canImport) { bindTtsFileDrop(listContainer, (files, target) => openTtsImport({ files, target, onDone: () => redraw(owner) })); } } } function allVoices(owner, count) { return createNavItem(owner, { label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count, filter: { type: 'all', value: null }, tab: 'presets', }); }