Study-Buddy / asr /models.py
Hamdy005's picture
refactor: replace standard logging with a centralized loguru configuration and interception handler
954e0aa
Raw
History Blame Contribute Delete
10.2 kB
"""
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)