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',
});
}