swapmyface commited on
Commit
7f9bc0e
·
verified ·
1 Parent(s): 90633dc

Switch TTS to browser SpeechSynthesis, fix chat API endpoint

Browse files
Files changed (2) hide show
  1. app.py +5 -39
  2. static/js/app.js +127 -132
app.py CHANGED
@@ -3,13 +3,13 @@
3
  import os
4
  import base64
5
  import logging
6
- from flask import Flask, request, jsonify, render_template, send_from_directory
7
 
8
  from backend.language_config import get_curriculum_data, LANGUAGES
9
- from backend.teacher_profiles import get_all_teachers, get_teacher
10
  from backend.session_manager import sessions
11
  from backend.tutor_engine import build_system_prompt, build_greeting_prompt, evaluate_pronunciation
12
- from backend.hf_client import chat_completion, text_to_speech, speech_to_text, get_model_info
13
 
14
  logging.basicConfig(level=logging.INFO)
15
  logger = logging.getLogger(__name__)
@@ -17,15 +17,11 @@ logger = logging.getLogger(__name__)
17
  app = Flask(__name__, static_folder="static", template_folder="templates")
18
 
19
 
20
- # --- Page Routes ---
21
-
22
  @app.route("/")
23
  def index():
24
  return render_template("index.html")
25
 
26
 
27
- # --- API Routes ---
28
-
29
  @app.route("/api/health", methods=["GET"])
30
  def health():
31
  return jsonify({"status": "ok", "models": get_model_info()})
@@ -43,23 +39,15 @@ def teachers():
43
 
44
  @app.route("/api/teacher/voice-sample", methods=["POST"])
45
  def teacher_voice_sample():
 
46
  data = request.json or {}
47
- teacher_id = data.get("teacher_id", "anaya")
48
  target_lang = data.get("target_lang", "hindi")
49
 
50
  lang = LANGUAGES.get(target_lang)
51
  if not lang:
52
  return jsonify({"error": "Unknown language"}), 400
53
 
54
- sample_text = lang.get("sample_text", "Hello!")
55
- tts_model = lang.get("tts_model", "facebook/mms-tts-eng")
56
-
57
- audio_bytes = text_to_speech(sample_text, tts_model)
58
- if audio_bytes:
59
- audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
60
- return jsonify({"audio": audio_b64, "text": sample_text})
61
-
62
- return jsonify({"audio": None, "text": sample_text, "error": "TTS unavailable"})
63
 
64
 
65
  @app.route("/api/session/start", methods=["POST"])
@@ -88,16 +76,9 @@ def session_start():
88
  greeting = result.get("content", "Hello! Let's begin our lesson.")
89
  session.add_message("assistant", greeting)
90
 
91
- # Generate TTS for greeting
92
- lang = LANGUAGES.get(target_lang, {})
93
- tts_model = lang.get("tts_model", "facebook/mms-tts-eng")
94
- audio_bytes = text_to_speech(greeting, tts_model)
95
- audio_b64 = base64.b64encode(audio_bytes).decode("utf-8") if audio_bytes else None
96
-
97
  return jsonify({
98
  "session_id": session.session_id,
99
  "greeting": greeting,
100
- "audio": audio_b64,
101
  "teacher_id": teacher_id
102
  })
103
 
@@ -119,19 +100,10 @@ def chat():
119
  result = chat_completion(session.get_messages())
120
  reply = result.get("content", "")
121
  session.add_message("assistant", reply)
122
-
123
- # XP for engaging
124
  session.add_xp(5)
125
 
126
- # Generate TTS
127
- lang = LANGUAGES.get(session.target_lang, {})
128
- tts_model = lang.get("tts_model", "facebook/mms-tts-eng")
129
- audio_bytes = text_to_speech(reply, tts_model)
130
- audio_b64 = base64.b64encode(audio_bytes).decode("utf-8") if audio_bytes else None
131
-
132
  return jsonify({
133
  "reply": reply,
134
- "audio": audio_b64,
135
  "xp": session.xp,
136
  "model": result.get("model", "")
137
  })
@@ -163,15 +135,9 @@ def voice():
163
  session.add_message("assistant", reply)
164
  session.add_xp(10)
165
 
