File size: 14,245 Bytes
4eab58f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"""
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")


@dataclass
class Word:
    text: str
    start: float = 0.0
    end: float = 0.0
    prob: float = NAN          # NaN when the backend cannot report confidence


@dataclass
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


@runtime_checkable
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,
        )

    @staticmethod
    @_gpu(duration=90)
    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)