Clearwave48 commited on
Commit
20eb2dc
·
verified ·
1 Parent(s): 2fcb053

Update transcriber.py

Browse files
Files changed (1) hide show
  1. transcriber.py +12 -424
transcriber.py CHANGED
@@ -1,429 +1,17 @@
1
- """
2
- ClearWave — Transcriber
3
- ========================
4
- Primary : Groq API (Whisper large-v3 on H100) — free 14,400s/day
5
- Fallback : faster-whisper large-v3 int8 (local CPU)
6
-
7
- FIXES APPLIED (original):
8
- - Pre-process audio to 16kHz mono WAV before Groq (~15% accuracy gain)
9
- - Added exponential backoff retry on Groq rate limit (429)
10
- - vad_parameters now includes speech_pad_ms=400 to avoid cutting word starts
11
- - Chunked offset: fixed in-place mutation bug + extend→append fix
12
- - Unsupported Groq languages (te, kn) fall back to auto-detect gracefully
13
- - Verified Groq supported language list used as gate
14
-
15
- BUGS FIXED (v2):
16
- [BUG-1] _preprocess_for_whisper() temp files (_16k.wav) were never deleted
17
- → disk leak: one file per chunk in long audio mode
18
- → Fix: cleanup in finally block inside _transcribe_single()
19
-
20
- [BUG-2] _get_duration() returned 0.0 silently on ffprobe failure
21
- → caused silent bad-file passthrough instead of clear error
22
- → Fix: raise RuntimeError if duration is 0.0
23
-
24
- [BUG-3] _last_segments not reset at start of _transcribe_single()
25
- → if preprocessing fell back to original, stale segments from
26
- previous chunk could bleed into current chunk's offset pass
27
- → Fix: self._last_segments = [] at top of _transcribe_single()
28
-
29
- [BUG-4] Race condition: _last_segments is shared singleton state
30
- → concurrent requests overwrite each other's segments before
31
- main.py can read them after transcribe() returns
32
- → Fix: transcribe() now returns segments as 4th return value
33
- instead of relying on shared mutable attribute
34
- → main.py call site updated: transcript, lang, method, segs = ...
35
-
36
- [BUG-5] _norm() used raw[:2] fallback for unknown language names
37
- → "bengali" → "be" (Belarusian), "urdu" → "ur" (correct by luck)
38
- → Fix: expanded _norm() map + warning log for truly unknown codes
39
- """
40
-
41
  import os
42
- import time
43
- import logging
44
- import subprocess
45
- import tempfile
46
- import shutil
47
-
48
- logger = logging.getLogger(__name__)
49
-
50
- LANG_TO_WHISPER = {
51
- "auto": None, "en": "en", "te": "te",
52
- "hi": "hi", "ta": "ta", "kn": "kn",
53
- "bn": "bn", "ur": "ur", "ko": "ko", "vi": "vi",
54
- }
55
-
56
- # Groq's Whisper large-v3 supported languages — te (Telugu) and kn (Kannada)
57
- # are NOT in Groq's supported list → fall back to auto-detect
58
- GROQ_SUPPORTED_LANGS = {
59
- "en", "hi", "ta", "es", "fr", "de", "ja", "zh",
60
- "ar", "pt", "ru", "it", "nl", "pl", "sv", "tr",
61
- "bn", "ur", "ko", "vi",
62
- }
63
-
64
- CHUNK_SEC = 60 # Groq max safe chunk size (seconds)
65
- MAX_RETRIES = 3 # Exponential backoff retries for Groq 429
66
-
67
 
68
  class Transcriber:
69
  def __init__(self):