166
- lang = LANGUAGES.get(session.target_lang, {})
167
- tts_model = lang.get("tts_model", "facebook/mms-tts-eng")
168
- audio_resp = text_to_speech(reply, tts_model)
169
- audio_b64 = base64.b64encode(audio_resp).decode("utf-8") if audio_resp else None
170
-
171
  return jsonify({
172
  "transcribed": transcribed,
173
  "reply": reply,
174
- "audio": audio_b64,
175
  "xp": session.xp
176
  })
177
 
 
3
  import os
4
  import base64
5
  import logging
6
+ from flask import Flask, request, jsonify, render_template
7
 
8
  from backend.language_config import get_curriculum_data, LANGUAGES
9
+ from backend.teacher_profiles import get_all_teachers
10
  from backend.session_manager import sessions
11
  from backend.tutor_engine import build_system_prompt, build_greeting_prompt, evaluate_pronunciation
12
+ from backend.hf_client import chat_completion, speech_to_text, get_model_info
13
 
14
  logging.basicConfig(level=logging.INFO)
15
  logger = logging.getLogger(__name__)
 
17
  app = Flask(__name__, static_folder="static", template_folder="templates")
18
 
19
 
 
 
20
  @app.route("/")
21
  def index():
22
  return render_template("index.html")
23
 
24
 
 
 
25
  @app.route("/api/health", methods=["GET"])
26
  def health():
27
  return jsonify({"status": "ok", "models": get_model_info()})
 
39
 
40
  @app.route("/api/teacher/voice-sample", methods=["POST"])
41
  def teacher_voice_sample():
42
+ """Return sample text for client-side TTS preview."""
43
  data = request.json or {}
 
44
  target_lang = data.get("target_lang", "hindi")
45
 
46
  lang = LANGUAGES.get(target_lang)
47
  if not lang:
48
  return jsonify({"error": "Unknown language"}), 400
49
 
50
+ return jsonify({"text": lang.get("sample_text", "Hello!")})
 
 
 
 
 
 
 
 
51
 
52
 
53
  @app.route("/api/session/start", methods=["POST"])
 
76
  greeting = result.get("content", "Hello! Let's begin our lesson.")
77
  session.add_message("assistant", greeting)
78
 
 
 
 
 
 
 
79
  return jsonify({
80
  "session_id": session.session_id,
81
  "greeting": greeting,
 
82
  "teacher_id": teacher_id
83
  })
84
 
 
100
  result = chat_completion(session.get_messages())
101
  reply = result.get("content", "")
102
  session.add_message("assistant", reply)
 
 
103
  session.add_xp(5)
104
 
 
 
 
 
 
 
105
  return jsonify({
106
  "reply": reply,
 
107
  "xp": session.xp,
108
  "model": result.get("model", "")
109
  })
 
135
  session.add_message("assistant", reply)
136
  session.add_xp(10)
137
 
 
 
 
 
 
138
  return jsonify({
139
  "transcribed": transcribed,
140
  "reply": reply,
 
141
  "xp": session.xp
142
  })
143
 
