# -*- coding: utf-8 -*- """فضاء اختبار GPU للمرتل — large-v3 (float16) على T4. POST /transcribe?m=general|quran (WAV/mp3 بايت أو JSON {pcm|audio: base64}) → {"text","model"}. • general = openai/whisper-large-v3 عبر faster-whisper/CTranslate2 (أمين للمنطوق — الأفضل لرصد أخطاء القارئ) • quran = ahishamm/finetuned-whisper-quranic-large-v3 عبر transformers على الـGPU (أدقّ تشكيلًا، قد «يُصحّح») نموذجان قابلان للمقارنة على نفس الصوت لحسم أيّهما أدقّ في رصد أخطاء القارئ.""" import io, os, base64, json, numpy as np from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse import soundfile as sf QURAN_REPO = "ahishamm/finetuned-whisper-quranic-large-v3" _fw = None # faster-whisper (general) _hf = None # transformers pipeline (quran) def _general(): global _fw if _fw is None: from faster_whisper import WhisperModel _fw = WhisperModel("large-v3", device="cuda", compute_type="float16") return _fw def _quran(): # مُعطَّل منذ 2026-08-14: أوزانه و‎torch/transformers‎ أُزيلت من الصورة لأنّ التطبيق لا يستدعيه # إطلاقًا (‎?m=general‎ حصرًا)، وكانت تُضيف ~٦.٥ جيجابايت لكلّ إقلاعٍ بارد (٢٠٩ ثوانٍ مقيسة). # راجع تعليق Dockerfile لإعادته إن لزم. raise RuntimeError("المسار القرآنيّ غير مُتاح في هذه الصورة — استعمل ?m=general") app = FastAPI() app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) @app.on_event("startup") def _warm(): try: _general() # سخّن العامّ عند الإقلاع (يزيل تأخّر أوّل نداء) except Exception as e: print("warm error:", e) @app.get("/") def health(): return {"ok": True, "engine": "large-v3 gpu float16", "models": ["general", "quran"], "loaded": {"general": _fw is not None, "quran": _hf is not None}} def _resample16k(data, sr): data = np.asarray(data, dtype=np.float32) if sr == 16000 or data.size == 0: return data n_new = int(round(data.shape[0] * 16000.0 / float(sr))) if n_new <= 1: return data xo = np.linspace(0.0, 1.0, num=data.shape[0], endpoint=False) xn = np.linspace(0.0, 1.0, num=n_new, endpoint=False) return np.interp(xn, xo, data).astype(np.float32) def _decode(body: bytes, ctype: str): if ctype.startswith("audio/") or body[:4] == b"RIFF" or body[:3] == b"ID3" or body[:2] == b"\xff\xfb": data, sr = sf.read(io.BytesIO(body)) if getattr(data, "ndim", 1) > 1: data = data.mean(axis=1) return _resample16k(data, int(sr)) j = json.loads(body.decode("utf-8")) b = j.get("pcm") or j.get("audio") or "" raw = base64.b64decode(b) if raw[:4] == b"RIFF": data, sr = sf.read(io.BytesIO(raw)) if getattr(data, "ndim", 1) > 1: data = data.mean(axis=1) return _resample16k(data, int(sr)) return np.frombuffer(raw, dtype=np.float32) @app.post("/transcribe") async def transcribe(request: Request): try: which = request.query_params.get("m", "general") body = await request.body() ctype = request.headers.get("content-type", "") audio = _decode(body, ctype).astype(np.float32) pk = float(np.max(np.abs(audio))) if audio.size else 0.0 print(f"[DIAG] body={len(body)}B ctype={ctype!r} samples={audio.size} peak={pk:.4f} m={which}", flush=True) if audio.size == 0: return {"text": "", "model": "none"} if which == "quran": out = _quran()(audio, generate_kwargs={"language": "ar", "task": "transcribe"}) return {"text": (out.get("text") or "").strip(), "model": "quran"} # VAD + عتبات مضادّة للهلوسة: large-v3 يهلوس على الصمت/المقاطع القصيرة (نصوص يوتيوب دخيلة)، # فيُنتج نصًّا لا يطابق فيتجمّد الوسم الحيّ. VAD يحذف السكتات قبل التفريغ، والعتبات تُسقط الهلوسة. # ★ 2026-08-13: word_timestamps=True — طوابع زمنيّة لكلّ كلمة (محاذاة DTW على أوزان الانتباه # داخل faster-whisper نفسه، بلا نموذجٍ إضافيّ ولا تحميلٍ جديد ولا إقلاعٍ باردٍ أطول). # السبب: العميل كان **يستنتج** حدود الآيات من التتبّع النصّيّ مع تعويض زمن الاستدلال (تقدير)، # فينزاح مقطع الآية المُرسَل لمحرّك QPS فينهار تطابقه رغم صحّة التلاوة (رُصد ١٢٪ و٢٪ حيًّا على # سورة الزلزلة). بهذه الطوابع تصير حدود الآيات **صوتيّةً مضبوطة** لا مُقدَّرة. # حقل `words` إضافيٌّ فقط — `text` يبقى كما هو تمامًا، فالعملاء القدامى لا يتأثّرون. segments, _ = _general().transcribe( audio, language="ar", beam_size=1, temperature=0.0, without_timestamps=False, word_timestamps=True, condition_on_previous_text=False, vad_filter=True, vad_parameters=dict(min_silence_duration_ms=300, speech_pad_ms=120), no_speech_threshold=0.6, compression_ratio_threshold=2.4, log_prob_threshold=-1.0, ) segs = list(segments) txt = "".join(s.text for s in segs).strip() words = [] for s in segs: for w in (getattr(s, "words", None) or []): words.append({"w": w.word.strip(), "s": round(float(w.start), 3), "e": round(float(w.end), 3)}) print(f"[DIAG] general → {txt!r} ({len(words)} words)", flush=True) return {"text": txt, "model": "general", "words": words} except Exception as e: print(f"[DIAG] EXC {type(e).__name__}: {e}", flush=True) return JSONResponse({"text": "", "error": str(e), "model": which}, status_code=200)