Spaces:
Sleeping
Sleeping
| """ | |
| Pluggable ASR backends. | |
| The whole point of this module: scoring.py must not know or care where the | |
| transcript came from. Every backend returns the same Transcript object, so you | |
| can flip ASR_BACKEND and compare error tables across engines with identical | |
| scoring logic. | |
| ASR_BACKEND=groq GROQ_API_KEY=gsk_... # free CPU Space, per-second billing | |
| ASR_BACKEND=zerogpu # free ZeroGPU Space, transformers | |
| ASR_BACKEND=local # your machine / paid GPU Space | |
| ASR_BACKEND=auto # default: groq > zerogpu > local | |
| Why three: CTranslate2 (the engine under faster-whisper) does not allocate | |
| through PyTorch's CUDA allocator, so it will not see a GPU under ZeroGPU's | |
| fork-based allocation. ZeroGPU therefore needs the transformers path. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import os | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import threading | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import Protocol, runtime_checkable | |
| # --------------------------------------------------------------------------- | |
| # Shared data types | |
| # --------------------------------------------------------------------------- | |
| NAN = float("nan") | |
| class Word: | |
| text: str | |
| start: float = 0.0 | |
| end: float = 0.0 | |
| prob: float = NAN # NaN when the backend cannot report confidence | |
| class Transcript: | |
| text: str | |
| words: list[Word] = field(default_factory=list) | |
| speech_seconds: float = 0.0 # voiced time, not wall-clock file length | |
| backend: str = "" | |
| model: str = "" | |
| latency_s: float = 0.0 | |
| has_confidence: bool = False | |
| class ASRBackend(Protocol): | |
| name: str | |
| supports_word_confidence: bool | |
| def describe(self) -> str: ... | |
| def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript: ... | |
| # --------------------------------------------------------------------------- | |
| # Audio preprocessing | |
| # --------------------------------------------------------------------------- | |
| _HAS_FFMPEG = shutil.which("ffmpeg") is not None | |
| def to_16k_mono_flac(path: str) -> str: | |
| """ | |
| Downsample to 16 kHz mono FLAC. Whisper resamples to 16 kHz internally | |
| anyway, so this is lossless for accuracy but shrinks the upload ~10x -- | |
| which matters because hosted endpoints cap request size (Groq: 25 MB on | |
| the free tier) and a phone recording is often 48 kHz stereo. | |
| """ | |
| if not _HAS_FFMPEG: | |
| return path | |
| out = tempfile.NamedTemporaryFile(delete=False, suffix=".flac").name | |
| try: | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-loglevel", "error", "-i", path, | |
| "-ar", "16000", "-ac", "1", "-c:a", "flac", out], | |
| check=True, timeout=120, | |
| ) | |
| return out | |
| except Exception: | |
| return path | |
| def _voiced_from_segments(segments) -> float: | |
| total = 0.0 | |
| for s in segments: | |
| start = s.get("start") if isinstance(s, dict) else getattr(s, "start", None) | |
| end = s.get("end") if isinstance(s, dict) else getattr(s, "end", None) | |
| if start is not None and end is not None: | |
| total += max(0.0, float(end) - float(start)) | |
| return total | |
| # --------------------------------------------------------------------------- | |
| # Backend 1: Groq (recommended for a free CPU Space) | |
| # --------------------------------------------------------------------------- | |
| GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions" | |
| # large-v3 is meaningfully better than turbo on Hindi; turbo is ~2.8x cheaper | |
| # and fine for English. Override with GROQ_MODEL_HI / GROQ_MODEL_EN. | |
| GROQ_MODELS = { | |
| "hi": os.getenv("GROQ_MODEL_HI", "whisper-large-v3"), | |
| "en": os.getenv("GROQ_MODEL_EN", "whisper-large-v3-turbo"), | |
| } | |
| class GroqBackend: | |
| name = "groq" | |
| supports_word_confidence = False # OpenAI-compatible API returns no logprobs | |
| def __init__(self, api_key: str | None = None, timeout: float = 90.0): | |
| self.api_key = api_key or os.environ["GROQ_API_KEY"] | |
| self.timeout = timeout | |
| def describe(self) -> str: | |
| return f"Groq API — {GROQ_MODELS['hi']} (hi) / {GROQ_MODELS['en']} (en)" | |
| def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript: | |
| import httpx | |
| model = GROQ_MODELS.get(lang, "whisper-large-v3") | |
| sent = to_16k_mono_flac(audio_path) | |
| t0 = time.perf_counter() | |
| data = { | |
| "model": model, | |
| "language": lang, | |
| "response_format": "verbose_json", | |
| "timestamp_granularities[]": ["word", "segment"], | |
| "temperature": "0", | |
| } | |
| if hint: | |
| data["prompt"] = hint[:220] | |
| payload = None | |
| last_error: Exception | None = None | |
| for attempt in range(3): | |
| try: | |
| with open(sent, "rb") as fh: | |
| resp = httpx.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {self.api_key}"}, | |
| data=data, | |
| files={"file": (os.path.basename(sent), fh, "audio/flac")}, | |
| timeout=self.timeout, | |
| ) | |
| if resp.status_code in (429, 500, 502, 503): | |
| raise RuntimeError(f"transient {resp.status_code}: {resp.text[:200]}") | |
| resp.raise_for_status() | |
| payload = resp.json() | |
| break | |
| except Exception as exc: | |
| last_error = exc | |
| if attempt == 2: | |
| raise RuntimeError(f"Groq transcription failed: {exc}") from exc | |
| time.sleep(1.5 * (attempt + 1)) # backoff; free tier is rate-limited | |
| assert payload is not None, last_error | |
| if sent != audio_path: | |
| try: | |
| os.unlink(sent) | |
| except OSError: | |
| pass | |
| words = [ | |
| Word(w.get("word", "").strip(), float(w.get("start", 0)), float(w.get("end", 0))) | |
| for w in (payload.get("words") or []) | |
| ] | |
| voiced = _voiced_from_segments(payload.get("segments") or []) | |
| if not voiced and words: | |
| voiced = words[-1].end - words[0].start | |
| return Transcript( | |
| text=(payload.get("text") or "").strip(), | |
| words=words, | |
| speech_seconds=voiced, | |
| backend=self.name, | |
| model=model, | |
| latency_s=round(time.perf_counter() - t0, 2), | |
| has_confidence=False, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Backend 2: local faster-whisper | |
| # --------------------------------------------------------------------------- | |
| LOCAL_MODELS = { | |
| "hi": {"fast": "small", "balanced": "medium", "accurate": "large-v3"}, | |
| "en": {"fast": "distil-small.en", "balanced": "distil-medium.en", | |
| "accurate": "distil-large-v3"}, | |
| } | |
| def _pick_device() -> tuple[str, str]: | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| major = torch.cuda.get_device_capability()[0] | |
| return "cuda", "int8_float16" if major >= 7 else "float16" | |
| except Exception: | |
| pass | |
| return "cpu", "int8" | |
| class LocalBackend: | |
| name = "local" | |
| supports_word_confidence = True # the reason to keep this backend around | |
| def __init__(self, tier: str | None = None): | |
| self.tier = tier or os.getenv("LOCAL_TIER", "accurate") | |
| self.device, self.compute_type = _pick_device() | |
| self._cache: dict[str, object] = {} | |
| self._lock = threading.Lock() | |
| def describe(self) -> str: | |
| return (f"faster-whisper {self.tier} on {self.device} ({self.compute_type})" | |
| f" — word confidence available") | |
| def _model(self, lang: str): | |
| name = LOCAL_MODELS[lang][self.tier] | |
| with self._lock: | |
| if name not in self._cache: | |
| from faster_whisper import WhisperModel | |
| self._cache[name] = WhisperModel( | |
| name, device=self.device, compute_type=self.compute_type, | |
| cpu_threads=os.cpu_count() or 4, | |
| ) | |
| return self._cache[name], name | |
| def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript: | |
| model, name = self._model(lang) | |
| t0 = time.perf_counter() | |
| segments, _info = model.transcribe( | |
| audio_path, | |
| language=lang, | |
| beam_size=1, # greedy: ~3x faster, ~1% WER cost | |
| word_timestamps=True, | |
| condition_on_previous_text=False, # stop one bad segment poisoning the rest | |
| vad_filter=True, # skip silence in learner recordings | |
| vad_parameters={"min_silence_duration_ms": 400}, | |
| initial_prompt=hint, | |
| temperature=0.0, | |
| ) | |
| chunks, words, voiced = [], [], 0.0 | |
| for seg in segments: # generator -- work happens here | |
| chunks.append(seg.text) | |
| voiced += max(0.0, seg.end - seg.start) | |
| for w in (seg.words or []): | |
| words.append(Word(w.word.strip(), w.start, w.end, | |
| getattr(w, "probability", NAN))) | |
| return Transcript( | |
| text=" ".join(chunks).strip(), words=words, speech_seconds=voiced, | |
| backend=self.name, model=name, | |
| latency_s=round(time.perf_counter() - t0, 2), has_confidence=True, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Backend 3: ZeroGPU (transformers) | |
| # --------------------------------------------------------------------------- | |
| try: | |
| import spaces # type: ignore | |
| _gpu = spaces.GPU | |
| except Exception: # not on a ZeroGPU Space | |
| def _gpu(func=None, duration=None): # no-op passthrough | |
| if func is None: | |
| return lambda f: f | |
| return func | |
| ZERO_MODELS = { | |
| "hi": os.getenv("ZERO_MODEL_HI", "openai/whisper-large-v3"), | |
| "en": os.getenv("ZERO_MODEL_EN", "distil-whisper/distil-large-v3"), | |
| } | |
| class ZeroGPUBackend: | |
| """ | |
| Loads on CPU at import, moves to CUDA inside the @spaces.GPU call. ZeroGPU | |
| forks a GPU-attached process per call, so the .to("cuda") must happen | |
| inside the decorated function, not at module scope. | |
| """ | |
| name = "zerogpu" | |
| supports_word_confidence = False | |
| def __init__(self): | |
| self._cache: dict[str, tuple] = {} | |
| self._lock = threading.Lock() | |
| def describe(self) -> str: | |
| return f"ZeroGPU transformers — {ZERO_MODELS['hi']} (hi) / {ZERO_MODELS['en']} (en)" | |
| def _load(self, lang: str): | |
| name = ZERO_MODELS[lang] | |
| with self._lock: | |
| if name not in self._cache: | |
| import torch | |
| from transformers import (AutoProcessor, | |
| WhisperForConditionalGeneration) | |
| proc = AutoProcessor.from_pretrained(name) | |
| model = WhisperForConditionalGeneration.from_pretrained( | |
| name, torch_dtype=torch.float16, low_cpu_mem_usage=True, | |
| ) | |
| self._cache[name] = (model, proc, name) | |
| return self._cache[name] | |
| def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript: | |
| model, proc, name = self._load(lang) | |
| t0 = time.perf_counter() | |
| text, words, voiced = self._run(model, proc, audio_path, lang, hint) | |
| return Transcript( | |
| text=text, words=words, speech_seconds=voiced, | |
| backend=self.name, model=name, | |
| latency_s=round(time.perf_counter() - t0, 2), has_confidence=False, | |
| ) | |
| def _run(model, proc, audio_path, lang, hint): | |
| import torch | |
| from transformers import pipeline | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| dtype = torch.float16 if device == "cuda" else torch.float32 | |
| model = model.to(device=device, dtype=dtype) | |
| asr = pipeline( | |
| "automatic-speech-recognition", | |
| model=model, | |
| tokenizer=proc.tokenizer, | |
| feature_extractor=proc.feature_extractor, | |
| torch_dtype=dtype, | |
| device=device, | |
| chunk_length_s=30, | |
| batch_size=8, # batched long-form: the big transformers speedup | |
| ) | |
| kwargs = {"language": lang, "task": "transcribe", "num_beams": 1} | |
| if hint: | |
| kwargs["prompt_ids"] = proc.get_prompt_ids(hint[:220], return_tensors="pt").to(device) | |
| out = asr(audio_path, return_timestamps="word", generate_kwargs=kwargs) | |
| words, voiced = [], 0.0 | |
| for ch in out.get("chunks", []) or []: | |
| ts = ch.get("timestamp") or (None, None) | |
| start, end = (ts[0] or 0.0), (ts[1] or 0.0) | |
| words.append(Word(ch.get("text", "").strip(), float(start), float(end))) | |
| voiced += max(0.0, float(end) - float(start)) | |
| return out.get("text", "").strip(), words, voiced | |
| # --------------------------------------------------------------------------- | |
| # Factory | |
| # --------------------------------------------------------------------------- | |
| _backend: ASRBackend | None = None | |
| _factory_lock = threading.Lock() | |
| def get_backend() -> ASRBackend: | |
| """Resolve once per process. ASR_BACKEND=auto prefers Groq, then ZeroGPU.""" | |
| global _backend | |
| with _factory_lock: | |
| if _backend is not None: | |
| return _backend | |
| choice = os.getenv("ASR_BACKEND", "auto").lower() | |
| if choice == "auto": | |
| if os.getenv("GROQ_API_KEY"): | |
| choice = "groq" | |
| elif os.getenv("SPACES_ZERO_GPU") or os.getenv("ZEROGPU"): | |
| choice = "zerogpu" | |
| else: | |
| choice = "local" | |
| _backend = {"groq": GroqBackend, "zerogpu": ZeroGPUBackend, | |
| "local": LocalBackend}[choice]() | |
| return _backend | |
| def is_nan(x: float) -> bool: | |
| return isinstance(x, float) and math.isnan(x) | |