CoProducer Measure a mix
Two scopes on black marble, magenta clipped waveform left and cyan limited waveform right, amber cable arc between them

Delivery · 6 October 2026 · 9 min

Clipper vs limiter for hip-hop delivery — loudness isn’t the only score

NoDAW Labs · Lacquer Vol. 04

A soft clipper often wins the loudness fight on transient-heavy hip-hop and electronic bounce. A limiter wins the true-peak fight. Sample peak at −1.0 dBFS is not the same as true-peak safe. Drive both the same way, read LUFS and dBTP together, then pick the chain that survives the codec.

Producers argue clipper vs limiter like it’s a religion. In the booth it’s simpler. You want the bounce loud enough that it doesn’t feel soft next to the playlist, without inventing inter-sample peaks that AAC will turn into little clicks. Hip-hop and club-leaning electronic music live on short, sharp transients — kicks with clicks, 808 fronts, snare noise — and those are exactly the shapes that overshoot between samples when you shove them into a ceiling.

A clipper reshapes the peak. A limiter turns it down for a moment. Same drive, different scar.

Same drive, two processors, real numbers

This script takes one stereo file, drives it the same amount into a −1.0 dBFS sample ceiling through a soft tanh clipper and through ffmpeg’s alimiter, then prints sample peak, a 4× true-peak estimate, ffmpeg’s true peak, integrated LUFS, and crest factor. Use it on a finished mix bounce, not a single kick.

#!/usr/bin/env python3
"""clipper_vs_limiter.py — soft clipper vs brickwall limiter on one bounce.
Usage: python3 clipper_vs_limiter.py input.wav [--drive 8]
Needs: numpy + ffmpeg on PATH.

Builds two renders at the same drive into the ceiling:
  • soft clipper (tanh-shaped, then ceiling)
  • ffmpeg alimiter at −1.0 dBFS
Then prints sample peak, true peak (4× oversample peak estimate),
integrated LUFS (ffmpeg ebur128), and crest factor for each.
Hip-hop / electronic delivery often wants the clipper’s punch and
the limiter’s safety net — this shows the tradeoff in numbers."""
import argparse, subprocess, tempfile, os, re, struct, wave
import numpy as np

SR = 48000


def load_stereo(path):
    raw = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", path, "-ac", "2", "-ar", str(SR),
         "-f", "f32le", "-"],
        capture_output=True, check=True,
    ).stdout
    return np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)


def write_wav(path, x):
    # 24-bit via ffmpeg from f32le pipe for fidelity
    proc = subprocess.run(
        ["ffmpeg", "-y", "-v", "error", "-f", "f32le", "-ar", str(SR), "-ac", "2",
         "-i", "pipe:0", "-c:a", "pcm_s24le", path],
        input=x.astype(np.float32).tobytes(), check=True,
    )


def soft_clip(x, drive_db, ceiling_db=-1.0):
    """Drive into a soft tanh clipper, then hard-cap at ceiling."""
    g = 10 ** (drive_db / 20)
    y = np.tanh(x * g)
    # Match post-clip peak to ceiling (soft clip rarely hits exactly ±1)
    pk = np.max(np.abs(y)) + 1e-12
    ceil = 10 ** (ceiling_db / 20)
    y = y * (ceil / pk)
    return y.astype(np.float32)


def limit_ffmpeg(path_in, path_out, drive_db, ceiling_db=-1.0):
    # Pre-gain then alimiter. level=false keeps the limiter from auto-makeup.
    ceil = 10 ** (ceiling_db / 20)
    af = f"volume={drive_db}dB,alimiter=limit={ceil}:attack=1:release=50:level=false"
    subprocess.run(
        ["ffmpeg", "-y", "-v", "error", "-i", path_in, "-af", af,
         "-c:a", "pcm_s24le", path_out],
        check=True,
    )


def sample_peak_db(x):
    return 20 * np.log10(np.max(np.abs(x)) + 1e-20)


