add web ui

This commit is contained in:
YuanHui
2025-12-02 12:22:06 +08:00
parent 27d6248833
commit 9d1429eeb2
25 changed files with 3184 additions and 0 deletions

594
web/app.js Normal file
View File

@@ -0,0 +1,594 @@
// Configuration
const API_BASE_URL = window.location.hostname === 'localhost'
? 'http://localhost:8000/api'
: '/api';
// Sample sentences for different languages
const SAMPLE_SENTENCES = {
'ar': ['مرحبا، كيف حالك اليوم؟', 'الطقس جميل اليوم.', 'أتمنى لك يوما سعيدا.'],
'bg': ['Здравейте, как сте днес?', 'Времето е хубаво днес.', 'Желая ви приятен ден.'],
'ca': ['Hola, com estàs avui?', 'El temps és agradable avui.', 'Que tinguis un bon dia.'],
'cs': ['Ahoj, jak se máš dnes?', 'Počasí je dnes pěkné.', 'Přeji ti hezký den.'],
'da': ['Hej, hvordan har du det i dag?', 'Vejret er dejligt i dag.', 'Hav en god dag.'],
'de': ['Hallo, wie geht es dir heute?', 'Das Wetter ist heute schön.', 'Ich wünsche dir einen schönen Tag.'],
'el': ['Γεια σου, πώς είσαι σήμερα;', 'Ο καιρός είναι ωραίος σήμερα.', 'Σου εύχομαι μια όμορφη μέρα.'],
'en': ['Hello, how are you today?', 'The weather is nice today.', 'Have a wonderful day!'],
'es': ['Hola, ¿cómo estás hoy?', 'El clima está agradable hoy.', '¡Que tengas un buen día!'],
'fi': ['Hei, mitä kuuluu tänään?', 'Sää on kaunis tänään.', 'Mukavaa päivää!'],
'fr': ['Bonjour, comment allez-vous aujourd\'hui?', 'Le temps est agréable aujourd\'hui.', 'Passez une bonne journée!'],
'hi': ['नमस्ते, आज आप कैसे हैं?', 'आज मौसम अच्छा है।', 'आपका दिन शुभ हो।'],
'hr': ['Bok, kako si danas?', 'Vrijeme je lijepo danas.', 'Želim ti lijep dan.'],
'hu': ['Szia, hogy vagy ma?', 'Az idő szép ma.', 'Szép napot kívánok!'],
'id': ['Halo, apa kabar hari ini?', 'Cuacanya bagus hari ini.', 'Semoga harimu menyenangkan!'],
'it': ['Ciao, come stai oggi?', 'Il tempo è bello oggi.', 'Ti auguro una buona giornata!'],
'ja': ['こんにちは、今日はお元気ですか?', '今日は天気がいいですね。', '良い一日をお過ごしください。'],
'ko': ['안녕하세요, 오늘은 어떠세요?', '오늘 날씨가 좋네요.', '좋은 하루 보내세요!'],
'nl': ['Hallo, hoe gaat het vandaag?', 'Het weer is mooi vandaag.', 'Fijne dag gewenst!'],
'no': ['Hei, hvordan har du det i dag?', 'Været er fint i dag.', 'Ha en fin dag!'],
'pl': ['Cześć, jak się masz dzisiaj?', 'Pogoda jest ładna dzisiaj.', 'Miłego dnia!'],
'pt': ['Olá, como você está hoje?', 'O tempo está agradável hoje.', 'Tenha um ótimo dia!'],
'ro': ['Bună, ce mai faci astăzi?', 'Vremea este frumoasă astăzi.', 'O zi bună!'],
'ru': ['Привет, как дела сегодня?', 'Погода сегодня хорошая.', 'Хорошего дня!'],
'sk': ['Ahoj, ako sa máš dnes?', 'Počasie je dnes pekné.', 'Prajem ti pekný deň.'],
'sv': ['Hej, hur mår du idag?', 'Vädret är fint idag.', 'Ha en trevlig dag!'],
'th': ['สวัสดี วันนี้เป็นอย่างไรบ้าง?', 'อากาศดีวันนี้.', 'ขอให้มีความสุขตลอดวัน!'],
'tr': ['Merhaba, bugün nasılsın?', 'Hava bugün güzel.', 'İyi günler dilerim!'],
'uk': ['Привіт, як справи сьогодні?', 'Погода сьогодні гарна.', 'Гарного дня!'],
'vi': ['Xin chào, hôm nay bạn thế nào?', 'Thời tiết hôm nay đẹp.', 'Chúc bạn một ngày tốt lành!'],
'zh': ['你好,今天过得怎么样?', '今天天气真好。', '祝你有美好的一天!'],
// Cantonese (yue-CN)
'yue': ['你好,今日點呀?', '今日天氣好好。', '祝你有美好嘅一天!'],
// Wu Chinese (wuu-CN) - uses Simplified Chinese
'wuu': ['侬好,今朝好伐?', '今朝天气老好额。', '祝侬开心!'],
};
// State
let voices = [];
let filteredVoices = [];
let currentAudioUrl = null;
let currentTestAudioUrl = null;
let history = [];
let deferredPrompt = null;
// DOM Elements
const textInput = document.getElementById('textInput');
const charCount = document.getElementById('charCount');
const voiceSelect = document.getElementById('voiceSelect');
const languageSelect = document.getElementById('languageSelect');
const genderFilter = document.getElementById('genderFilter');
const rateSlider = document.getElementById('rateSlider');
const rateValue = document.getElementById('rateValue');
const volumeSlider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const pitchSlider = document.getElementById('pitchSlider');
const pitchValue = document.getElementById('pitchValue');
const generateBtn = document.getElementById('generateBtn');
const clearBtn = document.getElementById('clearBtn');
const testVoiceBtn = document.getElementById('testVoiceBtn');
const progressBar = document.getElementById('progressBar');
const statusMessage = document.getElementById('statusMessage');
const audioSection = document.getElementById('audioSection');
const audioPlayer = document.getElementById('audioPlayer');
const downloadBtn = document.getElementById('downloadBtn');
const historyList = document.getElementById('historyList');
const onlineStatus = document.getElementById('onlineStatus');
const installPrompt = document.getElementById('installPrompt');
const installBtn = document.getElementById('installBtn');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadVoices();
loadHistory();
setupEventListeners();
setupPWA();
updateOnlineStatus();
});
// Event Listeners
function setupEventListeners() {
textInput.addEventListener('input', updateCharCount);
languageSelect.addEventListener('change', () => {
filterVoices();
updateTestVoiceButton();
});
genderFilter.addEventListener('change', filterVoices);
voiceSelect.addEventListener('change', updateTestVoiceButton);
rateSlider.addEventListener('input', (e) => {
const value = e.target.value;
rateValue.textContent = `${value >= 0 ? '+' : ''}${value}%`;
});
volumeSlider.addEventListener('input', (e) => {
const value = e.target.value;
volumeValue.textContent = `${value >= 0 ? '+' : ''}${value}%`;
});
pitchSlider.addEventListener('input', (e) => {
const value = e.target.value;
pitchValue.textContent = `${value >= 0 ? '+' : ''}${value}Hz`;
});
generateBtn.addEventListener('click', generateSpeech);
clearBtn.addEventListener('click', clearForm);
testVoiceBtn.addEventListener('click', testVoice);
downloadBtn.addEventListener('click', downloadAudio);
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
}
// Character count
function updateCharCount() {
const count = textInput.value.length;
charCount.textContent = count;
if (count > 4500) {
charCount.style.color = 'var(--error-color)';
} else if (count > 4000) {
charCount.style.color = 'var(--primary-color)';
} else {
charCount.style.color = '';
}
}
// Load voices from API
async function loadVoices() {
try {
// Add cache busting to ensure fresh data
const response = await fetch(`${API_BASE_URL}/voices?_=${Date.now()}`);
if (!response.ok) throw new Error('Failed to load voices');
voices = await response.json();
populateLanguageSelect();
showStatus('Voices loaded successfully', 'success');
console.log(`Loaded ${voices.length} voices from API`);
} catch (error) {
console.error('Error loading voices:', error);
showStatus('Failed to load voices. Please check the server connection.', 'error');
}
}
// Populate language select
function populateLanguageSelect() {
const languages = [...new Set(voices.map(v => v.Locale))].sort();
languageSelect.innerHTML = '<option value="">Select a language</option>';
languages.forEach(lang => {
const option = document.createElement('option');
option.value = lang;
option.textContent = getLanguageName(lang);
languageSelect.appendChild(option);
});
}
// Get language name from locale
function getLanguageName(locale) {
const names = voices.filter(v => v.Locale === locale);
return names.length > 0 ? names[0].LocaleName : locale;
}
// Filter voices based on selected language and gender
function filterVoices() {
const selectedLanguage = languageSelect.value;
const selectedGender = genderFilter.value;
// If no language is selected, clear voice dropdown
if (!selectedLanguage) {
voiceSelect.innerHTML = '<option value="">Select language first</option>';
filteredVoices = [];
return;
}
// Filter voices by language and gender
filteredVoices = voices.filter(voice => {
const languageMatch = voice.Locale === selectedLanguage;
const genderMatch = !selectedGender || voice.Gender === selectedGender;
return languageMatch && genderMatch;
});
populateVoiceSelect();
}
// Populate voice select
function populateVoiceSelect() {
voiceSelect.innerHTML = '';
if (filteredVoices.length === 0) {
voiceSelect.innerHTML = '<option value="">No voices available for selected filters</option>';
return;
}
// Sort voices alphabetically by LocalName
const sortedVoices = [...filteredVoices].sort((a, b) => {
return a.LocalName.localeCompare(b.LocalName);
});
sortedVoices.forEach(voice => {
const option = document.createElement('option');
option.value = voice.Name;
option.textContent = `${voice.LocalName} (${voice.Gender})`;
voiceSelect.appendChild(option);
});
}
// Generate speech
async function generateSpeech() {
const text = textInput.value.trim();
if (!text) {
showStatus('Please enter some text', 'error');
return;
}
if (text.length > 5000) {
showStatus('Text exceeds maximum length of 5000 characters', 'error');
return;
}
const voice = voiceSelect.value;
if (!voice) {
showStatus('Please select a voice', 'error');
return;
}
const params = {
text: text,
voice: voice,
rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`,
volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`,
pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz`
};
try {
generateBtn.disabled = true;
generateBtn.innerHTML = '<span class="loading"></span> Generating...';
progressBar.style.display = 'block';
hideStatus();
const response = await fetch(`${API_BASE_URL}/synthesize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to generate speech' }));
const errorMsg = error.detail || 'Failed to generate speech';
// Check if it's an invalid voice error
if (errorMsg.includes('No audio') || errorMsg.includes('voice')) {
throw new Error(`Voice error: ${errorMsg}. Try selecting a different voice or refresh the page.`);
}
throw new Error(errorMsg);
}
const blob = await response.blob();
// Clean up previous audio URL
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
}
currentAudioUrl = URL.createObjectURL(blob);
audioPlayer.src = currentAudioUrl;
audioSection.style.display = 'block';
// Add to history
const selectedVoice = voices.find(v => v.Name === voice);
addToHistory({
text: text,
voice: voice,
voiceName: voiceSelect.options[voiceSelect.selectedIndex].text,
locale: selectedVoice ? selectedVoice.Locale : '',
localeName: selectedVoice ? selectedVoice.LocaleName : '',
params: params,
timestamp: new Date().toISOString()
});
showStatus('Speech generated successfully!', 'success');
} catch (error) {
console.error('Error generating speech:', error);
showStatus(error.message, 'error');
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = '<span class="btn-icon">🎵</span> Generate Speech';
progressBar.style.display = 'none';
}
}
// Download audio
function downloadAudio() {
if (!currentAudioUrl) return;
const text = textInput.value.substring(0, 30).replace(/[^a-z0-9]/gi, '_');
const filename = `edge-tts-${text}-${Date.now()}.mp3`;
const a = document.createElement('a');
a.href = currentAudioUrl;
a.download = filename;
a.click();
}
// Update test voice button state
function updateTestVoiceButton() {
const hasVoice = voiceSelect.value && voiceSelect.value !== '';
testVoiceBtn.disabled = !hasVoice;
}
// Get sample sentence for language
function getSampleSentence(locale) {
// Extract language code (e.g., 'en' from 'en-US')
const langCode = locale.split('-')[0];
// Check for exact locale match first (for special cases like yue-CN, wuu-CN)
if (SAMPLE_SENTENCES[locale]) {
const sentences = SAMPLE_SENTENCES[locale];
return sentences[Math.floor(Math.random() * sentences.length)];
}
// Then check for language code match
if (SAMPLE_SENTENCES[langCode]) {
const sentences = SAMPLE_SENTENCES[langCode];
return sentences[Math.floor(Math.random() * sentences.length)];
}
// Default to English
const sentences = SAMPLE_SENTENCES['en'];
return sentences[Math.floor(Math.random() * sentences.length)];
}
// Test voice with sample sentence
async function testVoice() {
const voice = voiceSelect.value;
const selectedLanguage = languageSelect.value;
if (!voice || !selectedLanguage) {
showStatus('Please select a voice first', 'error');
return;
}
// Get sample sentence
const sampleText = getSampleSentence(selectedLanguage);
const params = {
text: sampleText,
voice: voice,
rate: `${rateSlider.value >= 0 ? '+' : ''}${rateSlider.value}%`,
volume: `${volumeSlider.value >= 0 ? '+' : ''}${volumeSlider.value}%`,
pitch: `${pitchSlider.value >= 0 ? '+' : ''}${pitchSlider.value}Hz`
};
try {
testVoiceBtn.disabled = true;
testVoiceBtn.innerHTML = '<span class="loading"></span> Testing...';
const response = await fetch(`${API_BASE_URL}/synthesize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to generate test speech' }));
const errorMsg = error.detail || 'Failed to generate test speech';
// Check if it's an invalid voice error
if (errorMsg.includes('No audio') || errorMsg.includes('voice')) {
throw new Error(`Voice error: ${errorMsg}. This voice may not be available. Try refreshing the page.`);
}
throw new Error(errorMsg);
}
const blob = await response.blob();
// Clean up previous test audio URL
if (currentTestAudioUrl) {
URL.revokeObjectURL(currentTestAudioUrl);
}
currentTestAudioUrl = URL.createObjectURL(blob);
// Play audio automatically
const testAudio = new Audio(currentTestAudioUrl);
testAudio.play();
showStatus(`Testing voice: "${sampleText}"`, 'info');
} catch (error) {
console.error('Error testing voice:', error);
showStatus(error.message, 'error');
} finally {
testVoiceBtn.disabled = false;
testVoiceBtn.innerHTML = '<span class="btn-icon">🎧</span> Test Voice';
}
}
// Clear form
function clearForm() {
textInput.value = '';
updateCharCount();
rateSlider.value = 0;
volumeSlider.value = 0;
pitchSlider.value = 0;
rateValue.textContent = '+0%';
volumeValue.textContent = '+0%';
pitchValue.textContent = '+0Hz';
hideStatus();
}
// History management
function loadHistory() {
const saved = localStorage.getItem('tts_history');
if (saved) {
history = JSON.parse(saved);
renderHistory();
}
}
function saveHistory() {
// Keep only last 10 items
history = history.slice(0, 10);
localStorage.setItem('tts_history', JSON.stringify(history));
}
function addToHistory(item) {
history.unshift(item);
saveHistory();
renderHistory();
}
function renderHistory() {
if (history.length === 0) {
historyList.innerHTML = '<p class="empty-state">No recent generations yet</p>';
return;
}
historyList.innerHTML = '';
history.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'history-item';
const date = new Date(item.timestamp);
const timeAgo = getTimeAgo(date);
const languageInfo = item.localeName ? ` - ${item.localeName}` : '';
div.innerHTML = `
<div class="history-item-header">
<div class="history-item-text" title="${escapeHtml(item.text)}">
${escapeHtml(item.text)}
</div>
<div class="history-item-time">${timeAgo}</div>
</div>
<div class="history-item-voice">${escapeHtml(item.voiceName)}${languageInfo}</div>
<div class="history-item-actions">
<button class="btn btn-primary" onclick="loadHistoryItem(${index})">
Load
</button>
<button class="btn btn-secondary" onclick="deleteHistoryItem(${index})">
Delete
</button>
</div>
`;
historyList.appendChild(div);
});
}
function loadHistoryItem(index) {
const item = history[index];
textInput.value = item.text;
updateCharCount();
// Find the voice in the voices list to get its locale
const voice = voices.find(v => v.Name === item.voice);
if (voice) {
// Set language first
languageSelect.value = voice.Locale;
// Trigger filter to populate voice dropdown
filterVoices();
// Then set the specific voice
voiceSelect.value = item.voice;
}
// Set parameters
if (item.params) {
rateSlider.value = parseInt(item.params.rate);
volumeSlider.value = parseInt(item.params.volume);
pitchSlider.value = parseInt(item.params.pitch);
rateValue.textContent = item.params.rate;
volumeValue.textContent = item.params.volume;
pitchValue.textContent = item.params.pitch;
}
window.scrollTo({ top: 0, behavior: 'smooth' });
showStatus('History item loaded', 'info');
}
function deleteHistoryItem(index) {
history.splice(index, 1);
saveHistory();
renderHistory();
}
// Utilities
function showStatus(message, type = 'info') {
statusMessage.textContent = message;
statusMessage.className = `status-message ${type}`;
}
function hideStatus() {
statusMessage.className = 'status-message';
}
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function updateOnlineStatus() {
if (navigator.onLine) {
onlineStatus.textContent = '● Online';
onlineStatus.className = 'online';
} else {
onlineStatus.textContent = '● Offline';
onlineStatus.className = 'offline';
}
}
// PWA Setup
function setupPWA() {
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
// Install prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installPrompt.style.display = 'inline';
});
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to install prompt: ${outcome}`);
deferredPrompt = null;
installPrompt.style.display = 'none';
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed');
installPrompt.style.display = 'none';
});
}