document.addEventListener('DOMContentLoaded', () => { const HF_API = "https://bilalrhch-arabic-tts-api.hf.space"; // ───────────────────────────────────────────── // Voice definitions // Add more voices here by uploading .wav + .txt to the HF /voices folder // ───────────────────────────────────────────── const availableVoices = [ { id: "Auto", name: "تلقائي", nameEn: "Auto", gender: "ذكاء اصطناعي", color: "#4A90E2" }, { id: "tariq", name: "طارق", nameEn: "Tariq", gender: "رجل", color: "#10B981" }, { id: "omar", name: "عمر", nameEn: "Omar", gender: "رجل", color: "#F59E0B" }, { id: "layla", name: "ليلى", nameEn: "Layla", gender: "امرأة", color: "#E11D48" }, { id: "nour", name: "نور", nameEn: "Nour", gender: "امرأة", color: "#8B5CF6" }, ]; let selectedVoiceId = "Auto"; let previewAudio = null; let playingVoiceId = null; // ───────────────────────────────────────────── // Theme Toggle // ───────────────────────────────────────────── const themeToggle = document.getElementById('theme-toggle'); const moonIcon = document.getElementById('moon-icon'); const sunIcon = document.getElementById('sun-icon'); const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.setAttribute('data-theme', 'dark'); moonIcon.classList.add('hidden'); sunIcon.classList.remove('hidden'); } themeToggle.addEventListener('click', () => { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; if (isDark) { document.documentElement.removeAttribute('data-theme'); localStorage.setItem('theme', 'light'); moonIcon.classList.remove('hidden'); sunIcon.classList.add('hidden'); } else { document.documentElement.setAttribute('data-theme', 'dark'); localStorage.setItem('theme', 'dark'); moonIcon.classList.add('hidden'); sunIcon.classList.remove('hidden'); } }); // ───────────────────────────────────────────── // Voice Selector Modal // ───────────────────────────────────────────── const voiceBtn = document.getElementById('custom-voice-btn'); const voiceModal = document.getElementById('voice-selector-modal'); const closeModalBtn = document.getElementById('close-modal-btn'); const voiceListEl = document.getElementById('voice-list-container'); function getInitial(voice) { return voice.name.charAt(0); } function renderVoiceList() { voiceListEl.innerHTML = ''; availableVoices.forEach(voice => { const isActive = voice.id === selectedVoiceId; const item = document.createElement('div'); item.className = 'voice-item' + (isActive ? ' active' : ''); item.dataset.voiceId = voice.id; item.innerHTML = `
${getInitial(voice)}
${voice.name} ${voice.gender}
${voice.id !== 'Auto' ? ` ` : ''}
`; // Select voice on row click (not the preview button) item.addEventListener('click', (e) => { if (e.target.closest('.preview-btn')) return; selectVoice(voice); closeModal(); }); voiceListEl.appendChild(item); }); // Attach preview button handlers voiceListEl.querySelectorAll('.preview-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const vId = btn.dataset.voiceId; togglePreview(vId); }); }); } function selectVoice(voice) { selectedVoiceId = voice.id; // Update trigger button display document.querySelector('#selected-voice-display .avatar').style.backgroundColor = voice.color; document.querySelector('#selected-voice-display .avatar').textContent = getInitial(voice); document.querySelector('#selected-voice-display .voice-name').textContent = voice.name; document.querySelector('#selected-voice-display .voice-gender').textContent = voice.gender; // Re-render to update active checkmark renderVoiceList(); } function openModal() { renderVoiceList(); voiceModal.classList.remove('hidden'); voiceBtn.classList.add('open'); } function closeModal() { voiceModal.classList.add('hidden'); voiceBtn.classList.remove('open'); } voiceBtn.addEventListener('click', () => { if (voiceModal.classList.contains('hidden')) { openModal(); } else { closeModal(); } }); closeModalBtn.addEventListener('click', closeModal); // Close modal when clicking outside document.addEventListener('click', (e) => { if (!voiceModal.classList.contains('hidden') && !voiceBtn.contains(e.target) && !voiceModal.contains(e.target)) { closeModal(); } }); // ───────────────────────────────────────────── // Audio Preview // ───────────────────────────────────────────── function togglePreview(voiceId) { if (playingVoiceId === voiceId) { // Stop the current preview stopPreview(); return; } // Stop previous if any stopPreview(); const url = `${HF_API}/voices/${voiceId}.wav`; previewAudio = new Audio(url); playingVoiceId = voiceId; // Show EQ animation, hide play icon const eq = document.getElementById(`eq-${voiceId}`); const playIcon = document.getElementById(`play-icon-${voiceId}`); if (eq) eq.classList.remove('hidden'); if (playIcon) playIcon.classList.add('hidden'); previewAudio.play().catch(() => { // Voice file not uploaded yet — silently fail stopPreview(); }); previewAudio.addEventListener('ended', stopPreview); } function stopPreview() { if (previewAudio) { previewAudio.pause(); previewAudio.currentTime = 0; previewAudio = null; } if (playingVoiceId) { const eq = document.getElementById(`eq-${playingVoiceId}`); const playIcon = document.getElementById(`play-icon-${playingVoiceId}`); if (eq) eq.classList.add('hidden'); if (playIcon) playIcon.classList.remove('hidden'); playingVoiceId = null; } } // ───────────────────────────────────────────── // TTS Logic // ───────────────────────────────────────────── const synthesizeBtn = document.getElementById('synthesize-btn'); const btnText = synthesizeBtn.querySelector('.btn-text'); const spinner = synthesizeBtn.querySelector('.spinner'); const textInput = document.getElementById('tts-text'); const speedInput = document.getElementById('tts-speed'); const resultContainer = document.getElementById('result-container'); const audioPlayer = document.getElementById('audio-player'); const downloadBtn = document.getElementById('download-btn'); const errorMessage = document.getElementById('error-message'); let currentAudioUrl = null; synthesizeBtn.addEventListener('click', async () => { const text = textInput.value.trim(); if (!text) { showError("الرجاء إدخال النص أولاً"); return; } hideError(); resultContainer.classList.add('hidden'); setLoading(true); stopPreview(); closeModal(); try { const response = await fetch(`${HF_API}/synthesize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text, voice: selectedVoiceId, speed: parseFloat(speedInput.value) || 1.0 }) }); if (!response.ok) { const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || `HTTP Error: ${response.status}`); } const data = await response.json(); const taskId = data.task_id; document.getElementById('progress-container').classList.remove('hidden'); pollTaskStatus(taskId); } catch (error) { console.error("Synthesis error:", error); showError(`حدث خطأ أثناء التوليد: ${error.message}`); setLoading(false); } }); // ───────────────────────────────────────────── // Status Polling // ───────────────────────────────────────────── async function pollTaskStatus(taskId) { try { const statusRes = await fetch(`${HF_API}/status/${taskId}`); if (!statusRes.ok) { if (statusRes.status === 404 || statusRes.status === 503) { throw new Error(`تعذر جلب الحالة (HTTP ${statusRes.status}). الخادم قيد إعادة التشغيل، يرجى المحاولة بعد دقيقة.`); } throw new Error(`Could not fetch status: HTTP ${statusRes.status}`); } const state = await statusRes.json(); document.getElementById('progress-fill').style.width = `${state.progress}%`; let statusText = `جاري المعالجة... ${state.progress}%`; if (state.status === "stitching") statusText = "جاري تجميع الملفات الصوتية..."; document.getElementById('progress-text').textContent = statusText; if (state.status === "completed") { document.getElementById('progress-container').classList.add('hidden'); if (currentAudioUrl && currentAudioUrl.startsWith('blob:')) { URL.revokeObjectURL(currentAudioUrl); } currentAudioUrl = `${HF_API}/${state.download_url}`; audioPlayer.src = currentAudioUrl; resultContainer.classList.remove('hidden'); setLoading(false); audioPlayer.play().catch(e => console.log('Autoplay blocked', e)); } else if (state.status === "failed") { throw new Error(state.error || "Unknown background generation error"); } else { setTimeout(() => pollTaskStatus(taskId), 2000); } } catch (error) { console.error("Polling error:", error); document.getElementById('progress-container').classList.add('hidden'); showError(`انقطع الاتصال أو حدث خطأ: ${error.message}`); setLoading(false); } } // ───────────────────────────────────────────── // Download // ───────────────────────────────────────────── downloadBtn.addEventListener('click', () => { if (!currentAudioUrl) return; const a = document.createElement('a'); a.href = currentAudioUrl; a.download = `OmniVoice_Arabic_${new Date().getTime()}.wav`; document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // ───────────────────────────────────────────── // Utils // ───────────────────────────────────────────── function setLoading(isLoading) { synthesizeBtn.disabled = isLoading; if (isLoading) { btnText.classList.add('hidden'); spinner.classList.remove('hidden'); } else { btnText.classList.remove('hidden'); spinner.classList.add('hidden'); } } function showError(msg) { errorMessage.textContent = msg; errorMessage.classList.remove('hidden'); } function hideError() { errorMessage.classList.add('hidden'); } });