Speech Transcription via Regularized Encoder-Acoustic Modeling, a from-scratch, streaming-capable automatic speech recognition system.
STREAM ASR is trained end to end on the full LibriSpeech 960 h corpus
(train-clean-100 + train-clean-360 + train-other-500) on a single 12 GB consumer GPU
(RTX 5070, Blackwell sm_120). Every component is implemented in pure PyTorch, with no k2/icefall
dependency, no pretrained weights, and no external decoder.
| Metric | Value |
|---|---|
| WER, test-clean | 3.43 % offline / 4.45 % streaming |
| WER, test-other | 9.17 % offline / 12.24 % streaming |
| Model | 55.3 M parameters (Zipformer encoder + RNN-T head) |
| Real-time factor | 0.038 offline / 0.074 streaming, single GPU |
| Streaming latency | 17 ms to first partial, 0.26 s to finalize |
| Training cost | ~21 h wall for the whole pipeline on one RTX 5070 |
Measured end to end, the pipeline is a single overnight run:
| Stage | Wall clock |
|---|---|
| BEST-RQ encoder pretrain | 6 h 20 m |
| Transducer (175k batches) | 6 h 15 m |
| STREAM-LM (70k steps) | 5 h 40 m |
| Feature extraction, CMVN, checkpoint averaging, both evals | ~2-3 h |
| Total | ~21 h |
training.transducer.total_steps counts loader batches, not optimizer updates: at
grad_accum=4 the 175k figure is 43,750 updates, and at ≤18,000 frames (180 s of audio) per batch
the run sees 8,750 h of audio — 9.1 passes over the 961 h corpus. The reference icefall recipe
trains 50 epochs on 3× speed-perturbed data, ~150 effective passes. That gap, not architecture, is
the dominant term in the remaining WER.
The design target was 6 to 8 % offline and 8 to 10 % streaming WER at RTF below 0.3. Both paths beat it.
Results · How it compares · Quickstart · Architecture · Layout · Demo · Implementation notes · License
Both LibriSpeech conditions, decoded with transducer_avg.pt, rescoring weights tuned on the
matching dev split and never on test. RTF and latency come from a contention-free timing pass.
test-clean, n = 2,620 (52,576 reference words), α = 0.6, β = 0.2 offline / 0.3 streaming:
| Stage | Offline WER / CER | Streaming WER / CER | RTF (offline / streaming) |
|---|---|---|---|
greedy_transducer |
4.55 % / 1.58 % | 6.15 % / 2.25 % | 0.009 / 0.042 |
beam |
4.38 % / 1.49 % | 5.84 % / 2.09 % | 0.035 / 0.071 |
beam_lm |
3.43 % / 1.19 % | 4.45 % / 1.62 % | 0.038 / 0.074 |
test-other, n = 2,939 (52,343 reference words), α = 0.6, β = 0.3 in both modes:
| Stage | Offline WER / CER | Streaming WER / CER | RTF (offline / streaming) |
|---|---|---|---|
greedy_transducer |
11.44 % / 5.04 % | 15.29 % / 6.92 % | 0.008 / 0.041 |
beam |
10.90 % / 4.76 % | 14.35 % / 6.44 % | 0.034 / 0.066 |
beam_lm |
9.17 % / 4.10 % | 12.24 % / 5.64 % | 0.035 / 0.067 |
Search, the LM, and the ILME subtraction all contribute in both conditions, and the LM earns more on the harder one. Beam over greedy is worth -0.17 / -0.31 abs on clean and -0.54 / -0.94 on other, with LM+ILME a further -0.95 / -1.39 and -1.73 / -2.11. The streaming-to-offline gap widens from 1.02 abs points on clean to 3.07 on other: the chunked causal encoder costs three times as much when the acoustics are hard. Streaming holds 14 to 17 ms to first partial. Finalize, meaning the post-audio search and rescore a live session still owes, takes 0.21 to 0.26 s with the LM and about 0.04 s without it.
Oracle floors and remaining headroom: how much is left in the beam
The n-best oracle WER is the floor any rescoring of that beam can reach, which separates LM headroom from insufficient search coverage. Measured on each dev split:
| dev acoustic-only | dev tuned | dev n-best oracle | |
|---|---|---|---|
| dev-clean offline / streaming | 4.46 % / 5.94 % | 3.43 % / 4.44 % | 2.47 % / 3.45 % |
| dev-other offline / streaming | 10.74 % / 14.22 % | 8.92 % / 11.95 % | 7.62 % / 10.49 % |
Roughly a point of in-beam headroom on clean, and 1.3 to 1.5 on other, goes unextracted by the rescorer. That is LM headroom rather than search coverage.
α landed on 0.6 in every one of the four sweeps, which is the top of the default
--lm-grid. The optimum may lie beyond it, so widening the grid is the cheapest untried experiment in the repo.
The design target (6 to 8 % offline, 8 to 10 % streaming) was set against test-clean, which both
paths beat. test-other sits above that band in every configuration.
Two different reference classes, and STREAM ASR sits in a different place against each.
Against large speech foundation models, the point is cost. These are general-purpose multilingual systems, they are not streaming, and they reach their accuracy through data and compute that a single consumer GPU cannot touch:
| Whisper large-v3 | Parakeet TDT 0.6B v2 | STREAM ASR v1.0 | |
|---|---|---|---|
| Parameters | 1.55 B | 600 M | 55.3 M |
| Training audio | ~1 M h weak + ~4 M h pseudo-labeled | ~120 k h (~10 k human-transcribed) | 960 h (LibriSpeech only) |
| Training hardware | undisclosed large-scale cluster | 64 × A100 (stage 1) | 1 × RTX 5070, 12 GB |
| Streaming | no (30 s windows) | no | yes, one set of weights |
| test-clean WER | 2.7 % | not reported | 3.43 % offline / 4.45 % streaming |
Against LibriSpeech-specialized streaming transducers, the point is that this is a from-scratch
reimplementation, and it is honestly behind the reference recipe. icefall's
pruned_transducer_stateless7_streaming is the same architecture family on the same 960 h at the
same 320 ms chunk (STREAM ASR's chunk_size: 16 base-rate frames = 320 ms of audio):
| icefall streaming Zipformer | Zipformer-L (non-streaming) | STREAM ASR v1.0 | |
|---|---|---|---|
| Parameters | 70.4 M | 148 M | 55.3 M |
| Streaming @ 320 ms, clean / other | 3.11 % / 7.93 % | not applicable | 4.45 % / 12.24 % |
| Offline, clean / other | not reported | 2.00 % / 4.38 % | 3.43 % / 9.17 % |
That puts STREAM ASR about 1.3 abs points behind the streaming reference on test-clean, at a
smaller parameter count and without a k2/icefall dependency. On test-other it is 4.3 points
behind, and 4.8 behind the offline reference. The clean-only comparison flatters this model
considerably. That widening gap is the honest cost of building every block (encoder, RNN-T loss,
beam search, LM) from scratch on one 12 GB card, and it says where the remaining work is: robustness
on noisy and accented speech rather than clean read speech.
The 14 to 17 ms first-partial figure above is compute time, not algorithmic latency. The algorithmic latency is the 320 ms chunk, the same as the icefall column.
What it cost to build: same task, wildly different budgets
The accuracy tables above are the fair comparison. This one is the interesting one:
| STREAM ASR | icefall Zipformer | Parakeet TDT 0.6B v2 | Whisper large-v3 | |
|---|---|---|---|---|
| Team | 1 person | k2-fsa/Xiaomi + academic collaborators | NVIDIA NeMo ASR team | OpenAI research + infra org |
| Calendar time | ~1 month | recipe: days. Toolkit behind it: years | months, atop years of NeMo | v1 to v3 across more than a year |
| Training hardware | 1 consumer GPU (12 GB) | typically 4 to 8 datacenter GPUs | 64 × A100 + 4 × A100 | undisclosed cluster |
| Audio data | 960 h, free (CC BY 4.0) | same 960 h, free | ~120 k h incl. ~10 k h human-transcribed | ~5 M h scraped + weak/pseudo-labeled |
| Compute bill | electricity on hardware already owned | one run is cheap to rerun, expensive to have invented | tens of thousands of A100-hours | undisclosed, at the top of this scale |
| Labeling bill | none, the corpus ships pre-transcribed | none | 10 k h of human transcription | web-scale weak-label pipeline |
Only the STREAM ASR column and the published model-card facts (GPU counts, data hours, licensing) are sourced. Team sizes, calendar time and anything about money are order-of-magnitude inference from public information, not disclosed figures. Read them as which power of ten, not as quotes.
The takeaway is not that this model is better. It is that a single-GPU, one-person, one-month build on free data lands within a couple of WER points of a research toolkit's reference recipe on clean speech, and that the gap that remains is concentrated somewhere specific and measurable.
Sources: Whisper large-v3, Parakeet TDT 0.6B v2, icefall LibriSpeech RESULTS.md, Zipformer (ICLR 2024).
Requires uv and a CUDA GPU. Python 3.12 is provisioned by uv, and the wheels come from the
CUDA 12.8 index.
uv venv .venv --python 3.12
uv pip install -r requirements.txt
.venv/bin/python scripts/verify_env.py # expect: OK: ... cap=(12, 0)
PYTHONPATH=. .venv/bin/python -m pytest -q # 213 passed, 2 deselected (slow GPU gates)Then decode a file with a trained checkpoint:
PYTHONPATH=. .venv/bin/python -m src.slices.Decode.streaming_decode AUDIO.flac --offline
PYTHONPATH=. .venv/bin/python -m src.slices.Decode.streaming_decode AUDIO.flac # streamingEvery flag, tuning knob and failure mode is documented in COMMANDS.md. The stages below are the short form.
Data preparation: four ordered scripts
The fp16 log-mel cache is the one-time cost (~53 GB) that lets the training epoch loop run GPU-bound instead of re-decoding FLAC every epoch.
PYTHONPATH=. .venv/bin/python scripts/build_manifests.py # 5-split manifests (train 281,241 utts)
PYTHONPATH=. .venv/bin/python scripts/train_tokenizer.py # BPE-500 on 960 h transcripts
PYTHONPATH=. .venv/bin/python scripts/compute_cmvn.py # global CMVN over a 15 % sample
PYTHONPATH=. .venv/bin/python scripts/precompute_features.py # fp16 log-mel mmap cacheTraining: pretrain, then transducer, then language model
Both models train against the same 960 h tokenizer.
# BEST-RQ self-supervised encoder pretrain -> data/checkpoints/bestrq_encoder.pt
.venv/bin/python -m src.slices.PretrainEncoder.pretrain_bestrq
# Transducer: encoder + predictor + joiner trained jointly under rnnt + ctc_aux + interctc losses.
# Warm-starts the encoder from bestrq_encoder.pt (training.transducer.warm_start).
.venv/bin/python -m src.slices.TrainAcousticModel.train_transducer
# STREAM-LM: fetch and pack the LibriSpeech-LM corpus (see COMMANDS.md Step 6), then train.
.venv/bin/python scripts/download_lm_text.py
.venv/bin/python -m src.slices.TrainLanguageModel.train_lm
# Monitor any run from a second terminal
.venv/bin/tensorboard --logdir runs/transducerEvery trainer is resumable and interrupt-safe. It atomically checkpoints *_last.pt (model, all
optimizers, RNG state, step) and auto-resumes from it, so you re-launch the same command after a
crash or interrupt. Ctrl-C is caught cooperatively: the loop finishes its step, checkpoints, and
exits cleanly. To force a fresh run, pass --fresh on the transducer and LM trainers, or
resume=False on the BEST-RQ command.
Checkpoint averaging is a required post-training step. The transducer trainer keeps a rolling window
of transducer_step{N}.pt snapshots (training.transducer.keep_last_n). Mean the tail into the one
checkpoint that decode, evaluation and the demo all load by default:
PYTHONPATH=. .venv/bin/python scripts/average_checkpoints.py --last-n 5 # -> transducer_avg.ptAveraging is element-wise over the snapshots' float tensors, so the result is one model of identical architecture and parameter count (same VRAM, same RTF) that lands nearer the centre of the basin the late iterates bounce around.
All tunables are read from config/*.yaml via get_config(), so you edit the YAML and change no
code.
Decoding parameters: the knobs in config/decode.yaml
The beam is a time-synchronous Graves A/B search with equal-prefix recombination (log-sum-exp merge) at every prune, so beam width buys distinct transcripts rather than duplicate alignments of the same one.
lm_weight(α) turns on STREAM-LM n-best rescoring of the acoustic beam. Tuned optimum 0.6. The shipped default0.0is byte-identical to the pure-acoustic decoder.ilm_weight(β) subtracts the transducer's internal language prior (ILME) so α is not fighting a double count. Tuned optimum 0.2 offline and 0.3 streaming, because streaming's weaker acoustic scores want more of the prior removed.length_bonusis a per-token re-ranking bonus that counters RNN-T's un-normalised deletion bias.chunk_size,beam_size,max_symbols,lm_checkpoint.
Evaluation: corpus WER/CER/RTF with automatic dev tuning
The Evaluate slice reports corpus WER/CER plus RTF, first-partial latency and finalize cost across
a cumulative decoder ablation (greedy_transducer → beam → beam_lm, each × offline/streaming). A run
picks one acoustic condition, and that choice binds the scored manifest, the tuning manifest and the
report path together, so a number can never be reported against the other condition's weights:
# test-clean: score test-clean, tune on dev-clean → runs/eval/report-clean.json
PYTHONPATH=. .venv/bin/python -m src.slices.Evaluate.evaluate --clean
# test-other: score test-other, tune on dev-other → runs/eval/report-other.json
PYTHONPATH=. .venv/bin/python -m src.slices.Evaluate.evaluate --otherRescoring weights are tuned automatically, on dev only and never on test, so the headline number stays an honest held-out result. Dev is decoded once per mode acoustic-only and the whole (α, β) grid is then swept over the cached scores for free, since neither weight moves the acoustic beam. Offline and streaming get their own pair.
A single-pass streaming RNN-T (transducer) built around a Zipformer acoustic encoder. One set of weights serves both offline and streaming inference through dynamic-chunk masking, and one joint training stage produces the checkpoint both modes decode from.
flowchart LR
A["LibriSpeech 960 h<br/>FLAC"] --> B["BuildManifest<br/><i>manifests + BPE-500</i>"]
B --> C["ComputeCmvn<br/><i>cmvn.pt</i>"]
C --> D["ExtractFeatures<br/><i>fp16 log-mel cache</i>"]
D --> E["PretrainEncoder<br/><i>BEST-RQ</i>"]
E -- warm start --> F["TrainAcousticModel<br/><i>Zipformer + RNN-T</i>"]
D --> F
B -- tokenizer --> G["TrainLanguageModel<br/><i>STREAM-LM</i>"]
F --> H["Decode<br/><i>beam + LM rescore + ILME</i>"]
G --> H
H --> I["Evaluate<br/><i>WER / CER / RTF</i>"]
H --> J["Demo<br/><i>upload + live mic</i>"]
| Component | Implementation | Role |
|---|---|---|
| Acoustic encoder | Zipformer, 53.8 M params | log-mel → conv subsampling → multi-rate Zipformer stacks with dynamic-chunk masking (~25 Hz output) |
| Prediction network | StatelessPredictor |
embeds the previous non-blank token plus a small causal depthwise-conv context. There is no recurrence, so streaming state is just the last context-1 token ids |
| Joint network | TransducerJoiner |
additive joiner (project encoder + predictor, sum, tanh, read out to vocab+blank). Trains against the full [B,T,U+1,V] lattice, decodes one (t,u) cell at a time |
| Auxiliary heads | CTC + two InterCTC taps | weighted CTC losses on the final and two intermediate encoder outputs. They act as regularizers and a cheap greedy-WER health probe rather than a separate decoding pass |
| Language model | STREAM-LM, causal Transformer | optional n-best rescoring of the acoustic beam by acoustic + α·lm - β·ilm |
| Internal-LM estimate | InternalLmScorer |
the transducer's own language prior, read off the joiner with encoder memory zeroed. Subtracted at β so the external LM is not counted on top of a prior the model already has |
Value-residual attention and causal-by-construction streaming
The encoder stacks and STREAM-LM inject block 0's attention values into deeper blocks, which adds
a gradient shortcut that trains stably at depth. The mix is a learnable per-block gate initialised
to zero (encoder_value_residual_lambda in config/model.yaml, value_residual_lambda in
config/lm.yaml), so a fresh model starts identical to a no-value-residual baseline and the
residual grows only as far as training wants it. This matters because the CTC branch sits on a
blank-collapse knife-edge that a fixed non-zero gate destabilises. The streaming path caches
post-residual values, which keeps streaming_forward exactly equal to the chunked forward.
The encoder is also causal by construction. The Conv2dSubsampling frontend is causal per frame,
ConvModule uses per-frame BiasNorm instead of normalising across a chunk, and RoPE carries a
pos_offset for chunk-local positions. Together these make ZipformerEncoder.streaming_forward()
exactly equivalent to the full-context batched forward() on every aligned frame, so there is no
re-forward fallback. A test locks the equivalence.
The repository follows a vertical-slice layout (see VSA.md). Slices communicate only
through artifact files (manifests, tokenizer, checkpoints) and typed dataclass DTOs, never by
importing each other's internals. Every slice carries a CONTEXT.md that documents its contract,
artifacts and design decisions.
config/ # YAML tunables (pydantic-validated), authoritative for every hyperparameter
src/
shared_kernel/ # pure transforms + infrastructure adapters, zero business logic
slices/
BuildManifest/ # LibriSpeech split -> manifest.jsonl; BPE tokenizer training
ComputeCmvn/ # global mean/var over train -> data/features/cmvn.pt
ExtractFeatures/ # fp16 log-mel mmap cache + dataset/collator/sampler + GPU SpecAugment
PretrainEncoder/ # BEST-RQ self-supervised encoder pretrain
TrainAcousticModel/ # Zipformer encoder + CTC/InterCTC heads + predictor + joiner + trainer
TrainLanguageModel/ # STREAM-LM: causal GQA Transformer + corpus prep + trainer
Decode/ # streaming/offline RNN-T beam search + LM n-best rescoring + ILME
Evaluate/ # corpus WER/CER/RTF/latency + ablation table + dev weight tuning
Demo/ # local FastAPI web UI: file upload + live-mic streaming transcription
scripts/ # env check, 960 h data build, LM corpus download, ckpt averaging, profiler
tests/ # shape / round-trip / equivalence / count sanity tests
data/ # LibriSpeech splits, manifests, tokenizer, cmvn, checkpoints (gitignored)
shared_kernel contents
| Module | Purpose |
|---|---|
Config_Adapter.py |
loads and validates config/*.yaml into get_config() |
AudioIO_Adapter.py |
FLAC/WAV/OGG load (soundfile, path or bytes) + resample + manifest loader |
LogMel_Transform.py |
80-bin log-mel frontend |
Tokenizer_Adapter.py |
SentencePiece BPE-500 wrapper |
RnntLoss.py |
RNN-T forward-backward (own kernel, replaces torchaudio's) |
BiasNorm.py, SwiGluFfn.py, RoPE_Transform.py |
blocks shared by encoder and LM |
Checkpoint_Adapter.py |
atomic stateful save/load + resume_if_available |
SignalGuard.py |
cooperative SIGINT/SIGTERM stop for training loops |
Muon_Optimizer.py, Optimizer_Adapter.py |
Muon + AdamW optimizer stack |
RandomProjectionQuantizer.py |
frozen BEST-RQ target quantizer |
MaskUtils.py, Logging_Adapter.py |
masking helpers, loguru sink |
A local web UI for listening to the model: upload an audio file, or speak into the microphone and
watch partial transcripts stream in. The Demo slice is pure transport. It loads the model once and
drives the Decode slice, so an upload runs an offline beam, while the live mic produces streaming
partials that a full-context final replaces on endpoint.
PYTHONPATH=. .venv/bin/python -m src.slices.Demo.serve_demo --lm-weight 0.6 --ilm-weight 0.2
# then open http://127.0.0.1:8000The two weights are the dev-tuned offline pair; omit them for the faster acoustic-only decoder.
--beam-size, --checkpoint, --tokenizer, --host and --port are also available. The server
binds 127.0.0.1 only and has no authentication. The browser captures 16 kHz raw PCM over a
WebSocket, which avoids any dependency on FFmpeg or external assets.
Transcripts are sentence-cased on the display path only, since the model is trained on LibriSpeech's
upper-case unpunctuated text: lower case, leading capital, pronoun "I". Proper nouns and sentence
boundaries cannot be recovered from unpunctuated output, so the display path infers nothing else,
and the Decode slice still emits raw corpus casing so evaluation stays comparable.
Own RNN-T loss kernel: 4.3× faster than torchaudio's, identical numerics
shared_kernel/RnntLoss.py replaces torchaudio.transforms.RNNTLoss. It uses the same Graves
recursion and the same analytic gradient (locked against torchaudio to fp32 round-off, plus a
float64 gradcheck), but the α/β scan runs over anti-diagonals of the [B,T,U+1] alignment grid,
which is T+U vectorised steps instead of T·U, with both variables advancing in one loop. That
leaves only two fused elementwise passes over the lattice itself. torchaudio's kernel ran ~50× off
the card's memory roofline and was ~45 % of a training step. Ours also fixes a torchaudio CUDA bug
where an empty transcript returns cost 0.0 instead of -Σ log p(blank).
Aligned joiner readout: a -inf bias worth 14 % of the training step
The joiner's readout is the model's only GEMM over the whole lattice, and its N = 501 is not
16-byte aligned in bf16, so cuBLAS fell back to a half-rate kernel. forward pads the readout to a
multiple of 8 with a -inf bias on the pad columns. exp() of those logits is exactly 0, so every
log-softmax, gather and gradient downstream is the 501-wide result, and the pad columns' own
gradient is exactly 0. Parameters stay 501-wide, which leaves checkpoints unaffected.
Batched beam search: one predictor+joiner call per symbol step
The whole live beam is evaluated in one predictor+joiner call per symbol step (batch dim = beam width), so a frame costs a few GPU launches and one host sync rather than one per hypothesis. LM and ILME rescoring are likewise one batched forward over the n-best.
Miscellaneous: audio decode, features, eager training
- Audio decode uses
soundfilerather thantorchaudio.load, because torchaudio 2.11 removed its native decode/metadata backends and now routes through TorchCodec, which requires FFmpeg. torchaudio is kept only for pure-tensor operations (resample, mel spectrogram). - Features are precomputed once into an fp16 log-mel mmap cache. SpecAugment is a GPU batch op
(
SpecAugmentBatch.py) applied insideTransducerModel.joint_losson the train path only, gated bytraining.spec_augment. - Training runs eager (
compile_model=False), becausetorch.compilehits inductor bugs on this torch 2.11 + Blackwell build.
STREAM ASR is open source under the Apache License 2.0. Use it, modify it, and ship it, whether personally, academically, or commercially, in open- or closed-source products. You do not need to ask permission and you owe no royalty.
One thing is asked in return, and the license makes it binding (§4(d)): carry the NOTICE file into anything you distribute that is built on this work, and credit it where you list your other open-source components:
Based on STREAM ASR (https://github.com/PanzerPeter/STREAM-ASR), Copyright 2026 PanzerPeter, licensed under Apache-2.0.
That covers the code, the trained checkpoints and the derived data artifacts alike. LibriSpeech is distributed by its own authors under CC BY 4.0 and is not part of this repository.