Download longform.py from niobures/Fast-Hebrew-ASR: direct link, hf CLI and curl.
- Browser
- Download file 8.39 kB
-
https://huggingface.co/niobures/Fast-Hebrew-ASR/resolve/main/longform.py
- Command line
-
hf download hf://niobures/Fast-Hebrew-ASR/longform.py
-
curl -L -o longform.py https://huggingface.co/niobures/Fast-Hebrew-ASR/resolve/main/longform.py
8.39 kB
| #!/usr/bin/env python3 | |
| """ | |
| Long-form transcription for the Hebrew GigaAM CTC model, without pyannote. | |
| GigaAM ships `transcribe_longform`, but it imports `vad_utils`, which needs the | |
| `longform` extra -- and that extra pins torch==2.10 / torchcodec==0.10. Installing | |
| it would downgrade torch underneath a running training job, and it also drags in | |
| a gated pyannote segmentation model. Neither is acceptable here. | |
| So: energy-based segmentation, numpy and torch only, nothing new installed. | |
| This is not a learned VAD and does not try to be. Its only job is to find | |
| *silences deep enough to cut at*, so no word is severed and no chunk exceeds | |
| what the model was trained on (<=20 s). For that, energy is enough. | |
| python longform.py --checkpoint ... --manifest ... [--device cuda] | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from typing import List, Sequence, Tuple | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import gigaam # noqa: E402 | |
| from gigaam.utils import normalize_raw_text # noqa: E402 | |
| SAMPLE_RATE = 16000 | |
| def frame_energy_db(x: np.ndarray, win: int, hop: int) -> np.ndarray: | |
| n = 1 + max(0, (len(x) - win) // hop) | |
| if n <= 0: | |
| return np.zeros(0, dtype="float32") | |
| idx = np.arange(win)[None, :] + hop * np.arange(n)[:, None] | |
| frames = x[idx] | |
| rms = np.sqrt(np.maximum((frames ** 2).mean(axis=1), 1e-12)) | |
| return 20.0 * np.log10(rms) | |
| def speech_mask( | |
| x: np.ndarray, | |
| hop_s: float = 0.010, | |
| win_s: float = 0.030, | |
| rel_db: float = 35.0, | |
| min_speech_s: float = 0.20, | |
| min_silence_s: float = 0.30, | |
| ) -> np.ndarray: | |
| """Per-frame speech/silence, thresholded relative to the clip's own loud parts. | |
| A fixed dBFS threshold does not survive contact with real recordings -- a | |
| quiet WhatsApp note and a loud podcast differ by tens of dB. Thresholding | |
| relative to this clip's 95th-percentile energy adapts to both. | |
| """ | |
| win, hop = int(win_s * SAMPLE_RATE), int(hop_s * SAMPLE_RATE) | |
| e = frame_energy_db(x, win, hop) | |
| if not len(e): | |
| return np.zeros(0, dtype=bool) | |
| loud = np.percentile(e, 95) | |
| mask = e > (loud - rel_db) | |
| # Bridge short gaps, then drop short blips. Order matters: closing before | |
| # opening keeps a brief inter-word pause from fragmenting one word run. | |
| def runs(m: np.ndarray, value: bool): | |
| out, start = [], None | |
| for i, v in enumerate(m): | |
| if v == value and start is None: | |
| start = i | |
| elif v != value and start is not None: | |
| out.append((start, i)); start = None | |
| if start is not None: | |
| out.append((start, len(m))) | |
| return out | |
| for s, e_ in runs(mask, False): | |
| if (e_ - s) * hop_s < min_silence_s: | |
| mask[s:e_] = True | |
| for s, e_ in runs(mask, True): | |
| if (e_ - s) * hop_s < min_speech_s: | |
| mask[s:e_] = False | |
| return mask | |
| def segment_audio( | |
| x: np.ndarray, | |
| max_len_s: float = 18.0, | |
| min_len_s: float = 1.0, | |
| pad_s: float = 0.15, | |
| hop_s: float = 0.010, | |
| ) -> List[Tuple[float, float]]: | |
| """Return [(start_s, end_s)] chunks, each <= max_len_s, cut inside silence.""" | |
| total_s = len(x) / SAMPLE_RATE | |
| if total_s <= max_len_s: | |
| return [(0.0, total_s)] | |
| mask = speech_mask(x, hop_s=hop_s) | |
| if not mask.any(): | |
| return [(0.0, total_s)] | |
| # Speech regions, then split any that is still too long at its quietest point. | |
| regions, start = [], None | |
| for i, v in enumerate(mask): | |
| if v and start is None: | |
| start = i | |
| elif not v and start is not None: | |
| regions.append((start * hop_s, i * hop_s)); start = None | |
| if start is not None: | |
| regions.append((start * hop_s, len(mask) * hop_s)) | |
| win = int(0.030 * SAMPLE_RATE) | |
| hop = int(hop_s * SAMPLE_RATE) | |
| e = frame_energy_db(x, win, hop) | |
| out: List[Tuple[float, float]] = [] | |
| queue = list(regions) | |
| while queue: | |
| s, t = queue.pop(0) | |
| if t - s <= max_len_s: | |
| if t - s >= min_len_s or not out: | |
| out.append((s, t)) | |
| else: | |
| # Too short to stand alone: glue onto the previous chunk. | |
| ps, pt = out[-1] | |
| if s - pt < 2.0 and t - ps <= max_len_s: | |
| out[-1] = (ps, t) | |
| else: | |
| out.append((s, t)) | |
| continue | |
| # Cut at the quietest frame in the middle third, so we never sever a word | |
| # at an arbitrary fixed offset. | |
| a, b = int((s + (t - s) / 3) / hop_s), int((s + 2 * (t - s) / 3) / hop_s) | |
| a, b = max(a, 0), min(b, len(e)) | |
| cut = (a + int(np.argmin(e[a:b]))) * hop_s if b > a else (s + t) / 2 | |
| queue.insert(0, (cut, t)) | |
| queue.insert(0, (s, cut)) | |
| # Pad for a little context, clamped to the clip. | |
| return [(max(0.0, s - pad_s), min(total_s, t + pad_s)) for s, t in out] | |
| def transcribe_long(model, path: str, max_len_s: float = 18.0, batch_size: int = 8) -> str: | |
| x, sr = sf.read(path, dtype="float32") | |
| if x.ndim > 1: | |
| x = x.mean(axis=1) | |
| if sr != SAMPLE_RATE: | |
| import torchaudio.functional as taF | |
| x = taF.resample(torch.from_numpy(x), sr, SAMPLE_RATE).numpy() | |
| # Telephony scoring (GIGAAM_TELEPHONY_P=1): degrade BEFORE segmentation, so | |
| # the energy VAD sees phone-band audio, which is what it faces in production. | |
| _tel = float(os.getenv("GIGAAM_TELEPHONY_P", "0")) | |
| if _tel > 0: | |
| from gigaam.utils import telephony_degrade | |
| x = telephony_degrade(torch.from_numpy(np.ascontiguousarray(x)).float()).numpy() | |
| spans = segment_audio(x, max_len_s=max_len_s) | |
| chunks = [torch.from_numpy(x[int(s * SAMPLE_RATE): int(t * SAMPLE_RATE)]) for s, t in spans] | |
| chunks = [c for c in chunks if len(c) >= int(0.1 * SAMPLE_RATE)] | |
| if not chunks: | |
| return "" | |
| texts: List[str] = [] | |
| dev = model._device | |
| for i in range(0, len(chunks), batch_size): | |
| batch = chunks[i: i + batch_size] | |
| lens = torch.tensor([len(c) for c in batch], dtype=torch.long) | |
| pad = torch.zeros(len(batch), int(lens.max()), dtype=torch.float32) | |
| for j, c in enumerate(batch): | |
| pad[j, : len(c)] = c | |
| enc, enc_len = model.forward(pad.to(dev).to(model._dtype), lens.to(dev)) | |
| for text, _ in model._decode(enc, enc_len, lens.to(dev), False): | |
| if text.strip(): | |
| texts.append(text.strip()) | |
| return " ".join(texts) | |
| def main() -> None: | |
| ap = argparse.ArgumentParser(description=__doc__) | |
| ap.add_argument("--checkpoint", required=True) | |
| ap.add_argument("--manifest", required=True) | |
| ap.add_argument("--device", default="cuda") | |
| ap.add_argument("--max-len", type=float, default=18.0) | |
| ap.add_argument("--batch-size", type=int, default=8) | |
| ap.add_argument("--limit", type=int, default=0) | |
| ap.add_argument("--out", default=None) | |
| args = ap.parse_args() | |
| import editdistance | |
| import time | |
| base = Path(args.manifest).parent | |
| rows = [] | |
| with open(args.manifest, encoding="utf-8") as f: | |
| f.readline() | |
| for line in f: | |
| p = line.rstrip("\n").split("\t") | |
| if len(p) >= 3: | |
| rows.append((base / p[0], float(p[1]), p[2])) | |
| if args.limit: | |
| rows = rows[: args.limit] | |
| model = gigaam.load_model(args.checkpoint, device=args.device) | |
| we = wt = ce = ct = 0 | |
| results = [] | |
| t0 = time.time() | |
| for path, dur, ref in rows: | |
| hyp = transcribe_long(model, str(path), args.max_len, args.batch_size) | |
| r, h = normalize_raw_text(ref), normalize_raw_text(hyp) | |
| we += editdistance.eval(r.split(), h.split()); wt += len(r.split()) | |
| ce += editdistance.eval(r, h); ct += len(r) | |
| results.append({"path": str(path), "duration": dur, "ref": r, "hyp": h}) | |
| wall = time.time() - t0 | |
| audio = sum(r[1] for r in rows) | |
| print(f"clips : {len(rows)} audio {audio/3600:.2f} h") | |
| print(f"WER : {100*we/max(wt,1):.2f}% ({we}/{wt})") | |
| print(f"CER : {100*ce/max(ct,1):.2f}% ({ce}/{ct})") | |
| print(f"wall : {wall:.1f}s ({audio/wall:.1f}x realtime)") | |
| if args.out: | |
| with open(args.out, "w", encoding="utf-8") as f: | |
| for r in results: | |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") | |
| if __name__ == "__main__": | |
| main() | |