70
- self.groq_key = os.environ.get("GROQ_API_KEY", "")
71
- self._groq_client = None
72
- self._local_model = None
73
- # NOTE: _last_segments kept for backwards compat but is now also
74
- # returned directly from transcribe() — do not rely on it under
75
- # concurrent load (see BUG-4 fix notes above).
76
- self._last_segments = []
77
-
78
- if self.groq_key:
79
- print("[Transcriber] Groq API key found — primary = Groq Whisper large-v3")
80
- self._init_groq()
81
- else:
82
- print("[Transcriber] No GROQ_API_KEY — local Whisper loads on first use")
83
-
84
- # ══════════════════════════════════════════════════════════════════
85
- # PUBLIC
86
- # ══════════════════════════════════════════════════════════════════
87
- def transcribe(self, audio_path: str, language: str = "auto"):
88
- """
89
- Returns (transcript_text, detected_language, method_label, word_segments)
90
-
91
- word_segments: list of {'word', 'start', 'end'} dicts with timestamps.
92
-
93
- BUG-4 FIX: segments are returned directly instead of only stored on
94
- self._last_segments, eliminating the race condition when the singleton
95
- is shared across concurrent requests.
96
- """
97
- lang_hint = LANG_TO_WHISPER.get(language, None)
98
-
99
- # BUG-2 FIX: raise clearly if duration cannot be read
100
- duration = self._get_duration(audio_path)
101
- if duration == 0.0:
102
- raise RuntimeError(
103
- f"[Transcriber] Could not read audio duration — "
104
- f"file may be corrupt or unsupported: {audio_path}"
105
- )
106
- print(f"[Transcriber] Audio duration: {duration:.1f}s")
107
-
108
- self._last_segments = []
109
-
110
- if duration <= CHUNK_SEC:
111
- transcript, detected, method, segs = self._transcribe_single(audio_path, lang_hint)
112
- else:
113
- print(f"[Transcriber] Long audio — splitting into {CHUNK_SEC}s chunks")
114
- transcript, detected, method, segs = self._transcribe_chunked(
115
- audio_path, lang_hint, duration
116
- )
117
-
118
- # Keep attribute in sync for any legacy callers
119
- self._last_segments = segs
120
- return transcript, detected, method, segs
121
-
122
- # ══════════════════════════════════════════════════════════════════
123
- # CHUNKED PROCESSING
124
- # ══════════════════════════════════════════════════════════════════
125
- def _transcribe_chunked(self, audio_path, language, duration):
126
- tmp_dir = tempfile.mkdtemp()
127
- chunks = []
128
- start = 0
129
- idx = 0
130
-
131
- while start < duration:
132
- cp = os.path.join(tmp_dir, f"chunk_{idx:03d}.wav")
133
- subprocess.run([
134
- "ffmpeg", "-y", "-i", audio_path,
135
- "-ss", str(start), "-t", str(CHUNK_SEC),
136
- "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", cp
137
- ], capture_output=True)
138
- if os.path.exists(cp):
139
- chunks.append((cp, start))
140
- start += CHUNK_SEC
141
- idx += 1
142
-
143
- print(f"[Transcriber] Processing {len(chunks)} chunks...")
144
- all_texts = []
145
- all_segments = []
146
- detected = language or "en"
147
- method = "unknown"
148
-
149
- for i, (chunk_path, offset) in enumerate(chunks):
150
- print(f"[Transcriber] Chunk {i+1}/{len(chunks)} (offset={offset:.0f}s)...")
151
- try:
152
- # BUG-4 FIX: receive segments directly — no shared-state dependency
153
- text, lang, m, segs = self._transcribe_single(chunk_path, language)
154
- all_texts.append(text.strip())
155
- detected = lang
156
- method = m
157
-
158
- # Apply chunk time offset to each word segment
159
- for seg in segs:
160
- all_segments.append({
161
- 'word': seg['word'],
162
- 'start': round(seg['start'] + offset, 3),
163
- 'end': round(seg['end'] + offset, 3),
164
- })
165
-
166
- except Exception as e:
167
- logger.warning(f"Chunk {i+1} failed: {e}")
168
-
169
- shutil.rmtree(tmp_dir, ignore_errors=True)
170
- full = " ".join(t for t in all_texts if t)
171
- print(f"[Transcriber] ✅ {len(full)} chars, {len(all_segments)} word segments")
172
- return full, detected, f"{method} (chunked {len(chunks)}x)", all_segments
173
-
174
- # ══════════════════════════════════════════════════════════════════
175
- # SINGLE FILE
176
- # ══════════════════════════════════════════════════════════════════
177
- def _transcribe_single(self, audio_path, language):
178
- """
179
- Returns (transcript, detected_lang, method, word_segments).
180
-
181
- BUG-1 FIX: preprocessed temp file is always cleaned up in finally.
182
- BUG-3 FIX: local segment state reset at entry so stale data never
183
- bleeds across calls.
184
- """
185
- # BUG-3 FIX: always start clean
186
- self._last_segments = []
187
-
188
- preprocessed = self._preprocess_for_whisper(audio_path)
189
- try:
190
- if self._groq_client is not None:
191
- try:
192
- transcript, detected, method, segs = self._transcribe_groq(
193
- preprocessed, language
194
- )
195
- return transcript, detected, method, segs
196
- except Exception as e:
197
- logger.warning(f"Groq failed ({e}), falling back to local")
198
- if self._local_model is None:
199
- self._init_local()
200
-
201
- return self._transcribe_local(preprocessed, language)
202
-
203
- finally:
204
- # BUG-1 FIX: always delete the temp preprocessed file
205
- if preprocessed != audio_path and os.path.exists(preprocessed):
206
- try:
207
- os.unlink(preprocessed)
208
- except Exception as cleanup_err:
209
- logger.warning(f"[Transcriber] Temp file cleanup failed: {cleanup_err}")
210
-
211
- # ══════════════════════════════════════════════════════════════════
212
- # AUDIO PRE-PROCESSING
213
- # ══════════════════════════════════════════════════════════════════
214
- def _preprocess_for_whisper(self, audio_path: str) -> str:
215
- """
216
- Convert audio to 16kHz mono WAV before transcription.
217
- Whisper was trained on 16kHz audio — sending higher SR or stereo
218
- reduces accuracy (~10-15% WER improvement from this step alone).
219
- Returns path to preprocessed file; caller is responsible for cleanup.
220
- """
221
- try:
222
- # Use a proper temp file so we never collide on parallel calls
223
- fd, out_path = tempfile.mkstemp(suffix="_16k.wav")
224
- os.close(fd)
225
-
226
- result = subprocess.run([
227
- "ffmpeg", "-y", "-i", audio_path,
228
- "-ar", "16000",
229
- "-ac", "1",
230
- "-acodec", "pcm_s16le",
231
- out_path
232
- ], capture_output=True)
233
-
234
- if result.returncode == 0 and os.path.getsize(out_path) > 0:
235
- return out_path
236
- else:
237
- logger.warning("[Transcriber] Preprocessing failed, using original file")
238
- # Clean up the empty temp file
239
- try:
240
- os.unlink(out_path)
241
- except Exception:
242
- pass
243
- return audio_path
244
-
245
- except Exception as e:
246
- logger.warning(f"[Transcriber] Preprocess error: {e}")
247
- return audio_path
248
-
249
- # ══════════════════════════════════════════════════════════════════
250
- # GROQ (word-level timestamps + retry on 429)
251
- # ══════════════════════════════════════════════════════════════════
252
- def _init_groq(self):
253
- try:
254
- from groq import Groq
255
- self._groq_client = Groq(api_key=self.groq_key)
256
- print("[Transcriber] ✅ Groq client ready")
257
- except Exception as e:
258
- logger.warning(f"Groq init failed: {e}")
259
- self._groq_client = None
260
-
261
- def _transcribe_groq(self, audio_path, language=None):
262
- # If language not in Groq's supported list, use auto-detect
263
- if language and language not in GROQ_SUPPORTED_LANGS:
264
- logger.info(
265
- f"[Transcriber] Lang '{language}' not in Groq supported list → auto-detect"
266
- )
267
- language = None
268
-
269
- t0 = time.time()
270
-
271
- # Exponential backoff retry for rate limit (429)
272
- resp = None
273
- for attempt in range(1, MAX_RETRIES + 1):
274
- try:
275
- with open(audio_path, "rb") as f:
276
- kwargs = dict(
277
- file=f,
278
- model="whisper-large-v3",
279
- response_format="verbose_json",
280
- timestamp_granularities=["word"],
281
- temperature=0.0,
282
- )
283
- if language:
284
- kwargs["language"] = language
285
- resp = self._groq_client.audio.transcriptions.create(**kwargs)
286
- break # success
287
-
288
- except Exception as e:
289
- err_str = str(e).lower()
290
- if "429" in err_str or "rate" in err_str:
291
- wait = 2 ** attempt # 2s, 4s, 8s
292
- logger.warning(
293
- f"[Transcriber] Groq rate limit — retry {attempt}/{MAX_RETRIES} in {wait}s"
294
- )
295
- time.sleep(wait)
296
- if attempt == MAX_RETRIES:
297
- raise
298
- else:
299
- raise
300
-
301
- transcript = resp.text.strip()
302
- detected_lang = self._norm(getattr(resp, "language", language or "en") or "en")
303
-
304
- words = getattr(resp, "words", []) or []
305
- segs = [
306
- {
307
- 'word': w.word.strip() if hasattr(w, 'word') else str(w),
308
- 'start': float(w.start) if hasattr(w, 'start') else 0.0,
309
- 'end': float(w.end) if hasattr(w, 'end') else 0.0,
310
- }
311
- for w in words
312
- ]
313
-
314
- logger.info(
315
- f"Groq done in {time.time()-t0:.2f}s, lang={detected_lang}, words={len(segs)}"
316
- )
317
- # BUG-4 FIX: return segs directly, not only via self._last_segments
318
- self._last_segments = segs
319
- return transcript, detected_lang, "Groq Whisper large-v3", segs
320
-
321
- # ══════════════════════════════════════════════════════════════════
322
- # LOCAL faster-whisper (word-level timestamps + speech_pad fix)
323
- # ══════════════════════════════════════════════════════════════════
324
- def _init_local(self):
325
- try:
326
- from faster_whisper import WhisperModel
327
- print("[Transcriber] Loading faster-whisper large-v3 int8 (CPU)...")
328
- self._local_model = WhisperModel(
329
- "large-v3", device="cpu", compute_type="int8"
330
  )
