File size: 21,500 Bytes
a49dc7a
 
 
 
 
 
 
f43d717
a49dc7a
 
 
f43d717
a49dc7a
 
f43d717
a49dc7a
 
f43d717
 
d50f8d9
 
a49dc7a
f43d717
9084bbb
 
 
 
 
 
 
 
 
 
f43d717
 
d50f8d9
 
 
 
 
 
 
 
 
 
 
 
 
 
a49dc7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f43d717
 
 
a49dc7a
 
 
 
 
 
 
e63c07f
 
 
 
 
 
 
 
a49dc7a
 
 
 
 
 
 
 
 
e63c07f
 
a49dc7a
 
 
 
 
 
 
 
 
e63c07f
a49dc7a
 
 
 
 
 
 
 
ae58ce5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a49dc7a
 
 
 
 
 
 
 
 
 
 
 
ae58ce5
a49dc7a
ae58ce5
 
a49dc7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc41108
a49dc7a
 
 
dc41108
a49dc7a
 
 
 
 
 
dc41108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a49dc7a
 
 
 
 
 
 
 
 
 
 
 
dc41108
a49dc7a
ae58ce5
 
 
 
 
 
 
a49dc7a
 
ae58ce5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e91533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a49dc7a
f43d717
a49dc7a
 
 
 
 
 
 
 
 
 
 
d50f8d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
"""معالِج الطبقة الصوتية الكاملة (Tarteel-class) — يتبنّى حزمة المؤلّف tajweed/.

لكل كلمة من النصّ المرجعيّ يُرجع:
  • توقيت (start_ms / end_ms) من المحاذاة القسرية CTC،
  • prob = احتمال صحّة النطق (رأس النطق المُدرَّب 1.33M معامل)،
  • gop = جودة النطق (Goodness of Pronunciation، Witt & Young)،
  • status = correct / warning / wrong (دمج الرأس + GOP).

بروتوكول الطلب:
  {"inputs": "<pcm16 b64>", "ref": "<نصّ الكلمات بالتشكيل>"}  →
  {"text": "...", "words": [{w, start_ms, end_ms, prob, gop, status}], "provider": "quran-stt-acoustic"}

تدهور آمن: إن غابت تبعية/نموذج، يعمل بالنصّ فقط (لا ينهار). متوافق مع البروتوكول القديم
({"inputs": pcm} بلا ref → نصّ فقط).
"""
from __future__ import annotations

import base64
import os
import time
import traceback
from typing import Any, Dict, List, Optional

# Tame OpenMP/BLAS pools BEFORE torch/numpy load their runtimes. On a CPU-only HF
# image the base torch may be a CUDA build whose Intel-OpenMP pool spins across every
# host core; combined with onnxruntime's own pool this oversubscribes shared nodes and
# makes inference ~40x slower. The ONNX pool is bounded in tajweed/aligner.py; these
# env caps bound torch/BLAS the same way. QURAN_STT_THREADS overrides (see aligner).
_omp = os.environ.get("QURAN_STT_THREADS", "").strip()
_omp = _omp if (_omp.isdigit() and int(_omp) > 0) else "4"
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
    os.environ.setdefault(_v, _omp)

import numpy as np

# DIAG: اطبع نسخ المكتبات مرّةً عند الاستيراد لكشف انحدار numpy/ort على الصورة الأحدث.
try:
    import onnxruntime as _ort_diag
    _ort_v = _ort_diag.__version__
except Exception as _e:
    _ort_v = f"ERR:{_e}"
try:
    import torch as _torch_diag
    _torch_v = _torch_diag.__version__
except Exception as _e:
    _torch_v = f"ERR:{_e}"
print(f"DIAG versions: numpy={np.__version__} onnxruntime={_ort_v} torch={_torch_v} "
      f"OMP={os.environ.get('OMP_NUM_THREADS')} cpu_count={os.cpu_count()}", flush=True)

_HERE = os.path.dirname(os.path.abspath(__file__))


def _decode_pcm(b64: str) -> np.ndarray:
    raw = base64.b64decode(b64)
    return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0


# تطبيع الرسم العثمانيّ إلى فضاء النموذج (وإلا تصير ٱ/ٰ رموز <unk> فتُحسَب خطأ).
# النموذج يُخرج ا كاملة لألف الوصل وألف الخنجريّة (madd) — فنحوّلها لا نحذفها.
_NORM_MAP = {
    0x0671: "ا",  # ٱ ألف وصل → ا
    0x0623: "ا",  # أ → ا
    0x0625: "ا",  # إ → ا
    0x0622: "ا",  # آ → ا
    0x0670: "ا",  # ٰ ألف خنجرية (مدّ) → ا
}
# علامات قرآنية صغيرة تُزال (لا تؤثّر في النطق ضمن فضاء النموذج).
_STRIP = set(range(0x06D6, 0x06EE)) | {0x0640, 0x0653, 0x0654, 0x0655, 0x0656}


