Download static/js/app.js from grimshaw/tts-api: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/grimshaw/tts-api/resolve/main/static/js/app.js
- Command line
-
hf download hf://spaces/grimshaw/tts-api/static/js/app.js
-
curl -L -o app.js https://huggingface.co/spaces/grimshaw/tts-api/resolve/main/static/js/app.js
10.2 kB
| 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(); | |
| }); | |