/* ── Ijwi — app.js (Hugging Face Spaces build) ──────────────────────────── */ const micBtn = document.getElementById('micBtn'); const micHint = document.getElementById('micHint'); const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const transcriptEl = document.getElementById('transcriptEl'); const translationEl = document.getElementById('translationEl'); const playBtn = document.getElementById('playBtn'); const timingStrip = document.getElementById('timingStrip'); const tStt = document.getElementById('tStt'); const tTrans = document.getElementById('tTrans'); const tTts = document.getElementById('tTts'); const tTotal = document.getElementById('tTotal'); const warmupOverlay = document.getElementById('warmupOverlay'); const warmupBar = document.getElementById('warmupBar'); const warmupLabel = document.getElementById('warmupLabel'); let mediaRecorder = null; let audioChunks = []; let isRecording = false; let audioPlayer = null; let currentAudioToken = null; let warmupDone = false; // ── Warmup polling ──────────────────────────────────────────────────────── async function pollWarmup() { try { const res = await fetch('/api/status'); const data = await res.json(); const pct = Math.round((data.loaded / data.total) * 100); warmupBar.style.width = pct + '%'; warmupLabel.textContent = `Loading models… ${data.loaded} / ${data.total}`; if (data.ready) { warmupDone = true; warmupOverlay.classList.add('hidden'); micBtn.disabled = false; setStatus('dot-done', 'Ready — hold the button to speak'); return; } } catch (_) { /* server not up yet, keep polling */ } setTimeout(pollWarmup, 2500); } pollWarmup(); // ── MediaRecorder ───────────────────────────────────────────────────────── async function startRecording() { if (!warmupDone) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioChunks = []; const options = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? { mimeType: 'audio/webm;codecs=opus' } : {}; mediaRecorder = new MediaRecorder(stream, options); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType }); await sendAudio(blob); }; mediaRecorder.start(100); isRecording = true; setRecordingState(); } catch (err) { setError('Microphone access denied. Please allow microphone in your browser.'); } } function stopRecording() { if (mediaRecorder && isRecording) { mediaRecorder.stop(); isRecording = false; setProcessingState(); } } // ── Send to API ─────────────────────────────────────────────────────────── async function sendAudio(blob) { const formData = new FormData(); formData.append('audio', blob, 'recording.webm'); try { const res = await fetch('/api/translate', { method: 'POST', body: formData }); const data = await res.json(); // 503 = models still loading (shouldn't normally happen after warmup) if (res.status === 503 && data.warming) { setError('Still warming up — please wait a moment and try again.'); return; } if (!res.ok || data.error) { setError(data.error || 'Translation failed. Please try again.'); return; } showResult(data); } catch (err) { setError('Network error — check your connection.'); } } // ── Display result ──────────────────────────────────────────────────────── function showResult(data) { transcriptEl.innerHTML = ''; transcriptEl.textContent = data.transcript; translationEl.innerHTML = ''; translationEl.textContent = data.translation; if (data.timing) { const t = data.timing; tStt.textContent = `STT ${t.stt_ms}ms`; tTrans.textContent = `Translation ${t.trans_ms}ms`; tTts.textContent = `TTS ${t.tts_ms}ms`; tTotal.textContent = `Total ${t.total_ms}ms`; timingStrip.style.display = 'flex'; } currentAudioToken = data.audio_token || null; playBtn.disabled = !currentAudioToken; setDoneState(); } // ── Audio playback ──────────────────────────────────────────────────────── playBtn.addEventListener('click', () => { if (!currentAudioToken) return; if (audioPlayer) { audioPlayer.pause(); audioPlayer = null; } audioPlayer = new Audio(`/api/audio/${currentAudioToken}`); audioPlayer.play(); playBtn.innerHTML = ` Playing…`; audioPlayer.onended = resetPlayBtn; audioPlayer.onerror = resetPlayBtn; }); function resetPlayBtn() { playBtn.innerHTML = ` Play audio`; } // ── Input — hold on desktop, tap on mobile ──────────────────────────────── function isTouchDevice() { return window.matchMedia('(hover: none)').matches; } if (isTouchDevice()) { let toggled = false; micBtn.addEventListener('touchstart', (e) => { e.preventDefault(); if (!toggled && !isRecording) { startRecording(); toggled = true; } else if (isRecording) { stopRecording(); toggled = false; } }, { passive: false }); } else { micBtn.addEventListener('mousedown', () => { if (!isRecording) startRecording(); }); micBtn.addEventListener('mouseup', () => { if (isRecording) stopRecording(); }); micBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); }); } document.addEventListener('keydown', (e) => { if (e.code === 'Space' && e.target === document.body && !isRecording) { e.preventDefault(); startRecording(); } }); document.addEventListener('keyup', (e) => { if (e.code === 'Space' && isRecording) { e.preventDefault(); stopRecording(); } }); // ── State helpers ───────────────────────────────────────────────────────── function setStatus(dotClass, text) { statusDot.className = `status-dot ${dotClass}`; statusText.textContent = text; } function setRecordingState() { micBtn.classList.add('recording'); micBtn.classList.remove('processing'); setStatus('dot-recording', 'Listening…'); micHint.textContent = 'Release to translate'; playBtn.disabled = true; } function setProcessingState() { micBtn.classList.remove('recording'); micBtn.classList.add('processing'); setStatus('dot-processing', 'Translating…'); micHint.textContent = 'Processing…'; } function setDoneState() { micBtn.classList.remove('recording', 'processing'); setStatus('dot-done', 'Done — speak again anytime'); micHint.textContent = 'Hold to speak · or press Space'; } function setError(msg) { micBtn.classList.remove('recording', 'processing'); setStatus('dot-error', msg); micHint.textContent = 'Hold to speak · or press Space'; }