// Voice Manager - Handles voice library and selection let voiceData = null; // Make availableVoices globally accessible for main.js window.availableVoices = null; window.customVoiceMap = window.customVoiceMap || {}; window.availablePocketTtsVoices = null; let chatterboxVoices = []; const CHATTERBOX_ALLOWED_EXTENSIONS = ['.wav', '.mp3', '.m4a', '.flac', '.ogg']; const chatterboxPreviewController = createChatterboxPreviewController(); window.chatterboxPreviewController = chatterboxPreviewController; const audioPreviewCache = {}; let currentPreviewAudio = null; let currentPreviewItem = null; let samplesReady = false; let lastFailedSamples = []; let customVoices = []; let qwenVoicePreview = null; const generateSamplesBtnId = 'generate-voice-samples-btn'; const regenerateSamplesBtnId = 'regenerate-voice-samples-btn'; const sampleStatusId = 'voice-sample-status'; const VOICES_UPDATED_EVENT = window.VOICES_UPDATED_EVENT || 'voices:updated'; window.VOICES_UPDATED_EVENT = VOICES_UPDATED_EVENT; const CHATTERBOX_VOICES_EVENT = window.CHATTERBOX_VOICES_EVENT || 'chatterboxVoices:updated'; window.CHATTERBOX_VOICES_EVENT = CHATTERBOX_VOICES_EVENT; // Load voices on page load document.addEventListener('DOMContentLoaded', () => { loadVoices(); loadPocketTtsVoices(); loadCustomVoices(); setupCustomVoiceModal(); loadChatterboxVoices(); setupChatterboxVoiceSection(); setupVoiceListControls(); initEditVoiceModal(); setupVoicesAccordion(); setupQwenVoiceCreation(); }); function setupVoicesAccordion() { const sections = document.querySelectorAll('.voices-section'); sections.forEach(section => { const header = section.querySelector('.voices-section-header'); const toggle = section.querySelector('.voices-section-toggle'); if (!header || !toggle) return; header.addEventListener('click', event => { if (event.target.closest('button')) { return; } section.classList.toggle('collapsed'); toggle.textContent = section.classList.contains('collapsed') ? '▶' : '▼'; }); }); } async function loadPocketTtsVoices() { try { const response = await fetch('/api/pocket-tts/voices'); const data = await response.json(); if (data.success) { window.availablePocketTtsVoices = data.voices || []; } else { window.availablePocketTtsVoices = []; } } catch (error) { console.error('Error loading Pocket TTS voices:', error); window.availablePocketTtsVoices = []; } finally { emitVoicesUpdated(); } } function getSelectionSet(scope) { return scope === 'archived' ? selectedArchivedVoiceIds : selectedVoiceIds; } function getSelectedCount(scope) { return getSelectionSet(scope).size; } function updateBatchToolbar(scope) { const isArchive = scope === 'archived'; const count = getSelectedCount(scope); const countLabel = document.getElementById(isArchive ? 'voice-archive-selected-count' : 'voice-selected-count'); if (countLabel) { countLabel.textContent = `${count} selected`; } const exportBtn = document.getElementById(isArchive ? 'voice-archive-export-btn' : 'voice-batch-export-btn'); const archiveBtn = document.getElementById(isArchive ? 'voice-archive-restore-btn' : 'voice-batch-archive-btn'); const deleteBtn = document.getElementById(isArchive ? 'voice-archive-delete-btn' : 'voice-batch-delete-btn'); [exportBtn, archiveBtn, deleteBtn].forEach(btn => { if (btn) btn.disabled = count === 0; }); const selectAll = document.getElementById(isArchive ? 'voice-archive-select-all' : 'voice-select-all'); if (selectAll) { const totalRows = getVoiceRowCheckboxes(scope).length; selectAll.checked = totalRows > 0 && count === totalRows; selectAll.indeterminate = count > 0 && count < totalRows; } } function updateSelectAllState(scope, totalRows) { const selection = getSelectionSet(scope); const selectAll = document.getElementById(scope === 'archived' ? 'voice-archive-select-all' : 'voice-select-all'); if (!selectAll) return; const count = selection.size; selectAll.checked = totalRows > 0 && count === totalRows; selectAll.indeterminate = count > 0 && count < totalRows; } function updateRowSelection(row, checked) { if (!row) return; const voiceId = row.dataset.voiceId; if (!voiceId) return; const scope = row.dataset.archived === 'true' ? 'archived' : 'active'; const selection = getSelectionSet(scope); if (checked) { selection.add(voiceId); } else { selection.delete(voiceId); } updateBatchToolbar(scope); } function getVoiceRowCheckboxes(scope) { const container = scope === 'archived' ? document.getElementById('chatterbox-voice-archive-list') : document.getElementById('chatterbox-voice-list'); if (!container) return []; return Array.from(container.querySelectorAll('input.voice-select-checkbox')); } function syncSelectionSets() { const activeIds = new Set(allVoices.filter(entry => !entry.archived).map(entry => entry.id)); const archivedIds = new Set(allVoices.filter(entry => entry.archived).map(entry => entry.id)); selectedVoiceIds.forEach(id => { if (!activeIds.has(id)) selectedVoiceIds.delete(id); }); selectedArchivedVoiceIds.forEach(id => { if (!archivedIds.has(id)) selectedArchivedVoiceIds.delete(id); }); } async function applyArchiveState(voiceIds, archived) { if (!voiceIds.length) return; const response = await fetch('/api/chatterbox-voices/archive', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_ids: voiceIds, archived }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Archive update failed'); } } async function deleteVoicesBatch(voiceIds) { if (!voiceIds.length) return; const response = await fetch('/api/chatterbox-voices/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_ids: voiceIds }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Delete failed'); } } async function exportVoicesBatch(voiceIds) { if (!voiceIds.length) return; const response = await fetch('/api/chatterbox-voices/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_ids: voiceIds }) }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Export failed'); } const blob = await response.blob(); const disposition = response.headers.get('Content-Disposition') || ''; const filenameMatch = disposition.match(/filename="?([^";]+)"?/i); const filename = filenameMatch ? filenameMatch[1] : (blob.type === 'application/zip' ? 'voice_samples.zip' : 'voice_sample.wav'); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function setupQwenVoiceCreation() { const generateBtn = document.getElementById('qwen-voice-generate-btn'); const saveBtn = document.getElementById('qwen-voice-save-btn'); if (generateBtn) { generateBtn.addEventListener('click', generateQwenVoicePreview); } if (saveBtn) { saveBtn.addEventListener('click', saveQwenVoicePrompt); } loadQwenVoiceLanguages(); } async function loadQwenVoiceLanguages() { const select = document.getElementById('qwen-voice-language'); if (!select) return; try { const response = await fetch('/api/qwen3/metadata'); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to load Qwen3 metadata'); } const previous = select.value; select.innerHTML = ''; (data.languages || []).forEach(language => { const option = document.createElement('option'); option.value = language; option.textContent = language; select.appendChild(option); }); if (previous) { select.value = previous; } } catch (error) { console.warn('Unable to load Qwen3 metadata', error); } } async function generateQwenVoicePreview() { const textInput = document.getElementById('qwen-voice-text'); const instructInput = document.getElementById('qwen-voice-instruct'); const languageSelect = document.getElementById('qwen-voice-language'); const previewAudio = document.getElementById('qwen-voice-preview'); const status = document.getElementById('qwen-voice-status'); const saveBtn = document.getElementById('qwen-voice-save-btn'); const generateBtn = document.getElementById('qwen-voice-generate-btn'); const text = textInput?.value.trim() || ''; const instruct = instructInput?.value.trim() || ''; const language = languageSelect?.value || 'Auto'; if (!text) { showToast('Enter sample text for the preview.', 'warning'); return; } if (status) { status.textContent = 'Generating preview...'; } if (generateBtn) { generateBtn.disabled = true; } if (saveBtn) { saveBtn.disabled = true; } qwenVoicePreview = null; try { const response = await fetch('/api/qwen3/voice-design/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, instruct, language }), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to enqueue preview'); } const result = await pollQwenVoiceTask(data.job_id, status, 'Generating preview...'); qwenVoicePreview = { audio_base64: result.audio_base64, mime_type: result.mime_type || 'audio/wav', }; if (previewAudio && result.audio_base64) { previewAudio.src = `data:${qwenVoicePreview.mime_type};base64,${result.audio_base64}`; previewAudio.load(); } if (saveBtn) { saveBtn.disabled = false; } if (status) { status.textContent = 'Preview ready. Save when you like it.'; } } catch (error) { console.error('Failed to generate Qwen preview', error); showToast(error.message || 'Preview failed', 'error'); if (status) { status.textContent = 'Preview failed.'; } } finally { if (generateBtn) { generateBtn.disabled = false; } } } async function saveQwenVoicePrompt() { const nameInput = document.getElementById('qwen-voice-name'); const genderSelect = document.getElementById('qwen-voice-gender'); const languageSelect = document.getElementById('qwen-voice-language'); const descriptionInput = document.getElementById('qwen-voice-description'); const textInput = document.getElementById('qwen-voice-text'); const instructInput = document.getElementById('qwen-voice-instruct'); const status = document.getElementById('qwen-voice-status'); const saveBtn = document.getElementById('qwen-voice-save-btn'); const name = nameInput?.value.trim() || ''; const gender = genderSelect?.value || null; const language = languageSelect?.value || 'Auto'; const description = descriptionInput?.value.trim() || ''; const text = textInput?.value.trim() || ''; const instruct = instructInput?.value.trim() || ''; if (!name) { showToast('Add a name before saving the voice prompt.', 'warning'); return; } if (!text) { showToast('Sample text is required to save this voice.', 'warning'); return; } if (!qwenVoicePreview?.audio_base64) { showToast('Generate a preview before saving.', 'warning'); return; } if (status) { status.textContent = 'Saving voice prompt...'; } if (saveBtn) { saveBtn.disabled = true; } try { const response = await fetch('/api/qwen3/voice-design/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gender, language, description, text, instruct, audio_base64: qwenVoicePreview.audio_base64, }), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to enqueue save'); } await pollQwenVoiceTask(data.job_id, status, 'Saving voice prompt...'); showToast('Qwen voice prompt saved.', 'success'); if (status) { status.textContent = 'Saved to Voice Prompts.'; } if (nameInput) nameInput.value = ''; if (descriptionInput) descriptionInput.value = ''; qwenVoicePreview = null; if (saveBtn) { saveBtn.disabled = true; } await loadChatterboxVoices(); } catch (error) { console.error('Failed to save Qwen voice prompt', error); showToast(error.message || 'Save failed', 'error'); if (status) { status.textContent = 'Save failed.'; } if (saveBtn) { saveBtn.disabled = false; } } } async function pollQwenVoiceTask(taskId, statusEl, message) { if (!taskId) { throw new Error('Missing task id for queued request.'); } const start = Date.now(); const timeoutMs = 10 * 60 * 1000; while (Date.now() - start < timeoutMs) { if (statusEl && message) { statusEl.textContent = message; } const response = await fetch(`/api/qwen3/voice-design/tasks/${taskId}`); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to fetch task status'); } if (data.status === 'completed') { return data.result || {}; } if (data.status === 'failed') { throw new Error(data.error || 'Queued task failed'); } await new Promise(resolve => setTimeout(resolve, 1500)); } throw new Error('Timed out waiting for the queued task.'); } // Load available voices from API async function loadVoices() { try { const response = await fetch('/api/voices'); const data = await response.json(); if (data.success) { voiceData = data.voices; window.availableVoices = data.voices; // Make globally accessible updateCustomVoiceMap(data.voices); samplesReady = data.samples_ready; updateSampleStatus(data); displayVoiceLibrary(data.voices); emitVoicesUpdated(); } else { displaySampleError(data.error || 'Unable to load voices'); } } catch (error) { console.error('Error loading voices:', error); displaySampleError('Error loading voices'); } } // --------------------------------------------------------------------------- // Chatterbox voice management async function loadChatterboxVoices() { try { const response = await fetch('/api/chatterbox-voices'); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Unable to load Chatterbox voices'); } chatterboxVoices = Array.isArray(data.voices) ? data.voices : []; renderChatterboxVoiceList(); updateGlobalPreviewSelections(); emitChatterboxVoicesUpdated(); } catch (error) { console.error('Failed to load Chatterbox voices', error); showToast(error.message || 'Failed to load Chatterbox voices', 'error'); } } function setupChatterboxVoiceSection() { const form = document.getElementById('chatterbox-voice-form'); const list = document.getElementById('chatterbox-voice-list'); if (form) { form.addEventListener('submit', async event => { event.preventDefault(); const nameInput = document.getElementById('chatterbox-voice-name'); const fileInput = document.getElementById('chatterbox-voice-file'); const name = nameInput?.value.trim(); const file = fileInput?.files?.[0]; if (!name) { showToast('A friendly voice name is required.', 'warning'); return; } if (!file) { showToast('Select an audio file to upload.', 'warning'); return; } const formData = new FormData(); formData.append('name', name); formData.append('file', file); try { const response = await fetch('/api/chatterbox-voices', { method: 'POST', body: formData, }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to save voice'); } showToast('Chatterbox voice saved.', 'success'); form.reset(); fileInput.value = ''; await loadChatterboxVoices(); } catch (error) { console.error('Failed to save Chatterbox voice', error); showToast(error.message || 'Failed to save voice', 'error'); } }); } initChatterboxDropzone(); if (list) { list.addEventListener('click', event => { const actionButton = event.target.closest('[data-action]'); if (!actionButton) return; const card = event.target.closest('.chatterbox-voice-card'); if (!card) return; const voiceId = card.dataset.voiceId; if (!voiceId) return; const action = actionButton.dataset.action; if (action === 'rename') { renameChatterboxVoice(voiceId); } else if (action === 'delete') { deleteChatterboxVoice(voiceId); } else if (action === 'copy') { copyChatterboxVoicePath(voiceId); } else if (action === 'preview') { toggleChatterboxVoicePreview(voiceId, actionButton); } }); } } function initChatterboxDropzone() { const dropzone = document.getElementById('chatterbox-dropzone'); const fileInput = document.getElementById('chatterbox-dropzone-input'); const statusContainer = document.getElementById('chatterbox-dropzone-status'); if (!dropzone || !fileInput || !statusContainer) { return; } const browseBtn = dropzone.querySelector('button'); if (browseBtn) { browseBtn.addEventListener('click', () => { if (dropzone.classList.contains('is-uploading')) return; fileInput.click(); }); } fileInput.addEventListener('change', event => { const files = Array.from(event.target.files || []); if (files.length) { bulkUploadChatterboxVoices(files, dropzone, statusContainer, fileInput); } event.target.value = ''; }); ['dragenter', 'dragover'].forEach(eventName => { dropzone.addEventListener(eventName, event => { event.preventDefault(); event.stopPropagation(); if (dropzone.classList.contains('is-uploading')) return; dropzone.classList.add('is-dragging'); }); }); ['dragleave', 'dragend'].forEach(eventName => { dropzone.addEventListener(eventName, event => { event.preventDefault(); event.stopPropagation(); dropzone.classList.remove('is-dragging'); }); }); dropzone.addEventListener('drop', event => { event.preventDefault(); event.stopPropagation(); dropzone.classList.remove('is-dragging'); if (dropzone.classList.contains('is-uploading')) return; const files = Array.from(event.dataTransfer?.files || []); if (files.length) { bulkUploadChatterboxVoices(files, dropzone, statusContainer, fileInput); } }); } function updateGlobalPreviewSelections() { if (typeof populateReferenceSelects === 'function') { try { populateReferenceSelects(); } catch (error) { console.warn('populateReferenceSelects failed', error); } } if (typeof window.rebuildTurboPreviewMenus === 'function') { try { window.rebuildTurboPreviewMenus(); } catch (error) { console.warn('rebuildTurboPreviewMenus failed', error); } } const globalSelect = document.getElementById('chatterbox-reference-select'); const globalButton = document.getElementById('global-chatterbox-preview-btn'); if (globalButton) { const hasSelection = (globalSelect?.value || '').trim().length > 0; if (!hasSelection) { globalButton.disabled = true; globalButton.classList.remove('is-playing', 'is-loading'); globalButton.textContent = globalButton.dataset.labelPlay || 'Play'; } else { globalButton.disabled = false; } } } function createChatterboxPreviewController() { let currentAudio = null; let currentVoiceId = null; let currentTrigger = null; function resetTrigger(trigger) { if (!trigger) return; trigger.classList.remove('is-playing', 'is-loading'); trigger.textContent = trigger.dataset.labelPlay || 'Play'; trigger.disabled = trigger.dataset.disabled === 'true'; } function applyPlayingState(trigger) { if (!trigger) return; trigger.classList.remove('is-loading'); trigger.classList.add('is-playing'); trigger.textContent = trigger.dataset.labelStop || 'Stop'; } function applyLoadingState(trigger) { if (!trigger) return; trigger.dataset.disabled = trigger.disabled ? 'true' : 'false'; trigger.disabled = true; trigger.classList.add('is-loading'); trigger.textContent = 'Loading…'; } function stopPlayback() { if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; currentAudio = null; } if (currentTrigger) { resetTrigger(currentTrigger); currentTrigger = null; } currentVoiceId = null; } async function toggleById(voiceId, trigger) { if (!voiceId) return; if (voiceId === currentVoiceId) { stopPlayback(); return; } stopPlayback(); currentVoiceId = voiceId; currentTrigger = trigger || null; if (currentTrigger) { applyLoadingState(currentTrigger); } const previewUrl = `/api/chatterbox-voices/${voiceId}/preview?_=${Date.now()}`; const audio = new Audio(previewUrl); currentAudio = audio; audio.addEventListener('ended', () => { stopPlayback(); }); audio.addEventListener('error', () => { showToast('Unable to play preview audio.', 'error'); stopPlayback(); }); try { await audio.play(); if (currentTrigger) { currentTrigger.disabled = false; applyPlayingState(currentTrigger); } } catch (error) { console.error('Failed to play chatterbox preview', error); showToast('Unable to play preview audio.', 'error'); stopPlayback(); } } return { toggleById, stop: stopPlayback, getCurrentVoiceId() { return currentVoiceId; }, }; } function appendDropzoneStatus(container, message, type = 'info') { if (!container) return null; const row = document.createElement('div'); row.className = `dropzone-status-row ${type}`; row.textContent = message; container.prepend(row); while (container.childElementCount > 10) { container.removeChild(container.lastElementChild); } return row; } function normalizeVoiceNameFromFile(filename = '') { const stem = filename.replace(/\.[^/.]+$/, ''); const cleaned = stem.replace(/[_\s-]+/g, ' ').trim(); if (cleaned) { return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned; } return stem || 'Untitled Voice'; } function getFileExtension(filename = '') { const lastDot = filename.lastIndexOf('.'); if (lastDot === -1) return ''; return filename.slice(lastDot).toLowerCase(); } async function bulkUploadChatterboxVoices(files, dropzone, statusContainer, fileInput) { if (!files.length) { appendDropzoneStatus(statusContainer, 'No files detected.', 'info'); return; } dropzone.classList.add('is-uploading'); let createdAny = false; try { for (const file of files) { const extension = getFileExtension(file.name); if (!CHATTERBOX_ALLOWED_EXTENSIONS.includes(extension)) { appendDropzoneStatus( statusContainer, `${file.name}: Unsupported file type (${extension || 'unknown'}).`, 'error' ); continue; } const pendingRow = appendDropzoneStatus( statusContainer, `Uploading ${file.name}…`, 'info' ); const friendlyName = normalizeVoiceNameFromFile(file.name); const formData = new FormData(); formData.append('name', friendlyName); formData.append('file', file); try { const response = await fetch('/api/chatterbox-voices', { method: 'POST', body: formData, }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to save voice.'); } pendingRow.textContent = `Saved ${file.name} (${friendlyName}).`; pendingRow.classList.remove('info'); pendingRow.classList.add('success'); createdAny = true; } catch (error) { pendingRow.textContent = `${file.name}: ${error.message}`; pendingRow.classList.remove('info'); pendingRow.classList.add('error'); } } if (createdAny) { await loadChatterboxVoices(); } } finally { dropzone.classList.remove('is-uploading'); if (fileInput) { fileInput.value = ''; } } } // Voice list state let externalVoices = []; let allVoices = []; // Combined local + external let voiceListSortKey = 'name'; let voiceListSortDir = 'asc'; const selectedVoiceIds = new Set(); const selectedArchivedVoiceIds = new Set(); // Locale code to human-readable language name mapping (voice-manager specific) const VM_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 (Australia)', 'en-CA': 'English (Canada)', 'en-GB': 'English (UK)', 'en-HK': 'English (Hong Kong)', 'en-IE': 'English (Ireland)', 'en-IN': 'English (India)', 'en-KE': 'English (Kenya)', 'en-NG': 'English (Nigeria)', 'en-NZ': 'English (New Zealand)', 'en-PH': 'English (Philippines)', 'en-SG': 'English (Singapore)', 'en-TZ': 'English (Tanzania)', 'en-US': 'English (US)', 'en-ZA': 'English (South Africa)', 'es-AR': 'Spanish (Argentina)', 'es-BO': 'Spanish (Bolivia)', 'es-CL': 'Spanish (Chile)', 'es-CO': 'Spanish (Colombia)', 'es-CR': 'Spanish (Costa Rica)', 'es-CU': 'Spanish (Cuba)', 'es-DO': 'Spanish (Dominican Rep.)', 'es-EC': 'Spanish (Ecuador)', 'es-ES': 'Spanish (Spain)', 'es-GQ': 'Spanish (Eq. Guinea)', 'es-GT': 'Spanish (Guatemala)', 'es-HN': 'Spanish (Honduras)', 'es-MX': 'Spanish (Mexico)', 'es-NI': 'Spanish (Nicaragua)', 'es-PA': 'Spanish (Panama)', 'es-PE': 'Spanish (Peru)', 'es-PR': 'Spanish (Puerto Rico)', 'es-PY': 'Spanish (Paraguay)', 'es-SV': 'Spanish (El Salvador)', 'es-US': 'Spanish (US)', 'es-UY': 'Spanish (Uruguay)', 'es-VE': 'Spanish (Venezuela)', 'et-EE': 'Estonian', 'eu-ES': 'Basque', 'fa-IR': 'Persian', 'fi-FI': 'Finnish', 'fil-PH': 'Filipino', 'fr-BE': 'French (Belgium)', 'fr-CA': 'French (Canada)', 'fr-CH': 'French (Swiss)', '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 (Belgium)', 'nl-NL': 'Dutch', 'pl-PL': 'Polish', 'ps-AF': 'Pashto', 'pt-BR': 'Portuguese (Brazil)', '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 (Kenya)', 'sw-TZ': 'Swahili (Tanzania)', 'ta-IN': 'Tamil (India)', 'ta-LK': 'Tamil (Sri Lanka)', 'ta-MY': 'Tamil (Malaysia)', 'ta-SG': 'Tamil (Singapore)', 'te-IN': 'Telugu', 'th-TH': 'Thai', 'tr-TR': 'Turkish', 'uk-UA': 'Ukrainian', 'ur-IN': 'Urdu (India)', 'ur-PK': 'Urdu (Pakistan)', 'uz-UZ': 'Uzbek', 'vi-VN': 'Vietnamese', 'wuu-CN': 'Wu Chinese', 'yue-CN': 'Cantonese', 'zh-CN': 'Chinese (Mandarin)', 'zh-HK': 'Chinese (Hong Kong)', 'zh-TW': 'Chinese (Taiwan)', 'zu-ZA': 'Zulu', }; function getLanguageDisplayName(localeCode) { if (!localeCode) return '—'; return VM_LOCALE_NAMES[localeCode] || localeCode; } function renderChatterboxVoiceList() { const container = document.getElementById('chatterbox-voice-list'); const archiveContainer = document.getElementById('chatterbox-voice-archive-list'); if (!container) return; // Build set of local file names to detect duplicates const localFileNames = new Set( chatterboxVoices.map(v => v.file_name).filter(Boolean) ); // Filter out external voices that have been downloaded (exist in local list) const filteredExternalVoices = externalVoices.filter(v => { const fileName = `${v.short_name}.mp3`; return !localFileNames.has(fileName); }); // Combine local and external voices allVoices = [ ...chatterboxVoices.map(v => ({ ...v, source: 'local' })), ...filteredExternalVoices.map(v => ({ ...v, source: 'external' })) ]; const activeVoices = allVoices.filter(entry => !entry.archived); const archivedVoices = allVoices.filter(entry => entry.archived); // Apply filters const filteredVoices = applyVoiceFilters(activeVoices); const filteredArchived = applyVoiceFilters(archivedVoices); // Apply sorting const sortedVoices = sortVoices(filteredVoices, voiceListSortKey, voiceListSortDir); const sortedArchived = sortVoices(filteredArchived, voiceListSortKey, voiceListSortDir); // Update stats updateVoiceListStats(filteredVoices.length, activeVoices.length); // Update language filter options updateLanguageFilterOptions(allVoices); renderVoiceRows(container, sortedVoices, { archived: false }); if (archiveContainer) { renderVoiceRows(archiveContainer, sortedArchived, { archived: true }); } syncSelectionSets(); updateBatchToolbar('active'); updateBatchToolbar('archived'); } function renderVoiceRows(container, rows, { archived }) { if (!container) return; if (!rows.length) { container.innerHTML = `${archived ? 'No archived voices.' : 'No voices match your filters.'}`; return; } container.innerHTML = ''; rows.forEach(entry => { const row = document.createElement('tr'); row.dataset.voiceId = entry.id; row.dataset.source = entry.source; row.dataset.archived = archived ? 'true' : 'false'; const isExternal = entry.source === 'external'; const isDownloaded = isExternal ? entry.is_downloaded : true; const genderBadge = entry.gender ? `${entry.gender}` : ''; const sourceBadge = isExternal ? (isDownloaded ? 'Downloaded' : 'External') : 'Local'; const durationText = entry.duration_seconds ? `${entry.duration_seconds.toFixed(1)}s` : '—'; const languageText = getLanguageDisplayName(entry.language); const selectionSet = archived ? selectedArchivedVoiceIds : selectedVoiceIds; const isSelected = selectionSet.has(entry.id); const archiveAction = archived ? 'unarchive' : 'archive'; const archiveLabel = archived ? 'Unarchive' : 'Archive'; row.innerHTML = ` ${escapeHtml(entry.name || 'Untitled Voice')} ${entry.missing_file ? 'Missing' : ''} ${genderBadge} ${escapeHtml(languageText)} ${durationText} ${sourceBadge} ${isExternal && !isDownloaded ? ` ` : ` `} ${!isExternal ? ` ` : ''} `; container.appendChild(row); }); } function applyVoiceFilters(voices) { const searchInput = document.getElementById('voice-search-input'); const sourceFilter = document.getElementById('voice-filter-source'); const genderFilter = document.getElementById('voice-filter-gender'); const languageFilter = document.getElementById('voice-filter-language'); const searchTerm = (searchInput?.value || '').toLowerCase().trim(); const sourceValue = sourceFilter?.value || 'all'; const genderValue = genderFilter?.value || 'all'; const languageValue = languageFilter?.value || 'all'; return voices.filter(v => { // Search filter if (searchTerm) { const nameMatch = (v.name || '').toLowerCase().includes(searchTerm); const langMatch = (v.language || '').toLowerCase().includes(searchTerm); const fileMatch = (v.file_name || '').toLowerCase().includes(searchTerm); if (!nameMatch && !langMatch && !fileMatch) return false; } // Source filter if (sourceValue !== 'all' && v.source !== sourceValue) return false; // Gender filter if (genderValue !== 'all' && v.gender !== genderValue) return false; // Language filter if (languageValue !== 'all' && v.language !== languageValue) return false; return true; }); } function sortVoices(voices, key, dir) { return [...voices].sort((a, b) => { let aVal = a[key] ?? ''; let bVal = b[key] ?? ''; if (key === 'duration') { aVal = a.duration_seconds ?? 0; bVal = b.duration_seconds ?? 0; } if (typeof aVal === 'string') { aVal = aVal.toLowerCase(); bVal = bVal.toLowerCase(); } if (aVal < bVal) return dir === 'asc' ? -1 : 1; if (aVal > bVal) return dir === 'asc' ? 1 : -1; return 0; }); } function updateVoiceListStats(filtered, total) { const statsEl = document.getElementById('voice-count-display'); if (statsEl) { if (filtered === total) { statsEl.textContent = `${total} voice${total !== 1 ? 's' : ''}`; } else { statsEl.textContent = `${filtered} of ${total} voices`; } } } function updateLanguageFilterOptions(voices) { const select = document.getElementById('voice-filter-language'); if (!select) return; const currentValue = select.value; const languages = new Set(); voices.forEach(v => { if (v.language) languages.add(v.language); }); // Sort by display name, not locale code const sortedLangs = [...languages].sort((a, b) => getLanguageDisplayName(a).localeCompare(getLanguageDisplayName(b)) ); select.innerHTML = ''; sortedLangs.forEach(lang => { const opt = document.createElement('option'); opt.value = lang; opt.textContent = getLanguageDisplayName(lang); select.appendChild(opt); }); // Restore selection if still valid if (currentValue && languages.has(currentValue)) { select.value = currentValue; } } function setupVoiceListControls() { // Search input const searchInput = document.getElementById('voice-search-input'); if (searchInput) { let debounceTimer; searchInput.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => renderChatterboxVoiceList(), 200); }); } // Filter selects ['voice-filter-source', 'voice-filter-gender', 'voice-filter-language'].forEach(id => { const el = document.getElementById(id); if (el) { el.addEventListener('change', () => renderChatterboxVoiceList()); } }); // Sort headers const table = document.getElementById('chatterbox-voice-table'); if (table) { table.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { const sortKey = th.dataset.sort; if (voiceListSortKey === sortKey) { voiceListSortDir = voiceListSortDir === 'asc' ? 'desc' : 'asc'; } else { voiceListSortKey = sortKey; voiceListSortDir = 'asc'; } // Update sort indicators table.querySelectorAll('th.sortable').forEach(h => { h.classList.remove('sort-asc', 'sort-desc'); }); th.classList.add(voiceListSortDir === 'asc' ? 'sort-asc' : 'sort-desc'); renderChatterboxVoiceList(); }); }); } const archiveTable = document.getElementById('chatterbox-voice-archive-table'); if (archiveTable) { archiveTable.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { const sortKey = th.dataset.sort; if (voiceListSortKey === sortKey) { voiceListSortDir = voiceListSortDir === 'asc' ? 'desc' : 'asc'; } else { voiceListSortKey = sortKey; voiceListSortDir = 'asc'; } archiveTable.querySelectorAll('th.sortable').forEach(h => { h.classList.remove('sort-asc', 'sort-desc'); }); th.classList.add(voiceListSortDir === 'asc' ? 'sort-asc' : 'sort-desc'); renderChatterboxVoiceList(); }); }); } // Load external voices button const loadExternalBtn = document.getElementById('load-external-voices-btn'); if (loadExternalBtn) { loadExternalBtn.addEventListener('click', loadExternalVoices); } const selectAllActive = document.getElementById('voice-select-all'); if (selectAllActive) { selectAllActive.addEventListener('change', event => { const checked = event.target.checked; getVoiceRowCheckboxes('active').forEach(checkbox => { checkbox.checked = checked; updateRowSelection(checkbox.closest('tr'), checked); }); }); } const selectAllArchived = document.getElementById('voice-archive-select-all'); if (selectAllArchived) { selectAllArchived.addEventListener('change', event => { const checked = event.target.checked; getVoiceRowCheckboxes('archived').forEach(checkbox => { checkbox.checked = checked; updateRowSelection(checkbox.closest('tr'), checked); }); }); } const batchExportBtn = document.getElementById('voice-batch-export-btn'); if (batchExportBtn) { batchExportBtn.addEventListener('click', () => handleBatchExport('active')); } const batchArchiveBtn = document.getElementById('voice-batch-archive-btn'); if (batchArchiveBtn) { batchArchiveBtn.addEventListener('click', () => handleBatchArchive('active')); } const batchDeleteBtn = document.getElementById('voice-batch-delete-btn'); if (batchDeleteBtn) { batchDeleteBtn.addEventListener('click', () => handleBatchDelete('active')); } const archiveExportBtn = document.getElementById('voice-archive-export-btn'); if (archiveExportBtn) { archiveExportBtn.addEventListener('click', () => handleBatchExport('archived')); } const archiveRestoreBtn = document.getElementById('voice-archive-restore-btn'); if (archiveRestoreBtn) { archiveRestoreBtn.addEventListener('click', () => handleBatchArchive('archived')); } const archiveDeleteBtn = document.getElementById('voice-archive-delete-btn'); if (archiveDeleteBtn) { archiveDeleteBtn.addEventListener('click', () => handleBatchDelete('archived')); } // Table row actions (delegated) const tbody = document.getElementById('chatterbox-voice-list'); if (tbody) { tbody.addEventListener('click', handleVoiceTableAction); tbody.addEventListener('change', event => { const target = event.target; if (!(target instanceof HTMLInputElement)) return; if (!target.classList.contains('voice-select-checkbox')) return; updateRowSelection(target.closest('tr'), target.checked); }); } const archiveBody = document.getElementById('chatterbox-voice-archive-list'); if (archiveBody) { archiveBody.addEventListener('click', handleVoiceTableAction); archiveBody.addEventListener('change', event => { const target = event.target; if (!(target instanceof HTMLInputElement)) return; if (!target.classList.contains('voice-select-checkbox')) return; updateRowSelection(target.closest('tr'), target.checked); }); } } async function loadExternalVoices() { const btn = document.getElementById('load-external-voices-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } try { const response = await fetch('/api/external-voices'); const data = await response.json(); if (data.success) { externalVoices = data.voices || []; showToast(`Loaded ${externalVoices.length} external voices`, 'success'); renderChatterboxVoiceList(); } else { throw new Error(data.error || 'Failed to load external voices'); } } catch (error) { console.error('Failed to load external voices:', error); showToast(error.message || 'Failed to load external voices', 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = 'Load External Voices'; } } } async function handleVoiceTableAction(event) { const btn = event.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const row = btn.closest('tr'); const voiceId = row?.dataset.voiceId; const source = row?.dataset.source; if (action === 'preview') { if (source === 'external') { const shortName = voiceId.replace('external:', ''); previewExternalVoice(shortName, btn); } else { toggleChatterboxVoicePreview(voiceId, btn); } } else if (action === 'download') { const shortName = btn.dataset.voiceId; await downloadExternalVoice(shortName, btn); } else if (action === 'export') { await handleRowExport(voiceId); } else if (action === 'archive') { await handleRowArchive(voiceId, true); } else if (action === 'unarchive') { await handleRowArchive(voiceId, false); } else if (action === 'delete') { await handleRowDelete(voiceId); } else if (action === 'edit-meta') { await editVoiceMetadata(voiceId); } } async function handleBatchExport(scope) { const voiceIds = Array.from(getSelectionSet(scope)); if (!voiceIds.length) return; try { await exportVoicesBatch(voiceIds); } catch (error) { console.error('Batch export failed', error); showToast(error.message || 'Export failed', 'error'); } } async function handleBatchArchive(scope) { const voiceIds = Array.from(getSelectionSet(scope)); if (!voiceIds.length) return; const archived = scope === 'active'; try { await applyArchiveState(voiceIds, archived); voiceIds.forEach(id => getSelectionSet(scope).delete(id)); await loadChatterboxVoices(); renderChatterboxVoiceList(); showToast(archived ? 'Voices archived.' : 'Voices restored.', 'success'); } catch (error) { console.error('Archive update failed', error); showToast(error.message || 'Archive update failed', 'error'); } } async function handleBatchDelete(scope) { const voiceIds = Array.from(getSelectionSet(scope)); if (!voiceIds.length) return; const confirmed = confirm(`Delete ${voiceIds.length} voice sample${voiceIds.length === 1 ? '' : 's'}? This cannot be undone.`); if (!confirmed) return; try { await deleteVoicesBatch(voiceIds); voiceIds.forEach(id => getSelectionSet(scope).delete(id)); await loadChatterboxVoices(); renderChatterboxVoiceList(); showToast('Voices deleted.', 'success'); } catch (error) { console.error('Batch delete failed', error); showToast(error.message || 'Delete failed', 'error'); } } async function handleRowExport(voiceId) { if (!voiceId) return; try { await exportVoicesBatch([voiceId]); } catch (error) { console.error('Export failed', error); showToast(error.message || 'Export failed', 'error'); } } async function handleRowArchive(voiceId, archived) { if (!voiceId) return; try { await applyArchiveState([voiceId], archived); selectedVoiceIds.delete(voiceId); selectedArchivedVoiceIds.delete(voiceId); await loadChatterboxVoices(); renderChatterboxVoiceList(); showToast(archived ? 'Voice archived.' : 'Voice restored.', 'success'); } catch (error) { console.error('Archive update failed', error); showToast(error.message || 'Archive update failed', 'error'); } } async function handleRowDelete(voiceId) { if (!voiceId) return; const confirmed = confirm('Delete this voice sample? This cannot be undone.'); if (!confirmed) return; try { await deleteVoicesBatch([voiceId]); selectedVoiceIds.delete(voiceId); selectedArchivedVoiceIds.delete(voiceId); await loadChatterboxVoices(); renderChatterboxVoiceList(); showToast('Voice deleted.', 'success'); } catch (error) { console.error('Delete failed', error); showToast(error.message || 'Delete failed', 'error'); } } async function downloadExternalVoice(shortName, btn) { if (btn) { btn.disabled = true; btn.textContent = 'Downloading...'; } try { const response = await fetch(`/api/external-voices/${shortName}/download`, { method: 'POST' }); const data = await response.json(); if (data.success) { showToast(`Downloaded ${shortName}`, 'success'); await loadChatterboxVoices(); // Refresh local voices renderChatterboxVoiceList(); } else { throw new Error(data.error || 'Download failed'); } } catch (error) { console.error('Download failed:', error); showToast(error.message || 'Download failed', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Download'; } } } async function previewExternalVoice(shortName, btn) { if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } try { const audio = new Audio(`/api/external-voices/${shortName}/preview`); audio.addEventListener('ended', () => { if (btn) { btn.disabled = false; btn.textContent = 'Play'; } }); audio.addEventListener('error', () => { showToast('Failed to play preview', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Play'; } }); await audio.play(); if (btn) { btn.disabled = false; btn.textContent = 'Stop'; } } catch (error) { console.error('Preview failed:', error); showToast('Failed to play preview', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Play'; } } } async function editVoiceMetadata(voiceId) { const entry = chatterboxVoices.find(v => v.id === voiceId); if (!entry) return; openEditVoiceModal(entry); } function openEditVoiceModal(entry) { const overlay = document.getElementById('edit-voice-modal-overlay'); const modal = document.getElementById('edit-voice-modal'); const idInput = document.getElementById('edit-voice-id'); const nameInput = document.getElementById('edit-voice-name'); const genderSelect = document.getElementById('edit-voice-gender'); const languageSelect = document.getElementById('edit-voice-language'); if (!overlay || !modal) return; // Populate language dropdown with all available locales languageSelect.innerHTML = ''; Object.entries(VM_LOCALE_NAMES).sort((a, b) => a[1].localeCompare(b[1])).forEach(([code, name]) => { const opt = document.createElement('option'); opt.value = code; opt.textContent = name; languageSelect.appendChild(opt); }); // Fill in current values idInput.value = entry.id || ''; nameInput.value = entry.name || ''; genderSelect.value = entry.gender || ''; languageSelect.value = entry.language || ''; // Show modal overlay.classList.remove('hidden'); modal.classList.remove('hidden'); nameInput.focus(); } function closeEditVoiceModal() { const overlay = document.getElementById('edit-voice-modal-overlay'); const modal = document.getElementById('edit-voice-modal'); if (overlay) overlay.classList.add('hidden'); if (modal) modal.classList.add('hidden'); } async function saveEditVoiceModal() { const idInput = document.getElementById('edit-voice-id'); const nameInput = document.getElementById('edit-voice-name'); const genderSelect = document.getElementById('edit-voice-gender'); const languageSelect = document.getElementById('edit-voice-language'); const voiceId = idInput.value; const name = nameInput.value.trim(); const gender = genderSelect.value || null; const language = languageSelect.value || null; if (!voiceId) return; try { const response = await fetch(`/api/chatterbox-voices/${voiceId}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gender, language }) }); const data = await response.json(); if (data.success) { showToast('Voice updated', 'success'); closeEditVoiceModal(); await loadChatterboxVoices(); } else { throw new Error(data.error || 'Update failed'); } } catch (error) { console.error('Update failed:', error); showToast(error.message || 'Update failed', 'error'); } } function initEditVoiceModal() { const overlay = document.getElementById('edit-voice-modal-overlay'); const closeBtn = document.getElementById('edit-voice-close'); const cancelBtn = document.getElementById('edit-voice-cancel'); const saveBtn = document.getElementById('edit-voice-save'); if (overlay) overlay.addEventListener('click', closeEditVoiceModal); if (closeBtn) closeBtn.addEventListener('click', closeEditVoiceModal); if (cancelBtn) cancelBtn.addEventListener('click', closeEditVoiceModal); if (saveBtn) saveBtn.addEventListener('click', saveEditVoiceModal); // Handle Enter key in form const form = document.getElementById('edit-voice-form'); if (form) { form.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); saveEditVoiceModal(); } }); } } function summarizeFileSize(bytes) { if (typeof bytes !== 'number' || Number.isNaN(bytes)) return ''; if (bytes < 1024) { return `${bytes} B`; } const units = ['KB', 'MB', 'GB']; let value = bytes / 1024; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value.toFixed(1)} ${units[unitIndex]}`; } function escapeHtml(value = '') { const div = document.createElement('div'); div.textContent = value; return div.innerHTML; } async function renameChatterboxVoice(voiceId) { const entry = chatterboxVoices.find(item => item.id === voiceId); if (!entry) return; const nextName = prompt('Rename voice', entry.name || ''); if (!nextName) { return; } const trimmed = nextName.trim(); if (!trimmed || trimmed === entry.name) { return; } try { const response = await fetch(`/api/chatterbox-voices/${voiceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: trimmed }), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to rename voice'); } showToast('Voice renamed.', 'success'); await loadChatterboxVoices(); } catch (error) { console.error('Failed to rename voice', error); showToast(error.message || 'Failed to rename voice', 'error'); } } async function deleteChatterboxVoice(voiceId) { const entry = chatterboxVoices.find(item => item.id === voiceId); if (!entry) return; const confirmed = confirm(`Delete Chatterbox voice "${entry.name}"? This cannot be undone.`); if (!confirmed) return; try { const response = await fetch(`/api/chatterbox-voices/${voiceId}`, { method: 'DELETE', }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to delete voice'); } showToast('Voice deleted.', 'success'); await loadChatterboxVoices(); } catch (error) { console.error('Failed to delete voice', error); showToast(error.message || 'Failed to delete voice', 'error'); } } async function copyChatterboxVoicePath(voiceId) { const entry = chatterboxVoices.find(item => item.id === voiceId); if (!entry) return; const path = entry.prompt_path || entry.file_name; if (!path) { showToast('Voice file path unavailable.', 'warning'); return; } try { await navigator.clipboard.writeText(path); showToast('Path copied to clipboard.', 'success'); } catch (error) { console.error('Clipboard copy failed', error); showToast('Unable to copy path. Copy it manually from the list.', 'warning'); } } function toggleChatterboxVoicePreview(voiceId, triggerButton) { if (!voiceId) return; if (!window.chatterboxPreviewController) { showToast('Preview controls are still loading. Try again shortly.', 'warning'); return; } window.chatterboxPreviewController.toggleById(voiceId, triggerButton); } function emitVoicesUpdated() { const detail = { voices: voiceData, samplesReady, customVoiceMap: window.customVoiceMap, pocketTtsVoices: window.availablePocketTtsVoices, }; window.dispatchEvent(new CustomEvent(VOICES_UPDATED_EVENT, { detail })); } function emitChatterboxVoicesUpdated() { const detail = { voices: chatterboxVoices, }; window.dispatchEvent(new CustomEvent(CHATTERBOX_VOICES_EVENT, { detail })); } function updateCustomVoiceMap(voices) { const map = {}; if (voices && typeof voices === 'object') { Object.values(voices).forEach(config => { if (!config) return; const langCode = config.lang_code; (config.custom_voices || []).forEach(entry => { if (!entry || !entry.code) return; map[entry.code] = { ...entry, lang_code: entry.lang_code || langCode, }; }); }); } window.customVoiceMap = map; } async function loadCustomVoices() { try { const response = await fetch('/api/custom-voices'); const data = await response.json(); if (data.success) { customVoices = data.voices || []; renderCustomVoices(); } else { showToast(data.error || 'Unable to load custom voices', 'error'); } } catch (error) { console.error('Failed to load custom voices', error); showToast('Failed to load custom voices', 'error'); } } function renderCustomVoices() { const container = document.getElementById('custom-voices-list'); if (!container) return; if (!customVoices.length) { container.innerHTML = '

