Spaces:
Sleeping
Sleeping
Switch TTS to browser SpeechSynthesis, fix chat API endpoint
Browse files- app.py +5 -39
- 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
|
| 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,
|
| 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 |
-
|
| 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 |
-
// =====
|
| 25 |
-
const
|
|
|
|
| 26 |
queue: [],
|
| 27 |
-
playing: false,
|
| 28 |
-
currentAudio: null,
|
| 29 |
|
| 30 |
-
|
| 31 |
-
if (state.isMuted || !
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
|
| 36 |
-
|
| 37 |
if (this.queue.length === 0) {
|
| 38 |
-
this.
|
| 39 |
AvatarController.setIdle();
|
| 40 |
return;
|
| 41 |
}
|
| 42 |
-
this.
|
| 43 |
AvatarController.setSpeaking();
|
| 44 |
-
|
| 45 |
-
const
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
},
|
| 57 |
|
| 58 |
stop() {
|
| 59 |
this.queue = [];
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
|
| 96 |
-
container.classList.add("speaking");
|
| 97 |
-
|
| 98 |
-
// Animate mouth via SVG inside if accessible
|
| 99 |
-
this._animateMouth(true);
|
| 100 |
},
|
| 101 |
|
| 102 |
setIdle() {
|
| 103 |
-
|
| 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 |
-
|
| 198 |
-
|
| 199 |
-
|
| 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 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 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.
|
| 287 |
-
const
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
}
|
| 290 |
};
|
| 291 |
});
|
|
@@ -301,10 +313,10 @@
|
|
| 301 |
const card = document.createElement("div");
|
| 302 |
card.className = "selection-card";
|
| 303 |
card.innerHTML = `
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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, "&")
|
| 396 |
.replace(/</g, "<")
|
| 397 |
.replace(/>/g, ">");
|
| 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 |
-
|
| 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 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 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 |
-
|
| 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)
|
| 634 |
};
|
| 635 |
|
| 636 |
-
// Back button
|
| 637 |
document.getElementById("btnBack").onclick = () => {
|
| 638 |
-
|
| 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(
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "&")
|
| 401 |
.replace(/</g, "<")
|
| 402 |
.replace(/>/g, ">");
|
| 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 |
})();
|