Ahmed062646 commited on
Commit
b07a70d
Β·
verified Β·
1 Parent(s): a794b95

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +259 -132
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import streamlit as st
 
2
  import numpy as np
3
  import pandas as pd
4
  import pickle, os, re, time
@@ -9,7 +10,6 @@ import whisper
9
  from sentence_transformers import SentenceTransformer
10
  from sklearn.metrics.pairwise import cosine_similarity
11
  from huggingface_hub import hf_hub_download
12
- from audio_recorder_streamlit import audio_recorder
13
 
14
  # ── Page config ──────────────────────────────────────────────
15
  st.set_page_config(
@@ -23,8 +23,8 @@ HF_DATASET = "Ahmed062646/WhisperModel_Ai"
23
  TARGET_SR = 16000
24
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
25
  EMBED_NAME = "CAMeL-Lab/bert-base-arabic-camelbert-ca"
 
26
 
27
- # Ayah count per surah β€” for global ayah number computation
28
  SURAH_AYAH_COUNTS = [
29
  7,286,200,176,120,165,206,75,129,109,123,111,43,52,99,128,111,110,98,
30
  135,112,78,118,64,77,227,93,88,69,60,34,30,73,54,45,83,182,88,75,85,
@@ -57,32 +57,18 @@ RTL_LANGS = {"ur", "ar"}
57
  # ── Helpers ──────────────────────────────────────────────────
58
  def hf_download(filename):
59
  return hf_hub_download(
60
- repo_id=HF_DATASET, filename=filename, repo_type="dataset"
 
61
  )
62
 
 
 
 
63
  def get_ayah_audio_url(surah_id: int, ayah_id: int, reciter_id: str) -> str:
64
- """Returns Islamic Network CDN audio URL for a given ayah."""
65
- global_n = sum(SURAH_AYAH_COUNTS[:surah_id - 1]) + ayah_id
66
- return f"https://cdn.islamic.network/quran/audio/128/{reciter_id}/{global_n}.mp3"
67
-
68
- def ayah_audio_html(url: str, label: str) -> str:
69
- """
70
- Returns an HTML audio player for the ayah.
71
- Using raw HTML <audio> instead of st.audio() because st.audio()
72
- with a URL fetches server-side and fails on CDN URLs in hosted apps.
73
- The HTML audio element loads directly in the browser β€” no server proxy needed.
74
- """
75
- return f"""
76
- <div class="audio-verify">
77
- <p>πŸ”Š {label}</p>
78
- <audio controls preload="none" style="width:100%;margin-top:6px;">
79
- <source src="{url}" type="audio/mpeg">
80
- Your browser does not support the audio element.
81
- </audio>
82
- </div>
83
- """
84
-
85
- # ── Load all resources ────────────────────────────────────────
86
  @st.cache_resource(show_spinner=False)
87
  def load_resources():
88
  corpus_path = hf_download("quran_verses_multilingual.csv")
@@ -168,7 +154,7 @@ def hybrid_search(query_norm, df, tfidf_vec, tfidf_mat, fidx, embed, top_k=3):
168
  })
169
  return results
170
 
