Downloads · 30 days
47
41% of all-time downloads
mohammedaly22/Metro-ASR-Small
Metro-ASR-Small is a automatic speech recognition model from mohammedaly22. Use it when you need speech turned into text. It is set up for metro-asr. The card lists the license as mit.
<p align="center" <img src="images/banner.png" alt="Metro-ASR banner" width="100%" </p
Downloads · 30 days
47
41% of all-time downloads
All-time downloads
116
Public
Repo size
7.1 GB
Likes
2
Trending 1
Click a slice to open those files.
.bin6.4 GB · 90%
From the Hugging Face model README
Metro-ASR separates the two things a speech recogniser has to know — what the audio sounds like and what the words are likely to be — into two artefacts trained and shipped separately. This repository holds the first one: Metro-Small, a 61.6M-parameter Conformer acoustic model trained with CTC. It is non-autoregressive — one forward pass turns an utterance into a matrix of per-frame log-probabilities, with no decoder loop and no dependence on previously emitted tokens — which is why it runs at 40–55× real time on a laptop CPU with no GPU involved anywhere in this card's examples.
The second artefact, the language model, is an n-gram over text. It never sees audio, trains
in minutes on a laptop, and plugs into the decoder at run time — including domain-specific
variants you build yourself from nothing but text (see
The language head below). The general-purpose one is included in this
repository as lm_5gram.bin.
For the full architecture writeup, training-from-scratch instructions, and how to build your own domain language head, see the GitHub repository. For real transcripts, audio players, and a measured comparison of greedy vs. beam+LM decoding across three interchangeable language heads, see the interactive evaluation report.
<p align="center"> <img src="images/architecture.svg" alt="Metro-ASR architecture" width="100%"> </p>Twelve identical Conformer-style blocks, each a Macaron sandwich of two half-weighted feed-forward networks around an attention module and a convolution module:
<p align="center"> <img src="images/metro-block.svg" alt="Inside one Metro block" width="100%"> </p>| Component | Details | Why |
|---|---|---|
| Encoder | Conformer, 12 layers, d_model=384, 6 heads | — |
| Position encoding | RoPE (rotary), bias-free Q/K projections | attention depends on relative offset, so it doesn't break on utterances longer than any seen in training |
| Feed-forward | SwiGLU, expansion 3×, applied twice at half weight | a learned gate beats a plain ReLU/GELU FFN at equal parameter count |
| Normalization | RMSNorm, pre-norm | cheaper than LayerNorm, keeps gradients well-behaved as depth grows |
| Convolution | SE-gated depthwise separable, kernel 31 | local context (~1.24 s) to complement attention's global view |
| Regularization | Stochastic depth, rate 0.05 | deeper layers dropped more often during training |
| Auxiliary loss | Intermediate CTC at layer 6, weight 0.3 | mid-stack layers get gradient directly; discarded at inference |
| Tokenizer | BPE (SentencePiece), vocab 5,000 | trained on a deliberately balanced Arabic/English corpus so English words survive as whole tokens |
| Decoding | CTC greedy, or beam search + KenLM | see below |
| Parameters | 61,586,320 (61.6M) | — |
Frame rate. 16 kHz audio → 80-bin log-Mel (100 fps) → Conv2D ×4 subsampling → 25 fps through the encoder and the CTC head. Subsampling by 4 before the first block cuts attention's quadratic cost 16× before a single block runs; one output token covers 40 ms of audio.
CTC's per-frame independence produces a specific, recognisable error pattern: doubled syllables, dropped affixes, malformed English fragments — the model heard correctly and wrote something that isn't a word. A word-level n-gram model fixes this during beam search, because it knows which sequences are plausible, without ever having heard a single second of audio:
<p align="center"> <img src="images/decoding.svg" alt="Greedy vs. beam search with the language head" width="100%"> </p>The language head is a separate file with no learned interaction with the acoustic weights.
Swap it, and nothing about model.pt changes:
engine = MetroASREngine.from_pretrained("small")
engine.load_lm("lm/medical_head.bin") # swap language heads at run time,
print(engine.transcribe("call.wav", beam_search=True).text) # same acoustic weights throughout
This is measured, not asserted. The GitHub repo ships two extra language heads — technical and medical — built from real text (Egyptian medical chat/QA, Egyptian Arabic Wikipedia's technical articles, real Arabic-English code-switching text) plus synthesised domain-term carrier phrases, and decodes 11 real clips with all three heads against human references. Headline results from the full interactive report:
| Test set | Greedy | General head | Technical head | Medical head |
|---|---|---|---|---|
| Technical clips (WER) | 34.8% | 26.2% | 24.1% | 26.2% |
| Medical clips (WER) | 44.9% | 34.7% | 38.8% | 34.7% |
| General speech (WER) | 25.6% | 24.7% | 36.9% | 32.5% |
Two things worth reading out of that table. The domain heads win in their own domain despite being 4-grams built from far less text than the general 5-gram — domain fit beats scale for this component. And the technical head actively hurts on general speech (36.9% vs. greedy's 25.6%) — a language head is a strong prior, and matching it to your traffic matters. (These numbers are from a small, hard 11-clip demo set — unscripted, overlapping speech, dense code-switching — and are not the same evaluation as the Performance section below; see the report for methodology.)
Building your own head takes text and minutes, no GPU:
python scripts/train_lm.py --corpus my_domain.txt --out lm/my_domain_4gram.arpa --order 4
Full walkthrough — including a from-scratch corpus-building example for a technical and a medical domain — in the GitHub README's Domain-specialised heads section.
Three sizes share one block definition and one training recipe — only width, depth and vocabulary change. Small (this repository) is the only one currently trained. Medium and Large exist as configs in the GitHub repo with exact parameter counts, but no weights — see Scaling to Medium and Large for what training them actually requires (data volume most of all).
| Params | d_model | Layers | BPE vocab | Status | |
|---|---|---|---|---|---|
| Small | 61.6M | 384 | 12 | 5,000 | Released — this repo |
| Medium | 247.4M | 512 | 24 | 8,000 | Config only |
| Large | 747.8M | 768 | 32 | 16,000 | Config only |
Held-out test-set WER/CER (the numbers in this card's metadata):
| Split | WER (%) | CER (%) |
|---|---|---|
| All | 46.85 | 28.41 |
| Arabic only | 37.24 | 17.45 |
| Code-switching | 36.32 | 17.44 |
Speed — measured, Intel Core Ultra 7 155H, 4 CPU threads, PyTorch 2.13 CPU build, fp32, minimum of 15 runs after warm-up:
<p align="center"> <img src="images/latency.svg" alt="Measured CPU latency" width="100%"> </p>| Audio length | Latency | RTF | Faster than real time |
|---|---|---|---|
| 1 s | 37 ms | 0.037 | 27× |
| 5 s | 99 ms | 0.020 | 51× |
| 10 s | 181 ms | 0.018 | 55× |
| 30 s | 686 ms | 0.023 | 44× |
Beam search with the 5-gram head adds roughly 10–250 ms per utterance depending on length (RTF ≈ 0.024 overall). Loading the 5.9 GB binary itself takes about 3.4 s, once, at startup.
pip install metro-asr # greedy decoding only
pip install "metro-asr[lm]" # + KenLM beam search
pip install -U "numpy>=2.0" # see note below
[!WARNING]
pyctcdecode's only PyPI release pinsnumpy<2.0.0in its own metadata, even though it runs fine under numpy 2.x. Installing the[lm]extra will downgrade numpy to satisfy that — on an environment that already had numpy 2.x with other packages built against it (Colab, most fresh installs today), that breaks those packages withnumpy.dtype size changed. The third line above fixes it. If[lm]isn't installed at all,lm_path="auto"now degrades to greedy with a warning rather than crashing engine construction.
from metro_asr import MetroASREngine
engine = MetroASREngine.from_pretrained("small") # auto-downloads weights + tokenizer, caches locally
result = engine.transcribe("audio.wav")
print(result.text)
The 5.9 GB language model is not downloaded by this call.
engine = MetroASREngine.from_pretrained("small", lm_path="auto") # also fetches lm_5gram.bin
result = engine.transcribe("audio.wav", beam_search=True)
print(result.text)
If you've already run snapshot_download (or git cloned this repo) into a local directory,
point from_pretrained at that directory instead — nothing gets re-downloaded, and it works
fully offline:
from huggingface_hub import snapshot_download
from metro_asr import MetroASREngine
snapshot_download(repo_id="MohammedAly22/metro-asr-small", local_dir="checkpoints")
engine = MetroASREngine.from_pretrained("checkpoints", lm_path="auto")
[!TIP] This is the fix for a common mistake: calling
from_pretrained("checkpoints")used to be interpreted as a HuggingFace repo id named literally "checkpoints" and fail with Repository Not Found. Current versions check for an existing local directory first — update if you hit that error.
import torch
from metro_asr.utils.config import load_config
from metro_asr.model.metro import MetroASR
from metro_asr.model.tokenizer import build_tokenizer
config = load_config("checkpoints/config.yaml")
tokenizer = build_tokenizer(config, "checkpoints") # must run before MetroASR.from_config —
model = MetroASR.from_config(config) # it fixes the CTC head's vocab size
ckpt = torch.load("checkpoints/model.pt", map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model_state_dict"])
model.eval()
results = engine.transcribe_batch(["audio1.wav", "audio2.wav", "audio3.wav"])
for r in results:
print(f"{r.text} (RTF={r.rtf:.4f})")
engine.transcribe_stream(chunk_generator) yields incremental transcriptions from any audio
generator, and scripts/serve.py in the GitHub repo wraps the same engine in a Flask REST API
(/transcribe, /transcribe/batch, /health, /info). See the README's
Streaming and
Serving sections, or the runnable
streaming_server.ipynb
notebook.
| File | Description | Size |
|---|---|---|
model.pt | Checkpoint — weights + AdamW optimizer state | 705 MB |
config.yaml | Model architecture configuration | <1 KB |
bpe.model | SentencePiece BPE tokenizer | 316 KB |
bpe.vocab | Human-readable vocabulary listing | 70 KB |
lm_5gram.bin | KenLM 5-gram general-purpose language head (optional) | 5.9 GB |
Weights alone are 235 MB; model.pt is larger because it also carries optimizer state so
training can be resumed from it. Strip that for deployment:
import torch
ckpt = torch.load("model.pt", map_location="cpu", weights_only=False)
torch.save({"model_state_dict": ckpt["model_state_dict"], "config": ckpt["config"]},
"model_inference.pt")
Full step-by-step instructions to reproduce this from scratch — tokenizer training, language model training, data preparation, the acoustic training loop, and how to scale the recipe to Medium/Large — are in the GitHub README's Training from scratch section.
Adapting to a new domain or accent starts from these weights, not from scratch — 10-20× lower learning rate, encoder frozen for the first few thousand steps so the CTC head adapts first:
python scripts/finetune.py \
--checkpoint checkpoints/model.pt \
--tokenizer-dir checkpoints \
--dataset your/dataset-id \
--lr 5e-5 --max-steps 30000 --freeze-steps 3000
Runnable end-to-end in fine_tuning.ipynb (Colab, needs a GPU), or see Fine-tuning in the README.
CNN, MRI, AIC) are acoustically ambiguous and
unreliable without a matching language head.transcribe_stream is chunked offline decoding,
with a floor latency of one chunk.@software{metro_asr_2025,
title = {Metro-ASR: Non-Autoregressive Speech Recognition for Egyptian Arabic
and Code-Switching with a Detachable N-gram Language Head},
author = {Mohammed Aly},
year = {2025},
url = {https://github.com/MohammedAly22/metro-asr}
}
MIT — see LICENSE.