File size: 10,190 Bytes
6f77435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954e0aa
6f77435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
ASR Models β€” English (Parakeet) and Arabic (wav2vec2) singletons.

Strategy (ported from Raij/src/models.py, hotword biasing removed):
  - Lazy-loaded singletons: models load on first call, not at import time.
  - Warmup on load: a silent audio pass pre-JITs the computation graph so
    the first real request has the same latency as subsequent ones.
  - Batch inference: both transcribe_*_batch functions accept a list of
    audio file paths and run a single forward pass.
  - Thread lock on Parakeet: model.transcribe() is stateful (TDT decoder),
    so we serialize all calls behind _en_model_lock.
  - Warmup functions (warmup_parakeet / warmup_wav2vec2) are called
    periodically by the batch worker warmup loop to prevent OpenMP
    thread pool spin-down during idle periods.
"""

import os
import threading
from loguru import logger

# Force PyTorch path, no TensorFlow
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("USE_TORCH", "1")

# Cap OpenMP threads β€” adjust if running on a GPU server with more cores
os.environ.setdefault("OMP_NUM_THREADS", "2")

import torch
torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", "2")))
torch.set_num_interop_threads(1)

_audio_model_en = None
_audio_model_ar = None
_en_model_lock = threading.Lock()
_ar_model_lock = threading.Lock()


# ═══════════════════════ English ASR (Parakeet) ════════════════════════

def get_audio_model_en():
    """
    Loads nvidia/parakeet-tdt-0.6b-v2 via NeMo.
    Runs a 1-second silence warmup to pre-JIT internal computation graphs.
    Uses greedy_batch decoding strategy for best throughput.
    No hotword biasing β€” general-purpose decoding.
    """
    global _audio_model_en
    if _audio_model_en is not None:
        return _audio_model_en

    import wave
    import tempfile
    import nemo.collections.asr as nemo_asr

    logger.info("Loading English ASR model (nvidia/parakeet-tdt-0.6b-v2)...")
    model = nemo_asr.models.ASRModel.from_pretrained("nvidia/parakeet-tdt-0.6b-v2")

    # ── Warmup: transcribe 1s of silence to pre-JIT computation graphs ──
    warmup_path = None
    try:
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
            warmup_path = f.name
        with wave.open(warmup_path, "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(16000)
            wf.writeframes(b"\x00" * 32000)  # 1s of silence at 16kHz
        model.freeze()
        with torch.no_grad():
            model.transcribe([warmup_path])
        model.unfreeze()
        logger.info("βœ… Parakeet warmup complete")
    except Exception as e:
        logger.warning(f"⚠️ Parakeet warmup failed (non-fatal): {e}")
    finally:
        if warmup_path:
            try:
                os.unlink(warmup_path)
            except Exception:
                pass

    # ── Switch to greedy_batch for speed (no hotword biasing) ──
    try:
        from omegaconf import OmegaConf
        decoding_cfg = OmegaConf.structured(model.cfg.decoding)
        OmegaConf.update(decoding_cfg, "strategy", "greedy_batch")
        if hasattr(decoding_cfg, "greedy"):
            OmegaConf.update(decoding_cfg, "greedy.max_symbols", 5)
        try:
            model.change_decoding_strategy(decoding_cfg, verbose=False)
            logger.info("βœ… Parakeet decoding strategy: greedy_batch")
        except Exception as strat_e:
            logger.warning(f"⚠️ greedy_batch strategy failed ({strat_e}), using default")
    except Exception as e:
        logger.warning(f"⚠️ Parakeet decoding strategy setup failed (non-fatal): {e}")

    _audio_model_en = model
    logger.info("βœ… English ASR model (Parakeet) loaded and ready")
    return _audio_model_en


def transcribe_en_batch(audio_paths: list[str]) -> list[str]:
    """
    Batch transcription for English audio using Parakeet.
    NeMo's model.transcribe() natively handles batching internally β€”
    it pads to the same length and runs a single forward pass.
    Serialized behind _en_model_lock (Parakeet TDT decoder is stateful).
    Returns a list of transcription strings (one per input path).
    """
    model = get_audio_model_en()
    with _en_model_lock:
        with torch.no_grad():
            transcriptions = model.transcribe(audio_paths)
            if isinstance(transcriptions, tuple):
                transcriptions = transcriptions[0]
            return [
                (t.text if hasattr(t, "text") else str(t)).strip().rstrip(".")
                for t in transcriptions
            ]


def warmup_parakeet():
    """
    Keeps Parakeet's OpenMP threads alive via a 0.5s silence transcription.
    Must use model.transcribe() (not raw encoder) to avoid corrupting the
    TDT decoder cache. No-op if model is not yet loaded.
    """
    if _audio_model_en is None:
        return
    import wave
    import tempfile
    model = _audio_model_en
    warmup_path = None
    try:
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
            warmup_path = f.name
        with wave.open(warmup_path, "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(16000)
            wf.writeframes(b"\x00" * 16000)  # 0.5s of silence
        with _en_model_lock:
            model.eval()
            with torch.no_grad():
                model.transcribe([warmup_path])
    except Exception as e:
        logger.warning(f"⚠️ Parakeet warmup error (non-fatal): {e}")
    finally:
        if warmup_path:
            try:
                os.unlink(warmup_path)
            except Exception:
                pass


# ═══════════════════════ Arabic ASR (wav2vec2) ════════════════════════

def get_audio_model_ar():
    """
    Loads IbrahimAmin/egyptian-arabic-wav2vec2-xlsr-53 via HuggingFace Transformers.
    Runs a 0.5s dummy forward pass to warm up OpenMP threads.
    No hotword biasing β€” pure greedy argmax decoding.
    """
    global _audio_model_ar
    if _audio_model_ar is not None:
        return _audio_model_ar

    import numpy as np
    from transformers import Wav2Vec2ForCTC, AutoProcessor

    model_name = "IbrahimAmin/egyptian-arabic-wav2vec2-xlsr-53"
    logger.info(f"Loading Arabic ASR model ({model_name})...")

    processor = AutoProcessor.from_pretrained(model_name)
    model = Wav2Vec2ForCTC.from_pretrained(model_name)
    model.eval()

    # ── Warmup: single forward pass on dummy audio ──
    try:
        dummy = np.zeros(8000, dtype=np.float32)
        warmup_inputs = processor(
            [dummy], sampling_rate=16000, return_tensors="pt", padding=True
        )
        with torch.no_grad():
            model(**warmup_inputs)
        logger.info("βœ… Arabic ASR warmup complete")
    except Exception as e:
        logger.warning(f"⚠️ Arabic ASR warmup failed (non-fatal): {e}")

    _audio_model_ar = {"model": model, "processor": processor, "model_name": model_name}
    logger.info("βœ… Arabic ASR model (wav2vec2) loaded and ready")
    return _audio_model_ar


def _load_audio_file(path: str):
    """
    Load an audio file to a 16kHz mono float32 numpy array.
    Tries soundfile first (fast, no subprocess), then falls back to
    librosa (handles more formats including webm via ffmpeg backend).
    """
    import numpy as np
    try:
        import soundfile as sf
        data, sr = sf.read(path, dtype="float32", always_2d=False)
        if data.ndim > 1:
            data = data.mean(axis=1)
        if sr != 16000:
            import librosa
            data = librosa.resample(data, orig_sr=sr, target_sr=16000)
        return data.astype(np.float32)
    except Exception:
        import librosa
        data, _ = librosa.load(path, sr=16000, mono=True)
        return data.astype(np.float32)


def transcribe_ar_batch(audio_paths: list[str]) -> list[str]:
    """
    Batch transcription for Arabic audio using wav2vec2.
    Loads all audio concurrently, pads to same length,
    runs one forward pass, and decodes via greedy argmax.
    Returns a list of transcription strings (one per input path).
    """
    import numpy as np
    from concurrent.futures import ThreadPoolExecutor

    ar = get_audio_model_ar()
    model = ar["model"]
    processor = ar["processor"]

    # Load all waveforms concurrently
    with ThreadPoolExecutor(max_workers=min(len(audio_paths), 8)) as executor:
        waveforms_raw = list(executor.map(_load_audio_file, audio_paths))

    # Guard against empty waveforms from failed decodes
    final_texts = [""] * len(audio_paths)
    valid_indices: list[int] = []
    valid_waveforms: list[np.ndarray] = []

    for idx, wav in enumerate(waveforms_raw):
        arr = np.asarray(wav, dtype=np.float32).reshape(-1)
        if arr.size == 0:
            logger.warning(f"⚠️ Arabic ASR: empty waveform at index {idx}, skipping")
            continue
        valid_indices.append(idx)
        valid_waveforms.append(arr)

    if not valid_waveforms:
        return final_texts

    inputs = processor(
        valid_waveforms,
        sampling_rate=16000,
        return_tensors="pt",
        padding=True,
    )

    with _ar_model_lock:
        with torch.no_grad():
            outputs = model(**inputs)

    predicted_ids = torch.argmax(outputs.logits, dim=-1)
    transcriptions = processor.batch_decode(predicted_ids)

    for local_i, text in enumerate(transcriptions):
        final_texts[valid_indices[local_i]] = text.strip().rstrip(".")

    return final_texts


def warmup_wav2vec2():
    """
    Lightweight raw forward pass to keep wav2vec2's OpenMP threads alive.
    No-op if the Arabic model is not yet loaded.
    """
    if _audio_model_ar is None:
        return
    import numpy as np
    ar = _audio_model_ar
    dummy = np.zeros(8000, dtype=np.float32)
    inputs = ar["processor"](
        [dummy], sampling_rate=16000, return_tensors="pt", padding=True
    )
    with torch.no_grad():
        ar["model"](**inputs)