| |
|
|
| 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; |
|
|
| |
| 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 (_) { } |
|
|
| setTimeout(pollWarmup, 2500); |
| } |
|
|
| pollWarmup(); |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| 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(); |
|
|
| |
| 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.'); |
| } |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| playBtn.addEventListener('click', () => { |
| if (!currentAudioToken) return; |
| if (audioPlayer) { audioPlayer.pause(); audioPlayer = null; } |
|
|
| audioPlayer = new Audio(`/api/audio/${currentAudioToken}`); |
| audioPlayer.play(); |
|
|
| playBtn.innerHTML = ` |
| <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"> |
| <rect x="5" y="3" width="4" height="18" rx="1"/> |
| <rect x="15" y="3" width="4" height="18" rx="1"/> |
| </svg> Playing…`; |
|
|
| audioPlayer.onended = resetPlayBtn; |
| audioPlayer.onerror = resetPlayBtn; |
| }); |
|
|
| function resetPlayBtn() { |
| playBtn.innerHTML = ` |
| <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"> |
| <polygon points="5,3 19,12 5,21"/> |
| </svg> Play audio`; |
| } |
|
|
| |
| 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(); } |
| }); |
|
|
| |
| 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'; |
| } |
|
|