const app = document.querySelector("#app"); const toast = document.querySelector("#toast"); const API = "/api/v1"; function showToast(message) { toast.textContent = message; toast.classList.add("show"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove("show"), 3400); } function escapeHtml(value = "") { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); } function lessonId() { const match = location.pathname.match(/^\/lesson\/([^/]+)/); return match && match[1] !== "new" ? match[1] : null; } async function fetchLesson(id) { const response = await fetch(`${API}/sessions/${id}`); if (!response.ok) throw new Error("Lesson not found"); return response.json(); } function setActiveNavigation() { document.querySelectorAll("[data-nav]").forEach((link) => { link.classList.toggle("active", link.dataset.nav === location.pathname); }); } function landingPage() { return `
Meet your adaptive AI teacher

Learn anything.
Understand it deeply.

Gargi listens, explains, draws and adapts in real time—turning every question into a lesson made just for you.

AISTEMART
From quantum physics to poetry—ask freely.
Gargi AI owl
Adapts to your level
Natural voice lessons
One classroom, many ways to learn

Teaching that meets you
where you are.

Speak naturally, watch ideas take shape on the whiteboard, and check your understanding without leaving the lesson.

Talk it through

Ask questions in your own words. Gemini Live listens, understands and responds with a natural teaching voice.

See the idea

Gargi turns explanations into diagrams, equations and step-by-step whiteboard notes.

Make it stick

Quick adaptive quizzes uncover misconceptions and shape the next explanation around you.

`; } function setupPage() { return `
Your next lesson starts here

What are you curious about?

Give Gargi a topic and a little context. Your classroom will be ready in seconds.

`; } function renderBoard(blocks = []) { if (!blocks.length) { return `

Your ideas will appear here

Start the conversation and Gargi will build diagrams, equations and notes around your questions.

`; } return blocks.map((block) => { if (block.type === "bullets") { return `
`; } if (block.type === "mermaid") { return `
Concept diagram${renderMermaidDiagram(block.content)}
`; } if (block.type === "equation") { return renderEquation(block.content); } return `
${escapeHtml(block.content)}
`; }).join(""); } function readLatexGroup(source, start) { if (source[start] !== "{") return null; let depth = 0; for (let index = start; index < source.length; index++) { const char = source[index]; if (char === "{" && source[index - 1] !== "\\") depth++; if (char === "}" && source[index - 1] !== "\\") depth--; if (depth === 0) return {content: source.slice(start + 1, index), end: index + 1}; } return null; } function findLatexFraction(source) { const start = source.indexOf("\\frac"); if (start < 0) return null; let cursor = start + "\\frac".length; while (/\s/.test(source[cursor])) cursor++; const numerator = readLatexGroup(source, cursor); if (!numerator) return null; cursor = numerator.end; while (/\s/.test(source[cursor])) cursor++; const denominator = readLatexGroup(source, cursor); if (!denominator) return null; return {start, end: denominator.end, numerator: numerator.content, denominator: denominator.content}; } function formatLatexInline(source = "") { let output = String(source).trim().replace(/^\$|\$$/g, ""); let textMatch = output.match(/\\text\s*\{/); while (textMatch) { const groupStart = textMatch.index + textMatch[0].lastIndexOf("{"); const group = readLatexGroup(output, groupStart); if (!group) break; output = `${output.slice(0, textMatch.index)}${group.content}${output.slice(group.end)}`; textMatch = output.match(/\\text\s*\{/); } output = output .replace(/\\left|\\right/g, "") .replace(/\\times/g, "×") .replace(/\\cdot/g, "·") .replace(/\\leq?/g, "≤") .replace(/\\geq?/g, "≥") .replace(/\\neq/g, "≠") .replace(/\\approx/g, "≈") .replace(/\\pi/g, "π") .replace(/\\%/g, "%") .replace(/\s+/g, " "); return escapeHtml(output); } function renderEquation(source = "") { const equation = String(source).trim(); const fraction = findLatexFraction(equation); if (!fraction) { return `
${formatLatexInline(equation)}
`; } const before = equation.slice(0, fraction.start).trim(); const after = equation.slice(fraction.end).trim(); return `
${before ? `${formatLatexInline(before)}` : ""} ${formatLatexInline(fraction.numerator)} ${formatLatexInline(fraction.denominator)} ${after ? `${formatLatexInline(after)}` : ""}
`; } function parseMermaidNode(raw = "") { const trimmed = raw.trim(); const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*(?:\[\s*([^\]]+?)\s*\]|\(\(\s*([^)]+?)\s*\)\)|\(\s*([^)]+?)\s*\)|\{\s*([^}]+?)\s*\})?$/); if (!match) return {id: trimmed, label: trimmed.replace(/_/g, " ")}; const [, id, square, circle, round, brace] = match; return {id, label: square || circle || round || brace || id.replace(/_/g, " ")}; } function renderMermaidDiagram(source = "") { const lines = String(source) .split(/\n|;/) .map((line) => line.trim()) .filter(Boolean) .filter((line) => !/^(graph|flowchart)\s+/i.test(line)); const edges = lines.map((line) => { const match = line.match(/^(.+?)\s*[-.=]+>\s*(?:\|(.+?)\|\s*)?(.+)$/) || line.match(/^(.+?)\s*--\s*(?:\|(.+?)\|\s*)?(.+)$/); if (!match) return null; const from = parseMermaidNode(match[1]); const label = match[2] || ""; const to = parseMermaidNode(match[3]); return {from, label, to}; }).filter(Boolean); if (!edges.length) return `
${escapeHtml(source)}
`; return `
${edges.map((edge) => `
${escapeHtml(edge.from.label)}
${escapeHtml(edge.to.label)}
`).join("")}
`; } function classroomPage(lesson) { const artifact = lesson.artifacts.at(-1); const messages = lesson.messages.length ? lesson.messages.map((message) => `
${message.role === "student" ? "You" : "Gargi"}${escapeHtml(message.text)}
`).join("") : `
Your conversation will appear here.
Use the microphone or type a question below.
`; return `

${escapeHtml(lesson.topic)}

${escapeHtml(lesson.learner_level)} · ${escapeHtml(lesson.language)} · Adaptive lesson

Connecting Take quiz End lesson
GARGI · LIVE TEACHERGemini Live
Gargi avatar placeholder Loading Haru Live2D...

Tap to ask Gargi a question

Tap again when you finish speaking

Interactive whiteboard

Updates after each answer
${renderBoard(artifact?.whiteboard_blocks)}
`; } function emptyState(title, copy) { return `

${title}

${copy}

Start a new lesson
`; } function quizPage(lesson) { const quiz = lesson.artifacts.at(-1)?.quiz || []; if (!quiz.length) return emptyState("No quiz yet", "Ask Gargi a question first. Your adaptive quiz appears after the explanation."); return `
Knowledge check

Let us make it stick.

Three quick questions based on your latest conversation.

0 correct
`; } function summaryPage(lesson) { const latest = lesson.artifacts.at(-1); const score = lesson.quiz_attempts.length ? Math.round(lesson.quiz_attempts.at(-1).score * 100) : 0; return `
Lesson snapshot

Curiosity looks good on you.

${escapeHtml(lesson.topic)} · ${Math.floor(lesson.messages.length / 2)} conversation turns

What you explored

${escapeHtml(latest?.summary || lesson.objective || "Start a conversation to build your lesson summary.")}

${Math.floor(lesson.messages.length / 2)}Questions
${lesson.artifacts.length}Explanations
${score}%Quiz score

Understanding improved

${lesson.misconceptions.length ? `
    ${lesson.misconceptions.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` : "

Gargi will track and clarify misconceptions as your lesson develops.

"}

Suggested next step

${escapeHtml(latest?.suggested_follow_up || "Continue the lesson and ask for a practical example.")}

Keep exploring →

Lesson details

Level: ${escapeHtml(lesson.learner_level)}
Language: ${escapeHtml(lesson.language)}
Status: ${escapeHtml(lesson.status)}

${latest ? `Review quiz →` : ""}
`; } async function render() { setActiveNavigation(); try { if (location.pathname === "/") app.innerHTML = landingPage(); else if (location.pathname === "/lesson/new") app.innerHTML = setupPage(); else { const lesson = await fetchLesson(lessonId()); if (location.pathname.endsWith("/quiz")) app.innerHTML = quizPage(lesson); else if (location.pathname.endsWith("/summary")) app.innerHTML = summaryPage(lesson); else app.innerHTML = classroomPage(lesson); } bindPage(); } catch { app.innerHTML = emptyState("We could not find that classroom", "The lesson may have ended or the link is no longer available."); } } function bindPage() { document.querySelectorAll("[data-topic]").forEach((button) => { button.addEventListener("click", () => { document.querySelector("#topic").value = button.dataset.topic; }); }); document.querySelector("#lesson-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const button = event.submitter; button.disabled = true; button.textContent = "Preparing classroom..."; try { const response = await fetch(`${API}/sessions`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))), }); if (!response.ok) throw new Error("Could not create the lesson."); location.href = `/lesson/${(await response.json()).id}`; } catch (error) { showToast(error.message); button.disabled = false; button.innerHTML = "Create my classroom "; } }); const quizData = document.querySelector("#quiz-data"); if (quizData) initializeQuiz(JSON.parse(quizData.textContent)); initializeClassroom(); } function initializeQuiz(quiz) { let index = 0; let correct = 0; const id = lessonId(); const card = document.querySelector("#quiz-card"); function showQuestion() { const question = quiz[index]; const answers = question.type === "multiple_choice" ? question.options.map((option) => ``).join("") : `
`; card.innerHTML = `Question ${index + 1}

${escapeHtml(question.question)}

${answers}
Back to class
`; document.querySelector("#progress-label").textContent = `Question ${index + 1} of ${quiz.length}`; document.querySelector("#progress-fill").style.width = `${((index + 1) / quiz.length) * 100}%`; document.querySelectorAll(".answer-option").forEach((option) => option.addEventListener("click", () => { document.querySelectorAll(".answer-option").forEach((item) => item.classList.remove("selected")); option.classList.add("selected"); })); document.querySelector("#submit-answer").addEventListener("click", async () => { const answer = question.type === "multiple_choice" ? document.querySelector('input[name="answer"]:checked')?.value : document.querySelector("#short-answer")?.value.trim(); if (!answer) return showToast("Choose or write an answer first."); const response = await fetch(`${API}/sessions/${id}/quiz/${question.id}/answer`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({answer}), }); if (!response.ok) return showToast("The answer could not be checked."); const result = await response.json(); if (result.is_correct) correct++; document.querySelector("#score-label").textContent = `${correct} correct`; card.innerHTML = `${result.is_correct ? "Nicely done" : "Let us clarify it"}

${result.is_correct ? "That is correct." : `Expected answer: ${escapeHtml(result.expected_answer)}`}

${escapeHtml(result.explanation)}

`; document.querySelector("#next-question").addEventListener("click", () => { if (index === quiz.length - 1) location.href = `/lesson/${id}/summary`; else { index++; showQuestion(); } }); }); } showQuestion(); } function initializeClassroom() { const form = document.querySelector("#ask-form"); if (!form) return; const id = lessonId(); const panel = document.querySelector("#teacher-panel"); const status = document.querySelector("#live-status"); const transcript = document.querySelector("#transcript"); const board = document.querySelector("#whiteboard"); const boardStatus = document.querySelector("#board-status"); const quizLink = document.querySelector("#quiz-link"); const micButton = document.querySelector("#mic-button"); const micLabel = document.querySelector("#mic-label"); const messageCount = document.querySelector("#message-count"); const avatarMount = document.querySelector("#avatar-mount"); const avatarCanvas = document.querySelector("#avatar-canvas"); const avatarFallback = document.querySelector("#avatar-fallback"); const avatarLabel = document.querySelector("#avatar-label"); const protocol = location.protocol === "https:" ? "wss:" : "ws:"; let socket; let socketReady = false; let recording = false; let mediaStream; let captureContext; let micNode; let micSource; let playbackContext; let nextPlayTime = 0; let activeSources = 0; let playbackSources = new Set(); let startingMicrophone = false; let pendingTyped = ""; let currentQuiz = JSON.parse(document.querySelector("#classroom-quiz-data")?.textContent || "[]"); let currentBoardBlocks = JSON.parse(document.querySelector("#classroom-board-data")?.textContent || "[]"); let heardLiveAudioThisTurn = false; let fallbackSpeechText = ""; let fallbackSpeechBuffer = ""; let fallbackSpeechTimer; let fallbackLipSyncTimer; let live2dApp; let live2dModel; function setState(name, label) { panel.dataset.state = name; status.className = `status-pill ${name}`; status.textContent = label; micLabel.textContent = name === "listening" ? "Listening... tap when finished" : label; } function updateCount() { const count = transcript.querySelectorAll(".message").length; messageCount.textContent = `${count} message${count === 1 ? "" : "s"}`; } function appendMessage(role, text, merge = false) { transcript.querySelector(".transcript-empty")?.remove(); const last = transcript.lastElementChild; if (merge && last?.classList.contains(role)) { last.querySelector("span").textContent += text; } else { transcript.insertAdjacentHTML("beforeend", `
${role === "student" ? "You" : "Gargi"}${escapeHtml(text)}
`); } transcript.scrollTop = transcript.scrollHeight; updateCount(); } function showWhiteboard() { board.innerHTML = renderBoard(currentBoardBlocks); boardStatus.textContent = currentBoardBlocks.length ? "Whiteboard view" : "Updates after each answer"; quizLink.textContent = "Take quiz"; quizLink.href = "#quiz"; } function showInlineQuiz() { if (!currentQuiz.length) return showToast("Ask Gargi a question first. Your quiz appears after the explanation."); let index = 0; let correct = 0; boardStatus.textContent = "Quiz in whiteboard"; quizLink.textContent = "Whiteboard"; quizLink.href = "#whiteboard"; board.innerHTML = `
0 correct
`; const card = board.querySelector("#inline-quiz-card"); function showQuestion() { const question = currentQuiz[index]; const answers = question.type === "multiple_choice" ? question.options.map((option) => ``).join("") : `
`; card.innerHTML = `Question ${index + 1}

${escapeHtml(question.question)}

${answers}
`; board.querySelector("#inline-progress-label").textContent = `Question ${index + 1} of ${currentQuiz.length}`; board.querySelector("#inline-progress-fill").style.width = `${((index + 1) / currentQuiz.length) * 100}%`; board.querySelectorAll(".answer-option").forEach((option) => option.addEventListener("click", () => { board.querySelectorAll(".answer-option").forEach((item) => item.classList.remove("selected")); option.classList.add("selected"); })); board.querySelector("#inline-back-board").addEventListener("click", showWhiteboard); board.querySelector("#inline-submit-answer").addEventListener("click", async () => { const answer = question.type === "multiple_choice" ? board.querySelector('input[name="inline-answer"]:checked')?.value : board.querySelector("#inline-short-answer")?.value.trim(); if (!answer) return showToast("Choose or write an answer first."); const response = await fetch(`${API}/sessions/${id}/quiz/${question.id}/answer`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({answer}), }); if (!response.ok) return showToast("The answer could not be checked."); const result = await response.json(); if (result.is_correct) correct++; board.querySelector("#inline-score-label").textContent = `${correct} correct`; card.innerHTML = `${result.is_correct ? "Nicely done" : "Let us clarify it"}

${result.is_correct ? "That is correct." : `Expected answer: ${escapeHtml(result.expected_answer)}`}

${escapeHtml(result.explanation)}

`; board.querySelector("#inline-review-board").addEventListener("click", showWhiteboard); board.querySelector("#inline-next-question").addEventListener("click", () => { if (index === currentQuiz.length - 1) { showToast(`Quiz complete: ${correct}/${currentQuiz.length} correct.`); showWhiteboard(); } else { index++; showQuestion(); } }); }); } showQuestion(); } function setAvatarMouth(level = 0) { if (!live2dModel?.internalModel?.coreModel) return; try { const coreModel = live2dModel.internalModel.coreModel; const value = Math.max(0, Math.min(1, level)); if (typeof coreModel.setParameterValueById === "function") { coreModel.setParameterValueById("ParamMouthOpenY", value); } } catch {} } function stopFallbackLipSync() { clearInterval(fallbackLipSyncTimer); fallbackLipSyncTimer = null; panel.style.setProperty("--talk-level", "0"); setAvatarMouth(0); } function startFallbackLipSync() { clearInterval(fallbackLipSyncTimer); fallbackLipSyncTimer = setInterval(() => { const level = 0.18 + Math.random() * 0.62; panel.style.setProperty("--talk-level", level.toFixed(2)); setAvatarMouth(level); }, 90); } async function initializeLive2DAvatar() { if (!avatarCanvas || !avatarMount || !window.PIXI?.live2d?.Live2DModel) { if (avatarLabel) avatarLabel.textContent = "Live2D runtime unavailable"; return; } try { live2dApp = new PIXI.Application({ view: avatarCanvas, resizeTo: avatarMount, backgroundAlpha: 0, antialias: true, autoDensity: true, }); live2dModel = await PIXI.live2d.Live2DModel.from("/static/vendor/live2d/haru/haru_greeter_t03.model3.json", { autoInteract: true, }); live2dModel.anchor.set(0.5, 0.52); live2dApp.stage.addChild(live2dModel); const fitModel = () => { const width = live2dApp.renderer.width; const height = live2dApp.renderer.height; if (!width || !height) return; live2dModel.scale.set(1); live2dModel.position.set(width / 2, height * 0.78); const scale = Math.min(width / live2dModel.width, height / live2dModel.height) * 1.68; live2dModel.scale.set(scale); }; fitModel(); window.addEventListener("resize", fitModel); avatarFallback?.classList.add("hidden"); avatarMount.classList.add("live2d-ready"); avatarLabel.textContent = "Haru Live2D teacher ready"; live2dModel.on("hit", () => live2dModel.motion("Tap")); } catch (error) { console.warn("Live2D avatar failed to load", error); avatarLabel.textContent = "Live2D fallback active"; } } async function ensurePlaybackContext() { playbackContext ||= new AudioContext({sampleRate: 24000}); if (playbackContext.state === "suspended") await playbackContext.resume(); } async function playPcm(buffer) { await ensurePlaybackContext(); heardLiveAudioThisTurn = true; window.speechSynthesis?.cancel(); clearTimeout(fallbackSpeechTimer); fallbackSpeechBuffer = ""; stopFallbackLipSync(); const pcm = new Int16Array(buffer); const samples = new Float32Array(pcm.length); let sum = 0; for (let index = 0; index < pcm.length; index++) { samples[index] = pcm[index] / 32768; sum += samples[index] * samples[index]; } const rms = Math.sqrt(sum / Math.max(samples.length, 1)); const talkLevel = Math.min(1, rms * 8); panel.style.setProperty("--talk-level", talkLevel.toFixed(2)); setAvatarMouth(talkLevel); const audio = playbackContext.createBuffer(1, samples.length, 24000); audio.copyToChannel(samples, 0); const source = playbackContext.createBufferSource(); source.buffer = audio; source.connect(playbackContext.destination); nextPlayTime = Math.max(nextPlayTime, playbackContext.currentTime + 0.04); source.start(nextPlayTime); nextPlayTime += audio.duration; playbackSources.add(source); activeSources = playbackSources.size; setState("speaking", "Gargi is speaking"); source.onended = () => { playbackSources.delete(source); activeSources = playbackSources.size; if (activeSources <= 0 && !recording) { panel.style.setProperty("--talk-level", "0"); setAvatarMouth(0); setState("ready", "Gargi is ready"); } }; } async function startMicrophone() { if (startingMicrophone || recording) return; if (!socketReady) return showToast("The live classroom is still connecting."); if (!navigator.mediaDevices?.getUserMedia) return showToast("This browser does not support microphone capture."); startingMicrophone = true; try { await ensurePlaybackContext(); stopPlayback(false); mediaStream = await navigator.mediaDevices.getUserMedia({ audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true}, }); captureContext = new AudioContext(); await captureContext.audioWorklet.addModule("/static/mic-processor.js"); micSource = captureContext.createMediaStreamSource(mediaStream); micNode = new AudioWorkletNode(captureContext, "gargi-mic-processor"); const silentGain = captureContext.createGain(); silentGain.gain.value = 0; micNode.port.onmessage = (event) => { if (recording && socket?.readyState === WebSocket.OPEN) socket.send(event.data); }; micSource.connect(micNode); micNode.connect(silentGain).connect(captureContext.destination); heardLiveAudioThisTurn = false; fallbackSpeechText = ""; fallbackSpeechBuffer = ""; clearTimeout(fallbackSpeechTimer); recording = true; micButton.classList.add("recording"); setState("listening", "Listening"); } catch (error) { showToast(error.name === "NotAllowedError" ? "Microphone permission was denied." : "The microphone could not start."); await stopMicrophone(false); } finally { startingMicrophone = false; } } function stopPlayback(updateState = true) { window.speechSynthesis?.cancel(); clearTimeout(fallbackSpeechTimer); fallbackSpeechBuffer = ""; stopFallbackLipSync(); playbackSources.forEach((source) => { source.onended = null; try { source.stop(); } catch {} try { source.disconnect(); } catch {} }); playbackSources.clear(); activeSources = 0; nextPlayTime = playbackContext?.currentTime || 0; panel.style.setProperty("--talk-level", "0"); setAvatarMouth(0); if (updateState && !recording) setState("ready", "Gargi is ready"); } function speakBrowserFallback(text, cancelExisting = false) { if (!window.speechSynthesis || !text.trim()) return; if (cancelExisting) window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text.trim()); utterance.lang = "en-US"; utterance.rate = 0.95; utterance.pitch = 1.08; const voices = window.speechSynthesis.getVoices(); utterance.voice = voices.find((voice) => /Jenny|Aria|Zira|Samantha|Female/i.test(voice.name)) || voices.find((voice) => voice.lang?.startsWith("en")) || null; utterance.onstart = () => { startFallbackLipSync(); setState("speaking", "Gargi is speaking"); }; utterance.onend = () => { if (!window.speechSynthesis.speaking) stopFallbackLipSync(); if (!recording) setState("ready", "Ask a follow-up"); }; utterance.onerror = stopFallbackLipSync; window.speechSynthesis.speak(utterance); } function flushFallbackSpeech(force = false) { if (heardLiveAudioThisTurn || !fallbackSpeechBuffer.trim()) return; const source = fallbackSpeechBuffer.trimStart(); let textToSpeak = ""; let remaining = source; if (force) { textToSpeak = source; remaining = ""; } else { const sentenceEnd = Math.max(source.lastIndexOf("."), source.lastIndexOf("?"), source.lastIndexOf("!")); if (sentenceEnd >= 0) { textToSpeak = source.slice(0, sentenceEnd + 1); remaining = source.slice(sentenceEnd + 1); } else if (source.length > 130) { const splitAt = Math.max(source.lastIndexOf(","), source.lastIndexOf(" ")); textToSpeak = source.slice(0, splitAt > 30 ? splitAt : source.length); remaining = source.slice(textToSpeak.length); } } fallbackSpeechBuffer = remaining.trimStart(); if (textToSpeak.trim()) speakBrowserFallback(textToSpeak, false); } function scheduleRealtimeFallbackSpeech() { if (heardLiveAudioThisTurn) return; clearTimeout(fallbackSpeechTimer); const delay = /[.!?]\s*$/.test(fallbackSpeechBuffer.trim()) ? 80 : 520; fallbackSpeechTimer = setTimeout(() => flushFallbackSpeech(false), delay); } async function stopMicrophone(sendEnd = true) { if (!recording && !mediaStream && !captureContext) return; const contextToClose = captureContext; recording = false; micButton.classList.remove("recording"); if (sendEnd && socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({type: "audio_end"})); setState("thinking", "Gargi is thinking"); } else { setState("ready", "Gargi is ready"); } if (micNode?.port) micNode.port.onmessage = null; micNode?.disconnect(); micSource?.disconnect(); mediaStream?.getTracks().forEach((track) => track.stop()); mediaStream = captureContext = micNode = micSource = null; if (contextToClose && contextToClose.state !== "closed") { contextToClose.close().catch(() => {}); } } socket = new WebSocket(`${protocol}//${location.host}${API}/sessions/${id}/live`); socket.binaryType = "arraybuffer"; socket.onopen = () => setState("connecting", "Connecting"); socket.onmessage = async (event) => { if (event.data instanceof ArrayBuffer) { if (recording) return; return playPcm(event.data); } const message = JSON.parse(event.data); if (message.type === "ready") { socketReady = true; setState("ready", "Gargi is ready"); } else if (message.type === "input_transcription") { const text = message.text.trim(); if (pendingTyped && text.toLowerCase() === pendingTyped.toLowerCase()) pendingTyped = ""; else if (text) appendMessage("student", text, true); } else if (message.type === "output_transcription") { fallbackSpeechText += message.text; fallbackSpeechBuffer += message.text; scheduleRealtimeFallbackSpeech(); appendMessage("teacher", message.text, true); if (!recording) setState("speaking", "Gargi is speaking"); } else if (message.type === "lesson_payload") { currentBoardBlocks = message.whiteboard_blocks || []; currentQuiz = message.quiz || []; board.innerHTML = renderBoard(message.whiteboard_blocks); boardStatus.textContent = "Updated just now"; quizLink.classList.remove("hidden"); showToast(message.fallback ? (message.notice || "Fallback whiteboard and quiz are ready.") : "Whiteboard and quiz are ready."); } else if (message.type === "interrupted") { stopPlayback(false); setState(recording ? "listening" : "ready", recording ? "Listening" : "Gargi is ready"); } else if (message.type === "turn_complete") { if (!heardLiveAudioThisTurn && fallbackSpeechText.trim()) { clearTimeout(fallbackSpeechTimer); flushFallbackSpeech(true); } heardLiveAudioThisTurn = false; fallbackSpeechText = ""; fallbackSpeechBuffer = ""; if (activeSources <= 0) setState("ready", "Ask a follow-up"); } else if (message.type === "error") { const retry = message.retry_delay ? ` Try again in ${message.retry_delay}.` : ""; showToast(`${message.message || "The live classroom hit a problem."}${retry}`); setState("error", "Connection problem"); } }; socket.onclose = () => { socketReady = false; setState("error", "Voice disconnected"); }; socket.onerror = () => showToast("Could not connect to the live classroom."); initializeLive2DAvatar(); quizLink?.addEventListener("click", (event) => { event.preventDefault(); if (quizLink.textContent === "Whiteboard") showWhiteboard(); else showInlineQuiz(); }); micButton.addEventListener("click", () => recording ? stopMicrophone(true) : startMicrophone()); form.addEventListener("submit", async (event) => { event.preventDefault(); const input = document.querySelector("#ask-input"); const text = input.value.trim(); if (!text) return; if (!socketReady) return showToast("The live classroom is still connecting."); await ensurePlaybackContext(); heardLiveAudioThisTurn = false; fallbackSpeechText = ""; fallbackSpeechBuffer = ""; clearTimeout(fallbackSpeechTimer); pendingTyped = text; appendMessage("student", text); socket.send(JSON.stringify({type: "text", text})); input.value = ""; setState("thinking", "Gargi is thinking"); }); window.addEventListener("beforeunload", () => { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({type: "close"})); stopPlayback(false); stopFallbackLipSync(); live2dApp?.destroy(true); mediaStream?.getTracks().forEach((track) => track.stop()); }); } render();