tts-api / static /js /app.js
gavanduffy
Port pocket-tts to supertonic3: 44100 Hz, 10 voices, 31 languages
d715e26
Raw
History Blame Contribute Delete
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();
});