Hamdy005 commited on
Commit
6f77435
·
1 Parent(s): 2badbec

feat: implement ASR pipeline with async batch processing, FastAPI endpoints, and client-side transcription support

Browse files
Files changed (7) hide show
  1. asr/__init__.py +1 -0
  2. asr/batch_workers.py +256 -0
  3. asr/constants.py +13 -0
  4. asr/models.py +287 -0
  5. asr/routes.py +90 -0
  6. asr/schemas.py +11 -0
  7. main.py +16 -0
asr/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # ASR package
asr/batch_workers.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASR Batch Workers — Async batching infrastructure for voice transcription.
3
+
4
+ Architecture (mirrors Raij/src/smart_search/batch_workers.py, audio workers only):
5
+ - Two asyncio.Queues: audio_en_queue and audio_ar_queue.
6
+ - Two worker coroutines per queue drain jobs in micro-batches.
7
+ - Workers offload heavy inference to a thread via run_in_executor.
8
+ - A shared in-memory job_store tracks job status + results.
9
+ - A warmup loop periodically keeps OpenMP threads alive between requests.
10
+ Parakeet warmup runs every PARAKEET_WARMUP_EVERY cycles (full transcribe,
11
+ must use the full pipeline to avoid corrupting TDT decoder state).
12
+ wav2vec2 warmup runs every cycle (cheap raw forward pass).
13
+ """
14
+
15
+ import asyncio
16
+ import time
17
+ import uuid
18
+ import logging
19
+ from typing import Any
20
+
21
+ from .constants import ASR_BATCH_MAX, ASR_BATCH_WINDOW_S, WARMUP_INTERVAL_S, PARAKEET_WARMUP_EVERY
22
+ from .schemas import AudioJob
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ═══════════════════════ Job Store ════════════════════════
28
+
29
+ job_store: dict[str, dict[str, Any]] = {}
30
+ """
31
+ {
32
+ "<job_id>": {
33
+ "status": "pending" | "processing" | "done" | "error",
34
+ "result": <str transcript> | None,
35
+ "error": <str> | None,
36
+ }
37
+ }
38
+ """
39
+
40
+
41
+ def create_job() -> str:
42
+ """Create a new pending job and return its ID."""
43
+ job_id = str(uuid.uuid4())
44
+ job_store[job_id] = {"status": "pending", "result": None, "error": None}
45
+ return job_id
46
+
47
+
48
+ # ═══════════════════════ Request-in-Flight Gate ════════════════════════
49
+
50
+ _request_in_flight_count = 0
51
+
52
+
53
+ def set_request_in_flight(active: bool):
54
+ """Increment/decrement in-flight counter used to gate warmup cycles."""
55
+ global _request_in_flight_count
56
+ if active:
57
+ _request_in_flight_count += 1
58
+ else:
59
+ _request_in_flight_count = max(0, _request_in_flight_count - 1)
60
+
61
+
62
+ def is_request_in_flight() -> bool:
63
+ return _request_in_flight_count > 0
64
+
65
+
66
+ # ═══════════════════════ Queues ════════════════════════
67
+
68
+ audio_en_queue: asyncio.Queue[AudioJob] = asyncio.Queue()
69
+ audio_ar_queue: asyncio.Queue[AudioJob] = asyncio.Queue()
70
+
71
+
72
+ # ═══════════════════════ Workers ════════════════════════
73
+
74
+ async def audio_en_worker():
75
+ """
76
+ Drains up to ASR_BATCH_MAX English audio jobs every ASR_BATCH_WINDOW_S seconds.
77
+ Runs one batched Parakeet transcription via run_in_executor.
78
+ Writes transcript into job_store and sets job.done.
79
+ Cleans up temp audio files after processing.
80
+ """
81
+ import os
82
+ from .models import transcribe_en_batch
83
+
84
+ loop = asyncio.get_event_loop()
85
+
86
+ while True:
87
+ first_job: AudioJob = await audio_en_queue.get()
88
+ batch: list[AudioJob] = [first_job]
89
+
90
+ # Collect up to (ASR_BATCH_MAX - 1) more within the time window
91
+ deadline = loop.time() + ASR_BATCH_WINDOW_S
92
+ while len(batch) < ASR_BATCH_MAX:
93
+ remaining = deadline - loop.time()
94
+ if remaining <= 0:
95
+ break
96
+ try:
97
+ job = await asyncio.wait_for(audio_en_queue.get(), timeout=remaining)
98
+ batch.append(job)
99
+ except asyncio.TimeoutError:
100
+ break
101
+
102
+ for job in batch:
103
+ job_store[job.job_id]["status"] = "processing"
104
+
105
+ try:
106
+ set_request_in_flight(True)
107
+ audio_paths = [job.audio_path for job in batch]
108
+ transcripts = await loop.run_in_executor(None, transcribe_en_batch, audio_paths)
109
+
110
+ for job, transcript in zip(batch, transcripts):
111
+ if not transcript.strip():
112
+ job_store[job.job_id]["status"] = "error"
113
+ job_store[job.job_id]["error"] = "Could not transcribe audio. Please try again and speak clearly."
114
+ else:
115
+ job_store[job.job_id]["status"] = "done"
116
+ job_store[job.job_id]["result"] = transcript
117
+ job.done.set()
118
+
119
+ except Exception as e:
120
+ logger.error(f"English ASR batch failed: {e}", exc_info=True)
121
+ for job in batch:
122
+ job_store[job.job_id]["status"] = "error"
123
+ job_store[job.job_id]["error"] = str(e)
124
+ if not job.done.is_set():
125
+ job.done.set()
126
+ finally:
127
+ set_request_in_flight(False)
128
+ for job in batch:
129
+ try:
130
+ os.unlink(job.audio_path)
131
+ except Exception:
132
+ pass
133
+
134
+
135
+ async def audio_ar_worker():
136
+ """
137
+ Drains up to ASR_BATCH_MAX Arabic audio jobs every ASR_BATCH_WINDOW_S seconds.
138
+ Runs one batched wav2vec2 transcription via run_in_executor.
139
+ Writes transcript into job_store and sets job.done.
140
+ Cleans up temp audio files after processing.
141
+ """
142
+ import os
143
+ from .models import transcribe_ar_batch
144
+
145
+ loop = asyncio.get_event_loop()
146
+
147
+ while True:
148
+ first_job: AudioJob = await audio_ar_queue.get()
149
+ batch: list[AudioJob] = [first_job]
150
+
151
+ deadline = loop.time() + ASR_BATCH_WINDOW_S
152
+ while len(batch) < ASR_BATCH_MAX:
153
+ remaining = deadline - loop.time()
154
+ if remaining <= 0:
155
+ break
156
+ try:
157
+ job = await asyncio.wait_for(audio_ar_queue.get(), timeout=remaining)
158
+ batch.append(job)
159
+ except asyncio.TimeoutError:
160
+ break
161
+
162
+ for job in batch:
163
+ job_store[job.job_id]["status"] = "processing"
164
+
165
+ try:
166
+ set_request_in_flight(True)
167
+ audio_paths = [job.audio_path for job in batch]
168
+ transcripts = await loop.run_in_executor(None, transcribe_ar_batch, audio_paths)
169
+
170
+ for job, transcript in zip(batch, transcripts):
171
+ if not transcript.strip():
172
+ job_store[job.job_id]["status"] = "error"
173
+ job_store[job.job_id]["error"] = "لم يتم التعرف على الصوت. الرجاء المحاولة مرة أخرى والتحدث بوضوح."
174
+ else:
175
+ job_store[job.job_id]["status"] = "done"
176
+ job_store[job.job_id]["result"] = transcript
177
+ job.done.set()
178
+
179
+ except Exception as e:
180
+ logger.error(f"Arabic ASR batch failed: {e}", exc_info=True)
181
+ for job in batch:
182
+ job_store[job.job_id]["status"] = "error"
183
+ job_store[job.job_id]["error"] = str(e)
184
+ if not job.done.is_set():
185
+ job.done.set()
186
+ finally:
187
+ set_request_in_flight(False)
188
+ for job in batch:
189
+ try:
190
+ os.unlink(job.audio_path)
191
+ except Exception:
192
+ pass
193
+
194
+
195
+ # ═══════════════════════ Warmup Loop ════════════════════════
196
+
197
+ async def _asr_warmup_loop():
198
+ """
199
+ Periodically poke both ASR models to prevent OpenMP/MKL thread pool
200
+ spin-down during idle periods.
201
+
202
+ - wav2vec2: every WARMUP_INTERVAL_S seconds (raw forward pass, ~5-15ms)
203
+ - Parakeet: every PARAKEET_WARMUP_EVERY cycles (~6 min at 45s/cycle)
204
+ Uses full model.transcribe() to avoid corrupting TDT decoder cache.
205
+
206
+ Skipped entirely if a real request is in flight.
207
+ """
208
+ from .models import warmup_parakeet, warmup_wav2vec2
209
+
210
+ loop = asyncio.get_event_loop()
211
+ parakeet_cycle = 0
212
+
213
+ while True:
214
+ await asyncio.sleep(WARMUP_INTERVAL_S)
215
+ if is_request_in_flight():
216
+ continue
217
+
218
+ t0 = time.monotonic()
219
+ try:
220
+ await loop.run_in_executor(None, warmup_wav2vec2)
221
+ parakeet_cycle += 1
222
+ if parakeet_cycle >= PARAKEET_WARMUP_EVERY:
223
+ parakeet_cycle = 0
224
+ await loop.run_in_executor(None, warmup_parakeet)
225
+ except Exception as e:
226
+ logger.warning(f"⚠️ ASR warmup cycle error (non-fatal): {e}")
227
+ continue
228
+
229
+ elapsed_ms = (time.monotonic() - t0) * 1000
230
+ logger.info(f"🔥 ASR warmup cycle done in {elapsed_ms:.0f}ms")
231
+
232
+
233
+ # ═══════════════════════ Startup ════════════════════════
234
+
235
+ _asr_workers_started = False
236
+
237
+
238
+ def start_asr_workers():
239
+ """
240
+ Launch all ASR async worker coroutines. Call once during app startup.
241
+ - 2 English audio workers (Parakeet)
242
+ - 2 Arabic audio workers (wav2vec2)
243
+ - 1 warmup loop
244
+ """
245
+ global _asr_workers_started
246
+ if _asr_workers_started:
247
+ return
248
+ _asr_workers_started = True
249
+
250
+ for i in range(2):
251
+ asyncio.create_task(audio_en_worker(), name=f"asr_en_worker_{i}")
252
+ for i in range(2):
253
+ asyncio.create_task(audio_ar_worker(), name=f"asr_ar_worker_{i}")
254
+ asyncio.create_task(_asr_warmup_loop(), name="asr_warmup_loop")
255
+
256
+ logger.info("✅ ASR batch workers started (2 EN + 2 AR + warmup loop)")
asr/constants.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ═══════════════════════ ASR Batch Worker Constants ════════════════════════
2
+
3
+ # Max audio files coalesced per batch window
4
+ ASR_BATCH_MAX = 6
5
+
6
+ # Seconds to collect concurrent jobs before firing inference
7
+ ASR_BATCH_WINDOW_S = 0.1
8
+
9
+ # Lightweight wav2vec2 warmup cadence (seconds)
10
+ WARMUP_INTERVAL_S = 45
11
+
12
+ # Parakeet (full model.transcribe) warmup every N lightweight cycles (~6 min at 45s each)
13
+ PARAKEET_WARMUP_EVERY = 8
asr/models.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASR Models — English (Parakeet) and Arabic (wav2vec2) singletons.
3
+
4
+ Strategy (ported from Raij/src/models.py, hotword biasing removed):
5
+ - Lazy-loaded singletons: models load on first call, not at import time.
6
+ - Warmup on load: a silent audio pass pre-JITs the computation graph so
7
+ the first real request has the same latency as subsequent ones.
8
+ - Batch inference: both transcribe_*_batch functions accept a list of
9
+ audio file paths and run a single forward pass.
10
+ - Thread lock on Parakeet: model.transcribe() is stateful (TDT decoder),
11
+ so we serialize all calls behind _en_model_lock.
12
+ - Warmup functions (warmup_parakeet / warmup_wav2vec2) are called
13
+ periodically by the batch worker warmup loop to prevent OpenMP
14
+ thread pool spin-down during idle periods.
15
+ """
16
+
17
+ import os
18
+ import threading
19
+ import logging
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Force PyTorch path, no TensorFlow
24
+ os.environ.setdefault("USE_TF", "0")
25
+ os.environ.setdefault("USE_TORCH", "1")
26
+
27
+ # Cap OpenMP threads — adjust if running on a GPU server with more cores
28
+ os.environ.setdefault("OMP_NUM_THREADS", "2")
29
+
30
+ import torch
31
+ torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", "2")))
32
+ torch.set_num_interop_threads(1)
33
+
34
+ _audio_model_en = None
35
+ _audio_model_ar = None
36
+ _en_model_lock = threading.Lock()
37
+ _ar_model_lock = threading.Lock()
38
+
39
+
40
+ # ═══════════════════════ English ASR (Parakeet) ════════════════════════
41
+
42
+ def get_audio_model_en():
43
+ """
44
+ Loads nvidia/parakeet-tdt-0.6b-v2 via NeMo.
45
+ Runs a 1-second silence warmup to pre-JIT internal computation graphs.
46
+ Uses greedy_batch decoding strategy for best throughput.
47
+ No hotword biasing — general-purpose decoding.
48
+ """
49
+ global _audio_model_en
50
+ if _audio_model_en is not None:
51
+ return _audio_model_en
52
+
53
+ import wave
54
+ import tempfile
55
+ import nemo.collections.asr as nemo_asr
56
+
57
+ logger.info("Loading English ASR model (nvidia/parakeet-tdt-0.6b-v2)...")
58
+ model = nemo_asr.models.ASRModel.from_pretrained("nvidia/parakeet-tdt-0.6b-v2")
59
+
60
+ # ── Warmup: transcribe 1s of silence to pre-JIT computation graphs ──
61
+ warmup_path = None
62
+ try:
63
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
64
+ warmup_path = f.name
65
+ with wave.open(warmup_path, "wb") as wf:
66
+ wf.setnchannels(1)
67
+ wf.setsampwidth(2)
68
+ wf.setframerate(16000)
69
+ wf.writeframes(b"\x00" * 32000) # 1s of silence at 16kHz
70
+ model.freeze()
71
+ with torch.no_grad():
72
+ model.transcribe([warmup_path])
73
+ model.unfreeze()
74
+ logger.info("✅ Parakeet warmup complete")
75
+ except Exception as e:
76
+ logger.warning(f"⚠️ Parakeet warmup failed (non-fatal): {e}")
77
+ finally:
78
+ if warmup_path:
79
+ try:
80
+ os.unlink(warmup_path)
81
+ except Exception:
82
+ pass
83
+
84
+ # ── Switch to greedy_batch for speed (no hotword biasing) ──
85
+ try:
86
+ from omegaconf import OmegaConf
87
+ decoding_cfg = OmegaConf.structured(model.cfg.decoding)
88
+ OmegaConf.update(decoding_cfg, "strategy", "greedy_batch")
89
+ if hasattr(decoding_cfg, "greedy"):
90
+ OmegaConf.update(decoding_cfg, "greedy.max_symbols", 5)
91
+ try:
92
+ model.change_decoding_strategy(decoding_cfg, verbose=False)
93
+ logger.info("✅ Parakeet decoding strategy: greedy_batch")
94
+ except Exception as strat_e:
95
+ logger.warning(f"⚠️ greedy_batch strategy failed ({strat_e}), using default")
96
+ except Exception as e:
97
+ logger.warning(f"⚠️ Parakeet decoding strategy setup failed (non-fatal): {e}")
98
+
99
+ _audio_model_en = model
100
+ logger.info("✅ English ASR model (Parakeet) loaded and ready")
101
+ return _audio_model_en
102
+
103
+
104
+ def transcribe_en_batch(audio_paths: list[str]) -> list[str]:
105
+ """
106
+ Batch transcription for English audio using Parakeet.
107
+ NeMo's model.transcribe() natively handles batching internally —
108
+ it pads to the same length and runs a single forward pass.
109
+ Serialized behind _en_model_lock (Parakeet TDT decoder is stateful).
110
+ Returns a list of transcription strings (one per input path).
111
+ """
112
+ model = get_audio_model_en()
113
+ with _en_model_lock:
114
+ with torch.no_grad():
115
+ transcriptions = model.transcribe(audio_paths)
116
+ if isinstance(transcriptions, tuple):
117
+ transcriptions = transcriptions[0]
118
+ return [
119
+ (t.text if hasattr(t, "text") else str(t)).strip().rstrip(".")
120
+ for t in transcriptions
121
+ ]
122
+
123
+
124
+ def warmup_parakeet():
125
+ """
126
+ Keeps Parakeet's OpenMP threads alive via a 0.5s silence transcription.
127
+ Must use model.transcribe() (not raw encoder) to avoid corrupting the
128
+ TDT decoder cache. No-op if model is not yet loaded.
129
+ """
130
+ if _audio_model_en is None:
131
+ return
132
+ import wave
133
+ import tempfile
134
+ model = _audio_model_en
135
+ warmup_path = None
136
+ try:
137
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
138
+ warmup_path = f.name
139
+ with wave.open(warmup_path, "wb") as wf:
140
+ wf.setnchannels(1)
141
+ wf.setsampwidth(2)
142
+ wf.setframerate(16000)
143
+ wf.writeframes(b"\x00" * 16000) # 0.5s of silence
144
+ with _en_model_lock:
145
+ model.eval()
146
+ with torch.no_grad():
147
+ model.transcribe([warmup_path])
148
+ except Exception as e:
149
+ logger.warning(f"⚠️ Parakeet warmup error (non-fatal): {e}")
150
+ finally:
151
+ if warmup_path:
152
+ try:
153
+ os.unlink(warmup_path)
154
+ except Exception:
155
+ pass
156
+
157
+
158
+ # ═══════════════════════ Arabic ASR (wav2vec2) ════════════════════════
159
+
160
+ def get_audio_model_ar():
161
+ """
162
+ Loads IbrahimAmin/egyptian-arabic-wav2vec2-xlsr-53 via HuggingFace Transformers.
163
+ Runs a 0.5s dummy forward pass to warm up OpenMP threads.
164
+ No hotword biasing — pure greedy argmax decoding.
165
+ """
166
+ global _audio_model_ar
167
+ if _audio_model_ar is not None:
168
+ return _audio_model_ar
169
+
170
+ import numpy as np
171
+ from transformers import Wav2Vec2ForCTC, AutoProcessor
172
+
173
+ model_name = "IbrahimAmin/egyptian-arabic-wav2vec2-xlsr-53"
174
+ logger.info(f"Loading Arabic ASR model ({model_name})...")
175
+
176
+ processor = AutoProcessor.from_pretrained(model_name)
177
+ model = Wav2Vec2ForCTC.from_pretrained(model_name)
178
+ model.eval()
179
+
180
+ # ── Warmup: single forward pass on dummy audio ──
181
+ try:
182
+ dummy = np.zeros(8000, dtype=np.float32)
183
+ warmup_inputs = processor(
184
+ [dummy], sampling_rate=16000, return_tensors="pt", padding=True
185
+ )
186
+ with torch.no_grad():
187
+ model(**warmup_inputs)
188
+ logger.info("✅ Arabic ASR warmup complete")
189
+ except Exception as e:
190
+ logger.warning(f"⚠️ Arabic ASR warmup failed (non-fatal): {e}")
191
+
192
+ _audio_model_ar = {"model": model, "processor": processor, "model_name": model_name}
193
+ logger.info("✅ Arabic ASR model (wav2vec2) loaded and ready")
194
+ return _audio_model_ar
195
+
196
+
197
+ def _load_audio_file(path: str):
198
+ """
199
+ Load an audio file to a 16kHz mono float32 numpy array.
200
+ Tries soundfile first (fast, no subprocess), then falls back to
201
+ librosa (handles more formats including webm via ffmpeg backend).
202
+ """
203
+ import numpy as np
204
+ try:
205
+ import soundfile as sf
206
+ data, sr = sf.read(path, dtype="float32", always_2d=False)
207
+ if data.ndim > 1:
208
+ data = data.mean(axis=1)
209
+ if sr != 16000:
210
+ import librosa
211
+ data = librosa.resample(data, orig_sr=sr, target_sr=16000)
212
+ return data.astype(np.float32)
213
+ except Exception:
214
+ import librosa
215
+ data, _ = librosa.load(path, sr=16000, mono=True)
216
+ return data.astype(np.float32)
217
+
218
+
219
+ def transcribe_ar_batch(audio_paths: list[str]) -> list[str]:
220
+ """
221
+ Batch transcription for Arabic audio using wav2vec2.
222
+ Loads all audio concurrently, pads to same length,
223
+ runs one forward pass, and decodes via greedy argmax.
224
+ Returns a list of transcription strings (one per input path).
225
+ """
226
+ import numpy as np
227
+ from concurrent.futures import ThreadPoolExecutor
228
+
229
+ ar = get_audio_model_ar()
230
+ model = ar["model"]
231
+ processor = ar["processor"]
232
+
233
+ # Load all waveforms concurrently
234
+ with ThreadPoolExecutor(max_workers=min(len(audio_paths), 8)) as executor:
235
+ waveforms_raw = list(executor.map(_load_audio_file, audio_paths))
236
+
237
+ # Guard against empty waveforms from failed decodes
238
+ final_texts = [""] * len(audio_paths)
239
+ valid_indices: list[int] = []
240
+ valid_waveforms: list[np.ndarray] = []
241
+
242
+ for idx, wav in enumerate(waveforms_raw):
243
+ arr = np.asarray(wav, dtype=np.float32).reshape(-1)
244
+ if arr.size == 0:
245
+ logger.warning(f"⚠️ Arabic ASR: empty waveform at index {idx}, skipping")
246
+ continue
247
+ valid_indices.append(idx)
248
+ valid_waveforms.append(arr)
249
+
250
+ if not valid_waveforms:
251
+ return final_texts
252
+
253
+ inputs = processor(
254
+ valid_waveforms,
255
+ sampling_rate=16000,
256
+ return_tensors="pt",
257
+ padding=True,
258
+ )
259
+
260
+ with _ar_model_lock:
261
+ with torch.no_grad():
262
+ outputs = model(**inputs)
263
+
264
+ predicted_ids = torch.argmax(outputs.logits, dim=-1)
265
+ transcriptions = processor.batch_decode(predicted_ids)
266
+
267
+ for local_i, text in enumerate(transcriptions):
268
+ final_texts[valid_indices[local_i]] = text.strip().rstrip(".")
269
+
270
+ return final_texts
271
+
272
+
273
+ def warmup_wav2vec2():
274
+ """
275
+ Lightweight raw forward pass to keep wav2vec2's OpenMP threads alive.
276
+ No-op if the Arabic model is not yet loaded.
277
+ """
278
+ if _audio_model_ar is None:
279
+ return
280
+ import numpy as np
281
+ ar = _audio_model_ar
282
+ dummy = np.zeros(8000, dtype=np.float32)
283
+ inputs = ar["processor"](
284
+ [dummy], sampling_rate=16000, return_tensors="pt", padding=True
285
+ )
286
+ with torch.no_grad():
287
+ ar["model"](**inputs)
asr/routes.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import tempfile
4
+ import uuid
5
+ import logging
6
+ from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from src.dependencies import get_current_user_id
10
+ from src.asr.schemas import AudioJob
11
+ from src.asr.batch_workers import audio_en_queue, audio_ar_queue, job_store
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ router = APIRouter(prefix="/api/asr", tags=["ASR"])
16
+
17
+ # Allowed audio MIME types from browsers (MediaRecorder output)
18
+ _ALLOWED_MIME_PREFIXES = ("audio/", "video/webm") # webm is video/* but contains audio
19
+
20
+ _ASR_TIMEOUT_S = 60 # max seconds to wait for a transcription result
21
+
22
+
23
+ @router.post("/transcribe")
24
+ async def transcribe_audio(
25
+ audio: UploadFile = File(..., description="Audio recording from the browser (.webm, .wav, .ogg)"),
26
+ language: str = Form(..., description="Language code: 'en' for English, 'ar' for Arabic"),
27
+ user_id: str = Depends(get_current_user_id),
28
+ ):
29
+ """
30
+ Transcribe an audio file using the selected language model:
31
+ - 'en': nvidia/parakeet-tdt-0.6b-v2 (NeMo)
32
+ - 'ar': IbrahimAmin/egyptian-arabic-wav2vec2-xlsr-53 (HuggingFace)
33
+
34
+ The audio is queued for batch inference and the response waits
35
+ for the transcript to be ready (up to 60 seconds).
36
+ """
37
+ if language not in ("en", "ar"):
38
+ raise HTTPException(400, "Invalid language. Must be 'en' or 'ar'.")
39
+
40
+ # Validate MIME type loosely (browsers vary on exact content-type for webm)
41
+ content_type = audio.content_type or ""
42
+ if not any(content_type.startswith(prefix) for prefix in _ALLOWED_MIME_PREFIXES):
43
+ logger.warning(f"Unexpected audio content-type: {content_type} — allowing anyway")
44
+
45
+ # Save upload to a temp file (workers clean up after processing)
46
+ suffix = ".webm"
47
+ if audio.filename:
48
+ _, ext = os.path.splitext(audio.filename)
49
+ if ext:
50
+ suffix = ext
51
+
52
+ tmp_path = None
53
+ try:
54
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
55
+ tmp_path = tmp.name
56
+ content = await audio.read()
57
+ tmp.write(content)
58
+ except Exception as e:
59
+ logger.error(f"Failed to save audio upload: {e}", exc_info=True)
60
+ raise HTTPException(500, "Failed to save audio file.")
61
+
62
+ # Create job and queue it
63
+ job_id = str(uuid.uuid4())
64
+ job = AudioJob(job_id=job_id, audio_path=tmp_path, language=language)
65
+ job_store[job_id] = {"status": "pending", "result": None, "error": None}
66
+
67
+ if language == "en":
68
+ await audio_en_queue.put(job)
69
+ else:
70
+ await audio_ar_queue.put(job)
71
+
72
+ # Wait for the worker to finish (timeout = _ASR_TIMEOUT_S)
73
+ try:
74
+ await asyncio.wait_for(job.done.wait(), timeout=_ASR_TIMEOUT_S)
75
+ except asyncio.TimeoutError:
76
+ # Clean up the temp file if the worker hasn't done so
77
+ try:
78
+ if tmp_path and os.path.exists(tmp_path):
79
+ os.unlink(tmp_path)
80
+ except Exception:
81
+ pass
82
+ job_store.pop(job_id, None)
83
+ raise HTTPException(504, "Transcription timed out. Please try a shorter recording.")
84
+
85
+ entry = job_store.pop(job_id, {})
86
+ if entry.get("status") == "error":
87
+ raise HTTPException(500, entry.get("error", "Transcription failed."))
88
+
89
+ transcript = entry.get("result", "")
90
+ return JSONResponse({"transcript": transcript})
asr/schemas.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class AudioJob:
7
+ """Single audio transcription job queued for batch inference."""
8
+ job_id: str
9
+ audio_path: str # path to the temp audio file on disk
10
+ language: str # "en" | "ar"
11
+ done: asyncio.Event = field(default_factory=asyncio.Event)
main.py CHANGED
@@ -11,6 +11,7 @@ from src.summary_generator.routes import router as summary_router
11
  from src.rag.routes import router as tutor_router
