Spaces:
Sleeping
Sleeping
| 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 βββββββββββββββββββββββββββββββββββββββββββ | |
| 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 & 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} | | |
| No-speech: {round(no_sp_prob,3)} | | |
| 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 Β· **{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">β 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} | |
| Β· Surah {m["surah_id"]} | |
| Β· 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 | ' | |
| f'Search: {search_ms}ms | ' | |
| 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 Β· AI2002 Artificial Intelligence Β· " | |
| "FAST-NUCES Lahore Β· Spring 2026" | |
| "</p>", | |
| unsafe_allow_html=True | |
| ) |