EstebanBarac's picture
Final hackathon submission: README, Field Notes, UI overhaul, fine-tuned model
9ee7168
Raw
History Blame Contribute Delete
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");
}