Files changed (1) hide show
  1. app.py +1099 -116
app.py CHANGED
@@ -1,148 +1,1131 @@
1
- """Piper TTS HTTP server — Under The Palm Tree.
2
- Robust against piper-tts API differences across versions.
3
-
4
- POST /tts {"text":"...", "lang":"en"|"ar"} -> audio/wav
5
- POST /synthesize {"text":"...", "voice":"..."} -> audio/wav
6
- GET /healthz -> "ok"
7
- GET / -> {"status":"ready","voices":[...]}
8
- """
9
- import io
10
  import os
 
11
  import wave
12
- from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- from fastapi import FastAPI, HTTPException
15
- from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.responses import Response, JSONResponse
17
- from pydantic import BaseModel
18
- from piper import PiperVoice
 
 
 
 
19
 
20
- VOICES_DIR = Path(os.environ.get("VOICES_DIR", "/voices"))
21
- DEFAULT_VOICE = "en_GB-alan-low"
22
- LANG_VOICE_MAP = {"ar": "ar_JO-kareem-medium", "en": "en_GB-alan-low"}
23
 
24
- app = FastAPI(title="Palm Tree TTS Piper")
25
- app.add_middleware(CORSMiddleware, allow_origins=["*"],
26
- allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"])
27
 
28
- _cache = {}
 
 
 
 
29
 
 
 
 
 
30
 
31
- def load_voice(name):
32
- if name in _cache:
33
- return _cache[name]
34
- onnx = VOICES_DIR / f"{name}.onnx"
35
- cfg = VOICES_DIR / f"{name}.onnx.json"
36
- if not onnx.exists():
37
- raise HTTPException(status_code=404, detail=f"voice '{name}' not found at {onnx}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
- v = PiperVoice.load(str(onnx), config_path=str(cfg))
40
- except TypeError:
41
- v = PiperVoice.load(str(onnx))
42
- _cache[name] = v
43
- return v
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
 
 
 
 
 
 
 
45
 
46
- def available_voices():
47
- return sorted(p.stem for p in VOICES_DIR.glob("*.onnx"))
 
 
 
 
 
 
48
 
 
49
 
50
- def synth_wav(text, voice_name):
51
- """Generate a WAV byte string, handling several piper-tts API shapes."""
52
- voice = load_voice(voice_name)
53
- buf = io.BytesIO()
54
 
55
- # --- Strategy 1: synthesize_wav(text, wav_file) writes a full WAV ---
56
- if hasattr(voice, "synthesize_wav"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  try:
58
- with wave.open(buf, "wb") as wf:
59
- voice.synthesize_wav(text, wf)
60
- data = buf.getvalue()
61
- if len(data) > 44: # more than just a header
62
- return data
63
- except Exception:
64
- buf = io.BytesIO()
65
-
66
- # --- Strategy 2: synthesize() yields AudioChunk objects ---
67
- if hasattr(voice, "synthesize"):
 
 
68
  try:
69
- chunks = list(voice.synthesize(text))
70
- if chunks:
71
- first = chunks[0]
72
- rate = getattr(first, "sample_rate", 22050)
73
- width = getattr(first, "sample_width", 2)
74
- channels = getattr(first, "sample_channels", 1)
75
- buf = io.BytesIO()
76
- with wave.open(buf, "wb") as wf:
77
- wf.setnchannels(channels)
78
- wf.setsampwidth(width)
79
- wf.setframerate(rate)
80
- for ch in chunks:
81
- pcm = getattr(ch, "audio_int16_bytes", None)
82
- if pcm is None:
83
- pcm = getattr(ch, "audio", b"")
84
- wf.writeframes(pcm)
85
- data = buf.getvalue()
86
- if len(data) > 44:
87
- return data
88
- except Exception as e:
89
- raise HTTPException(status_code=500, detail=f"synthesize failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- # --- Strategy 3: older synthesize_stream_raw -> raw int16 PCM ---
92
- if hasattr(voice, "synthesize_stream_raw"):
 
 
 
 
 
 
 
 
 
 
 
 
93
  try:
94
- cfg = getattr(voice, "config", None)
95
- rate = getattr(cfg, "sample_rate", 22050) if cfg else 22050
96
- buf = io.BytesIO()
97
- with wave.open(buf, "wb") as wf:
98
- wf.setnchannels(1)
99
- wf.setsampwidth(2)
100
- wf.setframerate(rate)
101
- for pcm in voice.synthesize_stream_raw(text):
102
- wf.writeframes(pcm)
103
- data = buf.getvalue()
104
- if len(data) > 44:
105
- return data
106
  except Exception as e:
107
- raise HTTPException(status_code=500, detail=f"stream synth failed: {e}")
 
108
 
109
- raise HTTPException(status_code=500, detail="no usable piper synth method")
 
110
 
 
111
 
112
- class Req(BaseModel):
113
- text: str
114
- voice: str | None = None
115
- lang: str | None = None
 
 
116
 
 
117
 
118
- @app.get("/")
119
- def root():
120
- return JSONResponse({"status": "ready", "voices": available_voices()})
 
 
 
 
 
 
 
 
121
 
 
 
 
122
 
123
- @app.get("/healthz")
124
- def healthz():
125
- return Response("ok", media_type="text/plain")
126
 
127
 
128
- def resolve(req):
129
- if req.voice:
130
- return req.voice
131
- if req.lang:
132
- return LANG_VOICE_MAP.get(req.lang.lower(), DEFAULT_VOICE)
133
- return DEFAULT_VOICE
 
 
 
 
 
134
 
135
 
136
- @app.post("/synthesize")
137
- def synthesize(req: Req):
138
- text = (req.text or "").strip()
139
- if not text:
140
- raise HTTPException(status_code=400, detail="text is required")
141
- audio = synth_wav(text, resolve(req))
142
- return Response(audio, media_type="audio/wav",
143
- headers={"Access-Control-Allow-Origin": "*"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
 
 
 
145
 
146
- @app.post("/tts")
147
- def tts(req: Req):
148
- return synthesize(req)
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import re
3
  import wave
4
+ import tempfile
5
+ import subprocess
6
+ from functools import lru_cache
7
+
8
+ import gradio as gr
9
+ from huggingface_hub import hf_hub_download
10
+ from piper import PiperVoice, SynthesisConfig
11
+ from groq import Groq
12
+
13
+
14
+ # ============================================================
15
+ # ARABIC TTS FIX
16
+ # ============================================================
17
+ try:
18
+ subprocess.run(["espeak-ng", "--voices=ar"], capture_output=True, timeout=10)
19
+ except:
20
+ pass
21
+
22
+
23
+ # ============================================================
24
+ # 1. CONFIGURATION
25
+ # ============================================================
26
+
27
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
28
+ client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
29
+ REPO_ID = "rhasspy/piper-voices"
30
+
31
+
32
+ # ============================================================
33
+ # 2. LATIFA AI — SYSTEM PROMPT
34
+ # ============================================================
35
+
36
+ LATIFA_PROMPT = (
37
+ "You are Latifa AI — the official voice guide of 'Under the Palm Tree' "
38
+ "(تحت شجرة النخيل), an interactive English learning project set in Oman, 1973.\n\n"
39
+
40
+ "IDENTITY:\n"
41
+ "- You are an Omani educational guide created by Thuraya Mohammed Ali Al-Naabia.\n"
42
+ "- You are warm, professional, knowledgeable, and encouraging.\n"
43
+ "- You speak naturally like a real Omani English teacher.\n\n"
44
+
45
+ "═══════════════════════════════════════\n"
46
+ "LANGUAGE RULES (CRITICAL — FOLLOW STRICTLY):\n"
47
+ "═══════════════════════════════════════\n"
48
+ "- Detect the user's language automatically.\n"
49
+ "- If the user writes in Arabic → respond ENTIRELY in Arabic. No English words mixed in.\n"
50
+ "- If the user writes in English → respond ENTIRELY in English. No Arabic words mixed in.\n"
51
+ "- If the user writes in mixed Arabic-English → respond in the SAME language the user used MOST.\n"
52
+ "- NEVER switch languages mid-sentence. Pick ONE language per response.\n"
53
+ "- When responding in Arabic, you MUST add full tashkeel (diacritics) to ALL Arabic words.\n"
54
+ " Example: مَرْحَبًا بِكُمْ فِي تَحْتَ شَجَرَةِ النَّخِيل\n"
55
+ " The tashkeel is essential for correct pronunciation by the text-to-speech system.\n"
56
+ "- When responding in English, use simple clear English suitable for language learners.\n\n"
57
+
58
+ "PROJECT:\n"
59
+ "- Name: 123 Let's Learn English Under The Palm Tree\n"
60
+ "- Founder: Thuraya Mohammed Ali Al-Naabia — Omani English Teacher & Educational Innovator\n"
61
+ "- Website: www.under-palm-tree.com\n"
62
+ "- Setting: Oman, 1973, Al-Qurawashiyah Village, Samail\n"
63
+ "- The project blends English learning with Omani heritage through interactive storytelling, "
64
+ "games, and cultural exploration.\n\n"
65
+
66
+ "MAIN CHARACTERS:\n"
67
+ "- Thuraya: Omani English teacher, black hijab, black abaya/blazer, story narrator. "
68
+ "Mature, natural, warm.\n"
69
+ "- John: British male teacher, tall, brown hair, light beard, beige shirt, brown trousers. "
70
+ "Professional, kind.\n"
71
+ "- Sophia: British female teacher, blonde hair/bun, green eyes, soft blouse, long skirt. "
72
+ "Friendly, gentle, curious.\n"
73
+ "- Malood: Small white baby goat, funny, mischievous. Eats Sophia's ring, chews notebooks.\n\n"
74
+
75
+ "LOCATIONS:\n"
76
+ "- Al-Qurawashiyah Village, Samail: Traditional Omani village with palm groves, "
77
+ "falaj irrigation, mud houses, old school.\n"
78
+ "- Seeb Airport (Muscat): Where John and Sophia first arrive.\n"
79
+ "- Dukkan Al-Hillah: The village shop.\n"
80
+ "- Samail Market (Souq): Local market visited via Uncle Nasser's pickup.\n"
81
+ "- Falaj: Traditional Omani water channels, UNESCO heritage.\n\n"
82
 
83
+ "CHAPTERS:\n"
84
+ "1. 'Thuraya's Book of Thoughts' — Turkey, 2026. Thuraya remembers: John reading the letter, "
85
+ "Sophia's hesitation, the flight, arrival in Oman.\n"
86
+ "2. 'The First Walk in the Village' — Oman, 1973. Sophia's first night in the clay house. "
87
+ "Morning walk: letj game, falaj, Sadoo's shop, Khamis, nahsher trees.\n"
88
+ "3. 'The First Day at School' — John writes 'HELLO' on chalkboard. Malood eats Sophia's ring "
89
+ "and chews notebooks.\n"
90
+ "4. 'The Morning Departure to the Souq' — Trip to Dukkan Al-Hillah, Uncle Nasser's pickup, "
91
+ "Samail market. Learning 'I want.' Meeting Habbabouh Saif.\n\n"
92
 
93
+ "GAMES:\n"
94
+ "- Palm Echo: Speaking and pronunciation practice. Listen, repeat, improve confidence.\n\n"
 
95
 
96
+ "SKILLS: Speaking, listening, reading, writing, vocabulary, grammar, pronunciation, spelling.\n\n"
 
 
97
 
98
+ "CULTURAL NOTES:\n"
99
+ "- Falaj: Traditional irrigation channels, UNESCO heritage.\n"
100
+ "- Oud & Luban: Fragrant incense (oud wood, frankincense).\n"
101
+ "- Letj: Traditional Omani children's game.\n"
102
+ "- 1973: Oman modernizing under Sultan Qaboos.\n\n"
103
 
104
+ "AUTHOR:\n"
105
+ "Thuraya Mohammed Ali Al-Naabia — Omani English teacher, educational innovator, storyteller.\n"
106
+ "Quote: 'Learning is not only about mastering a language; it is about discovering stories, "
107
+ "building connections, and creating new possibilities.'\n\n"
108
 
109
+ "WEBSITE PAGES: Home, Characters, Chapters, Games, Skills Practice, "
110
+ "Gallery, Music, Story World, Latifa AI. Guide users to pages when asked.\n\n"
111
+
112
+ "STORY RULES:\n"
113
+ "- Never invent story events or change character identities.\n"
114
+ "- Never change the era (always 1973) or add modern objects.\n"
115
+ "- If you don't know something, say: 'I don't know that part of the story yet.'\n\n"
116
+
117
+ "RESPONSE FORMATTING:\n"
118
+ "- Do NOT use emojis in your responses.\n"
119
+ "- Do NOT use markdown formatting (no **, ##, bullets with special chars).\n"
120
+ "- Write plain text only — the response will be spoken aloud by a voice system.\n"
121
+ "- Keep answers clear, natural, and conversational.\n"
122
+ "- For vocabulary: word → meaning in the other language → example sentence.\n"
123
+ "- For quizzes: ask one question at a time.\n"
124
+ "- Be culturally respectful and proud of Omani heritage.\n"
125
+ "- Maximum 3-4 sentences per response to keep it conversational."
126
+ )
127
+
128
+
129
+ # ============================================================
130
+ # 3. VOICE CONFIGURATION & LOADER
131
+ # ============================================================
132
+
133
+ VOICE_MAP = {
134
+ "en": {
135
+ "model": "en/en_GB/alba/medium/en_GB-alba-medium.onnx",
136
+ "config": "en/en_GB/alba/medium/en_GB-alba-medium.onnx.json",
137
+ },
138
+ "ar": {
139
+ "model": "ar/ar_JO/khalil/medium/ar_JO-khalil-medium.onnx",
140
+ "config": "ar/ar_JO/khalil/medium/ar_JO-khalil-medium.onnx.json",
141
+ },
142
+ }
143
+
144
+
145
+ @lru_cache(maxsize=4)
146
+ def load_piper_voice(model_file, config_file):
147
+ """Download and cache a Piper voice model from Hugging Face."""
148
  try:
149
+ mpath = hf_hub_download(
150
+ repo_id=REPO_ID, filename=model_file, repo_type="model"
151
+ )
152
+ cpath = hf_hub_download(
153
+ repo_id=REPO_ID, filename=config_file, repo_type="model"
154
+ )
155
+ return PiperVoice.load(mpath, cpath)
156
+ except Exception as e:
157
+ print(f"[Latifa] Voice load failed: {e}")
158
+ return None
159
+
160
+
161
+ def detect_lang(text):
162
+ """Return 'ar' if text is predominantly Arabic, else 'en'."""
163
+ if not text:
164
+ return "en"
165
+ arabic_chars = 0
166
+ total_chars = 0
167
+ for c in text:
168
+ if "\u0600" <= c <= "\u06FF" or "\u0750" <= c <= "\u077F" or "\u064B" <= c <= "\u065F":
169
+ arabic_chars += 1
170
+ if c.isalpha():
171
+ total_chars += 1
172
+ if total_chars == 0:
173
+ return "en"
174
+ return "ar" if arabic_chars / total_chars > 0.3 else "en"
175
+
176
+
177
+ def clean_text_for_tts(text, lang):
178
+ """Clean text before sending to Piper TTS."""
179
+ if not text:
180
+ return ""
181
+
182
+ text = re.sub(r'https?://\S+', '', text)
183
+ text = re.sub(r'\S+@\S+', '', text)
184
+
185
+ emoji_pattern = re.compile(
186
+ "["
187
+ "\U0001F600-\U0001F64F"
188
+ "\U0001F300-\U0001F5FF"
189
+ "\U0001F680-\U0001F6FF"
190
+ "\U0001F1E0-\U0001F1FF"
191
+ "\U00002702-\U000027B0"
192
+ "\U000024C2-\U0001F251"
193
+ "\U0001f926-\U0001f937"
194
+ "\U00010000-\U0010ffff"
195
+ "\u2640-\u2642"
196
+ "\u2600-\u2B55"
197
+ "\u200d"
198
+ "\u23cf"
199
+ "\u23e9"
200
+ "\u231a"
201
+ "\ufe0f"
202
+ "\u3030"
203
+ "]+",
204
+ flags=re.UNICODE,
205
+ )
206
+ text = emoji_pattern.sub('', text)
207
 
208
+ text = re.sub(r'\*+', '', text)
209
+ text = re.sub(r'#+\s*', '', text)
210
+ text = re.sub(r'`+', '', text)
211
+ text = re.sub(r'_{2,}', '', text)
212
+ text = re.sub(r'-{2,}', '', text)
213
+ text = re.sub(r'\|', '', text)
214
+ text = re.sub(r'[{}[\]<>()@#$%^&*=+~\\]', '', text)
215
 
216
+ if lang == "ar":
217
+ text = re.sub(
218
+ r'[^\u0600-\u06FF\u0750-\u077F\u064B-\u065F\u0670\s.,!?;:،؟!\n]',
219
+ ' ',
220
+ text,
221
+ )
222
+ else:
223
+ text = re.sub(r'[^a-zA-Z\s.,!?;:\'"\-\n]', ' ', text)
224
 
225
+ text = re.sub(r'\s+', ' ', text).strip()
226
 
227
+ max_chars = 800
228
+ if len(text) > max_chars:
229
+ text = text[:max_chars].rsplit(' ', 1)[0]
 
230
 
231
+ return text
232
+
233
+
234
+ def speak(text, lang="en"):
235
+ """Synthesize speech with Piper. Returns file path or None."""
236
+ clean = clean_text_for_tts(text, lang)
237
+ if not clean or len(clean) < 2:
238
+ return None
239
+
240
+ cfg = VOICE_MAP.get(lang, VOICE_MAP["en"])
241
+ voice = load_piper_voice(cfg["model"], cfg["config"])
242
+ if voice is None:
243
+ return None
244
+
245
+ syn = SynthesisConfig(
246
+ length_scale=1.0,
247
+ noise_scale=0.667,
248
+ noise_w_scale=0.8,
249
+ )
250
+
251
+ # Try 1: original text
252
+ try:
253
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
254
+ tmp.close()
255
+ with wave.open(tmp.name, "wb") as wf:
256
+ voice.synthesize_wav(clean, wf, syn_config=syn)
257
+ return tmp.name
258
+ except Exception as e1:
259
+ print(f"[Latifa] TTS attempt 1 failed ({lang}): {e1}")
260
+
261
+ # Try 2: Arabic without diacritics (tashkeel)
262
+ if lang == "ar":
263
  try:
264
+ no_diac = re.sub(r'[\u064B-\u065F\u0670]+', '', clean)
265
+ if no_diac and len(no_diac) >= 2:
266
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
267
+ tmp.close()
268
+ with wave.open(tmp.name, "wb") as wf:
269
+ voice.synthesize_wav(no_diac, wf, syn_config=syn)
270
+ print("[Latifa] Arabic TTS worked WITHOUT diacritics")
271
+ return tmp.name
272
+ except Exception as e2:
273
+ print(f"[Latifa] TTS attempt 2 failed: {e2}")
274
+
275
+ # Try 3: short Arabic text
276
  try:
277
+ short = clean[:100]
278
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
279
+ tmp.close()
280
+ with wave.open(tmp.name, "wb") as wf:
281
+ voice.synthesize_wav(short, wf, syn_config=syn)
282
+ print("[Latifa] Arabic TTS worked with SHORT text")
283
+ return tmp.name
284
+ except Exception as e3:
285
+ print(f"[Latifa] TTS attempt 3 failed: {e3}")
286
+
287
+ return None
288
+
289
+
290
+ # ============================================================
291
+ # 4. KNOWLEDGE BASE
292
+ # ============================================================
293
+
294
+ KB = {
295
+ "characters": {
296
+ "thuraya": (
297
+ "Thuraya — Omani English teacher, story narrator. Black hijab, "
298
+ "black abaya/blazer. Mature, natural, warm. Leads the story and "
299
+ "teaches English in Al-Qurawashiyah village."
300
+ ),
301
+ "john": (
302
+ "John — British male teacher. Tall, brown hair, light beard. "
303
+ "Beige shirt, brown trousers. Professional and kind. Comes to "
304
+ "Oman to teach English."
305
+ ),
306
+ "sophia": (
307
+ "Sophia — British female teacher. Blonde hair in a bun, green eyes. "
308
+ "Soft blouse, long skirt. Friendly, gentle, curious about Omani culture. "
309
+ "Her ring gets eaten by Malood!"
310
+ ),
311
+ "malood": (
312
+ "Malood — Small white baby goat. Funny and mischievous. Famous for "
313
+ "eating Sophia's ring and chewing notebooks. A beloved chaos-maker."
314
+ ),
315
+ },
316
+ "chapters": {
317
+ "1": (
318
+ "Chapter 1: 'Thuraya's Book of Thoughts' — Turkey, 2026. Thuraya "
319
+ "opens her notebook and remembers: John reading the letter in London, "
320
+ "Sophia's hesitation, the long flight, the heat of Oman's arrival, "
321
+ "and their first meeting at the airport."
322
+ ),
323
+ "2": (
324
+ "Chapter 2: 'The First Walk in the Village' — Oman, 1973, late afternoon. "
325
+ "Sophia's first night in the clay house with oud and luban. Morning walk "
326
+ "through the village: letj game, falaj, Sadoo's shop, Khamis the "
327
+ "Thursday-named man, nahsher trees."
328
+ ),
329
+ "3": (
330
+ "Chapter 3: 'The First Day at School' — Al-Qurawashiyah, 1973. John "
331
+ "writes 'HELLO' on the chalkboard and meets students. Malood the goat "
332
+ "eats Sophia's ring and chews the notebooks!"
333
+ ),
334
+ "4": (
335
+ "Chapter 4: 'The Morning Departure to the Souq' — Trip to Dukkan "
336
+ "Al-Hillah, then Uncle Nasser's pickup to Samail market. Class learns "
337
+ "'I want.' Meet the unforgettable Habbabouh Saif."
338
+ ),
339
+ },
340
+ "locations": {
341
+ "al-qurawashiyah": (
342
+ "Al-Qurawashiyah Village, Samail, Oman — Traditional Omani village "
343
+ "with palm groves, falaj irrigation, mud/clay houses, old school."
344
+ ),
345
+ "seeb": "Seeb Airport (Muscat) — Where John and Sophia first arrive in 1973.",
346
+ "falaj": (
347
+ "Falaj — Traditional Omani irrigation channels carrying mountain water "
348
+ "to villages. UNESCO heritage."
349
+ ),
350
+ "souq": (
351
+ "Samail Market (Souq) — Local market visited via Uncle Nasser's pickup."
352
+ ),
353
+ "dukkan": "Dukkan Al-Hillah — The village shop in Al-Qurawashiyah.",
354
+ },
355
+ "games": {
356
+ "palm echo": (
357
+ "Palm Echo — Speaking and pronunciation practice game. Listen to words, "
358
+ "repeat them, build confidence. Skills: pronunciation, speaking, listening."
359
+ ),
360
+ },
361
+ "skills": {
362
+ "speaking": "Speaking Practice — Conversations, repetition exercises, pronunciation drills. Try Palm Echo.",
363
+ "listening": "Listening Practice — Audio activities and story narration.",
364
+ "reading": "Reading Practice — Story chapters and interactive texts set in Oman.",
365
+ "writing": "Writing Practice — Guided exercises and creative prompts.",
366
+ "vocabulary": "Vocabulary — Contextual learning tied to Omani culture and daily life.",
367
+ "grammar": "Grammar — Rules learned through story examples.",
368
+ "pronunciation": "Pronunciation — Palm Echo game and repetition exercises.",
369
+ "spelling": "Spelling — Word games and activities.",
370
+ },
371
+ "cultural": {
372
+ "falaj": "Falaj: Traditional Omani irrigation — channels from mountains to villages. UNESCO heritage.",
373
+ "oud": "Oud: Fragrant wood incense, deeply valued in Omani homes.",
374
+ "luban": "Luban (Frankincense): Traditional Omani aromatic resin. Oman was the world's main source.",
375
+ "letj": "Letj: Traditional Omani children's game played in villages.",
376
+ "1973": (
377
+ "Oman 1973: Modernizing under Sultan Qaboos. Traditional village life "
378
+ "coexisting with development. Foreign teachers building the education system."
379
+ ),
380
+ },
381
+ "author": {
382
+ "name": "Thuraya Mohammed Ali Al-Naabia",
383
+ "title": "Founder of Under the Palm Tree",
384
+ "role": "Omani English Teacher & Educational Innovator",
385
+ "bio": (
386
+ "An Omani English teacher dedicated to transforming language learning "
387
+ "through creativity and technology. Combines storytelling, cultural "
388
+ "heritage, digital learning, and AI."
389
+ ),
390
+ "vision": "English learning as an immersive journey connecting language, culture, creativity.",
391
+ "philosophy": "The most effective learning happens when students are emotionally connected.",
392
+ "quote": (
393
+ "Learning is not only about mastering a language; it is about discovering "
394
+ "stories, building connections, and creating new possibilities."
395
+ ),
396
+ },
397
+ "sitemap": {
398
+ "home": "https://www.under-palm-tree.com — Project introduction and mission",
399
+ "characters": "https://www.under-palm-tree.com/characters — Story characters",
400
+ "chapters": "https://www.under-palm-tree.com/chapters — Story chapters in 1973 Oman",
401
+ "games": "https://www.under-palm-tree.com/games — Interactive educational games",
402
+ "skills": "https://www.under-palm-tree.com/skills — Skills practice hub",
403
+ "gallery": "https://www.under-palm-tree.com/gallery — Visual gallery",
404
+ "music": "https://www.under-palm-tree.com/music — Music and audio",
405
+ "story world": "https://www.under-palm-tree.com/story-world — Immersive 1973 world",
406
+ "latifa": "https://www.under-palm-tree.com/latifa-ai — Latifa AI page",
407
+ },
408
+ }
409
+
410
+
411
+ # ============================================================
412
+ # 5. KNOWLEDGE SEARCH
413
+ # ============================================================
414
+
415
+ QUERY_KEYWORDS = {
416
+ "character": "characters", "who is": "characters",
417
+ "john": "characters", "sophia": "characters", "malood": "characters",
418
+ "chapter": "chapters", "story": "chapters", "episode": "chapters",
419
+ "game": "games", "play": "games", "echo": "games", "palm echo": "games",
420
+ "practice": "skills", "skill": "skills", "learn": "skills",
421
+ "speaking": "skills", "listening": "skills", "reading": "skills",
422
+ "writing": "skills", "vocab": "skills", "grammar": "skills",
423
+ "pronunciation": "skills", "spelling": "skills",
424
+ "author": "author", "founder": "author", "creator": "author",
425
+ "thuraya mohammed": "author",
426
+ "oman": "cultural", "culture": "cultural", "1973": "cultural",
427
+ "falaj": "cultural", "oud": "cultural", "luban": "cultural", "letj": "cultural",
428
+ "page": "sitemap", "website": "sitemap", "navigate": "sitemap",
429
+ "where": "sitemap", "link": "sitemap", "show me": "sitemap",
430
+ "village": "locations", "location": "locations", "samail": "locations",
431
+ "airport": "locations", "souq": "locations", "shop": "locations",
432
+ "ثريا": "characters", "صوفيا": "characters",
433
+ "معلود": "characters", "الماعز": "characters",
434
+ "شخصية": "characters", "شخصيات": "characters",
435
+ "فصل": "chapters", "قصة": "chapters", "حكاية": "chapters",
436
+ "لعبة": "games", "بالم ايكو": "games", "صدى النخلة": "games",
437
+ "تمرين": "skills", "تعلم": "skills", "ممارسة": "skills",
438
+ "كتابة": "skills", "قواعد": "skills", "تهجئة": "skills",
439
+ "مؤلف": "author", "المؤسسة": "author", "المعلمة": "author",
440
+ "ثريا محمد": "author",
441
+ "عمان": "cultural", "ثقافة": "cultural", "تراث": "cultural",
442
+ "فلج": "cultural", "عود": "cultural", "لبان": "cultural", "لتج": "cultural",
443
+ "صفحة": "sitemap", "موقع": "sitemap", "رابط": "sitemap",
444
+ "قرية": "locations", "سمائل": "locations", "مطار": "locations",
445
+ "سوق": "locations", "دكان": "locations",
446
+ "مرحبا": None, "هلا": None, "السلام": None,
447
+ }
448
+
449
+
450
+ def search_kb(query):
451
+ """Return relevant knowledge-base entries as a context string."""
452
+ if not query:
453
+ return ""
454
+ q = query.lower()
455
+ cats = set()
456
+
457
+ for kw, cat in QUERY_KEYWORDS.items():
458
+ if kw in q and cat:
459
+ cats.add(cat)
460
+
461
+ if not cats:
462
+ return ""
463
+
464
+ hits = []
465
+ for cat in cats:
466
+ if cat == "author":
467
+ a = KB["author"]
468
+ hits.append(
469
+ f"Author: {a['name']} — {a['title']}. {a['bio']} "
470
+ f"Philosophy: {a['philosophy']} Quote: \"{a['quote']}\""
471
+ )
472
+ elif cat in KB:
473
+ for val in KB[cat].values():
474
+ hits.append(val)
475
+
476
+ seen = set()
477
+ unique = []
478
+ for h in hits:
479
+ if h not in seen:
480
+ seen.add(h)
481
+ unique.append(h)
482
+ return "\n---\n".join(unique[:6])
483
+
484
+
485
+ # ============================================================
486
+ # 6. CHAT FUNCTIONS
487
+ # ============================================================
488
+
489
+ STATUS_READY = '<div id="status-bar">🟡 Ready</div>'
490
+ STATUS_SPEAKING = '<div id="status-bar">🔊 Speaking</div>'
491
+ STATUS_ERROR = '<div id="status-bar">❌ Error</div>'
492
+
493
+
494
+ def build_messages(history, user_msg):
495
+ """Build Groq message list with knowledge-base context injected."""
496
+ system = LATIFA_PROMPT
497
+ kb = search_kb(user_msg)
498
+ if kb:
499
+ system += f"\n\nRELEVANT CONTEXT:\n{kb}"
500
+
501
+ msgs = [{"role": "system", "content": system}]
502
+
503
+ if history:
504
+ for m in history:
505
+ if isinstance(m, dict):
506
+ r, c = m.get("role"), m.get("content")
507
+ if r in ("user", "assistant") and c:
508
+ msgs.append({"role": r, "content": str(c)})
509
+ elif isinstance(m, (list, tuple)) and len(m) == 2:
510
+ u, b = m
511
+ if u:
512
+ msgs.append({"role": "user", "content": str(u)})
513
+ if b:
514
+ msgs.append({"role": "assistant", "content": str(b)})
515
+
516
+ msgs.append({"role": "user", "content": user_msg})
517
+ return msgs
518
 
519
+
520
+ def push(history, user_msg, bot_msg):
521
+ history.append({"role": "user", "content": user_msg})
522
+ history.append({"role": "assistant", "content": bot_msg})
523
+ return history
524
+
525
+
526
+ def latifa_respond(user_text, voice_file, history):
527
+ """Main handler: text or voice → text reply + Piper audio."""
528
+ if history is None:
529
+ history = []
530
+
531
+ # Voice input → Whisper transcription
532
+ if voice_file and os.path.exists(voice_file):
533
  try:
534
+ with open(voice_file, "rb") as af:
535
+ tr = client.audio.transcriptions.create(
536
+ model="whisper-large-v3", file=af
537
+ )
538
+ user_text = tr.text
 
 
 
 
 
 
 
539
  except Exception as e:
540
+ history = push(history, "🎙️ Voice", f"Transcription failed: {e}")
541
+ return history, None, "", None, STATUS_ERROR
542
 
543
+ if not user_text or not user_text.strip():
544
+ return history, None, "", None, STATUS_READY
545
 
546
+ user_text = user_text.strip()
547
 
548
+ if not client:
549
+ history = push(
550
+ history, user_text,
551
+ "API key not set. Please add GROQ_API_KEY in Space settings.",
552
+ )
553
+ return history, None, "", None, STATUS_ERROR
554
 
555
+ messages = build_messages(history, user_text)
556
 
557
+ try:
558
+ resp = client.chat.completions.create(
559
+ model="llama-3.3-70b-versatile",
560
+ messages=messages,
561
+ temperature=0.6,
562
+ max_tokens=300,
563
+ )
564
+ reply = resp.choices[0].message.content
565
+ except Exception as e:
566
+ history = push(history, user_text, f"AI error: {e}")
567
+ return history, None, "", None, STATUS_ERROR
568
 
569
+ lang = detect_lang(reply)
570
+ audio = speak(reply, lang)
571
+ history = push(history, user_text, reply)
572
 
573
+ status = STATUS_SPEAKING if audio else STATUS_READY
574
+ return history, audio, "", None, status
 
575
 
576
 
577
+ def replay(history):
578
+ """Re-synthesize the last assistant message."""
579
+ if not history:
580
+ return None, STATUS_READY
581
+ for m in reversed(history):
582
+ if isinstance(m, dict) and m.get("role") == "assistant":
583
+ txt = m.get("content", "")
584
+ if txt:
585
+ a = speak(txt, detect_lang(txt))
586
+ return a, (STATUS_SPEAKING if a else STATUS_READY)
587
+ return None, STATUS_READY
588
 
589
 
590
+ def clear_all():
591
+ return [], None, "", STATUS_READY
592
+
593
+
594
+ # ============================================================
595
+ # 7. CUSTOM THEME
596
+ # ============================================================
597
+
598
+ latifa_theme = gr.themes.Base(
599
+ primary_hue=gr.themes.colors.Color(
600
+ c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80",
601
+ c300="#8C6A3F", c400="#4A3520", c500="#360304",
602
+ c600="#360304", c700="#360304", c800="#360304",
603
+ c900="#360304", c950="#360304",
604
+ ),
605
+ secondary_hue=gr.themes.colors.Color(
606
+ c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80",
607
+ c300="#8C6A3F", c400="#4A3520", c500="#360304",
608
+ c600="#360304", c700="#360304", c800="#360304",
609
+ c900="#360304", c950="#360304",
610
+ ),
611
+ neutral_hue=gr.themes.colors.Color(
612
+ c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80",
613
+ c300="#8C6A3F", c400="#4A3520", c500="#4A3520",
614
+ c600="#4A3520", c700="#4A3520", c800="#4A3520",
615
+ c900="#4A3520", c950="#4A3520",
616
+ ),
617
+ font=[
618
+ gr.themes.GoogleFont("Almarai"),
619
+ "ui-sans-serif",
620
+ "sans-serif",
621
+ ],
622
+ font_mono=[
623
+ gr.themes.GoogleFont("Almarai"),
624
+ "ui-monospace",
625
+ "monospace",
626
+ ],
627
+ )
628
+
629
+
630
+ # ============================================================
631
+ # 8. CSS
632
+ # ============================================================
633
+
634
+ CUSTOM_CSS = """
635
+ @import url('https://fonts.googleapis.com/css2?family=Ovo&family=Almarai:wght@300;400;700;800&display=swap');
636
+
637
+ :root {
638
+ --sand: #F5EDD8;
639
+ --linen: #EDE0C4;
640
+ --clay: #C9AA80;
641
+ --earth: #8C6A3F;
642
+ --soil: #4A3520;
643
+ --burgundy: #360304;
644
+ }
645
+
646
+ .gradio-container {
647
+ background: var(--sand) !important;
648
+ font-family: 'Almarai', sans-serif !important;
649
+ color: var(--soil) !important;
650
+ }
651
+
652
+ body,
653
+ .gradio-container,
654
+ .gradio-container *,
655
+ .gradio-container p,
656
+ .gradio-container span,
657
+ .gradio-container label,
658
+ .gradio-container div,
659
+ .prose,
660
+ .prose p,
661
+ .prose span {
662
+ color: var(--soil) !important;
663
+ font-family: 'Almarai', sans-serif !important;
664
+ }
665
+
666
+ #latifa-header {
667
+ background: linear-gradient(135deg, var(--soil) 0%, var(--burgundy) 100%);
668
+ border-radius: 20px;
669
+ padding: 36px 28px 28px;
670
+ text-align: center;
671
+ margin-bottom: 16px;
672
+ position: relative;
673
+ overflow: hidden;
674
+ animation: fadeUp 0.6s ease-out;
675
+ }
676
+ #latifa-header * {
677
+ color: var(--sand) !important;
678
+ }
679
+ #latifa-header::before {
680
+ content: '';
681
+ position: absolute;
682
+ inset: 0;
683
+ background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 0 L60 30 L30 60 L0 30Z' fill='none' stroke='%23F5EDD8' stroke-opacity='0.05' stroke-width='1'/%3E%3C/svg%3E") repeat;
684
+ pointer-events: none;
685
+ }
686
+ #latifa-header h1 {
687
+ font-family: 'Ovo', serif !important;
688
+ font-size: 2.4rem !important;
689
+ color: var(--sand) !important;
690
+ margin: 0 0 6px !important;
691
+ letter-spacing: 0.02em;
692
+ position: relative;
693
+ }
694
+ #latifa-header .tagline {
695
+ font-size: 1rem;
696
+ color: var(--clay) !important;
697
+ letter-spacing: 0.04em;
698
+ margin: 0;
699
+ position: relative;
700
+ }
701
+ #latifa-header .tagline-ar {
702
+ font-size: 0.9rem;
703
+ color: var(--clay) !important;
704
+ opacity: 0.8;
705
+ margin: 4px 0 0;
706
+ position: relative;
707
+ direction: rtl;
708
+ }
709
+ #latifa-header .palm-deco {
710
+ font-size: 2.2rem;
711
+ margin-bottom: 8px;
712
+ position: relative;
713
+ }
714
+
715
+ #status-bar {
716
+ text-align: center;
717
+ padding: 6px 18px;
718
+ border-radius: 16px;
719
+ font-size: 0.82rem;
720
+ font-weight: 700;
721
+ color: var(--soil) !important;
722
+ background: var(--linen) !important;
723
+ border: 1px solid var(--clay);
724
+ margin-bottom: 10px;
725
+ display: inline-block;
726
+ }
727
+
728
+ #chat-window,
729
+ #chat-window *,
730
+ #chat-window div,
731
+ #chat-window span,
732
+ #chat-window p,
733
+ #chat-window label {
734
+ border-radius: 16px !important;
735
+ }
736
+ #chat-window {
737
+ background: var(--linen) !important;
738
+ border: 1px solid var(--clay) !important;
739
+ box-shadow: 0 4px 24px rgba(74,53,32,0.08) !important;
740
+ animation: fadeUp 0.8s ease-out;
741
+ }
742
+
743
+ #chat-window .user,
744
+ #chat-window .message.user,
745
+ #chat-window [class*="user"],
746
+ div[data-testid*="user"] .message,
747
+ div[data-testid*="user"] p,
748
+ div[data-testid*="user"] span {
749
+ background: var(--burgundy) !important;
750
+ color: #F5EDD8 !important;
751
+ border-radius: 14px !important;
752
+ }
753
+
754
+ #chat-window .bot,
755
+ #chat-window .message.bot,
756
+ #chat-window [class*="bot"],
757
+ div[data-testid*="bot"] .message,
758
+ div[data-testid*="bot"] p,
759
+ div[data-testid*="bot"] span {
760
+ background: var(--sand) !important;
761
+ color: var(--soil) !important;
762
+ border: 1px solid var(--clay) !important;
763
+ border-radius: 14px !important;
764
+ }
765
+
766
+ .gradio-chatbot .message,
767
+ .gradio-chatbot p,
768
+ .gradio-chatbot span,
769
+ .gradio-chatbot div {
770
+ color: var(--soil) !important;
771
+ }
772
+ .gradio-chatbot .user p,
773
+ .gradio-chatbot .user span,
774
+ .gradio-chatbot .user div {
775
+ color: #F5EDD8 !important;
776
+ }
777
+ .gradio-chatbot .bot p,
778
+ .gradio-chatbot .bot span,
779
+ .gradio-chatbot .bot div {
780
+ color: var(--soil) !important;
781
+ }
782
+
783
+ .chip-btn {
784
+ border-radius: 20px !important;
785
+ background: var(--sand) !important;
786
+ border: 1.5px solid var(--clay) !important;
787
+ color: var(--soil) !important;
788
+ padding: 6px 18px !important;
789
+ font-size: 0.82rem !important;
790
+ font-weight: 700 !important;
791
+ cursor: pointer !important;
792
+ transition: all 0.25s ease !important;
793
+ white-space: nowrap !important;
794
+ }
795
+ .chip-btn:hover {
796
+ background: var(--earth) !important;
797
+ color: var(--sand) !important;
798
+ border-color: var(--earth) !important;
799
+ transform: translateY(-1px) !important;
800
+ box-shadow: 0 4px 12px rgba(74,53,32,0.15) !important;
801
+ }
802
+ .chip-btn:active {
803
+ transform: scale(0.97) !important;
804
+ }
805
+
806
+ #input-row textarea,
807
+ #input-row input {
808
+ background: var(--sand) !important;
809
+ border: 1.5px solid var(--clay) !important;
810
+ border-radius: 12px !important;
811
+ color: var(--soil) !important;
812
+ font-size: 0.95rem !important;
813
+ }
814
+ #input-row textarea:focus,
815
+ #input-row input:focus {
816
+ border-color: var(--earth) !important;
817
+ box-shadow: 0 0 0 2px rgba(140,106,63,0.2) !important;
818
+ }
819
+ #input-row textarea::placeholder,
820
+ #input-row input::placeholder {
821
+ color: var(--earth) !important;
822
+ opacity: 0.7 !important;
823
+ }
824
+
825
+ #send-btn {
826
+ background: var(--burgundy) !important;
827
+ color: var(--sand) !important;
828
+ border: none !important;
829
+ border-radius: 12px !important;
830
+ font-weight: 700 !important;
831
+ font-size: 0.95rem !important;
832
+ padding: 10px 24px !important;
833
+ transition: all 0.25s ease !important;
834
+ }
835
+ #send-btn:hover {
836
+ background: var(--soil) !important;
837
+ transform: translateY(-1px) !important;
838
+ }
839
+ #replay-btn,
840
+ #clear-btn {
841
+ border-radius: 12px !important;
842
+ font-weight: 700 !important;
843
+ border: 1.5px solid var(--clay) !important;
844
+ color: var(--soil) !important;
845
+ background: var(--sand) !important;
846
+ transition: all 0.25s ease !important;
847
+ }
848
+ #replay-btn:hover,
849
+ #clear-btn:hover {
850
+ background: var(--earth) !important;
851
+ color: var(--sand) !important;
852
+ }
853
+
854
+ #audio-input,
855
+ #audio-output {
856
+ border-radius: 12px !important;
857
+ background: var(--linen) !important;
858
+ border: 1px solid var(--clay) !important;
859
+ }
860
+
861
+ label,
862
+ .label-wrap span {
863
+ color: var(--soil) !important;
864
+ font-weight: 700 !important;
865
+ }
866
+
867
+ #footer-credit {
868
+ text-align: center;
869
+ padding: 18px 10px 8px;
870
+ font-size: 0.75rem;
871
+ color: var(--earth) !important;
872
+ line-height: 1.6;
873
+ }
874
+ #footer-credit a {
875
+ color: var(--earth) !important;
876
+ text-decoration: underline;
877
+ }
878
+
879
+ @keyframes fadeUp {
880
+ from { opacity: 0; transform: translateY(12px); }
881
+ to { opacity: 1; transform: translateY(0); }
882
+ }
883
+
884
+ @media (max-width: 640px) {
885
+ #latifa-header { padding: 24px 16px 20px; }
886
+ #latifa-header h1 { font-size: 1.8rem !important; }
887
+ .chip-btn { font-size: 0.72rem !important; padding: 5px 10px !important; }
888
+ }
889
+
890
+ ::-webkit-scrollbar { width: 6px; }
891
+ ::-webkit-scrollbar-track { background: var(--linen); }
892
+ ::-webkit-scrollbar-thumb { background: var(--clay); border-radius: 3px; }
893
+ ::-webkit-scrollbar-thumb:hover { background: var(--earth); }
894
+
895
+ .gradio-accordion,
896
+ .gradio-group,
897
+ .gradio-box {
898
+ background: var(--linen) !important;
899
+ border-color: var(--clay) !important;
900
+ color: var(--soil) !important;
901
+ }
902
+
903
+ .gradio-container .prose h1,
904
+ .gradio-container .prose h2,
905
+ .gradio-container .prose h3,
906
+ .gradio-container .prose h4 {
907
+ color: var(--soil) !important;
908
+ }
909
+
910
+ .chips-label {
911
+ text-align: center;
912
+ margin: 8px 0 4px;
913
+ font-size: 0.8rem;
914
+ color: var(--earth) !important;
915
+ font-weight: 700;
916
+ }
917
+ """
918
+
919
+
920
+ # ============================================================
921
+ # 9. GRADIO UI
922
+ # ============================================================
923
+
924
+ with gr.Blocks(
925
+ css=CUSTOM_CSS,
926
+ title="Latifa AI — Under The Palm Tree",
927
+ theme=latifa_theme,
928
+ ) as demo:
929
+
930
+ # ---------- Header ----------
931
+ gr.HTML(
932
+ """
933
+ <link href="https://fonts.googleapis.com/css2?family=Ovo&family=Almarai:wght@300;400;700;800&display=swap" rel="stylesheet">
934
+ <div id="latifa-header">
935
+ <div class="palm-deco">🌴</div>
936
+ <h1>Latifa AI — لطيفة</h1>
937
+ <p class="tagline">Your Voice Guide Under The Palm Tree</p>
938
+ <p class="tagline-ar">دليلك الصوتي تحت شجرة النخيل</p>
939
+ </div>
940
+ """
941
+ )
942
+
943
+ # ---------- Status ----------
944
+ status = gr.HTML(STATUS_READY)
945
+
946
+ # ---------- Chat ----------
947
+ chatbot = gr.Chatbot(
948
+ label="Conversation",
949
+ height=380,
950
+ type="messages",
951
+ elem_id="chat-window",
952
+ )
953
+
954
+ # ---------- Quick Action Chips ----------
955
+ gr.HTML('<div class="chips-label">Quick Actions | إجراءات سريعة</div>')
956
+ with gr.Row(equal_height=False):
957
+ chip_char = gr.Button("👤 Characters | الشخصيات", elem_classes=["chip-btn"], size="sm")
958
+ chip_chap = gr.Button("📖 Chapters | الفصول", elem_classes=["chip-btn"], size="sm")
959
+ chip_game = gr.Button("🎮 Games | الألعاب", elem_classes=["chip-btn"], size="sm")
960
+ chip_vocab = gr.Button("📝 Vocabulary | المفردات", elem_classes=["chip-btn"], size="sm")
961
+ chip_quiz = gr.Button("❓ Quiz | اختبرني", elem_classes=["chip-btn"], size="sm")
962
+ chip_oman = gr.Button("🇴🇲 Oman 1973", elem_classes=["chip-btn"], size="sm")
963
+
964
+ # ---------- Input ----------
965
+ with gr.Row(elem_id="input-row"):
966
+ user_input = gr.Textbox(
967
+ show_label=False,
968
+ placeholder="Ask Latifa anything... | اسأل لطيفة عن أي شيء…",
969
+ scale=4,
970
+ elem_id="text-input",
971
+ )
972
+ voice_input = gr.Audio(
973
+ sources=["microphone"],
974
+ type="filepath",
975
+ show_label=False,
976
+ scale=2,
977
+ elem_id="audio-input",
978
+ )
979
+
980
+ # ---------- Action Buttons ----------
981
+ with gr.Row():
982
+ send_btn = gr.Button(
983
+ "Send & Speak | أرسل ✨", variant="primary", elem_id="send-btn", scale=3
984
+ )
985
+ replay_btn = gr.Button("🔊 Replay | أعد", elem_id="replay-btn", scale=1)
986
+ clear_btn = gr.Button("🗑️ Clear | مسح", elem_id="clear-btn", scale=1)
987
+
988
+ # ---------- Audio Output ----------
989
+ audio_output = gr.Audio(
990
+ label="Voice Response | الرد الصوتي", autoplay=True, elem_id="audio-output"
991
+ )
992
+
993
+ # ---------- Footer ----------
994
+ gr.HTML(
995
+ """
996
+ <div id="footer-credit">
997
+ Created by <strong>Thuraya Mohammed Ali Al-Naabia</strong>
998
+ — Founder of Under the Palm Tree<br>
999
+ <a href="https://www.under-palm-tree.com" target="_blank">
1000
+ www.under-palm-tree.com
1001
+ </a>
1002
+ </div>
1003
+ """
1004
+ )
1005
+
1006
+ # ========================================================
1007
+ # 10. EVENT WIRING
1008
+ # ========================================================
1009
+
1010
+ main_inputs = [user_input, voice_input, chatbot]
1011
+ main_outputs = [chatbot, audio_output, user_input, voice_input, status]
1012
+
1013
+ send_btn.click(
1014
+ fn=latifa_respond, inputs=main_inputs, outputs=main_outputs
1015
+ )
1016
+ user_input.submit(
1017
+ fn=latifa_respond, inputs=main_inputs, outputs=main_outputs
1018
+ )
1019
+
1020
+ replay_btn.click(
1021
+ fn=replay, inputs=[chatbot], outputs=[audio_output, status]
1022
+ )
1023
+ clear_btn.click(
1024
+ fn=clear_all, outputs=[chatbot, audio_output, user_input, status]
1025
+ )
1026
+
1027
+ chip_presets = {
1028
+ chip_char: "Tell me about the characters in Under the Palm Tree",
1029
+ chip_chap: "Show me the story chapters",
1030
+ chip_game: "What games can I play to practice English?",
1031
+ chip_vocab: "Help me practice vocabulary from the story",
1032
+ chip_quiz: "Quiz me on Under the Palm Tree",
1033
+ chip_oman: "Tell me about Oman in 1973",
1034
+ }
1035
+ for btn, prompt in chip_presets.items():
1036
+ btn.click(
1037
+ fn=lambda h, p=prompt: latifa_respond(p, None, h),
1038
+ inputs=[chatbot],
1039
+ outputs=main_outputs,
1040
+ )
1041
+
1042
+
1043
+ # ============================================================
1044
+ # 11. AVATAR BRIDGE — sends signals to parent website
1045
+ # ============================================================
1046
+
1047
+ BRIDGE_JS = """
1048
+ <script>
1049
+ (function() {
1050
+ function send(cmd) {
1051
+ try { window.parent.postMessage({latifa: cmd}, '*'); } catch(e) {}
1052
+ }
1053
+
1054
+ function watchAudio() {
1055
+ var audioEls = document.querySelectorAll('audio');
1056
+ for (var i = 0; i < audioEls.length; i++) {
1057
+ var a = audioEls[i];
1058
+ if (a._bridged) continue;
1059
+ a._bridged = true;
1060
+ a.addEventListener('playing', function() { send('talking'); });
1061
+ a.addEventListener('ended', function() { send('stop'); });
1062
+ a.addEventListener('pause', function() { send('stop'); });
1063
+ }
1064
+ }
1065
+
1066
+ function watchChat() {
1067
+ var observer = new MutationObserver(function(mutations) {
1068
+ for (var i = 0; i < mutations.length; i++) {
1069
+ var nodes = mutations[i].addedNodes;
1070
+ for (var j = 0; j < nodes.length; j++) {
1071
+ var node = nodes[j];
1072
+ if (node.nodeType !== 1) continue;
1073
+ var isBot = false;
1074
+ try {
1075
+ isBot = (node.querySelector && node.querySelector('[data-testid*="bot"]'))
1076
+ || (node.classList && node.classList.contains('bot'));
1077
+ } catch(e) {}
1078
+ if (isBot) {
1079
+ send('thinking');
1080
+ setTimeout(function() { watchAudio(); }, 1000);
1081
+ setTimeout(function() { send('stop'); }, 15000);
1082
+ }
1083
+ }
1084
+ }
1085
+ });
1086
+ var chatbot = null;
1087
+ try {
1088
+ chatbot = document.querySelector('.gradio-chatbot')
1089
+ || document.querySelector('[data-testid="chatbot"]');
1090
+ } catch(e) {}
1091
+ if (chatbot) {
1092
+ observer.observe(chatbot, { childList: true, subtree: true });
1093
+ } else {
1094
+ setTimeout(watchChat, 2000);
1095
+ }
1096
+ }
1097
+
1098
+ function watchMic() {
1099
+ document.addEventListener('click', function(e) {
1100
+ var el = e.target;
1101
+ if (el.closest && (
1102
+ el.closest('[data-testid="audio"]') ||
1103
+ el.closest('.audio-container')
1104
+ )) {
1105
+ send('listening');
1106
+ setTimeout(function() { send('stop'); }, 15000);
1107
+ }
1108
+ });
1109
+ }
1110
+
1111
+ if (document.readyState === 'loading') {
1112
+ document.addEventListener('DOMContentLoaded', function() {
1113
+ watchChat(); watchMic(); watchAudio();
1114
+ });
1115
+ } else {
1116
+ watchChat(); watchMic(); watchAudio();
1117
+ }
1118
+ })();
1119
+ </script>
1120
+ """
1121
+
1122
+ with demo:
1123
+ gr.HTML(BRIDGE_JS, visible=False)
1124
+
1125
 
1126
+ # ============================================================
1127
+ # 12. LAUNCH
1128
+ # ============================================================
1129
 
1130
+ if __name__ == "__main__":
1131
+ demo.launch()