| document.addEventListener('DOMContentLoaded', async () => { |
| const voiceInput = document.getElementById('voice-input'); |
| const voiceList = document.getElementById('voice-list'); |
| const voiceClearBtn = document.getElementById('voice-clear-btn'); |
| const customVoiceGroup = document.getElementById('custom-voice-group'); |
| const generateBtn = document.getElementById('generate-btn'); |
| const textInput = document.getElementById('text-input'); |
| const voiceFile = document.getElementById('voice-file'); |
| 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', |
| }); |
| }); |
|
|
| |
| availableVoices.push({ |
| id: 'custom', |
| label: 'Custom Voice', |
| display: 'Custom (Upload .wav, .mp3, .flac)...', |
| type: 'manual', |
| }); |
|
|
| |
| 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) { |
| |
| const cleanId = voice.id.replace( |
| /\.(wav|mp3|flac|safetensors)$/i, |
| '', |
| ); |
| idSpan.textContent = cleanId; |
| } |
| idDisplay.classList.remove('hidden'); |
| } else { |
| idDisplay.classList.add('hidden'); |
| } |
| } |
|
|
| |
| voiceClearBtn.disabled = false; |
| if (closeList) hideVoiceList(); |
|
|
| |
| if (id === 'custom') { |
| const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; |
| if (isDocker) { |
| alert('Custom voices are not available in Docker mode.'); |
| |
| const fallbackVoice = availableVoices.find((v) => v.id !== 'custom'); |
| if (fallbackVoice) { |
| selectVoice(fallbackVoice.id || ''); |
| } else { |
| |
| selectedVoiceId = null; |
| voiceInput.value = ''; |
| voiceClearBtn.disabled = true; |
| customVoiceGroup.classList.add('hidden'); |
| } |
| return; |
| } |
| customVoiceGroup.classList.remove('hidden'); |
| document.querySelector('#custom-voice-group label').textContent = |
| 'Absolute Path to Audio File:'; |
| voiceFile.type = 'text'; |
| voiceFile.placeholder = 'C:\\path\\to\\voice.wav'; |
| } else { |
| customVoiceGroup.classList.add('hidden'); |
| } |
| } |
|
|
| function renderVoiceList(filterText = '') { |
| const normalizedFilter = filterText.trim().toLowerCase(); |
| const fragment = document.createDocumentFragment(); |
|
|
| let matchCount = 0; |
| let firstMatchId = null; |
|
|
| const isDocker = window.POCKET_TTS_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) => { |
| matchCount++; |
| if (matchCount === 1) firstMatchId = voice.id; |
|
|
| 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 = ''; |
| } else { |
| subSpan.textContent = voice.id; |
| } |
|
|
| infoDiv.appendChild(nameSpan); |
| if (subSpan.textContent) infoDiv.appendChild(subSpan); |
|
|
| const badgeSpan = document.createElement('span'); |
| badgeSpan.className = 'voice-badge'; |
|
|
| |
| let badgeText = 'Default'; |
| if (voice.type === 'custom') badgeText = 'Custom'; |
| if (voice.type === 'manual') badgeText = 'Upload'; |
|
|
| badgeSpan.textContent = badgeText; |
|
|
| |
| badgeSpan.classList.add( |
| voice.type === 'builtin' ? 'badge-builtin' : 'badge-custom', |
| ); |
|
|
| btn.appendChild(infoDiv); |
| btn.appendChild(badgeSpan); |
|
|
| item.appendChild(btn); |
| fragment.appendChild(item); |
| }); |
| voiceList.appendChild(fragment); |
| } |
|
|
| return { count: matchCount, firstId: firstMatchId }; |
| } |
|
|
| 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(); |
| } else if (count > 0 && 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 isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; |
| const match = availableVoices.find( |
| (v) => |
| (v.label === val || v.id === val) && |
| |
| !(isDocker && v.id === 'custom'), |
| ); |
| if (match) voice = match.id; |
| } |
|
|
| if (!voice) return alert('Please choose a valid voice from the list'); |
|
|
| const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; |
| if (isDocker && voice === 'custom') { |
| return alert( |
| 'The custom voice is not available in Docker mode. Please choose another voice.', |
| ); |
| } |
|
|
| if (voice === 'custom') { |
| voice = voiceFile.value.trim(); |
| if (!voice) return alert('Please enter the path to the voice file.'); |
| } |
|
|
| |
| 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: 'pocket-tts', |
| 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(); |
| }); |
|
|
| |
| |
| |
|
|
| const modelUI = { |
| activeLabel: document.getElementById('active-model-label'), |
| quantizeBadge: document.getElementById('quantize-badge'), |
| sessionBadge: document.getElementById('session-badge'), |
| loadingIndicator: document.getElementById('loading-indicator'), |
| loadingTargetLabel: document.getElementById('loading-target-label'), |
| languageSelect: document.getElementById('language-select'), |
| quantizeToggle: document.getElementById('quantize-toggle'), |
| applyBtn: document.getElementById('apply-model-btn'), |
| nonEnglishWarning: document.getElementById('non-english-warning'), |
| modelPathLockedNotice: document.getElementById('model-path-locked-notice'), |
| sessionOnlyNotice: document.getElementById('session-only-notice'), |
| applyError: document.getElementById('apply-error'), |
| generateBtn: document.getElementById('generate-btn'), |
| }; |
|
|
| let currentModelState = null; |
| let pollTimer = null; |
| let pollDeadline = 0; |
|
|
| function populateLanguageOptions(languages) { |
| if (modelUI.languageSelect.options.length > 0) return; |
| for (const lang of languages) { |
| const opt = document.createElement('option'); |
| opt.value = lang; |
| opt.textContent = lang; |
| modelUI.languageSelect.appendChild(opt); |
| } |
| } |
|
|
| function setHidden(el, hidden) { |
| if (hidden) { el.setAttribute('hidden', ''); } |
| else { el.removeAttribute('hidden'); } |
| } |
|
|
| |
| |
| |
| |
| function effectiveActiveValue(state) { |
| return state.active.value || 'english'; |
| } |
|
|
| function updateUIForState(state) { |
| currentModelState = state; |
| populateLanguageOptions(state.available_languages); |
|
|
| const activeLang = effectiveActiveValue(state); |
|
|
| |
| modelUI.activeLabel.textContent = activeLang; |
| setHidden(modelUI.quantizeBadge, !state.active.quantize); |
| setHidden(modelUI.sessionBadge, !state.differs_from_boot); |
|
|
| |
| if (state.loading && state.loading_target) { |
| modelUI.loadingTargetLabel.textContent = `→ ${state.loading_target.value}`; |
| setHidden(modelUI.loadingIndicator, false); |
| } else { |
| setHidden(modelUI.loadingIndicator, true); |
| } |
|
|
| |
| if (modelUI.languageSelect.value !== activeLang) { |
| modelUI.languageSelect.value = activeLang; |
| } |
| modelUI.quantizeToggle.checked = state.active.quantize; |
|
|
| |
| const locked = state.model_path_locked; |
| modelUI.languageSelect.disabled = locked || state.loading; |
| modelUI.quantizeToggle.disabled = locked || state.loading; |
| setHidden(modelUI.modelPathLockedNotice, !locked); |
|
|
| |
| const selectedLang = modelUI.languageSelect.value; |
| const isEnglishVariant = selectedLang && selectedLang.startsWith('english'); |
| setHidden(modelUI.nonEnglishWarning, locked || isEnglishVariant); |
|
|
| |
| setHidden(modelUI.sessionOnlyNotice, !state.differs_from_boot); |
|
|
| |
| updateApplyButton(); |
|
|
| |
| |
| if (state.last_error) { |
| modelUI.applyError.textContent = state.last_error; |
| setHidden(modelUI.applyError, false); |
| } else { |
| modelUI.applyError.textContent = ''; |
| setHidden(modelUI.applyError, true); |
| } |
|
|
| |
| modelUI.generateBtn.disabled = state.loading; |
| modelUI.generateBtn.title = state.loading ? 'Model is loading…' : ''; |
| } |
|
|
| function updateApplyButton() { |
| if (!currentModelState) return; |
| const { active, model_path_locked, loading } = currentModelState; |
| const activeLang = effectiveActiveValue(currentModelState); |
| const targetLang = modelUI.languageSelect.value; |
| const targetQuantize = modelUI.quantizeToggle.checked; |
| const differs = targetLang !== activeLang || targetQuantize !== active.quantize; |
| modelUI.applyBtn.disabled = loading || model_path_locked || !differs; |
| } |
|
|
| async function fetchModelState() { |
| try { |
| const resp = await fetch('/v1/model'); |
| if (!resp.ok) throw new Error(`GET /v1/model → ${resp.status}`); |
| const state = await resp.json(); |
| updateUIForState(state); |
|
|
| if (!state.loading && pollTimer) { |
| clearInterval(pollTimer); |
| pollTimer = null; |
| } |
| } catch (err) { |
| console.warn('Failed to fetch model state:', err); |
| } |
| } |
|
|
| function startPolling() { |
| if (pollTimer) return; |
| pollDeadline = Date.now() + 120_000; |
| pollTimer = setInterval(() => { |
| if (Date.now() > pollDeadline) { |
| clearInterval(pollTimer); |
| pollTimer = null; |
| modelUI.applyError.textContent = |
| 'Model load timed out after 2 minutes. Check server logs.'; |
| setHidden(modelUI.applyError, false); |
| return; |
| } |
| fetchModelState(); |
| }, 1000); |
| } |
|
|
| async function applyModel() { |
| setHidden(modelUI.applyError, true); |
| modelUI.applyBtn.disabled = true; |
|
|
| try { |
| const resp = await fetch('/v1/model', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ |
| language: modelUI.languageSelect.value, |
| quantize: modelUI.quantizeToggle.checked, |
| }), |
| }); |
| if (resp.status === 202) { |
| startPolling(); |
| |
| fetchModelState(); |
| } else { |
| const body = await resp.json(); |
| modelUI.applyError.textContent = |
| body.error || `Server returned ${resp.status}`; |
| setHidden(modelUI.applyError, false); |
| |
| |
| |
| if (currentModelState) { |
| modelUI.languageSelect.value = |
| effectiveActiveValue(currentModelState); |
| } |
| updateApplyButton(); |
| } |
| } catch (err) { |
| modelUI.applyError.textContent = `Apply failed: ${err.message}`; |
| setHidden(modelUI.applyError, false); |
| updateApplyButton(); |
| } |
| } |
|
|
| modelUI.languageSelect.addEventListener('change', updateApplyButton); |
| modelUI.quantizeToggle.addEventListener('change', updateApplyButton); |
| modelUI.applyBtn.addEventListener('click', applyModel); |
|
|
| |
| fetchModelState().then(() => { |
| if (currentModelState?.loading) startPolling(); |
| }); |
|
|