| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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: "📖" }; |
|
|
| |
| |
| 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)]; |
| } |
|
|
| |
| |
| |
| 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") { |
| |
| } 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(); |
| }); |
|
|
| |
| 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(); |
| } |
| } |
|
|
| |
| |
| function returnToIdleOrAsking() { |
| if (pendingLearn) { |
| setMood("asking"); |
| showMediaCaption(pendingLearn.emoji, pendingLearn.prompt); |
| } else { |
| setMood("idle"); |
| } |
| } |
|
|
| |
| |
| async function playLearn(activity) { |
| pendingLearn = activity; |
| learnAttempts = 0; |
| setMood("asking"); |
| showMediaCaption(activity.emoji, activity.prompt); |
| await playReply(activity.prompt); |
| } |
|
|
| |
| |
| |
| 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"); |
| } |
|
|