Kenya / static /js /app.js
Walelign's picture
Upload 7 files
088fe97 verified
/* ── 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 = `
<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`;
}
// ── 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';
}