331
- print("[Transcriber] ✅ faster-whisper ready")
332
- except Exception as e:
333
- logger.error(f"Local Whisper init failed: {e}")
334
- self._local_model = None
335
-
336
- def _transcribe_local(self, audio_path, language=None):
337
- t0 = time.time()
338
- if self._local_model is None:
339
- self._init_local()
340
- if self._local_model is None:
341
- raise RuntimeError("No transcription engine available.")
342
-
343
- segments, info = self._local_model.transcribe(
344
- audio_path,
345
- language=language,
346
- beam_size=5,
347
- word_timestamps=True,
348
- vad_filter=True,
349
- # speech_pad_ms=400 prevents clipping of word starts/ends
350
- vad_parameters=dict(
351
- min_silence_duration_ms=500,
352
- speech_pad_ms=400,
353
- ),
354
- )
355
-
356
- all_words = []
357
- text_parts = []
358
- for seg in segments:
359
- text_parts.append(seg.text.strip())
360
- if seg.words:
361
- for w in seg.words:
362
- all_words.append({
363
- 'word': w.word.strip(),
364
- 'start': round(w.start, 3),
365
- 'end': round(w.end, 3),
366
- })
367
-
368
- transcript = " ".join(text_parts).strip()
369
- detected_lang = info.language or language or "en"
370
-
371
- logger.info(f"Local done in {time.time()-t0:.2f}s, words={len(all_words)}")
372
- # BUG-4 FIX: return segs directly, not only via self._last_segments
373
- self._last_segments = all_words
374
- return transcript, detected_lang, "faster-whisper large-v3 int8 (local)", all_words
375
-
376
- # ══════════════════════════════════════════════════════════════════
377
- # HELPERS
378
- # ══════════════════════════════════════════════════════════════════
379
- def _get_duration(self, audio_path):
380
- try:
381
- r = subprocess.run([
382
- "ffprobe", "-v", "error",
383
- "-show_entries", "format=duration",
384
- "-of", "default=noprint_wrappers=1:nokey=1",
385
- audio_path
386
- ], capture_output=True, text=True)
387
- val = float(r.stdout.strip())
388
- return val
389
- except Exception:
390
- return 0.0
391
-
392
- @staticmethod
393
- def _norm(raw: str) -> str:
394
- """
395
- Normalise a language name or code returned by Whisper/Groq to a
396
- consistent 2-letter ISO 639-1 code.
397
-
398
- BUG-5 FIX: expanded map covers more languages; unknown codes now log
399
- a warning instead of silently truncating to raw[:2] which could
400
- produce wrong codes (e.g. "bengali" → "be" = Belarusian via raw[:2]).
401
- """
402
- LANG_MAP = {
403
- # Full names (Groq verbose_json returns these)
404
- "english": "en", "telugu": "te", "hindi": "hi",
405
- "tamil": "ta", "kannada": "kn", "spanish": "es",
406
- "french": "fr", "german": "de", "japanese": "ja",
407
- "chinese": "zh", "arabic": "ar", "portuguese": "pt",
408
- "russian": "ru", "italian": "it", "dutch": "nl",
409
- "polish": "pl", "swedish": "sv", "turkish": "tr",
410
- "bengali": "bn", "urdu": "ur", "korean": "ko",
411
- "vietnamese": "vi", "malay": "ms", "indonesian": "id",
412
- # Short codes pass through
413
- "en": "en", "te": "te", "hi": "hi", "ta": "ta", "kn": "kn",
414
- "es": "es", "fr": "fr", "de": "de", "ja": "ja", "zh": "zh",
415
- "ar": "ar", "pt": "pt", "ru": "ru", "it": "it", "nl": "nl",
416
- "pl": "pl", "sv": "sv", "tr": "tr", "bn": "bn", "ur": "ur",
417
- "ko": "ko", "vi": "vi", "ms": "ms", "id": "id",
418
- }
419
- key = raw.lower().strip()
420
- result = LANG_MAP.get(key)
421
- if result:
422
- return result
423
- # Last-resort: use first 2 chars but warn so it's visible in logs
424
- fallback = key[:2] if len(key) >= 2 else key
425
- logger.warning(
426
- f"[Transcriber] Unknown language '{raw}' — using fallback code '{fallback}'. "
427
- f"Add it to _norm() LANG_MAP if incorrect."
428
- )
429
- return fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from groq import Groq
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  class Transcriber:
5
  def __init__(self):
6
+ self.api_key = os.environ.get("GROQ_API_KEY", "")
7
+ self.client = Groq(api_key=self.api_key)
8
+
9
+ def transcribe(self, audio_path, **kwargs):
10
+ with open(audio_path, "rb") as file:
11
+ # We remove 'language' to let the model auto-detect perfectly
12
+ response = self.client.audio.transcriptions.create(
13
+ file=(audio_path, file.read()),
14
+ model="whisper-large-v3",
15
+ response_format="verbose_json",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  )
17
+ return {"text": response.text}