Leon4gr45 commited on
Commit
ce6f334
·
verified ·
1 Parent(s): 60fb08a

Delete vrag/transcribe.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. vrag/transcribe.py +0 -274
vrag/transcribe.py DELETED
@@ -1,274 +0,0 @@
1
- """Timestamped transcripts -> data/videos/<id>/transcript.<engine>.json as [{start, end, text}, ...].
2
-
3
- Engines:
4
- gemma - Gemma 4 12B on the vLLM server (default). Audio is cut at pauses into <=28s chunks,
5
- because Gemma 4 only hears the first 30s of any audio input.
6
- whisper - Whisper large-v3-turbo locally, with word timestamps.
7
- Creator captions (captions*.vtt from the YouTube download path) win over both when present.
8
-
9
- transcript.json is a copy of the *active* engine's transcript; everything downstream reads that.
10
- """
11
-
12
- import io
13
- import json
14
- import re
15
- import shutil
16
- import subprocess
17
- import time
18
- from concurrent.futures import ThreadPoolExecutor
19
- from pathlib import Path
20
-
21
- import numpy as np
22
- from rich.console import Console
23
-
24
- from .config import ASR_ENGINE, GEN_MODEL, VIDEOS_DIR
25
-
26
- console = Console()
27
-
28
- ENGINE_MODELS = {"whisper": "openai/whisper-large-v3-turbo", "gemma": "google/gemma-4-12B-it (vLLM)"}
29
- SR = 16000
30
- _asr = None
31
-
32
-
33
- def _parse_vtt(path: Path) -> list[dict]:
34
- import webvtt
35
-
36
- return [
37
- {"start": round(c.start_in_seconds, 2), "end": round(c.end_in_seconds, 2), "text": " ".join(c.text.split())}
38
- for c in webvtt.read(str(path))
39
- if c.text.strip()
40
- ]
41
-
42
-
43
- def load_audio(path: Path, sr: int = SR) -> np.ndarray:
44
- out = subprocess.run(
45
- ["ffmpeg", "-loglevel", "error", "-i", str(path), "-f", "f32le", "-ac", "1", "-ar", str(sr), "-"],
46
- capture_output=True, check=True,
47
- )
48
- return np.frombuffer(out.stdout, dtype=np.float32)
49
-
50
-
51
- # ---------------------------------------------------------------- whisper
52
-
53
- def _whisper(path: Path) -> list[dict]:
54
- global _asr
55
- import torch
56
- from transformers import pipeline
57
-
58
- if _asr is None:
59
- _asr = pipeline("automatic-speech-recognition", model=ENGINE_MODELS["whisper"], dtype=torch.bfloat16, device="cuda")
60
- audio = load_audio(path)
61
- # Word-level timestamps: chunk-level ones from the batched pipeline often collapse into
62
- # multi-minute cues, which breaks segmentation. Words are regrouped into cues below.
63
- out = _asr(
64
- {"raw": audio, "sampling_rate": SR},
65
- return_timestamps="word",
66
- chunk_length_s=30,
67
- batch_size=4, # word timestamps need eager cross-attention maps, which are memory-hungry
68
- generate_kwargs={"language": "en", "task": "transcribe"},
69
- )
70
- return _group_words(out["chunks"])
71
-
72
-
73
- def _group_words(words: list[dict], max_s: float = 8.0) -> list[dict]:
74
- """Group word timestamps into caption-like cues: break after sentence punctuation or every ~max_s."""
75
- cues, cur = [], []
76
- for w in words:
77
- start, end = w["timestamp"]
78
- if start is None:
79
- continue
80
- end = end if end is not None else start + 0.3
81
- # Whisper word tokens carry their own leading space ("1" + ".6"), so join raw text.
82
- cur.append((start, end, w["text"]))
83
- if w["text"].rstrip().endswith((".", "?", "!")) or end - cur[0][0] >= max_s:
84
- cues.append(cur)
85
- cur = []
86
- if cur:
87
- cues.append(cur)
88
- return [
89
- {"start": round(c[0][0], 2), "end": round(c[-1][1], 2), "text": "".join(t for _, _, t in c).strip()}
90
- for c in cues
91
- ]
92
-
93
-
94
- # ---------------------------------------------------------------- gemma
95
-
96
- GEMMA_PROMPT = (
97
- "Transcribe the following audio verbatim in English. Output only the transcript text, with normal "
98
- "punctuation and capitalisation. Do not summarise, translate, or add commentary. If there is no speech, output nothing."
99
- )
100
- CHUNK_MIN_S, CHUNK_MAX_S = 18.0, 28.0 # Gemma 4 truncates audio inputs at 30s
101
- # Every chunk is padded with silence to exactly this length. vLLM 0.23's Gemma 4 audio encoder
102
- # crashes the whole engine ("'list' object has no attribute 'squeeze'") when clips of different
103
- # lengths land in one encoder batch; identical lengths keep the features stackable.
104
- PAD_S = 30.0
105
-
106
-
107
- def split_at_pauses(audio: np.ndarray, sr: int = SR) -> list[tuple[int, int]]:
108
- """Chunks of CHUNK_MIN_S..CHUNK_MAX_S, each ending at the quietest 50ms frame in that window,
109
- so cuts land in pauses between words rather than mid-word."""
110
- hop = sr // 20
111
- n_frames = len(audio) // hop
112
- rms = np.sqrt(np.mean(audio[: n_frames * hop].reshape(n_frames, hop) ** 2, axis=1) + 1e-12)
113
- # Smooth over ~250ms so a single quiet frame inside a word doesn't count as a pause.
114
- rms = np.convolve(rms, np.ones(5) / 5, mode="same")
115
- chunks, start = [], 0
116
- while start < len(audio):
117
- if len(audio) - start <= CHUNK_MAX_S * sr:
118
- chunks.append((start, len(audio)))
119
- break
120
- lo = start // hop + int(CHUNK_MIN_S * 20)
121
- hi = start // hop + int(CHUNK_MAX_S * 20)
122
- cut = (lo + int(np.argmin(rms[lo:hi]))) * hop
123
- chunks.append((start, cut))
124
- start = cut
125
- return chunks
126
-
127
-
128
- def _wav_b64(samples: np.ndarray) -> str:
129
- import base64
130
-
131
- import soundfile as sf
132
-
133
- buf = io.BytesIO()
134
- sf.write(buf, samples, SR, format="WAV", subtype="PCM_16")
135
- return base64.b64encode(buf.getvalue()).decode()
136
-
137
-
138
- def pad_audio(samples: np.ndarray, seconds: float = PAD_S) -> np.ndarray:
139
- n = int(seconds * SR)
140
- samples = samples[:n]
141
- return np.pad(samples, (0, n - len(samples))) if len(samples) < n else samples
142
-
143
-
144
- # A word or short phrase repeated 6+ times in a row ("thought thought thought ..."): a decoding loop.
145
- _LOOP = re.compile(r"\b((?:\w+\W+){1,3}?)(?:\1){5,}", re.IGNORECASE)
146
- # Attempts per chunk: greedy first, then a repetition penalty with a little sampling to break loops.
147
- ATTEMPTS = [{"temperature": 0.0}, {"temperature": 0.3, "extra_body": {"repetition_penalty": 1.15}},
148
- {"temperature": 0.6, "extra_body": {"repetition_penalty": 1.3}}]
149
-
150
-
151
- def _is_loop(text: str) -> bool:
152
- return bool(_LOOP.search(text + " "))
153
-
154
-
155
- def _gemma_chunk(samples: np.ndarray) -> str:
156
- from .rag import client
157
-
158
- samples = pad_audio(samples)
159
- audio_b64 = _wav_b64(samples)
160
- text = ""
161
- for opts in ATTEMPTS:
162
- resp = client().chat.completions.create(
163
- model=GEN_MODEL,
164
- # ~30s of speech is <=150 tokens; a tight cap stops a runaway loop from eating 1k tokens.
165
- max_tokens=400,
166
- # Instruction before the audio: with audio first, Gemma tends to *reply* to the speaker.
167
- messages=[{"role": "user", "content": [
168
- {"type": "text", "text": GEMMA_PROMPT},
169
- {"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
170
- ]}],
171
- **opts,
172
- )
173
- text = " ".join((resp.choices[0].message.content or "").split())
174
- if not _is_loop(text):
175
- return text
176
- # Still looping after retries: keep the transcript but collapse the repeated run to one copy.
177
- return _LOOP.sub(lambda m: m.group(1), text + " ").strip()
178
-
179
-
180
- _SENT = re.compile(r"(?<=[.!?])\s+")
181
-
182
-
183
- def _chunk_cues(text: str, t0: float, t1: float) -> list[dict]:
184
- """Gemma returns plain text, so sentence times are interpolated by character offset within the chunk.
185
- Good to a second or two, which is plenty for ~30s retrieval segments."""
186
- sents = [s for s in _SENT.split(text) if s.strip()]
187
- total = sum(len(s) for s in sents) or 1
188
- cues, pos = [], 0
189
- for s in sents:
190
- a = t0 + (t1 - t0) * pos / total
191
- pos += len(s)
192
- b = t0 + (t1 - t0) * pos / total
193
- cues.append({"start": round(a, 2), "end": round(b, 2), "text": s.strip()})
194
- return cues
195
-
196
-
197
- def _gemma(path: Path, workers: int = 16) -> list[dict]:
198
- audio = load_audio(path)
199
- spans = split_at_pauses(audio)
200
- with ThreadPoolExecutor(workers) as pool:
201
- texts = list(pool.map(lambda sp: _gemma_chunk(audio[sp[0]:sp[1]]), spans))
202
- cues = []
203
- for (a, b), text in zip(spans, texts):
204
- cues += _chunk_cues(text, a / SR, b / SR)
205
- return cues
206
-
207
-
208
- # ---------------------------------------------------------------- orchestration
209
-
210
- def transcript_path(vdir: Path, engine: str) -> Path:
211
- return vdir / f"transcript.{engine}.json"
212
-
213
-
214
- def ensure_transcript(vdir: Path, engine: str = ASR_ENGINE, force: bool = False, activate: bool = True) -> list[dict]:
215
- vtts = sorted(vdir.glob("captions*.vtt"))
216
- out = transcript_path(vdir, "captions" if vtts else engine)
217
- if force or not out.exists():
218
- t0 = time.time()
219
- if vtts:
220
- cues = _parse_vtt(vtts[0])
221
- elif engine == "gemma":
222
- cues = _gemma(vdir / "video.mp4")
223
- elif engine == "whisper":
224
- cues = _whisper(vdir / "video.mp4")
225
- else:
226
- raise ValueError(f"unknown ASR engine {engine!r}")
227
- out.write_text(json.dumps(cues, indent=1))
228
- meta = json.loads((vdir / "meta.json").read_text())
229
- meta.setdefault("transcribe_seconds", {})[out.stem.split(".")[-1]] = round(time.time() - t0, 1)
230
- (vdir / "meta.json").write_text(json.dumps(meta, indent=2))
231
- if activate:
232
- shutil.copyfile(out, vdir / "transcript.json")
233
- meta = json.loads((vdir / "meta.json").read_text())
234
- meta["transcript_source"] = "captions" if vtts else ENGINE_MODELS[engine]
235
- meta["transcript_engine"] = "captions" if vtts else engine
236
- (vdir / "meta.json").write_text(json.dumps(meta, indent=2))
237
- return json.loads(out.read_text())
238
-
239
-
240
- def available(vdir: Path) -> dict[str, Path]:
241
- return {p.stem.split(".", 1)[1]: p for p in sorted(vdir.glob("transcript.*.json"))}
242
-
243
-
244
- def compare(engines: tuple[str, str] = ("whisper", "gemma")) -> list[dict]:
245
- """Word error rate of one engine's transcript against the other, per video (no ground truth here,
246
- so this is agreement, not accuracy)."""
247
- import jiwer
248
-
249
- norm = lambda cues: re.sub(r"[^a-z0-9' ]+", " ", " ".join(c["text"] for c in cues).lower())
250
- rows = []
251
- for vdir in sorted(p for p in VIDEOS_DIR.iterdir() if (p / "meta.json").exists()):
252
- paths = available(vdir)
253
- if not all(e in paths for e in engines):
254
- continue
255
- ref, hyp = (norm(json.loads(paths[e].read_text())) for e in engines)
256
- out = jiwer.process_words(ref, hyp)
257
- meta = json.loads((vdir / "meta.json").read_text())
258
- rows.append({
259
- "video_id": vdir.name,
260
- "title": meta["title"],
261
- "wer": round(out.wer, 4),
262
- "words": {engines[0]: len(ref.split()), engines[1]: len(hyp.split())},
263
- "seconds": meta.get("transcribe_seconds", {}),
264
- })
265
- return rows
266
-
267
-
268
- def run(engine: str = ASR_ENGINE, force: bool = False) -> None:
269
- console.print(f"[bold]Transcribing with {engine}[/bold] ({ENGINE_MODELS[engine]})")
270
- for vdir in sorted(p for p in VIDEOS_DIR.iterdir() if (p / "video.mp4").exists()):
271
- t0 = time.time()
272
- cues = ensure_transcript(vdir, engine=engine, force=force)
273
- words = sum(len(c["text"].split()) for c in cues)
274
- console.print(f" {vdir.name}: {len(cues)} cues, {words} words ({time.time() - t0:.0f}s)")