static/js/app.js CHANGED
@@ -3,6 +3,22 @@
3
  (function () {
4
  "use strict";
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  // ===== State =====
7
  const state = {
8
  step: 1,
@@ -21,54 +37,66 @@
21
  teachersData: null,
22
  };
23
 
24
- // ===== Audio Manager =====
25
- const AudioManager = {
 
26
  queue: [],
27
- playing: false,
28
- currentAudio: null,
29
 
30
- play(base64Audio) {
31
- if (state.isMuted || !base64Audio) return;
32
- this.queue.push(base64Audio);
33
- if (!this.playing) this._playNext();
 
 
 
 
 
 
 
 
 
 
34
  },
35
 
36
- _playNext() {
37
  if (this.queue.length === 0) {
38
- this.playing = false;
39
  AvatarController.setIdle();
40
  return;
41
  }
42
- this.playing = true;
43
  AvatarController.setSpeaking();
44
- const b64 = this.queue.shift();
45
- const audio = new Audio("data:audio/wav;base64," + b64);
46
- this.currentAudio = audio;
47
- audio.onended = () => {
48
- this.currentAudio = null;
49
- this._playNext();
50
- };
51
- audio.onerror = () => {
52
- this.currentAudio = null;
53
- this._playNext();
54
- };
55
- audio.play().catch(() => this._playNext());
 
 
 
 
 
 
 
56
  },
57
 
58
  stop() {
59
  this.queue = [];
60
- if (this.currentAudio) {
61
- this.currentAudio.pause();
62
- this.currentAudio = null;
63
- }
64
- this.playing = false;
65
  AvatarController.setIdle();
66
  },
67
  };
68
 
69
  // ===== Avatar Controller =====
70
  const AvatarController = {
71
- speakInterval: null,
72
  blinkInterval: null,
73
 
74
  load(teacherId, color) {
@@ -85,44 +113,16 @@
85
  teacherId.charAt(0).toUpperCase() + teacherId.slice(1);
86
  document.getElementById("avatarName").style.color = color;
87
 
88
- // Set teacher color theme
89
  document.documentElement.style.setProperty("--teacher-color", color);
90
-
91
  this.startBlinking();
92
  },
93
 
94
  setSpeaking() {
95
- const container = document.getElementById("avatarContainer");
96
- container.classList.add("speaking");
97
-
98
- // Animate mouth via SVG inside if accessible
99
- this._animateMouth(true);
100
  },
101
 
102
  setIdle() {
103
- const container = document.getElementById("avatarContainer");
104
- container.classList.remove("speaking");
105
- this._animateMouth(false);
106
- },
107
-
108
- _animateMouth(speaking) {
109
- try {
110
- const container = document.getElementById("avatarContainer");
111
- const svg =
112
- container.querySelector("svg") ||
113
- container.querySelector("img")?.getSVGDocument?.();
114
- if (!svg) return;
115
- const mouth = svg.querySelector("#mouth");
116
- if (!mouth) return;
117
-
118
- if (speaking) {
119
- mouth.style.animation = "mouthSpeak 300ms ease infinite";
120
- } else {
121
- mouth.style.animation = "none";
122
- }
123
- } catch (e) {
124
- // SVG cross-origin or not loaded yet
125
- }
126
  },
127
 
128
  startBlinking() {
@@ -194,10 +194,10 @@
194
  if (key === preselectKey) card.classList.add("selected");
195
  card.dataset.key = key;
196
  card.innerHTML = `
197
- <span class="card-icon">${lang.flag}</span>
198
- <span class="card-title">${lang.name}</span>
199
- <span class="card-subtitle">${lang.native_name}</span>
200
- `;
201
  card.onclick = () => {
202
  container
203
  .querySelectorAll(".selection-card")
@@ -240,15 +240,15 @@
240
  card.className = "teacher-card";
241
  card.style.setProperty("--teacher-color", t.color);
242
  card.innerHTML = `
243
- <div class="teacher-avatar-preview" style="background:${t.color_light}">
244
- <img src="/static/svg/${t.avatar}" alt="${t.name}">
245
- </div>
246
- <h3>${t.name}</h3>
247
- <div class="teacher-title" style="color:${t.color}">${t.title}</div>
248
- <div class="teacher-style">${t.style}</div>
249
- <div class="teacher-desc">${t.description}</div>
250
- <button class="btn-voice-preview" data-teacher="${t.id}">Preview Voice</button>
251
- `;
252
  card.onclick = (e) => {
253
  if (e.target.classList.contains("btn-voice-preview")) return;
254
  container
@@ -269,7 +269,7 @@
269
  container.appendChild(card);
270
  }
271
 
272
- // Voice preview buttons
273
  container.querySelectorAll(".btn-voice-preview").forEach((btn) => {
274
  btn.onclick = async (e) => {
275
  e.stopPropagation();
@@ -283,9 +283,21 @@
283
  }),
284
  });
285
  btn.textContent = "Preview Voice";
286
- if (data.audio) {
287
- const audio = new Audio("data:audio/wav;base64," + data.audio);
288
- audio.play().catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
  };
291
  });
@@ -301,10 +313,10 @@
301
  const card = document.createElement("div");
302
  card.className = "selection-card";
303
  card.innerHTML = `
304
- <span class="card-icon">${t.icon}</span>
305
- <span class="card-title">${t.title}</span>
306
- <span class="card-subtitle">${t.description}</span>
307
- `;
308
  card.onclick = () => {
309
  container
310
  .querySelectorAll(".selection-card")
@@ -364,13 +376,9 @@
364
  ) || { color: "#E91E63" };
365
  AvatarController.load(state.teacherId, teacher.color);
366
 
367
- // Show greeting
368
  addMessage("tutor", data.greeting);
369
-
370
- // Auto-speak greeting
371
- if (data.audio) {
372
- AudioManager.play(data.audio);
373
- }
374
 
375
  document.getElementById("inputMessage").focus();
376
  }
@@ -383,33 +391,25 @@
383
  div.innerHTML = formatMessage(text);
384
  container.appendChild(div);
385
 
386
- // Render any special blocks
387
  renderSpecialBlocks(div, text);
388
-
389
  scrollToBottom();
390
  }
