Ai_Project / app.py
Ahmed062646's picture
Update app.py
b07a70d verified
Raw
History Blame Contribute Delete
31.4 kB
import streamlit as st
import streamlit.components.v1 as components
import numpy as np
import pandas as pd
import pickle, os, re, time
import torch
import librosa
import faiss
import whisper
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
from huggingface_hub import hf_hub_download
# ── Page config ──────────────────────────────────────────────
st.set_page_config(
page_title="HudaAI β€” Quranic Verse Recognition",
page_icon="πŸ•Œ",
layout="wide"
)
# ── Constants ────────────────────────────────────────────────
HF_DATASET = "Ahmed062646/WhisperModel_Ai"
TARGET_SR = 16000
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
EMBED_NAME = "CAMeL-Lab/bert-base-arabic-camelbert-ca"
MIN_SCORE = 0.12
SURAH_AYAH_COUNTS = [
7,286,200,176,120,165,206,75,129,109,123,111,43,52,99,128,111,110,98,
135,112,78,118,64,77,227,93,88,69,60,34,30,73,54,45,83,182,88,75,85,
54,53,89,59,37,35,38,29,18,45,60,49,62,55,78,96,29,22,24,13,14,11,11,
18,12,12,30,52,52,44,28,28,20,56,40,31,50,40,46,42,29,19,36,25,22,17,
19,26,30,20,15,21,11,8,8,19,5,8,8,11,11,8,3,9,5,4,7,3,6,3,5,4,5,6
]
RECITERS = {
"Mishary Alafasy" : "ar.alafasy",
"Abdul Basit" : "ar.abdulbasitmurattal",
"Maher Al Muaiqly" : "ar.mahermuaiqly",
"Minshawi Murattal" : "ar.minshawimujawwad",
}
LANGUAGES = {
"English" : "en",
"Urdu" : "ur",
"Hindi" : "hi",
"French" : "fr",
"Spanish" : "es",
"Bengali" : "bn",
"Portuguese" : "pt",
"Russian" : "ru",
"Mandarin Chinese" : "zh",
"Arabic (Original)" : "ar",
}
RTL_LANGS = {"ur", "ar"}
# ── Helpers ──────────────────────────────────────────────────
def hf_download(filename):
return hf_hub_download(
repo_id=HF_DATASET, filename=filename, repo_type="dataset",
token=os.environ.get("HF_TOKEN")
)
def get_global_ayah_number(surah_id: int, ayah_id: int) -> int:
return sum(SURAH_AYAH_COUNTS[:surah_id - 1]) + ayah_id
def get_ayah_audio_url(surah_id: int, ayah_id: int, reciter_id: str) -> str:
n = get_global_ayah_number(surah_id, ayah_id)
return f"https://cdn.islamic.network/quran/audio/128/{reciter_id}/{n}.mp3"
# ── Load resources ───────────────────────────────────────────
@st.cache_resource(show_spinner=False)
def load_resources():
corpus_path = hf_download("quran_verses_multilingual.csv")
tfidf_vec_path = hf_download("tfidf_vectorizer.pkl")
tfidf_mat_path = hf_download("tfidf_matrix.pkl")
faiss_idx_path = hf_download("faiss_index.bin")
df = pd.read_csv(corpus_path)
with open(tfidf_vec_path, "rb") as f: tfidf_vec = pickle.load(f)
with open(tfidf_mat_path, "rb") as f: tfidf_mat = pickle.load(f)
fidx = faiss.read_index(faiss_idx_path)
embed = SentenceTransformer(EMBED_NAME, device=DEVICE)
wmodel = whisper.load_model("small", device=DEVICE)
return df, tfidf_vec, tfidf_mat, fidx, embed, wmodel
# ── Arabic normalization ─────────────────────────────────────
def normalize_arabic(text):
if not isinstance(text, str): return ""
text = re.sub(r"[\u064B-\u065F\u0670]", "", text)
text = re.sub(r"[Ψ£Ψ₯Ψ’Ω±]", "Ψ§", text)
text = re.sub(r"Ψ©", "Ω‡", text)
text = re.sub(r"Ω‰", "ي", text)
text = re.sub(r"Ω€", "", text)
text = re.sub(r"[^\u0600-\u06FF\s]", "", text)
return re.sub(r"\s+", " ", text).strip()
# ── Hybrid search ────────────────────────────────────────────
def hybrid_search(query_norm, df, tfidf_vec, tfidf_mat, fidx, embed, top_k=3):
if not query_norm.strip(): return []
pool = top_k * 3
qv = tfidf_vec.transform([query_norm])
t_scores = cosine_similarity(qv, tfidf_mat).flatten()
t_top = t_scores.argsort()[::-1][:pool]
qe = embed.encode(
[query_norm], normalize_embeddings=True, convert_to_numpy=True
).astype("float32")
f_scores, f_idx = fidx.search(qe, pool)
score_map = {}
t_min, t_max = t_scores[t_top].min(), t_scores[t_top].max()
t_range = t_max - t_min if t_max != t_min else 1.0
for i in t_top:
k = (int(df.iloc[i]["surah_id"]), int(df.iloc[i]["ayah_id"]))
score_map[k] = {"t": (t_scores[i] - t_min) / t_range, "f": 0.0, "idx": i}
f_arr = f_scores[0]
f_min, f_max = f_arr.min(), f_arr.max()
f_range = f_max - f_min if f_max != f_min else 1.0
for idx, sc in zip(f_idx[0], f_arr):
if idx == -1: continue
k = (int(df.iloc[idx]["surah_id"]), int(df.iloc[idx]["ayah_id"]))
norm = (sc - f_min) / f_range
if k in score_map: score_map[k]["f"] = norm
else: score_map[k] = {"t": 0.0, "f": norm, "idx": idx}
combined = sorted(
[(0.4*v["t"] + 0.6*v["f"], k, v["idx"]) for k, v in score_map.items()],
reverse=True
)
results = []
for score, (sid, aid), idx in combined[:top_k]:
row = df.iloc[idx]
results.append({
"score" : round(float(score), 4),
"surah_id" : sid,
"ayah_id" : aid,
"surah_name_en" : str(row["surah_name_en"]),
"surah_name_ar" : str(row["surah_name_ar"]),
"ayah_ar" : str(row.get("ayah_ar", "")),
"ayah_tr" : str(row.get("ayah_tr", "")),
"ayah_en" : str(row.get("ayah_en", "")),
"ayah_ur" : str(row.get("ayah_ur", "")),
"ayah_hi" : str(row.get("ayah_hi", "")),
"ayah_fr" : str(row.get("ayah_fr", "")),
"ayah_es" : str(row.get("ayah_es", "")),
"ayah_bn" : str(row.get("ayah_bn", "")),
"ayah_pt" : str(row.get("ayah_pt", "")),
"ayah_ru" : str(row.get("ayah_ru", "")),
"ayah_zh" : str(row.get("ayah_zh", "")),
})
return results
# ── Global CSS ───────────────────────────────────────────────
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Amiri:ital@0;1&family=IBM+Plex+Sans:wght@300;400;600&display=swap');
* { font-family: "IBM Plex Sans", sans-serif; }
.header {
background: linear-gradient(135deg, #0d1f17, #1b4332 50%, #2d6a4f);
padding: 2.5rem 2rem; border-radius: 18px;
text-align: center; margin-bottom: 2rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.22);
border: 1px solid rgba(82,183,136,0.15);
}
.header h1 { color:white; font-size:2.6rem; font-weight:300; letter-spacing:2px; margin:0; }
.header p { color:#95d5b2; margin:0.4rem 0 0; font-size:1rem; font-weight:300; }
.top-match {
background: linear-gradient(135deg, #d8f3dc, #b7e4c7);
border: 2px solid #52b788; border-radius:16px;
padding:1.8rem; margin-bottom:1.2rem;
box-shadow: 0 4px 16px rgba(82,183,136,0.18);
}
.candidate {
background:#f8fffe; border:1px solid #d0ede0;
border-radius:16px; padding:1.5rem; margin-bottom:1rem;
}
.rank-badge {
display:inline-block; background:#1b4332; color:#95d5b2;
padding:4px 14px; border-radius:20px;
font-size:0.72rem; font-weight:600;
letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
}
.rank-badge-alt {
display:inline-block; background:#f1f3f5; color:#6c757d;
padding:4px 14px; border-radius:20px;
font-size:0.72rem; font-weight:600;
letter-spacing:1px; text-transform:uppercase; margin-bottom:1rem;
}
.ayah-meta { display:flex; gap:0.6rem; flex-wrap:wrap; margin-bottom:1rem; }
.meta-chip {
background:rgba(255,255,255,0.7);
border:1px solid #c3e6cb; border-radius:6px;
padding:3px 10px; font-size:0.82rem;
color:#1b4332; font-weight:500;
}
.arabic-text {
font-family:"Amiri",serif; font-size:2rem;
text-align:right; direction:rtl; color:#0d1f17;
line-height:2.3; background:rgba(255,255,255,0.75);
padding:1.2rem 1.4rem; border-radius:10px;
border-right:5px solid #2d6a4f; margin-bottom:1rem;
}
.translation-box {
background:rgba(255,255,255,0.75); border-radius:10px;
padding:1rem 1.2rem; border-left:4px solid #52b788;
font-size:0.98rem; color:#2c3e50; line-height:1.75;
}
.translation-rtl {
direction:rtl; text-align:right;
font-size:1.2rem; font-family:"Amiri",serif;
border-left:none !important; border-right:4px solid #52b788 !important;
}
.audio-verify {
background:#f0fdf4; border:1px solid #a3cfbb;
border-radius:10px; padding:0.8rem 1.1rem; margin-top:0.9rem;
}
.audio-verify p {
font-size:0.8rem; color:#2d6a4f;
font-weight:600; margin-bottom:5px;
}
.score-bar-wrap { margin:0.5rem 0 1rem; }
.score-label { font-size:0.78rem; color:#6c757d; margin-bottom:4px; }
.whisper-box {
background:#f0fdf4; border:1px solid #c3e6cb;
border-radius:10px; padding:1rem 1.2rem;
margin:1rem 0; font-size:0.88rem;
}
.invalid-box {
background:linear-gradient(135deg,#fff0f0,#ffe0e0);
border:1px solid #ffb3b3; border-radius:14px;
padding:1.8rem; text-align:center;
}
.invalid-box h3 { color:#c0392b; }
.invalid-box p { color:#555; margin-top:6px; }
.timing { color:#a0aab4; font-size:0.79rem; text-align:right; margin-top:0.75rem; }
</style>
""", unsafe_allow_html=True)
# ── Header ───────────────────────────────────────────────────
st.markdown("""
<div class="header">
<h1>πŸ•Œ HudaAI</h1>
<p>AI-Based Quranic Verse Recognition &amp; Multilingual Translation</p>
</div>
""", unsafe_allow_html=True)
# ── Load models ───────────────────────────────────────────────
with st.spinner("⏳ Loading AI models β€” please wait (~2 min first time)..."):
df, tfidf_vec, tfidf_mat, fidx, embed, wmodel = load_resources()
st.success("βœ… Models ready!")
# ── Session state init ────────────────────────────────────────
if "rec_audio_bytes" not in st.session_state:
st.session_state["rec_audio_bytes"] = None
if "rec_ready" not in st.session_state:
st.session_state["rec_ready"] = False
# ── Sidebar ──────────────────────────────────────────────────
with st.sidebar:
st.markdown("### βš™οΈ Settings")
lang_name = st.selectbox("🌐 Translation Language", list(LANGUAGES.keys()), index=0)
lang_code = LANGUAGES[lang_name]
reciter_name = st.selectbox("🎡 Verification Reciter", list(RECITERS.keys()), index=0)
reciter_id = RECITERS[reciter_name]
top_k = st.slider("Matches to show", 1, 5, 3)
min_score = st.slider("Min confidence threshold", 0.0, 0.5, 0.12, 0.01)
show_trans = st.checkbox("Show transliteration", value=True)
show_audio = st.checkbox("Show ayah audio player", value=True)
st.markdown("---")
st.markdown("### πŸ“– About")
st.markdown("""
HudaAI identifies Quranic verses using
**Whisper ASR** + **FAISS semantic search**.
**Course:** AI2002 Β· Artificial Intelligence
**University:** FAST-NUCES Lahore
**Semester:** Spring 2026
""")
# ── Input section ─────────────────────────────────────────────
col_l, col_r = st.columns([3, 2])
audio_bytes = None
input_source = None
with col_l:
st.markdown("### πŸŽ™οΈ Input Recitation")
tab_up, tab_rec = st.tabs(["πŸ“ Upload Audio", "πŸŽ™οΈ Record Audio"])
with tab_up:
uploaded = st.file_uploader(
"Upload Quranic recitation (MP3, WAV, M4A, OGG)",
type=["mp3", "wav", "m4a", "ogg"]
)
if uploaded:
audio_bytes = uploaded.read()
input_source = "upload"
st.audio(audio_bytes)
with tab_rec:
# ── Recorder: JS sends base64 WAV to a hidden text_area, Python reads it ──
# We write the b64 payload to a temp file path stored in session_state key
# and trigger a rerun via a checkbox toggle bridge.
# Hidden textarea bridge β€” JS will inject base64 audio here
b64_bridge = st.text_area(
"rec_bridge", value="", key="rec_b64_bridge",
label_visibility="collapsed", height=1,
help="internal"
)
# Hide the textarea with CSS
st.markdown("""
<style>
[data-testid="stTextArea"][aria-label="rec_bridge"],
div:has(> [data-testid="stTextArea"] textarea#rec_b64_bridge) { display:none !important; }
</style>""", unsafe_allow_html=True)
# Process incoming base64 audio from JS
if b64_bridge and b64_bridge.startswith("data:audio"):
import base64 as _b64
header, b64data = b64_bridge.split(",", 1)
raw = _b64.b64decode(b64data)
st.session_state["rec_audio_bytes"] = raw
st.session_state["rec_ready"] = True
# Show recorder component
components.html("""
<html><head><meta charset="utf-8"><style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Segoe UI',sans-serif;background:transparent;padding:8px;}
.card{background:linear-gradient(145deg,#0f2419,#1b4332);border-radius:16px;
padding:16px 22px;box-shadow:0 6px 24px rgba(0,0,0,0.3);
border:1px solid rgba(82,183,136,0.2);}
.toprow{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}
.left{display:flex;align-items:center;gap:8px;}
.dot{width:8px;height:8px;border-radius:50%;background:#ff4444;display:none;animation:blink 1s infinite;}
.dot.on{display:block;}.dot.hold{animation:none;background:#ffaa00;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
.status{font-size:0.7rem;color:#95d5b2;letter-spacing:1.5px;text-transform:uppercase;font-weight:600;}
.timer{font-size:1.3rem;font-weight:300;color:#fff;letter-spacing:2px;font-variant-numeric:tabular-nums;}
.wave{display:flex;align-items:center;justify-content:center;gap:3px;height:40px;margin:8px 0;}
.bar{width:4px;border-radius:2px;background:#2d6a4f;height:4px;transition:height 0.07s ease;}
.btns{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;}
.btn{border:none;border-radius:50px;cursor:pointer;font-weight:600;font-size:0.78rem;
padding:8px 18px;display:flex;align-items:center;gap:4px;transition:all 0.15s;}
.btn:hover{transform:scale(1.05);}.btn:active{transform:scale(0.97);}
.btn-rec{background:#52b788;color:#0f2419;}.btn-paus{background:#f9c74f;color:#0f2419;}
.btn-res{background:#4dabf7;color:#0f2419;}.btn-stop{background:#f94144;color:#fff;}
.done{margin-top:10px;background:rgba(82,183,136,0.1);border:1px solid rgba(82,183,136,0.35);
border-radius:10px;padding:10px;text-align:center;display:none;}
.done.show{display:block;}
audio{width:100%;margin:6px 0;border-radius:6px;}
.use-btn{width:100%;padding:9px;background:#52b788;color:#0f2419;border:none;border-radius:8px;
font-weight:700;font-size:0.88rem;cursor:pointer;margin-top:4px;}
.use-btn:hover{background:#74c69d;}
.note{color:#aaa;font-size:0.72rem;margin-top:5px;}
.sending{color:#f9c74f;font-size:0.78rem;margin-top:6px;display:none;}
</style></head><body>
<div class="card">
<div class="toprow">
<div class="left"><div class="dot" id="dot"></div><span class="status" id="status">Ready</span></div>
<div class="timer" id="timer">00:00</div>
</div>
<div class="wave" id="wave"></div>
<div class="btns" id="btns"><button class="btn btn-rec" onclick="rec()">● Record</button></div>
<div class="done" id="done">
<audio id="prev" controls></audio>
<button class="use-btn" onclick="sendToStreamlit()">βœ… Use This Recording</button>
<div class="sending" id="sending">⏳ Saving to Streamlit...</div>
<p class="note">This will save your recording instantly β€” no download needed.</p>
</div>
</div>
<script>
const N=36, wave=document.getElementById('wave');
for(let i=0;i<N;i++){const b=document.createElement('div');b.className='bar';b.id='b'+i;wave.appendChild(b);}
let mr=null,chunks=[],ctx=null,an=null,src=null,raf=null,ti=null,secs=0,paused=false,blob=null;
function fmt(s){return[Math.floor(s/60),s%60].map(v=>String(v).padStart(2,'0')).join(':');}
function startTimer(){secs=0;document.getElementById('timer').textContent='00:00';
ti=setInterval(()=>{if(!paused){secs++;document.getElementById('timer').textContent=fmt(secs);}},1000);}
function stopTimer(){clearInterval(ti);}
function resetBars(){for(let i=0;i<N;i++){const b=document.getElementById('b'+i);b.style.height='4px';b.style.background='#2d6a4f';}}
function animate(){
if(!an)return;const d=new Uint8Array(an.frequencyBinCount);an.getByteFrequencyData(d);
for(let i=0;i<N;i++){const b=document.getElementById('b'+i),v=d[Math.floor(i*d.length/N)];
const h=paused?4:Math.max(4,Math.min(42,v*0.42));b.style.height=h+'px';
const p=v/255;b.style.background=p<0.4?'#52b788':p<0.75?'#f9c74f':'#f94144';}
raf=requestAnimationFrame(animate);}
function setbtns(h){document.getElementById('btns').innerHTML=h;}
async function rec(){
try{
const stream=await navigator.mediaDevices.getUserMedia({audio:true});
ctx=new(window.AudioContext||window.webkitAudioContext)();
an=ctx.createAnalyser();an.fftSize=128;
src=ctx.createMediaStreamSource(stream);src.connect(an);
// Prefer OGG/opus β†’ WAV fallback (both accepted by file_uploader)
const mime=MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: MediaRecorder.isTypeSupported('audio/wav') ? 'audio/wav' : '';
mr=new MediaRecorder(stream, mime?{mimeType:mime}:{});
chunks=[];mr.ondataavailable=e=>chunks.push(e.data);mr.onstop=finalize;mr.start(100);
paused=false;blob=null;
document.getElementById('dot').className='dot on';
document.getElementById('status').textContent='Recording...';
document.getElementById('done').className='done';
document.getElementById('sending').style.display='none';
setbtns('<button class="btn btn-paus" onclick="paus()">⏸ Pause</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');
startTimer();animate();
}catch(e){document.getElementById('status').textContent='⚠️ Mic access denied';}
}
function paus(){if(mr&&mr.state==='recording'){mr.pause();paused=true;
document.getElementById('dot').className='dot hold';
document.getElementById('status').textContent='Paused';
setbtns('<button class="btn btn-res" onclick="res()">β–Ά Resume</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');}}
function res(){if(mr&&mr.state==='paused'){mr.resume();paused=false;
document.getElementById('dot').className='dot on';
document.getElementById('status').textContent='Recording...';
setbtns('<button class="btn btn-paus" onclick="paus()">⏸ Pause</button><button class="btn btn-stop" onclick="stop()">⏹ Stop</button>');}}
function stop(){if(mr){mr.stop();mr.stream.getTracks().forEach(t=>t.stop());}
cancelAnimationFrame(raf);stopTimer();resetBars();paused=false;
document.getElementById('dot').className='dot';
document.getElementById('status').textContent='Processing...';
setbtns('');}
function finalize(){
blob=new Blob(chunks,{type:mr.mimeType||'audio/ogg'});
const url=URL.createObjectURL(blob);
document.getElementById('prev').src=url;
document.getElementById('status').textContent='Done β€” '+fmt(secs);
document.getElementById('done').className='done show';
setbtns('<button class="btn btn-rec" onclick="rec()">● New Recording</button>');
}
function sendToStreamlit(){
if(!blob)return;
document.getElementById('sending').style.display='block';
const reader=new FileReader();
reader.onload=function(e){
const dataURL=e.target.result; // data:audio/ogg;base64,...
// Find the hidden textarea Streamlit rendered and set its value
const textareas=window.parent.document.querySelectorAll('textarea');
let found=false;
for(const ta of textareas){
if(ta.id && ta.id.includes('rec_b64_bridge')){
// React-controlled input β€” use nativeInputValueSetter
const nativeInputValueSetter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
nativeInputValueSetter.call(ta,dataURL);
ta.dispatchEvent(new Event('input',{bubbles:true}));
found=true;break;
}
}
if(!found){
// Fallback: try by label text
const all=window.parent.document.querySelectorAll('textarea');
for(const ta of all){
try{
const niv=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
niv.call(ta,dataURL);
ta.dispatchEvent(new Event('input',{bubbles:true}));
found=true;break;
}catch(e){}
}
}
document.getElementById('sending').style.display='none';
document.getElementById('status').textContent='βœ… Sent!';
};
reader.readAsDataURL(blob);
}
</script></body></html>
""", height=310, scrolling=False)
# Show recorded audio if available in session state
if st.session_state.get("rec_ready") and st.session_state.get("rec_audio_bytes"):
st.success("βœ… Recording saved! Click **Identify Ayah** below.")
st.audio(st.session_state["rec_audio_bytes"], format="audio/ogg")
if st.button("πŸ—‘οΈ Clear Recording", key="clear_rec"):
st.session_state["rec_ready"] = False
st.session_state["rec_audio_bytes"] = None
st.rerun()
with col_r:
st.markdown("### 🌐 Settings Summary")
st.info(
f"**Translation:** {lang_name} \n"
f"**Verification reciter:** {reciter_name}"
)
if audio_bytes:
st.success(
f"βœ… Audio ready \n"
f"Source: {'Recording' if input_source == 'record' else 'Upload'}"
)
# ── Pull recorded audio from session_state if no upload ──────
if audio_bytes is None and st.session_state.get("rec_ready") and st.session_state.get("rec_audio_bytes"):
audio_bytes = st.session_state["rec_audio_bytes"]
input_source = "record"
# ── Identify button ───────────────────────────────────────────
if audio_bytes:
st.markdown("---")
if st.button("πŸ” Identify Ayah", type="primary", use_container_width=True):
tmp = "/tmp/huda_in"
with open(tmp, "wb") as f:
f.write(audio_bytes)
with st.spinner("πŸ”„ Preprocessing..."):
audio, _ = librosa.load(tmp, sr=TARGET_SR, mono=True)
audio, _ = librosa.effects.trim(audio, top_db=20)
audio = (audio / (np.abs(audio).max() + 1e-8) * 0.95).astype(np.float32)
with st.spinner("πŸŽ™οΈ Whisper transcribing..."):
t0 = time.time()
result = wmodel.transcribe(
audio, language="ar", task="transcribe",
fp16=(DEVICE == "cuda"),
condition_on_previous_text=False,
no_speech_threshold=0.4, verbose=False
)
whisper_ms = round((time.time() - t0) * 1000)
raw_text = result["text"].strip()
norm_text = normalize_arabic(raw_text)
no_sp_prob = result["segments"][0]["no_speech_prob"] if result.get("segments") else 1.0
detected_lg = result.get("language", "ar")
src_lbl = "πŸŽ™οΈ Recorded" if input_source == "record" else "πŸ“ Uploaded"
st.markdown(f"""
<div class="whisper-box">
<b>πŸŽ™οΈ Whisper Output</b>
<span style="background:#d8f3dc;border-radius:4px;padding:2px 8px;
font-size:0.73rem;margin-left:8px;color:#1b4332;">
{src_lbl}
</span><br><br>
<span style="color:#555;">Raw:</span> {raw_text}<br>
<span style="color:#555;">Normalized:</span> {norm_text}<br>
<span style="color:#888;font-size:0.79rem;">
Language: {detected_lg} &nbsp;|&nbsp;
No-speech: {round(no_sp_prob,3)} &nbsp;|&nbsp;
Time: {whisper_ms}ms
</span>
</div>
""", unsafe_allow_html=True)
# Validation
invalid, reason = False, ""
if no_sp_prob > 0.60:
invalid = True
reason = f"Audio appears silent (no-speech prob: {round(no_sp_prob,2)})"
elif not norm_text:
invalid = True
reason = "No Arabic text could be transcribed"
elif detected_lg not in ("ar", "arabic"):
invalid = True
reason = f"Audio is not in Arabic (detected: {detected_lg})"
elif len(norm_text.split()) < 2:
invalid = True
reason = "Transcription too short to be an ayah"
if invalid:
st.markdown(f"""
<div class="invalid-box">
<h3>β›” Invalid Input</h3>
<p>❌ Audio does not contain a Quranic ayah.</p>
<p><b>Reason:</b> {reason}</p>
</div>
""", unsafe_allow_html=True)
else:
with st.spinner("πŸ” Searching Quran..."):
t1 = time.time()
matches = hybrid_search(norm_text, df, tfidf_vec, tfidf_mat, fidx, embed, top_k)
search_ms = round((time.time() - t1) * 1000)
if not matches or matches[0]["score"] < min_score:
best = matches[0]["score"] if matches else 0.0
st.markdown(f"""
<div class="invalid-box">
<h3>β›” No Quranic Ayah Found</h3>
<p>❌ Audio does not appear to be a Quranic recitation.</p>
<p><b>Reason:</b> Best score ({best:.4f}) below threshold ({min_score})</p>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(f"### 🎯 Results &nbsp;·&nbsp; **{lang_name}**")
for i, m in enumerate(matches):
is_top = (i == 0)
box_cls = "top-match" if is_top else "candidate"
badge = (
'<span class="rank-badge">βœ… &nbsp;Top Match</span>'
if is_top else
f'<span class="rank-badge-alt">Candidate #{i+1}</span>'
)
translation = m["ayah_ar"] if lang_code == "ar" else m.get(f"ayah_{lang_code}", "")
if not translation or translation == "nan":
translation = "[Translation not available]"
score_pct = min(int(m["score"] / 0.8 * 100), 100)
bar_color = "#2d6a4f" if is_top else "#adb5bd"
rtl_cls = "translation-rtl" if lang_code in RTL_LANGS else ""
tr_row = ""
if show_trans and m.get("ayah_tr", "") not in ("", "nan"):
tr_row = (
f'<p style="color:#888;font-size:0.82rem;'
f'margin-top:0.6rem;font-style:italic;">'
f'πŸ“ {m["ayah_tr"]}</p>'
)
st.markdown(f"""
<div class="{box_cls}">
{badge}
<div class="ayah-meta">
<span class="meta-chip">πŸ“– Surah {m["surah_id"]}</span>
<span class="meta-chip">{m["surah_name_en"]} β€” {m["surah_name_ar"]}</span>
<span class="meta-chip">Ayah {m["ayah_id"]}</span>
</div>
<div class="score-bar-wrap">
<div class="score-label">Confidence: {m["score"]}</div>
<div style="background:#e9ecef;border-radius:4px;height:5px;">
<div style="background:{bar_color};width:{score_pct}%;
height:5px;border-radius:4px;"></div>
</div>
</div>
<div class="arabic-text">{m["ayah_ar"]}</div>
<div class="translation-box {rtl_cls}">
🌐 <b>{lang_name}:</b><br>{translation}
</div>
{tr_row}
</div>
""", unsafe_allow_html=True)
# ── Ayah audio verification ───────────────────────────
if show_audio:
audio_url = get_ayah_audio_url(m["surah_id"], m["ayah_id"], reciter_id)
st.markdown(f"""
<div class="audio-verify">
<p>πŸ”Š Verify β€” {reciter_name}
&nbsp;Β·&nbsp; Surah {m["surah_id"]}
&nbsp;Β·&nbsp; Ayah {m["ayah_id"]}</p>
</div>
""", unsafe_allow_html=True)
# Fetch audio bytes server-side so Streamlit serves reliably
try:
import urllib.request
with urllib.request.urlopen(audio_url, timeout=10) as resp:
ayah_audio_bytes = resp.read()
st.audio(ayah_audio_bytes, format="audio/mp3")
except Exception:
st.warning(
f"⚠️ Could not load audio automatically. "
f"[β–Ά Listen directly]({audio_url})"
)
st.markdown(
f'<p class="timing">⏱ Whisper: {whisper_ms}ms &nbsp;|&nbsp;'
f'Search: {search_ms}ms &nbsp;|&nbsp;'
f'Total: {whisper_ms+search_ms}ms</p>',
unsafe_allow_html=True
)
# ── Footer ───────────────────────────────────────────────────
st.markdown("---")
st.markdown(
"<p style='text-align:center;color:#adb5bd;font-size:0.8rem;'>"
"HudaAI &nbsp;Β·&nbsp; AI2002 Artificial Intelligence &nbsp;Β·&nbsp; "
"FAST-NUCES Lahore &nbsp;Β·&nbsp; Spring 2026"
"</p>",
unsafe_allow_html=True
)