Spaces:
Sleeping
Sleeping
| 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 = '<div class="list-item"><strong>No live turns yet</strong><span>Start the interpreter and speak to see rolling translated output.</span></div>'; | |
| return; | |
| } | |
| for (const item of items.slice(-8).reverse()) { | |
| const entry = document.createElement("div"); | |
| entry.className = "list-item"; | |
| entry.innerHTML = ` | |
| <strong>${item.title}</strong> | |
| <span>${item.body}</span> | |
| `; | |
| 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(); | |
| }); | |