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; // The actual value used for generation
// Format & Streaming Logic
function updateStreamingAvailability() {
const fmt = formatSelect.value;
// Server only supports streaming for PCM and WAV currently
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 {
// WAV
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);
// Initialize state
updateStreamingAvailability();
// 1. Load Voices
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, // For search
type: voice.type || 'builtin',
});
});
// Custom option
availableVoices.push({
id: 'custom',
label: 'Custom Voice',
display: 'Custom (Upload .wav, .mp3, .flac)...',
type: 'manual',
});
// Default selection: Prefer first non-custom voice
const defaultVoice = availableVoices.find((v) => v.id !== 'custom');
if (defaultVoice) {
selectVoice(defaultVoice.id, false);
}
}
} catch (e) {
console.error('Failed to list voices:', e);
}
}
// 2. Core Search & Selection Logic
function selectVoice(id, closeList = true) {
const voice = availableVoices.find((v) => v.id === id);
if (!voice) return;
selectedVoiceId = voice.id;
voiceInput.value = voice.label; // Display nice name
// Update ID Display helper
const idDisplay = document.getElementById('voice-id-display');
if (idDisplay) {
if (id !== 'custom') {
const idSpan = idDisplay.querySelector('.voice-id-text');
if (idSpan) {
// Clean extension from ID for cleaner display/copying
const cleanId = voice.id.replace(
/\.(wav|mp3|flac|safetensors)$/i,
'',
);
idSpan.textContent = cleanId;
}
idDisplay.classList.remove('hidden');
} else {
idDisplay.classList.add('hidden');
}
}
// Handle UI state
voiceClearBtn.disabled = false;
if (closeList) hideVoiceList();
// Handle Custom
if (id === 'custom') {
const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
if (isDocker) {
alert('Custom voices are not available in Docker mode.');
// Fallback to the first non-custom voice if available
const fallbackVoice = availableVoices.find((v) => v.id !== 'custom');
if (fallbackVoice) {
selectVoice(fallbackVoice.id || '');
} else {
// No valid fallback; clear selection and hide custom UI
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;
// Better content structure
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 = ''; // No ID for the upload button itself
} else {
subSpan.textContent = voice.id;
}
infoDiv.appendChild(nameSpan);
if (subSpan.textContent) infoDiv.appendChild(subSpan);
const badgeSpan = document.createElement('span');
badgeSpan.className = 'voice-badge';
// Format badge text: "builtin" -> "Default", "custom" -> "Custom"
let badgeText = 'Default';
if (voice.type === 'custom') badgeText = 'Custom';
if (voice.type === 'manual') badgeText = 'Upload';
badgeSpan.textContent = badgeText;
// Add specific class for styling if needed
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() {
// Small delay to allow click events to propagate
setTimeout(() => {
voiceList.classList.remove('show');
}, 150);
}
function getSelectedVoiceLabel() {
const v = availableVoices.find((v) => v.id === selectedVoiceId);
return v ? v.label : '';
}
// Smart Input Handling
voiceInput.addEventListener('focus', () => {
// On focus, if the input value matches the current selection, wipe it to allow fresh search?
// Or keep it? Standard combobox keeps it but selects text.
// Let's select text so user can type over immediately.
voiceInput.select();
showVoiceList();
});
voiceInput.addEventListener('input', () => {
voiceClearBtn.disabled = voiceInput.value.length === 0;
// If user types, we conceptually deselect until they pick or we auto-match
// But strictly clearing selectedVoiceId might be annoying if they just made a typo.
// Let's keep selectedVoiceId as fallback, but filter.
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();
// Auto-select if 1 result
const { count, firstId } = renderVoiceList(voiceInput.value);
if (count === 1 && firstId) {
selectVoice(firstId);
voiceInput.blur();
} else if (count > 0 && firstId) {
// If multiple, maybe select first? Or do nothing?
// User asked: "If I filter so much that there is just 1 result, I still have to select it"
// implies standard Enter behavior triggers selection of top result usually.
selectVoice(firstId);
voiceInput.blur();
}
}
});
// Handle Blur: Auto-select if logic dictates
voiceInput.addEventListener('blur', () => {
// Delay logic slightly to allow Click to happen first
setTimeout(() => {
if (!document.activeElement.classList.contains('voice-list-item')) {
// Validate: Is text a partial match for exactly one voice?
const val = voiceInput.value.trim();
if (!val) {
// Cleared -> maybe clear selection? Or revert?
// Let's revert to last selected for safety unless user explicitly cleared?
// If they cleared, they probably want to clear.
// But we need a voice to generate?
// Let's revert if empty.
voiceInput.value = getSelectedVoiceLabel();
hideVoiceList();
return;
}
// If the text matches the currently selected label, do nothing
if (val === getSelectedVoiceLabel()) {
hideVoiceList();
return;
}
// Try to find a match
// 1. Exact Name Match
const exact = availableVoices.find(
(v) =>
v.label.toLowerCase() === val.toLowerCase() ||
v.id.toLowerCase() === val.toLowerCase(),
);
if (exact) {
selectVoice(exact.id);
} else {
// 2. Single Filter Match
const { count, firstId } = renderVoiceList(val);
if (count === 1) {
selectVoice(firstId);
} else {
// 3. No clean match (0 or >1). Revert to last valid.
// User said "get an error because the value from the search field is taken"
// So passing the raw text is bad. We must force valid selection.
voiceInput.value = getSelectedVoiceLabel();
}
}
hideVoiceList();
}
}, 200);
});
// List Click Handling
voiceList.addEventListener('mousedown', (e) => {
// Use mousedown to trigger before blur
const btn = e.target.closest('.voice-list-item');
if (btn) {
const id = btn.dataset.voiceId;
selectVoice(id);
}
});
voiceClearBtn.addEventListener('mousedown', (e) => {
e.preventDefault(); // Prevent blur on input
selectedVoiceId = null;
voiceInput.value = '';
voiceInput.focus();
renderVoiceList('');
showVoiceList();
voiceClearBtn.disabled = true;
const idDisplay = document.getElementById('voice-id-display');
if (idDisplay) idDisplay.classList.add('hidden');
});
// Copy Button Logic
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;
// Show checkmark
copyBtn.innerHTML = ``;
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
copyBtn.classList.remove('copied');
}, 1500);
} catch (err) {
console.error('Failed to copy: ', err);
// Fallback for non-secure contexts (optional but good for localhost sometimes)
const input = document.createElement('textarea');
input.value = idText;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
}
});
}
// 3. Generate Logic
generateBtn.addEventListener('click', async () => {
const text = textInput.value.trim();
if (!text) return alert('Please enter text');
// Use the ID, not the Input Value
let voice = selectedVoiceId;
// Fallback: If for some reason ID is null but text exists (shouldn't happen with our blur logic), try to resolve
if (!voice) {
// Try to find by name from input
const val = voiceInput.value.trim();
const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
const match = availableVoices.find(
(v) =>
(v.label === val || v.id === val) &&
// In Docker mode, do not allow resolving the special "custom" voice
!(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.');
}
// ... rest of generation logic ...
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);
}
// Currently we always fetch the full blob and play it once ready.
// The `stream` flag is still sent to the server, but client playback
// uses a single blob path for robustness.
const blob = await response.blob();
const url = URL.createObjectURL(blob);
audioPlayer.src = url;
downloadBtn.href = url;
downloadBtn.download = `generated_speech.${fmt}`;
// PCM raw audio usually won't play in standard