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
154 lines
6.6 KiB
JavaScript
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',
|
|
}));
|
|
}
|