expressindia / web /app.js
YuvarajDeveloper's picture
Update AI4Bharat pipeline and UI docs
43f30d0
Raw
History Blame Contribute Delete
17.5 kB
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();
});