171
- # ── CSS ──────────────────────────────────────────────────────
172
  st.markdown("""
173
  <style>
174
  @import url('https://fonts.googleapis.com/css2?family=Amiri:ital@0;1&family=IBM+Plex+Sans:wght@300;400;600&display=swap');
@@ -176,8 +162,8 @@ st.markdown("""
176
 
177
  .header {
178
  background: linear-gradient(135deg, #0d1f17, #1b4332 50%, #2d6a4f);
179
- padding: 2.5rem 2rem; border-radius: 18px; text-align: center;
180
- margin-bottom: 2rem;
181
  box-shadow: 0 8px 32px rgba(0,0,0,0.22);
182
  border: 1px solid rgba(82,183,136,0.15);
183
  }
@@ -196,25 +182,29 @@ st.markdown("""
196
  }
197
  .rank-badge {
198
  display:inline-block; background:#1b4332; color:#95d5b2;
199
- padding:4px 14px; border-radius:20px; font-size:0.72rem;
200
- font-weight:600; letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
 
201
  }
202
  .rank-badge-alt {
203
  display:inline-block; background:#f1f3f5; color:#6c757d;
204
- padding:4px 14px; border-radius:20px; font-size:0.72rem;
205
- font-weight:600; letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
 
206
  }
207
  .ayah-meta { display:flex; gap:0.6rem; flex-wrap:wrap; margin-bottom:1rem; }
208
  .meta-chip {
209
- background:rgba(255,255,255,0.7); border:1px solid #c3e6cb;
210
- border-radius:6px; padding:3px 10px;
211
- font-size:0.82rem; color:#1b4332; font-weight:500;
 
212
  }
213
  .arabic-text {
214
- font-family:"Amiri",serif; font-size:2rem; text-align:right;
215
- direction:rtl; color:#0d1f17; line-height:2.3;
216
- background:rgba(255,255,255,0.75); padding:1.2rem 1.4rem;
217
- border-radius:10px; border-right:5px solid #2d6a4f; margin-bottom:1rem;
 
218
  }
219
  .translation-box {
220
  background:rgba(255,255,255,0.75); border-radius:10px;
@@ -222,8 +212,8 @@ st.markdown("""
222
  font-size:0.98rem; color:#2c3e50; line-height:1.75;
223
  }
224
  .translation-rtl {
225
- direction:rtl; text-align:right; font-size:1.2rem;
226
- font-family:"Amiri",serif;
227
  border-left:none !important; border-right:4px solid #52b788 !important;
228
  }
229
  .audio-verify {
@@ -231,26 +221,23 @@ st.markdown("""
231
  border-radius:10px; padding:0.8rem 1.1rem; margin-top:0.9rem;
232
  }
233
  .audio-verify p {
234
- font-size:0.8rem; color:#2d6a4f; font-weight:600; margin-bottom:4px;
 
235
  }
236
  .score-bar-wrap { margin:0.5rem 0 1rem; }
237
  .score-label { font-size:0.78rem; color:#6c757d; margin-bottom:4px; }
238
  .whisper-box {
239
  background:#f0fdf4; border:1px solid #c3e6cb;
240
- border-radius:10px; padding:1rem 1.2rem; margin:1rem 0; font-size:0.88rem;
 
241
  }
242
  .invalid-box {
243
  background:linear-gradient(135deg,#fff0f0,#ffe0e0);
244
- border:1px solid #ffb3b3; border-radius:14px; padding:1.8rem; text-align:center;
 
245
  }
246
  .invalid-box h3 { color:#c0392b; }
247
  .invalid-box p { color:#555; margin-top:6px; }
248
- .rec-prompt {
249
- background:#f0fdf4; border:1px dashed #52b788;
250
- border-radius:12px; padding:1.2rem; text-align:center;
251
- margin-top:0.5rem;
252
- }
253
- .rec-prompt p { color:#2d6a4f; font-size:0.88rem; margin-bottom:0.8rem; }
254
  .timing { color:#a0aab4; font-size:0.79rem; text-align:right; margin-top:0.75rem; }
255
  </style>
256
  """, unsafe_allow_html=True)
@@ -269,10 +256,10 @@ with st.spinner("⏳ Loading AI models β€” please wait (~2 min first time)..."):
269
  st.success("βœ… Models ready!")
270
 
271
  # ── Session state init ────────────────────────────────────────
272
- if "recorded_audio" not in st.session_state:
273
- st.session_state.recorded_audio = None
274
- if "use_recorded" not in st.session_state:
275
- st.session_state.use_recorded = False
276
 
277
  # ── Sidebar ──────────────────────────────────────────────────
278
  with st.sidebar:
@@ -288,7 +275,7 @@ with st.sidebar:
288
  st.markdown("---")
289
  st.markdown("### πŸ“– About")
290
  st.markdown("""
291
- HudaAI identifies Quranic verses from recitation using
292
  **Whisper ASR** + **FAISS semantic search**.
293
 
294
  **Course:** AI2002 Β· Artificial Intelligence
@@ -315,58 +302,190 @@ with col_l:
315
  audio_bytes = uploaded.read()
316
  input_source = "upload"
317
  st.audio(audio_bytes)
318
- # Clear any saved recording if user uploads a file
319
- st.session_state.use_recorded = False
320
 
321
  with tab_rec:
322
- st.markdown(
323
- "<p style='color:#6c757d;font-size:0.85rem;margin-bottom:4px;'>"
324
- "πŸ”΄ Click mic to <b>start</b> β€” click again to <b>stop</b><br>"
325
- "<small>Auto-stops after 3 seconds of silence</small></p>",
326
- unsafe_allow_html=True
327
- )
328
-
329
- # Returns WAV bytes directly β€” no download/upload needed
330
- # Works with Python 3.13, no native dependency issues
331
- recorded = audio_recorder(
332
- text="",
333
- recording_color="#e74c3c",
334
- neutral_color="#2d6a4f",
335
- icon_name="microphone",
336
- icon_size="3x",
337
- pause_threshold=3.0,
338
- sample_rate=TARGET_SR
339
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- if recorded:
342
- # New recording β€” store in session state
343
- st.session_state.recorded_audio = recorded
344
- st.session_state.use_recorded = False
345
-
346
- # Show confirmation prompt if there's a stored recording
347
- if st.session_state.recorded_audio is not None:
348
- st.markdown("""
349
- <div class="rec-prompt">
350
- <p>βœ… Recording captured β€” do you want to use it?</p>
351
- </div>
352
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
- st.audio(st.session_state.recorded_audio, format="audio/wav")
355
-
356
- col_yes, col_no = st.columns(2)
357
- with col_yes:
358
- if st.button("βœ… Use this recording", use_container_width=True):
359
- st.session_state.use_recorded = True
360
- with col_no:
361
- if st.button("πŸ—‘οΈ Discard", use_container_width=True):
362
- st.session_state.recorded_audio = None
363
- st.session_state.use_recorded = False
364
- st.rerun()
365
-
366
- if st.session_state.use_recorded and st.session_state.recorded_audio is not None:
367
- audio_bytes = st.session_state.recorded_audio
368
- input_source = "record"
369
- st.success("πŸŽ™οΈ Recording set β€” click **Identify Ayah** below.")
370
  with col_r:
371
  st.markdown("### 🌐 Settings Summary")
372
  st.info(
@@ -374,8 +493,15 @@ with col_r:
374
  f"**Verification reciter:** {reciter_name}"
375
  )
376
  if audio_bytes:
377
- src = "πŸŽ™οΈ Recorded" if input_source == "record" else "πŸ“ Uploaded"
378
- st.success(f"βœ… Audio ready ({src})")
 
 
 
 
 
 
 
379
 
380
  # ── Identify button ───────────────────────────────────────────
381
  if audio_bytes:
@@ -386,12 +512,12 @@ if audio_bytes:
386
  with open(tmp, "wb") as f:
387
  f.write(audio_bytes)
388
 
389
- with st.spinner("πŸ”„ Preprocessing audio..."):
390
  audio, _ = librosa.load(tmp, sr=TARGET_SR, mono=True)
391
  audio, _ = librosa.effects.trim(audio, top_db=20)
392
  audio = (audio / (np.abs(audio).max() + 1e-8) * 0.95).astype(np.float32)
393
 
394
- with st.spinner("πŸŽ™οΈ Transcribing with Whisper..."):
395
  t0 = time.time()
396
  result = wmodel.transcribe(
397
  audio, language="ar", task="transcribe",
@@ -417,13 +543,13 @@ if audio_bytes:
417
  <span style="color:#555;">Normalized:</span> {norm_text}<br>
418
  <span style="color:#888;font-size:0.79rem;">
419
  Language: {detected_lg} &nbsp;|&nbsp;
420
- No-speech prob: {round(no_sp_prob, 3)} &nbsp;|&nbsp;
421
- Whisper time: {whisper_ms}ms
422
  </span>
423
  </div>
424
  """, unsafe_allow_html=True)
425
 
426
- # ── Validation ────────────────────────────────────────
427
  invalid, reason = False, ""
428
  if no_sp_prob > 0.60:
429
  invalid = True
@@ -448,11 +574,9 @@ if audio_bytes:
448
  """, unsafe_allow_html=True)
449
 
450
  else:
451
- with st.spinner("πŸ” Searching Quran database..."):
452
  t1 = time.time()
453
- matches = hybrid_search(
454
- norm_text, df, tfidf_vec, tfidf_mat, fidx, embed, top_k
455
- )
456
  search_ms = round((time.time() - t1) * 1000)
457
 
458
  if not matches or matches[0]["score"] < min_score:
@@ -461,7 +585,7 @@ if audio_bytes:
461
  <div class="invalid-box">
462
  <h3>β›” No Quranic Ayah Found</h3>
463
  <p>❌ Audio does not appear to be a Quranic recitation.</p>
464
- <p><b>Reason:</b> Best score ({best:.4f}) below threshold ({min_score}).</p>
465
  </div>
466
  """, unsafe_allow_html=True)
467
 
@@ -477,10 +601,7 @@ if audio_bytes:
477
  f'<span class="rank-badge-alt">Candidate #{i+1}</span>'
478
  )
479
 
480
- translation = (
481
- m["ayah_ar"] if lang_code == "ar"
482
- else m.get(f"ayah_{lang_code}", "")
483
- )
484
  if not translation or translation == "nan":
485
  translation = "[Translation not available]"
486
 
@@ -519,21 +640,27 @@ if audio_bytes:
519
  </div>
520
  """, unsafe_allow_html=True)
521
 
522
- # ── Ayah verification audio ───────────────
523
- # Uses raw HTML <audio> element β€” loads CDN URL
524
- # directly in browser without server proxy
525
  if show_audio:
526
- audio_url = get_ayah_audio_url(
527
- m["surah_id"], m["ayah_id"], reciter_id
528
- )
529
- st.markdown(
530
- ayah_audio_html(
531
- audio_url,
532
- f"Verify β€” {reciter_name} Β· "
533
- f"Surah {m['surah_id']} Β· Ayah {m['ayah_id']}"
534
- ),
535
- unsafe_allow_html=True
536
- )
 
 
 
 
 
 
 
 
537
 
538
  st.markdown(
539
  f'<p class="timing">⏱ Whisper: {whisper_ms}ms &nbsp;|&nbsp;'
 
1
  import streamlit as st
2
+ import streamlit.components.v1 as components
3
  import numpy as np
4
  import pandas as pd
5
  import pickle, os, re, time
 
10
  from sentence_transformers import SentenceTransformer
11
  from sklearn.metrics.pairwise import cosine_similarity
12
  from huggingface_hub import hf_hub_download
 
13
 
14
  # ── Page config ──────────────────────────────────────────────
15
  st.set_page_config(
 
23
  TARGET_SR = 16000
24
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
25
  EMBED_NAME = "CAMeL-Lab/bert-base-arabic-camelbert-ca"
26
+ MIN_SCORE = 0.12
27
 
 
28
  SURAH_AYAH_COUNTS = [
29
  7,286,200,176,120,165,206,75,129,109,123,111,43,52,99,128,111,110,98,
30
  135,112,78,118,64,77,227,93,88,69,60,34,30,73,54,45,83,182,88,75,85,
 
57
  # ── Helpers ──────────────────────────────────────────────────
58
  def hf_download(filename):
59
  return hf_hub_download(
60
+ repo_id=HF_DATASET, filename=filename, repo_type="dataset",
61
+ token=os.environ.get("HF_TOKEN")
62
  )
63
 
64
+ def get_global_ayah_number(surah_id: int, ayah_id: int) -> int:
65
+ return sum(SURAH_AYAH_COUNTS[:surah_id - 1]) + ayah_id
66
+
67
  def get_ayah_audio_url(surah_id: int, ayah_id: int, reciter_id: str) -> str:
68
+ n = get_global_ayah_number(surah_id, ayah_id)
69
+ return f"https://cdn.islamic.network/quran/audio/128/{reciter_id}/{n}.mp3"
70
+
71
+ # ── Load resources ───────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  @st.cache_resource(show_spinner=False)
73
  def load_resources():
74
  corpus_path = hf_download("quran_verses_multilingual.csv")
 
154
  })
155
  return results
156
 
157
+ # ── Global CSS ───────────────────────────────────────────────
158
  st.markdown("""
159
  <style>
160
  @import url('https://fonts.googleapis.com/css2?family=Amiri:ital@0;1&family=IBM+Plex+Sans:wght@300;400;600&display=swap');
 
162
 
163
  .header {
164
  background: linear-gradient(135deg, #0d1f17, #1b4332 50%, #2d6a4f);
165
+ padding: 2.5rem 2rem; border-radius: 18px;
166
+ text-align: center; margin-bottom: 2rem;
167
  box-shadow: 0 8px 32px rgba(0,0,0,0.22);
168
  border: 1px solid rgba(82,183,136,0.15);
169
  }
 
182
  }
183
  .rank-badge {
184
  display:inline-block; background:#1b4332; color:#95d5b2;
185
+ padding:4px 14px; border-radius:20px;
186
+ font-size:0.72rem; font-weight:600;
187
+ letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
188
  }
189
  .rank-badge-alt {
190
  display:inline-block; background:#f1f3f5; color:#6c757d;
191
+ padding:4px 14px; border-radius:20px;
192
+ font-size:0.72rem; font-weight:600;
193
+ letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
194
  }
195
  .ayah-meta { display:flex; gap:0.6rem; flex-wrap:wrap; margin-bottom:1rem; }
196
  .meta-chip {
197
+ background:rgba(255,255,255,0.7);
198
+ border:1px solid #c3e6cb; border-radius:6px;
199
+ padding:3px 10px; font-size:0.82rem;
200
+ color:#1b4332; font-weight:500;
201
  }
202
  .arabic-text {
203
+ font-family:"Amiri",serif; font-size:2rem;
204
+ text-align:right; direction:rtl; color:#0d1f17;
205
+ line-height:2.3; background:rgba(255,255,255,0.75);
206
+ padding:1.2rem 1.4rem; border-radius:10px;
207
+ border-right:5px solid #2d6a4f; margin-bottom:1rem;
208
  }
209
  .translation-box {
210
  background:rgba(255,255,255,0.75); border-radius:10px;
 
212
  font-size:0.98rem; color:#2c3e50; line-height:1.75;
213
  }
214
  .translation-rtl {
215
+ direction:rtl; text-align:right;
216
+ font-size:1.2rem; font-family:"Amiri",serif;
217
  border-left:none !important; border-right:4px solid #52b788 !important;
218
  }
219
  .audio-verify {
 
221
  border-radius:10px; padding:0.8rem 1.1rem; margin-top:0.9rem;
222
  }
223
  .audio-verify p {
224
+ font-size:0.8rem; color:#2d6a4f;
225
+ font-weight:600; margin-bottom:5px;
226
  }
227
  .score-bar-wrap { margin:0.5rem 0 1rem; }
228
  .score-label { font-size:0.78rem; color:#6c757d; margin-bottom:4px; }
229
  .whisper-box {
230
  background:#f0fdf4; border:1px solid #c3e6cb;
231
+ border-radius:10px; padding:1rem 1.2rem;
232
+ margin:1rem 0; font-size:0.88rem;
233
  }
234
  .invalid-box {
235
  background:linear-gradient(135deg,#fff0f0,#ffe0e0);
236
+ border:1px solid #ffb3b3; border-radius:14px;
237
+ padding:1.8rem; text-align:center;
238
  }
239
  .invalid-box h3 { color:#c0392b; }
240
  .invalid-box p { color:#555; margin-top:6px; }
 
 
 
 
 
 
241
  .timing { color:#a0aab4; font-size:0.79rem; text-align:right; margin-top:0.75rem; }
242
  </style>
243
  """, unsafe_allow_html=True)
 
256
  st.success("βœ… Models ready!")
257
 
258
  # ── Session state init ────────────────────────────────────────
259
+ if "rec_audio_bytes" not in st.session_state:
260
+ st.session_state["rec_audio_bytes"] = None
261
+ if "rec_ready" not in st.session_state:
262
+ st.session_state["rec_ready"] = False
263
 
264
  # ── Sidebar ──────────────────────────────────────────────────
265
  with st.sidebar:
 
275
  st.markdown("---")
276
  st.markdown("### πŸ“– About")
277
  st.markdown("""
278
+ HudaAI identifies Quranic verses using
279
  **Whisper ASR** + **FAISS semantic search**.
280
 
281
  **Course:** AI2002 Β· Artificial Intelligence
 
302
  audio_bytes = uploaded.read()
303
  input_source = "upload"
304
  st.audio(audio_bytes)
 
 
305
 
306
  with tab_rec:
307
+ # ── Recorder: JS sends base64 WAV to a hidden text_area, Python reads it ──
308
+ # We write the b64 payload to a temp file path stored in session_state key
309
+ # and trigger a rerun via a checkbox toggle bridge.
310
+
311
+ # Hidden textarea bridge β€” JS will inject base64 audio here
312
+ b64_bridge = st.text_area(
313
+ "rec_bridge", value="", key="rec_b64_bridge",
314
+ label_visibility="collapsed", height=1,
315
+ help="internal"
 
 
 
 
 
 
 
 
316
  )
317
+ # Hide the textarea with CSS
318
+ st.markdown("""
319
+ <style>
320
+ [data-testid="stTextArea"][aria-label="rec_bridge"],
321
+ div:has(> [data-testid="stTextArea"] textarea#rec_b64_bridge) { display:none !important; }
322
+ </style>""", unsafe_allow_html=True)
323
+
324
+ # Process incoming base64 audio from JS
325
+ if b64_bridge and b64_bridge.startswith("data:audio"):
326
+ import base64 as _b64
327
+ header, b64data = b64_bridge.split(",", 1)
328
+ raw = _b64.b64decode(b64data)
329
+ st.session_state["rec_audio_bytes"] = raw
330
+ st.session_state["rec_ready"] = True
331
+
332
+ # Show recorder component
333
+ components.html("""
334
+ <html><head><meta charset="utf-8"><style>
335
+ *{margin:0;padding:0;box-sizing:border-box;}
336
+ body{font-family:'Segoe UI',sans-serif;background:transparent;padding:8px;}
337
+ .card{background:linear-gradient(145deg,#0f2419,#1b4332);border-radius:16px;
338
+ padding:16px 22px;box-shadow:0 6px 24px rgba(0,0,0,0.3);
339
+ border:1px solid rgba(82,183,136,0.2);}
340
+ .toprow{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}
341
+ .left{display:flex;align-items:center;gap:8px;}
342
+ .dot{width:8px;height:8px;border-radius:50%;background:#ff4444;display:none;animation:blink 1s infinite;}
343
+ .dot.on{display:block;}.dot.hold{animation:none;background:#ffaa00;}
344
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
345
+ .status{font-size:0.7rem;color:#95d5b2;letter-spacing:1.5px;text-transform:uppercase;font-weight:600;}
346
+ .timer{font-size:1.3rem;font-weight:300;color:#fff;letter-spacing:2px;font-variant-numeric:tabular-nums;}
347
+ .wave{display:flex;align-items:center;justify-content:center;gap:3px;height:40px;margin:8px 0;}
348
+ .bar{width:4px;border-radius:2px;background:#2d6a4f;height:4px;transition:height 0.07s ease;}
349
+ .btns{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;}
350
+ .btn{border:none;border-radius:50px;cursor:pointer;font-weight:600;font-size:0.78rem;
351
+ padding:8px 18px;display:flex;align-items:center;gap:4px;transition:all 0.15s;}
352
+ .btn:hover{transform:scale(1.05);}.btn:active{transform:scale(0.97);}
353
+ .btn-rec{background:#52b788;color:#0f2419;}.btn-paus{background:#f9c74f;color:#0f2419;}
354
+ .btn-res{background:#4dabf7;color:#0f2419;}.btn-stop{background:#f94144;color:#fff;}
355
+ .done{margin-top:10px;background:rgba(82,183,136,0.1);border:1px solid rgba(82,183,136,0.35);
356
+ border-radius:10px;padding:10px;text-align:center;display:none;}
357
+ .done.show{display:block;}
358
+ audio{width:100%;margin:6px 0;border-radius:6px;}
359
+ .use-btn{width:100%;padding:9px;background:#52b788;color:#0f2419;border:none;border-radius:8px;
360
+ font-weight:700;font-size:0.88rem;cursor:pointer;margin-top:4px;}
361
+ .use-btn:hover{background:#74c69d;}
362
+ .note{color:#aaa;font-size:0.72rem;margin-top:5px;}
363
+ .sending{color:#f9c74f;font-size:0.78rem;margin-top:6px;display:none;}
364
+ </style></head><body>
365
+ <div class="card">
366
+ <div class="toprow">
367
+ <div class="left"><div class="dot" id="dot"></div><span class="status" id="status">Ready</span></div>
368
+ <div class="timer" id="timer">00:00</div>
369
+ </div>
370
+ <div class="wave" id="wave"></div>
371
+ <div class="btns" id="btns"><button class="btn btn-rec" onclick="rec()">● Record</button></div>
372
+ <div class="done" id="done">
373
+ <audio id="prev" controls></audio>
374
+ <button class="use-btn" onclick="sendToStreamlit()">βœ… Use This Recording</button>
375
+ <div class="sending" id="sending">⏳ Saving to Streamlit...</div>
376
+ <p class="note">This will save your recording instantly β€” no download needed.</p>
377
+ </div>
378
+ </div>
379
+ <script>
380
+ const N=36, wave=document.getElementById('wave');
381
+ for(let i=0;i<N;i++){const b=document.createElement('div');b.className='bar';b.id='b'+i;wave.appendChild(b);}
382
+ let mr=null,chunks=[],ctx=null,an=null,src=null,raf=null,ti=null,secs=0,paused=false,blob=null;
383
+
384
+ function fmt(s){return[Math.floor(s/60),s%60].map(v=>String(v).padStart(2,'0')).join(':');}
385
+ function startTimer(){secs=0;document.getElementById('timer').textContent='00:00';
386
+ ti=setInterval(()=>{if(!paused){secs++;document.getElementById('timer').textContent=fmt(secs);}},1000);}
387
+ function stopTimer(){clearInterval(ti);}
388
+
389
+ function resetBars(){for(let i=0;i<N;i++){const b=document.getElementById('b'+i);b.style.height='4px';b.style.background='#2d6a4f';}}
390
+ function animate(){
391
+ if(!an)return;const d=new Uint8Array(an.frequencyBinCount);an.getByteFrequencyData(d);
392
+ for(let i=0;i<N;i++){const b=document.getElementById('b'+i),v=d[Math.floor(i*d.length/N)];
393
+ const h=paused?4:Math.max(4,Math.min(42,v*0.42));b.style.height=h+'px';
394
+ const p=v/255;b.style.background=p<0.4?'#52b788':p<0.75?'#f9c74f':'#f94144';}
395
+ raf=requestAnimationFrame(animate);}
396
+ function setbtns(h){document.getElementById('btns').innerHTML=h;}
397
+
398
+ async function rec(){
399
+ try{
400
+ const stream=await navigator.mediaDevices.getUserMedia({audio:true});
401
+ ctx=new(window.AudioContext||window.webkitAudioContext)();
402
+ an=ctx.createAnalyser();an.fftSize=128;
403
+ src=ctx.createMediaStreamSource(stream);src.connect(an);
404
+ // Prefer OGG/opus β†’ WAV fallback (both accepted by file_uploader)
405
+ const mime=MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
406
+ ? 'audio/ogg;codecs=opus'
407
+ : MediaRecorder.isTypeSupported('audio/wav') ? 'audio/wav' : '';
408
+ mr=new MediaRecorder(stream, mime?{mimeType:mime}:{});
409
+ chunks=[];mr.ondataavailable=e=>chunks.push(e.data);mr.onstop=finalize;mr.start(100);
410
+ paused=false;blob=null;
411
+ document.getElementById('dot').className='dot on';
412
+ document.getElementById('status').textContent='Recording...';
413
+ document.getElementById('done').className='done';
414
+ document.getElementById('sending').style.display='none';
415
+ setbtns('<button class="btn btn-paus" onclick="paus()">⏸ Pause</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');
416
+ startTimer();animate();
417
+ }catch(e){document.getElementById('status').textContent='⚠️ Mic access denied';}
418
+ }
419
+ function paus(){if(mr&&mr.state==='recording'){mr.pause();paused=true;
420
+ document.getElementById('dot').className='dot hold';
421
+ document.getElementById('status').textContent='Paused';
422
+ setbtns('<button class="btn btn-res" onclick="res()">β–Ά Resume</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');}}
423
+ function res(){if(mr&&mr.state==='paused'){mr.resume();paused=false;
424
+ document.getElementById('dot').className='dot on';
425
+ document.getElementById('status').textContent='Recording...';
426
+ setbtns('<button class="btn btn-paus" onclick="paus()">⏸ Pause</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');}}
427
+ function stop(){if(mr){mr.stop();mr.stream.getTracks().forEach(t=>t.stop());}
428
+ cancelAnimationFrame(raf);stopTimer();resetBars();paused=false;
429
+ document.getElementById('dot').className='dot';
430
+ document.getElementById('status').textContent='Processing...';
431
+ setbtns('');}
432
+
433
+ function finalize(){
434
+ blob=new Blob(chunks,{type:mr.mimeType||'audio/ogg'});
435
+ const url=URL.createObjectURL(blob);
436
+ document.getElementById('prev').src=url;
437
+ document.getElementById('status').textContent='Done β€” '+fmt(secs);
438
+ document.getElementById('done').className='done show';
439
+ setbtns('<button class="btn btn-rec" onclick="rec()">● New Recording</button>');
440
+ }
441
 
442
+ function sendToStreamlit(){
443
+ if(!blob)return;
444
+ document.getElementById('sending').style.display='block';
445
+ const reader=new FileReader();
446
+ reader.onload=function(e){
447
+ const dataURL=e.target.result; // data:audio/ogg;base64,...
448
+ // Find the hidden textarea Streamlit rendered and set its value
449
+ const textareas=window.parent.document.querySelectorAll('textarea');
450
+ let found=false;
451
+ for(const ta of textareas){
452
+ if(ta.id && ta.id.includes('rec_b64_bridge')){
453
+ // React-controlled input β€” use nativeInputValueSetter
454
+ const nativeInputValueSetter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
455
+ nativeInputValueSetter.call(ta,dataURL);
456
+ ta.dispatchEvent(new Event('input',{bubbles:true}));
457
+ found=true;break;
458
+ }
459
+ }
460
+ if(!found){
461
+ // Fallback: try by label text
462
+ const all=window.parent.document.querySelectorAll('textarea');
463
+ for(const ta of all){
464
+ try{
465
+ const niv=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
466
+ niv.call(ta,dataURL);
467
+ ta.dispatchEvent(new Event('input',{bubbles:true}));
468
+ found=true;break;
469
+ }catch(e){}
470
+ }
471
+ }
472
+ document.getElementById('sending').style.display='none';
473
+ document.getElementById('status').textContent='βœ… Sent!';
474
+ };
475
+ reader.readAsDataURL(blob);
476
+ }
477
+ </script></body></html>
478
+ """, height=310, scrolling=False)
479
+
480
+ # Show recorded audio if available in session state
481
+ if st.session_state.get("rec_ready") and st.session_state.get("rec_audio_bytes"):
482
+ st.success("βœ… Recording saved! Click **Identify Ayah** below.")
483
+ st.audio(st.session_state["rec_audio_bytes"], format="audio/ogg")
484
+ if st.button("πŸ—‘οΈ Clear Recording", key="clear_rec"):
485
+ st.session_state["rec_ready"] = False
486
+ st.session_state["rec_audio_bytes"] = None
487
+ st.rerun()
488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  with col_r:
490
  st.markdown("### 🌐 Settings Summary")
491
  st.info(
 
493
  f"**Verification reciter:** {reciter_name}"
494
  )
495
  if audio_bytes:
496
+ st.success(
497
+ f"βœ… Audio ready \n"
498
+ f"Source: {'Recording' if input_source == 'record' else 'Upload'}"
499
+ )
500
+
501
+ # ── Pull recorded audio from session_state if no upload ──────
502
+ if audio_bytes is None and st.session_state.get("rec_ready") and st.session_state.get("rec_audio_bytes"):
503
+ audio_bytes = st.session_state["rec_audio_bytes"]
504
+ input_source = "record"
505
 
506
  # ── Identify button ───────────────────────────────────────────
507
  if audio_bytes:
 
512
  with open(tmp, "wb") as f:
513
  f.write(audio_bytes)
514
 
515
+ with st.spinner("πŸ”„ Preprocessing..."):
516
  audio, _ = librosa.load(tmp, sr=TARGET_SR, mono=True)
517
  audio, _ = librosa.effects.trim(audio, top_db=20)
518
  audio = (audio / (np.abs(audio).max() + 1e-8) * 0.95).astype(np.float32)
519
 
520
+ with st.spinner("πŸŽ™οΈ Whisper transcribing..."):
521
  t0 = time.time()
522
  result = wmodel.transcribe(
523
  audio, language="ar", task="transcribe",
 
543
  <span style="color:#555;">Normalized:</span> {norm_text}<br>
544
  <span style="color:#888;font-size:0.79rem;">
545
  Language: {detected_lg} &nbsp;|&nbsp;
546
+ No-speech: {round(no_sp_prob,3)} &nbsp;|&nbsp;
547
+ Time: {whisper_ms}ms
548
  </span>
549
  </div>
550
  """, unsafe_allow_html=True)
551
 
552
+ # Validation
553
  invalid, reason = False, ""
554
  if no_sp_prob > 0.60:
555
  invalid = True
 
574
  """, unsafe_allow_html=True)
575
 
576
  else:
577
+ with st.spinner("πŸ” Searching Quran..."):
578
  t1 = time.time()
579
+ matches = hybrid_search(norm_text, df, tfidf_vec, tfidf_mat, fidx, embed, top_k)
 
 
580
  search_ms = round((time.time() - t1) * 1000)
581
 
582
  if not matches or matches[0]["score"] < min_score:
 
585
  <div class="invalid-box">
586
  <h3>β›” No Quranic Ayah Found</h3>
587
  <p>❌ Audio does not appear to be a Quranic recitation.</p>
588
+ <p><b>Reason:</b> Best score ({best:.4f}) below threshold ({min_score})</p>
589
  </div>
590
  """, unsafe_allow_html=True)
591
 
 
601
  f'<span class="rank-badge-alt">Candidate #{i+1}</span>'
602
  )
603
 
604
+ translation = m["ayah_ar"] if lang_code == "ar" else m.get(f"ayah_{lang_code}", "")
 
 
 
605
  if not translation or translation == "nan":
606
  translation = "[Translation not available]"
607
 
 
640
  </div>
641
  """, unsafe_allow_html=True)
642
 
643
+ # ── Ayah audio verification ───────────────────────────
 
 
644
  if show_audio:
645
+ audio_url = get_ayah_audio_url(m["surah_id"], m["ayah_id"], reciter_id)
646
+ st.markdown(f"""
647
+ <div class="audio-verify">
648
+ <p>πŸ”Š Verify β€” {reciter_name}
649
+ &nbsp;Β·&nbsp; Surah {m["surah_id"]}
650
+ &nbsp;Β·&nbsp; Ayah {m["ayah_id"]}</p>
651
+ </div>
652
+ """, unsafe_allow_html=True)
653
+ # Fetch audio bytes server-side so Streamlit serves reliably
654
+ try:
655
+ import urllib.request
656
+ with urllib.request.urlopen(audio_url, timeout=10) as resp:
657
+ ayah_audio_bytes = resp.read()
658
+ st.audio(ayah_audio_bytes, format="audio/mp3")
659
+ except Exception:
660
+ st.warning(
661
+ f"⚠️ Could not load audio automatically. "
662
+ f"[β–Ά Listen directly]({audio_url})"
663
+ )
664
 
665
  st.markdown(
666
  f'<p class="timing">⏱ Whisper: {whisper_ms}ms &nbsp;|&nbsp;'