def true_peak_db(x, factor=4):
    """Crude true-peak estimate: upsample with linear FFT zero-pad, take peak.
    Good enough to compare processors; not a certified ITU meter."""
    # process mono sum for speed, then each channel and take max
    peaks = []
    for ch in range(x.shape[1]):
        n = len(x[:, ch])
        X = np.fft.rfft(x[:, ch])
        Xz = np.zeros(n * factor // 2 + 1, dtype=complex)
        Xz[: X.shape[0]] = X
        y = np.fft.irfft(Xz, n=n * factor) * factor
        peaks.append(np.max(np.abs(y)))
    return 20 * np.log10(max(peaks) + 1e-20)


def crest_db(x):
    rms = np.sqrt(np.mean(x ** 2) + 1e-20)
    pk = np.max(np.abs(x)) + 1e-20
    return 20 * np.log10(pk / rms)


def ebur128(path):
    r = subprocess.run(
        ["ffmpeg", "-hide_banner", "-nostats", "-i", path,
         "-af", "ebur128=peak=true", "-f", "null", "-"],
        capture_output=True, text=True,
    )
    text = r.stderr
    # Prefer the Summary block (last "I:" before end)
    summ = text.rsplit("Summary:", 1)[-1]
    i = re.search(r"I:\s+([-\d.]+)\s+LUFS", summ)
    # ffmpeg prints True peak / Peak: under Summary
    m_tp = re.search(r"True peak:\s*\n\s*Peak:\s+([-\d.]+)\s+dBFS", summ)
    if not m_tp:
        m_tp = re.search(r"Peak:\s+([-\d.]+)\s+dBFS", summ)
    integ = float(i.group(1)) if i else float("nan")
    tpeak = float(m_tp.group(1)) if m_tp else float("nan")
    return integ, tpeak, text


def report(label, path, x):
    integ, ftp, _ = ebur128(path)
    print(f"--- {label} ---")
    print(f"file         : {path}")
    print(f"sample peak  : {sample_peak_db(x):+.2f} dBFS")
    print(f"true peak~   : {true_peak_db(x):+.2f} dBTP (4× FFT estimate)")
    print(f"ffmpeg TP    : {ftp:+.2f} dBTP")
    print(f"integrated   : {integ:+.1f} LUFS")
    print(f"crest factor : {crest_db(x):.1f} dB")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("input")
    ap.add_argument("--drive", type=float, default=8.0)
    ap.add_argument("--out-dir", default=".")
    args = ap.parse_args()
    os.makedirs(args.out_dir, exist_ok=True)
    x = load_stereo(args.input)
    print(f"input        : {args.input}")
    print(f"drive        : +{args.drive:.1f} dB into −1.0 dBFS ceiling")
    report("INPUT (pre)", args.input, x)

    clip_path = os.path.join(args.out_dir, "out_clipper.wav")
    lim_path = os.path.join(args.out_dir, "out_limiter.wav")
    y = soft_clip(x, args.drive, -1.0)
    write_wav(clip_path, y)
    limit_ffmpeg(args.input, lim_path, args.drive, -1.0)
    yc = load_stereo(clip_path)
    yl = load_stereo(lim_path)
    report("SOFT CLIPPER", clip_path, yc)
    report("ALIMITER", lim_path, yl)


if __name__ == "__main__":
    main()

Synthetic trap loop, +8 dB into −1.0 dBFS

The test file is synthetic on purpose: kick clicks, snare noise, sustained sub, bright hats — transient soup with headroom. Input sat at about −6.9 dBFS sample peak, −6.5 dBTP, −24.1 LUFS, crest 16.2 dB.

Pushed +8 dB into a −1.0 dBFS sample ceiling:

PathIntegratedTrue peak (ffmpeg)Crest
Soft clipper−15.6 LUFS+1.10 dBTP13.5 dB
alimiter−16.3 LUFS+0.70 dBTP14.2 dB

Same ceiling. Clipper came out 0.7 LU louder and 0.4 dB worse on true peak. Neither is delivery-safe if your store wants ≤ −1.0 dBTP. The sample-peak meter said −1.0 on both. The true-peak meter said “not yet.”

At +12 dB drive the gap flipped character: clipper −13.3 LUFS / +2.3 dBTP, limiter −13.0 LUFS / +1.0 dBTP. Heavy drive makes both loud; the clipper’s extra “punch” starts showing up as inter-sample junk faster than the limiter’s gain reduction does.

Leave ceiling on purpose

If you like the clipper’s tone, don’t park the sample ceiling at −1.0 and pray. Leave margin:

Soft-clip sample ceilingLUFSTrue peak
−1.0 dBFS−15.6+1.10 dBTP
−1.5 dBFS−16.1+0.60 dBTP
−2.0 dBFS−16.6+0.10 dBTP

Limiter at a −2.0 dBFS sample limit on the same +8 dB drive landed −16.4 LUFS / −0.30 dBTP — finally under a −1.0 dBTP gate. A common delivery move in this lane: clip for tone, then a true-peak-aware limiter (or a lower ceiling) for the last 1–2 dB of safety. On this synthetic file, ffmpeg’s asoftclip into an alimiter at −1.5 measured −16.4 LUFS / +0.2 dBTP. Still not a certified ITU chain, but it shows the idea: shape first, protect second.

# tone from soft clip, safety from a lower limiter ceiling
ffmpeg -i mix.wav -af "volume=8dB,asoftclip=type=tanh,alimiter=limit=0.8414:attack=1:release=50:level=false" -c:a pcm_s24le delivery.wav

# always read true peak, not only sample peak
ffmpeg -hide_banner -nostats -i delivery.wav -af ebur128=peak=true -f null -

Which one when

Reach forWhenWatch for
Soft clipper on the mix bus or drumsYou want denser transients and a little loudness without the limiter’s pumpInter-sample peaks; harsh hats if you overdo it
Lookahead limiterYou need a hard ceiling and less crest reduction in the bodyOver-release pumping on 808s; still not true-peak safe at −1.0 sample
Clipper → TP limiterHip-hop / electronic delivery that should feel slammed but pass −1.0 dBTPTwo processors means two places to overcook — check LUFS and dBTP after the chain
NeitherSample clips are already non-zero, or the mix is only 0.5 LU hotFix the mix or trim 0.5 dB. Another processor won’t invent headroom that isn’t there

Crest factor is the texture clue. On the +8 dB test the clipper pulled crest from 16.2 → 13.5 dB; the limiter landed at 14.2 dB. Lower crest often feels louder even when LUFS is close. That’s why the clipper can win a blind loudness fight and still lose the codec fight.

Don’t clip the vocal the way you clip the drums. Vocal peaks into a soft clipper turn into spit and broken esses. If the vocal is the problem, fix its ride or its S’s first — then decide what the bus needs.

Streaming still turns hot masters down. Hitting −9 LUFS with a clipper doesn’t beat −14 LUFS normalization. It just hands the store a file with less punch after turn-down. Aim the loudness at the lane you’re shipping to, then spend the processor budget on clean true peak.

Screw renders inherit the damage. A clipped-too-hard bounce, slowed and pitched, still carries the distortion products. Do the delivery pass on the version you’re uploading, and spot-check true peak on the ScrewAI export if that edit is going out as its own file.

If all you have is the bounce

You can still A/B a clipper and a limiter on the stereo file with the script above. If the clipped version is louder but fails true peak, either lower the clip ceiling or put a safety limiter after it. If both versions show clipped samples before you even drive them, go back to the session — or pull stems with StemSplit, find which transient is slamming, and fix that part instead of stacking processors on the 2-mix. When the chain is done, run CoProducer and read the true-peak row before you upload.

Is a clipper better than a limiter for rap?
Often for tone and short-term loudness on drums, yes — but only if true peak still clears your delivery gate. Many release chains use both: clip for shape, limiter for safety.
Why does my −1.0 dBFS bounce still clip after upload?
Sample peak isn’t true peak. Codecs reconstruct between samples. Aim ≤ −1.0 dBTP (many teams leave −1.0 to −1.5 dBTP), not only −1.0 dBFS on a sample meter.

A free one-pager

The checklist we run before every release.

Loudness, true peak, mono, tails, export settings and the metadata basics, on one page. Leave your email and it opens straight away, or read it here first. No email needed for that.