391
 
392
  function formatMessage(text) {
393
- // Escape HTML
394
  let html = text
395
  .replace(/&/g, "&amp;")
396
  .replace(/</g, "&lt;")
397
  .replace(/>/g, "&gt;");
398
 
399
- // Bold
400
  html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
401
- // Italic
402
  html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
403
- // Line breaks
404
  html = html.replace(/\n/g, "<br>");
405
 
406
- // Cultural notes
407
  html = html.replace(
408
  /\[CULTURAL NOTE:\s*(.*?)\]/gs,
409
  '<div class="cultural-note"><div class="cultural-note-title">Cultural Insight</div>$1</div>'
410
  );
411
 
412
- // Dialogue blocks
413
  html = html.replace(
414
  /\[DIALOGUE\](.*?)\[\/DIALOGUE\]/gs,
415
  '<div class="dialogue-block">$1</div>'
@@ -419,10 +419,7 @@
419
  }
420
 
421
  function renderSpecialBlocks(msgEl, text) {
422
- // Sentence builder
423
- const sbMatch = text.match(
424
- /\[SENTENCE_BUILDER:\s*(.*?)\]/
425
- );
426
  if (sbMatch) {
427
  const words = sbMatch[1].split("|").map((w) => w.trim());
428
  const shuffled = [...words].sort(() => Math.random() - 0.5);
@@ -431,17 +428,16 @@
431
  const block = document.createElement("div");
432
  block.className = "sentence-builder";
433
  block.innerHTML = `
434
- <div class="sentence-builder-title">Arrange the words in the correct order:</div>
435
- <div class="word-tiles">${shuffled.map((w) => `<span class="word-tile" data-word="${escapeAttr(w)}">${w}</span>`).join("")}</div>
436
- <div class="sentence-drop-zone" data-correct="${escapeAttr(correctOrder)}"></div>
437
- <div class="sentence-builder-actions">
438
- <button class="btn-check-sentence">Check</button>
439
- <button class="btn-reset-sentence">Reset</button>
440
- </div>
441
- `;
442
  msgEl.appendChild(block);
443
 
444
- // Tile click handlers
445
  const tiles = block.querySelectorAll(".word-tile");
446
  const dropZone = block.querySelector(".sentence-drop-zone");
447
 
@@ -542,10 +538,8 @@
542
  updateXP();
543
  }
544
 
545
- // Auto-speak
546
- if (data.audio) {
547
- AudioManager.play(data.audio);
548
- }
549
  }
550
 
551
  // ===== Voice Input =====
@@ -597,19 +591,16 @@
597
  }
598
  if (data.reply) {
599
  addMessage("tutor", data.reply);
 
600
  }
601
  if (data.xp) {
602
  state.xp = data.xp;
603
  updateXP();
604
  }
605
- if (data.audio) {
606
- AudioManager.play(data.audio);
607
- }
608
  }
609
 
610
  // ===== Event Handlers =====
611
  function bindEvents() {
612
- // Send message
613
  document.getElementById("btnSend").onclick = sendMessage;
614
  document.getElementById("inputMessage").onkeydown = (e) => {
615
  if (e.key === "Enter" && !e.shiftKey) {
@@ -618,10 +609,8 @@
618
  }
619
  };
620
 
621
- // Voice
622
  document.getElementById("btnMic").onclick = () => startRecording();
623
 
624
- // Mute toggle
625
  document.getElementById("btnMute").onclick = () => {
626
  state.isMuted = !state.isMuted;
627
  document
@@ -630,54 +619,60 @@
630
  document
631
  .getElementById("iconMuted")
632
  .classList.toggle("hidden", !state.isMuted);
633
- if (state.isMuted) AudioManager.stop();
634
  };
635
 
636
- // Back button
637
  document.getElementById("btnBack").onclick = () => {
638
- AudioManager.stop();
639
  state.sessionId = null;
640
  document.getElementById("chatMessages").innerHTML = "";
641
  document.getElementById("lesson").classList.add("hidden");
642
  document.getElementById("onboarding").classList.remove("hidden");
643
  document.getElementById("btnStart").textContent = "Start Lesson";
644
  document.getElementById("btnStart").disabled = true;
645
- // Reset topic selection
646
  document
647
  .querySelectorAll("#topicGrid .selection-card")
648
  .forEach((c) => c.classList.remove("selected"));
649
  };
650
 
651
- // Start button
652
  document.getElementById("btnStart").onclick = startLesson;
 
 
 
 
 
 
 
 
653
  }
