Generating Waveform Peaks with FFmpeg
This page sits beneath FFmpeg Batch Processing for Podcasts within the broader Media Ingestion & Format Architecture pipeline, and it covers a small but frequently botched artifact: the pre-computed waveform your web player draws as a scrubbable strip above the play button. A player that renders a waveform live in the browser has to download the entire episode first — tens of megabytes — just to draw a picture. The right approach is to compute the waveform once, at ingest, into a tiny JSON of peak amplitudes that the player loads in kilobytes and paints instantly. This page shows how to produce that file from any input using FFmpeg’s PCM decode plus a deterministic decimation pass.
Problem Framing
The naive attempts all fail in the same way — they conflate rendering a waveform image with extracting the data behind it. Reaching for showwavespic gives you a PNG, which is fine for a static thumbnail but useless for an interactive player that needs to highlight the played portion, respond to hover, and reflow at any width. What the player actually wants is an array of amplitude peaks: for each horizontal pixel column, the loudest sample in the slice of audio that column represents. Get the extraction wrong and the symptoms are visible immediately:
WARN waveform ep=ep0193 buckets=1024 expected=2000 reason="decimation stride off by channel count"
WARN waveform ep=ep0193 peak_max=32767 peak_min=-32768 reason="raw int16, never normalized to -1..1"
INFO waveform ep=ep0193 bytes=5_931_204 reason="serialized every sample, not decimated -> 5.9 MB json"
Those three lines are the entire problem space. The first is a stride bug: stereo PCM interleaves left and right samples, so if you decimate without accounting for two channels your bucket boundaries land in the wrong place and the waveform is half the length it should be. The second is a normalization miss: 16-bit PCM samples run from -32768 to 32767, but every waveform renderer expects floats in -1.0..1.0, so an un-normalized file draws as a solid block. The third is the size blowout: a 45-minute episode at 44.1 kHz is 119 million samples — serialize them all and you have shipped a 6 MB “optimization” that is bigger than the audio. The fix is to downmix to mono, decimate to a fixed bucket count, and store a min/max pair per bucket as small normalized numbers, matching the compact shape used by well-known player formats like the BBC’s peaks.json and the audiowaveform tool.
Solution Architecture
The pipeline is three stages and holds no state between assets. First, FFmpeg decodes the input — MP3, AAC, WAV, anything — to raw signed 16-bit little-endian PCM, resampled to a modest rate and downmixed to a single channel, streamed to stdout so nothing hits disk. Second, a decimation pass reads that PCM stream in fixed-size windows, and for each of a fixed number of output buckets records the minimum and maximum sample it saw — the min/max pair is what gives the waveform its filled, symmetric look rather than a single-sided envelope. Third, the buckets are normalized to -1.0..1.0 and serialized into a peaks.json carrying the metadata a player needs: sample rate, bucket count (length), bits, and the flat peaks array.
Because the output is a fixed-width array, the file size is constant regardless of episode length: a two-minute trailer and a three-hour deep-dive both serialize to the same 2,000-bucket array. That predictability is what makes the artifact safe to cache on a CDN and cheap to store beside every episode.
Implementation
1. Decode to raw mono PCM with FFmpeg
Let FFmpeg do the format-agnostic decode and resample, and stream the result to stdout so a long episode never materializes as a multi-hundred-megabyte WAV on disk. Signed 16-bit little-endian (s16le) is the right intermediate: it is exact, trivially parsed as NumPy int16, and half the size of 32-bit float. Downmix to one channel with -ac 1 and drop the rate to something like 8 kHz with -ar 8000 — a waveform shape needs nothing near full-fidelity temporal resolution, and the lower rate shrinks the decimation work by roughly 5.5x versus 44.1 kHz:
# Python 3.10+
# numpy==1.26.4 (buffer -> int16 decimation)
import subprocess
import numpy as np
def decode_pcm(input_path: str, rate: int = 8000) -> np.ndarray:
"""Decode any input to raw signed 16-bit mono PCM as an int16 array."""
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-nostdin", "-v", "error",
"-i", input_path,
"-ac", "1", # downmix to mono -> no channel-stride bug
"-ar", str(rate), # low rate: shape only, not fidelity
"-f", "s16le", # raw signed 16-bit little-endian
"-acodec", "pcm_s16le",
"pipe:1"],
capture_output=True, check=True,
)
return np.frombuffer(proc.stdout, dtype="<i2")
Downmixing in FFmpeg with -ac 1 is what eliminates the interleave stride bug at the source: the byte stream reaching Python is already one sample per frame, so the decimator never has to know or care how many channels the original had. Container and codec edge cases that can make this decode fail — a truncated mdat, a missing moov — are caught upstream by video container parsing with Python before the asset ever reaches this stage.
2. Decimate into fixed-width min/max buckets
Slice the sample array into a fixed number of equal windows and record the min and max of each. The min/max pair (rather than a single peak or an RMS average) is what reproduces the familiar filled, mirror-symmetric waveform. Use NumPy so the whole reduction is vectorized rather than a Python loop over millions of samples:
# Python 3.10+
def decimate_peaks(samples: np.ndarray, buckets: int = 2000) -> list[list[int]]:
"""Reduce an int16 sample stream to `buckets` [min, max] pairs."""
if samples.size == 0:
raise ValueError("empty PCM stream; upstream decode produced no audio")
# Trim to an exact multiple of `buckets` so reshape is clean.
usable = (samples.size // buckets) * buckets
if usable == 0:
# Clip shorter than the bucket count: one bucket per sample.
buckets = samples.size
usable = samples.size
windowed = samples[:usable].reshape(buckets, -1)
mins = windowed.min(axis=1)
maxs = windowed.max(axis=1)
return [[int(lo), int(hi)] for lo, hi in zip(mins, maxs)]
The buckets value is the horizontal resolution of the rendered waveform — 2000 comfortably covers a full-width desktop player, and the player downsamples further for narrow viewports. Reshaping to (buckets, -1) after trimming to an exact multiple is the vectorization trick: every window is the same length, so min/max collapse along one axis in a single pass.
3. Serialize a normalized peaks.json
Normalize the int16 peaks into -1.0..1.0 by dividing by 32768.0, and wrap them in the metadata envelope a player expects. This shape mirrors the widely-used audiowaveform/BBC peaks.json convention so an off-the-shelf renderer can consume it directly:
# Python 3.10+
import json
def build_peaks_json(pairs: list[list[int]], rate: int) -> str:
flat = []
for lo, hi in pairs:
flat.append(round(lo / 32768.0, 4)) # normalize int16 -> -1.0..1.0
flat.append(round(hi / 32768.0, 4))
return json.dumps({
"version": 2,
"channels": 1,
"sample_rate": rate,
"samples_per_pixel": None, # implied by length; player reflows to width
"bits": 16,
"length": len(pairs), # bucket count == horizontal resolution
"data": flat, # interleaved [min, max, min, max, ...]
}, separators=(",", ":"))
Flattening to an interleaved [min, max, min, max, ...] array (rather than nested pairs) matches what most JavaScript renderers iterate over and shaves bytes off the payload. The -nostdin and PCM streaming semantics used above are documented in the official FFmpeg documentation; the astats filter referenced in the FAQ is described in the FFmpeg filters documentation.
Verification
Prove the bucket count, the normalized range, and the payload size independently — each corresponds to one of the three failure lines above.
# 1. Bucket count must equal the requested resolution (length), not the sample count.
python -c "from waveform import decode_pcm, decimate_peaks; \
p = decimate_peaks(decode_pcm('episode.m4a'), 2000); print(len(p))"
# expected: 2000
# 2. Normalized peaks must lie inside [-1.0, 1.0] — proves normalization ran.
python -c "import json; d=json.load(open('peaks.json'))['data']; \
print(min(d), max(d), all(-1.0 <= x <= 1.0 for x in d))"
# expected: something like -0.94 0.97 True
# 3. Payload must be constant-size and small regardless of episode length.
ls -l peaks.json # a 2000-bucket file is ~30-40 KB, not megabytes
# 4. Cross-check overall level against FFmpeg's own peak measurement.
ffmpeg -hide_banner -nostdin -i episode.m4a -af astats=metadata=1 -f null - 2>&1 | grep "Peak level"
A healthy artifact has exactly length buckets, every value inside -1.0..1.0, a file weight in the tens of kilobytes independent of duration, and a peak level from the JSON that tracks FFmpeg’s own astats “Peak level” reading within rounding. Emit waveform_buckets, waveform_bytes, and waveform_generate_ms to your observability stack so a sudden jump in payload size — the signature of a decimation or normalization regression — surfaces before it ships to the player.
Failure Modes & Edge Cases
| Edge case | Symptom | Remediation |
|---|---|---|
Decimated stereo without -ac 1 |
Waveform is half-length or L/R interleave shows as noise | Downmix to mono in FFmpeg with -ac 1; never stride across raw channels |
| Int16 peaks never normalized | Player draws a solid block (values swamp the -1..1 scale) |
Divide by 32768.0; assert every value lands inside -1.0..1.0 |
| Every sample serialized | Multi-megabyte “peaks” file larger than the audio | Decimate to a fixed bucket count before serialization |
| Clip shorter than the bucket count | reshape raises, or empty buckets |
Fall back to one bucket per sample when size < buckets |
| Sample rate left at 44.1 kHz | Decimation reads 5.5x more samples for identical output | Drop to -ar 8000; shape resolution is unaffected |
| Silent lead-in dominates buckets | Flat waveform with a tiny spike at the end | Trim leading/trailing silence upstream, or use per-bucket RMS instead of raw peak |
showwavespic used for interactive UI |
PNG cannot highlight played region or reflow | Extract peak data as JSON; reserve showwavespic for static thumbnails |
When the batch layer generates peaks for a whole back catalogue at once, fan the work across the bounded pool described in parallel FFmpeg encoding with a worker pool rather than serializing one episode at a time.
FAQ
Why store min and max per bucket instead of a single peak value?
A single peak per bucket only captures the positive envelope, so the rendered waveform looks one-sided and thin. Audio is bipolar — samples swing above and below zero — and the recognizable filled, mirror-symmetric waveform comes from drawing a vertical bar from the bucket's minimum sample to its maximum. Storing both doubles the array length but is what makes the strip read as a real waveform rather than a jagged top edge, and it is what player formats like audiowaveform's peaks.json expect.
Should I use the astats filter instead of decoding raw PCM?
Use astats when you want summary statistics — overall peak level, RMS, DC offset — for validation, which is exactly how the verification step cross-checks the output. It is the wrong tool for building the waveform array itself, because it reports aggregate figures rather than a per-window amplitude series at a resolution you control. Decoding to raw PCM and decimating yourself gives you an exact, fixed bucket count that maps one-to-one onto the player's horizontal pixels; astats cannot.
How many buckets should the peaks file have?
Match the widest rendering context, then let the player downsample for narrow screens. Around 2,000 buckets covers a full-width desktop player at roughly one bucket per pixel and keeps the JSON in the tens of kilobytes. Going much higher wastes bytes the eye cannot resolve and forces the browser to average them back down anyway; going much lower makes the waveform look blocky when a listener expands the player. Pick one bucket count for the whole catalogue so every episode's file is uniform and cacheable.
Related
- Up to the parent guide: FFmpeg Batch Processing for Podcasts
- Sibling technique on the same batch layer: Parallel FFmpeg Encoding with a Worker Pool
- Structural pre-flight that guarantees a decodable stream: video container parsing with Python
- Codec conformance before peak extraction: audio codec normalization workflows
- Pipeline overview: Media Ingestion & Format Architecture