File size: 11,282 Bytes
00dbb37
9ee7168
 
4dba873
bf47841
 
 
 
9ee7168
 
 
 
 
 
00dbb37
 
 
 
 
 
 
 
 
 
 
 
 
 
bf47841
9ee7168
00dbb37
 
 
 
bf47841
 
9ee7168
 
 
 
 
 
 
 
 
 
 
 
 
4dba873
 
 
 
 
 
 
 
 
 
 
 
 
 
00dbb37
 
 
 
 
 
bf47841
00dbb37
 
 
 
 
 
 
9ee7168
 
00dbb37
bf47841
 
00dbb37
 
9ee7168
00dbb37
 
bf47841
 
00dbb37
bf47841
00dbb37
 
bf47841
9ee7168
bf47841
00dbb37
 
 
 
 
 
 
 
 
bf47841
 
 
 
00dbb37
 
 
 
 
 
 
 
4dba873
 
 
 
 
 
 
 
00dbb37
 
 
 
 
 
 
 
 
bf47841
 
 
 
 
 
 
 
 
00dbb37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf47841
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ee7168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00dbb37
 
 
4dba873
00dbb37
 
 
4dba873
00dbb37
 
 
 
 
 
4dba873
 
00dbb37
 
 
bf47841
 
 
 
 
 
 
 
 
9ee7168
 
 
 
bf47841
00dbb37
 
 
 
 
 
 
 
 
 
9ee7168
00dbb37
 
 
 
 
9ee7168
 
 
 
 
 
00dbb37
 
 
4dba873
 
00dbb37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ee7168
00dbb37
 
 
 
 
 
 
 
bf47841
 
 
 
00dbb37
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
// 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");
}