654
 
655
  // ===== Init =====
656
  async function init() {
657
  bindEvents();
658
 
659
- // Load data in parallel
660
  const [currData, teachData] = await Promise.all([
661
  loadCurriculum(),
662
  loadTeachers(),
663
  ]);
664
 
665
- // Render Step 1: Target language
666
  renderLanguageGrid("targetLangGrid", (key) => {
667
  state.targetLang = key;
668
  setTimeout(() => {
669
- renderLanguageGrid("instructionLangGrid", (k) => {
670
- state.instructionLang = k;
671
- setTimeout(() => {
672
- renderLevelCards();
673
- renderStep(3);
674
- }, 300);
675
- }, "english");
 
 
 
 
676
  renderStep(2);
677
  }, 300);
678
  });
679
  }
680
 
681
- // Boot
682
  document.addEventListener("DOMContentLoaded", init);
683
  })();
 
3
  (function () {
4
  "use strict";
5
 
6
+ // ===== Language to BCP-47 voice mapping =====
7
+ const LANG_VOICE_MAP = {
8
+ hindi: "hi-IN",
9
+ tamil: "ta-IN",
10
+ telugu: "te-IN",
11
+ bengali: "bn-IN",
12
+ marathi: "mr-IN",
13
+ gujarati: "gu-IN",
14
+ kannada: "kn-IN",
15
+ malayalam: "ml-IN",
16
+ english: "en-US",
17
+ spanish: "es-ES",
18
+ french: "fr-FR",
19
+ japanese: "ja-JP",
20
+ };
21
+
22
  // ===== State =====
23
  const state = {
24
  step: 1,
 
37
  teachersData: null,
38
  };
39
 
40
+ // ===== Speech Manager (Browser TTS) =====
41
+ const SpeechManager = {
42
+ speaking: false,
43
  queue: [],
 
 
44
 
45
+ speak(text) {
46
+ if (state.isMuted || !text) return;
47
+ // Clean markup/special blocks from text
48
+ const clean = text
49
+ .replace(/\[CULTURAL NOTE:.*?\]/gs, "")
50
+ .replace(/\[DIALOGUE\].*?\[\/DIALOGUE\]/gs, "")
51
+ .replace(/\[SENTENCE_BUILDER:.*?\]/gs, "")
52
+ .replace(/\*\*/g, "")
53
+ .replace(/\*/g, "")
54
+ .replace(/\[.*?\]/g, "")
55
+ .trim();
56
+ if (!clean) return;
57
+ this.queue.push(clean);
58
+ if (!this.speaking) this._speakNext();
59
  },
60
 
61
+ _speakNext() {
62
  if (this.queue.length === 0) {
63
+ this.speaking = false;
64
  AvatarController.setIdle();
65
  return;
66
  }
67
+ this.speaking = true;
68
  AvatarController.setSpeaking();
69
+
70
+ const text = this.queue.shift();
71
+ const synth = window.speechSynthesis;
72
+ const utt = new SpeechSynthesisUtterance(text);
73
+
74
+ // Try to pick a voice matching the target language
75
+ const langCode = LANG_VOICE_MAP[state.targetLang] || "en-US";
76
+ const voices = synth.getVoices();
77
+ const match = voices.find(
78
+ (v) => v.lang === langCode || v.lang.startsWith(langCode.split("-")[0])
79
+ );
80
+ if (match) utt.voice = match;
81
+ utt.lang = langCode;
82
+ utt.rate = 0.9;
83
+
84
+ utt.onend = () => this._speakNext();
85
+ utt.onerror = () => this._speakNext();
86
+
87
+ synth.speak(utt);
88
  },
89
 
90
  stop() {
91
  this.queue = [];
92
+ window.speechSynthesis.cancel();
93
+ this.speaking = false;
 
 
 
94
  AvatarController.setIdle();
95
  },
96
  };
97
 
98
  // ===== Avatar Controller =====
99
  const AvatarController = {
 
100
  blinkInterval: null,
101
 
102
  load(teacherId, color) {
 
113
  teacherId.charAt(0).toUpperCase() + teacherId.slice(1);
114
  document.getElementById("avatarName").style.color = color;
115
 
 
116
  document.documentElement.style.setProperty("--teacher-color", color);
 
117
  this.startBlinking();
118
  },
119
 
120
  setSpeaking() {
121
+ document.getElementById("avatarContainer").classList.add("speaking");
 
 
 
 
122
  },
123
 
124
  setIdle() {
125
+ document.getElementById("avatarContainer").classList.remove("speaking");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  },
127
 
128
  startBlinking() {
 
194
  if (key === preselectKey) card.classList.add("selected");
195
  card.dataset.key = key;
196
  card.innerHTML = `
197
+ <span class="card-icon">${lang.flag}</span>
198
+ <span class="card-title">${lang.name}</span>
199
+ <span class="card-subtitle">${lang.native_name}</span>
200
+ `;
201
  card.onclick = () => {
202
  container
203
  .querySelectorAll(".selection-card")
 
240
  card.className = "teacher-card";
241
  card.style.setProperty("--teacher-color", t.color);
242
  card.innerHTML = `
243
+ <div class="teacher-avatar-preview" style="background:${t.color_light}">
244
+ <img src="/static/svg/${t.avatar}" alt="${t.name}">
245
+ </div>
246
+ <h3>${t.name}</h3>
247
+ <div class="teacher-title" style="color:${t.color}">${t.title}</div>
248
+ <div class="teacher-style">${t.style}</div>
249
+ <div class="teacher-desc">${t.description}</div>
250
+ <button class="btn-voice-preview" data-teacher="${t.id}">Preview Voice</button>
251
+ `;
252
  card.onclick = (e) => {
253
  if (e.target.classList.contains("btn-voice-preview")) return;
254
  container
 
269
  container.appendChild(card);
270
  }
271
 
272
+ // Voice preview buttons — use browser TTS
273
  container.querySelectorAll(".btn-voice-preview").forEach((btn) => {
274
  btn.onclick = async (e) => {
275
  e.stopPropagation();
 
283
  }),
284
  });
285
  btn.textContent = "Preview Voice";
286
+ if (data.text) {
287
+ const synth = window.speechSynthesis;
288
+ const utt = new SpeechSynthesisUtterance(data.text);
289
+ const langCode =
290
+ LANG_VOICE_MAP[state.targetLang] || "en-US";
291
+ const voices = synth.getVoices();
292
+ const match = voices.find(
293
+ (v) =>
294
+ v.lang === langCode ||
295
+ v.lang.startsWith(langCode.split("-")[0])
296
+ );
297
+ if (match) utt.voice = match;
298
+ utt.lang = langCode;
299
+ utt.rate = 0.9;
300
+ synth.speak(utt);
301
  }
302
  };
303
  });
 
313
  const card = document.createElement("div");
314
  card.className = "selection-card";
315
  card.innerHTML = `
316
+ <span class="card-icon">${t.icon}</span>
317
+ <span class="card-title">${t.title}</span>
318
+ <span class="card-subtitle">${t.description}</span>
319
+ `;
320
  card.onclick = () => {
321
  container
322
  .querySelectorAll(".selection-card")
 
376
  ) || { color: "#E91E63" };
377
  AvatarController.load(state.teacherId, teacher.color);
378
 
379
+ // Show greeting and auto-speak
380
  addMessage("tutor", data.greeting);
381
+ SpeechManager.speak(data.greeting);
 
 
 
 
382
 
383
  document.getElementById("inputMessage").focus();
384
  }
 
