import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { parseTaggedSpeech } from './audio_script.js';
/**
* Audio Gallery: generated audio in ComfyUI's output folder, shown with the
* speech and voice recorded in each file; unsaved previews from the temp folder
* can be copied into output/audio. Playback, seeking, search, paging,
* download and deletion.
*/
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
const renderTokens = new WeakMap();
let activeAudio = null;
let activePlayBtn = null;
let activeProgressFill = null;
let activeTimeLabel = null;
const SVG = {
PLAY: ``,
PAUSE: ``,
DOWNLOAD: ``,
TRASH: ``,
AGAIN: ``,
COPY: ``,
SAVE: ``,
AUDIO_WAVE: ``,
REFRESH: ``,
SEARCH: ``,
};
export function stopGalleryAudio() {
if (activeAudio) {
activeAudio.pause();
activeAudio.removeAttribute('src');
activeAudio = null;
}
if (activePlayBtn) {
activePlayBtn.innerHTML = SVG.PLAY;
activePlayBtn.classList.remove('is-playing');
activePlayBtn = null;
}
if (activeProgressFill) {
activeProgressFill.style.width = '0%';
activeProgressFill = null;
}
if (activeTimeLabel) {
activeTimeLabel.textContent = '--:--';
activeTimeLabel = null;
}
}
// ---------- formatting ----------
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function iconButton(className, svg, title, onClick) {
const btn = el('button', `anomalous-audio-play-btn ${className}`.trim());
btn.type = 'button';
btn.innerHTML = svg;
btn.title = title;
btn.setAttribute('aria-label', title);
btn.onclick = (e) => {
e.stopPropagation();
onClick(btn);
};
return btn;
}
function formatDuration(sec) {
if (!sec || !Number.isFinite(sec)) return '00:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatDate(timestampSec) {
if (!timestampSec) return '';
const d = new Date(timestampSec * 1000);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function voiceName(samplePath) {
const file = String(samplePath || '').split('/').pop() || '';
return file.replace(/\.[^.]+$/, '');
}
// ---------- playback ----------
function startPlayback(url, playBtn, fillEl, timeEl) {
if (activePlayBtn === playBtn) {
stopGalleryAudio();
return;
}
stopGalleryAudio();
const audio = new Audio(url);
activeAudio = audio;
activePlayBtn = playBtn;
activeProgressFill = fillEl;
activeTimeLabel = timeEl;
playBtn.innerHTML = SVG.PAUSE;
playBtn.classList.add('is-playing');
const isCurrent = () => activeAudio === audio;
audio.onloadedmetadata = () => {
if (isCurrent()) timeEl.textContent = `00:00 / ${formatDuration(audio.duration)}`;
};
audio.ontimeupdate = () => {
if (!isCurrent() || !audio.duration) return;
fillEl.style.width = `${(audio.currentTime / audio.duration) * 100}%`;
timeEl.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(audio.duration)}`;
};
audio.onended = () => { if (isCurrent()) stopGalleryAudio(); };
audio.onerror = () => { if (isCurrent()) stopGalleryAudio(); };
audio.play().catch(() => { if (isCurrent()) stopGalleryAudio(); });
}
// ---------- actions ----------
async function postJson(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
return data;
}
async function requestDeleteTrack(item, rowEl, onDeleted) {
if (!await anomalousConfirm(`${t('audioDeleteConfirm')}\n${item.filename}`)) return;
try {
await postJson('/anomalous/delete_audio_gallery', { filename: item.filename, subfolder: item.subfolder });
} catch (e) {
await anomalousAlert(t('audioDeleteFailed', { error: e.message }));
return;
}
if (activePlayBtn && rowEl.contains(activePlayBtn)) stopGalleryAudio();
rowEl.classList.add('is-removing');
setTimeout(() => {
rowEl.remove();
onDeleted?.();
}, 200);
}
async function copySpeech(speech, btn) {
if (!navigator.clipboard?.writeText) return;
try {
await navigator.clipboard.writeText(speech);
} catch (_) {
return;
}
btn.classList.add('is-done');
btn.title = t('audioSpeechCopied');
setTimeout(() => {
btn.classList.remove('is-done');
btn.title = t('audioCopySpeech');
}, 1500);
}
// ---------- rows ----------
/** Title line, emotion chips and an expandable per-segment script for audio with recorded speech. */
function renderSpeech(generation) {
const segments = parseTaggedSpeech(generation.speech);
const box = el('div', 'anomalous-track-speech');
const chips = el('span', 'anomalous-track-emotions');
const emotions = [...new Set(segments.map(segment => segment.emotion))];
if (emotions.length > 1 || emotions[0] !== 'main') {
for (const emotion of emotions) {
chips.appendChild(el('span', `anomalous-track-emotion${emotion === 'main' ? ' is-main' : ''}`, emotion === 'main' ? t('scriptDirectorMainEmotion') : emotion));
}
}
const summary = el('button', 'anomalous-track-speech-summary', segments.map(segment => segment.text).join(' '));
summary.type = 'button';
summary.title = t('audioExpandSpeech');
const full = el('div', 'anomalous-track-speech-full');
full.hidden = true;
for (const segment of segments) {
const line = el('div', 'anomalous-track-speech-line');
line.append(el('span', `anomalous-track-emotion${segment.emotion === 'main' ? ' is-main' : ''}`, segment.emotion === 'main' ? t('scriptDirectorMainEmotion') : segment.emotion), el('span', '', segment.text));
full.appendChild(line);
}
summary.onclick = () => {
full.hidden = !full.hidden;
box.classList.toggle('is-expanded', !full.hidden);
};
box.append(chips, summary, full);
return box;
}
/** `onVoiceAgain(item)`: open the file's lines on Voice-over (saved files only). */
function createTrackRow(item, { mode, onDeleted, onSaved, onVoiceAgain }) {
const generation = item.generation || null;
const row = el('div', 'anomalous-audio-track-item');
row.draggable = true;
row.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', generation?.speech || item.audio_url);
});
const main = el('div', 'anomalous-track-main');
const playBtn = iconButton('', SVG.PLAY, t('audioPlay'), () => startPlayback(item.audio_url, playBtn, progressFill, timeLabel));
const infoCol = el('div', 'anomalous-track-info');
const meta = el('div', 'anomalous-track-meta');
if (item.subfolder) meta.appendChild(el('span', 'anomalous-track-folder', item.subfolder));
const metaParts = [];
if (generation) metaParts.push(item.filename);
if (generation?.sample) metaParts.push(t('audioVoiceLabel', { name: voiceName(generation.sample) }));
if (generation?.seed !== null && generation?.seed !== undefined) metaParts.push(t('audioSeedLabel', { seed: generation.seed }));
metaParts.push(formatBytes(item.size_bytes), formatDate(item.mtime));
metaParts.forEach(part => meta.appendChild(el('span', '', part)));
if (!generation) {
const nameEl = el('span', 'anomalous-track-name', item.filename);
nameEl.title = item.filename;
infoCol.appendChild(nameEl);
}
infoCol.appendChild(meta);
const progressTrack = el('div', 'anomalous-track-progress-bar');
const progressFill = el('div', 'anomalous-track-progress-fill');
progressTrack.appendChild(progressFill);
progressTrack.onclick = (e) => {
if (!activeAudio || activePlayBtn !== playBtn || !activeAudio.duration) return;
const rect = progressTrack.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
activeAudio.currentTime = ratio * activeAudio.duration;
};
const timeLabel = el('span', 'anomalous-track-time', '--:--');
const actions = el('div', 'anomalous-track-actions');
if (generation?.speech && onVoiceAgain) actions.appendChild(iconButton('', SVG.AGAIN, t('audioVoiceAgain'), () => onVoiceAgain(item)));
if (generation) actions.appendChild(iconButton('', SVG.COPY, t('audioCopySpeech'), btn => copySpeech(generation.speech, btn)));
if (mode === 'preview') {
const saveBtn = el('button', 'anomalous-audio-tool-btn anomalous-track-save', t('audioSavePreview'));
saveBtn.type = 'button';
saveBtn.onclick = async (e) => {
e.stopPropagation();
saveBtn.disabled = true;
try {
const data = await postJson('/anomalous/save_audio_preview', { filename: item.filename, subfolder: item.subfolder });
saveBtn.textContent = t('audioPreviewSaved');
row.classList.add('is-saved');
onSaved?.(data.audio);
} catch (err) {
saveBtn.disabled = false;
await anomalousAlert(t('audioSaveFailed', { error: err.message }));
}
};
actions.appendChild(saveBtn);
} else {
const dlBtn = el('a', 'anomalous-audio-play-btn');
dlBtn.href = item.audio_url;
dlBtn.download = item.filename;
dlBtn.innerHTML = SVG.DOWNLOAD;
dlBtn.title = t('audioDownload');
actions.append(dlBtn, iconButton('anomalous-track-delete', SVG.TRASH, t('audioDelete'), () => requestDeleteTrack(item, row, onDeleted)));
}
main.append(playBtn, infoCol, progressTrack, timeLabel, actions);
row.appendChild(main);
if (generation) row.appendChild(renderSpeech(generation));
return row;
}
// ---------- sections ----------
function renderGalleryToolbar({ onRefresh, onSearch }) {
const bar = el('div', 'anomalous-audio-toolbar');
const left = el('div', 'anomalous-audio-title-group');
const iconBox = el('div', 'anomalous-audio-icon-box');
iconBox.innerHTML = SVG.AUDIO_WAVE;
const textGroup = el('div', 'anomalous-audio-title-stack');
textGroup.append(el('span', 'anomalous-audio-title-text', t('audioGalleryTitle')), el('span', 'anomalous-audio-sub-text', t('audioGallerySubtitle')));
left.append(iconBox, textGroup);
const right = el('div', 'anomalous-audio-right-actions');
const search = el('label', 'anomalous-audio-search');
const searchIcon = el('span');
searchIcon.innerHTML = SVG.SEARCH;
const input = el('input');
input.type = 'search';
input.placeholder = t('audioGallerySearch');
let timer = null;
input.oninput = () => {
clearTimeout(timer);
timer = setTimeout(() => onSearch(input.value.trim()), SEARCH_DELAY_MS);
};
search.append(searchIcon, input);
const countBadge = el('span', 'anomalous-track-count');
right.append(search, countBadge, iconButton('', SVG.REFRESH, t('audioRefresh'), onRefresh));
bar.append(left, right);
return { bar, countBadge };
}
async function fetchJson(url) {
const resp = await fetch(url);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
return data;
}
/** Unsaved previews from ComfyUI's temp folder; hidden when there are none. */
async function renderPreviewSection(section, isCurrent, onSaved) {
let data;
try {
data = await fetchJson('/anomalous/audio_previews');
} catch (_) {
return;
}
if (!isCurrent() || !data.audios?.length) return;
const details = el('details', 'anomalous-track-previews');
const summary = el('summary', 'anomalous-track-previews-summary');
summary.append(
el('span', 'anomalous-track-previews-title', `${t('audioPreviewsTitle')} ยท ${data.audios.length}`),
el('span', 'anomalous-track-previews-hint', t('audioPreviewsHint')),
);
const list = el('div', 'anomalous-track-list');
data.audios.forEach(item => list.appendChild(createTrackRow(item, { mode: 'preview', onSaved })));
details.append(summary, list);
section.replaceChildren(details);
}
/** Only the latest render for a container may write into it. */
/** `owner`: the browser, for "voice it again" (Voice-over). */
export async function renderAudioGallery(container, { owner = null } = {}) {
const token = {};
renderTokens.set(container, token);
const isCurrent = () => renderTokens.get(container) === token;
stopGalleryAudio();
const wrapper = el('div', 'anomalous-audio-gallery-wrapper');
const previewSection = el('div', 'anomalous-track-preview-section');
const listSection = el('div', 'anomalous-track-list-section');
let loadList = () => {};
const { bar, countBadge } = renderGalleryToolbar({
onRefresh: () => renderAudioGallery(container, { owner }),
onSearch: query => loadList(query),
});
wrapper.append(bar, previewSection, listSection);
container.replaceChildren(wrapper);
let listToken = null;
let lastQuery = '';
loadList = async (query = '') => {
lastQuery = query;
const current = {};
listToken = current;
const isCurrentList = () => isCurrent() && listToken === current;
stopGalleryAudio();
const status = renderMessage('anomalous-audio-status', t('audioLoading'));
const trackList = el('div', 'anomalous-track-list');
const loadMore = el('button', 'anomalous-audio-tool-btn anomalous-track-load-more', t('audioLoadMore'));
loadMore.type = 'button';
listSection.replaceChildren(status);
let page = 0;
let shown = 0;
let total = 0;
const updateCount = () => { countBadge.textContent = t('audioTrackCount', { shown, total }); };
const onDeleted = () => {
shown = Math.max(0, shown - 1);
total = Math.max(0, total - 1);
updateCount();
};
const loadNextPage = async () => {
loadMore.disabled = true;
let data;
try {
data = await fetchJson(`/anomalous/audio_gallery?page=${page + 1}&limit=${PAGE_SIZE}&q=${encodeURIComponent(query)}`);
} catch (e) {
if (!isCurrentList()) return;
if (page === 0) status.replaceWith(renderMessage('anomalous-audio-status is-error', t('audioLoadFailed', { error: e.message })));
else await anomalousAlert(t('audioLoadFailed', { error: e.message }));
loadMore.disabled = false;
return;
}
if (!isCurrentList()) return;
page = data.page;
total = data.total;
const audios = data.audios || [];
const onVoiceAgain = owner?.openScript
? item => owner.openScript(null, { speech: item.generation.speech, subfolder: item.subfolder })
: null;
audios.forEach(item => trackList.appendChild(createTrackRow(item, { mode: 'output', onDeleted, onVoiceAgain })));
shown += audios.length;
updateCount();
if (page === 1) {
const empty = query ? t('audioNoMatches') : t('audioGalleryEmpty');
status.replaceWith(total === 0 ? renderMessage('anomalous-audio-empty', empty) : trackList);
}
loadMore.disabled = false;
if (data.has_more) {
if (!loadMore.isConnected) listSection.appendChild(loadMore);
} else {
loadMore.remove();
}
};
loadMore.onclick = loadNextPage;
await loadNextPage();
};
await Promise.all([
renderPreviewSection(previewSection, isCurrent, () => loadList(lastQuery)),
loadList(''),
]);
}
function renderMessage(className, message) {
return el('div', className, message);
}