document.addEventListener('DOMContentLoaded', async () => { const voiceInput = document.getElementById('voice-input'); const voiceList = document.getElementById('voice-list'); const voiceClearBtn = document.getElementById('voice-clear-btn'); const generateBtn = document.getElementById('generate-btn'); const textInput = document.getElementById('text-input'); const outputSection = document.getElementById('output-section'); const audioPlayer = document.getElementById('audio-player'); const downloadBtn = document.getElementById('download-btn'); const streamToggle = document.getElementById('stream-toggle'); const formatSelect = document.getElementById('format-select'); let availableVoices = []; let selectedVoiceId = null; function updateStreamingAvailability() { const fmt = formatSelect.value; const supportsStreaming = ['wav', 'pcm'].includes(fmt); const infoLabel = document.getElementById('format-info'); if (supportsStreaming) { streamToggle.disabled = false; streamToggle.parentElement.title = ''; if (fmt === 'pcm') { infoLabel.textContent = "Streaming is available for Raw PCM. Note: This format creates a specialized raw stream that will not play in the browser's audio player."; } else { infoLabel.textContent = 'Streaming is available for WAV. The server streams audio chunks for lower latency.'; } } else { streamToggle.disabled = true; streamToggle.checked = false; streamToggle.parentElement.title = 'Streaming is only available for WAV and PCM formats'; if (fmt === 'mp3') { infoLabel.textContent = 'Streaming is not available for MP3 (Server limitation). A full file will be generated and played.'; } else if (['opus', 'aac', 'flac'].includes(fmt)) { infoLabel.textContent = `Streaming is not available for ${fmt.toUpperCase()}. A full file will be generated and played.`; } else { infoLabel.textContent = 'Streaming is not available for this format.'; } } } formatSelect.addEventListener('change', updateStreamingAvailability); updateStreamingAvailability(); async function loadVoices() { try { const res = await fetch('/v1/voices'); const data = await res.json(); availableVoices = []; if (data.data) { data.data.forEach((voice) => { availableVoices.push({ id: voice.id, label: voice.name || voice.id, display: voice.name || voice.id, type: voice.type || 'builtin', }); }); const defaultVoice = availableVoices.find((v) => v.id !== 'custom'); if (defaultVoice) { selectVoice(defaultVoice.id, false); } } } catch (e) { console.error('Failed to list voices:', e); } } function selectVoice(id, closeList = true) { const voice = availableVoices.find((v) => v.id === id); if (!voice) return; selectedVoiceId = voice.id; voiceInput.value = voice.label; const idDisplay = document.getElementById('voice-id-display'); if (idDisplay) { if (id !== 'custom') { const idSpan = idDisplay.querySelector('.voice-id-text'); if (idSpan) { idSpan.textContent = voice.id; } idDisplay.classList.remove('hidden'); } else { idDisplay.classList.add('hidden'); } } voiceClearBtn.disabled = false; if (closeList) hideVoiceList(); } function renderVoiceList(filterText = '') { const normalizedFilter = filterText.trim().toLowerCase(); const fragment = document.createDocumentFragment(); const isDocker = window.SUPERTONIC3_CONFIG?.isDocker || false; const filtered = availableVoices.filter((v) => { if (v.id === 'custom' && isDocker) return false; if (!normalizedFilter) return true; return ( v.id.toLowerCase().includes(normalizedFilter) || v.label.toLowerCase().includes(normalizedFilter) || (v.display && v.display.toLowerCase().includes(normalizedFilter)) ); }); voiceList.innerHTML = ''; if (filtered.length === 0) { const emptyItem = document.createElement('li'); emptyItem.className = 'voice-list-empty'; emptyItem.textContent = 'No matching voices'; voiceList.appendChild(emptyItem); } else { filtered.forEach((voice) => { const item = document.createElement('li'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'voice-list-item'; btn.dataset.voiceId = voice.id; const infoDiv = document.createElement('div'); infoDiv.className = 'voice-info'; const nameSpan = document.createElement('span'); nameSpan.className = 'voice-name'; nameSpan.textContent = voice.display || voice.label; const subSpan = document.createElement('span'); subSpan.className = 'voice-sub'; if (voice.id !== 'custom') { subSpan.textContent = voice.id; } infoDiv.appendChild(nameSpan); if (subSpan.textContent) infoDiv.appendChild(subSpan); const badgeSpan = document.createElement('span'); badgeSpan.className = 'voice-badge'; badgeSpan.textContent = 'Default'; badgeSpan.classList.add('badge-builtin'); btn.appendChild(infoDiv); btn.appendChild(badgeSpan); item.appendChild(btn); fragment.appendChild(item); }); voiceList.appendChild(fragment); } } function showVoiceList() { voiceList.classList.add('show'); renderVoiceList( voiceInput.value === getSelectedVoiceLabel() ? '' : voiceInput.value, ); } function hideVoiceList() { setTimeout(() => { voiceList.classList.remove('show'); }, 150); } function getSelectedVoiceLabel() { const v = availableVoices.find((v) => v.id === selectedVoiceId); return v ? v.label : ''; } voiceInput.addEventListener('focus', () => { voiceInput.select(); showVoiceList(); }); voiceInput.addEventListener('input', () => { voiceClearBtn.disabled = voiceInput.value.length === 0; renderVoiceList(voiceInput.value); voiceList.classList.add('show'); }); voiceInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { voiceInput.value = getSelectedVoiceLabel(); hideVoiceList(); voiceInput.blur(); } else if (e.key === 'Enter') { e.preventDefault(); const { count, firstId } = renderVoiceList(voiceInput.value); if (count >= 1 && firstId) { selectVoice(firstId); voiceInput.blur(); } } }); voiceInput.addEventListener('blur', () => { setTimeout(() => { if (!document.activeElement.classList.contains('voice-list-item')) { const val = voiceInput.value.trim(); if (!val) { voiceInput.value = getSelectedVoiceLabel(); hideVoiceList(); return; } if (val === getSelectedVoiceLabel()) { hideVoiceList(); return; } const exact = availableVoices.find( (v) => v.label.toLowerCase() === val.toLowerCase() || v.id.toLowerCase() === val.toLowerCase(), ); if (exact) { selectVoice(exact.id); } else { const { count, firstId } = renderVoiceList(val); if (count === 1) { selectVoice(firstId); } else { voiceInput.value = getSelectedVoiceLabel(); } } hideVoiceList(); } }, 200); }); voiceList.addEventListener('mousedown', (e) => { const btn = e.target.closest('.voice-list-item'); if (btn) { const id = btn.dataset.voiceId; selectVoice(id); } }); voiceClearBtn.addEventListener('mousedown', (e) => { e.preventDefault(); selectedVoiceId = null; voiceInput.value = ''; voiceInput.focus(); renderVoiceList(''); showVoiceList(); voiceClearBtn.disabled = true; const idDisplay = document.getElementById('voice-id-display'); if (idDisplay) idDisplay.classList.add('hidden'); }); const copyBtn = document.getElementById('voice-id-copy-btn'); if (copyBtn) { copyBtn.addEventListener('click', async () => { const idText = document.querySelector('.voice-id-text')?.textContent; if (idText) { try { await navigator.clipboard.writeText(idText); const originalHTML = copyBtn.innerHTML; copyBtn.innerHTML = ``; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.innerHTML = originalHTML; copyBtn.classList.remove('copied'); }, 1500); } catch (err) { console.error('Failed to copy: ', err); const input = document.createElement('textarea'); input.value = idText; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); } } }); } generateBtn.addEventListener('click', async () => { const text = textInput.value.trim(); if (!text) return alert('Please enter text'); let voice = selectedVoiceId; if (!voice) { const val = voiceInput.value.trim(); const match = availableVoices.find( (v) => (v.label === val || v.id === val), ); if (match) voice = match.id; } if (!voice) return alert('Please choose a valid voice from the list'); const stream = streamToggle.checked; const fmt = formatSelect.value; generateBtn.classList.add('loading'); generateBtn.disabled = true; outputSection.classList.remove('active'); try { const response = await fetch('/v1/audio/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'tts-1', input: text, voice: voice, response_format: fmt, stream: stream, }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || response.statusText); } const blob = await response.blob(); const url = URL.createObjectURL(blob); audioPlayer.src = url; downloadBtn.href = url; downloadBtn.download = `generated_speech.${fmt}`; if (fmt !== 'pcm') { audioPlayer .play() .catch((e) => console.warn('Auto-play blocked or failed:', e)); } outputSection.classList.add('active'); } catch (e) { alert('Error generating speech: ' + e.message); } finally { generateBtn.classList.remove('loading'); generateBtn.disabled = false; } }); await loadVoices(); });