Delete vrag/transcribe.py with huggingface_hub
Browse files- 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)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|