391
  div.innerHTML = formatMessage(text);
392
  container.appendChild(div);
393
 
 
394
  renderSpecialBlocks(div, text);
 
395
  scrollToBottom();
396
  }
397
 
398
  function formatMessage(text) {
 
399
  let html = text
400
  .replace(/&/g, "&amp;")
401
  .replace(/</g, "&lt;")
402
  .replace(/>/g, "&gt;");
403
 
 
404
  html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
 
405
  html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
 
406
  html = html.replace(/\n/g, "<br>");
407
 
 
408
  html = html.replace(
409
  /\[CULTURAL NOTE:\s*(.*?)\]/gs,
410
  '<div class="cultural-note"><div class="cultural-note-title">Cultural Insight</div>$1</div>'
411
  );
412
 
 
413
  html = html.replace(
414
  /\[DIALOGUE\](.*?)\[\/DIALOGUE\]/gs,
415
  '<div class="dialogue-block">$1</div>'
 
419
  }
420
 
421
  function renderSpecialBlocks(msgEl, text) {
422
+ const sbMatch = text.match(/\[SENTENCE_BUILDER:\s*(.*?)\]/);
 
 
 
423
  if (sbMatch) {
424
  const words = sbMatch[1].split("|").map((w) => w.trim());
425
  const shuffled = [...words].sort(() => Math.random() - 0.5);
 
428
  const block = document.createElement("div");
429
  block.className = "sentence-builder";
430
  block.innerHTML = `
431
+ <div class="sentence-builder-title">Arrange the words in the correct order:</div>
432
+ <div class="word-tiles">${shuffled.map((w) => `<span class="word-tile" data-word="${escapeAttr(w)}">${w}</span>`).join("")}</div>
433
+ <div class="sentence-drop-zone" data-correct="${escapeAttr(correctOrder)}"></div>
434
+ <div class="sentence-builder-actions">
435
+ <button class="btn-check-sentence">Check</button>
436
+ <button class="btn-reset-sentence">Reset</button>
437
+ </div>
438
+ `;
439
  msgEl.appendChild(block);
440
 
 
441
  const tiles = block.querySelectorAll(".word-tile");
442
  const dropZone = block.querySelector(".sentence-drop-zone");
443
 
 
538
  updateXP();
539
  }
540
 
541
+ // Auto-speak using browser TTS
542
+ SpeechManager.speak(data.reply);
 
 
543
  }
544
 
545
  // ===== Voice Input =====
 
591
  }
