flozi00 commited on
Commit
3f1a9bc
·
verified ·
1 Parent(s): 4766d84

Update README.md

Browse files
Files changed (1) hide show
  1. README.md +640 -0
README.md CHANGED
@@ -126,6 +126,646 @@ print(output[0].text)
126
 
127
  ```
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
 
131
 
 
126
 
127
  ```
128
 
129
+ #### Openai compatible server
130
+
131
+
132
+ ```python
133
+ import asyncio
134
+ import gc
135
+ import io
136
+ import json
137
+ import logging
138
+ import os
139
+ import time
140
+ from concurrent.futures import ThreadPoolExecutor
141
+ from contextlib import asynccontextmanager
142
+ from dataclasses import dataclass, field
143
+ from pathlib import Path
144
+ from typing import Optional
145
+
146
+ import numpy as np
147
+ import soundfile as sf
148
+ from fastapi import FastAPI, File, HTTPException, UploadFile
149
+ from fastapi.responses import JSONResponse
150
+ from starlette.middleware import Middleware
151
+ from starlette.middleware.cors import CORSMiddleware
152
+
153
+ middleware = [
154
+ Middleware(
155
+ CORSMiddleware,
156
+ allow_origins=["*"],
157
+ allow_credentials=True,
158
+ allow_methods=["*"],
159
+ allow_headers=["*"],
160
+ )
161
+ ]
162
+
163
+
164
+ @asynccontextmanager
165
+ async def lifespan(app: FastAPI):
166
+ """Startup and shutdown logic"""
167
+ global batch_processor
168
+ logger.info("Starting ASR Proxy Server...")
169
+ try:
170
+ load_model()
171
+ except Exception as e:
172
+ logger.error(f"Failed to initialize model on startup: {e}")
173
+ batch_processor = BatchProcessor(BATCH_SIZE, BATCH_TIMEOUT_MS, MAX_QUEUE_SIZE)
174
+ batch_processor.start()
175
+ logger.info(
176
+ f"Batch processor started (batch_size={BATCH_SIZE}, "
177
+ f"timeout={BATCH_TIMEOUT_MS}ms, max_queue={MAX_QUEUE_SIZE})"
178
+ )
179
+ yield
180
+ logger.info("Shutting down ASR Proxy Server...")
181
+ if batch_processor:
182
+ await batch_processor.stop()
183
+
184
+
185
+ app = FastAPI(title="Openai ASR Server", middleware=middleware, lifespan=lifespan)
186
+
187
+ # Configure logging
188
+ logging.basicConfig(
189
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
190
+ )
191
+ logger = logging.getLogger(__name__)
192
+
193
+ # Silence httpx logs
194
+ logging.getLogger("httpx").setLevel(logging.WARNING)
195
+
196
+ # Environment variables
197
+ ASR_MODEL_NAME = os.getenv("ASR_MODEL_NAME", "primeline/parakeet-primeline")
198
+ ASR_MODEL_PATH = os.getenv("ASR_MODEL_PATH", None)
199
+ ASR_QUANTIZATION = os.getenv("ASR_QUANTIZATION", None) or None
200
+ ASR_PROVIDER = os.getenv("ASR_PROVIDER", "tensorrt")
201
+ TRT_FP16_ENABLE = os.getenv("TRT_FP16_ENABLE", "true").lower() == "true"
202
+ TRT_MAX_WORKSPACE_GB = int(os.getenv("TRT_MAX_WORKSPACE_GB", "6"))
203
+ USE_VAD = os.getenv("USE_VAD", "true").lower() == "true"
204
+
205
+ # NeMo export settings (only used when ONNX files not cached)
206
+ NEMO_REPO_ID = os.getenv("NEMO_REPO_ID", "primeline/parakeet-primeline")
207
+ NEMO_FILENAME = os.getenv("NEMO_FILENAME", "2_95_WER.nemo")
208
+ ONNX_CACHE_DIR = Path(os.getenv("ONNX_CACHE_DIR", "/root/.cache/huggingface/onnx_export"))
209
+
210
+ # Batching configuration
211
+ BATCH_SIZE = int(os.getenv("BATCH_SIZE", "8"))
212
+ BATCH_TIMEOUT_MS = float(os.getenv("BATCH_TIMEOUT_MS", "100"))
213
+ MAX_QUEUE_SIZE = int(os.getenv("MAX_QUEUE_SIZE", "64"))
214
+
215
+ # Global state
216
+ asr_model = None
217
+ model_loading = False
218
+ batch_processor: Optional["BatchProcessor"] = None
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Batch processor
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ @dataclass
227
+ class _BatchItem:
228
+ """A single queued transcription request."""
229
+ waveform: np.ndarray
230
+ sample_rate: int
231
+ future: asyncio.Future
232
+ filename: str
233
+ submit_time: float = field(default_factory=time.time)
234
+
235
+
236
+ class BatchProcessor:
237
+ """Collects concurrent transcription requests and processes them with
238
+ controlled GPU concurrency. Incoming requests are queued; a background
239
+ task drains up to *max_batch_size* items (or fewer after *batch_timeout_ms*)
240
+ and runs inference sequentially on a single-thread executor so the event
241
+ loop stays responsive while only one GPU call is in-flight at a time."""
242
+
243
+ def __init__(self, max_batch_size: int, batch_timeout_ms: float, max_queue_size: int):
244
+ self.max_batch_size = max_batch_size
245
+ self.batch_timeout_ms = batch_timeout_ms
246
+ self._queue: asyncio.Queue[_BatchItem] = asyncio.Queue(maxsize=max_queue_size)
247
+ self._gpu_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu")
248
+ self._task: Optional[asyncio.Task] = None
249
+ self._stats = {
250
+ "batches_processed": 0,
251
+ "items_processed": 0,
252
+ "items_failed": 0,
253
+ }
254
+
255
+ # -- lifecycle ------------------------------------------------------------
256
+
257
+ def start(self):
258
+ self._task = asyncio.create_task(self._process_loop())
259
+
260
+ async def stop(self):
261
+ if self._task:
262
+ self._task.cancel()
263
+ try:
264
+ await self._task
265
+ except asyncio.CancelledError:
266
+ pass
267
+ self._gpu_executor.shutdown(wait=False)
268
+
269
+ # -- public API -----------------------------------------------------------
270
+
271
+ @property
272
+ def queue_size(self) -> int:
273
+ return self._queue.qsize()
274
+
275
+ @property
276
+ def stats(self) -> dict:
277
+ return {**self._stats, "queue_depth": self._queue.qsize()}
278
+
279
+ async def submit(self, waveform: np.ndarray, sample_rate: int, filename: str) -> dict:
280
+ """Submit audio for transcription. Blocks until result is ready.
281
+ Raises asyncio.QueueFull when the service is overloaded."""
282
+ loop = asyncio.get_running_loop()
283
+ future: asyncio.Future = loop.create_future()
284
+ item = _BatchItem(waveform=waveform, sample_rate=sample_rate,
285
+ future=future, filename=filename)
286
+ try:
287
+ self._queue.put_nowait(item)
288
+ except asyncio.QueueFull:
289
+ raise HTTPException(
290
+ status_code=429,
291
+ detail=f"Transcription queue full ({self._queue.maxsize}). Try again later.",
292
+ )
293
+ return await future
294
+
295
+ # -- internal -------------------------------------------------------------
296
+
297
+ async def _collect_batch(self) -> list[_BatchItem]:
298
+ """Wait for at least one item, then collect up to max_batch_size
299
+ within the timeout window."""
300
+ batch: list[_BatchItem] = []
301
+ # Block until the first item arrives
302
+ batch.append(await self._queue.get())
303
+
304
+ deadline = asyncio.get_event_loop().time() + self.batch_timeout_ms / 1000.0
305
+ while len(batch) < self.max_batch_size:
306
+ remaining = deadline - asyncio.get_event_loop().time()
307
+ if remaining <= 0:
308
+ break
309
+ try:
310
+ item = await asyncio.wait_for(self._queue.get(), timeout=remaining)
311
+ batch.append(item)
312
+ except asyncio.TimeoutError:
313
+ break
314
+ return batch
315
+
316
+ def _run_inference(self, waveform: np.ndarray, sample_rate: int):
317
+ """Blocking inference — called inside the thread-pool executor."""
318
+ return asr_model.recognize(waveform, sample_rate=sample_rate)
319
+
320
+ async def _process_loop(self):
321
+ """Background loop: collect batches and process items."""
322
+ loop = asyncio.get_running_loop()
323
+ while True:
324
+ try:
325
+ batch = await self._collect_batch()
326
+ logger.info(f"Batch collected: {len(batch)} item(s)")
327
+ self._stats["batches_processed"] += 1
328
+
329
+ for item in batch:
330
+ try:
331
+ start_time = time.time()
332
+ result = await loop.run_in_executor(
333
+ self._gpu_executor,
334
+ self._run_inference,
335
+ item.waveform,
336
+ item.sample_rate,
337
+ )
338
+ elapsed = round(time.time() - start_time, 3)
339
+ queue_wait = round(start_time - item.submit_time, 3)
340
+
341
+ full_text = _extract_text(result)
342
+ segments = _result_to_segments(result)
343
+
344
+ total_duration = 0.0
345
+ if segments:
346
+ total_duration = max(seg["end"] for seg in segments)
347
+ if total_duration == 0.0:
348
+ total_duration = round(len(item.waveform) / item.sample_rate, 3)
349
+
350
+ response = {
351
+ "text": full_text,
352
+ "segments": segments,
353
+ "language": "en",
354
+ "duration": total_duration,
355
+ "transcription_time": elapsed,
356
+ "queue_wait_time": queue_wait,
357
+ "task": "transcribe",
358
+ }
359
+ item.future.set_result(response)
360
+ self._stats["items_processed"] += 1
361
+ logger.info(
362
+ f"Batch item '{item.filename}': {elapsed}s inference, "
363
+ f"{queue_wait}s queue wait, {len(full_text)} chars"
364
+ )
365
+ except Exception as e:
366
+ if not item.future.done():
367
+ item.future.set_exception(e)
368
+ self._stats["items_failed"] += 1
369
+ logger.error(f"Batch item '{item.filename}' failed: {e}")
370
+
371
+ except asyncio.CancelledError:
372
+ # Drain remaining items on shutdown
373
+ while not self._queue.empty():
374
+ try:
375
+ item = self._queue.get_nowait()
376
+ if not item.future.done():
377
+ item.future.set_exception(
378
+ HTTPException(status_code=503, detail="Server shutting down")
379
+ )
380
+ except asyncio.QueueEmpty:
381
+ break
382
+ raise
383
+ except Exception as e:
384
+ logger.error(f"Batch processing loop error: {e}", exc_info=True)
385
+ await asyncio.sleep(0.1) # avoid tight error loop
386
+
387
+
388
+ def _build_providers():
389
+ """Build ONNX Runtime provider list based on configuration."""
390
+ if ASR_PROVIDER == "tensorrt":
391
+ try:
392
+ import tensorrt_libs # noqa: F401
393
+ except ImportError:
394
+ logger.warning("tensorrt_libs not available, will try TensorRT anyway")
395
+
396
+ return [
397
+ (
398
+ "TensorrtExecutionProvider",
399
+ {
400
+ "trt_max_workspace_size": TRT_MAX_WORKSPACE_GB * 1024**3,
401
+ "trt_fp16_enable": TRT_FP16_ENABLE,
402
+ },
403
+ ),
404
+ "CUDAExecutionProvider",
405
+ "CPUExecutionProvider",
406
+ ]
407
+ elif ASR_PROVIDER == "cuda":
408
+ return ["CUDAExecutionProvider", "CPUExecutionProvider"]
409
+ else:
410
+ return ["CPUExecutionProvider"]
411
+
412
+
413
+ def _ensure_onnx_export():
414
+ """Export .nemo model to ONNX if not already cached. Returns local ONNX path."""
415
+ onnx_dir = ONNX_CACHE_DIR / NEMO_REPO_ID.replace("/", "_")
416
+ marker = onnx_dir / "config.json"
417
+
418
+ if marker.exists():
419
+ logger.info(f"ONNX export found at {onnx_dir}, skipping export.")
420
+ return str(onnx_dir)
421
+
422
+ logger.info(f"No ONNX export cached. Exporting {NEMO_REPO_ID}/{NEMO_FILENAME}...")
423
+ onnx_dir.mkdir(parents=True, exist_ok=True)
424
+
425
+ from huggingface_hub import hf_hub_download
426
+ from nemo.collections.asr.models import ASRModel
427
+
428
+ # Download .nemo checkpoint
429
+ nemo_path = hf_hub_download(repo_id=NEMO_REPO_ID, filename=NEMO_FILENAME)
430
+ logger.info(f"Downloaded .nemo to {nemo_path}, loading model for export...")
431
+
432
+ # Load on CPU to minimise GPU memory during export
433
+ model = ASRModel.restore_from(nemo_path, map_location="cpu")
434
+ model.eval()
435
+
436
+ # Export to ONNX
437
+ onnx_path = str(onnx_dir / "model.onnx")
438
+ logger.info(f"Exporting to ONNX: {onnx_path}")
439
+ model.export(onnx_path)
440
+
441
+ # NeMo produces model_encoder.onnx + model_decoder_joint.onnx
442
+ # onnx-asr expects encoder-model.onnx + decoder_joint-model.onnx
443
+ renames = {
444
+ "model_encoder.onnx": "encoder-model.onnx",
445
+ "model_decoder_joint.onnx": "decoder_joint-model.onnx",
446
+ "model_encoder.onnx.data": "encoder-model.onnx.data",
447
+ }
448
+ for src_name, dst_name in renames.items():
449
+ src = onnx_dir / src_name
450
+ if src.exists():
451
+ src.rename(onnx_dir / dst_name)
452
+ logger.info(f"Renamed {src_name} -> {dst_name}")
453
+
454
+ # Write vocab.txt
455
+ vocab_path = onnx_dir / "vocab.txt"
456
+ with vocab_path.open("wt") as f:
457
+ for i, token in enumerate([*model.tokenizer.vocab, "<blk>"]):
458
+ f.write(f"{token} {i}\n")
459
+ logger.info(f"Wrote vocab ({i+1} tokens) to {vocab_path}")
460
+
461
+ # Write config.json (written last — acts as completion marker)
462
+ config = {
463
+ "model_type": "nemo-conformer-tdt",
464
+ "features_size": 128,
465
+ "subsampling_factor": 8,
466
+ "max_tokens_per_step": 10,
467
+ }
468
+ with marker.open("w") as f:
469
+ json.dump(config, f, indent=2)
470
+ logger.info(f"Wrote config.json to {marker}")
471
+
472
+ # Free NeMo/torch memory before loading with onnx-asr
473
+ del model
474
+ gc.collect()
475
+ try:
476
+ import torch
477
+ torch.cuda.empty_cache()
478
+ except Exception:
479
+ pass
480
+
481
+ logger.info(f"ONNX export complete at {onnx_dir}")
482
+ return str(onnx_dir)
483
+
484
+
485
+ def load_model():
486
+ """Load the ASR model lazily on first request"""
487
+ global asr_model, model_loading
488
+
489
+ if asr_model is not None:
490
+ return
491
+
492
+ if model_loading:
493
+ max_wait = 120
494
+ waited = 0
495
+ while model_loading and waited < max_wait:
496
+ time.sleep(0.5)
497
+ waited += 0.5
498
+ return
499
+
500
+ model_loading = True
501
+ try:
502
+ import onnx_asr
503
+
504
+ # If model path not set, ensure ONNX export exists
505
+ model_path = ASR_MODEL_PATH
506
+ if not model_path:
507
+ model_path = _ensure_onnx_export()
508
+
509
+ providers = _build_providers()
510
+ logger.info(
511
+ f"Loading ASR model: {ASR_MODEL_NAME} from {model_path} "
512
+ f"(quantization={ASR_QUANTIZATION}, providers={[p if isinstance(p, str) else p[0] for p in providers]})"
513
+ )
514
+
515
+ model = onnx_asr.load_model(
516
+ ASR_MODEL_NAME,
517
+ path=model_path,
518
+ quantization=ASR_QUANTIZATION,
519
+ providers=providers,
520
+ )
521
+
522
+ # Use timestamps adapter for segment-level results
523
+ asr_model = model.with_timestamps()
524
+
525
+ if USE_VAD:
526
+ vad = onnx_asr.load_vad("silero", providers=["CPUExecutionProvider"])
527
+ asr_model = model.with_vad(vad).with_timestamps()
528
+ logger.info("VAD (Silero) enabled for long audio support.")
529
+
530
+ logger.info("ASR model loaded successfully.")
531
+
532
+ # Warmup
533
+ warmup_audio_path = os.path.join(
534
+ os.path.dirname(os.path.abspath(__file__)), "flo.wav"
535
+ )
536
+ warmup_iterations = 3
537
+ if os.path.exists(warmup_audio_path):
538
+ logger.info(f"Performing {warmup_iterations} warmup transcriptions...")
539
+ for i in range(warmup_iterations):
540
+ try:
541
+ warmup_start = time.time()
542
+ warmup_result = asr_model.recognize(warmup_audio_path)
543
+ warmup_time = time.time() - warmup_start
544
+ if i == 0 or i == warmup_iterations - 1:
545
+ text = _extract_text(warmup_result)
546
+ logger.info(
547
+ f"Warmup {i+1}/{warmup_iterations}: {warmup_time:.2f}s - '{text[:80]}'"
548
+ )
549
+ except Exception as e:
550
+ logger.warning(f"Warmup {i+1}/{warmup_iterations} failed (non-fatal): {e}")
551
+ else:
552
+ logger.warning(f"Warmup audio not found at {warmup_audio_path}, skipping.")
553
+
554
+ except Exception as e:
555
+ logger.critical(f"FATAL: Could not load ASR model. Error: {e}")
556
+ raise
557
+ finally:
558
+ model_loading = False
559
+
560
+
561
+ def _extract_text(result):
562
+ """Extract text from various onnx-asr result types."""
563
+ if isinstance(result, str):
564
+ return result
565
+ if hasattr(result, "text"):
566
+ return result.text
567
+ # VAD iterator result
568
+ parts = []
569
+ try:
570
+ for seg in result:
571
+ if hasattr(seg, "text"):
572
+ parts.append(seg.text)
573
+ elif isinstance(seg, str):
574
+ parts.append(seg)
575
+ except TypeError:
576
+ return str(result)
577
+ return " ".join(parts)
578
+
579
+
580
+ def _result_to_segments(result):
581
+ """Convert onnx-asr result to OpenAI-compatible segments list."""
582
+ segments = []
583
+
584
+ # Check if it's an iterator (VAD segments)
585
+ items = []
586
+ try:
587
+ if hasattr(result, "__iter__") and not isinstance(result, str) and not hasattr(result, "text"):
588
+ items = list(result)
589
+ else:
590
+ items = [result]
591
+ except TypeError:
592
+ items = [result]
593
+
594
+ for idx, item in enumerate(items):
595
+ if hasattr(item, "start") and hasattr(item, "end"):
596
+ # SegmentResult / TimestampedSegmentResult from VAD
597
+ segments.append({
598
+ "id": idx,
599
+ "start": round(item.start, 3),
600
+ "end": round(item.end, 3),
601
+ "text": item.text.strip() if hasattr(item, "text") else "",
602
+ "seek": 0,
603
+ "tokens": list(item.tokens) if hasattr(item, "tokens") and item.tokens else [],
604
+ "temperature": 0.0,
605
+ "avg_logprob": None,
606
+ "compression_ratio": None,
607
+ "no_speech_prob": None,
608
+ })
609
+ elif hasattr(item, "timestamps") and item.timestamps:
610
+ # TimestampedResult without VAD — build segments from token timestamps
611
+ segments.append({
612
+ "id": idx,
613
+ "start": round(item.timestamps[0], 3) if item.timestamps else 0.0,
614
+ "end": round(item.timestamps[-1], 3) if item.timestamps else 0.0,
615
+ "text": item.text.strip() if hasattr(item, "text") else "",
616
+ "seek": 0,
617
+ "tokens": list(item.tokens) if hasattr(item, "tokens") and item.tokens else [],
618
+ "temperature": 0.0,
619
+ "avg_logprob": None,
620
+ "compression_ratio": None,
621
+ "no_speech_prob": None,
622
+ })
623
+ elif hasattr(item, "text"):
624
+ # Plain text result
625
+ segments.append({
626
+ "id": idx,
627
+ "start": 0.0,
628
+ "end": 0.0,
629
+ "text": item.text.strip(),
630
+ "seek": 0,
631
+ "tokens": [],
632
+ "temperature": 0.0,
633
+ "avg_logprob": None,
634
+ "compression_ratio": None,
635
+ "no_speech_prob": None,
636
+ })
637
+
638
+ return segments
639
+
640
+
641
+ @app.get("/health")
642
+ async def health_check(deep: bool = False):
643
+ """Health check endpoint."""
644
+ base_status = {
645
+ "model_loaded": asr_model is not None,
646
+ "model_name": ASR_MODEL_NAME,
647
+ "provider": ASR_PROVIDER,
648
+ "quantization": ASR_QUANTIZATION,
649
+ "vad_enabled": USE_VAD,
650
+ "batch": batch_processor.stats if batch_processor else None,
651
+ }
652
+
653
+ if not deep:
654
+ base_status["status"] = "healthy" if asr_model else "degraded"
655
+ return base_status
656
+
657
+ try:
658
+ if not asr_model:
659
+ load_model()
660
+ if not asr_model:
661
+ base_status["status"] = "unhealthy"
662
+ base_status["error"] = "Model not loaded"
663
+ return JSONResponse(content=base_status, status_code=503)
664
+
665
+ warmup_audio_path = os.path.join(
666
+ os.path.dirname(os.path.abspath(__file__)), "flo.wav"
667
+ )
668
+ if not os.path.exists(warmup_audio_path):
669
+ base_status["status"] = "degraded"
670
+ base_status["error"] = "Health check audio file not found"
671
+ return base_status
672
+
673
+ start_time = time.time()
674
+ result = asr_model.recognize(warmup_audio_path)
675
+ transcription_time = round(time.time() - start_time, 3)
676
+
677
+ text = _extract_text(result)
678
+ if len(text) < 3:
679
+ base_status["status"] = "unhealthy"
680
+ base_status["error"] = "Transcription returned empty or too short result"
681
+ return JSONResponse(content=base_status, status_code=503)
682
+
683
+ base_status["status"] = "healthy"
684
+ base_status["transcription_test"] = {
685
+ "success": True,
686
+ "text_length": len(text),
687
+ "transcription_time_seconds": transcription_time,
688
+ }
689
+ return base_status
690
+
691
+ except Exception as e:
692
+ logger.error(f"Deep health check failed: {e}")
693
+ base_status["status"] = "unhealthy"
694
+ base_status["error"] = str(e)
695
+ return JSONResponse(content=base_status, status_code=503)
696
+
697
+
698
+ @app.post("/v1/audio/transcriptions")
699
+ async def transcribe_rest(file: UploadFile = File(...)):
700
+ """Handles audio transcription via REST API (OpenAI compatible).
701
+ Requests are queued and processed in batches for scalability."""
702
+ if not asr_model:
703
+ load_model()
704
+ if not asr_model:
705
+ raise HTTPException(status_code=503, detail="ASR model not available.")
706
+ if not batch_processor:
707
+ raise HTTPException(status_code=503, detail="Batch processor not ready.")
708
+
709
+ logger.info(f"transcribe_rest: Received request for file: {file.filename}")
710
+
711
+ try:
712
+ # Read audio bytes and decode with soundfile (handles wav, flac, ogg, etc.)
713
+ audio_bytes = await file.read()
714
+ waveform, sample_rate = sf.read(io.BytesIO(audio_bytes), dtype="float32")
715
+
716
+ # Convert stereo to mono if needed
717
+ if waveform.ndim == 2:
718
+ waveform = waveform.mean(axis=1)
719
+
720
+ logger.info(
721
+ f"transcribe_rest: Audio loaded, {len(waveform)} samples, {sample_rate}Hz, "
722
+ f"{len(waveform) / sample_rate:.1f}s — submitting to batch queue "
723
+ f"(depth={batch_processor.queue_size})"
724
+ )
725
+
726
+ response = await batch_processor.submit(
727
+ waveform, sample_rate, file.filename or "unknown"
728
+ )
729
+
730
+ logger.info(
731
+ f"transcribe_rest: Completed for '{file.filename}', "
732
+ f"{response['transcription_time']}s inference, "
733
+ f"{response.get('queue_wait_time', 0)}s queued"
734
+ )
735
+
736
+ return JSONResponse(content=response)
737
+
738
+ except HTTPException:
739
+ raise
740
+ except Exception as e:
741
+ logger.error(f"transcribe_rest: Unhandled exception: {str(e)}")
742
+ raise HTTPException(status_code=500, detail=str(e))
743
+
744
+
745
+ def main():
746
+ """Main application entry point."""
747
+ import uvicorn
748
+
749
+ port = int(os.getenv("PORT", "8000"))
750
+ log_level = os.getenv("LOG_LEVEL", "warning")
751
+
752
+ uvicorn.run(
753
+ app,
754
+ host="0.0.0.0",
755
+ port=port,
756
+ log_level=log_level,
757
+ workers=None,
758
+ forwarded_allow_ips="*",
759
+ proxy_headers=True,
760
+ timeout_keep_alive=900,
761
+ reload=False,
762
+ )
763
+
764
+
765
+ if __name__ == "__main__":
766
+ main()
767
+
768
+ ```
769
 
770
 
771