Download example.py from FM-1976/eg2-voice-reader-a4: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/FM-1976/eg2-voice-reader-a4/resolve/main/example.py
- Command line
-
hf download hf://FM-1976/eg2-voice-reader-a4/example.py
-
curl -L -o example.py https://huggingface.co/FM-1976/eg2-voice-reader-a4/resolve/main/example.py
10.6 kB
| #!/usr/bin/env python3 | |
| """Standalone transcription demo for the eg2-voice CTC reader (13.3 M parameters). | |
| Transcribes one 16 kHz mono WAV/FLAC file end to end, 100% locally (CPU by default): | |
| 1. EmbeddingGemma 2 audio encoder -> soft tokens [T, 512], 25 tokens per second | |
| 2. this reader -> per-frame scores against 200,000 word vectors + a learned blank logit | |
| 3. greedy CTC decode (argmax, merge repeats, drop blanks) -> words | |
| Requirements (no repo needed — this file is self-contained): | |
| pip install torch numpy soundfile safetensors "transformers>=5.19" | |
| (scipy only if your input is not 16 kHz; ffmpeg only for non-libsndfile formats) | |
| The EmbeddingGemma 2 base model is loaded from a local snapshot directory (recommended) | |
| or downloaded from the hub on first use (several GB): | |
| python example.py clip.flac --eg2 /path/to/embeddinggemma-2 | |
| python example.py clip.flac --eg2 google/embeddinggemma-2 | |
| """ | |
| import argparse | |
| import math | |
| import os | |
| import time | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| D_IN, D_OUT = 512, 768 | |
| SR = 16000 | |
| # ------------------------------------------------------------------ the audio contract | |
| def n_frames_for(n_samples): | |
| """Valid mel frames: frame i needs samples up to 160 * i + 160.""" | |
| return max(0, (n_samples - 161) // 160 + 1) | |
| def n_tokens_for(n_frames): | |
| """ceil(ceil(n_frames / 2) / 2): two stride-2 convolutions.""" | |
| return ((n_frames + 1) // 2 + 1) // 2 | |
| # ------------------------------------------------------------------ the reader architecture | |
| # (exact copy of the trained model definition; the checkpoint loads with strict=True) | |
| def key_mask(lengths, T): | |
| """[B, 1, 1, T] bool, True = a real token (attend), for scaled_dot_product_attention.""" | |
| return (torch.arange(T, device=lengths.device)[None, :] < lengths[:, None])[:, None, None, :] | |
| def rope(T, dim, base, device): | |
| inv = 1.0 / (base ** (torch.arange(0, dim, 2, device=device, dtype=torch.float32) / dim)) | |
| f = torch.arange(T, device=device, dtype=torch.float32)[:, None] * inv[None, :] | |
| emb = torch.cat([f, f], dim=-1) | |
| return emb.cos(), emb.sin() | |
| def rotate_half(x): | |
| x1, x2 = x.chunk(2, dim=-1) | |
| return torch.cat([-x2, x1], dim=-1) | |
| class Block(nn.Module): | |
| def __init__(self, d, heads, ffn): | |
| super().__init__() | |
| self.heads = heads | |
| self.ln1 = nn.LayerNorm(d) | |
| self.qkv = nn.Linear(d, 3 * d) | |
| self.proj = nn.Linear(d, d) | |
| self.ln2 = nn.LayerNorm(d) | |
| self.ff1 = nn.Linear(d, ffn) | |
| self.ff2 = nn.Linear(ffn, d) | |
| def forward(self, x, mask, cos, sin): | |
| B, T, D = x.shape | |
| q, k, v = self.qkv(self.ln1(x)).view(B, T, 3, self.heads, D // self.heads).permute(2, 0, 3, 1, 4) | |
| q = q * cos + rotate_half(q) * sin | |
| k = k * cos + rotate_half(k) * sin | |
| a = F.scaled_dot_product_attention(q, k, v, attn_mask=mask) | |
| x = x + self.proj(a.transpose(1, 2).reshape(B, T, D)) | |
| return x + self.ff2(F.gelu(self.ff1(self.ln2(x)))) | |
| class ReaderA(nn.Module): | |
| """Pre-LN bidirectional transformer: LN -> Linear 512->512 -> N blocks -> LN -> Linear 512->768.""" | |
| def __init__(self, d=512, layers=4, heads=8, ffn=2048, rope_base=10000.0): | |
| super().__init__() | |
| self.heads, self.rope_base = heads, rope_base | |
| self.ln_in = nn.LayerNorm(D_IN) | |
| self.inp = nn.Linear(D_IN, d) | |
| self.blocks = nn.ModuleList([Block(d, heads, ffn) for _ in range(layers)]) | |
| self.ln_out = nn.LayerNorm(d) | |
| self.out = nn.Linear(d, D_OUT) | |
| def forward(self, x, lengths): | |
| T = x.shape[1] | |
| mask = key_mask(lengths, T) | |
| cos, sin = rope(T, self.inp.out_features // self.heads, self.rope_base, x.device) | |
| h = self.inp(self.ln_in(x)) | |
| for blk in self.blocks: | |
| h = blk(h, mask, cos, sin) | |
| return self.out(self.ln_out(h)) | |
| class Reader(nn.Module): | |
| """Body + linear blank head + learned log_scale (scale = exp(log_scale), starts at 20).""" | |
| def __init__(self, layers=4): | |
| super().__init__() | |
| self.body = ReaderA(layers=layers) | |
| self.blank = nn.Linear(D_OUT, 1) | |
| self.log_scale = nn.Parameter(torch.tensor(math.log(20.0))) | |
| def scale(self): | |
| return self.log_scale.exp() | |
| def forward(self, x, lengths): | |
| """-> unit vectors [B, T, 768], blank logits [B, T].""" | |
| v = self.body(x, lengths) | |
| return F.normalize(v, dim=-1), self.blank(v).squeeze(-1) | |
| def greedy_labels(h, blank, table, scale, chunk=1024): | |
| """h [M, D] unit vectors, blank [M] -> per-frame class [M] (blank = index N). | |
| A frame goes to the best word only when scale * cos(h, word) beats the frame's blank logit.""" | |
| M = h.shape[0] | |
| if M == 0: | |
| return torch.zeros(0, dtype=torch.long, device=h.device) | |
| out = [] | |
| for a in range(0, M, chunk): | |
| v, i = (h[a:a + chunk] @ table.T).max(dim=1) | |
| out.append(torch.where(blank[a:a + chunk] > v * scale, torch.full_like(i, table.shape[0]), i)) | |
| return torch.cat(out) | |
| def collapse(labels, blank_id): | |
| """Greedy CTC read-out: merge repeats, drop blanks. labels: 1-D sequence of ints.""" | |
| out, prev = [], None | |
| for c in labels: | |
| if c != prev and c != blank_id: | |
| out.append(c) | |
| prev = c | |
| return out | |
| # ------------------------------------------------------------------ EmbeddingGemma 2 soft tokens | |
| def load_eg2(model_dir, device): | |
| """-> (feature extractor, EmbeddingGemma 2 audio+text model in float32 on device). | |
| transformers 5.19.0 contract: float32 (never float16), default/sdpa attention (never eager) — | |
| the eager audio attention reads the wrong positions in this version.""" | |
| from transformers import EmbeddingGemma2Model, Gemma4AudioFeatureExtractor | |
| fe = Gemma4AudioFeatureExtractor.from_pretrained(model_dir) | |
| model = EmbeddingGemma2Model.from_pretrained(model_dir, dtype=torch.float32, vision_config=None) | |
| model = model.to(device).eval() | |
| impl = getattr(model.config.audio_config, "_attn_implementation", None) | |
| if impl == "eager": | |
| raise SystemExit("audio attention is eager; transformers 5.19.0 eager reads the wrong positions") | |
| return fe, model | |
| def soft_tokens(model, fe, wav, device): | |
| """-> soft tokens [T, 512] float32 numpy for one waveform (no 30 s cut). | |
| A one-item list, as the processor expects: a bare 1-D array fails in transformers 5.19.0.""" | |
| out = fe([np.asarray(wav, dtype=np.float32)], return_tensors="np", max_length=None, truncation=False) | |
| feats = torch.from_numpy(out["input_features"][0].astype(np.float32))[None].to(device) | |
| mask = torch.from_numpy(out["input_features_mask"][0].astype(bool))[None].to(device) | |
| n_frames = n_frames_for(len(wav)) | |
| if int(mask.sum()) != n_frames: | |
| raise AssertionError(f"extractor mask {int(mask.sum())} != {n_frames} frames") | |
| res = model.get_audio_features(feats, mask, return_dict=True) | |
| soft = res.pooler_output[0][res.attention_mask[0]].float().cpu().numpy() | |
| if soft.shape[0] != n_tokens_for(n_frames): | |
| raise AssertionError(f"{soft.shape[0]} tokens != {n_tokens_for(n_frames)}") | |
| return soft | |
| # ------------------------------------------------------------------ the demo | |
| def load_audio(path): | |
| """-> float32 mono 16 kHz in [-1, 1].""" | |
| import soundfile as sf | |
| wav, sr = sf.read(path, dtype="float32", always_2d=False) | |
| if wav.ndim == 2: | |
| wav = wav.mean(axis=1) | |
| wav = np.asarray(wav, dtype=np.float32) | |
| np.clip(wav, -1.0, 1.0, out=wav) | |
| if sr != SR: | |
| try: | |
| from scipy.signal import resample_poly | |
| from math import gcd | |
| g = gcd(SR, int(sr)) or 1 | |
| wav = np.asarray(resample_poly(wav, SR // g, sr // g), dtype=np.float32) | |
| except ImportError: | |
| raise SystemExit(f"input is {sr} Hz; install scipy to resample, or provide 16 kHz audio") | |
| return wav | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("audio", help="WAV/FLAC file (16 kHz mono recommended)") | |
| ap.add_argument("--eg2", default=os.environ.get("EG2_MODEL", "google/embeddinggemma-2"), | |
| help="EmbeddingGemma 2 snapshot dir or hub id (default: env EG2_MODEL or the hub)") | |
| ap.add_argument("--reader", default=os.path.dirname(os.path.abspath(__file__)), | |
| help="dir with reader.safetensors, vocab.txt, word_table.npy (default: beside this file)") | |
| ap.add_argument("--device", default="cpu") | |
| args = ap.parse_args() | |
| t0 = time.perf_counter() | |
| print(f"loading EmbeddingGemma 2 from {args.eg2} ...", flush=True) | |
| fe, eg2 = load_eg2(args.eg2, args.device) | |
| reader_dir = args.reader | |
| import json | |
| with open(os.path.join(reader_dir, "config.json"), encoding="utf-8") as f: | |
| cfg = json.load(f) | |
| with open(os.path.join(reader_dir, cfg["vocab"]), encoding="utf-8") as f: | |
| vocab = [line.rstrip("\n") for line in f if line.strip()] | |
| table = torch.from_numpy(np.load(os.path.join(reader_dir, cfg["table"]["file"])).astype(np.float32)) | |
| table = F.normalize(table.to(args.device), dim=-1) | |
| model = Reader(layers=cfg["architecture"]["layers"]) | |
| from safetensors.torch import load_file | |
| model.load_state_dict(load_file(os.path.join(reader_dir, "reader.safetensors")), strict=True) | |
| model = model.to(args.device).eval() | |
| n = sum(p.numel() for p in model.parameters()) | |
| print(f"reader ready: {n:,} params, {len(vocab):,} words, scale {model.scale.item():.2f} " | |
| f"({time.perf_counter() - t0:.0f} s)", flush=True) | |
| wav = load_audio(args.audio) | |
| if len(wav) / SR < 0.15: | |
| raise SystemExit("audio too short (< 0.15 s)") | |
| t0 = time.perf_counter() | |
| tokens = soft_tokens(eg2, fe, wav, args.device) | |
| enc_s = time.perf_counter() - t0 | |
| t0 = time.perf_counter() | |
| x = torch.from_numpy(tokens[None]).to(args.device) | |
| lengths = torch.tensor([x.shape[1]], dtype=torch.long, device=args.device) | |
| with torch.no_grad(): | |
| h, blank = model(x, lengths) | |
| labels = greedy_labels(h[0], blank[0], table, model.scale).cpu().tolist() | |
| words = collapse(labels, len(vocab)) | |
| text = " ".join(vocab[i] for i in words) | |
| dec_s = time.perf_counter() - t0 | |
| print(f"encode {enc_s:.2f} s | decode {dec_s:.2f} s | {tokens.shape[0]} tokens " | |
| f"({len(wav) / SR:.1f} s of audio)") | |
| print(text) | |
| if __name__ == "__main__": | |
| main() | |