12
  from src.quiz_generator.routes import router as quiz_router
13
  from src.auth.routes import router as auth_router
 
14
  from src.store import get_usage
15
  from src.dependencies import get_current_user_id
16
  from src.config import settings
@@ -51,11 +52,25 @@ async def lifespan(app: FastAPI):
51
  except Exception as e:
52
  logger.warning(f"Embedder failed to load: {e}")
53
 
 
 
 
 
 
 
54
 
 
 
 
 
 
55
 
56
  from src.rag.batch_workers import start_workers
57
  start_workers()
58
 
 
 
 
59
  yield
60
 
61
 
@@ -87,6 +102,7 @@ app.include_router(summary_router)
87
  app.include_router(tutor_router)
88
  app.include_router(quiz_router)
89
  app.include_router(auth_router)
 
90
 
91
 
92
  @app.get("/")
 
11
  from src.rag.routes import router as tutor_router
12
  from src.quiz_generator.routes import router as quiz_router
13
  from src.auth.routes import router as auth_router
14
+ from src.asr.routes import router as asr_router
15
  from src.store import get_usage
16
  from src.dependencies import get_current_user_id
17
  from src.config import settings
 
52
  except Exception as e:
53
  logger.warning(f"Embedder failed to load: {e}")
54
 
55
+ # Eagerly load ASR models so warmup runs at startup, not on first request
56
+ try:
57
+ from src.asr.models import get_audio_model_en
58
+ get_audio_model_en()
59
+ except Exception as e:
60
+ logger.warning(f"English ASR model failed to load: {e}")
61
 
62
+ try:
63
+ from src.asr.models import get_audio_model_ar
64
+ get_audio_model_ar()
65
+ except Exception as e:
66
+ logger.warning(f"Arabic ASR model failed to load: {e}")
67
 
68
  from src.rag.batch_workers import start_workers
69
  start_workers()
70
 
71
+ from src.asr.batch_workers import start_asr_workers
72
+ start_asr_workers()
73
+
74
  yield
75
 
76
 
 
102
  app.include_router(tutor_router)
103
  app.include_router(quiz_router)
104
  app.include_router(auth_router)
105
+ app.include_router(asr_router)
106
 
107
 
108
  @app.get("/")