Everything to know about audio-to-text: how ASR works, the mid-2026 model landscape, evaluation metrics, and runnable code to test the leading open models.
Author
Benedict Thekkel
1. What is ASR?
Automatic Speech Recognition (ASR, also called speech-to-text / STT) maps a raw audio waveform to a text transcript.
Input. Audio is a 1-D array of amplitude samples. Almost every ASR model expects 16 kHz mono audio; resample anything else first. Models do not consume the waveform directly - it is converted to a log-mel spectrogram (a time x frequency image of energy), which the neural encoder ingests.
Output. Plain text, optionally with:
Timestamps - per segment or per word (needed for subtitles, editing tools)
Language ID - which language was spoken
Translation - some models (Whisper) can emit English text from non-English speech in one pass
Neighbouring tasks (separate models, often chained with ASR):
Task
What it does
Typical tool
Voice activity detection (VAD)
Find where speech is
Silero VAD
Speaker diarization
Who spoke when
pyannote.audio
Forced alignment
Align a known transcript to audio
Qwen3-ForcedAligner, MFA
Text-to-speech (TTS)
The inverse task
see 00_Text_to_Speech
2. How Modern ASR Works
Four architectural eras, each still alive in some niche:
HMM/GMM pipelines (Kaldi era, pre-2016). Hand-built acoustic + pronunciation + language models. Legacy - only relevant for understanding older literature.
Self-supervised encoders + CTC (wav2vec 2.0, 2020). Pretrain an encoder on unlabeled audio, fine-tune with a CTC head. CTC predicts a character/token per frame independently - extremely fast, slightly weaker on punctuation and context.
Attention encoder-decoder (Whisper, 2022). A transformer decoder attends over the full encoded audio - best robustness and formatting, but slow (autoregressive) and offline-only by design (fixed 30 s windows).
Transducers and LLM decoders (2024-2026).
RNN-T / TDT (Parakeet): streams natively, near-attention accuracy at 10-50x the speed.
SALM / LLM-decoder (Canary-Qwen, Granite Speech, Qwen3-ASR): bolt a speech encoder onto a pretrained LLM. Current accuracy leaders - the LLM brings world knowledge to disambiguate hard audio.
Nearly all modern encoders are Conformers (convolution + self-attention) or close variants.
Decoder trade-off cheat sheet:
Decoder
Accuracy
Speed
Streaming
Example
CTC
good
fastest
yes
wav2vec 2.0
TDT / RNN-T
very good
very fast
yes
Parakeet TDT
Attention
very good
slow
no
Whisper
LLM (SALM)
best
slowest
partial
Canary-Qwen, Qwen3-ASR
3. Evaluation Metrics
Word Error Rate (WER) - the standard metric. Edit distance between hypothesis and reference, normalised by reference length:
\[WER = \frac{S + D + I}{N}\]
where S = substitutions, D = deletions, I = insertions, N = reference words. Lower is better; can exceed 100%.
Pitfalls:
Normalisation dominates. Casing, punctuation and number formatting (“25” vs “twenty five”) swing WER by several points. Always compare models under the same text normalisation (the Open ASR Leaderboard uses the Whisper normaliser).
CER (character error rate) is used for languages without whitespace word boundaries (Chinese, Japanese).
RTFx (inverse real-time factor) = audio duration / processing time. RTFx 100 means 1 hour of audio transcribes in 36 s. The accuracy/speed trade-off is the key engineering decision.
jiwer is the standard Python package for WER:
import jiwerreference ="the quick brown fox jumps over the lazy dog"hypothesis ="the quick brown fox jumped over a lazy dog"print("WER:", jiwer.wer(reference, hypothesis))out = jiwer.process_words(reference, hypothesis)print(f"substitutions={out.substitutions} deletions={out.deletions} insertions={out.insertions}")
Whisper is no longer the accuracy leader, but remains the ecosystem default. The Open ASR Leaderboard is the reference ranking (avg WER over 8+ English test sets).
Model
Params
License
Languages
Decoder
Best for
NVIDIA Canary-Qwen 2.5B
2.5B
CC-BY-4.0
en
SALM
max English accuracy (~5.6% avg WER)
IBM Granite Speech
2-8B
Apache 2.0
en + few
LLM
accuracy per parameter (~5.3% WER)
NVIDIA Parakeet TDT
0.6-1.1B
CC-BY-4.0
en (v2), 25 (v3)
TDT
bulk transcription (RTFx ~2000)
Qwen3-ASR
0.6B / 1.7B
Apache 2.0
52 (incl. dialects)
LLM
multilingual open SOTA, language ID
Voxtral Transcribe 2
4B
Apache 2.0
13
streaming
native real-time streaming
Whisper large-v3
1.5B
MIT
99+
attention
long-tail languages, ecosystem
Moonshine
27-60M
MIT
en
attention
edge / embedded / Raspberry Pi
License nuance. MIT/Apache models are open source in the full sense. NVIDIA’s CC-BY-4.0 is a content license - commercial use is fine but attribution is required, and purists do not call it open source. Watch out: older Canary checkpoints (pre-Qwen) were CC-BY-NC (non-commercial only). Qwen3-ASR-Flash (the 52-language flagship numbers) is API-only on Alibaba Cloud; the open 0.6B/1.7B checkpoints cover fewer languages. Training data is closed for all of them - you get weights, not reproducibility.
5. Setup
Everything below runs on a single modest GPU (tested on a 6 GB GTX 1660 SUPER) or CPU. Package roles:
import timeimport urllib.requestfrom pathlib import Pathimport torchfrom dotenv import find_dotenv, load_dotenv# Knowledge/.env sets HF_TOKEN - authenticated HF Hub requests get higher rate limitsload_dotenv(find_dotenv(usecwd=True))device ="cuda:0"if torch.cuda.is_available() else"cpu"if device !="cpu":print(torch.cuda.get_device_name(0))print("device:", device)# All dataset downloads go to DL_tasks/datasets/ (gitignored)DATA_DIR = Path("../../datasets")DATA_DIR.mkdir(exist_ok=True)# A ~14 s English sample clip (from the Qwen3-ASR repo)SAMPLE = DATA_DIR /"sample_en.wav"ifnot SAMPLE.exists(): urllib.request.urlretrieve("https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav", SAMPLE, )
from datasets import load_dataset# 73 short LibriSpeech clips with reference transcripts - our eval setds = load_dataset("hf-internal-testing/librispeech_asr_dummy","clean", split="validation", cache_dir=str(DATA_DIR /"hf_cache"),)def to_audio_input(audio):"Normalize a datasets audio entry to the dict transformers pipelines expect."ifisinstance(audio, dict): # datasets < 4.xreturn {"array": audio["array"], "sampling_rate": audio["sampling_rate"]} samples = audio.get_all_samples() # datasets >= 4.x returns a torchcodec AudioDecoderreturn {"array": samples.data.numpy().squeeze(), "sampling_rate": samples.sample_rate}row = ds[0]print(to_audio_input(row["audio"])["array"].shape)print("ref:", row["text"][:70])
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
(93680,)
ref: MISTER QUILTER IS THE APOSTLE OF THE MIDDLE CLASSES AND WE ARE GLAD TO
6. Whisper with transformers
The automatic-speech-recognition pipeline handles decoding (via ffmpeg), resampling, batching and chunking. Model sizes: tiny (39M) -> base -> small (244M) -> medium -> large-v3 (1.5B). small is a good accuracy/VRAM compromise on 6 GB cards.
from transformers import pipelineasr = pipeline("automatic-speech-recognition", model="openai/whisper-small", device=device)t0 = time.perf_counter()result = asr(str(SAMPLE), return_timestamps=True)print(f"{time.perf_counter() - t0:.1f}s")print(result["text"])for chunk in result["chunks"]:print(chunk["timestamp"], chunk["text"])
# Long-form audio: chunk the input and batch the chunkslong_form = pipeline("automatic-speech-recognition", model="openai/whisper-small", chunk_length_s=30, batch_size=8, device=device,)# Word-level timestamps and forced language / task selection:result = asr(str(SAMPLE), return_timestamps="word", generate_kwargs={"language": "en", "task": "transcribe"}, # task="translate" -> English output from any language)print(result["chunks"][:5])
7. faster-whisper (CTranslate2)
The same Whisper weights on the CTranslate2 inference engine: ~4x faster than the reference implementation, with int8 quantisation that halves memory at negligible accuracy cost. The usual production choice for batch Whisper workloads.
# %pip install -q faster-whisperfrom faster_whisper import WhisperModel# compute_type: "float16" (GPU), "int8" (CPU or low VRAM)fw = WhisperModel("small", device="cuda"if device !="cpu"else"cpu", compute_type="int8")t0 = time.perf_counter()segments, info = fw.transcribe(str(SAMPLE), beam_size=5)print("language:", info.language, f"(p={info.language_probability:.2f})")for seg in segments:print(f"[{seg.start:6.2f} -> {seg.end:6.2f}] {seg.text}")print(f"{time.perf_counter() - t0:.1f}s")
8. Qwen3-ASR
The strongest open multilingual family (Apache 2.0, Jan 2026). 52 languages/dialects with built-in language ID, singing-voice robustness, and an optional forced aligner for timestamps. The 0.6B checkpoint fits comfortably in 6 GB; the qwen-asr package wraps transformers (and optionally vLLM for streaming/batch serving).
# %pip install -q qwen-asrfrom qwen_asr import Qwen3ASRModel# Note: bfloat16 needs an Ampere+ GPU; use float16 on GTX 16xx / older cardsqwen = Qwen3ASRModel.from_pretrained("Qwen/Qwen3-ASR-0.6B", dtype=torch.float16 if device !="cpu"else torch.float32, device_map=device, max_inference_batch_size=8, max_new_tokens=256,)results = qwen.transcribe(audio=str(SAMPLE), language=None) # language=None -> auto-detectprint(results[0].language)print(results[0].text)# For word/segment timestamps, load with:# forced_aligner="Qwen/Qwen3-ForcedAligner-0.6B", and pass return_time_stamps=True
9. NVIDIA Parakeet and Canary (NeMo)
The Open ASR Leaderboard toppers. Parakeet TDT is the speed king (RTFx ~2000 - an hour of audio in under 2 s on a datacenter GPU); Canary-Qwen 2.5B is the English accuracy king. Both CC-BY-4.0, both need the (heavy) NeMo toolkit rather than plain transformers.
# %pip install -q "nemo_toolkit[asr]" # large install; needs a fairly recent GPU stackimport nemo.collections.asr as nemo_asrparakeet = nemo_asr.models.ASRModel.from_pretrained("nvidia/parakeet-tdt-0.6b-v2")out = parakeet.transcribe([str(SAMPLE)], timestamps=True)print(out[0].text)for stamp in out[0].timestamp["segment"]:print(f"[{stamp['start']:6.2f} -> {stamp['end']:6.2f}] {stamp['segment']}")
10. Moonshine (edge devices)
Purpose-built for embedded real-time ASR: tiny is 27M params (~27 MB quantised), and unlike Whisper it processes variable-length windows instead of always padding to 30 s - so short commands transcribe with proportionally less compute. English-only, MIT license, runs happily on CPU / Raspberry Pi.
moonshine = pipeline("automatic-speech-recognition", model="UsefulSensors/moonshine-tiny", device=device, # or device="cpu" - it is fast enough)t0 = time.perf_counter()print(moonshine(str(SAMPLE))["text"])print(f"{time.perf_counter() - t0:.1f}s")
11. Head-to-head Benchmark
Same clips, same normalisation, WER + wall clock per model. Caveats: this is a tiny English-only sample of clean read speech - treat it as a smoke test, not a leaderboard. For real evaluation use the full LibriSpeech test sets, or noisy/conversational sets like AMI and Earnings-22 (both on the Open ASR Leaderboard).
Measured here on a GTX 1660 SUPER (5 clips): whisper-tiny 10.6% WER / 2.4 s, moonshine-tiny 12.6% WER / 1.3 s.
import jiwerN =20# clips to evaluate (max 73)normalize = jiwer.Compose([ jiwer.ToLowerCase(), jiwer.RemovePunctuation(), jiwer.RemoveMultipleSpaces(), jiwer.Strip(),])def benchmark(name, transcribe_fn):"Run transcribe_fn over the eval set; return (wer, seconds)." refs, hyps = [], [] t0 = time.perf_counter()for row in ds.select(range(N)): hyps.append(transcribe_fn(to_audio_input(row["audio"]))) refs.append(row["text"]) elapsed = time.perf_counter() - t0 wer = jiwer.wer(normalize(refs), normalize(hyps))print(f"{name:25s} WER {wer:6.2%}{elapsed:5.1f}s / {N} clips")return wer, elapsed
results = {}for model_id in ["openai/whisper-tiny", "openai/whisper-small", "UsefulSensors/moonshine-tiny"]: asr_pipe = pipeline("automatic-speech-recognition", model=model_id, device=device) results[model_id] = benchmark(model_id, lambda a: asr_pipe(a)["text"])del asr_pipeif device !="cpu": torch.cuda.empty_cache()# faster-whisper takes (array, sr) via a file or ndarray; qwen accepts (ndarray, sr) tuples:# results["faster-whisper-small"] = benchmark(# "faster-whisper-small",# lambda a: " ".join(s.text for s in fw.transcribe(a["array"])[0]),# )# results["Qwen3-ASR-0.6B"] = benchmark(# "Qwen3-ASR-0.6B",# lambda a: qwen.transcribe(audio=(a["array"], a["sampling_rate"]))[0].text,# )
import pandas as pddf = pd.DataFrame( [(k, wer, sec) for k, (wer, sec) in results.items()], columns=["model", "wer", "seconds"],).sort_values("wer")df
12. Real-time Microphone Input
Quick capture-then-transcribe loop with sounddevice. For true streaming (partial results while speaking) use a natively streaming model - Voxtral Transcribe 2, Qwen3-ASR via its vLLM backend, or Parakeet/RNN-T - or a VAD-chunked wrapper project like RealtimeSTT / whisper-streaming.
Fine-tuning. Whisper fine-tunes well on ~5-10 h of domain audio: HF fine-tuning guide. Common Voice, FLEURS and People’s Speech are the usual open training sets.
Domain hints without fine-tuning. faster-whisper’s initial_prompt / hotwords bias decoding toward domain vocabulary (names, jargon) for free.
Diarization + ASR.WhisperX and pyannote.audio give “who said what” with word-level alignment.
Forced alignment. Qwen3-ForcedAligner-0.6B (11 languages, up to 5 min) or the classic Montreal Forced Aligner.
VAD preprocessing. Silero VAD to strip silence - big speed and accuracy win on real-world recordings.