
Pitch · 8 October 2026 · 14 min
Resampling, pitch shifting, and tempo change are three different jobs
Resampling moves pitch and time together, like slowing tape: 440 to 432 is −31.77 cents and 1.85% longer. Pitch shifting moves pitch and rebuilds the timeline so length stays fixed. Tempo change moves length and keeps pitch. They answer different questions, and the time-preserving ones always rebuild something you should listen to.
Every DAW, DJ app, and plugin has a knob for this, and most of them are labeled something vague like “speed,” “pitch,” “warp,” or “key lock.” Three different operations hide behind those labels. If you don’t know which one you’re using, you can’t predict what will happen to your hats, your reverb tails, or the runtime the video editor is counting on.
So we rendered one loop seven ways and measured all of them: length, where the held note lands, BPM, how far the last hit drifts, how sharp the transients stay, loudness, and true peak. Some results were what you’d expect. One of them surprised us.
The one question that sorts them
Ask what stays fixed.
- Resampling (varispeed): nothing stays fixed. The samples are played back as if the clock ran slower, so every frequency drops by the ratio and every event arrives later by the inverse. For 440 → 432 that’s ×0.981818 on pitch and ×1.018519 on length. It’s the cleanest operation of the three because there’s nothing to invent. It’s just very good interpolation.
- Pitch shifting (time-preserving): length stays fixed and pitch moves. To do that, the algorithm has to take the audio apart and put it back together: phase vocoders split it into short FFT frames, and time-domain methods cut and overlap little grains. Something gets rebuilt, and rebuilding is where artifacts come from.
- Tempo change (time stretch): pitch stays fixed and length moves. It’s the same reconstruction problem pointed the other way. It’s what DJ key-lock and practice apps do, and it isn’t a 432 conversion, even though the slowdown can feel similar.
There’s also a combined recipe you’ll see in batch tools: resample first, then time-stretch back to the original length. On paper that equals a pitch shift. In practice its artifacts come from the stretcher, not the resampler.
The FFmpeg lines, so you know what you’re running
These are the exact filters we measured. They run the same in any terminal: Windows, macOS, or Linux. The rubberband lines need an FFmpeg built with librubberband. Most “full” builds include it. Run ffmpeg -filters and look for it.
# A resample, exact 54/55 (48 kHz source; for 44.1 kHz use 48510 / 47628 / 44100)
ffmpeg -i in.wav -af "aresample=52800,asetrate=51840,aresample=48000" -c:a pcm_s24le A_resample.wav
# B/C/D time-preserving pitch shift (Rubber Band), default "crisp", then mixed, then smooth
ffmpeg -i in.wav -af "rubberband=pitch=0.9818181818" -c:a pcm_s24le B_rb_crisp.wav
ffmpeg -i in.wav -af "rubberband=pitch=0.9818181818:transients=mixed" -c:a pcm_s24le C_rb_mixed.wav
ffmpeg -i in.wav -af "rubberband=pitch=0.9818181818:transients=smooth" -c:a pcm_s24le D_rb_smooth.wav
# E resample, then stretch back to the original length (WSOLA-style atempo)
ffmpeg -i in.wav -af "aresample=52800,asetrate=51840,aresample=48000,atempo=1.0185185185" -c:a pcm_s24le E_resample_atempo.wav
# F/G tempo only: 1.85% slower, pitch kept
ffmpeg -i in.wav -af "atempo=0.9818181818" -c:a pcm_s24le F_atempo.wav
ffmpeg -i in.wav -af "rubberband=tempo=0.9818181818" -c:a pcm_s24le G_rb_tempo.wav
Watch for one trap. asetrate and aresample only accept plain whole numbers. Something like asetrate=sample_rate*432/440 looks reasonable, but FFmpeg rejects it with “Undefined constant” and renders nothing. Work out the number yourself, or use the exact 52800 → 51840 trick above.
A script that measures all seven
This runs every render above on one file and measures each result against the source. Pitch is measured by sliding the spectrum on a log-frequency axis, in one-second frames taken at the same musical position, so a longer render is compared bar for bar instead of by clock time. The “match” column shows how cleanly the shifted spectrum lines up with the original, where 1.000 is identical in shape. Hits are found above 3 kHz. Width is how long each hit stays within 12 dB of its peak. Pre-echo is the loudest level in the 2–20 ms before each hit.
#!/usr/bin/env python3
"""three_ways.py: resampling vs pitch shifting vs tempo change, measured on one file.
Usage: python3 three_ways.py in.wav [source_bpm]
Needs: numpy + ffmpeg on PATH. Rows that need librubberband are skipped if your build lacks it.
Sample rate must be a multiple of 50 Hz (44.1/48/88.2/96 kHz). Renders are written next to
the input as 32-bit float WAVs; the input is never modified.
Per render: length change; pitch change (log-frequency cross-correlation of 1 s frames:
median, spread in cents, and "match", how cleanly the shifted spectrum lines up, 1.000 = perfect); time scale from matched high-band hits (and BPM if you pass it);
drift of the last hit; hit width and pre-echo in the band above 3 kHz; loudness; true peak."""
import re, subprocess, sys
import numpy as np
r = 432 / 440
src = sys.argv[1]
bpm_in = float(sys.argv[2]) if len(sys.argv) > 2 else None
base = re.sub(r"\.\w+$", "", src)
sr = int(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
"stream=sample_rate", "-of", "csv=p=0", src], capture_output=True, text=True).stdout)
up = sr * 11 // 10
exact = f"aresample={up},asetrate={up * 54 // 55},aresample={sr}" # 54/55 == 432/440 exactly
RENDERS = [ # (label, filter, what the textbook says should happen: pitch factor, time factor)
("A classic resample", exact, r, 1 / r),
("B rubberband pitch, crisp", f"rubberband=pitch={r:.10f}", r, 1),
("C rubberband pitch, mixed", f"rubberband=pitch={r:.10f}:transients=mixed", r, 1),
("D rubberband pitch, smooth", f"rubberband=pitch={r:.10f}:transients=smooth", r, 1),
("E resample + atempo", f"{exact},atempo={1 / r:.10f}", r, 1),
("F atempo only", f"atempo={r:.10f}", 1, 1 / r),
("G rubberband tempo only", f"rubberband=tempo={r:.10f}", 1, 1 / r),
]
def decode(path):
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", str(sr), "-f", "f32le", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype=np.float32).astype(np.float64)
def loud(path):
e = subprocess.run(["ffmpeg", "-nostats", "-hide_banner", "-i", path, "-af", "ebur128=peak=true:framelog=quiet",
"-f", "null", "-"], capture_output=True, text=True).stderr
s = e[e.rfind("Summary:"):]
return float(re.search(r"I:\s+(-?[\d.]+) LUFS", s).group(1)), float(re.search(r"Peak:\s+(-?[\d.]+) dBFS", s).group(1))
def logspec(x, lo=100.0, hi=2000.0, step=0.25):
n = 1 << 18
mag = np.abs(np.fft.rfft(x * np.hanning(len(x)), n)); f = np.fft.rfftfreq(n, 1 / sr)
return np.interp(lo * 2 ** (np.arange(0, 1200 * np.log2(hi / lo), step) / 1200), f, mag)
def xcorr_cents(A, B, step=0.25, span=60):
"""Shift (cents) that best lines B's spectrum up with A's, and how well it lines up (1.0 = identical shape)."""
lags = np.arange(-int(span / step), int(span / step) + 1)
c = np.array([np.dot(A[max(0, -k):len(A) - max(0, k)], B[max(0, k):len(B) - max(0, -k)]) for k in lags])
i = int(np.clip(np.argmax(c), 1, len(c) - 2)); d = 0.5 * (c[i - 1] - c[i + 1]) / (c[i - 1] - 2 * c[i] + c[i + 1])
return (lags[i] + d) * step, c[i] / (np.linalg.norm(A) * np.linalg.norm(B) + 1e-20)
def pitch(x, y, tf):
"""Compare 1 s frames at the SAME musical position (source time t maps to t*tf in the render)."""
out = []
for t in np.arange(0.5, len(x) / sr - 1.5, 1.0):
a = x[int(t * sr): int((t + 1) * sr)]; b = y[int(t * tf * sr): int(t * tf * sr) + sr]
if len(b) == sr:
out.append(xcorr_cents(logspec(a), logspec(b)))
c, m = np.array(out).T
return np.median(c), c.max() - c.min(), np.median(m)
def match_hits(h0, h):
"""Pair source hits with render hits (allowing a dropped hit at either end), fit time = a*t + b.
Returns time scale a, drift of the last paired hit in ms, and how many hits were paired."""
best = None
for k in range(-3, 4):
a0, a1 = h0[max(0, -k):], h[max(0, k):]; n = min(len(a0), len(a1)); a0, a1 = a0[:n], a1[:n]
fit = np.polyfit(a0, a1, 1)
if best is None or abs(fit[1]) < best[0]: # the pairing whose first hit lines up at t = 0
best = (abs(fit[1]), fit[0], (a1[-1] - a0[-1]) * 1000, n)
return best[1:]
def hf_env(x):
spec = np.fft.rfft(x); f = np.fft.rfftfreq(len(x), 1 / sr)
edge = np.clip((f - 2200) / 800, 0, 1); edge = 0.5 - 0.5 * np.cos(np.pi * edge) # smooth 2.2-3.0 kHz high-pass
h = np.r_[1, 2 * np.ones(len(spec) - 1)] # analytic signal
env = np.abs(np.fft.ifft(np.r_[spec * edge * h, np.zeros(len(x) - len(spec))]))
k = max(int(0.0002 * sr), 1)
return np.convolve(env, np.ones(k) / k, "same")
def hits(x):
env = hf_env(x); thr = env.max() * 0.1; gap = int(0.05 * sr)
cand = np.where(env > thr)[0]; peaks, i = [], 0
while i < len(cand):
j = cand[i]; p = j + int(np.argmax(env[j:j + gap])); peaks.append(p)
i = np.searchsorted(cand, p + gap)
widths, pre = [], []
for p in peaks:
lvl = env[p] * 10 ** (-12 / 20); a = b = p
while a > 0 and env[a] > lvl: a -= 1
while b < len(env) - 1 and env[b] > lvl: b += 1
widths.append((b - a) / sr * 1000)
w = env[max(0, p - int(0.020 * sr)): max(1, p - int(0.002 * sr))]
pre.append(20 * np.log10(w.max() / env[p] + 1e-9))
return np.array(peaks) / sr, float(np.median(widths)), float(np.median(pre))
x = decode(src); li, ti = loud(src); h0, w0, p0 = hits(x)
print(f"source : {src} (SYNTHETIC if it came from synth.py) {len(x) / sr:.3f} s, {len(h0)} hits, "
f"width {w0:.2f} ms, pre-echo {p0:.1f} dB, {li:.1f} LUFS, {ti:+.2f} dBTP")
print(f"target : pitch x{r:.6f} ({1200 * np.log2(r):+.2f} c); classic resample length x{1 / r:.6f} ({100 * (1 / r - 1):+.2f}%)")
print(f"{'render':27s} {'length':>7s} {'pitch c':>8s} {'spread':>6s} {'match':>6s} {'time x':>8s} {'BPM':>7s} {'drift':>8s} "
f"{'hits':>4s} {'width':>7s} {'pre':>7s} {'LUFS':>6s} {'dBTP':>6s}")
for name, af, pf, tf in RENDERS:
out = f"{base}__{re.sub(r'[^a-z]+', '_', name.lower()[2:]).strip('_')}.wav"
run = subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-af", af, "-c:a", "pcm_f32le", out],
capture_output=True, text=True)
if run.returncode:
print(f"{name:27s} skipped: {run.stderr.strip().splitlines()[-1][:70]}"); continue
y = decode(out); lo_, to_ = loud(out); h, w, p = hits(y)
med, spread, match = pitch(x, y, tf)
scale, drift, paired = match_hits(h0, h)
tempo = f"{bpm_in / scale:7.2f}" if bpm_in else " n/a"
print(f"{name:27s} {100 * (len(y) / len(x) - 1):+6.2f}% {med:+8.2f} {spread:6.2f} {match:6.3f} {scale:8.5f} {tempo} "
f"{drift:+7.1f}ms {len(h):4d} {w:5.2f}ms {p:5.1f}dB {lo_:6.1f} {to_:+6.2f}")
Usage: python3 three_ways.py mix.wav 120. The BPM argument is optional and only used to turn the measured time scale into a tempo.
What it measured on a synthetic loop
The source is synthetic, built on the box from formulas so every answer is known: 17 seconds at 48 kHz and 120 BPM, with an A-major pad including a held A4, a 55 Hz sub on every bar, a swept kick on 1 and 3, and 1 ms high ticks on every eighth note standing in for hats and snare. It isn’t a real record. The table below combines the script’s output with a second check, a4_track.py, which reads the held A4 in each of 15 one-second frames.
| Render | Length | Held A4, 15 frames | Tempo (120 in) | Last hit drift | Hit width / pre-echo | LUFS / dBTP |
|---|---|---|---|---|---|---|
| Source | 17.000 s | 440.0 | 120.00 | 0 | 0.67 ms / −64.7 dB | −15.1 / −3.00 |
| A · Resample | +1.85% | 432.0 in 15/15 | 117.82 | +291.7 ms | 0.69 ms / −64.1 dB | −15.1 / −3.00 |
| B · Rubber Band pitch, crisp (default) | 0.00% | 0/15 at 432, read 426.4–439.9 | 120.00 | +1.0 ms | 0.69 ms / −15.6 dB | −15.5 / −3.20 |
| C · Rubber Band pitch, mixed | 0.00% | 432.0 in 15/15 | 120.00 | +1.0 ms | 0.69 ms / −15.6 dB | −15.2 / −3.20 |
| D · Rubber Band pitch, smooth | 0.00% | 432.0 in 15/15 | 120.00 | +3.3 ms | 2.60 ms / −2.6 dB | −15.7 / −4.00 |
| E · Resample + atempo | −0.12% | 432.0–432.2 in 15/15 | 119.99 | +6.5 ms | 0.69 ms / −64.1 dB | −15.1 / −3.00 |
| F · atempo only | +1.72% | 439.7–440.0 in 15/15 | 117.82 | +285.9 ms | 0.67 ms / −64.7 dB | −15.1 / −3.00 |
| G · Rubber Band tempo only | +1.85% | 0/15 at 440, read 431.9–447.5 | 117.82 | +290.4 ms | 0.71 ms / −14.4 dB | −15.6 / −3.60 |
Here’s how to read it.
Resampling did exactly what the math says. The length grew 1.85%, the held A4 read 432.0 in every frame, and the tempo measured from the hits was 117.82. Transients came through essentially untouched: 0.69 ms wide with no pre-echo to speak of. Loudness and true peak didn’t move. We also built the mathematically ideal 432 version of the loop from the same formulas and subtracted it. The residual was −44.5 dB, which is about as close as our sample-quantized reference can show. No other render can pass that test, because the time-preserving methods rebuild phase in their own way. They came in between −0.2 and −3.3 dB, which tells you they’re reconstructions, not that they’re bad.
Watch the last-hit drift. After 15.75 seconds, the resampled loop’s final hit lands 291.7 ms late. At 120 BPM a sixteenth note is 125 ms, so the two versions are a full sixteenth apart about 6.75 seconds in. That’s why a resampled 432 version can’t share stems, a beat grid, or a video timeline with the original, and why an A/B player that switches by clock time sends you to the wrong syllable.
The surprise was Rubber Band’s default in FFmpeg. With transients=crisp, which is what you get if you type nothing else, the held A4 never read 432 in a single frame. It read 426 to 429 most of the time and 439.9 once. The match score fell to 0.674, while every clean render scored above 0.97. Rubber Band documents crisp mode as resetting phase at transients. Our loop has a hit every eighth note, four per second, and what we measured looks like the held chord being reset four times a second: its energy splits into sidebands a few hertz either side of where it belongs. The tempo-only version (G) did the same thing around 440. Switching to transients=mixed fixed the pitch completely, 432.0 in every frame. That’s on a synthetic loop with very sharp ticks, so your music may behave better. But it’s exactly the kind of thing you should test before trusting a preset.
Smooth mode buys stable pitch with blurry hits. D held 432 perfectly, but the hits got almost four times wider (2.60 ms against 0.69) and the 2–20 ms before each hit came up to −2.6 dB of the hit itself. That’s the soft, chorus-like attack people describe as “phasey,” and it pulled true peak down by a full dB. All three Rubber Band pitch renders also dropped the very first hit at 0:00, so leave a little silence at the top of anything you process this way.
The resample-plus-atempo recipe held up better than its reputation. E kept the original length to within 0.12% (atempo trimmed about 20 ms off the end), held A4 at 432.0–432.2, and kept transients as sharp as the source. FFmpeg’s atempo is a time-domain splicer, and on dense polyphonic material those splices can turn into audible flutter or doubling. Our loop is simple enough that they mostly didn’t. The 0.2 Hz wobble in its A4 readings is the trace they left.
Choosing the mode from the job, not the label
| If the job is… | Use | Because | Then check |
|---|---|---|---|
| A 432 listening alternate of a finished master | Resample | Nothing gets rebuilt, and the slight slowdown is part of the sound people expect | New runtime, true peak, metadata |
| A 432 version locked to picture, choreography, or a DJ grid | Resample + stretch back, or a pitch shifter in a mode you’ve tested | The length has to match to the frame | Hats, reverb tails, held chords in a synced A/B |
| Matching a sample to a 432 session | Resample the sample | One-shots and loops can take the 1.85% length change, and it stays clean | Loop points and the grid |
| Practice, DJ key-lock, a slower edit | Tempo change only | The pitch has to stay at concert reference | Transient doubling on drums |
| Stems that must stay in sync with the full mix | Process every stem with the same operation and settings | Different algorithms drift and phase differently | Null the summed stems against the processed mix |
A render routine that doesn’t bite later
- Start from the approved lossless master and render once. Re-processing a render stacks one algorithm’s artifacts on another’s.
- Write down the operation, not just the number. “432, resample, 54/55 exact” and “432, Rubber Band mixed, length locked” are different masters.
- Check the length. A resample should come out at about the original duration ÷ 0.981818. Anything else means a second operation snuck in.
- Listen where reconstruction fails: the first transient, sustained cymbals, a held chord under busy drums, reverb tails, and the widest stereo moment.
- Re-measure loudness and true peak on the file you’ll actually ship. In our test the pitch shifters moved integrated loudness by up to 0.6 LU and true peak by up to 1 dB.
For the common case, a 432 alternate of a finished record, reTUNE[432] keeps it simple. The browser engine does an exact-ratio resample locally, and its synced A/B player maps musical position across the longer timeline, so switching lands on the same beat. The $4 full system adds full-length export and an offline Windows batch with a classic mode and a preserve-duration mode, so you can render both, compare them, and decide by ear. If you want the background on what the conversion itself does to a master, start with What 432 Hz actually changes in a finished master.
- What is the difference between resampling and pitch shifting?
- Resampling changes pitch and length together, like varispeed tape: 440 to 432 is −31.77 cents and 1.85% longer. Pitch shifting changes pitch while rebuilding the timeline so the length stays the same, which can add smearing or phasey artifacts.
- Does converting to 432 Hz change the BPM?
- With resampling, yes. Tempo drops by the ratio 0.981818, so 120 BPM becomes 117.82. With a time-preserving pitch shift, the BPM stays the same.
- Is FFmpeg atempo or rubberband better for pitch shifting?
- It depends on the material. On our synthetic loop, resample plus atempo kept transients sharp, while Rubber Band in its default crisp mode didn’t hold a sustained note at pitch and needed transients=mixed. Test both on your hardest passage.
- Why does my pitch-shifted audio sound phasey or smeared?
- Time-preserving algorithms rebuild phase in short frames or splice short grains. Transients can spread out and sustained notes can pick up sidebands. Try a different transient mode, or resample if a change in length is acceptable.
- Can I do an exact 440 to 432 conversion in FFmpeg?
- Yes. At 48 kHz, use aresample=52800,asetrate=51840,aresample=48000, which is exactly 54/55. The asetrate and aresample options take plain whole numbers, so expressions like sample_rate*432/440 fail.