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.

PCM decode to decimated min/max buckets to peaks.json A left-to-right flow. FFmpeg decodes an episode to raw signed 16-bit mono PCM at 8 kHz, streamed to standard output. A decimation stage reads the continuous sample stream and slices it into a fixed number of equal-width windows; a magnified inset shows one window of raw samples collapsing to a single min and max amplitude pair. The resulting per-bucket min/max pairs are normalized from the int16 range to minus one through one and serialized into a peaks.json object carrying sample_rate, length, bits, and a flat peaks array, which a web audio player then renders as a scrubbable waveform strip. FFmpeg decode s16le · mono · 8 kHz to stdout Decimate N fixed windows min/max per bucket Normalize int16 → -1.0..1.0 serialize peaks.json length · bits peaks[ ] ONE BUCKET WINDOW max min web player draws scrubbable strip

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.