Fast-Hebrew-ASR / longform.py
niobures's picture
fast-hebrew-asr
ace6209 verified
Raw History Blame Contribute Delete
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]
@torch.inference_mode()
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()