// Library management const currentChapterSelection = {}; let chunkReviewModalJobId = null; let chunkReviewModalData = null; let chapterReviewMode = false; let libraryChunkVoiceOverrides = {}; let libraryChunkRegenWatchers = {}; const LIBRARY_CHUNK_POLL_INTERVAL_MS = 2000; const LIBRARY_CHUNK_MAX_ATTEMPTS = 60; // Library item playback state (only one item should play at a time) let activeLibraryPlayer = null; let activeLibraryUpdateControls = null; function setActiveLibraryPlayer(player, updateControls) { if (activeLibraryPlayer && activeLibraryPlayer !== player) { activeLibraryPlayer.pause(); activeLibraryPlayer.currentTime = 0; if (activeLibraryUpdateControls) { activeLibraryUpdateControls(); } } activeLibraryPlayer = player; activeLibraryUpdateControls = updateControls; } function closeChapterActionMenus(root = document) { root.querySelectorAll('.chapter-action-menu').forEach(menu => { menu.classList.add('hidden'); menu.innerHTML = ''; menu.style.left = ''; menu.style.top = ''; }); } function ensureChapterActionMenu(container) { if (!container) return null; let menu = container.querySelector('.chapter-action-menu'); if (!menu) { menu = document.createElement('div'); menu.className = 'chapter-action-menu hidden'; container.appendChild(menu); } return menu; } function showChapterActionMenu({ menu, button, container, items }) { if (!menu || !button || !container) return; menu.innerHTML = ''; items.forEach(({ label, onClick }) => { const itemButton = document.createElement('button'); itemButton.type = 'button'; itemButton.className = 'chapter-action-menu-item'; itemButton.textContent = label; itemButton.addEventListener('click', (event) => { event.stopPropagation(); onClick(); closeChapterActionMenus(container); }); menu.appendChild(itemButton); }); menu.classList.remove('hidden'); const containerRect = container.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); const left = buttonRect.left - containerRect.left; const top = buttonRect.bottom - containerRect.top + 6; menu.style.left = `${left}px`; menu.style.top = `${top}px`; requestAnimationFrame(() => { const maxLeft = container.clientWidth - menu.offsetWidth - 8; if (menu.offsetWidth && left > maxLeft) { menu.style.left = `${Math.max(8, maxLeft)}px`; } }); } function ensureChunkReviewCloseHandlers() { const modalOverlay = document.getElementById('chunk-review-modal-overlay'); const modalCloseBtn = document.getElementById('chunk-review-modal-close'); const modalCloseFooterBtn = document.getElementById('chunk-review-close-btn'); if (modalOverlay && !modalOverlay.dataset.closeBound) { modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { closeChunkReviewModal(); } }); modalOverlay.dataset.closeBound = 'true'; } if (modalCloseBtn && !modalCloseBtn.dataset.closeBound) { modalCloseBtn.addEventListener('click', closeChunkReviewModal); modalCloseBtn.dataset.closeBound = 'true'; } if (modalCloseFooterBtn && !modalCloseFooterBtn.dataset.closeBound) { modalCloseFooterBtn.addEventListener('click', closeChunkReviewModal); modalCloseFooterBtn.dataset.closeBound = 'true'; } if (!document.body.dataset.chunkReviewEscBound) { document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const overlay = document.getElementById('chunk-review-modal-overlay'); if (overlay && !overlay.classList.contains('hidden')) { closeChunkReviewModal(); } } }); document.body.dataset.chunkReviewEscBound = 'true'; } } async function openChapterReviewModal(jobId, relativePath, fallbackTitle) { if (!jobId) return; ensureChunkReviewCloseHandlers(); chapterReviewMode = true; chunkReviewModalJobId = jobId; chunkReviewModalData = null; libraryChunkVoiceOverrides = {}; const overlay = document.getElementById('chunk-review-modal-overlay'); const modal = document.getElementById('chunk-review-modal'); const body = document.getElementById('chunk-review-modal-body'); const titleEl = document.getElementById('chunk-review-modal-title'); const recompileBtn = document.getElementById('chunk-review-recompile-btn'); if (overlay) overlay.classList.remove('hidden'); if (modal) modal.classList.remove('hidden'); if (body) body.innerHTML = '
Loading chapter...
'; if (titleEl) titleEl.textContent = fallbackTitle || 'Chapter Review'; if (recompileBtn) { recompileBtn.disabled = true; recompileBtn.style.display = 'none'; } try { const response = await fetch(`/api/library/${jobId}/chunks`); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to load chapter'); } const chapters = data.chapters || []; const target = chapters.find(ch => ch.relative_path === relativePath) || chapters.find(ch => ch.output_filename && relativePath?.endsWith(ch.output_filename)) || null; if (!target) { throw new Error('Chapter details not found'); } const filtered = filterChapterReviewData(data, target); chunkReviewModalData = filtered; if (titleEl) { titleEl.textContent = target.title || fallbackTitle || 'Chapter Review'; } renderChunkReviewModal(filtered); } catch (error) { console.error('Error loading chapter review:', error); if (body) body.innerHTML = '
Failed to load chapter.
'; alert(error.message || 'Failed to load chapter review'); } } function filterChapterReviewData(data, chapter) { const chapterIndex = chapter?.index ?? null; const chunks = (data.chunks || []).filter(chunk => { if (chapterIndex === null) return false; return (chunk.chapter_index ?? 0) === chapterIndex; }); return { ...data, chunks, chapters: [], books: [], has_chapters: false, has_books: false, full_story_available: false, review_chapter_index: chapterIndex, }; } function clearActiveLibraryPlayer(player) { if (activeLibraryPlayer === player) { activeLibraryPlayer = null; activeLibraryUpdateControls = null; } } // Audio playback state for chunk review modal let libraryActiveAudio = null; let libraryActivePlayButton = null; // Continuous chunk playback state let chunkSequenceAudio = null; let chunkSequenceItems = []; let chunkSequenceIndex = -1; let chunkSequenceButton = null; let chunkSequenceLabel = null; let chunkSequenceLastCard = null; let chunkSequenceStartIndex = null; let chunkSequenceResumeIndex = null; // Preview audio state let previewAudio = null; let previewButton = null; // Voice map for looking up lang_code by voice id let libraryVoiceMap = new Map(); function setRegenButtonBusy(button, isBusy, label = 'Regenerating...') { if (!button) return; if (isBusy) { if (!button.dataset.originalText) { button.dataset.originalText = button.textContent || 'Regenerate'; } button.disabled = true; button.classList.add('is-loading'); button.textContent = label; } else { button.disabled = false; button.classList.remove('is-loading'); button.textContent = button.dataset.originalText || 'Regenerate'; delete button.dataset.originalText; } } function resetChunkRegenButton(chunkId) { const card = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`); if (!card) return; const btn = card.querySelector('.library-chunk-regen'); setRegenButtonBusy(btn, false); } // Locale code to human-readable language name mapping const LIBRARY_LOCALE_NAMES = { 'af-ZA': 'Afrikaans', 'am-ET': 'Amharic', 'ar-AE': 'Arabic (UAE)', 'ar-BH': 'Arabic (Bahrain)', 'ar-DZ': 'Arabic (Algeria)', 'ar-EG': 'Arabic (Egypt)', 'ar-IQ': 'Arabic (Iraq)', 'ar-JO': 'Arabic (Jordan)', 'ar-KW': 'Arabic (Kuwait)', 'ar-LB': 'Arabic (Lebanon)', 'ar-LY': 'Arabic (Libya)', 'ar-MA': 'Arabic (Morocco)', 'ar-OM': 'Arabic (Oman)', 'ar-QA': 'Arabic (Qatar)', 'ar-SA': 'Arabic (Saudi)', 'ar-SY': 'Arabic (Syria)', 'ar-TN': 'Arabic (Tunisia)', 'ar-YE': 'Arabic (Yemen)', 'az-AZ': 'Azerbaijani', 'bg-BG': 'Bulgarian', 'bn-BD': 'Bengali (Bangladesh)', 'bn-IN': 'Bengali (India)', 'bs-BA': 'Bosnian', 'ca-ES': 'Catalan', 'cs-CZ': 'Czech', 'cy-GB': 'Welsh', 'da-DK': 'Danish', 'de-AT': 'German (Austria)', 'de-CH': 'German (Swiss)', 'de-DE': 'German', 'el-GR': 'Greek', 'en-AU': 'English (AU)', 'en-CA': 'English (CA)', 'en-GB': 'English (UK)', 'en-HK': 'English (HK)', 'en-IE': 'English (IE)', 'en-IN': 'English (IN)', 'en-KE': 'English (KE)', 'en-NG': 'English (NG)', 'en-NZ': 'English (NZ)', 'en-PH': 'English (PH)', 'en-SG': 'English (SG)', 'en-TZ': 'English (TZ)', 'en-US': 'English (US)', 'en-ZA': 'English (ZA)', 'es-AR': 'Spanish (AR)', 'es-BO': 'Spanish (BO)', 'es-CL': 'Spanish (CL)', 'es-CO': 'Spanish (CO)', 'es-CR': 'Spanish (CR)', 'es-CU': 'Spanish (CU)', 'es-DO': 'Spanish (DO)', 'es-EC': 'Spanish (EC)', 'es-ES': 'Spanish (ES)', 'es-GQ': 'Spanish (GQ)', 'es-GT': 'Spanish (GT)', 'es-HN': 'Spanish (HN)', 'es-MX': 'Spanish (MX)', 'es-NI': 'Spanish (NI)', 'es-PA': 'Spanish (PA)', 'es-PE': 'Spanish (PE)', 'es-PR': 'Spanish (PR)', 'es-PY': 'Spanish (PY)', 'es-SV': 'Spanish (SV)', 'es-US': 'Spanish (US)', 'es-UY': 'Spanish (UY)', 'es-VE': 'Spanish (VE)', 'et-EE': 'Estonian', 'eu-ES': 'Basque', 'fa-IR': 'Persian', 'fi-FI': 'Finnish', 'fil-PH': 'Filipino', 'fr-BE': 'French (BE)', 'fr-CA': 'French (CA)', 'fr-CH': 'French (CH)', 'fr-FR': 'French', 'ga-IE': 'Irish', 'gl-ES': 'Galician', 'gu-IN': 'Gujarati', 'he-IL': 'Hebrew', 'hi-IN': 'Hindi', 'hr-HR': 'Croatian', 'hu-HU': 'Hungarian', 'hy-AM': 'Armenian', 'id-ID': 'Indonesian', 'is-IS': 'Icelandic', 'it-IT': 'Italian', 'ja-JP': 'Japanese', 'jv-ID': 'Javanese', 'ka-GE': 'Georgian', 'kk-KZ': 'Kazakh', 'km-KH': 'Khmer', 'kn-IN': 'Kannada', 'ko-KR': 'Korean', 'lo-LA': 'Lao', 'lt-LT': 'Lithuanian', 'lv-LV': 'Latvian', 'mk-MK': 'Macedonian', 'ml-IN': 'Malayalam', 'mn-MN': 'Mongolian', 'mr-IN': 'Marathi', 'ms-MY': 'Malay', 'mt-MT': 'Maltese', 'my-MM': 'Burmese', 'nb-NO': 'Norwegian', 'ne-NP': 'Nepali', 'nl-BE': 'Dutch (BE)', 'nl-NL': 'Dutch', 'pl-PL': 'Polish', 'ps-AF': 'Pashto', 'pt-BR': 'Portuguese (BR)', 'pt-PT': 'Portuguese', 'ro-RO': 'Romanian', 'ru-RU': 'Russian', 'si-LK': 'Sinhala', 'sk-SK': 'Slovak', 'sl-SI': 'Slovenian', 'so-SO': 'Somali', 'sq-AL': 'Albanian', 'sr-RS': 'Serbian', 'su-ID': 'Sundanese', 'sv-SE': 'Swedish', 'sw-KE': 'Swahili (KE)', 'sw-TZ': 'Swahili (TZ)', 'ta-IN': 'Tamil (IN)', 'ta-LK': 'Tamil (LK)', 'ta-MY': 'Tamil (MY)', 'ta-SG': 'Tamil (SG)', 'te-IN': 'Telugu', 'th-TH': 'Thai', 'tr-TR': 'Turkish', 'uk-UA': 'Ukrainian', 'ur-IN': 'Urdu (IN)', 'ur-PK': 'Urdu (PK)', 'uz-UZ': 'Uzbek', 'vi-VN': 'Vietnamese', 'wuu-CN': 'Wu Chinese', 'yue-CN': 'Cantonese', 'zh-CN': 'Chinese (Mandarin)', 'zh-HK': 'Chinese (HK)', 'zh-TW': 'Chinese (TW)', 'zu-ZA': 'Zulu', }; function getLibraryLanguageDisplayName(localeCode) { if (!localeCode) return ''; return LIBRARY_LOCALE_NAMES[localeCode] || localeCode; } function wireBookToggleEvents() { const headers = document.querySelectorAll('.book-header'); headers.forEach(header => { header.addEventListener('click', () => { const bookIdx = header.getAttribute('data-book-index'); const chaptersContainer = document.querySelector(`.book-chapters[data-book-index="${bookIdx}"]`); const toggle = header.querySelector('.book-toggle'); if (chaptersContainer) { const isCollapsed = chaptersContainer.classList.contains('collapsed'); if (isCollapsed) { chaptersContainer.classList.remove('collapsed'); if (toggle) toggle.textContent = '▼'; } else { chaptersContainer.classList.add('collapsed'); if (toggle) toggle.textContent = '▶'; } } }); }); if (typeof initHelpSystem === 'function') { initHelpSystem(); } } // Library voice dropdown filter state let libraryVoiceFilters = { gender: 'all', language: 'all' }; // Load library on tab switch document.addEventListener('DOMContentLoaded', () => { loadLibrary(); if (typeof setupGlobalPlayerControls === 'function') { setupGlobalPlayerControls(); } if (typeof setupLibraryControls === 'function') { setupLibraryControls(); } // Load library when Library tab is clicked const libraryTab = document.querySelector('[data-tab="library"]'); if (libraryTab) { libraryTab.addEventListener('click', () => { loadLibrary(); }); } // Refresh button const refreshBtn = document.getElementById('refresh-library-btn'); if (refreshBtn) { refreshBtn.addEventListener('click', loadLibrary); } // Clear all button const clearBtn = document.getElementById('clear-library-btn'); if (clearBtn) { clearBtn.addEventListener('click', clearLibrary); } // Chunk review modal close handlers const modalOverlay = document.getElementById('chunk-review-modal-overlay'); const modalCloseBtn = document.getElementById('chunk-review-modal-close'); const modalCloseFooterBtn = document.getElementById('chunk-review-close-btn'); const recompileBtn = document.getElementById('chunk-review-recompile-btn'); if (modalOverlay) { modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { closeChunkReviewModal(); } }); } if (modalCloseBtn) { modalCloseBtn.addEventListener('click', closeChunkReviewModal); } if (modalCloseFooterBtn) { modalCloseFooterBtn.addEventListener('click', closeChunkReviewModal); } if (recompileBtn) { recompileBtn.addEventListener('click', recompileLibraryAudio); } }); document.addEventListener('library:refresh', () => { loadLibrary(); }); // Load library items async function loadLibrary() { try { const response = await fetch('/api/library'); const data = await response.json(); if (data.success) { displayLibraryItems(data.items); } else { alert('Error loading library: ' + data.error); } } catch (error) { console.error('Error loading library:', error); alert('Failed to load library'); } } // Display library items function formatEngineName(engine) { if (!engine) return ''; const engineMap = { 'kokoro': 'Kokoro', 'kokoro_replicate': 'Kokoro (Replicate)', 'chatterbox_turbo_local': 'Chatterbox', 'chatterbox_turbo_replicate': 'Chatterbox (Replicate)', 'voxcpm_local': 'VoxCPM 1.5', 'pocket_tts': 'Pocket TTS (Clone Voices)', 'pocket_tts_preset': 'Pocket TTS (Preset Voices)', 'qwen3_custom': 'Qwen3-TTS (Custom Voice)', 'qwen3_clone': 'Qwen3-TTS (Voice Clone)', }; return engineMap[engine] || engine; } function formatSectionLabel(section, fallbackLabel) { if (!section) { return fallbackLabel; } if (section.title) { return section.title; } if (section.index) { return `${fallbackLabel} ${section.index}`; } return fallbackLabel; } function renderChapterControls(item) { const sections = item.book_mode && item.books?.length ? item.books : item.chapters; if (!sections || sections.length <= 1) { if (!item.has_chunks) return ''; // Single-chapter or no chapters but chunk data exists — render just the review button const reviewAllButton = ` `; return `
${reviewAllButton}
`; } const label = item.book_mode ? 'Books' : 'Chapters'; const fallbackLabel = item.book_mode ? 'Book' : 'Chapter'; const reviewAllButton = item.has_chunks ? ` ` : ''; return `
${label}
${sections.map((section, idx) => ` `).join('')} ${reviewAllButton}
`; } function renderFullStoryBanner() { return ''; } function displayLibraryItems(items) { const container = document.getElementById('library-items'); const emptyMessage = document.getElementById('library-empty'); if (items.length === 0) { container.innerHTML = ''; emptyMessage.style.display = 'block'; return; } emptyMessage.style.display = 'none'; container.innerHTML = ''; items.forEach(item => { const itemCard = document.createElement('div'); itemCard.className = 'library-item'; const createdDate = new Date(item.created_at); const formattedDate = createdDate.toLocaleString(); const fileSizeMB = (item.file_size / (1024 * 1024)).toFixed(2); const sectionList = item.book_mode && item.books?.length ? item.books : item.chapters; const initialChapter = (sectionList && sectionList.length > 0) ? sectionList[0] : null; if (initialChapter) { const initialIndex = Number.isFinite(initialChapter.index) ? initialChapter.index : (sectionList ? sectionList.indexOf(initialChapter) : 0); currentChapterSelection[item.job_id] = { ...initialChapter, chapter_number: Number.isFinite(initialIndex) ? initialIndex + 1 : 1, }; } // Format engine name for display const engineLabel = formatEngineName(item.engine); const defaultTitle = item.book_mode ? 'Book Collection' : (item.chapter_mode ? 'Chapter Collection' : 'Generated Audio'); const displayTitle = item.collection_title || defaultTitle; const hasBooks = item.book_mode && Array.isArray(item.books) && item.books.length > 0; const hasChapters = Array.isArray(item.chapters) && item.chapters.length > 0; itemCard.innerHTML = `
${escapeHtml(displayTitle)} ${formattedDate}
${engineLabel ? `${engineLabel}` : ''} ${fileSizeMB} MB ${item.format.toUpperCase()} ${item.timing_metrics ? `` : ''}
`; if (item.timing_metrics) { itemCard.dataset.timingMetrics = JSON.stringify(item.timing_metrics); } container.appendChild(itemCard); const player = itemCard.querySelector(`#player-${item.job_id}`); if (player && initialChapter) { player.src = initialChapter.output_file; player.load(); } else if (player) { player.src = item.output_file; player.load(); } const summaryHeader = itemCard.querySelector(`.library-item-summary[data-job-id="${item.job_id}"]`); const details = itemCard.querySelector(`.library-item-details[data-job-id="${item.job_id}"]`); const toggleBtn = itemCard.querySelector(`.library-item-toggle`); const controls = itemCard.querySelector(`.library-item-controls[data-job-id="${item.job_id}"]`); const playBtn = itemCard.querySelector(`.library-item-play[data-job-id="${item.job_id}"]`); const pauseBtn = itemCard.querySelector(`.library-item-pause[data-job-id="${item.job_id}"]`); const titleEl = itemCard.querySelector('.library-item-title'); const editTitleBtn = itemCard.querySelector('.library-title-edit'); if (summaryHeader && details && toggleBtn) { summaryHeader.addEventListener('click', (event) => { if (event.target.closest('.library-item-controls') || event.target.closest('.library-title-edit') || event.target.closest('.library-item-meta-action')) { return; } const isCollapsed = details.classList.contains('collapsed'); details.classList.toggle('collapsed'); toggleBtn.textContent = isCollapsed ? '▼' : '▶'; toggleBtn.setAttribute('aria-expanded', String(isCollapsed)); }); } if (editTitleBtn && titleEl) { editTitleBtn.addEventListener('click', async (event) => { event.stopPropagation(); const nextTitle = prompt('Collection title', titleEl.textContent || ''); if (!nextTitle) { return; } try { const response = await fetch(`/api/library/${item.job_id}/title`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextTitle }) }); const data = await response.json(); if (!data.success) { alert(`Failed to update title: ${data.error}`); return; } titleEl.textContent = data.title; } catch (error) { console.error('Error updating collection title:', error); alert('Failed to update title'); } }); } if (controls && player) { const getFullStoryInfo = () => { const fullStorySrc = controls.getAttribute('data-full-story-src'); const fullStoryPath = controls.getAttribute('data-full-story-path'); return { fullStorySrc, fullStoryPath }; }; const updateControls = () => { if (!playBtn || !pauseBtn) return; const isStopped = (player.currentTime === 0 && player.paused) || player.ended; if (isStopped) { // Fully stopped: show play button, hide pause button playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play'); pauseBtn.style.display = 'none'; } else { // Playing or paused (but not stopped): show stop button and pause button playBtn.textContent = '■'; playBtn.setAttribute('aria-label', 'Stop'); pauseBtn.style.display = 'inline-block'; } }; // Initialize button states updateControls(); if (playBtn) { playBtn.addEventListener('click', (event) => { event.stopPropagation(); // If playing or paused, stop it if (player.currentTime > 0) { player.pause(); player.currentTime = 0; clearActiveLibraryPlayer(player); updateControls(); return; } // Start playing const { fullStorySrc, fullStoryPath } = getFullStoryInfo(); if (fullStorySrc) { player.src = fullStorySrc; player.load(); currentChapterSelection[item.job_id] = { output_file: fullStorySrc, relative_path: fullStoryPath, title: 'Full Story' }; } setActiveLibraryPlayer(player, updateControls); player.play(); updateControls(); }); } if (pauseBtn) { pauseBtn.addEventListener('click', (event) => { event.stopPropagation(); // Toggle pause/unpause if (!player.paused) { player.pause(); } else if (player.currentTime > 0) { player.play(); } // Don't call updateControls here - let the play/pause events handle it }); } player.addEventListener('play', () => { updateControls(); }); player.addEventListener('pause', () => { updateControls(); }); player.addEventListener('ended', () => { clearActiveLibraryPlayer(player); updateControls(); }); } // Wire chapter buttons const chapterControls = itemCard.querySelector('.chapter-controls'); const chapterButtons = itemCard.querySelectorAll(`.chapter-pill[data-job-id="${item.job_id}"]`); const reviewAllButton = itemCard.querySelector('.chapter-review-all'); const menu = ensureChapterActionMenu(chapterControls); chapterButtons.forEach(button => { if (button === reviewAllButton) return; button.addEventListener('click', (event) => { event.stopPropagation(); const relativePath = button.getAttribute('data-relative-path'); const src = button.getAttribute('data-src'); const jobId = button.getAttribute('data-job-id'); const playerEl = document.getElementById(`player-${jobId}`); chapterButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); if (playerEl && src) { playerEl.src = src; playerEl.load(); } const chapterNumber = Number(button.getAttribute('data-index')) || null; const selectedChapter = (sectionList || []).find(ch => ch.relative_path === relativePath) || { output_file: src, relative_path: relativePath, title: button.textContent.trim(), index: chapterNumber ? chapterNumber - 1 : null, }; currentChapterSelection[jobId] = { ...selectedChapter, chapter_number: chapterNumber || (Number.isFinite(selectedChapter.index) ? selectedChapter.index + 1 : null), }; const anchorId = relativePath || button.textContent.trim(); if (menu && !menu.classList.contains('hidden') && menu.dataset.anchorId === anchorId) { closeChapterActionMenus(chapterControls); return; } if (menu) { menu.dataset.anchorId = anchorId; } showChapterActionMenu({ menu, button, container: chapterControls, items: [ { label: 'Review Chunks', onClick: () => openChapterReviewModal(jobId, relativePath, button.textContent.trim()) }, { label: 'Download Chapter', onClick: () => downloadLibraryItem(jobId) } ] }); }); }); if (reviewAllButton) { reviewAllButton.addEventListener('click', (event) => { event.stopPropagation(); const jobId = reviewAllButton.getAttribute('data-job-id'); const fullStoryPath = reviewAllButton.getAttribute('data-full-story-path'); const anchorId = 'review-all'; if (menu && !menu.classList.contains('hidden') && menu.dataset.anchorId === anchorId) { closeChapterActionMenus(chapterControls); return; } if (menu) { menu.dataset.anchorId = anchorId; } const menuItems = [ { label: 'Review Chunks', onClick: () => restoreToReview(jobId) } ]; if (fullStoryPath) { menuItems.push({ label: 'Download Full Story', onClick: () => downloadFullStory(jobId, fullStoryPath) }); } showChapterActionMenu({ menu, button: reviewAllButton, container: chapterControls, items: menuItems }); }); } }); if (!document.body.dataset.chapterMenuBound) { document.addEventListener('click', (event) => { if (!event.target.closest('.chapter-pill') && !event.target.closest('.chapter-action-menu')) { closeChapterActionMenus(); } }); document.body.dataset.chapterMenuBound = 'true'; } if (typeof initHelpSystem === 'function') { initHelpSystem(); } } // Library Metrics Modal function openLibraryMetrics(jobId, btn) { const card = btn ? btn.closest('.library-item') : document.querySelector(`.library-item [data-job-id="${jobId}"]`)?.closest('.library-item'); const tm = card && card.dataset.timingMetrics ? JSON.parse(card.dataset.timingMetrics) : null; if (!tm) { alert('No metrics data available for this item.'); return; } function fmtTime(iso) { return iso ? new Date(iso).toLocaleString() : 'N/A'; } const startedAt = fmtTime(tm.started_at); const completedAt = fmtTime(tm.completed_at); const totalTime = fmtDuration(tm.total_seconds); const avgChunk = tm.avg_chunk_seconds != null ? fmtDuration(tm.avg_chunk_seconds) : 'N/A'; const minChunk = tm.min_chunk_seconds != null ? fmtDuration(tm.min_chunk_seconds) : 'N/A'; const maxChunk = tm.max_chunk_seconds != null ? fmtDuration(tm.max_chunk_seconds) : 'N/A'; const chunkCount = tm.chunk_count != null ? tm.chunk_count : 'N/A'; const chart = buildChunkChart(tm.chunk_times, tm.chunk_count, tm.total_seconds); // Build or reuse overlay let overlay = document.getElementById('library-metrics-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'library-metrics-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9000;display:flex;align-items:center;justify-content:center;'; overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } overlay.innerHTML = `

Generation Metrics

Started${startedAt}
Completed${completedAt}
Total Job Time${totalTime}
Chunks${chunkCount}
Avg Chunk Time${avgChunk}
Fastest Chunk${minChunk}
Slowest Chunk${maxChunk}
${chart}
`; if (!document.getElementById('library-metrics-overlay')) { document.body.appendChild(overlay); } // Close on Escape const escHandler = e => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); } // Download library item function downloadLibraryItem(jobId) { const selected = currentChapterSelection[jobId]; if (!selected || !selected.relative_path) { window.location.href = `/api/download/${jobId}`; return; } const queryParts = [`file=${encodeURIComponent(selected.relative_path)}`]; const downloadName = buildChapterDownloadName(selected); if (downloadName) { queryParts.push(`download_name=${encodeURIComponent(downloadName)}`); } window.location.href = `/api/download/${jobId}?${queryParts.join('&')}`; } function buildChapterDownloadName(selected) { if (!selected || !selected.relative_path) return ''; const rawTitle = selected.title || 'Chapter'; const sanitizedTitle = rawTitle.replace(/[^a-z0-9\-_. ]/gi, '').trim().replace(/\s+/g, ' '); const extension = selected.relative_path.split('.').pop() || 'mp3'; const chapterNumber = Number.isFinite(selected.chapter_number) ? selected.chapter_number : (Number.isFinite(selected.index) ? selected.index + 1 : null); const displayNumber = chapterNumber ? String(chapterNumber).padStart(2, '0') : ''; const prefix = displayNumber ? `Chapter-${displayNumber}` : 'Chapter'; const titlePart = sanitizedTitle && sanitizedTitle.toLowerCase() !== 'full story' ? `_${sanitizedTitle.replace(/\s+/g, '-')}` : ''; return `${prefix}${titlePart}.${extension}`; } function downloadChapterZip(jobId) { window.location.href = `/api/download/${jobId}/zip`; } function playFullStory(jobId, fileUrl, relativePath) { const playerEl = document.getElementById(`player-${jobId}`); if (playerEl && fileUrl) { playerEl.src = fileUrl; playerEl.load(); } currentChapterSelection[jobId] = { output_file: fileUrl, relative_path: relativePath, title: 'Full Story' }; } function downloadFullStory(jobId, relativePath) { window.location.href = `/api/download/${jobId}?file=${encodeURIComponent(relativePath)}`; } // Delete library item async function deleteLibraryItem(jobId) { if (!confirm('Are you sure you want to delete this audio file?')) { return; } try { const response = await fetch(`/api/library/${jobId}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { loadLibrary(); // Reload library } else { alert('Error deleting item: ' + data.error); } } catch (error) { console.error('Error deleting item:', error); alert('Failed to delete item'); } } async function repairLibraryItem(jobId, trigger) { if (!confirm('Repair this library item? This will rebuild chapter metadata and missing outputs.')) { return; } const button = trigger instanceof HTMLElement ? trigger : null; const originalLabel = button ? button.innerHTML : null; if (button) { button.disabled = true; button.classList.add('is-busy'); button.innerHTML = 'Repairing '; } try { const response = await fetch(`/api/library/${jobId}/repair`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force_rebuild: true }) }); const data = await response.json(); if (!data.success) { alert(`Repair failed: ${data.error || 'Unknown error'}`); return; } alert('Repair completed. Reloading library...'); loadLibrary(); } catch (error) { console.error('Repair library item error:', error); alert('Repair failed. Check the console for details.'); } finally { if (button) { button.disabled = false; button.classList.remove('is-busy'); button.innerHTML = originalLabel || 'Repair'; } } } // Open chunk review modal for a library item async function restoreToReview(jobId) { chunkReviewModalJobId = jobId; chunkReviewModalData = null; libraryChunkVoiceOverrides = {}; chapterReviewMode = false; ensureChunkReviewCloseHandlers(); const overlay = document.getElementById('chunk-review-modal-overlay'); const modal = document.getElementById('chunk-review-modal'); const body = document.getElementById('chunk-review-modal-body'); const recompileBtn = document.getElementById('chunk-review-recompile-btn'); const titleEl = document.getElementById('chunk-review-modal-title'); if (overlay) overlay.classList.remove('hidden'); if (modal) modal.classList.remove('hidden'); if (body) body.innerHTML = '
Loading chunks...
'; if (recompileBtn) recompileBtn.disabled = true; if (recompileBtn) recompileBtn.style.display = ''; if (titleEl) titleEl.textContent = 'Review Chunks'; try { const response = await fetch(`/api/library/${jobId}/chunks`); const data = await response.json(); if (!data.success) { if (body) body.innerHTML = `
Error: ${data.error}
`; return; } chunkReviewModalData = data; renderChunkReviewModal(data); if (recompileBtn) recompileBtn.disabled = false; } catch (error) { console.error('Error loading chunks:', error); if (body) body.innerHTML = '
Failed to load chunk data.
'; } } function closeChunkReviewModal() { const overlay = document.getElementById('chunk-review-modal-overlay'); const modal = document.getElementById('chunk-review-modal'); const titleEl = document.getElementById('chunk-review-modal-title'); const recompileBtn = document.getElementById('chunk-review-recompile-btn'); if (overlay) overlay.classList.add('hidden'); if (modal) modal.classList.add('hidden'); // Stop any playing audio stopLibraryChunkAudio(); stopPreviewAudio(); // Clear watchers Object.keys(libraryChunkRegenWatchers).forEach(key => { const entry = libraryChunkRegenWatchers[key]; if (entry && entry.timer) { clearTimeout(entry.timer); } }); libraryChunkRegenWatchers = {}; chunkReviewModalJobId = null; chunkReviewModalData = null; libraryChunkVoiceOverrides = {}; chapterReviewMode = false; if (recompileBtn) { recompileBtn.style.display = ''; recompileBtn.disabled = false; recompileBtn.textContent = 'Recompile Audio'; } if (titleEl) titleEl.textContent = 'Review Chunks'; } function renderChunkReviewModal(data) { const body = document.getElementById('chunk-review-modal-body'); if (!body) return; const chunks = data.chunks || []; const chapters = data.chapters || []; const books = data.books || []; const hasChapters = data.has_chapters || false; const hasBooks = data.has_books || false; const fullStoryAvailable = data.full_story_available || false; const engine = data.engine || 'kokoro'; const jobId = data.job_id; if (chunks.length === 0) { body.innerHTML = '
No chunks available.
'; return; } // Extract unique speakers and count their chunks const speakerMap = new Map(); const speakerInstructionMap = new Map(); chunks.forEach(chunk => { const speaker = chunk.speaker || 'default'; if (!speakerMap.has(speaker)) { speakerMap.set(speaker, { count: 0, voiceLabel: chunk.voice_label || chunk.voice || 'Default' }); } speakerMap.get(speaker).count++; if (!speakerInstructionMap.has(speaker)) { const instruction = getChunkInstruction(chunk); if (instruction) { speakerInstructionMap.set(speaker, instruction); } } }); // Build speaker section HTML - accordion layout const speakerRows = Array.from(speakerMap.entries()).map(([speaker, info]) => { const speakerInstruction = speakerInstructionMap.get(speaker) || ''; return `
${escapeHtml(speaker)} (${info.count} chunks) ${escapeHtml(info.voiceLabel)}
`; }).join(''); const chunksByChapter = new Map(); chunks.forEach((chunk, idx) => { const chapterIdx = chunk.chapter_index ?? 0; if (!chunksByChapter.has(chapterIdx)) { chunksByChapter.set(chapterIdx, []); } chunksByChapter.get(chapterIdx).push({ chunk, idx }); }); const buildChapterSection = (chapter, chapterNum) => { const chapterIdx = chapter.index ?? chapterNum; let chapterChunks = chunksByChapter.get(chapterIdx) || []; if (chapterChunks.length === 0 && chapterIdx > 0) { chapterChunks = chunksByChapter.get(chapterIdx - 1) || []; } if (chapterChunks.length === 0) { chapterChunks = chunksByChapter.get(chapterNum) || []; } const chapterTitle = chapter.title || `Chapter ${chapterIdx + 1}`; const chunkRows = chapterChunks.map(({ chunk, idx }) => renderLibraryChunkRow(jobId, chunk, engine, idx) ).join(''); return `
${escapeHtml(chapterTitle)} ${chapterChunks.length} chunks
`; }; // Build chunk content - grouped by book/chapters or flat list let chunkContent = ''; if (hasBooks && books.length > 0) { chunkContent = books.map((book, bookNum) => { const bookIdx = book.index ?? bookNum; const chapterIndices = new Set(book.chapter_indices || []); const bookChapters = chapters .filter(ch => (ch.book_index ?? bookIdx) === bookIdx || chapterIndices.has(ch.index)) .sort((a, b) => (a.book_order ?? a.index ?? 0) - (b.book_order ?? b.index ?? 0)); const chapterBlocks = bookChapters.map((chapter, chapterNum) => buildChapterSection(chapter, chapterNum) ).join(''); const bookTitle = book.title || `Book ${bookIdx + 1}`; return `
${escapeHtml(bookTitle)} ${bookChapters.length} chapters
`; }).join(''); } else if (hasChapters && chapters.length > 0) { chunkContent = chapters.map((chapter, chapterNum) => buildChapterSection(chapter, chapterNum)).join(''); } else { chunkContent = chunks.map((chunk, idx) => renderLibraryChunkRow(jobId, chunk, engine, idx)).join(''); } const chapterInfo = hasBooks ? `Books: ${books.length}Chapters: ${chapters.length}` : (hasChapters ? `Chapters: ${chapters.length}` : ''); const fullStoryControls = fullStoryAvailable ? `
` : ''; const chapterRebuildControls = chapterReviewMode && data.review_chapter_index !== null ? ` ` : ''; const batchControls = chunks.length ? ` ` : ''; body.innerHTML = `
Original Engine: ${formatEngineName(engine)} ${chapterInfo} Chunks: ${chunks.length}
${batchControls} ${chapterRebuildControls} ${fullStoryControls}
Bulk Speaker Regeneration Select speakers and choose a voice to regenerate all their chunks
${speakerRows}
Start Audio Review
${chunkContent}
`; // Store original engine for reference body.dataset.originalEngine = engine; // Wire chapter toggle events if chapters exist if (hasChapters) { wireChapterToggleEvents(); } if (hasBooks) { wireBookToggleEvents(); } wireChunkReviewEvents(jobId, chunks, engine); wireChapterRebuildEvents(jobId); wireFullStoryRebuildEvent(jobId, fullStoryAvailable); wireChapterReviewRebuildEvent(jobId, data.review_chapter_index); wireBatchRebuildEvents(jobId, chunks, engine); if (typeof initHelpSystem === 'function') { initHelpSystem(); } } function wireChapterReviewRebuildEvent(jobId, chapterIndex) { const button = document.getElementById('chunk-review-rebuild-chapter'); if (!button || chapterIndex === null || chapterIndex === undefined) { return; } button.addEventListener('click', async (event) => { event.stopPropagation(); const originalText = button.textContent; button.disabled = true; button.textContent = 'Rebuilding...'; try { const response = await fetch(`/api/library/${jobId}/rebuild/chapter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chapter_index: Number(chapterIndex) }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to rebuild chapter'); } alert('Chapter rebuilt successfully.'); loadLibrary(); } catch (error) { console.error('Rebuild chapter error:', error); alert(error.message || 'Failed to rebuild chapter'); } finally { button.disabled = false; button.textContent = originalText; } }); } function wireBatchRebuildEvents(jobId, chunks, engine) { const selectAll = document.getElementById('chunk-review-select-all'); const rebuildSelected = document.getElementById('chunk-review-rebuild-selected'); const checkboxes = Array.from(document.querySelectorAll('.bulk-speaker-checkbox')); const updateSelectedState = () => { if (!rebuildSelected) return; const selected = checkboxes.filter(box => box.checked); rebuildSelected.disabled = selected.length === 0; }; if (selectAll) { selectAll.addEventListener('change', () => { checkboxes.forEach(box => { box.checked = selectAll.checked; }); updateSelectedState(); }); } checkboxes.forEach(box => { box.addEventListener('click', (event) => { event.stopPropagation(); }); box.addEventListener('change', () => { if (selectAll) { selectAll.checked = checkboxes.every(cb => cb.checked); } updateSelectedState(); }); }); if (rebuildSelected) { rebuildSelected.addEventListener('click', async (event) => { event.stopPropagation(); const selected = checkboxes.filter(box => box.checked); if (!selected.length) return; const missingVoices = []; const selectedCards = selected.map(box => box.closest('.bulk-speaker-card')).filter(Boolean); selectedCards.forEach(card => { const speaker = card.getAttribute('data-speaker') || 'default'; const voiceSelect = card.querySelector('.bulk-speaker-voice-select'); if (!voiceSelect || !voiceSelect.value) { missingVoices.push(speaker); } }); if (missingVoices.length) { alert(`Select a voice for: ${missingVoices.join(', ')}`); return; } const originalText = rebuildSelected.textContent; rebuildSelected.disabled = true; rebuildSelected.textContent = 'Regenerating...'; try { for (const card of selectedCards) { const speaker = card.getAttribute('data-speaker') || 'default'; const engineSelect = card.querySelector('.bulk-speaker-engine-select'); const engineOverride = engineSelect?.value || engine; const button = card.querySelector('.bulk-speaker-regen'); if (button) { await triggerBulkSpeakerRegen(jobId, speaker, chunks, engineOverride, button); } } } catch (error) { console.error('Bulk regen selected error:', error); alert(error.message || 'Failed to regenerate selected speakers'); } finally { rebuildSelected.textContent = originalText; updateSelectedState(); } }); } updateSelectedState(); } function wireChapterRebuildEvents(jobId) { const buttons = document.querySelectorAll('.chapter-rebuild-btn'); buttons.forEach(btn => { btn.addEventListener('click', async (event) => { event.stopPropagation(); const chapterIdx = btn.getAttribute('data-chapter-index'); if (chapterIdx === null) return; const originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Rebuilding...'; try { const response = await fetch(`/api/library/${jobId}/rebuild/chapter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chapter_index: Number(chapterIdx) }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to rebuild chapter'); } alert('Chapter rebuilt successfully.'); loadLibrary(); } catch (error) { console.error('Rebuild chapter error:', error); alert(error.message || 'Failed to rebuild chapter'); } finally { btn.disabled = false; btn.textContent = originalText; } }); }); } function wireFullStoryRebuildEvent(jobId, fullStoryAvailable) { if (!fullStoryAvailable) return; const button = document.getElementById('chunk-review-rebuild-full'); if (!button) return; button.addEventListener('click', async (event) => { event.stopPropagation(); const originalText = button.textContent; button.disabled = true; button.textContent = 'Rebuilding...'; try { const response = await fetch(`/api/library/${jobId}/rebuild/full-story`, { method: 'POST' }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to rebuild full story'); } alert('Full story rebuilt successfully.'); loadLibrary(); } catch (error) { console.error('Rebuild full story error:', error); alert(error.message || 'Failed to rebuild full story'); } finally { button.disabled = false; button.textContent = originalText; } }); } function wireChapterToggleEvents() { const headers = document.querySelectorAll('.chapter-header'); headers.forEach(header => { header.addEventListener('click', () => { const chapterIdx = header.getAttribute('data-chapter-index'); const chunksContainer = document.querySelector(`.chapter-chunks[data-chapter-index="${chapterIdx}"]`); const toggle = header.querySelector('.chapter-toggle'); if (chunksContainer) { const isCollapsed = chunksContainer.classList.contains('collapsed'); if (isCollapsed) { chunksContainer.classList.remove('collapsed'); if (toggle) toggle.textContent = '▼'; } else { chunksContainer.classList.add('collapsed'); if (toggle) toggle.textContent = '▶'; } } }); }); } function renderLibraryChunkRow(jobId, chunk, engine, idx) { const chunkId = chunk.id; const text = chunk.text || ''; const speaker = chunk.speaker || ''; const voiceLabel = chunk.voice_label || chunk.voice || 'Default'; const currentVoiceLabel = voiceLabel ? `Current: ${voiceLabel}` : '-- Keep current --'; const instruction = getChunkInstruction(chunk); const fileUrl = chunk.file_url || ''; const cacheToken = chunk.regenerated_at || chunk.relative_file || Date.now().toString(); const audioUrl = fileUrl ? `${fileUrl}?t=${encodeURIComponent(cacheToken)}` : ''; const regenStatus = chunk.regen_status || ''; let statusBadge = ''; if (regenStatus === 'queued') { statusBadge = 'Queued'; } else if (regenStatus === 'running') { statusBadge = 'Rendering'; } else if (regenStatus === 'failed') { statusBadge = 'Failed'; } // Speaker tag display const speakerTag = speaker ? `${escapeHtml(speaker)}` : ''; // Truncate text for preview (first 80 chars) const textPreview = text.length > 80 ? text.substring(0, 80) + '...' : text; return `
${statusBadge} ${speakerTag} ${escapeHtml(textPreview)}
`; } function getChunkInstruction(chunk) { if (!chunk || typeof chunk !== 'object') return ''; if (chunk.emotion) return chunk.emotion; const extra = chunk.voice_assignment?.extra; if (extra && extra.instruct) return extra.instruct; return ''; } function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function handleLibraryChunkPlayClick(btn) { const url = btn.getAttribute('data-audio-url'); if (!url) return; // If this button is currently playing, stop it if (libraryActiveAudio && libraryActivePlayButton === btn) { stopLibraryChunkAudio(); return; } // Stop any other playing audio first if (libraryActiveAudio) { stopLibraryChunkAudio(); } stopChunkSequence(false); stopPreviewAudio(); // Start playing const audio = new Audio(url); libraryActiveAudio = audio; libraryActivePlayButton = btn; // Update button to show stop state btn.textContent = '■ Stop'; btn.classList.add('playing'); audio.addEventListener('ended', () => { resetLibraryPlayButton(btn); libraryActiveAudio = null; libraryActivePlayButton = null; }); audio.addEventListener('error', (err) => { console.error('Playback error:', err); resetLibraryPlayButton(btn); libraryActiveAudio = null; libraryActivePlayButton = null; }); audio.play().catch(err => { console.error('Playback error:', err); resetLibraryPlayButton(btn); libraryActiveAudio = null; libraryActivePlayButton = null; }); } function stopLibraryChunkAudio() { if (libraryActiveAudio) { libraryActiveAudio.pause(); libraryActiveAudio.currentTime = 0; libraryActiveAudio = null; } if (libraryActivePlayButton) { resetLibraryPlayButton(libraryActivePlayButton); libraryActivePlayButton = null; } } function resetLibraryPlayButton(btn) { if (btn) { btn.textContent = '▶'; btn.classList.remove('playing'); } } function resetChunkSequenceHighlight() { chunkSequenceItems.forEach(({ card }) => { card.classList.remove('is-playing-sequence'); card.classList.remove('is-playing-sequence-stopped'); }); } function stopChunkSequence(keepHighlight = false, preserveResume = false) { const lastIndex = chunkSequenceIndex; if (chunkSequenceAudio) { chunkSequenceAudio.pause(); chunkSequenceAudio.currentTime = 0; } chunkSequenceAudio = null; chunkSequenceItems = []; chunkSequenceIndex = -1; if (keepHighlight && chunkSequenceLastCard) { if (lastIndex >= 0) { chunkSequenceResumeIndex = lastIndex; } resetChunkSequenceHighlight(); chunkSequenceLastCard.classList.add('is-playing-sequence-stopped'); chunkSequenceStartIndex = null; } else { resetChunkSequenceHighlight(); if (!preserveResume) { chunkSequenceResumeIndex = null; } } if (chunkSequenceButton) { chunkSequenceButton.textContent = '▶'; chunkSequenceButton.classList.remove('playing'); } if (chunkSequenceLabel) { chunkSequenceLabel.textContent = 'Start Audio Review'; } chunkSequenceButton = null; chunkSequenceLabel = null; } function setChunkSequenceStartFromCard(card) { if (!card) return; const rawIndex = card.getAttribute('data-idx'); const index = rawIndex === null ? NaN : Number(rawIndex); if (!Number.isNaN(index)) { chunkSequenceStartIndex = index; chunkSequenceResumeIndex = index; } } function highlightManualChunkSelection(card) { if (!card) return; document.querySelectorAll('.library-chunk-card').forEach(item => { item.classList.remove('is-playing-sequence'); item.classList.remove('is-playing-sequence-stopped'); }); card.classList.add('is-playing-sequence-stopped'); } function startChunkSequence(items, startIndex = 0) { if (!items.length) return; const currentButton = chunkSequenceButton; const currentLabel = chunkSequenceLabel; stopChunkSequence(false, true); chunkSequenceButton = currentButton; chunkSequenceLabel = currentLabel; chunkSequenceItems = items; chunkSequenceIndex = Math.max(0, startIndex); chunkSequenceAudio = new Audio(); const playIndex = (index) => { if (!chunkSequenceAudio) return; if (index >= chunkSequenceItems.length) { stopChunkSequence(); return; } const item = chunkSequenceItems[index]; if (!item || !item.url) { playIndex(index + 1); return; } chunkSequenceIndex = index; chunkSequenceResumeIndex = index; resetChunkSequenceHighlight(); item.card.classList.add('is-playing-sequence'); chunkSequenceLastCard = item.card; chunkSequenceAudio.src = item.url; chunkSequenceAudio.play().catch(() => { playIndex(index + 1); }); }; chunkSequenceAudio.addEventListener('ended', () => { playIndex(chunkSequenceIndex + 1); }); chunkSequenceAudio.addEventListener('error', () => { playIndex(chunkSequenceIndex + 1); }); playIndex(chunkSequenceIndex); if (chunkSequenceButton) { chunkSequenceButton.textContent = '■'; chunkSequenceButton.classList.add('playing'); } if (chunkSequenceLabel) { chunkSequenceLabel.textContent = 'Stop Audio Review'; } } function wireChunkReviewEvents(jobId, chunks, engine) { const body = document.getElementById('chunk-review-modal-body'); if (!body) return; const playAllButton = body.querySelector('#chunk-review-play-all'); const playAllLabel = body.querySelector('#chunk-review-play-all-label'); if (playAllButton) { playAllButton.addEventListener('click', () => { if (chunkSequenceAudio) { stopChunkSequence(true); return; } const cards = Array.from(body.querySelectorAll('.library-chunk-card')); const items = cards.map(card => { const playButton = card.querySelector('.library-chunk-play'); return { card, url: playButton?.getAttribute('data-audio-url') || '', index: Number(card.getAttribute('data-idx')) }; }).filter(item => item.url); let startIndex = 0; const preferredIndex = chunkSequenceStartIndex ?? chunkSequenceResumeIndex; if (preferredIndex !== null && preferredIndex !== undefined) { const preferredPos = items.findIndex(item => item.index === preferredIndex); if (preferredPos >= 0) { startIndex = preferredPos; } else { const nextPos = items.findIndex(item => item.index > preferredIndex); if (nextPos >= 0) { startIndex = nextPos; } } } chunkSequenceButton = playAllButton; chunkSequenceLabel = playAllLabel || null; startChunkSequence(items, startIndex); }); } // Chunk card expand/collapse toggle body.querySelectorAll('.library-chunk-summary').forEach(summary => { summary.addEventListener('click', (e) => { // Don't toggle if clicking the play button if (e.target.closest('.library-chunk-play')) return; const chunkId = summary.getAttribute('data-chunk-id'); const details = body.querySelector(`.library-chunk-details[data-chunk-id="${chunkId}"]`); const toggle = summary.querySelector('.chunk-expand-toggle'); if (details) { const isCollapsed = details.classList.contains('collapsed'); if (isCollapsed) { details.classList.remove('collapsed'); if (toggle) toggle.textContent = '▼'; const card = summary.closest('.library-chunk-card'); setChunkSequenceStartFromCard(card); highlightManualChunkSelection(card); } else { details.classList.add('collapsed'); if (toggle) toggle.textContent = '▶'; } } }); }); // Play/Stop buttons body.querySelectorAll('.library-chunk-play').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); // Prevent triggering expand/collapse const card = btn.closest('.library-chunk-card'); setChunkSequenceStartFromCard(card); highlightManualChunkSelection(card); handleLibraryChunkPlayClick(btn); }); }); // Populate voice selects populateLibraryVoiceSelects(engine); // Regenerate buttons for individual chunks body.querySelectorAll('.library-chunk-regen').forEach(btn => { btn.addEventListener('click', () => { const chunkId = btn.getAttribute('data-chunk-id'); triggerLibraryChunkRegen(jobId, chunkId, btn); }); }); body.querySelectorAll('.library-chunk-voice-select').forEach(select => { select.addEventListener('change', () => { const chunkId = select.getAttribute('data-chunk-id'); if (!chunkId) return; const card = select.closest('.library-chunk-card'); const engineSelect = card?.querySelector('.library-chunk-engine-select'); const engineOverride = engineSelect?.value || engineSelect?.dataset.selectedEngine || engineSelect?.dataset.currentEngine || ''; const normalizedEngine = (engineOverride || engine || '').toLowerCase().replace(/[_-]/g, ''); const value = (select.value || '').trim(); if (!value) { delete libraryChunkVoiceOverrides[chunkId]; return; } if (normalizedEngine.includes('chatterbox') || normalizedEngine.includes('voxcpm') || normalizedEngine.includes('pockettts') || (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone')) ) { libraryChunkVoiceOverrides[chunkId] = { audio_prompt_path: value }; } else { const voiceData = libraryVoiceMap.get(value); libraryChunkVoiceOverrides[chunkId] = { voice: value, lang_code: voiceData?.langCode || 'a' }; } }); }); // Bulk speaker accordion toggle body.querySelectorAll('.bulk-speaker-summary').forEach(summary => { summary.addEventListener('click', (e) => { // Don't toggle if clicking the checkbox if (e.target.closest('.bulk-speaker-checkbox')) return; const speaker = summary.getAttribute('data-speaker'); const details = body.querySelector(`.bulk-speaker-details[data-speaker="${speaker}"]`); const toggle = summary.querySelector('.bulk-expand-toggle'); if (details) { const isCollapsed = details.classList.contains('collapsed'); if (isCollapsed) { details.classList.remove('collapsed'); if (toggle) toggle.textContent = '▼'; } else { details.classList.add('collapsed'); if (toggle) toggle.textContent = '▶'; } } }); }); // Bulk speaker checkbox and voice select handlers body.querySelectorAll('.bulk-speaker-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { e.stopPropagation(); // Prevent triggering accordion updateBulkRegenButtonState(checkbox); }); }); body.querySelectorAll('.bulk-speaker-voice-select').forEach(select => { select.addEventListener('change', () => { const card = select.closest('.bulk-speaker-card'); const checkbox = card?.querySelector('.bulk-speaker-checkbox'); if (checkbox) updateBulkRegenButtonState(checkbox); }); }); // Bulk regenerate buttons body.querySelectorAll('.bulk-speaker-regen').forEach(btn => { btn.addEventListener('click', () => { const speaker = btn.getAttribute('data-speaker'); // Use per-speaker engine dropdown or original engine const card = btn.closest('.bulk-speaker-card'); const engineSelect = card?.querySelector('.bulk-speaker-engine-select'); const engineOverride = engineSelect?.value || engine; triggerBulkSpeakerRegen(jobId, speaker, chunks, engineOverride, btn); }); }); // Individual chunk FX sliders body.querySelectorAll('.chunk-speed-slider').forEach(slider => { slider.addEventListener('input', () => { const valueSpan = slider.parentElement.querySelector('.chunk-speed-value'); if (valueSpan) valueSpan.textContent = `${parseFloat(slider.value).toFixed(2)}x`; updateChunkApplyFxButtonState(slider); }); }); body.querySelectorAll('.chunk-pitch-slider').forEach(slider => { slider.addEventListener('input', () => { const valueSpan = slider.parentElement.querySelector('.chunk-pitch-value'); if (valueSpan) valueSpan.textContent = parseFloat(slider.value).toFixed(1); updateChunkApplyFxButtonState(slider); }); }); // Individual chunk Preview FX buttons body.querySelectorAll('.library-chunk-preview-fx').forEach(btn => { btn.addEventListener('click', () => { const chunkId = btn.getAttribute('data-chunk-id'); triggerChunkPreviewFx(jobId, chunkId, btn); }); }); // Individual chunk Apply FX buttons body.querySelectorAll('.library-chunk-apply-fx').forEach(btn => { btn.addEventListener('click', () => { const chunkId = btn.getAttribute('data-chunk-id'); triggerChunkApplyFx(jobId, chunkId, btn); }); }); // Bulk speaker FX sliders body.querySelectorAll('.bulk-speed-slider').forEach(slider => { slider.addEventListener('input', () => { const valueSpan = slider.parentElement.querySelector('.bulk-speed-value'); if (valueSpan) valueSpan.textContent = `${parseFloat(slider.value).toFixed(2)}x`; updateBulkApplyFxButtonState(slider); }); }); body.querySelectorAll('.bulk-pitch-slider').forEach(slider => { slider.addEventListener('input', () => { const valueSpan = slider.parentElement.querySelector('.bulk-pitch-value'); if (valueSpan) valueSpan.textContent = parseFloat(slider.value).toFixed(1); updateBulkApplyFxButtonState(slider); }); }); // Bulk speaker Apply FX buttons body.querySelectorAll('.bulk-speaker-apply-fx').forEach(btn => { btn.addEventListener('click', () => { const speaker = btn.getAttribute('data-speaker'); triggerBulkSpeakerApplyFx(jobId, speaker, chunks, btn); }); }); } async function initLibraryVoiceFilters(engine) { const normalizedEngine = (engine || '').toLowerCase(); const usesPrompts = normalizedEngine.includes('chatterbox') || normalizedEngine.includes('voxcpm') || normalizedEngine.includes('pockettts') || (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone')); if (!usesPrompts) return; const genderFilter = document.getElementById('library-voice-filter-gender'); const languageFilter = document.getElementById('library-voice-filter-language'); if (!genderFilter || !languageFilter) return; // Reset filter state libraryVoiceFilters = { gender: 'all', language: 'all' }; // Fetch voice prompts to populate filter options try { const response = await fetch('/api/voice-prompts'); const data = await response.json(); if (data.success && data.prompts) { const genders = new Set(); const languages = new Set(); data.prompts.forEach(p => { if (p.gender) genders.add(p.gender); if (p.language) languages.add(p.language); }); // Populate gender filter genderFilter.innerHTML = ''; [...genders].sort().forEach(g => { const opt = document.createElement('option'); opt.value = g.toLowerCase(); opt.textContent = g; genderFilter.appendChild(opt); }); // Populate language filter languageFilter.innerHTML = ''; [...languages].sort((a, b) => getLibraryLanguageDisplayName(a).localeCompare(getLibraryLanguageDisplayName(b)) ).forEach(lang => { const opt = document.createElement('option'); opt.value = lang; opt.textContent = getLibraryLanguageDisplayName(lang); languageFilter.appendChild(opt); }); } } catch (err) { console.error('Failed to load voice prompts for filters:', err); } // Wire up filter change events genderFilter.addEventListener('change', () => { libraryVoiceFilters.gender = genderFilter.value; populateLibraryVoiceSelects(engine); }); languageFilter.addEventListener('change', () => { libraryVoiceFilters.language = languageFilter.value; populateLibraryVoiceSelects(engine); }); } async function populateLibraryVoiceSelects(engine) { const body = document.getElementById('chunk-review-modal-body'); if (!body) return; const chunks = chunkReviewModalData?.chunks || []; const normalizedEngine = (engine || '').toLowerCase().replace(/[_-]/g, ''); const isChatterbox = normalizedEngine.includes('chatterbox'); const isVoxCPM = normalizedEngine.includes('voxcpm'); const isQwen = normalizedEngine.includes('qwen3'); const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'); const isPocketPreset = normalizedEngine.includes('pocketttspreset'); const isPocket = normalizedEngine.includes('pockettts') && !isPocketPreset; const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone || isPocket; let voices = []; try { if (usesVoicePrompts) { // Chatterbox and VoxCPM use voice prompts const response = await fetch('/api/voice-prompts'); const data = await response.json(); if (data.success) { voices = (data.prompts || []).map(p => ({ id: p.name, // API returns 'name' as the filename name: p.display || p.name.replace('.wav', ''), duration: p.duration_seconds, gender: p.gender, language: p.language, transcript: p.transcript, isPrompt: true })); // Apply filters if (libraryVoiceFilters.gender && libraryVoiceFilters.gender !== 'all') { voices = voices.filter(v => (v.gender || '').toLowerCase() === libraryVoiceFilters.gender.toLowerCase() ); } if (libraryVoiceFilters.language && libraryVoiceFilters.language !== 'all') { voices = voices.filter(v => v.language === libraryVoiceFilters.language); } } } else if (isQwen) { // Qwen3 uses /api/qwen3/metadata for speakers const response = await fetch('/api/qwen3/metadata'); const data = await response.json(); if (data.success && data.speakers) { voices = data.speakers.map(speaker => ({ id: speaker, name: speaker, isQwen: true })); } } else if (isPocketPreset) { const response = await fetch('/api/pocket-tts/voices'); const data = await response.json(); if (data.success && data.voices) { voices = data.voices.map(voice => ({ id: voice, name: voice, isPrompt: false })); } } else { // Kokoro and others use /api/voices - returns nested structure by language const response = await fetch('/api/voices'); const data = await response.json(); if (data.success && data.voices) { // Flatten the nested voice structure, keeping lang_code for each voice Object.entries(data.voices).forEach(([langKey, langConfig]) => { const langLabel = langConfig.language || langKey; const langCode = langConfig.lang_code || 'a'; // Add built-in voices (langConfig.voices || []).forEach(voiceName => { voices.push({ id: voiceName, name: `${voiceName} (${langLabel})`, langCode: langCode, isPrompt: false }); }); // Add custom voices (langConfig.custom_voices || []).forEach(cv => { voices.push({ id: cv.code || cv.id, name: `${cv.name || cv.code} (${langLabel}, custom)`, langCode: langCode, isPrompt: false }); }); }); } } } catch (err) { console.error('Failed to load voices:', err); } // Store voice map globally for lookup during regeneration libraryVoiceMap = new Map(); voices.forEach(v => libraryVoiceMap.set(v.id, v)); // Helper to populate a single chunk voice select based on engine // Minimum duration requirements per engine (in seconds) const ENGINE_MIN_DURATION = { 'chatterbox': 5.0, 'chatterboxturbolocal': 5.0, 'chatterboxturborepl': 5.0, 'voxcpmlocal': 0, // VoxCPM accepts any duration 'pockettts': 0, 'pocketttspreset': 0, }; function getMinDuration(engineName) { const normalized = (engineName || '').toLowerCase().replace(/[_-]/g, ''); for (const [key, val] of Object.entries(ENGINE_MIN_DURATION)) { if (normalized.includes(key)) return val; } return 0; } function getPromptFilters(container, genderSelector, languageSelector) { if (!container) { return { gender: 'all', language: 'all' }; } const genderSelect = container.querySelector(genderSelector); const languageSelect = container.querySelector(languageSelector); return { gender: genderSelect?.value || 'all', language: languageSelect?.value || 'all' }; } let voicePromptCache = null; let voicePromptRequest = null; let qwenMetadataCache = null; let qwenMetadataRequest = null; let voicesCache = null; let voicesRequest = null; let pocketTtsVoicesCache = null; let pocketTtsVoicesRequest = null; async function getVoicePromptsCached() { if (voicePromptCache) return voicePromptCache; if (!voicePromptRequest) { voicePromptRequest = fetch('/api/voice-prompts') .then(response => response.json()) .then(data => { voicePromptCache = data; return data; }) .catch(err => { voicePromptRequest = null; throw err; }); } return voicePromptRequest; } async function getQwenMetadataCached() { if (qwenMetadataCache) return qwenMetadataCache; if (!qwenMetadataRequest) { qwenMetadataRequest = fetch('/api/qwen3/metadata') .then(response => response.json()) .then(data => { qwenMetadataCache = data; return data; }) .catch(err => { qwenMetadataRequest = null; throw err; }); } return qwenMetadataRequest; } async function getVoicesCached() { if (voicesCache) return voicesCache; if (!voicesRequest) { voicesRequest = fetch('/api/voices') .then(response => response.json()) .then(data => { voicesCache = data; return data; }) .catch(err => { voicesRequest = null; throw err; }); } return voicesRequest; } async function getPocketTtsVoicesCached() { if (pocketTtsVoicesCache) return pocketTtsVoicesCache; if (!pocketTtsVoicesRequest) { pocketTtsVoicesRequest = fetch('/api/pocket-tts/voices') .then(response => response.json()) .then(data => { pocketTtsVoicesCache = data; return data; }) .catch(err => { pocketTtsVoicesRequest = null; throw err; }); } return pocketTtsVoicesRequest; } async function populatePromptFilterOptions(genderSelect, languageSelect) { if (!genderSelect || !languageSelect) return; try { const data = await getVoicePromptsCached(); if (!data.success || !data.prompts) return; const genders = new Set(); const languages = new Set(); data.prompts.forEach(prompt => { if (prompt.gender) genders.add(prompt.gender); if (prompt.language) languages.add(prompt.language); }); const currentGender = genderSelect.value || 'all'; const currentLanguage = languageSelect.value || 'all'; genderSelect.innerHTML = ''; [...genders].sort().forEach(gender => { const opt = document.createElement('option'); opt.value = gender.toLowerCase(); opt.textContent = gender; genderSelect.appendChild(opt); }); languageSelect.innerHTML = ''; [...languages].sort((a, b) => getLibraryLanguageDisplayName(a).localeCompare(getLibraryLanguageDisplayName(b)) ).forEach(language => { const opt = document.createElement('option'); opt.value = language; opt.textContent = getLibraryLanguageDisplayName(language); languageSelect.appendChild(opt); }); genderSelect.value = currentGender; languageSelect.value = currentLanguage; } catch (err) { console.error('Failed to load prompt filter options:', err); } } async function populateChunkVoiceSelect(select, chunkId, engineName, filters = null, currentLabel = '', selectedValue = '') { const isPocketPreset = engineName.includes('pocketttspreset'); const usesPrompts = engineName.includes('chatterbox') || engineName.includes('voxcpm') || (engineName.includes('pockettts') && !isPocketPreset) || (engineName.includes('qwen3') && engineName.includes('clone')); const isQwenEngine = engineName.includes('qwen3'); const minDuration = getMinDuration(engineName); const activeFilters = filters || libraryVoiceFilters; let chunkVoices = []; try { if (usesPrompts) { const data = await getVoicePromptsCached(); if (data.success && data.prompts) { chunkVoices = data.prompts.map(p => ({ id: p.path || p.name, name: p.display || p.name, duration: p.duration_seconds, gender: p.gender, language: p.language, isPrompt: true })); } } else if (isQwenEngine) { const data = await getQwenMetadataCached(); if (data.success && data.speakers) { chunkVoices = data.speakers.map(speaker => ({ id: speaker, name: speaker, isQwen: true })); } } else if (isPocketPreset) { const data = await getPocketTtsVoicesCached(); if (data.success && data.voices) { chunkVoices = data.voices.map(voice => ({ id: voice, name: voice, isPrompt: false })); } } else { const data = await getVoicesCached(); if (data.success && data.voices) { Object.entries(data.voices).forEach(([langKey, langConfig]) => { const langLabel = langConfig.language || langKey; const langCode = langConfig.lang_code || 'a'; (langConfig.voices || []).forEach(voiceName => { chunkVoices.push({ id: voiceName, name: `${voiceName} (${langLabel})`, langCode: langCode, isPrompt: false }); }); (langConfig.custom_voices || []).forEach(cv => { chunkVoices.push({ id: cv.code || cv.id, name: `${cv.name || cv.code} (${langLabel}, custom)`, langCode: langCode, isPrompt: false }); }); }); } } } catch (err) { console.error('Failed to load voices for chunk:', err); } // Apply filters for prompt-based voices if (usesPrompts) { if (activeFilters.gender && activeFilters.gender !== 'all') { chunkVoices = chunkVoices.filter(v => (v.gender || '').toLowerCase() === activeFilters.gender.toLowerCase() ); } if (activeFilters.language && activeFilters.language !== 'all') { chunkVoices = chunkVoices.filter(v => v.language === activeFilters.language); } } const defaultLabel = currentLabel ? `Current: ${currentLabel}` : '-- Keep current --'; select.innerHTML = ``; chunkVoices.forEach(v => { const opt = document.createElement('option'); opt.value = v.id; const durationLabel = v.duration != null ? ` · ${v.duration.toFixed(1)}s` : ''; // Build label with gender and language for prompt voices let displayName = v.name; if (v.isPrompt && (v.gender || v.language)) { const gender = v.gender ? ` [${v.gender.charAt(0).toUpperCase()}]` : ''; const lang = v.language ? ` ${getLibraryLanguageDisplayName(v.language)}` : ''; displayName = `${v.name} ·${gender}${lang}`; } opt.textContent = `${displayName}${durationLabel}`; opt.dataset.gender = v.gender || ''; opt.dataset.language = v.language || ''; // Disable if duration is too short for this engine if (minDuration > 0 && v.duration != null && v.duration < minDuration) { opt.disabled = true; opt.style.color = '#ff6b6b'; opt.textContent = `${displayName}${durationLabel} (too short)`; } select.appendChild(opt); }); if (selectedValue) { select.value = selectedValue; if (select.value !== selectedValue) { select.value = ''; } } } // Helper to populate bulk speaker voice select based on engine async function populateBulkVoiceSelect(select, speaker, engineName, filters = null) { const isPocketPreset = engineName.includes('pocketttspreset'); const usesPrompts = engineName.includes('chatterbox') || engineName.includes('voxcpm') || (engineName.includes('pockettts') && !isPocketPreset) || (engineName.includes('qwen3') && engineName.includes('clone')); const isQwenEngine = engineName.includes('qwen3'); const minDuration = getMinDuration(engineName); const activeFilters = filters || libraryVoiceFilters; let bulkVoices = []; try { if (usesPrompts) { const data = await getVoicePromptsCached(); if (data.success && data.prompts) { bulkVoices = data.prompts.map(p => ({ id: p.path || p.name, name: p.display || p.name, duration: p.duration_seconds, gender: p.gender, language: p.language, isPrompt: true })); } } else if (isQwenEngine) { const data = await getQwenMetadataCached(); if (data.success && data.speakers) { bulkVoices = data.speakers.map(speaker => ({ id: speaker, name: speaker, isQwen: true })); } } else if (isPocketPreset) { const data = await getPocketTtsVoicesCached(); if (data.success && data.voices) { bulkVoices = data.voices.map(voice => ({ id: voice, name: voice, isPrompt: false })); } } else { const data = await getVoicesCached(); if (data.success && data.voices) { Object.entries(data.voices).forEach(([langKey, langConfig]) => { const langLabel = langConfig.language || langKey; const langCode = langConfig.lang_code || 'a'; (langConfig.voices || []).forEach(voiceName => { bulkVoices.push({ id: voiceName, name: `${voiceName} (${langLabel})`, langCode: langCode, isPrompt: false }); }); (langConfig.custom_voices || []).forEach(cv => { bulkVoices.push({ id: cv.code || cv.id, name: `${cv.name || cv.code} (${langLabel}, custom)`, langCode: langCode, isPrompt: false }); }); }); } } } catch (err) { console.error('Failed to load voices for bulk speaker:', err); } // Apply filters for prompt-based voices if (usesPrompts) { if (activeFilters.gender && activeFilters.gender !== 'all') { bulkVoices = bulkVoices.filter(v => (v.gender || '').toLowerCase() === activeFilters.gender.toLowerCase() ); } if (activeFilters.language && activeFilters.language !== 'all') { bulkVoices = bulkVoices.filter(v => v.language === activeFilters.language); } } select.innerHTML = ''; bulkVoices.forEach(v => { const opt = document.createElement('option'); opt.value = v.id; const durationLabel = v.duration != null ? ` · ${v.duration.toFixed(1)}s` : ''; // Build label with gender and language for prompt voices let displayName = v.name; if (v.isPrompt && (v.gender || v.language)) { const gender = v.gender ? ` [${v.gender.charAt(0).toUpperCase()}]` : ''; const lang = v.language ? ` ${getLibraryLanguageDisplayName(v.language)}` : ''; displayName = `${v.name} ·${gender}${lang}`; } opt.textContent = `${displayName}${durationLabel}`; opt.dataset.gender = v.gender || ''; opt.dataset.language = v.language || ''; // Disable if duration is too short for this engine if (minDuration > 0 && v.duration != null && v.duration < minDuration) { opt.disabled = true; opt.style.color = '#ff6b6b'; opt.textContent = `${displayName}${durationLabel} (too short)`; } select.appendChild(opt); }); } const chunkById = new Map(chunks.map(chunk => [chunk.id, chunk])); // Populate chunk engine selects body.querySelectorAll('.library-chunk-engine-select').forEach(select => { const chunkId = select.getAttribute('data-chunk-id'); const chunk = chunkById.get(chunkId); const currentEngine = chunk?.engine || engine; const currentEngineLabel = currentEngine ? `Current: ${formatEngineName(currentEngine)}` : '-- Same engine --'; const normalizedCurrentEngine = (currentEngine || '').toLowerCase(); select.dataset.currentEngine = currentEngine || ''; select.dataset.selectedEngine = ''; select.innerHTML = ` `; if (normalizedCurrentEngine) { Array.from(select.options).forEach(option => { if (option.value && normalizedCurrentEngine.includes(option.value.replace(/[_-]/g, ''))) { option.textContent = `Current: ${option.textContent}`; } }); } const regenSection = select.closest('.chunk-regen-section'); const voiceSelect = regenSection?.querySelector('.library-chunk-voice-select'); const qwen3Options = regenSection?.querySelector('.chunk-qwen3-options'); const promptFilters = regenSection?.querySelector('.chunk-prompt-filters'); const genderFilter = regenSection?.querySelector('.chunk-voice-filter-gender'); const languageFilter = regenSection?.querySelector('.chunk-voice-filter-language'); if (voiceSelect) { const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || ''; const voiceAssignment = chunk?.voice_assignment || {}; const override = libraryChunkVoiceOverrides[chunkId] || {}; const selectedValue = override.audio_prompt_path || override.voice || voiceAssignment.audio_prompt_path || voiceAssignment.voice || ''; const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language'); populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue); } const normalizedEngineValue = (currentEngine || '').toLowerCase(); const isQwen = normalizedEngineValue.includes('qwen3') && !normalizedEngineValue.includes('clone'); const usesPrompts = normalizedEngineValue.includes('chatterbox') || normalizedEngineValue.includes('voxcpm') || (normalizedEngineValue.includes('pockettts') && !normalizedEngineValue.includes('pocketttspreset')) || (normalizedEngineValue.includes('qwen3') && normalizedEngineValue.includes('clone')); if (promptFilters) { promptFilters.style.display = usesPrompts ? 'block' : 'none'; if (usesPrompts) { populatePromptFilterOptions(genderFilter, languageFilter); } } if (qwen3Options) { qwen3Options.style.display = isQwen ? 'block' : 'none'; if (isQwen) { const langSelect = qwen3Options.querySelector('.chunk-qwen3-language'); if (langSelect && langSelect.options.length <= 1) { populateQwen3LanguageSelect(langSelect); } } } if (genderFilter) { genderFilter.addEventListener('change', () => { if (!voiceSelect) return; const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || ''; const voiceAssignment = chunk?.voice_assignment || {}; const override = libraryChunkVoiceOverrides[chunkId] || {}; const selectedValue = override.audio_prompt_path || override.voice || voiceAssignment.audio_prompt_path || voiceAssignment.voice || ''; const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language'); populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue); }); } if (languageFilter) { languageFilter.addEventListener('change', () => { if (!voiceSelect) return; const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || ''; const voiceAssignment = chunk?.voice_assignment || {}; const override = libraryChunkVoiceOverrides[chunkId] || {}; const selectedValue = override.audio_prompt_path || override.voice || voiceAssignment.audio_prompt_path || voiceAssignment.voice || ''; const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language'); populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue); }); } // When engine changes, repopulate the voice dropdown for this chunk and show/hide Qwen3 options select.addEventListener('change', async () => { const selectedEngine = select.value || currentEngine || engine; select.dataset.selectedEngine = selectedEngine || ''; const regenSection = select.closest('.chunk-regen-section'); const voiceSelect = regenSection?.querySelector('.library-chunk-voice-select'); const qwen3Options = regenSection?.querySelector('.chunk-qwen3-options'); const promptFilters = regenSection?.querySelector('.chunk-prompt-filters'); const genderFilter = regenSection?.querySelector('.chunk-voice-filter-gender'); const languageFilter = regenSection?.querySelector('.chunk-voice-filter-language'); if (voiceSelect) { const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || ''; const voiceAssignment = chunk?.voice_assignment || {}; const override = libraryChunkVoiceOverrides[chunkId] || {}; const selectedValue = override.audio_prompt_path || override.voice || voiceAssignment.audio_prompt_path || voiceAssignment.voice || ''; const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language'); await populateChunkVoiceSelect(voiceSelect, chunkId, selectedEngine, filters, currentVoiceLabel, selectedValue); } // Show/hide Qwen3 options based on engine const normalizedSelectedEngine = selectedEngine.toLowerCase(); const isQwen = normalizedSelectedEngine.includes('qwen3') && !normalizedSelectedEngine.includes('clone'); const usesPrompts = normalizedSelectedEngine.includes('chatterbox') || normalizedSelectedEngine.includes('voxcpm') || (normalizedSelectedEngine.includes('pockettts') && !normalizedSelectedEngine.includes('pocketttspreset')) || (normalizedSelectedEngine.includes('qwen3') && normalizedSelectedEngine.includes('clone')); if (promptFilters) { promptFilters.style.display = usesPrompts ? 'block' : 'none'; if (usesPrompts) { await populatePromptFilterOptions(genderFilter, languageFilter); } } if (qwen3Options) { qwen3Options.style.display = isQwen ? 'block' : 'none'; // Populate language dropdown if Qwen3 selected if (isQwen) { const langSelect = qwen3Options.querySelector('.chunk-qwen3-language'); if (langSelect && langSelect.options.length <= 1) { await populateQwen3LanguageSelect(langSelect); } } } }); }); // Populate bulk speaker engine selects body.querySelectorAll('.bulk-speaker-engine-select').forEach(select => { const speaker = select.getAttribute('data-speaker'); select.innerHTML = ` `; // When engine changes, repopulate the voice dropdown for this speaker and show/hide Qwen3 options select.addEventListener('change', async () => { const selectedEngine = select.value || engine; const regenSection = select.closest('.bulk-regen-section'); const voiceSelect = regenSection?.querySelector('.bulk-speaker-voice-select'); const qwen3Options = regenSection?.querySelector('.bulk-qwen3-options'); const promptFilters = regenSection?.querySelector('.bulk-prompt-filters'); const genderFilter = regenSection?.querySelector('.bulk-voice-filter-gender'); const languageFilter = regenSection?.querySelector('.bulk-voice-filter-language'); if (voiceSelect) { const filters = getPromptFilters(regenSection, '.bulk-voice-filter-gender', '.bulk-voice-filter-language'); await populateBulkVoiceSelect(voiceSelect, speaker, selectedEngine, filters); } // Show/hide Qwen3 options based on engine const normalizedSelectedEngine = selectedEngine.toLowerCase(); const isQwen = normalizedSelectedEngine.includes('qwen3') && !normalizedSelectedEngine.includes('clone'); const usesPrompts = normalizedSelectedEngine.includes('chatterbox') || normalizedSelectedEngine.includes('voxcpm') || (normalizedSelectedEngine.includes('qwen3') && normalizedSelectedEngine.includes('clone')); if (promptFilters) { promptFilters.style.display = usesPrompts ? 'block' : 'none'; if (usesPrompts) { await populatePromptFilterOptions(genderFilter, languageFilter); } } if (qwen3Options) { qwen3Options.style.display = isQwen ? 'block' : 'none'; // Populate language dropdown if Qwen3 selected if (isQwen) { const langSelect = qwen3Options.querySelector('.bulk-qwen3-language'); if (langSelect && langSelect.options.length <= 1) { await populateQwen3LanguageSelect(langSelect); } } } }); }); // Also populate bulk speaker voice selects body.querySelectorAll('.bulk-speaker-voice-select').forEach(select => { select.innerHTML = ''; }); // Initialize prompt filters and voice lists for bulk speakers body.querySelectorAll('.bulk-speaker-card').forEach(card => { const engineSelect = card.querySelector('.bulk-speaker-engine-select'); const voiceSelect = card.querySelector('.bulk-speaker-voice-select'); const promptFilters = card.querySelector('.bulk-prompt-filters'); const genderFilter = card.querySelector('.bulk-voice-filter-gender'); const languageFilter = card.querySelector('.bulk-voice-filter-language'); if (!engineSelect || !voiceSelect) return; const selectedEngine = engineSelect.value || engine; const normalizedEngine = selectedEngine.toLowerCase(); const usesPrompts = normalizedEngine.includes('chatterbox') || normalizedEngine.includes('voxcpm') || (normalizedEngine.includes('pockettts') && !normalizedEngine.includes('pocketttspreset')) || (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone')); if (promptFilters) { promptFilters.style.display = usesPrompts ? 'block' : 'none'; if (usesPrompts) { populatePromptFilterOptions(genderFilter, languageFilter); const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language'); populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', selectedEngine, filters); } } if (genderFilter) { genderFilter.addEventListener('change', async () => { const currentEngine = engineSelect.value || engine; const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language'); await populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', currentEngine, filters); }); } if (languageFilter) { languageFilter.addEventListener('change', async () => { const currentEngine = engineSelect.value || engine; const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language'); await populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', currentEngine, filters); }); } }); // Store engine info for bulk regen (usesVoicePrompts covers both Chatterbox and VoxCPM) body.dataset.usesVoicePrompts = usesVoicePrompts ? 'true' : 'false'; } // Helper to populate Qwen3 language dropdown async function populateQwen3LanguageSelect(select) { try { const response = await fetch('/api/qwen3/metadata'); const data = await response.json(); if (data.success && data.languages) { data.languages.forEach(lang => { const opt = document.createElement('option'); opt.value = lang; opt.textContent = lang; select.appendChild(opt); }); } } catch (err) { console.error('Failed to load Qwen3 languages:', err); } } function updateBulkRegenButtonState(checkbox) { const card = checkbox.closest('.bulk-speaker-card'); if (!card) return; const select = card.querySelector('.bulk-speaker-voice-select'); const btn = card.querySelector('.bulk-speaker-regen'); if (!select || !btn) return; // Enable button only if checkbox is checked AND a voice is selected const isChecked = checkbox.checked; const hasVoice = select.value !== ''; btn.disabled = !(isChecked && hasVoice); } async function triggerBulkSpeakerRegen(jobId, speaker, chunks, engine, button) { const body = document.getElementById('chunk-review-modal-body'); const card = button.closest('.bulk-speaker-card'); const select = card?.querySelector('.bulk-speaker-voice-select'); const voiceValue = select?.value; if (!voiceValue) { alert('Please select a voice first.'); return; } // Get all chunks for this speaker const speakerChunks = chunks.filter(c => (c.speaker || 'default') === speaker); if (speakerChunks.length === 0) { alert('No chunks found for this speaker.'); return; } const normalizedEngine = (engine || '').toLowerCase().replace(/[_-]/g, ''); const isChatterbox = normalizedEngine.includes('chatterbox'); const isVoxCPM = normalizedEngine.includes('voxcpm'); const isQwenEngine = normalizedEngine.includes('qwen3'); const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'); const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone; // Build voice payload based on engine type const voiceData = libraryVoiceMap.get(voiceValue || ''); let voicePayload; if (usesVoicePrompts) { const promptEntry = libraryVoiceMap.get(voiceValue); voicePayload = { audio_prompt_path: voiceValue, ...(promptEntry?.transcript ? { extra: { prompt_text: promptEntry.transcript } } : {}) }; } else if (isQwenEngine) { // Get Qwen3 language and instruction from the bulk options const qwen3Options = card?.querySelector('.bulk-qwen3-options'); const langSelect = qwen3Options?.querySelector('.bulk-qwen3-language'); const instructInput = qwen3Options?.querySelector('.bulk-qwen3-instruct'); const language = langSelect?.value || 'Auto'; let instruct = instructInput?.value?.trim() || ''; if (!instruct) { const originalInstruction = speakerChunks .map(chunk => getChunkInstruction(chunk)) .find(Boolean) || ''; instruct = originalInstruction; } voicePayload = { voice: voiceValue, extra: { language: language, ...(instruct && { instruct: instruct }) } }; } else { voicePayload = { voice: voiceValue, lang_code: voiceData?.langCode || 'a' }; } button.disabled = true; button.textContent = `Regenerating ${speakerChunks.length}...`; try { // First restore the job to review mode await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' }); // Regenerate each chunk for this speaker for (const chunk of speakerChunks) { const chunkId = chunk.id; const chunkCard = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`); const textarea = chunkCard?.querySelector('.library-chunk-textarea'); const text = textarea ? textarea.value.trim() : chunk.text; if (!text) continue; // Update the individual chunk's voice override libraryChunkVoiceOverrides[chunkId] = { ...voicePayload }; const requestBody = { chunk_id: chunkId, text: text, voice: voicePayload, }; // Use the engine passed in from the per-speaker dropdown if (engine) { requestBody.engine = engine; } const response = await fetch(`/api/jobs/${jobId}/review/regen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); const data = await response.json(); if (data.success) { updateLibraryChunkStatus(chunkId, 'queued'); startLibraryChunkRegenWatcher(jobId, chunkId); } } button.textContent = 'Queued!'; setTimeout(() => { button.textContent = 'Regenerate All'; button.disabled = false; }, 2000); } catch (error) { console.error('Bulk regen error:', error); alert(error.message || 'Failed to queue bulk regeneration'); button.textContent = 'Regenerate All'; button.disabled = false; } } async function triggerLibraryChunkRegen(jobId, chunkId, button) { const card = button.closest('.library-chunk-card'); const textarea = card ? card.querySelector('.library-chunk-textarea') : null; const text = textarea ? textarea.value.trim() : ''; if (!text) { alert('Chunk text cannot be empty.'); return; } setRegenButtonBusy(button, true, '⟳ Queued...'); // Get engine override from per-chunk dropdown const chunkEngineSelect = card?.querySelector('.library-chunk-engine-select'); const resolvedEngine = chunkEngineSelect?.value || chunkEngineSelect?.dataset.selectedEngine || chunkEngineSelect?.dataset.currentEngine || (chunkReviewModalData?.chunks || []).find(chunk => chunk.id === chunkId)?.engine || chunkReviewModalData?.engine || ''; // Get voice selection from per-chunk dropdown or stored override const voiceSelect = card?.querySelector('.library-chunk-voice-select'); const voiceValue = voiceSelect?.value || ''; const storedOverride = libraryChunkVoiceOverrides[chunkId] || {}; const selectedVoiceValue = voiceValue || storedOverride.audio_prompt_path || storedOverride.voice || ''; // Build voice payload based on engine type let voicePayload = libraryChunkVoiceOverrides[chunkId] || {}; const originalChunk = (chunkReviewModalData?.chunks || []).find(chunk => chunk.id === chunkId); const normalizedEngine = (resolvedEngine || originalChunk?.engine || chunkReviewModalData?.engine || '') .toLowerCase() .replace(/[_-]/g, ''); const isChatterbox = normalizedEngine.includes('chatterbox'); const isVoxCPM = normalizedEngine.includes('voxcpm'); const isQwenEngine = normalizedEngine.includes('qwen3'); const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'); const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone; const voiceData = libraryVoiceMap.get(voiceValue); if (usesVoicePrompts) { if (selectedVoiceValue) { const promptEntry = libraryVoiceMap.get(selectedVoiceValue); voicePayload = { audio_prompt_path: selectedVoiceValue, ...(promptEntry?.transcript ? { extra: { prompt_text: promptEntry.transcript } } : {}) }; } } else if (isQwenEngine) { // Get Qwen3 language and instruction from the chunk options const qwen3Options = card?.querySelector('.chunk-qwen3-options'); const langSelect = qwen3Options?.querySelector('.chunk-qwen3-language'); const instructInput = qwen3Options?.querySelector('.chunk-qwen3-instruct'); const language = langSelect?.value || 'Auto'; let instruct = instructInput?.value?.trim() || ''; if (!instruct) { instruct = getChunkInstruction(originalChunk); } const existingExtra = voicePayload.extra || {}; voicePayload = { ...voicePayload, ...(selectedVoiceValue ? { voice: selectedVoiceValue } : {}), extra: { ...existingExtra, language: language, ...(instruct && { instruct: instruct }) } }; } else if (selectedVoiceValue) { voicePayload = { voice: selectedVoiceValue, lang_code: voiceData?.langCode || 'a' }; } if (Object.keys(voicePayload || {}).length > 0) { libraryChunkVoiceOverrides[chunkId] = { ...voicePayload }; } try { // First restore the job to review mode if not already await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' }); const requestBody = { chunk_id: chunkId, text: text, voice: voicePayload, engine: resolvedEngine, // Always send resolved engine }; const response = await fetch(`/api/jobs/${jobId}/review/regen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to queue regeneration'); } // Update UI to show queued status updateLibraryChunkStatus(chunkId, 'queued'); startLibraryChunkRegenWatcher(jobId, chunkId); } catch (error) { console.error('Regen error:', error); alert(error.message || 'Failed to regenerate chunk'); setRegenButtonBusy(button, false); } } function updateLibraryChunkStatus(chunkId, status) { const card = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`); if (!card) return; const summary = card.querySelector('.library-chunk-summary'); if (!summary) return; // Remove existing status badges summary.querySelectorAll('.review-chip').forEach(el => el.remove()); let badge = ''; if (status === 'queued') { badge = 'Queued'; } else if (status === 'running') { badge = 'Rendering'; } else if (status === 'failed') { badge = 'Failed'; } else if (status === 'completed') { badge = 'Updated'; } const regenButton = card.querySelector('.library-chunk-regen'); if (status === 'queued') { setRegenButtonBusy(regenButton, true, '⟳ Queued...'); } else if (status === 'running') { setRegenButtonBusy(regenButton, true, '⟳ Rendering...'); } else if (status === 'completed' || status === 'failed') { setRegenButtonBusy(regenButton, false); } if (badge) { const toggle = summary.querySelector('.chunk-expand-toggle'); if (toggle) { toggle.insertAdjacentHTML('afterend', badge); } } } function startLibraryChunkRegenWatcher(jobId, chunkId) { const key = `${jobId}:${chunkId}`; if (libraryChunkRegenWatchers[key]) { clearTimeout(libraryChunkRegenWatchers[key].timer); } const entry = { attempts: 0, timer: null }; libraryChunkRegenWatchers[key] = entry; pollLibraryChunkStatus(jobId, chunkId, entry); } async function pollLibraryChunkStatus(jobId, chunkId, entry) { entry.attempts++; try { const response = await fetch(`/api/jobs/${jobId}/chunks`); const data = await response.json(); if (data.success) { const chunks = data.chunks || []; const regenTasks = data.regen_tasks || {}; const task = regenTasks[chunkId]; const status = task ? task.status : null; updateLibraryChunkStatus(chunkId, status || 'completed'); // Update audio URL if completed if (!status || status === 'completed' || status === 'failed') { resetChunkRegenButton(chunkId); const chunk = chunks.find(c => c.id === chunkId); if (chunk && chunk.file_url) { const card = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`); if (card) { const playBtn = card.querySelector('.library-chunk-play'); const cacheToken = chunk.regenerated_at || Date.now().toString(); const newUrl = `${chunk.file_url}?t=${encodeURIComponent(cacheToken)}`; if (playBtn) { playBtn.setAttribute('data-audio-url', newUrl); playBtn.disabled = false; } // Update voice label (API returns 'voice', not 'voice_label') const voiceLabelEl = card.querySelector('.library-chunk-voice-label'); const newVoiceLabel = chunk.voice || chunk.voice_label; if (voiceLabelEl && newVoiceLabel) { voiceLabelEl.textContent = newVoiceLabel; } } } delete libraryChunkRegenWatchers[`${jobId}:${chunkId}`]; return; } } } catch (err) { console.error('Poll error:', err); } if (entry.attempts >= LIBRARY_CHUNK_MAX_ATTEMPTS) { delete libraryChunkRegenWatchers[`${jobId}:${chunkId}`]; return; } entry.timer = setTimeout(() => pollLibraryChunkStatus(jobId, chunkId, entry), LIBRARY_CHUNK_POLL_INTERVAL_MS); } async function recompileLibraryAudio() { const jobId = chunkReviewModalJobId; if (!jobId) return; const recompileBtn = document.getElementById('chunk-review-recompile-btn'); if (recompileBtn) { recompileBtn.disabled = true; recompileBtn.textContent = 'Recompiling...'; } try { // Finish review to recompile const response = await fetch(`/api/jobs/${jobId}/review/finish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to recompile audio'); } alert('Audio recompiled successfully!'); closeChunkReviewModal(); loadLibrary(); } catch (error) { console.error('Recompile error:', error); alert(error.message || 'Failed to recompile audio'); } finally { if (recompileBtn) { recompileBtn.disabled = false; recompileBtn.textContent = 'Recompile Audio'; } } } // FX button state helpers function updateChunkApplyFxButtonState(slider) { const card = slider.closest('.library-chunk-card'); if (!card) return; const speedSlider = card.querySelector('.chunk-speed-slider'); const pitchSlider = card.querySelector('.chunk-pitch-slider'); const applyBtn = card.querySelector('.library-chunk-apply-fx'); const previewBtn = card.querySelector('.library-chunk-preview-fx'); const speed = parseFloat(speedSlider?.value || 1.0); const pitch = parseFloat(pitchSlider?.value || 0); // Enable if either value is changed from default const hasChanges = Math.abs(speed - 1.0) > 0.01 || Math.abs(pitch) > 0.1; if (applyBtn) applyBtn.disabled = !hasChanges; if (previewBtn) previewBtn.disabled = !hasChanges; } function updateBulkApplyFxButtonState(slider) { const card = slider.closest('.bulk-speaker-card'); if (!card) return; const speedSlider = card.querySelector('.bulk-speed-slider'); const pitchSlider = card.querySelector('.bulk-pitch-slider'); const applyBtn = card.querySelector('.bulk-speaker-apply-fx'); if (!applyBtn) return; const speed = parseFloat(speedSlider?.value || 1.0); const pitch = parseFloat(pitchSlider?.value || 0); // Enable if either value is changed from default const hasChanges = Math.abs(speed - 1.0) > 0.01 || Math.abs(pitch) > 0.1; applyBtn.disabled = !hasChanges; } async function triggerChunkPreviewFx(jobId, chunkId, button) { const card = button.closest('.library-chunk-card'); if (!card) return; // If already previewing, stop it if (previewAudio && previewButton === button) { stopPreviewAudio(); return; } // Stop any existing preview or regular playback stopPreviewAudio(); stopLibraryChunkAudio(); const speedSlider = card.querySelector('.chunk-speed-slider'); const pitchSlider = card.querySelector('.chunk-pitch-slider'); const speed = parseFloat(speedSlider?.value || 1.0); const pitch = parseFloat(pitchSlider?.value || 0); const originalText = button.textContent; if (!button.dataset.originalText) { button.dataset.originalText = originalText; } button.textContent = '⏳ Loading...'; button.disabled = true; try { const response = await fetch(`/api/jobs/${jobId}/review/preview-fx`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk_id: chunkId, speed, pitch }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || 'Failed to preview effects'); } // Get the audio blob and play it const blob = await response.blob(); const audioUrl = URL.createObjectURL(blob); previewAudio = new Audio(audioUrl); previewButton = button; button.textContent = '■ Stop'; button.disabled = false; button.classList.add('previewing'); previewAudio.addEventListener('ended', () => { stopPreviewAudio(); button.textContent = button.dataset.originalText; button.disabled = false; // Re-check if button should be enabled based on slider values updateChunkApplyFxButtonState(speedSlider); }); previewAudio.addEventListener('error', (err) => { console.error('Preview playback error:', err); stopPreviewAudio(); button.textContent = originalText; button.disabled = false; updateChunkApplyFxButtonState(speedSlider); }); await previewAudio.play(); } catch (error) { console.error('Preview FX error:', error); alert(error.message || 'Failed to preview effects'); button.textContent = originalText; button.disabled = false; updateChunkApplyFxButtonState(speedSlider); } } function stopPreviewAudio() { if (previewAudio) { previewAudio.pause(); previewAudio.currentTime = 0; if (previewAudio.src && previewAudio.src.startsWith('blob:')) { URL.revokeObjectURL(previewAudio.src); } previewAudio = null; } if (previewButton) { previewButton.classList.remove('previewing'); previewButton.disabled = false; previewButton.textContent = previewButton.dataset.originalText || 'Preview'; previewButton = null; } } async function triggerChunkApplyFx(jobId, chunkId, button) { const card = button.closest('.library-chunk-card'); if (!card) return; const speedSlider = card.querySelector('.chunk-speed-slider'); const pitchSlider = card.querySelector('.chunk-pitch-slider'); const speed = parseFloat(speedSlider?.value || 1.0); const pitch = parseFloat(pitchSlider?.value || 0); button.disabled = true; button.textContent = 'Applying...'; try { // First restore the job to review mode if not already await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' }); const response = await fetch(`/api/jobs/${jobId}/review/apply-fx`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunks: [{ chunk_id: chunkId, speed, pitch }] }), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to apply effects'); } // Show success feedback button.textContent = 'Applied!'; updateLibraryChunkStatus(chunkId, 'completed'); // Reset sliders to default if (speedSlider) speedSlider.value = 1.0; if (pitchSlider) pitchSlider.value = 0; card.querySelector('.chunk-speed-value').textContent = '1.0x'; card.querySelector('.chunk-pitch-value').textContent = '0'; // Update audio player URL to bust cache const playBtn = card.querySelector('.library-chunk-play'); if (playBtn) { const currentUrl = playBtn.getAttribute('data-audio-url'); if (currentUrl) { const baseUrl = currentUrl.split('?')[0]; playBtn.setAttribute('data-audio-url', `${baseUrl}?t=${Date.now()}`); } } setTimeout(() => { button.textContent = 'Apply'; button.disabled = true; }, 1500); } catch (error) { console.error('Apply FX error:', error); alert(error.message || 'Failed to apply effects'); button.textContent = 'Apply'; button.disabled = false; } } async function triggerBulkSpeakerApplyFx(jobId, speaker, chunks, button) { const card = button.closest('.bulk-speaker-card'); if (!card) return; const speedSlider = card.querySelector('.bulk-speed-slider'); const pitchSlider = card.querySelector('.bulk-pitch-slider'); const speed = parseFloat(speedSlider?.value || 1.0); const pitch = parseFloat(pitchSlider?.value || 0); // Get all chunks for this speaker const speakerChunks = chunks.filter(c => (c.speaker || 'default') === speaker); if (speakerChunks.length === 0) { alert('No chunks found for this speaker.'); return; } button.disabled = true; button.textContent = `Applying to ${speakerChunks.length}...`; try { // First restore the job to review mode if not already await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' }); const chunksFx = speakerChunks.map(c => ({ chunk_id: c.id, speed, pitch })); const response = await fetch(`/api/jobs/${jobId}/review/apply-fx`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunks: chunksFx }), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to apply effects'); } // Show success feedback button.textContent = `Applied to ${data.processed}!`; // Update status for each chunk speakerChunks.forEach(c => { updateLibraryChunkStatus(c.id, 'completed'); // Update audio player URL to bust cache const chunkCard = document.querySelector(`.library-chunk-card[data-chunk-id="${c.id}"]`); if (chunkCard) { const playBtn = chunkCard.querySelector('.library-chunk-play'); if (playBtn) { const currentUrl = playBtn.getAttribute('data-audio-url'); if (currentUrl) { const baseUrl = currentUrl.split('?')[0]; playBtn.setAttribute('data-audio-url', `${baseUrl}?t=${Date.now()}`); } } } }); // Reset sliders to default if (speedSlider) speedSlider.value = 1.0; if (pitchSlider) pitchSlider.value = 0; card.querySelector('.bulk-speed-value').textContent = '1.0x'; card.querySelector('.bulk-pitch-value').textContent = '0'; setTimeout(() => { button.textContent = 'Apply FX'; button.disabled = true; }, 2000); } catch (error) { console.error('Bulk apply FX error:', error); alert(error.message || 'Failed to apply effects'); button.textContent = 'Apply FX'; button.disabled = false; } } // ─── Library Alt Word Registry ─────────────────────────────────────────────── // Reuses the full AWR modal from index.html (awr-entry-overlay / awr-modal). // When _libraryAwrJobId is set, the modal's OK button saves to the backend // instead of adding a single entry to the main-page registry. let _libraryAwrJobId = null; let _libraryAwrList = []; let _libraryAwrEditIdx = null; // index being edited, or null for new async function openLibraryAwr(jobId) { _libraryAwrJobId = jobId; _libraryAwrList = []; _libraryAwrEditIdx = null; // Load existing replacements from backend try { const resp = await fetch(`/api/library/${jobId}/word-replacements`); const data = await resp.json(); if (data.success) _libraryAwrList = data.word_replacements || []; } catch (e) { console.warn('Failed to load library AWR:', e); } // Reuse the main AWR modal — swap its title and show the registry table view _openLibraryAwrMainModal(); } function _openLibraryAwrMainModal() { // We repurpose the existing awr-modal (the main registry modal, not the entry sub-modal) // by injecting a library-specific overlay on top of it. // Simpler: build a standalone modal using the same CSS classes as the main AWR entry modal. let overlay = document.getElementById('lib-awr-registry-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'lib-awr-registry-overlay'; overlay.className = 'modal-overlay hidden'; overlay.innerHTML = ` `; document.body.appendChild(overlay); document.getElementById('lib-awr-reg-close').addEventListener('click', closeLibraryAwr); document.getElementById('lib-awr-reg-cancel').addEventListener('click', closeLibraryAwr); overlay.addEventListener('click', e => { if (e.target === overlay) closeLibraryAwr(); }); document.getElementById('lib-awr-reg-add-btn').addEventListener('click', () => { _libraryAwrEditIdx = null; _openLibraryAwrEntryModal(null); }); } // Reassign onclick every open so it always captures the current _libraryAwrJobId document.getElementById('lib-awr-reg-save').onclick = saveLibraryAwr; _renderLibraryAwrRegTable(); overlay.classList.remove('hidden'); document.getElementById('lib-awr-registry-modal').classList.remove('hidden'); } function _renderLibraryAwrRegTable() { const tbody = document.getElementById('lib-awr-reg-tbody'); if (!tbody) return; if (_libraryAwrList.length === 0) { tbody.innerHTML = 'No entries yet. Click + Add Entry to get started.'; return; } tbody.innerHTML = _libraryAwrList.map((entry, idx) => ` ${escapeHtml(entry.original)} ${escapeHtml(entry.replacement)} `).join(''); tbody.querySelectorAll('.awr-edit-btn').forEach(btn => { btn.addEventListener('click', () => { _libraryAwrEditIdx = Number(btn.dataset.idx); _openLibraryAwrEntryModal(_libraryAwrList[_libraryAwrEditIdx]); }); }); tbody.querySelectorAll('.awr-delete-btn').forEach(btn => { btn.addEventListener('click', () => { _libraryAwrList.splice(Number(btn.dataset.idx), 1); _renderLibraryAwrRegTable(); }); }); } function _ensureLibraryAwrEntryModal() { if (document.getElementById('lib-awr-entry-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'lib-awr-entry-overlay'; overlay.className = 'modal-overlay hidden'; overlay.style.zIndex = '1600'; overlay.innerHTML = `
`; document.body.appendChild(overlay); // Engine change → repopulate voices document.getElementById('lib-awr-preview-engine').addEventListener('change', function () { _libAwrPopulateVoices(this.value); }); _libAwrPopulateVoices('kokoro'); // Preview buttons let _libAwrPreviewAudio = null; const _libAwrStopPreview = () => { if (_libAwrPreviewAudio) { _libAwrPreviewAudio.pause(); _libAwrPreviewAudio = null; } }; const _libAwrPlayText = async (text) => { _libAwrStopPreview(); const engine = document.getElementById('lib-awr-preview-engine').value; const voice = document.getElementById('lib-awr-preview-voice').value; const statusEl = document.getElementById('lib-awr-preview-status'); if (!text) return; statusEl.textContent = 'Generating…'; try { const resp = await fetch('/api/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, tts_engine: engine, voice, lang_code: 'a' }), }); const data = await resp.json(); if (!data.success) { statusEl.textContent = data.error || 'Error'; return; } statusEl.textContent = ''; const audioSrc = data.audio_url ? data.audio_url : `data:${data.mime_type || 'audio/wav'};base64,${data.audio_base64}`; _libAwrPreviewAudio = new Audio(audioSrc); _libAwrPreviewAudio.play(); } catch (e) { statusEl.textContent = 'Error'; } }; document.getElementById('lib-awr-play-original').addEventListener('click', () => { _libAwrPlayText((document.getElementById('lib-awr-original-input').value || '').trim()); }); document.getElementById('lib-awr-play-replacement').addEventListener('click', () => { _libAwrPlayText((document.getElementById('lib-awr-replacement-input').value || '').trim()); }); // Enable/disable play buttons based on input const _updatePlayBtns = () => { const hasOrig = !!(document.getElementById('lib-awr-original-input').value || '').trim(); const hasRepl = !!(document.getElementById('lib-awr-replacement-input').value || '').trim(); document.getElementById('lib-awr-play-original').disabled = !hasOrig; document.getElementById('lib-awr-play-replacement').disabled = !hasRepl; }; document.getElementById('lib-awr-original-input').addEventListener('input', _updatePlayBtns); document.getElementById('lib-awr-replacement-input').addEventListener('input', _updatePlayBtns); } async function _libAwrPopulateVoices(engineName) { const select = document.getElementById('lib-awr-preview-voice'); if (!select) return; const prev = select.value; select.innerHTML = ''; const norm = (engineName || '').toLowerCase().replace(/[_-]/g, ''); const usesPrompts = norm.includes('chatterbox') || norm.includes('voxcpm') || (norm.includes('pockettts') && !norm.includes('pocketttspreset')) || (norm.includes('qwen3') && norm.includes('clone')); const isQwen = norm.includes('qwen3') && !norm.includes('clone'); const isPocketPreset = norm.includes('pocketttspreset'); try { if (usesPrompts) { const resp = await fetch('/api/voice-prompts'); const data = await resp.json(); if (data.success && data.prompts) { data.prompts.forEach(p => { const opt = document.createElement('option'); opt.value = p.path || p.name; opt.textContent = p.display || p.name; select.appendChild(opt); }); } } else if (isQwen) { const resp = await fetch('/api/qwen3/metadata'); const data = await resp.json(); if (data.success && data.speakers) { data.speakers.forEach(s => { const opt = document.createElement('option'); opt.value = s; opt.textContent = s; select.appendChild(opt); }); } } else if (isPocketPreset) { const resp = await fetch('/api/pocket-tts/voices'); const data = await resp.json(); if (data.success && data.voices) { data.voices.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; select.appendChild(opt); }); } } else { if (window.availableVoices) { Object.values(window.availableVoices).forEach(vc => { const grp = document.createElement('optgroup'); grp.label = vc.language || 'Voices'; (vc.voices || []).forEach(name => { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; grp.appendChild(opt); }); select.appendChild(grp); }); } } } catch (e) { console.warn('_libAwrPopulateVoices error:', e); } if (prev) select.value = prev; } function _openLibraryAwrEntryModal(entry) { _ensureLibraryAwrEntryModal(); const overlay = document.getElementById('lib-awr-entry-overlay'); const modal = document.getElementById('lib-awr-entry-modal'); document.getElementById('lib-awr-entry-title').textContent = entry ? 'Edit Alt Word Entry' : 'Add Alt Word Entry'; document.getElementById('lib-awr-original-input').value = entry ? entry.original : ''; document.getElementById('lib-awr-replacement-input').value = entry ? entry.replacement : ''; document.getElementById('lib-awr-preview-status').textContent = ''; document.getElementById('lib-awr-play-original').disabled = !entry?.original; document.getElementById('lib-awr-play-replacement').disabled = !entry?.replacement; overlay.classList.remove('hidden'); modal.classList.remove('hidden'); document.getElementById('lib-awr-original-input').focus(); const closeEntry = () => { overlay.classList.add('hidden'); modal.classList.add('hidden'); document.getElementById('lib-awr-entry-ok').onclick = null; document.getElementById('lib-awr-entry-close').onclick = null; document.getElementById('lib-awr-entry-cancel').onclick = null; }; document.getElementById('lib-awr-entry-ok').onclick = () => { const orig = (document.getElementById('lib-awr-original-input').value || '').trim(); const repl = (document.getElementById('lib-awr-replacement-input').value || '').trim(); if (!orig || !repl) return; if (_libraryAwrEditIdx !== null) { _libraryAwrList[_libraryAwrEditIdx] = { original: orig, replacement: repl }; _libraryAwrEditIdx = null; } else { if (_libraryAwrList.some(r => r.original.toLowerCase() === orig.toLowerCase())) { alert('An entry for that word already exists.'); return; } _libraryAwrList.push({ original: orig, replacement: repl }); } closeEntry(); _renderLibraryAwrRegTable(); }; document.getElementById('lib-awr-entry-close').onclick = closeEntry; document.getElementById('lib-awr-entry-cancel').onclick = closeEntry; } function closeLibraryAwr() { const overlay = document.getElementById('lib-awr-registry-overlay'); const modal = document.getElementById('lib-awr-registry-modal'); if (overlay) overlay.classList.add('hidden'); if (modal) modal.classList.add('hidden'); _libraryAwrJobId = null; _libraryAwrList = []; _libraryAwrEditIdx = null; } async function saveLibraryAwr() { const jobId = _libraryAwrJobId; if (!jobId) { closeLibraryAwr(); return; } const saveBtn = document.getElementById('lib-awr-reg-save'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; } try { const resp = await fetch(`/api/library/${jobId}/word-replacements`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ word_replacements: _libraryAwrList }), }); const data = await resp.json(); if (!data.success) { alert('Failed to save: ' + (data.error || 'Unknown error')); return; } closeLibraryAwr(); } catch (e) { console.error('Save library AWR error:', e); alert('Failed to save Alt Word Registry.'); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; } } } // ─── End Library Alt Word Registry ─────────────────────────────────────────── // ─── Time Codes Modal ───────────────────────────────────────────────────────── let timeCodesJobId = null; let timeCodesChapters = null; function fmtTimecode(totalSeconds) { const s = Math.floor(totalSeconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } function buildTimeCodesText(chapters, introSeconds, driftPerChapter) { // All entries formatted as "HH:MM:SS CH. XX" — uniform length, no alignment needed. const entries = chapters.map((ch, idx) => { const ts = Math.floor(ch.start_time + introSeconds + driftPerChapter * idx); return `${fmtTimecode(ts)} CH. ${String(idx).padStart(2, '0')}`; }); const cols = 3; const gap = ' '; // 5 spaces between columns const rows = []; for (let i = 0; i < entries.length; i += cols) { const row = []; for (let c = 0; c < cols; c++) { if (entries[i + c] !== undefined) row.push(entries[i + c]); } rows.push(row.join(gap)); } return rows.join('\n'); } function renderTimeCodesResult() { if (!timeCodesChapters) return; const introInput = document.getElementById('timecodes-intro-seconds'); const driftInput = document.getElementById('timecodes-drift-adjust'); const introSecs = Math.max(0, parseFloat(introInput?.value) || 0); const driftPerChapter = parseFloat(driftInput?.value) || 0; const output = document.getElementById('timecodes-output'); const resultEl = document.getElementById('timecodes-result'); if (output) output.textContent = buildTimeCodesText(timeCodesChapters, introSecs, driftPerChapter); if (resultEl) resultEl.classList.remove('hidden'); } async function openTimeCodesModal(jobId, title) { timeCodesJobId = jobId; timeCodesChapters = null; const overlay = document.getElementById('timecodes-modal-overlay'); const modal = document.getElementById('timecodes-modal'); const titleEl = document.getElementById('timecodes-modal-title'); const loadingEl = document.getElementById('timecodes-loading'); const errorEl = document.getElementById('timecodes-error'); const resultEl = document.getElementById('timecodes-result'); const introInput = document.getElementById('timecodes-intro-seconds'); const output = document.getElementById('timecodes-output'); if (titleEl) titleEl.textContent = `Time Codes — ${title || 'Untitled'}`; if (loadingEl) loadingEl.classList.remove('hidden'); if (errorEl) { errorEl.classList.add('hidden'); errorEl.textContent = ''; } if (resultEl) resultEl.classList.add('hidden'); if (output) output.textContent = ''; if (introInput) introInput.value = 0; overlay?.classList.remove('hidden'); modal?.classList.remove('hidden'); try { const response = await fetch(`/api/library/${jobId}/chapter-durations`); const data = await response.json(); if (!data.success) throw new Error(data.error || 'Failed to load chapter data'); if (!data.chapters || data.chapters.length === 0) throw new Error('No chapters found for this item.'); timeCodesChapters = data.chapters; if (loadingEl) loadingEl.classList.add('hidden'); renderTimeCodesResult(); } catch (err) { if (loadingEl) loadingEl.classList.add('hidden'); if (errorEl) { errorEl.textContent = err.message || 'Failed to load chapters.'; errorEl.classList.remove('hidden'); } } } function closeTimeCodesModal() { document.getElementById('timecodes-modal-overlay')?.classList.add('hidden'); document.getElementById('timecodes-modal')?.classList.add('hidden'); timeCodesJobId = null; timeCodesChapters = null; } document.addEventListener('DOMContentLoaded', () => { document.addEventListener('click', e => { const btn = e.target.closest('.timecodes-open-btn'); if (btn) { e.stopPropagation(); openTimeCodesModal(btn.dataset.jobId, btn.dataset.title || ''); } }); document.getElementById('timecodes-modal-close')?.addEventListener('click', closeTimeCodesModal); document.getElementById('timecodes-close-btn')?.addEventListener('click', closeTimeCodesModal); document.getElementById('timecodes-modal-overlay')?.addEventListener('click', e => { if (e.target === document.getElementById('timecodes-modal-overlay')) closeTimeCodesModal(); }); document.getElementById('timecodes-calculate-btn')?.addEventListener('click', renderTimeCodesResult); document.getElementById('timecodes-intro-seconds')?.addEventListener('keydown', e => { if (e.key === 'Enter') renderTimeCodesResult(); }); document.getElementById('timecodes-drift-adjust')?.addEventListener('keydown', e => { if (e.key === 'Enter') renderTimeCodesResult(); }); document.getElementById('timecodes-drift-adjust')?.addEventListener('change', renderTimeCodesResult); document.getElementById('timecodes-copy-btn')?.addEventListener('click', () => { const text = document.getElementById('timecodes-output')?.textContent || ''; if (!text) return; navigator.clipboard.writeText(text).then(() => { const btn = document.getElementById('timecodes-copy-btn'); if (btn) { const orig = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1800); } }).catch(() => { prompt('Copy the time codes below:', text); }); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { const overlay = document.getElementById('timecodes-modal-overlay'); if (overlay && !overlay.classList.contains('hidden')) closeTimeCodesModal(); } }); }); // ─── End Time Codes Modal ───────────────────────────────────────────────────── // Clear all library items async function clearLibrary() { if (!confirm('Are you sure you want to delete ALL audio files? This cannot be undone!')) { return; } try { const response = await fetch('/api/library/clear', { method: 'POST' }); const data = await response.json(); if (data.success) { loadLibrary(); // Reload library } else { alert('Error clearing library: ' + data.error); } } catch (error) { console.error('Error clearing library:', error); alert('Failed to clear library'); } }