Spaces:
Running
Running
File size: 9,272 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 | """
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)")
|