Monorepo for Aesthetic.Computer aesthetic.computer
core pop AUTOTUNE-ALGORITHMS.md
10 kB

autotune algorithms — non-neural offline pitch correction #

scope: the actual internals of pitch-correction, with code we can lift. neural systems (rvc, diffsinger, etc.) are the other agent's beat. this is dsp, c, and python — the stuff that runs on jeffrey's 8gb macbook without a gpu and finishes before lunch.

the problem with pop/bin/pitchsnap.mjs today: it slices a vocal by word and runs rubberband -p N per segment. that's a static shift — every sample inside the word slides up the same n semitones. the source's natural pitch contour rides along, which is the opposite of autotune. autotune clamps — it replaces f0 with a target curve.

1. what autotune actually does — five sentences #

step 1: estimate f0 (pitch) frame-by-frame on the input, ~5 ms hop. step 2: for each frame, snap the detected midi note to the nearest member of an allowed scale (with hysteresis so a wobble around the boundary doesn't flip notes every frame). step 3: compute a per-frame correction ratio target_f0 / source_f0. step 4: shift the audio by that ratio while preserving formants — so the timbre stays human, the pitch becomes mechanical. step 5: stitch frames back together with phase coherence (or, in psola/world world, resynth from decomposed parameters).

that's it. the magic is entirely in steps 1, 4, and 5 being good.

2. pitch detection — yin / pyin #

naive autocorrelation (what pitchsnap.mjs uses today) finds octave errors because the autocorrelation peak at lag t is matched at 2t, 3t, 4t, etc. any noise pushes the search to the wrong harmonic. yin (de cheveigné & kawahara, 2002) fixes this with the cumulative mean normalized difference function — the curve starts at 1 and only dips below when a true period is found, with the lower-lag (higher pitch) match preferred.

# from patriceguyot/Yin (mit license) — compact form
def differenceFunction(x, w, tau_max):
    # d(tau) = sum_i (x[i] - x[i+tau])^2, computed via fft for speed
    x_cumsum = np.concatenate(([0.], (x*x).cumsum()))
    fc = np.fft.rfft(x, size_pad)
    conv = np.fft.irfft(fc * fc.conjugate())[:tau_max]
    return x_cumsum[w:w-tau_max:-1] + x_cumsum[w] - x_cumsum[:tau_max] - 2*conv

def cmndf(df, N):
    # normalize so curve starts at 1, drops < 1 at true period
    return np.insert(df[1:] * range(1, N) / np.cumsum(df[1:]), 0, 1)

def getPitch(cmdf, tau_min, tau_max, harmo_th=0.1):
    tau = tau_min
    while tau < tau_max:
        if cmdf[tau] < harmo_th:
            while tau+1 < tau_max and cmdf[tau+1] < cmdf[tau]:
                tau += 1   # walk down to local minimum
            return tau
        tau += 1
    return 0  # no pitch found (unvoiced)

source: patriceguyot/Yin/yin.py, also vendored into nvidia/mellotron. pyin (mauch & dixon, 2014) extends yin with a probabilistic hmm over candidate periods — much more robust on noisy/breathy vocals. librosa.yin and librosa.pyin ship both.

3. pitch shifting — psola vs phase vocoder vs world resynth #

three families, each with a tradeoff:

td-psola (charpentier & stella, 1986). estimate f0, find pitch-period peaks, extract two-period hann-windowed grains, re-space the grains at the target period, overlap-add. key property: formants are intrinsic to the grain shape, so they survive untouched — no separate formant flag needed. weak on polyphonic / unvoiced material. clean reference: sannawag/TD-PSOLA:

def shift_pitch(signal, fs, f_ratio):
    peaks = find_peaks(signal, fs)        # autocorr-based pitch-mark detection
    return psola(signal, peaks, f_ratio)  # window+respace+overlap-add

phase vocoder (flanagan & golden, 1966; dolson 1986). stft → for each bin, estimate true frequency from the phase derivative across hops → resynthesize at scaled frame rate → resample to original duration. this is what librosa's effects.pitch_shift does (time-stretch via phase vocoder, then resample). also the engine inside rubber band's R2 mode. weakness: "phasiness" — bins drift apart, transients smear. fixes (laroche & dolson 1999, "phase locking") are what rubber band's R3 (--finer) mode adds.

world resynth (morise et al., 2016). decompose speech into three independent streams: f0, smoothed spectral envelope (cheaptrick), aperiodicity (d4c). modify any of them. resynth. this is the cleanest "replace the f0 curve" pipeline that exists outside the neural world — see section 4.

method formants best on failure mode
td-psola preserved (intrinsic) clean monophonic vocals breathy / unvoiced
phase vocoder requires extra filter broadband / polyphonic phasiness, transient smear
world resynth preserved (envelope split out) speech / vocals not free of artifacts on heavy distortion

4. world / pyworld — the f0-replace pipeline #

this is the one. pyworld is the python wrapper around morise's c library. install: pip install pyworld. has wheels for arm64 macos. the canonical autotune flow is six lines:

import pyworld as pw

# 1. decompose
_f0, t  = pw.dio(x, fs)             # raw f0 candidates (or pw.harvest for accuracy)
f0      = pw.stonemask(x, _f0, t, fs)   # refine f0 to sub-frame precision
sp      = pw.cheaptrick(x, f0, t, fs)   # smoothed spectral envelope (formants)
ap      = pw.d4c(x, f0, t, fs)          # aperiodicity (breath, fricatives)

# 2. modify f0 — this is where autotune happens
f0_corrected = quantize_to_scale(f0, scale_midi_notes)  # see section 5

# 3. resynthesize
y = pw.synthesize(f0_corrected, sp, ap, fs)

note what's not here: there is no pitch-shift step. you literally write the new f0 curve into the synthesizer. sp (formants) and ap (breath texture) are unchanged, so the speaker's identity survives perfectly. this is the api surface we want.

harvest is slower but recovers from the octave errors that bite dio on jeffrey's takes. for vocals always prefer harvest + stonemask.

5. scale quantization with hysteresis #

given an instantaneous f0 in hz and a target scale (set of midi numbers), the snap is straightforward — hz → midi via 69 + 12*log2(f/440), then nearest-member lookup. the trick is hysteresis: if you're sitting between two scale degrees and the f0 wobbles ±10 cents, naive nearest-snap will flip notes every frame, producing a digital trill. fix:

def quantize_to_scale(f0, scale_midi, prev_target=None,
                      hysteresis_cents=30, retain=1.0):
    out = np.zeros_like(f0)
    cur = prev_target
    for i, f in enumerate(f0):
        if f <= 0:                      # unvoiced — keep silent
            out[i] = 0; continue
        midi = 69 + 12*np.log2(f/440)
        # candidates sorted by distance to current frame
        cands = sorted(scale_midi, key=lambda n: abs(n - midi))
        nearest = cands[0]
        if cur is not None and nearest != cur:
            # only switch if we've moved > hysteresis cents past the boundary
            if abs(midi - nearest)*100 + hysteresis_cents > abs(midi - cur)*100:
                nearest = cur
        cur = nearest
        # `retain` interpolates between source pitch (0) and full snap (1)
        corrected_midi = midi + retain * (nearest - midi)
        out[i] = 440 * 2**((corrected_midi - 69)/12)
    return out

retain=1.0 is full hard auto-tune (the t-pain sound). retain=0.6 is melodyne-style — keeps human bend. hysteresis_cents=30 to 50 kills the trill without slowing legit pitch transitions.

6. shortlist — what we can plug in tonight #

lib language offline? install what we get
pyworld python yes pip install pyworld f0 extract + replace + resynth, formants intrinsic
psola python yes pip install psola td-psola via parselmouth/praat — needs target-pitch numpy array
librosa python yes pip install librosa yin, pyin, effects.pitch_shift (phase vocoder)
crepe python gpu-light pip install crepe neural pitch detector — slower but cleanest f0
autotalent c (ladspa) yes source build reference c implementation, gpl2
rubberband c++ yes brew install rubberband (already installed) phase vocoder pitch shift, no f0-replace
sannawag/TD-PSOLA python yes git clone minimal readable td-psola reference

all of the python options install on apple silicon python 3.14 with native wheels in 2026. pyworld's only build dep is numpy + cython.

7. integration sketch — pitchsnap.mjs → world #

the right move is to delegate the per-word pitch step to a small python helper that does world-style f0 replacement. pitchsnap.mjs keeps its responsibilities (whisper alignment, score parsing, grid snap, slice extraction) and calls the helper for the actual correction:

# pop/bin/pitchsnap_world.py — called per-word from pitchsnap.mjs
import sys, numpy as np, pyworld as pw, soundfile as sf

in_wav, out_wav, target_midi, retain = sys.argv[1:5]
target_midi = float(target_midi); retain = float(retain)

x, fs = sf.read(in_wav, dtype="float64")
if x.ndim > 1: x = x.mean(axis=1)

f0_raw, t = pw.harvest(x, fs, f0_floor=80, f0_ceil=600)
f0  = pw.stonemask(x, f0_raw, t, fs)
sp  = pw.cheaptrick(x, f0, t, fs)
ap  = pw.d4c(x, f0, t, fs)

# replace f0 with target — hold the bend with `retain`
target_hz = 440 * 2**((target_midi - 69)/12)
voiced = f0 > 0
f0_new = np.where(voiced, np.exp((1-retain)*np.log(np.maximum(f0,1e-6))
                                  + retain*np.log(target_hz)), 0)

y = pw.synthesize(f0_new, sp, ap, fs)
sf.write(out_wav, y.astype(np.float32), fs)

then in pitchsnap.mjs, replace the rubberband call inside the per-word loop with:

spawnSync("python3", [
  resolve(import.meta.dirname, "pitchsnap_world.py"),
  sliceWav, shiftedWav, String(targetMidi), String(retain)
], { stdio: "inherit" });

next phase: instead of a single target_midi per word, write a per-frame target curve that interpolates between syllable notes — that's the syllable-glide we already plan in pitchsnap.mjs curve mode, but executed by world instead of cross-fading two rubberband renders.


recommendation: integrate pyworld first. it's the only option in this list that lets us replace the f0 curve instead of shifting it, formants stay intact for free, and the install is one line.

pip install pyworld soundfile numpy