You haven’t created any blends yet. Click “New Custom Voice” to get started.

'; return; } container.innerHTML = ''; customVoices.forEach(entry => { const card = document.createElement('div'); card.className = 'custom-voice-card'; card.dataset.voiceCode = entry.code; card.innerHTML = `
${entry.name}
Lang ${entry.lang_code?.toUpperCase() ?? ''}
${renderComponentList(entry.components)}
${entry.code} ${entry.updated_at ? `Updated ${new Date(entry.updated_at).toLocaleString()}` : ''}
`; card.querySelector('[data-action="edit"]').addEventListener('click', () => openCustomVoiceModal(entry)); card.querySelector('[data-action="delete"]').addEventListener('click', () => deleteCustomVoice(entry)); container.appendChild(card); }); } function renderComponentList(components = []) { if (!components.length) { return 'No components defined.'; } return components.map(comp => { const weight = Number(comp.weight ?? 1).toFixed(2).replace(/\.00$/, ''); return `
${comp.voice} x${weight}
`; }).join(''); } function setupCustomVoiceModal() { const overlay = document.getElementById('custom-voice-modal-overlay'); const modal = document.getElementById('custom-voice-modal'); const openBtn = document.getElementById('create-custom-voice-btn'); const closeBtn = document.getElementById('custom-voice-modal-close'); const cancelBtn = document.getElementById('custom-voice-cancel'); const saveBtn = document.getElementById('custom-voice-save'); const addComponentBtn = document.getElementById('add-component-btn'); if (!overlay || !modal) return; function closeModal() { overlay.classList.add('hidden'); modal.classList.add('hidden'); modal.dataset.editCode = ''; document.getElementById('custom-voice-form').reset(); const rows = document.getElementById('custom-voice-components'); rows.innerHTML = ''; } function openModal(entry = null) { overlay.classList.remove('hidden'); modal.classList.remove('hidden'); const title = document.getElementById('custom-voice-modal-title'); const nameInput = document.getElementById('custom-voice-name'); const langSelect = document.getElementById('custom-voice-lang'); const notesInput = document.getElementById('custom-voice-notes'); const rows = document.getElementById('custom-voice-components'); rows.innerHTML = ''; if (entry) { modal.dataset.editCode = entry.code; title.textContent = 'Edit Custom Voice'; nameInput.value = entry.name || ''; langSelect.value = entry.lang_code || 'a'; notesInput.value = entry.notes || ''; (entry.components || []).forEach(component => addComponentRow(component)); } else { modal.dataset.editCode = ''; title.textContent = 'Create Custom Voice'; nameInput.value = ''; langSelect.value = 'a'; notesInput.value = ''; addComponentRow(); } } function addComponentRow(component = null) { const rows = document.getElementById('custom-voice-components'); const row = document.createElement('div'); row.className = 'component-row'; const voiceSelect = buildVoiceSelect(component?.voice, document.getElementById('custom-voice-lang').value); voiceSelect.classList.add('component-voice-select'); const weightInput = document.createElement('input'); weightInput.type = 'number'; weightInput.min = '0.1'; weightInput.step = '0.1'; weightInput.value = component?.weight ?? 1; weightInput.classList.add('component-weight-input'); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'remove-component'; removeBtn.innerHTML = '×'; removeBtn.addEventListener('click', () => { if (rows.children.length > 1) { row.remove(); } else { showToast('Need at least one component.', 'warning'); } }); row.appendChild(voiceSelect); row.appendChild(weightInput); row.appendChild(removeBtn); rows.appendChild(row); } function buildVoiceSelect(selectedVoice = '', langCode = 'a') { const select = document.createElement('select'); const voices = getVoicesForLang(langCode); voices.forEach(voice => { const option = document.createElement('option'); option.value = voice; option.textContent = voice; if (voice === selectedVoice) { option.selected = true; } select.appendChild(option); }); return select; } function getVoicesForLang(langCode = 'a') { if (!window.availableVoices) return []; const entry = Object.values(window.availableVoices).find(cfg => cfg.lang_code === langCode); return entry ? entry.voices : []; } openBtn?.addEventListener('click', () => openModal()); closeBtn?.addEventListener('click', closeModal); cancelBtn?.addEventListener('click', closeModal); overlay?.addEventListener('click', event => { if (event.target === overlay) closeModal(); }); addComponentBtn?.addEventListener('click', () => addComponentRow()); document.getElementById('custom-voice-lang')?.addEventListener('change', event => { const rows = document.querySelectorAll('.component-row select'); rows.forEach(select => { const value = select.value; const newSelect = buildVoiceSelect(value, event.target.value); newSelect.className = select.className; select.replaceWith(newSelect); }); }); saveBtn?.addEventListener('click', async () => { const form = document.getElementById('custom-voice-form'); const name = document.getElementById('custom-voice-name').value.trim(); const lang = document.getElementById('custom-voice-lang').value; const notes = document.getElementById('custom-voice-notes').value.trim(); const components = Array.from(document.querySelectorAll('.component-row')).map(row => ({ voice: row.querySelector('select').value, weight: parseFloat(row.querySelector('.component-weight-input').value || 1), })); if (!name) { showToast('Name is required.', 'warning'); return; } if (!components.length) { showToast('Add at least one component.', 'warning'); return; } const payload = { name, lang_code: lang, notes, components }; const isEdit = Boolean(modal.dataset.editCode); const url = isEdit ? `/api/custom-voices/${modal.dataset.editCode}` : '/api/custom-voices'; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to save custom voice'); } showToast(isEdit ? 'Custom voice updated.' : 'Custom voice created!', 'success'); closeModal(); await loadCustomVoices(); await loadVoices(); } catch (error) { console.error('Failed to save custom voice', error); showToast(error.message || 'Failed to save custom voice.', 'error'); } }); function openCustomVoiceModal(entry) { openModal(entry); } function deleteCustomVoice(entry) { if (!entry) return; if (!confirm(`Delete custom voice "${entry.name}"? This cannot be undone.`)) { return; } fetch(`/api/custom-voices/${entry.code}`, { method: 'DELETE', }) .then(res => res.json()) .then(data => { if (!data.success) { throw new Error(data.error || 'Failed to delete custom voice'); } showToast('Custom voice deleted.', 'success'); loadCustomVoices(); loadVoices(); }) .catch(err => { console.error('Delete custom voice failed', err); showToast(err.message || 'Failed to delete custom voice', 'error'); }); } window.openCustomVoiceModal = openCustomVoiceModal; window.deleteCustomVoice = deleteCustomVoice; window.addComponentRow = addComponentRow; } function summarizeVoiceList(list, max = 5) { if (!Array.isArray(list) || list.length === 0) { return 'None'; } const uniqueVoices = [...new Set(list)]; const shown = uniqueVoices.slice(0, max); const remainder = uniqueVoices.length - shown.length; return remainder > 0 ? `${shown.join(', ')} +${remainder} more` : shown.join(', '); } function updateSampleStatus(data) { const statusContainer = document.getElementById(sampleStatusId); const buttonContainer = document.getElementById('voice-sample-controls'); if (!statusContainer || !buttonContainer) { return; } const failedList = Array.isArray(data.failed) ? data.failed : lastFailedSamples; if (Array.isArray(data.failed)) { lastFailedSamples = data.failed; } const missingList = Array.isArray(data.missing_samples) ? data.missing_samples : []; const missingCount = missingList.length; const failedCount = failedList.length; const totalVoices = data.total_unique_voices || 0; const generatedCount = data.sample_count || 0; let summaryMessage = ''; let statusClass = 'info'; const detailMessages = []; if (missingCount === 0 && failedCount === 0 && generatedCount > 0) { summaryMessage = `All ${generatedCount} voice previews are ready.`; statusClass = 'success'; buttonContainer.style.display = 'none'; } else { const missingSummary = missingCount > 0 ? `${missingCount} of ${totalVoices} voices still need previews` : 'Some voices are ready to preview'; summaryMessage = missingSummary; if (missingCount > 0) { detailMessages.push(`Missing previews: ${summarizeVoiceList(missingList)}`); } if (failedCount > 0) { const failedNames = failedList .map(item => (typeof item === 'string' ? item : item.voice)) .filter(Boolean); detailMessages.push(`Failed to generate: ${summarizeVoiceList(failedNames)}`); statusClass = 'warning'; } else if (missingCount > 0) { statusClass = 'warning'; } else { statusClass = 'info'; } buttonContainer.style.display = 'flex'; } statusContainer.className = `sample-status ${statusClass}`.trim(); statusContainer.innerHTML = `
${summaryMessage}
${detailMessages.length ? `
${detailMessages.join('
')}
` : ''} `; } function displaySampleError(message) { const statusContainer = document.getElementById(sampleStatusId); const buttonContainer = document.getElementById('voice-sample-controls'); if (statusContainer) { statusContainer.textContent = message; statusContainer.className = 'sample-status error'; } if (buttonContainer) { buttonContainer.style.display = 'flex'; } } // Display voice library function displayVoiceLibrary(voices) { const container = document.getElementById('voice-library'); container.innerHTML = ''; const languageNames = { 'american_english': '🇺🇸 American English', 'british_english': '🇬🇧 British English', 'spanish': '🇪🇸 Spanish', 'french': '🇫🇷 French', 'hindi': '🇮🇳 Hindi', 'japanese': '🇯🇵 Japanese', 'chinese': '🇨🇳 Chinese', 'brazilian_portuguese': '🇧🇷 Brazilian Portuguese', }; for (const [key, config] of Object.entries(voices)) { const category = document.createElement('div'); category.className = 'voice-category'; const title = document.createElement('h3'); title.textContent = languageNames[key] || key; category.appendChild(title); const list = document.createElement('ul'); list.className = 'voice-list'; config.voices.forEach(voice => { const item = document.createElement('li'); item.className = 'voice-item'; item.dataset.voice = voice; item.dataset.langCode = config.lang_code; const samplePath = (config.samples && config.samples[voice]) || null; if (samplePath) { item.classList.add('has-preview'); item.dataset.samplePath = samplePath; } const friendlyName = voice.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const info = document.createElement('div'); info.className = 'voice-info'; info.innerHTML = ` ${friendlyName} ${voice} `; item.appendChild(info); const status = document.createElement('div'); status.className = 'voice-status'; status.textContent = samplePath ? 'Preview ready' : 'Preview unavailable'; if (!samplePath) { status.classList.add('muted'); } item.appendChild(status); item.addEventListener('click', () => { playVoicePreview(voice, config.lang_code, samplePath, item); }); list.appendChild(item); }); category.appendChild(list); container.appendChild(category); } } async function generateSamples(overwrite = false) { const button = document.getElementById(generateSamplesBtnId); const regenButton = document.getElementById(regenerateSamplesBtnId); const statusContainer = document.getElementById(sampleStatusId); if (!button || !statusContainer) { return; } button.disabled = true; button.textContent = overwrite ? 'Regenerating samples…' : 'Generating samples…'; if (regenButton) { regenButton.disabled = true; } statusContainer.textContent = 'Generating preview samples, please wait…'; statusContainer.className = 'sample-status info'; try { const response = await fetch('/api/voices/samples', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ overwrite }) }); const data = await response.json(); if (data.success) { samplesReady = data.samples_ready; lastFailedSamples = Array.isArray(data.failed) ? data.failed : []; if (data.voices) { voiceData = data.voices; window.availableVoices = data.voices; updateCustomVoiceMap(data.voices); updateSampleStatus(data); displayVoiceLibrary(data.voices); emitVoicesUpdated(); } else { await loadVoices(); } const failedCount = data.failed ? data.failed.length : 0; const generatedCount = data.generated ? data.generated.length : 0; if (failedCount > 0) { showToast(`${generatedCount} previews generated, ${failedCount} failed. Check status for details.`, 'info'); } else { showToast('Voice previews generated successfully!', 'success'); } } else { throw new Error(data.error || 'Unknown error'); } } catch (error) { console.error('Error generating voice samples:', error); showToast('Failed to generate voice samples: ' + error.message, 'error'); displaySampleError('Failed to generate voice samples. Please check the server logs.'); } finally { const refreshedButton = document.getElementById(generateSamplesBtnId); const refreshedRegenButton = document.getElementById(regenerateSamplesBtnId); if (refreshedButton) { refreshedButton.disabled = false; refreshedButton.textContent = 'Generate Voice Previews'; } if (refreshedRegenButton) { refreshedRegenButton.disabled = false; } } } function showToast(message, type) { if (window.showNotification) { window.showNotification(message, type); } else { alert(message); } } // Play voice preview using generated samples function playVoicePreview(voice, langCode, samplePath, listItem) { if (!samplePath) { alert(`Preview not available for ${voice}. Click "Generate Voice Previews" to create samples.`); return; } if (currentPreviewAudio) { currentPreviewAudio.pause(); currentPreviewAudio.currentTime = 0; if (currentPreviewItem) { currentPreviewItem.classList.remove('playing'); } } if (!audioPreviewCache[voice]) { audioPreviewCache[voice] = new Audio(samplePath); } const audio = audioPreviewCache[voice]; currentPreviewAudio = audio; currentPreviewItem = listItem; audio.currentTime = 0; audio.play().then(() => { listItem.classList.add('playing'); }).catch(err => { console.error('Error playing preview:', err); alert('Unable to play preview. See console for details.'); }); audio.onended = () => { listItem.classList.remove('playing'); currentPreviewAudio = null; currentPreviewItem = null; }; } // Get voice info function getVoiceInfo(voiceName) { if (!voiceData) return null; for (const [key, config] of Object.entries(voiceData)) { if (config.voices.includes(voiceName)) { return { language: key, lang_code: config.lang_code, voice: voiceName }; } } return null; }