592
  if (data.reply) {
593
  addMessage("tutor", data.reply);
594
+ SpeechManager.speak(data.reply);
595
  }
596
  if (data.xp) {
597
  state.xp = data.xp;
598
  updateXP();
599
  }
 
 
 
600
  }
601
 
602
  // ===== Event Handlers =====
603
  function bindEvents() {
 
604
  document.getElementById("btnSend").onclick = sendMessage;
605
  document.getElementById("inputMessage").onkeydown = (e) => {
606
  if (e.key === "Enter" && !e.shiftKey) {
 
609
  }
610
  };
611
 
 
612
  document.getElementById("btnMic").onclick = () => startRecording();
613
 
 
614
  document.getElementById("btnMute").onclick = () => {
615
  state.isMuted = !state.isMuted;
616
  document
 
619
  document
620
  .getElementById("iconMuted")
621
  .classList.toggle("hidden", !state.isMuted);
622
+ if (state.isMuted) SpeechManager.stop();
623
  };
624
 
 
625
  document.getElementById("btnBack").onclick = () => {
626
+ SpeechManager.stop();
627
  state.sessionId = null;
628
  document.getElementById("chatMessages").innerHTML = "";
629
  document.getElementById("lesson").classList.add("hidden");
630
  document.getElementById("onboarding").classList.remove("hidden");
631
  document.getElementById("btnStart").textContent = "Start Lesson";
632
  document.getElementById("btnStart").disabled = true;
 
633
  document
634
  .querySelectorAll("#topicGrid .selection-card")
635
  .forEach((c) => c.classList.remove("selected"));
636
  };
637
 
 
638
  document.getElementById("btnStart").onclick = startLesson;
639
+
640
+ // Pre-load voices
641
+ if (window.speechSynthesis) {
642
+ window.speechSynthesis.getVoices();
643
+ window.speechSynthesis.onvoiceschanged = () => {
644
+ window.speechSynthesis.getVoices();
645
+ };
646
+ }
647
  }
648
 
649
  // ===== Init =====
650
  async function init() {
651
  bindEvents();
652
 
 
653
  const [currData, teachData] = await Promise.all([
654
  loadCurriculum(),
655
  loadTeachers(),
656
  ]);
657
 
 
658
  renderLanguageGrid("targetLangGrid", (key) => {
659
  state.targetLang = key;
660
  setTimeout(() => {
661
+ renderLanguageGrid(
662
+ "instructionLangGrid",
663
+ (k) => {
664
+ state.instructionLang = k;
665
+ setTimeout(() => {
666
+ renderLevelCards();
667
+ renderStep(3);
668
+ }, 300);
669
+ },
670
+ "english"
671
+ );
672
  renderStep(2);
673
  }, 300);
674
  });
675
  }
676
 
 
677
  document.addEventListener("DOMContentLoaded", init);
678
  })();