Modalities

Whisper ASR: single-chunk and long-file

Whisper's 30-second window is elegant for short audio but painful for hour-long recordings. Understanding the encoder-decoder split, the mel spectrogram frontend, and the chunking strategies that make long-form transcription reliable.

Whisper was my introduction to non-LLM inference, and it taught me something important: the inference challenges for encoder-decoder models are fundamentally different from decoder-only LLMs. There is no KV cache that grows unboundedly. There is no continuous batching over variable-length generations. Instead, you get a fixed-size encoder bottleneck and a chunking problem that sounds simple but has subtle failure modes.

The architecture in 60 seconds

Whisper is an encoder-decoder transformer trained on 680,000 hours of multilingual audio. The pipeline has three stages:

The 30-second constraint is baked into the architecture. The encoder's positional embeddings are learned for exactly 3000 frames. You cannot feed it 60 seconds of audio and hope for the best. This is the root of the long-file problem.

Single-chunk inference: the happy path

For audio under 30 seconds, inference is straightforward:

import whisper
import torch

model = whisper.load_model("large-v3")

# Load and preprocess audio
audio = whisper.load_audio("short_clip.wav")
audio = whisper.pad_or_trim(audio)  # Pad/trim to exactly 30s

# Compute mel spectrogram
mel = whisper.log_mel_spectrogram(audio).to(model.device)

# Decode with greedy search
options = whisper.DecodingOptions(language="en", fp16=True)
result = whisper.decode(model, mel, options)
print(result.text)

The pad_or_trim function is doing the critical work: it ensures the audio is exactly 480,000 samples (30 seconds at 16 kHz). Shorter clips get zero-padded. The encoder processes the full 30-second window regardless, which means a 2-second clip wastes about 93% of the encoder compute on silence.

Performance note

For short audio clips, the encoder dominates latency. On an A100 with Whisper large-v3, the encoder takes roughly 40 ms and the decoder takes 10 to 200 ms depending on transcript length. Unlike LLMs where decode is the bottleneck, in Whisper the encoder is often the heavier phase.

The long-file problem

Real-world audio is rarely 30 seconds. Meeting recordings run for an hour. Podcasts go for two. Customer service calls can be anywhere from 30 seconds to 45 minutes. Whisper needs a chunking strategy, and there are two main approaches.

Approach 1: sequential chunking with timestamps

The simplest method splits audio into 30-second chunks and transcribes each one sequentially. Whisper's official transcribe() function does this, but with a clever twist: it uses the model's predicted timestamps to find the actual end of speech within each chunk, then seeks forward to that point rather than blindly advancing by 30 seconds.

# Whisper's transcribe() uses a seek-based approach:
# 1. Feed 30s chunk starting at current position
# 2. Decode with timestamp tokens enabled
# 3. Find the last timestamp token in the output
# 4. Advance seek position to that timestamp
# 5. Repeat until end of audio

# The timestamp tokens are special tokens like <|0.00|>, <|0.02|>, ...
# They indicate when each word occurs within the 30s window
# This lets the model "consume" only the speech it transcribed

This works well but has two failure modes:

Approach 2: sliding window with overlap

A more robust approach uses overlapping windows and merges the results. HuggingFace Transformers implements this as the "chunked" long-form algorithm:

from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
import torch

model_id = "openai/whisper-large-v3"
model = AutoModelForSpeechSeq2Seq.from_pretrained(
    model_id, torch_dtype=torch.float16
).to("cuda")
processor = AutoProcessor.from_pretrained(model_id)

# Chunked transcription with overlap
result = model.generate(
    input_features,
    return_timestamps=True,
    chunk_length_s=30,        # 30-second chunks
    stride_length_s=[4, 2],   # 4s left overlap, 2s right overlap
)

# The overlapping regions are used to align and merge
# transcriptions across chunk boundaries

The overlap regions serve as anchors: the model transcribes the same audio twice (once at the end of one chunk, once at the start of the next), and a merging algorithm uses the overlapping text to find the best splice point. This dramatically reduces boundary artifacts.

Batched inference for throughput

When you need to transcribe many files, the key optimization is batching across the encoder. The encoder processes fixed-size inputs (3000 frames), so batching is trivial compared to LLMs: stack N spectrograms into a tensor of shape [N, 80, 3000] and run one forward pass.

The decoder is harder to batch because different chunks produce different-length transcripts. The approaches mirror LLM batching:

In practice, the encoder is the throughput bottleneck for batch processing. On an A100, the encoder processes about 50 to 80 chunks per second for Whisper large-v3 in FP16 with a batch size of 32. The decoder adds roughly 20 to 40% more time on top.

Optimization techniques

Several optimizations are particularly effective for Whisper:

# VAD + Whisper pipeline (pseudocode)
from silero_vad import get_speech_timestamps, load_silero_vad

vad_model = load_silero_vad()
speech_segments = get_speech_timestamps(audio, vad_model)

# Only transcribe segments with actual speech
for segment in speech_segments:
    chunk = audio[segment['start']:segment['end']]
    chunk = whisper.pad_or_trim(chunk)
    mel = whisper.log_mel_spectrogram(chunk)
    result = whisper.decode(model, mel, options)
Key insight

The biggest throughput win for Whisper in production is not a GPU optimization. It is VAD preprocessing. For typical meeting audio with 40 to 60% silence, skipping non-speech segments cuts your GPU cost nearly in half.

Where Whisper fits in the modalities picture

Whisper's architecture is an interesting bridge between the pure encoder models we looked at yesterday with embeddings and the pure decoder models that dominate LLM inference. The encoder half behaves like an embedding model: fixed input size, compute-bound, easy to batch. The decoder half behaves like a small LLM: autoregressive, variable-length output, memory-bound at small batch sizes.

This duality means the optimization toolkit is broader. You can apply encoder tricks (sequence packing, INT8 compute) and decoder tricks (speculative decoding, KV cache optimization) to different parts of the same model. It is a good exercise in thinking about where the bottleneck actually is, rather than applying optimizations uniformly.

Tomorrow: TTS and streaming speech synthesis, where we flip the direction and generate audio from text, with real-time latency constraints that make LLM TTFT look generous.