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