Spaces:
Running
Running
| """ | |
| ASR Batch Workers โ Async batching infrastructure for voice transcription. | |
| Architecture (mirrors Raij/src/smart_search/batch_workers.py, audio workers only): | |
| - Two asyncio.Queues: audio_en_queue and audio_ar_queue. | |
| - Two worker coroutines per queue drain jobs in micro-batches. | |
| - Workers offload heavy inference to a thread via run_in_executor. | |
| - A shared in-memory job_store tracks job status + results. | |
| - A warmup loop periodically keeps OpenMP threads alive between requests. | |
| Parakeet warmup runs every PARAKEET_WARMUP_EVERY cycles (full transcribe, | |
| must use the full pipeline to avoid corrupting TDT decoder state). | |
| wav2vec2 warmup runs every cycle (cheap raw forward pass). | |
| """ | |
| import asyncio | |
| import time | |
| import uuid | |
| from loguru import logger | |
| from typing import Any | |
| from .constants import ASR_BATCH_MAX, ASR_BATCH_WINDOW_S, WARMUP_INTERVAL_S, PARAKEET_WARMUP_EVERY | |
| from .schemas import AudioJob | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Job Store โโโโโโโโโโโโโโโโโโโโโโโโ | |
| job_store: dict[str, dict[str, Any]] = {} | |
| """ | |
| { | |
| "<job_id>": { | |
| "status": "pending" | "processing" | "done" | "error", | |
| "result": <str transcript> | None, | |
| "error": <str> | None, | |
| } | |
| } | |
| """ | |
| def create_job() -> str: | |
| """Create a new pending job and return its ID.""" | |
| job_id = str(uuid.uuid4()) | |
| job_store[job_id] = {"status": "pending", "result": None, "error": None} | |
| return job_id | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Request-in-Flight Gate โโโโโโโโโโโโโโโโโโโโโโโโ | |
| _request_in_flight_count = 0 | |
| def set_request_in_flight(active: bool): | |
| """Increment/decrement in-flight counter used to gate warmup cycles.""" | |
| global _request_in_flight_count | |
| if active: | |
| _request_in_flight_count += 1 | |
| else: | |
| _request_in_flight_count = max(0, _request_in_flight_count - 1) | |
| def is_request_in_flight() -> bool: | |
| return _request_in_flight_count > 0 | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Queues โโโโโโโโโโโโโโโโโโโโโโโโ | |
| audio_en_queue: asyncio.Queue[AudioJob] = asyncio.Queue() | |
| audio_ar_queue: asyncio.Queue[AudioJob] = asyncio.Queue() | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Workers โโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def audio_en_worker(): | |
| """ | |
| Drains up to ASR_BATCH_MAX English audio jobs every ASR_BATCH_WINDOW_S seconds. | |
| Runs one batched Parakeet transcription via run_in_executor. | |
| Writes transcript into job_store and sets job.done. | |
| Cleans up temp audio files after processing. | |
| """ | |
| import os | |
| from .models import transcribe_en_batch | |
| loop = asyncio.get_event_loop() | |
| while True: | |
| first_job: AudioJob = await audio_en_queue.get() | |
| batch: list[AudioJob] = [first_job] | |
| # Collect up to (ASR_BATCH_MAX - 1) more within the time window | |
| deadline = loop.time() + ASR_BATCH_WINDOW_S | |
| while len(batch) < ASR_BATCH_MAX: | |
| remaining = deadline - loop.time() | |
| if remaining <= 0: | |
| break | |
| try: | |
| job = await asyncio.wait_for(audio_en_queue.get(), timeout=remaining) | |
| batch.append(job) | |
| except asyncio.TimeoutError: | |
| break | |
| for job in batch: | |
| job_store[job.job_id]["status"] = "processing" | |
| try: | |
| set_request_in_flight(True) | |
| audio_paths = [job.audio_path for job in batch] | |
| transcripts = await loop.run_in_executor(None, transcribe_en_batch, audio_paths) | |
| for job, transcript in zip(batch, transcripts): | |
| if not transcript.strip(): | |
| job_store[job.job_id]["status"] = "error" | |
| job_store[job.job_id]["error"] = "Could not transcribe audio. Please try again and speak clearly." | |
| else: | |
| job_store[job.job_id]["status"] = "done" | |
| job_store[job.job_id]["result"] = transcript | |
| job.done.set() | |
| except Exception as e: | |
| logger.error(f"English ASR batch failed: {e}", exc_info=True) | |
| for job in batch: | |
| job_store[job.job_id]["status"] = "error" | |
| job_store[job.job_id]["error"] = str(e) | |
| if not job.done.is_set(): | |
| job.done.set() | |
| finally: | |
| set_request_in_flight(False) | |
| for job in batch: | |
| try: | |
| os.unlink(job.audio_path) | |
| except Exception: | |
| pass | |
| async def audio_ar_worker(): | |
| """ | |
| Drains up to ASR_BATCH_MAX Arabic audio jobs every ASR_BATCH_WINDOW_S seconds. | |
| Runs one batched wav2vec2 transcription via run_in_executor. | |
| Writes transcript into job_store and sets job.done. | |
| Cleans up temp audio files after processing. | |
| """ | |
| import os | |
| from .models import transcribe_ar_batch | |
| loop = asyncio.get_event_loop() | |
| while True: | |
| first_job: AudioJob = await audio_ar_queue.get() | |
| batch: list[AudioJob] = [first_job] | |
| deadline = loop.time() + ASR_BATCH_WINDOW_S | |
| while len(batch) < ASR_BATCH_MAX: | |
| remaining = deadline - loop.time() | |
| if remaining <= 0: | |
| break | |
| try: | |
| job = await asyncio.wait_for(audio_ar_queue.get(), timeout=remaining) | |
| batch.append(job) | |
| except asyncio.TimeoutError: | |
| break | |
| for job in batch: | |
| job_store[job.job_id]["status"] = "processing" | |
| try: | |
| set_request_in_flight(True) | |
| audio_paths = [job.audio_path for job in batch] | |
| transcripts = await loop.run_in_executor(None, transcribe_ar_batch, audio_paths) | |
| for job, transcript in zip(batch, transcripts): | |
| if not transcript.strip(): | |
| job_store[job.job_id]["status"] = "error" | |
| job_store[job.job_id]["error"] = "ูู ูุชู ุงูุชุนุฑู ุนูู ุงูุตูุช. ุงูุฑุฌุงุก ุงูู ุญุงููุฉ ู ุฑุฉ ุฃุฎุฑู ูุงูุชุญุฏุซ ุจูุถูุญ." | |
| else: | |
| job_store[job.job_id]["status"] = "done" | |
| job_store[job.job_id]["result"] = transcript | |
| job.done.set() | |
| except Exception as e: | |
| logger.error(f"Arabic ASR batch failed: {e}", exc_info=True) | |
| for job in batch: | |
| job_store[job.job_id]["status"] = "error" | |
| job_store[job.job_id]["error"] = str(e) | |
| if not job.done.is_set(): | |
| job.done.set() | |
| finally: | |
| set_request_in_flight(False) | |
| for job in batch: | |
| try: | |
| os.unlink(job.audio_path) | |
| except Exception: | |
| pass | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Warmup Loop โโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def _asr_warmup_loop(): | |
| """ | |
| Periodically poke both ASR models to prevent OpenMP/MKL thread pool | |
| spin-down during idle periods. | |
| - wav2vec2: every WARMUP_INTERVAL_S seconds (raw forward pass, ~5-15ms) | |
| - Parakeet: every PARAKEET_WARMUP_EVERY cycles (~6 min at 45s/cycle) | |
| Uses full model.transcribe() to avoid corrupting TDT decoder cache. | |
| Skipped entirely if a real request is in flight. | |
| """ | |
| from .models import warmup_parakeet, warmup_wav2vec2 | |
| loop = asyncio.get_event_loop() | |
| parakeet_cycle = 0 | |
| while True: | |
| await asyncio.sleep(WARMUP_INTERVAL_S) | |
| if is_request_in_flight(): | |
| continue | |
| t0 = time.monotonic() | |
| try: | |
| await loop.run_in_executor(None, warmup_wav2vec2) | |
| parakeet_cycle += 1 | |
| if parakeet_cycle >= PARAKEET_WARMUP_EVERY: | |
| parakeet_cycle = 0 | |
| await loop.run_in_executor(None, warmup_parakeet) | |
| except Exception as e: | |
| logger.warning(f"โ ๏ธ ASR warmup cycle error (non-fatal): {e}") | |
| continue | |
| elapsed_ms = (time.monotonic() - t0) * 1000 | |
| logger.info(f"๐ฅ ASR warmup cycle done in {elapsed_ms:.0f}ms") | |
| # โโโโโโโโโโโโโโโโโโโโโโโ Startup โโโโโโโโโโโโโโโโโโโโโโโโ | |
| _asr_workers_started = False | |
| def start_asr_workers(): | |
| """ | |
| Launch all ASR async worker coroutines. Call once during app startup. | |
| - 2 English audio workers (Parakeet) | |
| - 2 Arabic audio workers (wav2vec2) | |
| - 1 warmup loop | |
| """ | |
| global _asr_workers_started | |
| if _asr_workers_started: | |
| return | |
| _asr_workers_started = True | |
| for i in range(2): | |
| asyncio.create_task(audio_en_worker(), name=f"asr_en_worker_{i}") | |
| for i in range(2): | |
| asyncio.create_task(audio_ar_worker(), name=f"asr_ar_worker_{i}") | |
| asyncio.create_task(_asr_warmup_loop(), name="asr_warmup_loop") | |
| logger.info("โ ASR batch workers started (2 EN + 2 AR + warmup loop)") | |