Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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;
|
| 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;
|
| 200 |
-
font-
|
|
|
|
| 201 |
}
|
| 202 |
.rank-badge-alt {
|
| 203 |
display:inline-block; background:#f1f3f5; color:#6c757d;
|
| 204 |
-
padding:4px 14px; border-radius:20px;
|
| 205 |
-
font-
|
|
|
|
| 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);
|
| 210 |
-
border-radius:6px;
|
| 211 |
-
font-size:0.82rem;
|
|
|
|
| 212 |
}
|
| 213 |
.arabic-text {
|
| 214 |
-
font-family:"Amiri",serif; font-size:2rem;
|
| 215 |
-
direction:rtl; color:#0d1f17;
|
| 216 |
-
background:rgba(255,255,255,0.75);
|
| 217 |
-
|
|
|
|
| 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;
|
| 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;
|
|
|
|
| 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;
|
|
|
|
| 241 |
}
|
| 242 |
.invalid-box {
|
| 243 |
background:linear-gradient(135deg,#fff0f0,#ffe0e0);
|
| 244 |
-
border:1px solid #ffb3b3; border-radius:14px;
|
|
|
|
| 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 "
|
| 273 |
-
st.session_state
|
| 274 |
-
if "
|
| 275 |
-
st.session_state
|
| 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
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 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 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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("ποΈ
|
| 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} |
|
| 420 |
-
No-speech
|
| 421 |
-
|
| 422 |
</span>
|
| 423 |
</div>
|
| 424 |
""", unsafe_allow_html=True)
|
| 425 |
|
| 426 |
-
#
|
| 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
|
| 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})
|
| 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
|
| 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 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
st.markdown(
|
| 539 |
f'<p class="timing">β± Whisper: {whisper_ms}ms | '
|
|
|
|
| 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} |
|
| 546 |
+
No-speech: {round(no_sp_prob,3)} |
|
| 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 |
+
Β· Surah {m["surah_id"]}
|
| 650 |
+
Β· 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 | '
|