Download frontend/static/sofia.js from build-small-hackathon/sofia-educational-companion: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/spaces/build-small-hackathon/sofia-educational-companion/resolve/main/frontend/static/sofia.js
- Command line
-
hf download hf://spaces/build-small-hackathon/sofia-educational-companion/frontend/static/sofia.js
-
curl -L -o sofia.js https://huggingface.co/spaces/build-small-hackathon/sofia-educational-companion/resolve/main/frontend/static/sofia.js
11.3 kB
| // sofia.js — pantalla "Hablar": personaje Sofía con estados de ánimo | |
| // (idle / listening / thinking / speaking / playing / asking) y el flujo | |
| // push-to-talk: botón → MediaRecorder → /transcribe → /chat → /speak → Audio. | |
| // | |
| // Si /chat devuelve una `activity` de tipo "song" o "story", Sofía la | |
| // reproduce directo en esta pantalla (mismo <audio> compartido) apenas | |
| // termina de hablar: el botón de hablar se transforma en "terminar" | |
| // (mood "playing") y un solo toque corta el audio y vuelve a la charla. | |
| // | |
| // Si la `activity` es de tipo "learn" (juego de aprender: números, animales | |
| // o colores), Sofía muestra el emoji y dice la pregunta curada (mood | |
| // "asking"): el mic queda listo para grabar la respuesta. Al contestar, se | |
| // compara localmente contra `activity.match` (sin pasar por el LLM) y Sofía | |
| // da el feedback con voz, antes de volver a la charla normal. | |
| import { transcribe, chat, speak } from "./api.js"; | |
| import { Recorder } from "./recorder.js"; | |
| const CAPTIONS = { | |
| idle: "Tocá para hablar", | |
| listening: "Te escucho…", | |
| }; | |
| const HINTS = { | |
| idle: "", | |
| listening: "Escuchando…", | |
| thinking: "", | |
| speaking: "Sofía responde", | |
| playing: "Tocá para terminar", | |
| asking: "Tocá y respondé", | |
| }; | |
| const LOCKED_MOODS = new Set(["thinking", "speaking"]); | |
| const MEDIA_ICONS = { song: "🎵", story: "📖" }; | |
| // Frases de feedback para el juego de "aprender" (no son contenido curado, | |
| // son aliento genérico: el LLM no interviene en la corrección). | |
| const LEARN_CORRECT = ["¡Sí! ¡Muy bien! 🎉", "¡Genial! ¡Lo sabés! ⭐", "¡Exacto! ¡Qué inteligente! 🌟"]; | |
| const LEARN_RETRY = ["¡Casi! Probemos de nuevo.", "Un poquito más, ¡vos podés!"]; | |
| const MAX_LEARN_ATTEMPTS = 2; | |
| function normalize(s) { | |
| return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); | |
| } | |
| function pickRandom(arr) { | |
| return arr[Math.floor(Math.random() * arr.length)]; | |
| } | |
| // CSS custom properties que pintan a Sofía (cuerpo, halo y mejillas). Ver | |
| // content/colors.json: cada color trae su propia paleta curada para estas | |
| // variables, aplicada a :root para que también pinte a Sofía en Actividades. | |
| const SOFIA_COLOR_VARS = ["--s1", "--s2", "--s3", "--halo", "--cheek", "--shadow-1", "--shadow-2"]; | |
| function applySofiaColor(palette) { | |
| if (!palette) return; | |
| const root = document.documentElement.style; | |
| for (const name of SOFIA_COLOR_VARS) { | |
| if (palette[name]) root.setProperty(name, palette[name]); | |
| } | |
| } | |
| export function initSofia() { | |
| const screen = document.getElementById("screen-hablar"); | |
| const stage = document.getElementById("stage"); | |
| const micBtn = document.getElementById("micBtn"); | |
| const micIconIdle = document.getElementById("micIconIdle"); | |
| const micIconListening = document.getElementById("micIconListening"); | |
| const micIconStop = document.getElementById("micIconStop"); | |
| const caption = document.getElementById("caption"); | |
| const hint = document.getElementById("hint"); | |
| const errorEl = document.getElementById("error"); | |
| let mood = "idle"; | |
| let recorder = null; | |
| let isRecording = false; | |
| let pendingLearn = null; | |
| let learnAttempts = 0; | |
| const mediaAudio = new Audio(); | |
| function setMood(next) { | |
| mood = next; | |
| screen.classList.remove("mood-idle", "mood-listening", "mood-thinking", "mood-speaking", "mood-playing", "mood-asking"); | |
| screen.classList.add(`mood-${mood}`); | |
| micIconListening.style.display = mood === "listening" ? "" : "none"; | |
| micIconStop.style.display = mood === "playing" ? "" : "none"; | |
| micIconIdle.style.display = (mood === "listening" || mood === "playing") ? "none" : ""; | |
| hint.textContent = HINTS[mood] ?? ""; | |
| micBtn.setAttribute("aria-label", mood === "playing" ? "Terminar" : "Hablar con Sofía"); | |
| if (mood === "thinking") { | |
| showThinkingCaption("Pensando…"); | |
| } else if (mood === "speaking" || mood === "playing" || mood === "asking") { | |
| // el bubble lo arma showBubble() / showMediaCaption() | |
| } else { | |
| caption.innerHTML = ""; | |
| const c = document.createElement("div"); | |
| c.className = "c-es"; | |
| c.textContent = CAPTIONS[mood] ?? ""; | |
| caption.appendChild(c); | |
| } | |
| } | |
| function showThinkingCaption(text) { | |
| caption.innerHTML = `<div class="thinking-label">${text}</div><div class="dots"><i></i><i></i><i></i></div>`; | |
| } | |
| function showCaptionText(text) { | |
| caption.innerHTML = ""; | |
| const c = document.createElement("div"); | |
| c.className = "c-es"; | |
| c.textContent = text; | |
| caption.appendChild(c); | |
| } | |
| function showHeard(text) { | |
| caption.innerHTML = ""; | |
| const c = document.createElement("div"); | |
| c.className = "c-es heard"; | |
| c.textContent = `"${text}"`; | |
| caption.appendChild(c); | |
| } | |
| function showBubble(text) { | |
| caption.innerHTML = ` | |
| <div class="bubble"> | |
| <div class="emoji">💬</div> | |
| <div class="btext"><div class="b-es"></div></div> | |
| </div>`; | |
| caption.querySelector(".b-es").textContent = text; | |
| } | |
| function showMediaCaption(icon, title) { | |
| caption.innerHTML = ` | |
| <div class="bubble"> | |
| <div class="emoji">${icon}</div> | |
| <div class="btext"><div class="b-es"></div></div> | |
| </div>`; | |
| caption.querySelector(".b-es").textContent = title; | |
| } | |
| function showError(msg) { | |
| errorEl.textContent = msg; | |
| errorEl.classList.add("show"); | |
| setTimeout(() => errorEl.classList.remove("show"), 4000); | |
| } | |
| async function playReply(text) { | |
| let audioUrl = ""; | |
| try { | |
| audioUrl = await speak(text); | |
| } catch { | |
| return; | |
| } | |
| if (!audioUrl) return; | |
| await new Promise((resolve) => { | |
| const audio = new Audio(audioUrl); | |
| audio.onended = resolve; | |
| audio.onerror = resolve; | |
| audio.play().catch(resolve); | |
| }); | |
| } | |
| mediaAudio.addEventListener("ended", stopMedia); | |
| mediaAudio.addEventListener("error", () => { | |
| showError("No pude reproducir el audio."); | |
| stopMedia(); | |
| }); | |
| // Corta la canción/cuento que esté sonando y vuelve al modo charla. | |
| function stopMedia() { | |
| mediaAudio.pause(); | |
| mediaAudio.currentTime = 0; | |
| setMood("idle"); | |
| } | |
| async function playSong(activity) { | |
| setMood("playing"); | |
| showMediaCaption(MEDIA_ICONS.song, activity.title ?? "Canción"); | |
| mediaAudio.src = `/content/songs/${activity.file}`; | |
| try { | |
| await mediaAudio.play(); | |
| } catch { | |
| showError("No pude reproducir la canción."); | |
| stopMedia(); | |
| } | |
| } | |
| async function playStory(activity) { | |
| setMood("thinking"); | |
| showThinkingCaption("Preparando el cuento…"); | |
| let audioUrl = ""; | |
| try { | |
| audioUrl = await speak(activity.script); | |
| } catch { | |
| audioUrl = ""; | |
| } | |
| if (!audioUrl) { | |
| showError("No pude preparar el cuento."); | |
| setMood("idle"); | |
| return; | |
| } | |
| setMood("playing"); | |
| showMediaCaption(MEDIA_ICONS.story, activity.title ?? "Cuento"); | |
| mediaAudio.src = audioUrl; | |
| try { | |
| await mediaAudio.play(); | |
| } catch { | |
| showError("No pude reproducir el cuento."); | |
| stopMedia(); | |
| } | |
| } | |
| // Vuelve al modo charla, o si hay una pregunta de "aprender" pendiente, | |
| // la vuelve a mostrar para que pueda intentar de nuevo. | |
| function returnToIdleOrAsking() { | |
| if (pendingLearn) { | |
| setMood("asking"); | |
| showMediaCaption(pendingLearn.emoji, pendingLearn.prompt); | |
| } else { | |
| setMood("idle"); | |
| } | |
| } | |
| // Muestra el emoji y dice la pregunta curada del juego de "aprender". | |
| // La respuesta se evalúa localmente (ver handleLearnAnswer), sin LLM. | |
| async function playLearn(activity) { | |
| pendingLearn = activity; | |
| learnAttempts = 0; | |
| setMood("asking"); | |
| showMediaCaption(activity.emoji, activity.prompt); | |
| await playReply(activity.prompt); | |
| } | |
| // Compara la respuesta hablada contra activity.match (sustring, sin | |
| // acentos). Da feedback con voz y, tras MAX_LEARN_ATTEMPTS intentos, | |
| // revela la respuesta antes de volver a la charla. | |
| async function handleLearnAnswer(text) { | |
| const activity = pendingLearn; | |
| const norm = normalize(text); | |
| const isCorrect = norm !== "" && activity.match.some((m) => norm.includes(m)); | |
| if (isCorrect) { | |
| pendingLearn = null; | |
| setMood("speaking"); | |
| const phrase = pickRandom(LEARN_CORRECT); | |
| showBubble(phrase); | |
| await playReply(phrase); | |
| setMood("idle"); | |
| return; | |
| } | |
| learnAttempts++; | |
| if (learnAttempts >= MAX_LEARN_ATTEMPTS) { | |
| pendingLearn = null; | |
| setMood("speaking"); | |
| const phrase = `¡Este es ${activity.answer_label}! 😊`; | |
| showBubble(phrase); | |
| await playReply(phrase); | |
| setMood("idle"); | |
| return; | |
| } | |
| setMood("speaking"); | |
| const retryPhrase = pickRandom(LEARN_RETRY); | |
| showBubble(retryPhrase); | |
| await playReply(retryPhrase); | |
| setMood("asking"); | |
| showMediaCaption(activity.emoji, activity.prompt); | |
| } | |
| async function sendToSofia(text) { | |
| setMood("thinking"); | |
| let reply = ""; | |
| let activity = null; | |
| try { | |
| const result = await chat(text, 3); | |
| reply = result.reply ?? ""; | |
| activity = result.activity ?? null; | |
| } catch { | |
| showError("Error al generar respuesta."); | |
| setMood("idle"); | |
| return; | |
| } | |
| if (activity?.intent === "sofia_color") applySofiaColor(activity.palette); | |
| setMood("speaking"); | |
| showBubble(reply); | |
| await playReply(reply); | |
| if (activity?.intent === "song") { | |
| await playSong(activity); | |
| return; | |
| } | |
| if (activity?.intent === "story") { | |
| await playStory(activity); | |
| return; | |
| } | |
| if (activity?.intent === "learn") { | |
| await playLearn(activity); | |
| return; | |
| } | |
| setMood("idle"); | |
| } | |
| async function processAudio(blob, ext, durationMs) { | |
| let text = ""; | |
| try { | |
| const result = await transcribe(blob, ext, durationMs); | |
| text = result.text ?? ""; | |
| } catch { | |
| showError("Error al transcribir el audio."); | |
| returnToIdleOrAsking(); | |
| return; | |
| } | |
| if (!text.trim()) { | |
| showCaptionText("No te escuché bien, ¿probamos de nuevo?"); | |
| setTimeout(returnToIdleOrAsking, 2000); | |
| return; | |
| } | |
| if (pendingLearn) { | |
| await handleLearnAnswer(text); | |
| return; | |
| } | |
| showHeard(text); | |
| await new Promise((resolve) => setTimeout(resolve, 700)); | |
| await sendToSofia(text); | |
| } | |
| async function startRecording() { | |
| recorder = new Recorder(); | |
| try { | |
| await recorder.start(); | |
| } catch { | |
| showError("Sin acceso al micrófono. Revisá los permisos del navegador."); | |
| return; | |
| } | |
| isRecording = true; | |
| setMood("listening"); | |
| } | |
| async function stopRecording() { | |
| isRecording = false; | |
| setMood("thinking"); | |
| const { blob, ext, durationMs } = await recorder.stop(); | |
| if (blob.size < 4000) { | |
| showCaptionText("Demasiado corto, intentá de nuevo"); | |
| setTimeout(returnToIdleOrAsking, 2000); | |
| return; | |
| } | |
| await processAudio(blob, ext, durationMs); | |
| } | |
| function toggleRecording() { | |
| if (LOCKED_MOODS.has(mood)) return; | |
| if (mood === "playing") { | |
| stopMedia(); | |
| return; | |
| } | |
| if (isRecording) stopRecording(); | |
| else startRecording(); | |
| } | |
| micBtn.addEventListener("click", toggleRecording); | |
| stage.addEventListener("click", toggleRecording); | |
| setMood("idle"); | |
| } | |