NLPV commited on
Commit
4eab58f
·
verified ·
1 Parent(s): 2441d52

Upload 7 files

Browse files
Files changed (7) hide show
  1. README.md +42 -7
  2. app.py +142 -0
  3. asr_backends.py +403 -0
  4. packages.txt +1 -0
  5. raeding coach.zip +3 -0
  6. requirements.txt +18 -0
  7. scoring.py +288 -0
README.md CHANGED
@@ -1,14 +1,49 @@
1
  ---
2
- title: ReadingCoach
3
- emoji: 📚
4
- colorFrom: red
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- short_description: Reading & Pronunciation Coach Read a passage aloud in Hindi
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Reading & Pronunciation Coach
3
+ emoji: 🗣️
4
+ colorFrom: indigo
5
  colorTo: green
6
  sdk: gradio
 
 
7
  app_file: app.py
8
  pinned: false
9
+ short_description: Hindi & English reading practice with pronunciation scoring
10
  ---
11
 
12
+ # Reading & Pronunciation Coach
13
+
14
+ Read a passage aloud in Hindi or English and get a word-level error breakdown:
15
+ WER, CER, strict and lenient accuracy, speaking rate, and pause count.
16
+
17
+ ## Configuration
18
+
19
+ Set these under **Settings → Variables and secrets**.
20
+
21
+ | Name | Type | Value |
22
+ |---|---|---|
23
+ | `ASR_BACKEND` | Variable | `groq` \| `zerogpu` \| `local` \| `auto` |
24
+ | `GROQ_API_KEY` | **Secret** | required for the `groq` backend |
25
+ | `GROQ_MODEL_HI` | Variable | default `whisper-large-v3` |
26
+ | `GROQ_MODEL_EN` | Variable | default `whisper-large-v3-turbo` |
27
+ | `LOCAL_TIER` | Variable | `fast` \| `balanced` \| `accurate` (local only) |
28
+
29
+ ## Choosing a backend
30
+
31
+ **`groq` on free CPU hardware** — recommended. Only transcription needs a GPU,
32
+ and Groq rents it per second of audio. Uncomment nothing in `requirements.txt`.
33
+ Trade-off: no per-word confidence, so the "Unclear words" metric is hidden.
34
+
35
+ **`zerogpu`** — free GPU, `transformers` path. Uncomment the ZeroGPU block in
36
+ `requirements.txt` and select ZeroGPU hardware. Note that `faster-whisper` will
37
+ *not* work here: CTranslate2 does not allocate through PyTorch's CUDA allocator,
38
+ so it cannot see a ZeroGPU-assigned device.
39
+
40
+ **`local`** — your machine or paid GPU Spaces hardware. The only backend that
41
+ reports per-word ASR confidence, which is a useful mumbling signal. Uncomment
42
+ the `faster-whisper` line.
43
+
44
+ ## Local development
45
+
46
+ ```bash
47
+ pip install -r requirements.txt faster-whisper
48
+ ASR_BACKEND=local LOCAL_TIER=fast python app.py
49
+ ```
app.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Space entrypoint: Hindi / English reading & pronunciation coach.
3
+
4
+ Set ASR_BACKEND (and GROQ_API_KEY as a Space secret) in Settings -> Variables.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import tempfile
12
+
13
+ import gradio as gr
14
+ import pandas as pd
15
+
16
+ from asr_backends import get_backend
17
+ from scoring import score
18
+
19
+ LANGS = {"हिंदी (Hindi)": "hi", "English": "en"}
20
+
21
+ SAMPLES = {
22
+ "hi": "सूरज सुबह जल्दी उठता है और अपनी किताब पढ़ता है। उसे कहानियाँ बहुत पसंद हैं।",
23
+ "en": "The quick brown fox jumps over the lazy dog while the children watch quietly.",
24
+ }
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # TTS -- cached, because on a Space every gTTS call is a network round trip
28
+ # ---------------------------------------------------------------------------
29
+
30
+ TTS_DIR = os.path.join(tempfile.gettempdir(), "coach_tts")
31
+ os.makedirs(TTS_DIR, exist_ok=True)
32
+
33
+
34
+ def speak(text: str, lang_label: str, slow: bool):
35
+ if not (text or "").strip():
36
+ raise gr.Error("Type or paste a passage first.")
37
+ lang = LANGS[lang_label]
38
+ key = hashlib.sha1(f"{lang}|{slow}|{text}".encode()).hexdigest()[:20]
39
+ path = os.path.join(TTS_DIR, f"{key}.mp3")
40
+ if not os.path.exists(path):
41
+ from gtts import gTTS
42
+
43
+ try:
44
+ gTTS(text=text, lang=lang, slow=slow).save(path)
45
+ except Exception as exc:
46
+ raise gr.Error(f"Text-to-speech unavailable: {exc}") from exc
47
+ return path
48
+
49
+
50
+ def fill_sample(lang_label: str) -> str:
51
+ return SAMPLES[LANGS[lang_label]]
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Evaluation
56
+ # ---------------------------------------------------------------------------
57
+
58
+ EMPTY = pd.DataFrame(columns=["अपेक्षित / Expected", "सुना गया / Heard",
59
+ "प्रकार / Error type", "समानता / Similarity"])
60
+
61
+
62
+ def evaluate(audio_path, passage, lang_label, lenient_decoding):
63
+ if not audio_path:
64
+ return {"error": "No recording received — record or upload audio first."}, EMPTY
65
+ if not (passage or "").strip():
66
+ return {"error": "Paste the passage to read first."}, EMPTY
67
+
68
+ lang = LANGS[lang_label]
69
+ try:
70
+ backend = get_backend()
71
+ hint = passage.strip() if lenient_decoding else None
72
+ tr = backend.transcribe(audio_path, lang, hint=hint)
73
+ except Exception as exc:
74
+ return {"error": f"{type(exc).__name__}: {exc}"}, EMPTY
75
+
76
+ if not tr.text:
77
+ return {"error": "Nothing was transcribed. Check the mic level and try again."}, EMPTY
78
+ return score(passage, tr, lang)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # UI
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def build() -> gr.Blocks:
86
+ with gr.Blocks(title="Reading & Pronunciation Coach") as app:
87
+ gr.Markdown("## 🗣️ Reading & Pronunciation Coach — हिंदी / English")
88
+ status = gr.Markdown("Resolving ASR backend…")
89
+
90
+ with gr.Row():
91
+ lang = gr.Dropdown(list(LANGS), value="हिंदी (Hindi)", label="Language", scale=2)
92
+ sample_btn = gr.Button("Load sample passage", scale=1)
93
+
94
+ passage = gr.Textbox(
95
+ label="Passage to read", lines=4,
96
+ placeholder="यहाँ हिंदी टेक्स्ट लिखें… / Paste English text here…",
97
+ )
98
+
99
+ with gr.Row():
100
+ slow = gr.Checkbox(label="Slow speech", value=False, scale=1)
101
+ listen = gr.Button("🔊 Listen", scale=1)
102
+ tts_audio = gr.Audio(label="Model reading", type="filepath")
103
+
104
+ gr.Markdown("### 🎤 Now read it aloud")
105
+ mic = gr.Audio(sources=["microphone", "upload"], type="filepath",
106
+ label="Your recording")
107
+ lenient = gr.Checkbox(
108
+ label="Lenient decoding — biases the ASR toward the passage. "
109
+ "Scores look better but real mistakes get hidden. Leave off for assessment.",
110
+ value=False,
111
+ )
112
+ submit = gr.Button("✅ Check my reading", variant="primary")
113
+
114
+ metrics = gr.JSON(label="Results")
115
+ table = gr.Dataframe(label="गलती तालिका / Error table", wrap=True)
116
+
117
+ gr.Markdown(
118
+ "**Reading the scores.** *Word accuracy* is strict: a word counts only if it "
119
+ "matches exactly. *Lenient score* gives partial credit by similarity, so a "
120
+ "near-miss on a hard word is not treated like a skipped line. *WER* is the "
121
+ "standard ASR metric and includes extra words, so it can exceed the accuracy gap."
122
+ )
123
+
124
+ def backend_line():
125
+ try:
126
+ b = get_backend()
127
+ extra = "" if b.supports_word_confidence else \
128
+ " · per-word confidence unavailable on this backend"
129
+ return f"**Backend:** {b.describe()}{extra}"
130
+ except Exception as exc:
131
+ return f"⚠️ **Backend not ready:** {exc}"
132
+
133
+ app.load(backend_line, None, status)
134
+ sample_btn.click(fill_sample, lang, passage)
135
+ listen.click(speak, [passage, lang, slow], tts_audio)
136
+ submit.click(evaluate, [mic, passage, lang, lenient], [metrics, table])
137
+
138
+ return app
139
+
140
+
141
+ if __name__ == "__main__":
142
+ build().queue(max_size=12).launch()
asr_backends.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pluggable ASR backends.
3
+
4
+ The whole point of this module: scoring.py must not know or care where the
5
+ transcript came from. Every backend returns the same Transcript object, so you
6
+ can flip ASR_BACKEND and compare error tables across engines with identical
7
+ scoring logic.
8
+
9
+ ASR_BACKEND=groq GROQ_API_KEY=gsk_... # free CPU Space, per-second billing
10
+ ASR_BACKEND=zerogpu # free ZeroGPU Space, transformers
11
+ ASR_BACKEND=local # your machine / paid GPU Space
12
+ ASR_BACKEND=auto # default: groq > zerogpu > local
13
+
14
+ Why three: CTranslate2 (the engine under faster-whisper) does not allocate
15
+ through PyTorch's CUDA allocator, so it will not see a GPU under ZeroGPU's
16
+ fork-based allocation. ZeroGPU therefore needs the transformers path.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import tempfile
26
+ import threading
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from typing import Protocol, runtime_checkable
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Shared data types
33
+ # ---------------------------------------------------------------------------
34
+
35
+ NAN = float("nan")
36
+
37
+
38
+ @dataclass
39
+ class Word:
40
+ text: str
41
+ start: float = 0.0
42
+ end: float = 0.0
43
+ prob: float = NAN # NaN when the backend cannot report confidence
44
+
45
+
46
+ @dataclass
47
+ class Transcript:
48
+ text: str
49
+ words: list[Word] = field(default_factory=list)
50
+ speech_seconds: float = 0.0 # voiced time, not wall-clock file length
51
+ backend: str = ""
52
+ model: str = ""
53
+ latency_s: float = 0.0
54
+ has_confidence: bool = False
55
+
56
+
57
+ @runtime_checkable
58
+ class ASRBackend(Protocol):
59
+ name: str
60
+ supports_word_confidence: bool
61
+
62
+ def describe(self) -> str: ...
63
+
64
+ def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript: ...
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Audio preprocessing
69
+ # ---------------------------------------------------------------------------
70
+
71
+ _HAS_FFMPEG = shutil.which("ffmpeg") is not None
72
+
73
+
74
+ def to_16k_mono_flac(path: str) -> str:
75
+ """
76
+ Downsample to 16 kHz mono FLAC. Whisper resamples to 16 kHz internally
77
+ anyway, so this is lossless for accuracy but shrinks the upload ~10x --
78
+ which matters because hosted endpoints cap request size (Groq: 25 MB on
79
+ the free tier) and a phone recording is often 48 kHz stereo.
80
+ """
81
+ if not _HAS_FFMPEG:
82
+ return path
83
+ out = tempfile.NamedTemporaryFile(delete=False, suffix=".flac").name
84
+ try:
85
+ subprocess.run(
86
+ ["ffmpeg", "-y", "-loglevel", "error", "-i", path,
87
+ "-ar", "16000", "-ac", "1", "-c:a", "flac", out],
88
+ check=True, timeout=120,
89
+ )
90
+ return out
91
+ except Exception:
92
+ return path
93
+
94
+
95
+ def _voiced_from_segments(segments) -> float:
96
+ total = 0.0
97
+ for s in segments:
98
+ start = s.get("start") if isinstance(s, dict) else getattr(s, "start", None)
99
+ end = s.get("end") if isinstance(s, dict) else getattr(s, "end", None)
100
+ if start is not None and end is not None:
101
+ total += max(0.0, float(end) - float(start))
102
+ return total
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Backend 1: Groq (recommended for a free CPU Space)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
110
+
111
+ # large-v3 is meaningfully better than turbo on Hindi; turbo is ~2.8x cheaper
112
+ # and fine for English. Override with GROQ_MODEL_HI / GROQ_MODEL_EN.
113
+ GROQ_MODELS = {
114
+ "hi": os.getenv("GROQ_MODEL_HI", "whisper-large-v3"),
115
+ "en": os.getenv("GROQ_MODEL_EN", "whisper-large-v3-turbo"),
116
+ }
117
+
118
+
119
+ class GroqBackend:
120
+ name = "groq"
121
+ supports_word_confidence = False # OpenAI-compatible API returns no logprobs
122
+
123
+ def __init__(self, api_key: str | None = None, timeout: float = 90.0):
124
+ self.api_key = api_key or os.environ["GROQ_API_KEY"]
125
+ self.timeout = timeout
126
+
127
+ def describe(self) -> str:
128
+ return f"Groq API — {GROQ_MODELS['hi']} (hi) / {GROQ_MODELS['en']} (en)"
129
+
130
+ def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript:
131
+ import httpx
132
+
133
+ model = GROQ_MODELS.get(lang, "whisper-large-v3")
134
+ sent = to_16k_mono_flac(audio_path)
135
+ t0 = time.perf_counter()
136
+
137
+ data = {
138
+ "model": model,
139
+ "language": lang,
140
+ "response_format": "verbose_json",
141
+ "timestamp_granularities[]": ["word", "segment"],
142
+ "temperature": "0",
143
+ }
144
+ if hint:
145
+ data["prompt"] = hint[:220]
146
+
147
+ payload = None
148
+ last_error: Exception | None = None
149
+ for attempt in range(3):
150
+ try:
151
+ with open(sent, "rb") as fh:
152
+ resp = httpx.post(
153
+ GROQ_URL,
154
+ headers={"Authorization": f"Bearer {self.api_key}"},
155
+ data=data,
156
+ files={"file": (os.path.basename(sent), fh, "audio/flac")},
157
+ timeout=self.timeout,
158
+ )
159
+ if resp.status_code in (429, 500, 502, 503):
160
+ raise RuntimeError(f"transient {resp.status_code}: {resp.text[:200]}")
161
+ resp.raise_for_status()
162
+ payload = resp.json()
163
+ break
164
+ except Exception as exc:
165
+ last_error = exc
166
+ if attempt == 2:
167
+ raise RuntimeError(f"Groq transcription failed: {exc}") from exc
168
+ time.sleep(1.5 * (attempt + 1)) # backoff; free tier is rate-limited
169
+ assert payload is not None, last_error
170
+
171
+ if sent != audio_path:
172
+ try:
173
+ os.unlink(sent)
174
+ except OSError:
175
+ pass
176
+
177
+ words = [
178
+ Word(w.get("word", "").strip(), float(w.get("start", 0)), float(w.get("end", 0)))
179
+ for w in (payload.get("words") or [])
180
+ ]
181
+ voiced = _voiced_from_segments(payload.get("segments") or [])
182
+ if not voiced and words:
183
+ voiced = words[-1].end - words[0].start
184
+
185
+ return Transcript(
186
+ text=(payload.get("text") or "").strip(),
187
+ words=words,
188
+ speech_seconds=voiced,
189
+ backend=self.name,
190
+ model=model,
191
+ latency_s=round(time.perf_counter() - t0, 2),
192
+ has_confidence=False,
193
+ )
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Backend 2: local faster-whisper
198
+ # ---------------------------------------------------------------------------
199
+
200
+ LOCAL_MODELS = {
201
+ "hi": {"fast": "small", "balanced": "medium", "accurate": "large-v3"},
202
+ "en": {"fast": "distil-small.en", "balanced": "distil-medium.en",
203
+ "accurate": "distil-large-v3"},
204
+ }
205
+
206
+
207
+ def _pick_device() -> tuple[str, str]:
208
+ try:
209
+ import torch
210
+
211
+ if torch.cuda.is_available():
212
+ major = torch.cuda.get_device_capability()[0]
213
+ return "cuda", "int8_float16" if major >= 7 else "float16"
214
+ except Exception:
215
+ pass
216
+ return "cpu", "int8"
217
+
218
+
219
+ class LocalBackend:
220
+ name = "local"
221
+ supports_word_confidence = True # the reason to keep this backend around
222
+
223
+ def __init__(self, tier: str | None = None):
224
+ self.tier = tier or os.getenv("LOCAL_TIER", "accurate")
225
+ self.device, self.compute_type = _pick_device()
226
+ self._cache: dict[str, object] = {}
227
+ self._lock = threading.Lock()
228
+
229
+ def describe(self) -> str:
230
+ return (f"faster-whisper {self.tier} on {self.device} ({self.compute_type})"
231
+ f" — word confidence available")
232
+
233
+ def _model(self, lang: str):
234
+ name = LOCAL_MODELS[lang][self.tier]
235
+ with self._lock:
236
+ if name not in self._cache:
237
+ from faster_whisper import WhisperModel
238
+
239
+ self._cache[name] = WhisperModel(
240
+ name, device=self.device, compute_type=self.compute_type,
241
+ cpu_threads=os.cpu_count() or 4,
242
+ )
243
+ return self._cache[name], name
244
+
245
+ def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript:
246
+ model, name = self._model(lang)
247
+ t0 = time.perf_counter()
248
+
249
+ segments, _info = model.transcribe(
250
+ audio_path,
251
+ language=lang,
252
+ beam_size=1, # greedy: ~3x faster, ~1% WER cost
253
+ word_timestamps=True,
254
+ condition_on_previous_text=False, # stop one bad segment poisoning the rest
255
+ vad_filter=True, # skip silence in learner recordings
256
+ vad_parameters={"min_silence_duration_ms": 400},
257
+ initial_prompt=hint,
258
+ temperature=0.0,
259
+ )
260
+
261
+ chunks, words, voiced = [], [], 0.0
262
+ for seg in segments: # generator -- work happens here
263
+ chunks.append(seg.text)
264
+ voiced += max(0.0, seg.end - seg.start)
265
+ for w in (seg.words or []):
266
+ words.append(Word(w.word.strip(), w.start, w.end,
267
+ getattr(w, "probability", NAN)))
268
+
269
+ return Transcript(
270
+ text=" ".join(chunks).strip(), words=words, speech_seconds=voiced,
271
+ backend=self.name, model=name,
272
+ latency_s=round(time.perf_counter() - t0, 2), has_confidence=True,
273
+ )
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Backend 3: ZeroGPU (transformers)
278
+ # ---------------------------------------------------------------------------
279
+
280
+ try:
281
+ import spaces # type: ignore
282
+
283
+ _gpu = spaces.GPU
284
+ except Exception: # not on a ZeroGPU Space
285
+ def _gpu(func=None, duration=None): # no-op passthrough
286
+ if func is None:
287
+ return lambda f: f
288
+ return func
289
+
290
+ ZERO_MODELS = {
291
+ "hi": os.getenv("ZERO_MODEL_HI", "openai/whisper-large-v3"),
292
+ "en": os.getenv("ZERO_MODEL_EN", "distil-whisper/distil-large-v3"),
293
+ }
294
+
295
+
296
+ class ZeroGPUBackend:
297
+ """
298
+ Loads on CPU at import, moves to CUDA inside the @spaces.GPU call. ZeroGPU
299
+ forks a GPU-attached process per call, so the .to("cuda") must happen
300
+ inside the decorated function, not at module scope.
301
+ """
302
+
303
+ name = "zerogpu"
304
+ supports_word_confidence = False
305
+
306
+ def __init__(self):
307
+ self._cache: dict[str, tuple] = {}
308
+ self._lock = threading.Lock()
309
+
310
+ def describe(self) -> str:
311
+ return f"ZeroGPU transformers — {ZERO_MODELS['hi']} (hi) / {ZERO_MODELS['en']} (en)"
312
+
313
+ def _load(self, lang: str):
314
+ name = ZERO_MODELS[lang]
315
+ with self._lock:
316
+ if name not in self._cache:
317
+ import torch
318
+ from transformers import (AutoProcessor,
319
+ WhisperForConditionalGeneration)
320
+
321
+ proc = AutoProcessor.from_pretrained(name)
322
+ model = WhisperForConditionalGeneration.from_pretrained(
323
+ name, torch_dtype=torch.float16, low_cpu_mem_usage=True,
324
+ )
325
+ self._cache[name] = (model, proc, name)
326
+ return self._cache[name]
327
+
328
+ def transcribe(self, audio_path: str, lang: str, hint: str | None = None) -> Transcript:
329
+ model, proc, name = self._load(lang)
330
+ t0 = time.perf_counter()
331
+ text, words, voiced = self._run(model, proc, audio_path, lang, hint)
332
+ return Transcript(
333
+ text=text, words=words, speech_seconds=voiced,
334
+ backend=self.name, model=name,
335
+ latency_s=round(time.perf_counter() - t0, 2), has_confidence=False,
336
+ )
337
+
338
+ @staticmethod
339
+ @_gpu(duration=90)
340
+ def _run(model, proc, audio_path, lang, hint):
341
+ import torch
342
+ from transformers import pipeline
343
+
344
+ device = "cuda" if torch.cuda.is_available() else "cpu"
345
+ dtype = torch.float16 if device == "cuda" else torch.float32
346
+ model = model.to(device=device, dtype=dtype)
347
+
348
+ asr = pipeline(
349
+ "automatic-speech-recognition",
350
+ model=model,
351
+ tokenizer=proc.tokenizer,
352
+ feature_extractor=proc.feature_extractor,
353
+ torch_dtype=dtype,
354
+ device=device,
355
+ chunk_length_s=30,
356
+ batch_size=8, # batched long-form: the big transformers speedup
357
+ )
358
+ kwargs = {"language": lang, "task": "transcribe", "num_beams": 1}
359
+ if hint:
360
+ kwargs["prompt_ids"] = proc.get_prompt_ids(hint[:220], return_tensors="pt").to(device)
361
+
362
+ out = asr(audio_path, return_timestamps="word", generate_kwargs=kwargs)
363
+
364
+ words, voiced = [], 0.0
365
+ for ch in out.get("chunks", []) or []:
366
+ ts = ch.get("timestamp") or (None, None)
367
+ start, end = (ts[0] or 0.0), (ts[1] or 0.0)
368
+ words.append(Word(ch.get("text", "").strip(), float(start), float(end)))
369
+ voiced += max(0.0, float(end) - float(start))
370
+ return out.get("text", "").strip(), words, voiced
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Factory
375
+ # ---------------------------------------------------------------------------
376
+
377
+ _backend: ASRBackend | None = None
378
+ _factory_lock = threading.Lock()
379
+
380
+
381
+ def get_backend() -> ASRBackend:
382
+ """Resolve once per process. ASR_BACKEND=auto prefers Groq, then ZeroGPU."""
383
+ global _backend
384
+ with _factory_lock:
385
+ if _backend is not None:
386
+ return _backend
387
+
388
+ choice = os.getenv("ASR_BACKEND", "auto").lower()
389
+ if choice == "auto":
390
+ if os.getenv("GROQ_API_KEY"):
391
+ choice = "groq"
392
+ elif os.getenv("SPACES_ZERO_GPU") or os.getenv("ZEROGPU"):
393
+ choice = "zerogpu"
394
+ else:
395
+ choice = "local"
396
+
397
+ _backend = {"groq": GroqBackend, "zerogpu": ZeroGPUBackend,
398
+ "local": LocalBackend}[choice]()
399
+ return _backend
400
+
401
+
402
+ def is_nan(x: float) -> bool:
403
+ return isinstance(x, float) and math.isnan(x)
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
raeding coach.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:33247a95d253b79feaa207723b810e956a413e31040a55c4824e1b1299f284c4
3
+ size 12502
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- core (all backends) ---
2
+ gradio>=5.0
3
+ pandas
4
+ gTTS
5
+ python-Levenshtein
6
+ num2words
7
+
8
+ # --- Groq backend: httpx only (ships with gradio, pinned here for clarity) ---
9
+ httpx
10
+
11
+ # --- local faster-whisper backend: uncomment for ASR_BACKEND=local ---
12
+ # faster-whisper>=1.1.0
13
+
14
+ # --- ZeroGPU backend: uncomment for ASR_BACKEND=zerogpu ---
15
+ # spaces
16
+ # torch
17
+ # transformers>=4.44
18
+ # accelerate
scoring.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backend-agnostic scoring. Takes a Transcript from any ASR backend plus the
3
+ reference passage, returns metrics + an error table.
4
+
5
+ Nothing in here touches a GPU or the network, which is why the app can run on a
6
+ free CPU Space: only transcription needs compute.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import string
13
+ import unicodedata
14
+ from functools import lru_cache
15
+ from typing import Iterable, Sequence
16
+
17
+ import pandas as pd
18
+
19
+ from asr_backends import Transcript, is_nan
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Optional deps
23
+ # ---------------------------------------------------------------------------
24
+
25
+ try:
26
+ from Levenshtein import distance as _lev
27
+ from Levenshtein import ratio as _ratio
28
+ except ImportError:
29
+ import difflib
30
+
31
+ def _lev(a: str, b: str) -> int:
32
+ sm = difflib.SequenceMatcher(None, a, b)
33
+ n = max(len(a), len(b))
34
+ return n - int(sm.ratio() * n)
35
+
36
+ def _ratio(a: str, b: str) -> float:
37
+ return difflib.SequenceMatcher(None, a, b).ratio()
38
+
39
+ try:
40
+ from num2words import num2words
41
+ except ImportError:
42
+ num2words = None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Normalisation -- most "errors" in a naive implementation are encoding noise
47
+ # ---------------------------------------------------------------------------
48
+
49
+ ZERO_WIDTH = dict.fromkeys(map(ord, "\u200b\u200c\u200d\ufeff"), None)
50
+ DEVANAGARI_DIGITS = {ord(c): str(i) for i, c in enumerate("०१२३४५६७८९")}
51
+ DEV_PUNCT = "।॥"
52
+ MATRAS = re.compile(r"[\u0900-\u0903\u093A-\u094F\u0951-\u0957\u0962\u0963]")
53
+
54
+ CONTRACTIONS = {
55
+ "cant": "cannot", "dont": "do not", "wont": "will not", "im": "i am",
56
+ "ive": "i have", "id": "i would", "ill": "i will", "its": "it is",
57
+ "lets": "let us", "thats": "that is", "youre": "you are", "hes": "he is",
58
+ "shes": "she is", "theyre": "they are", "isnt": "is not", "arent": "are not",
59
+ "wasnt": "was not", "didnt": "did not", "doesnt": "does not",
60
+ "couldnt": "could not", "wouldnt": "would not", "shouldnt": "should not",
61
+ }
62
+
63
+
64
+ def _expand_numbers(text: str, lang: str) -> str:
65
+ if num2words is None:
66
+ return text
67
+
68
+ def sub(m: re.Match) -> str:
69
+ try:
70
+ return num2words(int(m.group()), lang="hi" if lang == "hi" else "en")
71
+ except Exception:
72
+ return m.group()
73
+
74
+ return re.sub(r"\d+", sub, text)
75
+
76
+
77
+ def normalise(text: str, lang: str) -> list[str]:
78
+ """Canonical token list. Order of operations matters."""
79
+ text = unicodedata.normalize("NFC", text) # unifies the two encodings of ड़
80
+ text = text.translate(ZERO_WIDTH)
81
+
82
+ if lang == "hi":
83
+ text = text.translate(DEVANAGARI_DIGITS)
84
+ text = text.replace("ॐ", "ओम")
85
+ text = re.sub(f"[{DEV_PUNCT}]", " ", text)
86
+ text = text.replace("ँ", "ं") # chandrabindu ~ anusvara
87
+ else:
88
+ text = text.lower().replace("\u2019", "'").replace("-", " ")
89
+
90
+ text = _expand_numbers(text, lang)
91
+ text = text.translate(str.maketrans("", "", string.punctuation))
92
+ tokens = text.split()
93
+
94
+ if lang == "en":
95
+ tokens = [CONTRACTIONS.get(t, t) for t in tokens]
96
+ tokens = [w for t in tokens for w in t.split()]
97
+ return tokens
98
+
99
+
100
+ def skeleton(word: str, lang: str) -> str:
101
+ """Vowel-stripped form: equal skeletons mean same consonants, wrong vowels."""
102
+ if lang == "hi":
103
+ return MATRAS.sub("", word)
104
+ return re.sub(r"[aeiou]", "", word) or word
105
+
106
+
107
+ @lru_cache(maxsize=200_000)
108
+ def similarity(a: str, b: str) -> float:
109
+ return _ratio(a, b)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Alignment: Needleman-Wunsch weighted by edit distance
114
+ # ---------------------------------------------------------------------------
115
+ # difflib only matches byte-identical tokens, so बिगडा vs बिगड़ा becomes a
116
+ # delete + insert and the two words are never compared to each other.
117
+
118
+ GAP_COST = 0.62 # < 1.0 so a near-match always beats delete + insert
119
+
120
+
121
+ def align(ref: Sequence[str], hyp: Sequence[str]) -> list[tuple[str | None, str | None]]:
122
+ n, m = len(ref), len(hyp)
123
+ dist = [[0.0] * (m + 1) for _ in range(n + 1)]
124
+ back = [[""] * (m + 1) for _ in range(n + 1)]
125
+
126
+ for i in range(1, n + 1):
127
+ dist[i][0], back[i][0] = i * GAP_COST, "D"
128
+ for j in range(1, m + 1):
129
+ dist[0][j], back[0][j] = j * GAP_COST, "I"
130
+
131
+ for i in range(1, n + 1):
132
+ ri = ref[i - 1]
133
+ for j in range(1, m + 1):
134
+ sub = dist[i - 1][j - 1] + (1.0 - similarity(ri, hyp[j - 1]))
135
+ dele = dist[i - 1][j] + GAP_COST
136
+ ins = dist[i][j - 1] + GAP_COST
137
+ best = min(sub, dele, ins)
138
+ dist[i][j] = best
139
+ back[i][j] = "M" if best == sub else ("D" if best == dele else "I")
140
+
141
+ pairs: list[tuple[str | None, str | None]] = []
142
+ i, j = n, m
143
+ while i > 0 or j > 0:
144
+ op = back[i][j] if (i and j) else ("D" if i else "I")
145
+ if op == "M":
146
+ pairs.append((ref[i - 1], hyp[j - 1])); i -= 1; j -= 1
147
+ elif op == "D":
148
+ pairs.append((ref[i - 1], None)); i -= 1
149
+ else:
150
+ pairs.append((None, hyp[j - 1])); j -= 1
151
+ pairs.reverse()
152
+ return pairs
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Error taxonomy
157
+ # ---------------------------------------------------------------------------
158
+
159
+ SIMILAR_HI = [set("बवभ"), set("सशष"), set("दध"), set("तट"), set("कख"), set("गघ"),
160
+ set("जझ"), set("पफ"), set("नण"), set("रड़"), set("लर")]
161
+ SIMILAR_EN = [set("bvp"), set("sz"), set("td"), set("kg"), set("fp"), set("lr"),
162
+ set("mn"), set("jy")]
163
+
164
+ LABELS = {
165
+ "extra": ("अतिरिक्त शब्द", "Extra word"),
166
+ "omission": ("छूटा हुआ शब्द", "Omitted word"),
167
+ "vowel": ("मात्रा दोष", "Vowel error"),
168
+ "phonetic": ("ध्वनि भ्रम", "Confusable sound"),
169
+ "pronunciation": ("उच्चारण दोष", "Mispronounced"),
170
+ "order": ("अक्षर क्रम", "Letter order"),
171
+ "substitution": ("गलत शब्द", "Wrong word"),
172
+ }
173
+
174
+ SEVERITY = {"ok": 0, "vowel": 1, "phonetic": 1, "pronunciation": 2, "order": 2,
175
+ "omission": 3, "extra": 3, "substitution": 4}
176
+
177
+
178
+ def _label(code: str, lang: str) -> str:
179
+ hi, en = LABELS[code]
180
+ return f"{hi} / {en}" if lang == "hi" else en
181
+
182
+
183
+ def classify(ref: str | None, hyp: str | None, lang: str) -> str:
184
+ if ref is None:
185
+ return "extra"
186
+ if hyp is None:
187
+ return "omission"
188
+ if ref == hyp:
189
+ return "ok"
190
+
191
+ ed = _lev(ref, hyp)
192
+ if skeleton(ref, lang) == skeleton(hyp, lang):
193
+ return "vowel"
194
+
195
+ groups = SIMILAR_HI if lang == "hi" else SIMILAR_EN
196
+ if ed <= 2 and any((set(ref) & g) and (set(hyp) & g) for g in groups):
197
+ return "phonetic"
198
+ if similarity(ref, hyp) >= 0.75 or ed <= 2:
199
+ return "pronunciation"
200
+ if sorted(ref) == sorted(hyp):
201
+ return "order"
202
+ return "substitution"
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Metrics
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def cer(ref_tokens: Iterable[str], hyp_tokens: Iterable[str]) -> float:
210
+ r, h = " ".join(ref_tokens), " ".join(hyp_tokens)
211
+ return _lev(r, h) / max(1, len(r))
212
+
213
+
214
+ def score(expected: str, tr: Transcript, lang: str) -> tuple[dict, pd.DataFrame]:
215
+ ref = normalise(expected, lang)
216
+ hyp = normalise(tr.text, lang)
217
+
218
+ if not ref:
219
+ return {"error": "The passage is empty."}, pd.DataFrame()
220
+
221
+ pairs = align(ref, hyp)
222
+
223
+ # map normalised hypothesis token -> ASR confidence, when the backend has it
224
+ conf: dict[str, float] = {}
225
+ if tr.has_confidence:
226
+ for w in tr.words:
227
+ toks = normalise(w.text, lang)
228
+ if toks:
229
+ conf.setdefault(toks[0], w.prob)
230
+
231
+ rows, sub, dele, ins, soft = [], 0, 0, 0, 0.0
232
+ for r, h in pairs:
233
+ code = classify(r, h, lang)
234
+ if code == "ok":
235
+ soft += 1.0
236
+ continue
237
+ if code == "extra":
238
+ ins += 1
239
+ elif code == "omission":
240
+ dele += 1
241
+ else:
242
+ sub += 1
243
+ soft += similarity(r, h) # partial credit for a near miss
244
+
245
+ row = {
246
+ "अपेक्षित / Expected": r or "",
247
+ "सुना गया / Heard": h or "",
248
+ "प्रकार / Error type": _label(code, lang),
249
+ "समानता / Similarity": round(similarity(r or "", h or ""), 2),
250
+ }
251
+ if tr.has_confidence:
252
+ c = conf.get(h) if h else None
253
+ row["ASR conf."] = None if (c is None or is_nan(c)) else round(c, 2)
254
+ row["_sev"] = SEVERITY[code]
255
+ rows.append(row)
256
+
257
+ n = len(ref)
258
+ wer = (sub + dele + ins) / n
259
+ exact = 100.0 * max(0, n - sub - dele) / n
260
+ lenient = 100.0 * soft / n
261
+
262
+ dur = tr.speech_seconds or (
263
+ tr.words[-1].end - tr.words[0].start if len(tr.words) > 1 else 0.0)
264
+ wpm = round(60.0 * len(hyp) / dur, 1) if dur > 0.5 else None
265
+
266
+ pauses = sum(1 for a, b in zip(tr.words, tr.words[1:]) if b.start - a.end > 0.7)
267
+
268
+ metrics = {
269
+ "📝 Transcribed": tr.text,
270
+ "✅ Word accuracy (%)": round(exact, 2),
271
+ "🎯 Lenient score (%)": round(lenient, 2),
272
+ "📉 WER (%)": round(100 * wer, 2),
273
+ "🔤 CER (%)": round(100 * cer(ref, hyp), 2),
274
+ "⏱️ Speaking rate (wpm)": wpm,
275
+ "⏸️ Long pauses (>0.7s)": pauses if tr.words else "n/a",
276
+ "🔢 Errors": {"substitutions": sub, "omissions": dele, "insertions": ins},
277
+ "⚙️ Backend": f"{tr.backend}:{tr.model} ({tr.latency_s}s)",
278
+ }
279
+ if tr.has_confidence:
280
+ unclear = [w.text for w in tr.words if not is_nan(w.prob) and w.prob < 0.45]
281
+ metrics["🤔 Unclear words"] = unclear[:10] or "—"
282
+
283
+ df = pd.DataFrame(rows)
284
+ if not df.empty:
285
+ df = (df.sort_values("_sev", ascending=False)
286
+ .drop(columns="_sev")
287
+ .reset_index(drop=True))
288
+ return metrics, df