| 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 = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2ea043" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`; |
| 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(); |
| }); |
|
|