def _norm_ref(s: str) -> str:
    out = []
    for ch in s:
        o = ord(ch)
        if o in _STRIP:
            continue
        out.append(_NORM_MAP.get(o, ch))
    return "".join(out)


# دمج إشارة الرأس + GOP إلى حالة كلمة واحدة.
def _word_status(prob: Optional[float], gop: Optional[float]) -> str:
    # الرأس المُدرَّب هو الإشارة الأساسية (تمييزيّ، مُعايَر).
    if prob is not None:
        if prob >= 0.40:
            base = "correct"
        elif prob >= 0.30:
            base = "warning"
        else:
            base = "wrong"
    else:
        base = None
    # GOP إشارة مكمّلة (سالبة؛ قرب الصفر = أصحّ).
    if gop is not None:
        if gop < -2.0:
            g = "wrong"
        elif gop < -0.5:
            g = "warning"
        else:
            g = "correct"
    else:
        g = None
    # ادمج: خذ الأسوأ بين الإشارتين (احترازيّ — لا نُمرّر خطأً صامتًا).
    order = {"correct": 0, "warning": 1, "wrong": 2}
    cands = [x for x in (base, g) if x is not None]
    if not cands:
        return "correct"
    return max(cands, key=lambda s: order[s])


class EndpointHandler:
    def __init__(self, path: str = _HERE):
        self.aligner = None
        self.head = None
        self.gop_fn = None
        self.tokenizer = None
        self.init_error = None
        try:
            # كشف الجهاز: CUDA إن توفّر (نقطة GPU)، وإلا CPU — تراجع آمن تامّ فلا تنهار
            # الخدمة إن غاب CUDA. جلسة ONNX في المحاذي تكتشف CUDAExecutionProvider ذاتيًّا.
            try:
                import torch as _torch
                _device = "cuda" if _torch.cuda.is_available() else "cpu"
            except Exception:
                _device = "cpu"
            print(f"  device: {_device}")
            # CTCAligner يحمّل model_with_encoder (logprobs + encoder_output) + tokenizer.
            from tajweed.aligner import CTCAligner, ctc_forced_align, TokenInterval
            from tajweed.gop_scorer import gop_from_logprobs
            self._CTCAligner = CTCAligner
            self._ctc_forced_align = ctc_forced_align
            self._TokenInterval = TokenInterval
            self.gop_fn = gop_from_logprobs
            model = os.path.join(path, "model_with_encoder.q8.ort")
            tok = os.path.join(path, "tokenizer.model")
            # log-mel على GPU إن توفّر (أسرع)، وإلا CPU.
            self.aligner = CTCAligner(model, tok, cuda_feats=(_device == "cuda"))
            self.tokenizer = self.aligner.tokenizer
            if not self.aligner._has_encoder_output():
                # نموذج بلا encoder_output → الرأس معطّل، لكن المحاذاة+GOP تعملان.
                print("WARN: ONNX بلا encoder_output؛ رأس النطق معطّل.")
            else:
                head_ckpt = os.path.join(path, "head", "pronunciation_head.pt")
                if os.path.exists(head_ckpt):
                    try:
                        from tajweed.head_scorer import HeadPronunciationScorer
                        self.head = HeadPronunciationScorer(head_ckpt, device=_device)
                    except Exception as e:
                        print(f"WARN: فشل تحميل رأس النطق ({e})؛ يُكتفى بـ GOP.")
                        self.head = None
        except Exception as e:
            # تدهور آمن تامّ: نصّ فقط عبر معالِج المحاذاة الخفيف.
            self.init_error = str(e)
            print(f"WARN: تعذّر تهيئة الطبقة الصوتية الكاملة ({e}).")

        # محرّك التجويد الـDSP (Tier-1): قياس صوتيّ فعليّ لأحكام المدّ/الغنّة/القلقلة/
        # التفخيم/الإخفاء... لكل حرف. تدهور آمن مستقلّ: إن غاب لا يُعطّل المحاذاة+الرأس+GOP.
        self.engine = None
        self.analyze_text = None
        self._ARABIC_LETTERS = None
        if self.aligner is not None:
            try:
                from tajweed.engine import TajweedEngine
                from tajweed.text_analyzer import analyze_text, ARABIC_LETTERS
                self.engine = TajweedEngine(sample_rate=16000)
                self.analyze_text = analyze_text
                self._ARABIC_LETTERS = ARABIC_LETTERS
            except Exception as e:
                print(f"WARN: تعذّر تحميل محرّك التجويد الـDSP ({e})؛ يُكتفى بالنطق/GOP.")
                self.engine = None

    # ---- نصّ فقط (فكّ جشِع) عبر جلسة المحاذي ----
    def _greedy_text(self, logprobs: np.ndarray) -> str:
        ids = logprobs.argmax(axis=-1)
        out, prev = [], -1
        BLANK = self.aligner.BLANK_ID
        for i in ids:
            i = int(i)
            if i != prev and i != BLANK and 0 <= i < self.tokenizer.get_piece_size():
                out.append(self.tokenizer.id_to_piece(i))
            prev = i
        return "".join(out).replace("▁", " ").strip()

    def _align_to_words(self, logprobs, enc, audio, ref: str) -> List[Dict[str, Any]]:
        HOP = self.aligner.OUTPUT_HOP_S
        ref_norm = _norm_ref(ref)
        token_ids = self.tokenizer.encode(ref_norm, out_type=int)
        if not token_ids:
            return []
        intervals = self._ctc_forced_align(logprobs, token_ids, blank_id=self.aligner.BLANK_ID)
        token_intervals = [
            self._TokenInterval(
                token_id=tid, token_str=self.tokenizer.id_to_piece(tid),
                start_s=a * HOP, end_s=b * HOP,
            )
            for tid, (a, b) in zip(token_ids, intervals)
        ]
        # درجات الرأس لكل رمز (إن توفّر).
        head_by_idx: Dict[int, Any] = {}
        if self.head is not None and enc is not None:
            try:
                for i, hs in enumerate(self.head.score(enc, token_intervals, output_hop_s=HOP)):
                    head_by_idx[i] = hs
            except Exception as e:
                print(f"WARN: فشل تسجيل الرأس ({e}).")
        # GOP لكل رمز.
        gop_by_idx: Dict[int, float] = {}
        if self.gop_fn is not None:
            try:
                for i, gr in enumerate(self.gop_fn(logprobs, token_intervals, output_hop_s=HOP,
                                                   blank_id=self.aligner.BLANK_ID)):
                    gop_by_idx[i] = gr.gop_normalized
            except Exception as e:
                print(f"WARN: فشل GOP ({e}).")
        # تجميع الرموز إلى كلمات (الكلمة تبدأ عند رمز يبدأ بـ ▁).
        words: List[Dict[str, Any]] = []
        cur: Optional[Dict[str, Any]] = None
        for i, ti in enumerate(token_intervals):
            piece = ti.token_str
            starts_word = piece.startswith("▁") or cur is None
            if starts_word:
                if cur is not None:
                    words.append(cur)
                cur = {"w": "", "start_s": ti.start_s, "end_s": ti.end_s,
                       "fa": int(intervals[i][0]), "fb": int(intervals[i][1]),
                       "probs": [], "gops": []}
            cur["w"] += piece.replace("▁", "")
            cur["end_s"] = ti.end_s
            cur["fb"] = int(intervals[i][1])  # نطاق إطارات الكلمة (للفكّ الموضعيّ)
            if i in head_by_idx:
                cur["probs"].append(head_by_idx[i].prob_correct)
            if i in gop_by_idx:
                cur["gops"].append(gop_by_idx[i])
        if cur is not None:
            words.append(cur)
        # فكّ جشِع **موضعيّ** على نطاق إطارات كل كلمة — إشارة آمنة لتأكيد أن صوت المقطع يحوي
        # الكلمة المتوقَّعة فعلًا (يكشف ما يُسقطه/يخطئه التفريغ العامّ على الصوت الطويل). يُقارَن
        # بالمتوقَّع في العميل لإنقاذ كلمةٍ عُلِّمت خطأً نصيًّا لكنّها نُطِقت صحيحةً.
        BLANK = self.aligner.BLANK_ID
        def _local_decode(fa: int, fb: int) -> str:
            if fb < fa:
                return ""
            ids = logprobs[fa:fb + 1].argmax(axis=-1)
            o, prev = [], -1
            for x in ids:
                x = int(x)
                if x != prev and x != BLANK and 0 <= x < self.tokenizer.get_piece_size():
                    o.append(self.tokenizer.id_to_piece(x))
                prev = x
            return "".join(o).replace("▁", "")
        # اختزال إلى المخرجات النهائية لكل كلمة.
        out: List[Dict[str, Any]] = []
        for wd in words:
            prob = round(float(min(wd["probs"])), 3) if wd["probs"] else None   # أضعف حرف يحكم
            gop = round(float(np.mean(wd["gops"])), 3) if wd["gops"] else None
            out.append({
                "w": wd["w"],
                "start_ms": int(round(wd["start_s"] * 1000)),
                "end_ms": int(round(wd["end_s"] * 1000)),
                "prob": prob,
                "gop": gop,
                "status": _word_status(prob, gop),
                "decoded": _local_decode(wd.get("fa", 0), wd.get("fb", -1)),
            })
        # إثراء التجويد الـDSP (مدّ/غنّة/قلقلة/تفخيم/إخفاء...) لكل كلمة. تدهور آمن:
        # أيّ فشل يُبقي الكلمات بتوقيت+نطق+GOP دون تجويد.
        if self.engine is not None and self.analyze_text is not None:
            try:
                self._attach_tajweed(audio, token_intervals, out, ref_norm)
            except Exception as e:
                print(f"WARN: فشل إثراء التجويد ({e}).")
        return out

    # ---- إثراء التجويد الـDSP: يربط أحكام كل حرف بكلمتها بالتوقيت المُحاذى ----
    def _attach_tajweed(self, audio, token_intervals, out, ref_norm: str) -> None:
        """يبني توقيتًا لكل حرف من الرموز المُحاذاة (بترجيح حروف المدّ)، ثم يشغّل
        محلّل النصّ + محرّك القياس الصوتيّ، ويُسنِد أحكام كل حرف إلى كلمته بالفهرس.

        النصّ المُطبَّع (ref_norm) نفسه يُستعمل للتحليل والتوكنة → تطابق ١:١ بين
        تسلسل الحروف وتوقيت الرموز (لا انجراف). يُبقي التطبيع الحركات/السكون/الشدّة
        التي تحتاجها معظم القواعد، ويسقط فقط همزة الوصل وعلامات صغيرة.
        """
        MADD_LETTERS = {"ا", "و", "ي", "ى", "آ"}
        MADD_WEIGHT = 2.5  # حروف المدّ تستهلك وقتًا أطول → ترجيح أعلى عند تقسيم الفاصل
        # (١) توقيت كل حرف + الكلمة التي ينتمي إليها (إعادة نفس منطق تجميع الرموز للكلمات).
        char_times: List[tuple] = []
        char_word: List[int] = []
        wi = -1
        for i, ti in enumerate(token_intervals):
            piece = ti.token_str
            if piece.startswith("▁") or i == 0:
                wi += 1
            letters = [c for c in piece.replace("▁", "") if c in self._ARABIC_LETTERS]
            if not letters:
                continue
            weights = [MADD_WEIGHT if c in MADD_LETTERS else 1.0 for c in letters]
            total_w = sum(weights)
            unit = (ti.end_s - ti.start_s) / total_w if total_w else 0.0
            cur = ti.start_s
            for w in weights:
                seg = unit * w
                char_times.append((cur, cur + seg))
                char_word.append(wi)
                cur += seg
        if not char_times:
            return
        # (٢) تحليل النصّ (سياق عابر للكلمات محفوظ) + قياس صوتيّ لكل حرف.
        annotations = self.analyze_text(ref_norm)
        n = min(len(annotations), len(char_times))
        if n == 0:
            return
        results = self.engine.score_with_annotations(audio, annotations[:n], char_times[:n])
        # (٣) إسناد أحكام كل حرف إلى كلمته. نُبقي القواعد القابلة للقياس فقط (flag != n/a).
        per_word: List[List[dict]] = [[] for _ in out]
        for idx, r in enumerate(results):
            wj = char_word[idx]
            if not (0 <= wj < len(out)):
                continue
            for m in r.measurements:
                flag = m.get("flag")
                if not flag or flag == "n/a":
                    continue
                per_word[wj].append({
                    "rule": m.get("rule", ""),
                    "flag": flag,                       # ok | short | long | missing | weak ...
                    "msg_ar": m.get("msg_ar", ""),
                })
        for wd, tj in zip(out, per_word):
            if tj:
                wd["tajweed"] = tj

    # ---- تفريغ مُجزّأ عند الوقفات (VAD) — يحلّ تدهور المودل على الصوت الطويل ----
    def _vad_segments(self, audio: np.ndarray, sr: int = 16000):
        """نقاط تقطيع عند الصمت (>0.35ث): يعطي مقاطع بطول آية يتقنها المودل المدرَّب
        على مقاطع قصيرة. الصوت مُطبَّع (ذروة ~0.7) فالعتبة المطلقة 0.02 مستقرّة."""
        fr = int(sr * 0.03)
        n = (len(audio) - fr) // fr
        if n < 2:
            return [(0, len(audio))]
        rms = np.sqrt(np.array([(audio[i * fr:i * fr + fr] ** 2).mean() for i in range(n)]) + 1e-9)
        sil = rms < 0.02
        pad = int(0.2 * sr)
        segs, in_sp, st, k = [], False, 0, 0
        while k < n:
            if not sil[k]:
                if not in_sp:
                    in_sp, st = True, k
                k += 1
            else:
                j = k
                while j < n and sil[j]:
                    j += 1
                if in_sp and ((j - k) * 0.03 >= 0.35 or j >= n):
                    segs.append((max(0, st * fr), min(len(audio), k * fr + pad)))
                    in_sp = False
                k = j
        if in_sp:
            segs.append((st * fr, len(audio)))
        # حارس: مقطع طويل بلا وقفة (آية طويلة) يُقسَّم نوافذ ~8ث كي لا يتدهور.
        out = []
        for a, b in (segs or [(0, len(audio))]):
            if b - a > 12 * sr:
                w, ov = 8 * sr, 1 * sr
                t = a
                while t < b:
                    out.append((t, min(b, t + w)))
                    t += w - ov
            else:
                out.append((a, b))
        return out

    def _segmented_text(self, audio: np.ndarray, logprobs: np.ndarray) -> str:
        """نصّ مُجزّأ للصوت الطويل (>9ث): يُفرّغ كل مقطع وحده ويجمعها (يستعيد البسملة
        والكلمات التي تُسقطها التمريرة الواحدة). للقصير/اللحظيّ: تمريرة واحدة سريعة."""
        if len(audio) <= 9 * 16000:
            return self._greedy_text(logprobs)
        parts = []
        for a, b in self._vad_segments(audio):
            seg = audio[a:b]
            if len(seg) < int(0.3 * 16000):
                continue
            try:
                lp = (self.aligner._run_model_with_encoder(seg)[0]
                      if self.aligner._has_encoder_output() else self.aligner._run_model(seg))
                t = self._greedy_text(lp)
                if t:
                    parts.append(t)
            except Exception as e:
                print(f"WARN: فشل تفريغ مقطع ({e}).")
        joined = " ".join(parts).strip()
        return joined or self._greedy_text(logprobs)

    def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
        inp = data.get("inputs", data)
        if isinstance(inp, dict):
            pcm_b64 = inp.get("pcm")
            ref = data.get("ref") or inp.get("ref")
        else:
            pcm_b64 = inp
            ref = data.get("ref")
        if not pcm_b64:
            return {"error": "missing_pcm"}
        if self.aligner is None:
            return {"text": "", "words": [], "provider": "quran-stt-acoustic",
                    "error": self.init_error or "uninitialized"}
        try:
            _t = time.time()
            audio = _decode_pcm(pcm_b64)
            print(f"DIAG call: decoded {len(audio)} samples ({len(audio)/16000:.2f}s) in {time.time()-_t:.3f}s", flush=True)
            # تمريرة واحدة: logprobs (+encoder إن توفّر).
            _t = time.time()
            if self.aligner._has_encoder_output():
                logprobs, enc = self.aligner._run_model_with_encoder(audio)
            else:
                logprobs, enc = self.aligner._run_model(audio), None
            print(f"DIAG call: ONNX inference in {time.time()-_t:.3f}s (logprobs {getattr(logprobs,'shape',None)})", flush=True)
            _t = time.time()
            text = self._segmented_text(audio, logprobs)
            print(f"DIAG call: text in {time.time()-_t:.3f}s -> '{text[:40]}'", flush=True)
            words = []
            if ref:
                try:
                    _t = time.time()
                    words = self._align_to_words(logprobs, enc, audio, ref)
                    print(f"DIAG call: align in {time.time()-_t:.3f}s ({len(words)} words)", flush=True)
                except Exception as e:
                    print(f"WARN: فشل المحاذاة الكاملة ({e}).", flush=True)
                    words = []
            return {"text": text, "words": words, "provider": "quran-stt-acoustic"}
        except Exception as e:
            print("DIAG call ERROR:\n" + traceback.format_exc(), flush=True)
            return {"text": "", "words": [], "provider": "quran-stt-acoustic", "error": f"call:{e}"}