const LANGUAGES = [ ["en", "English"], ["as", "Assamese"], ["bn", "Bengali"], ["brx", "Bodo"], ["doi", "Dogri"], ["gu", "Gujarati"], ["hi", "Hindi"], ["kn", "Kannada"], ["ks", "Kashmiri"], ["mai", "Maithili"], ["ml", "Malayalam"], ["mr", "Marathi"], ["mni", "Manipuri"], ["ne", "Nepali"], ["or", "Odia"], ["pa", "Punjabi"], ["sa", "Sanskrit"], ["sat", "Santali"], ["sd", "Sindhi"], ["ta", "Tamil"], ["te", "Telugu"], ["ur", "Urdu"], ]; const MAX_RIPPLES = 10; const displayNameInput = document.getElementById("display-name"); const backendUrlInput = document.getElementById("backend-url"); const sourceLangSelect = document.getElementById("source-lang"); const targetLangSelect = document.getElementById("target-lang"); const connectBtn = document.getElementById("connect-btn"); const muteBtn = document.getElementById("mute-btn"); const connectionStatus = document.getElementById("connection-status"); const activityStatus = document.getElementById("activity-status"); const originalText = document.getElementById("original-text"); const translatedText = document.getElementById("translated-text"); const historyEl = document.getElementById("history"); const currentSpeakingStatus = document.getElementById("current-speaking-status"); const translatedStatus = document.getElementById("translated-status"); const logEl = document.getElementById("log"); const backendHealthEl = document.getElementById("backend-health"); const backendModeEl = document.getElementById("backend-mode"); const remoteAudio = document.getElementById("remote-audio"); let mediaRecorder = null; let localStream = null; let isMuted = false; let isConnecting = false; let isRecording = false; let shouldContinueRecording = false; let chunkMs = 4000; let sequenceId = 0; let currentChunkParts = []; let currentStopTimeout = null; let pendingUploads = Promise.resolve(); const urlParams = new URLSearchParams(window.location.search); const initialTargetLang = urlParams.get("targetLang") || "hi"; const initialSourceLang = urlParams.get("sourceLang") || "en"; const initialDisplayName = urlParams.get("displayName") || ""; const initialApiBase = urlParams.get("apiBase") || localStorage.getItem("translator-api-base") || window.location.origin; for (const [code, label] of LANGUAGES) { const sourceOption = document.createElement("option"); sourceOption.value = code; sourceOption.textContent = label; sourceLangSelect.appendChild(sourceOption); const targetOption = document.createElement("option"); targetOption.value = code; targetOption.textContent = label; targetLangSelect.appendChild(targetOption); } sourceLangSelect.value = initialSourceLang; if (!targetLangSelect.querySelector(`option[value="${initialTargetLang}"]`)) { targetLangSelect.value = "hi"; } else { targetLangSelect.value = initialTargetLang; } displayNameInput.value = initialDisplayName || displayNameInput.value; backendUrlInput.value = initialApiBase; renderHistory([]); setConnectedState(false); probeBackend().catch(() => { setActivityStatus("Backend unavailable"); }); initPixelBlastBackground(); function addLog(message) { const entry = document.createElement("div"); entry.className = "log-entry"; entry.textContent = `${new Date().toLocaleTimeString()} - ${message}`; logEl.prepend(entry); } function renderHistory(items) { historyEl.innerHTML = ""; if (!items.length) { historyEl.innerHTML = '
No live turns yetStart the interpreter and speak to see rolling translated output.
'; return; } for (const item of items.slice(-8).reverse()) { const entry = document.createElement("div"); entry.className = "list-item"; entry.innerHTML = ` ${item.title} ${item.body} `; historyEl.appendChild(entry); } } const historyItems = []; function pushHistory(title, body) { historyItems.push({ title, body }); if (historyItems.length > 12) { historyItems.shift(); } renderHistory(historyItems); } function normalizeHttpBase(input) { const raw = (input || "").trim(); if (!raw) { return window.location.origin; } try { return new URL(raw, window.location.origin).origin; } catch { return window.location.origin; } } function getApiBase() { const apiBase = normalizeHttpBase(backendUrlInput.value); backendUrlInput.value = apiBase; return apiBase; } function setActivityStatus(status) { activityStatus.textContent = status; } function setConnectedState(connected) { connectionStatus.textContent = connected ? "Connected" : "Disconnected"; connectBtn.disabled = isConnecting; connectBtn.textContent = connected ? "Stop live interpreter" : "Start live interpreter"; muteBtn.disabled = !connected; muteBtn.textContent = isMuted ? "Unmute microphone" : "Mute microphone"; if (!connected && !isConnecting) { setActivityStatus("Idle"); } } async function probeBackend() { const apiBase = getApiBase(); backendHealthEl.textContent = "Checking..."; backendModeEl.textContent = "Unknown"; const response = await fetch(`${apiBase}/health`, { headers: { Accept: "application/json" }, }); if (!response.ok) { throw new Error(`Health check failed with ${response.status}`); } const payload = await response.json(); chunkMs = payload.chunkMs || 4000; backendHealthEl.textContent = payload.status === "ok" ? "Online" : "Unavailable"; backendModeEl.textContent = `${payload.translationProvider} / ${payload.models?.enIndic || "pipeline"}`; localStorage.setItem("translator-api-base", apiBase); addLog(`Backend reachable at ${apiBase}. Mode: ${payload.mode}. Chunk size: ${chunkMs} ms.`); return payload; } async function toggleSession() { if (isRecording || isConnecting) { teardownSession(); return; } isConnecting = true; connectBtn.disabled = true; setActivityStatus("Preparing microphone..."); try { await probeBackend(); localStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, echoCancellation: true, autoGainControl: true, }, }); const mimeType = pickRecordingMimeType(); mediaRecorder = mimeType ? new MediaRecorder(localStream, { mimeType }) : new MediaRecorder(localStream); mediaRecorder.addEventListener("dataavailable", event => { if (event.data && event.data.size > 0) { currentChunkParts.push(event.data); } }); mediaRecorder.addEventListener("stop", () => { const chunkBlob = currentChunkParts.length ? new Blob(currentChunkParts, { type: mediaRecorder.mimeType || "audio/webm" }) : null; currentChunkParts = []; if (chunkBlob && chunkBlob.size > 0 && isRecording) { const currentSequence = ++sequenceId; pendingUploads = pendingUploads.then(() => uploadChunk(chunkBlob, currentSequence)).catch(error => { addLog(`Chunk upload failed: ${error.message || error}`); }); } if (shouldContinueRecording && mediaRecorder && mediaRecorder.state === "inactive") { startSingleChunkCapture(); } }); shouldContinueRecording = true; isRecording = true; startSingleChunkCapture(); setConnectedState(true); setActivityStatus("Listening"); currentSpeakingStatus.textContent = "Listening"; translatedStatus.textContent = "Waiting"; addLog(`Live interpreter started with ${chunkMs} ms self-contained chunks.`); } catch (error) { addLog(`Failed to start interpreter: ${error.message || error}`); teardownSession(); } finally { isConnecting = false; setConnectedState(isRecording); } } function startSingleChunkCapture() { if (!mediaRecorder || !shouldContinueRecording || mediaRecorder.state !== "inactive") { return; } currentChunkParts = []; mediaRecorder.start(); currentStopTimeout = window.setTimeout(() => { if (mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); } }, chunkMs); } function pickRecordingMimeType() { const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"]; return candidates.find(type => window.MediaRecorder && MediaRecorder.isTypeSupported(type)) || ""; } async function uploadChunk(blob, currentSequence) { if (!blob || blob.size === 0) { return; } setActivityStatus("Uploading audio chunk..."); currentSpeakingStatus.textContent = "Processing"; translatedStatus.textContent = "Translating"; const formData = new FormData(); formData.append("audio", blob, `chunk-${currentSequence}.webm`); formData.append("source_lang", sourceLangSelect.value); formData.append("target_lang", targetLangSelect.value); formData.append("sequence_id", String(currentSequence)); formData.append("display_name", displayNameInput.value.trim() || "Meeting participant"); const response = await fetch(`${getApiBase()}/api/translate/chunk`, { method: "POST", body: formData, }); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `Chunk translation failed with ${response.status}`); } const payload = await response.json(); originalText.textContent = payload.originalText || "No transcript returned."; translatedText.textContent = payload.translatedText || "No translation returned."; currentSpeakingStatus.textContent = payload.sourceLabel || "Ready"; translatedStatus.textContent = payload.targetLabel || "Ready"; setActivityStatus(payload.error ? "Partial result" : "Listening"); pushHistory("Speaker transcript", payload.originalText || "No transcript returned."); pushHistory("Interpreter output", payload.translatedText || "No translation returned."); addLog(`Chunk ${payload.sequenceId} translated with ${payload.provider}${payload.model ? ` (${payload.model})` : ""}.`); if (payload.error) { addLog(`Pipeline note: ${payload.error}`); } if (payload.audioBase64 && payload.audioMimeType) { playReturnedAudio(payload.audioBase64, payload.audioMimeType); } } function playReturnedAudio(audioBase64, audioMimeType) { const binary = atob(audioBase64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } const blob = new Blob([bytes], { type: audioMimeType }); const objectUrl = URL.createObjectURL(blob); remoteAudio.src = objectUrl; remoteAudio.play().catch(() => { addLog("Browser blocked autoplay for translated audio. Click the page once to enable playback."); }); remoteAudio.onended = () => URL.revokeObjectURL(objectUrl); } function toggleMute() { if (!localStream) { return; } isMuted = !isMuted; for (const track of localStream.getAudioTracks()) { track.enabled = !isMuted; } muteBtn.textContent = isMuted ? "Unmute microphone" : "Mute microphone"; setActivityStatus(isMuted ? "Microphone muted" : "Listening"); } function teardownSession() { shouldContinueRecording = false; if (currentStopTimeout) { window.clearTimeout(currentStopTimeout); currentStopTimeout = null; } if (mediaRecorder && mediaRecorder.state !== "inactive") { try { mediaRecorder.stop(); } catch {} } mediaRecorder = null; currentChunkParts = []; if (localStream) { localStream.getTracks().forEach(track => track.stop()); } localStream = null; isRecording = false; isMuted = false; currentSpeakingStatus.textContent = "Waiting"; translatedStatus.textContent = "Waiting"; setConnectedState(false); } function initPixelBlastBackground() { const canvas = document.getElementById("bg-scene"); if (!canvas) { return; } const gl = canvas.getContext("webgl2", { alpha: true, antialias: true, premultipliedAlpha: false, }); if (!gl) { addLog("WebGL2 background unavailable. Using static backdrop."); return; } const vertexShaderSource = `#version 300 es in vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; const fragmentShaderSource = `#version 300 es precision highp float; uniform vec2 uResolution; uniform float uTime; uniform vec3 uColor; uniform vec2 uClickPos[${MAX_RIPPLES}]; uniform float uClickTimes[${MAX_RIPPLES}]; out vec4 fragColor; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); } float fbm(vec2 p) { float v = 0.0; float a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 1.9; a *= 0.55; } return v; } void main() { vec2 uv = gl_FragCoord.xy / uResolution; vec2 centered = uv - 0.5; centered.x *= uResolution.x / max(uResolution.y, 1.0); float field = fbm(centered * 8.0 + vec2(0.0, uTime * 0.08)); float mask = step(0.52, field + hash(floor(gl_FragCoord.xy / 4.0)) * 0.18); for (int i = 0; i < ${MAX_RIPPLES}; i++) { vec2 pos = uClickPos[i]; if (pos.x < 0.0) continue; vec2 clickUv = (pos / uResolution) - 0.5; clickUv.x *= uResolution.x / max(uResolution.y, 1.0); float t = max(uTime - uClickTimes[i], 0.0); float ring = exp(-pow((distance(centered, clickUv) - 0.4 * t) / 0.12, 2.0)); mask += ring * exp(-1.2 * t) * 1.2; } float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); float alpha = clamp(mask, 0.0, 1.0) * smoothstep(0.0, 0.22, edge) * 0.72; vec3 color = mix(vec3(0.03, 0.05, 0.11), uColor, clamp(field * 1.3, 0.0, 1.0)); fragColor = vec4(color, alpha); } `; function compileShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const message = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(message || "Shader compilation failed."); } return shader; } try { const program = gl.createProgram(); gl.attachShader(program, compileShader(gl.VERTEX_SHADER, vertexShaderSource)); gl.attachShader(program, compileShader(gl.FRAGMENT_SHADER, fragmentShaderSource)); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) || "Program link failed."); } gl.useProgram(program); const positionLocation = gl.getAttribLocation(program, "position"); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); const resolutionLocation = gl.getUniformLocation(program, "uResolution"); const timeLocation = gl.getUniformLocation(program, "uTime"); const colorLocation = gl.getUniformLocation(program, "uColor"); const clickPosLocation = gl.getUniformLocation(program, "uClickPos"); const clickTimesLocation = gl.getUniformLocation(program, "uClickTimes"); const clickPositions = new Float32Array(MAX_RIPPLES * 2).fill(-1); const clickTimes = new Float32Array(MAX_RIPPLES); let clickIndex = 0; const start = performance.now(); function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.floor(window.innerWidth * dpr)); const height = Math.max(1, Math.floor(window.innerHeight * dpr)); canvas.width = width; canvas.height = height; canvas.style.width = `${window.innerWidth}px`; canvas.style.height = `${window.innerHeight}px`; gl.viewport(0, 0, width, height); gl.uniform2f(resolutionLocation, width, height); } function addRipple(event) { const rect = canvas.getBoundingClientRect(); const dpr = canvas.width / rect.width; clickPositions[clickIndex * 2] = (event.clientX - rect.left) * dpr; clickPositions[clickIndex * 2 + 1] = (event.clientY - rect.top) * dpr; clickTimes[clickIndex] = (performance.now() - start) * 0.001; clickIndex = (clickIndex + 1) % MAX_RIPPLES; } resize(); gl.uniform3f(colorLocation, 177 / 255, 158 / 255, 239 / 255); window.addEventListener("resize", resize); window.addEventListener("pointerdown", addRipple, { passive: true }); function render() { gl.uniform1f(timeLocation, (performance.now() - start) * 0.001 * 0.5); gl.uniform2fv(clickPosLocation, clickPositions); gl.uniform1fv(clickTimesLocation, clickTimes); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); requestAnimationFrame(render); } requestAnimationFrame(render); } catch (error) { addLog(`Background init failed: ${error.message || error}`); } } connectBtn.addEventListener("click", toggleSession); muteBtn.addEventListener("click", toggleMute); document.addEventListener("click", () => { remoteAudio.play().catch(() => {}); }, { once: false }); window.addEventListener("beforeunload", () => { teardownSession(); });