Bulgarian ASR — Zipformer transducer (streaming)
The causal twin of preslaff/bulgarian-asr-zipformer: same 2,222-hour corpus, same 65.5M-parameter architecture, trained with chunked attention so one checkpoint serves several latency settings chosen at decode time.
At its operating point of ~640 ms it reaches 5.33% WER on a frozen test set of unseen sessions, and 5.64% on simulated telephone-band audio. It runs on CPU with sherpa-onnx, which makes it usable for live captioning, dictation and call transcription.
The latency ladder
Chunk size is chosen at decode time, so you pick your own point on this curve.
| Chunk | Algorithmic latency | Dev WER |
|---|---|---|
| 16 | ~320 ms | 5.71 |
| 32 | ~640 ms | 5.28 |
| 64 | ~1.28 s | 5.07 |
Frozen test at chunk 32: 5.33. Degradation is graceful — there is no cliff at the lowest latency, and at 640 ms the model matches what an earlier offline model of mine achieved with no latency constraint at all.
Telephone band. Scored against an audio-domain G.711a simulation (16 k → 8 k, 300 Hz high-pass, A-law round trip), this model scores 5.64% against 6.11% for an otherwise identical model trained without band augmentation — a band penalty of +6.8% relative instead of +18.0%. Band augmentation matters more for a causal model, which cannot reach forward for context to recover what the band removed.
The ONNX files here are exported at chunk 32 / left-context 128. For a different latency point, re-export from the recipe rather than changing it at runtime.
Usage
import sherpa_onnx, soundfile as sf, numpy as np
recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(
encoder="encoder-epoch-40-avg-6-chunk-32-left-128.int8.onnx",
decoder="decoder-epoch-40-avg-6-chunk-32-left-128.int8.onnx",
joiner="joiner-epoch-40-avg-6-chunk-32-left-128.int8.onnx",
tokens="tokens.txt",
num_threads=4,
decoding_method="greedy_search",
enable_endpoint_detection=True,
)
stream = recognizer.create_stream()
wav, sr = sf.read("audio.wav", dtype="float32") # 16 kHz mono
for chunk in np.array_split(wav, max(1, len(wav) // (sr // 10))): # 100 ms chunks
stream.accept_waveform(sr, chunk)
while recognizer.is_ready(stream):
recognizer.decode_stream(stream)
print(recognizer.get_result(stream), end="\r")
stream.input_finished()
while recognizer.is_ready(stream):
recognizer.decode_stream(stream)
print(recognizer.get_result(stream))
Feed real microphone audio the same way. Greedy decoding is the right choice here — beam search adds latency where latency is the whole point.
Files
Encoder, decoder and joiner in both dynamic int8 (71 MB encoder) and fp32 (261 MB),
plus tokens.txt (BPE-500) and bpe.model.
Training
Identical to the offline model except --causal 1, with chunk size and left context
sampled per batch so one checkpoint covers multiple latencies. 40 epochs, fp16,
ScaledAdam + Eden, MUSAN CutMix and band-limiting augmentation. Decoding checkpoint is
epoch 40 averaged over the last 6 — streaming prefers a tighter averaging window than
offline's flat 6–10 plateau.
The training audio is third-party material and is not redistributable, so it is not published here.
Caveats
- The clean-speech numbers did not improve over an earlier streaming model trained on 477 fewer hours. At 65M parameters the causal model looks data-saturated on this register; the gain from the larger corpus went to telephone-band robustness instead. If you need a lower streaming number, capacity rather than more speech is the lever.
- FLEURS and Common Voice train splits are in the training corpus, so any score on those sets measures in-corpus performance rather than generalization.
- int8 figures were measured on a CPU with AVX-512 VNNI; re-measure elsewhere.
Fuller write-up, including the evaluation methodology: https://asr.cdsv.dev
Credits
Built with k2 / icefall, served with sherpa-onnx. Includes FLEURS (CC-BY-4.0) and Common Voice (CC0) in training; MUSAN (CC-BY-4.0) for augmentation.
Model by Preslav Penev · cdsv.dev · Apache-2.0.