
Delivery · 6 October 2026 · 9 min
Clipper vs limiter for hip-hop delivery — loudness isn’t the only score
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:
| Path | Integrated | True peak (ffmpeg) | Crest |
|---|---|---|---|
| Soft clipper | −15.6 LUFS | +1.10 dBTP | 13.5 dB |
| alimiter | −16.3 LUFS | +0.70 dBTP | 14.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 ceiling | LUFS | True 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 for | When | Watch for |
|---|---|---|
| Soft clipper on the mix bus or drums | You want denser transients and a little loudness without the limiter’s pump | Inter-sample peaks; harsh hats if you overdo it |
| Lookahead limiter | You need a hard ceiling and less crest reduction in the body | Over-release pumping on 808s; still not true-peak safe at −1.0 sample |
| Clipper → TP limiter | Hip-hop / electronic delivery that should feel slammed but pass −1.0 dBTP | Two processors means two places to overcook — check LUFS and dBTP after the chain |
| Neither | Sample clips are already non-zero, or the mix is only 0.5 LU hot | Fix 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.