Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_audio_sidebar.js
T
DemonGatanjieuandClaude Opus 5.5 66979f698a fix(audio): align voices with ComfyUI-F5-TTS multi-voice layout and repair studio UI
ComfyUI-F5-TTS resolves {happy} to <sample stem>.happy.wav beside the selected
sample, so `Arona_happy.wav` + `{Arona_happy}` could never run. Voices now use
Character.wav (main, {main}) and Character.<emotion>.wav ({emotion}); the
catalog groups a main voice with its variants and marks tags that F5-TTS
cannot resolve (missing main, missing .txt, different format).

- script director: one character per script, untagged lines pinned to {main},
  pushes sample (main voice, node's own combo spelling) and speech together,
  confirms before overwriting and re-checks the node after the dialog
- pure rules (splitting, composing, planning, combo spelling) in audio_script.js
- one module instance per file: drop ?v= imports that duplicated
  ui_audio_gallery/ui_audio_studio state, so studio playback stops the gallery
- all user/file-derived text goes through textContent (no innerHTML)
- studio/sidebar/gallery renders ignore stale responses; errors are shown
- gallery: count badge no longer overwrites the title, paging via "load more",
  deletion errors surfaced, timeupdate instead of a 100ms timer
- sidebar: filter selection updates in place instead of refetching; the header
  tabs, sidebar and domain switch share browser.switchAudioTab
- uploader: createViewScope lifecycle (Esc/overlay/close release listeners and
  object URLs, Esc no longer reaches the parent), 409 -> confirm -> overwrite,
  sends the pre-romanization text as original_text, no hard-coded character
- workflow template: confirm before replacing the canvas, clear message when
  the template file is missing, align sample paths to the node's combo values
- static styles moved from inline JS into 11-audio-studio.css; strings localized

Verified: tests/audio_script_rules.mjs, tests/test_audio_catalog.py (15), a
jsdom walkthrough of studio/gallery/director/uploader with a stubbed app
(outside the repo), JS syntax and import/export link check. Not yet checked in
a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:02:33 +00:00

154 lines
6.6 KiB
JavaScript

import { t } from './interface_settings.js';
/**
* Audio sidebar: All Voices, one entry per voice group (main sample + variants),
* and the generated-audio history. Owns the active audio filter.
*/
let activeFilter = { type: 'all', value: null };
const renderTokens = new WeakMap();
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>`,
DISC: `<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="10"/><circle cx="12" cy="12" r="3"/></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>`
};
function filterKey(filter) {
return `${filter?.type || 'all'}:${filter?.value || ''}`;
}
/** 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);
});
}
function renderSidebarHeader(owner) {
const topBar = document.createElement('div');
topBar.className = 'anomalous-audio-sidebar-header';
const title = document.createElement('h3');
title.className = 'anomalous-audio-sidebar-title';
const icon = document.createElement('span');
icon.className = 'anomalous-audio-sidebar-title-icon';
icon.innerHTML = SIDEBAR_SVG.MIC;
const label = document.createElement('span');
label.textContent = t('audioSidebarTitle');
title.append(icon, label);
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'anomalous-audio-sidebar-refresh';
refreshBtn.innerHTML = SIDEBAR_SVG.REFRESH;
refreshBtn.title = t('audioRefresh');
refreshBtn.setAttribute('aria-label', t('audioRefresh'));
refreshBtn.onclick = () => {
renderAudioSidebar(owner);
if (activeFilter.type !== 'gallery') owner.switchAudioTab?.('presets', activeFilter);
};
topBar.append(title, refreshBtn);
return topBar;
}
function createNavItem(owner, { label, iconSvg, count, filter, tab }) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'anomalous-audio-nav-item';
item.dataset.filterKey = filterKey(filter);
item.classList.toggle('active', item.dataset.filterKey === filterKey(activeFilter));
const left = document.createElement('span');
left.className = 'anomalous-audio-nav-label';
const icon = document.createElement('span');
icon.className = 'anomalous-audio-nav-icon';
icon.innerHTML = iconSvg;
const text = document.createElement('span');
text.textContent = label;
left.append(icon, text);
item.appendChild(left);
if (count !== undefined && count !== null) {
const badge = document.createElement('span');
badge.className = 'anomalous-audio-nav-count';
badge.textContent = String(count);
item.appendChild(badge);
}
item.onclick = () => {
activeFilter = filter;
syncAudioSidebarSelection(owner);
owner.switchAudioTab?.(tab, filter);
};
return item;
}
function createSectionLabel(text) {
const sec = document.createElement('div');
sec.className = 'anomalous-audio-nav-section';
sec.textContent = text;
return sec;
}
export async function renderAudioSidebar(owner) {
if (!owner?.sidebar) return;
const token = {};
renderTokens.set(owner.sidebar, token);
const listContainer = document.createElement('div');
listContainer.className = 'anomalous-audio-nav-list';
owner.sidebar.replaceChildren(renderSidebarHeader(owner), listContainer);
let data;
try {
const resp = await fetch('/anomalous/audio_voices');
data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
} catch (e) {
if (renderTokens.get(owner.sidebar) !== token) return;
const error = createSectionLabel(t('audioLoadFailed', { error: e.message }));
error.classList.add('is-error');
listContainer.appendChild(error);
return;
}
if (renderTokens.get(owner.sidebar) !== token) return;
const groups = data.characters || [];
if (activeFilter.type === 'group' && !groups.some(group => group.group === activeFilter.value)) {
activeFilter = { type: 'all', value: null };
}
listContainer.appendChild(createNavItem(owner, {
label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count: data.total_slices || 0,
filter: { type: 'all', value: null }, tab: 'presets',
}));
listContainer.appendChild(createSectionLabel(t('audioSectionCharacters')));
for (const group of groups) {
const label = group.folder && group.folder !== 'F5-TTS' ? `${group.character} · ${group.folder}` : group.character;
listContainer.appendChild(createNavItem(owner, {
label, iconSvg: SIDEBAR_SVG.USER, count: group.total_slices,
filter: { type: 'group', value: group.group, character: group.character }, tab: 'presets',
}));
}
listContainer.appendChild(createSectionLabel(t('audioSectionHistory')));
listContainer.appendChild(createNavItem(owner, {
label: t('audioGalleryTab'), iconSvg: SIDEBAR_SVG.DISC, count: null,
filter: { type: 'gallery', value: null }, tab: 'gallery',
}));
}