CoProducer Measure a mix
Three reel-to-reel machines on black marble: one throwing a long stretched cyan tape, one wrapped in a magenta pitch spiral, and a chrome metronome beside the third

Pitch · 8 October 2026 · 14 min

Resampling, pitch shifting, and tempo change are three different jobs

NoDAW Labs · Lacquer Vol. 05

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.

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.

RenderLengthHeld A4, 15 framesTempo (120 in)Last hit driftHit width / pre-echoLUFS / dBTP
Source17.000 s440.0120.0000.67 ms / −64.7 dB−15.1 / −3.00
A · Resample+1.85%432.0 in 15/15117.82+291.7 ms0.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.9120.00+1.0 ms0.69 ms / −15.6 dB−15.5 / −3.20
C · Rubber Band pitch, mixed0.00%432.0 in 15/15120.00+1.0 ms0.69 ms / −15.6 dB−15.2 / −3.20
D · Rubber Band pitch, smooth0.00%432.0 in 15/15120.00+3.3 ms2.60 ms / −2.6 dB−15.7 / −4.00
E · Resample + atempo−0.12%432.0–432.2 in 15/15119.99+6.5 ms0.69 ms / −64.1 dB−15.1 / −3.00
F · atempo only+1.72%439.7–440.0 in 15/15117.82+285.9 ms0.67 ms / −64.7 dB−15.1 / −3.00
G · Rubber Band tempo only+1.85%0/15 at 440, read 431.9–447.5117.82+290.4 ms0.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…UseBecauseThen check
A 432 listening alternate of a finished masterResampleNothing gets rebuilt, and the slight slowdown is part of the sound people expectNew runtime, true peak, metadata
A 432 version locked to picture, choreography, or a DJ gridResample + stretch back, or a pitch shifter in a mode you’ve testedThe length has to match to the frameHats, reverb tails, held chords in a synced A/B
Matching a sample to a 432 sessionResample the sampleOne-shots and loops can take the 1.85% length change, and it stays cleanLoop points and the grid
Practice, DJ key-lock, a slower editTempo change onlyThe pitch has to stay at concert referenceTransient doubling on drums
Stems that must stay in sync with the full mixProcess every stem with the same operation and settingsDifferent algorithms drift and phase differentlyNull the summed stems against the processed mix

A render routine that doesn’t bite later

  1. Start from the approved lossless master and render once. Re-processing a render stacks one algorithm’s artifacts on another’s.
  2. Write down the operation, not just the number. “432, resample, 54/55 exact” and “432, Rubber Band mixed, length locked” are different masters.
  3. Check the length. A resample should come out at about the original duration ÷ 0.981818. Anything else means a second operation snuck in.
  4. Listen where reconstruction fails: the first transient, sustained cymbals, a held chord under busy drums, reverb tails, and the widest stereo moment.
  5. 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.

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.