Three ways to move a pitch: yin.h, psola.h, pvoc.h
The user-facing chapter promised that three interchangeable engines land the same intonation. This appendix is about why that is hard: each engine is a claim about what a pitched sound is, and each claim fails somewhere specific and measurable. Two of those failures were found the good way — as failing tests during development — and both are now pinned as contracts rather than patched into vagueness.
These three headers live in the shared DspTap repository (the same home
as the real FFT that machine/spectral.md describes), because a pitch
detector and two shifters are not Max material or even TapTools material —
they are primitives, in the fft.h mold: a double-precision golden model,
a float32 embedded profile pinned against it, allocation-free noexcept
processing, fixed documented latency, and hot loops kept contiguous as
future Helium/HVX backend seams. Every number below is produced by the
shipping code through DspTap's C ABI in notebooks/pitchshift.ipynb, and
gated in test_yin.cpp / test_psola.cpp / test_pvoc.cpp.
A period is a lag that explains the signal: yin.h
Autocorrelation says: a signal is periodic at the lag where it best matches itself. The trouble is that a harmonic-rich signal matches itself rather well at twice the true period too, and "rather well" wins often enough to make naive autocorrelation an octave gambler. YIN (de Cheveigné & Kawahara, 2002) replaces "best match" with "smallest failure": a squared difference function
d(τ) = Σ (x[j] − x[j+τ])², j over the integration window
then divides each lag's failure by the running mean of all failures up to
that lag — the cumulative-mean normalization — so d′(0) ≡ 1 and small
lags stop being free wins. The detector takes the first lag whose
normalized failure dips under an absolute threshold (0.1 by default),
descends to the local minimum, and refines it with a parabolic fit over the
three surrounding values — the sub-sample step that turns an integer lag
grid into a fractional period.
The contract, measured: worst sine error 0.17 cents across 82–988 Hz (including deliberately non-integer periods), worst sawtooth error 0.155 cents with no octave errors — the trap the normalization exists to disarm. Noise and silence report unvoiced, and the threshold gates honestly (a deliberately dirtied sine flips to unvoiced when the threshold is tightened below its measured aperiodicity).
One honest limit, kept on purpose: first dip under threshold scans from short lags to long, so on synthetic material whose fundamental is nearly absent — a formant bump with almost no energy at f₀ — a subharmonic lag that happens to land on an exact integer can dip deeper than the true period's slightly-off-grid dip, and the detector follows it. The notebook demonstrates this deliberately and measures such material with a cepstral oracle instead. Real voices keep enough fundamental that the rule holds; the failure is documented, not hidden.
Finding 1 — a shifter that moves everything except the envelope: psola.h
TD-PSOLA's move is disarmingly physical. Put an analysis mark every period. Cut a two-period Hann grain around each mark. To synthesize a new pitch, lay the grains back down at a new spacing — period/ratio — and sum. The windows are arranged to sum to one at the identity, grains are scaled by 1/ratio to keep the sum flat elsewhere, and synthesis marks are placed with sub-sample precision (each grain resampled through the same 4-point Hermite kernel the rest of the family uses) so mark rounding never becomes pitch jitter. The file adds one real-time honesty: marks come from a free-running period-synchronous scheduler, not glottal-epoch estimation — the standard practical simplification — and the caller supplies the period, so detector and shifter stay independently testable.
Then the finding, told as it happened. The first shift-accuracy test fed the shifter a pure sine at ratio 2 and got back 0.0000 — silence, from a correct implementation. Because that is what PSOLA does: re-spacing period-synchronous grains resamples the source's spectral envelope at the new harmonic spacing. A voice's envelope is wide — formants — so the new harmonics sample it fine, which is exactly the celebrated property: formants stay put while pitch moves. A pure sine's envelope is a single spike at f₀, and after an octave up the new harmonic grid (2f₀, 4f₀, …) contains nothing at f₀ — the output honestly, correctly vanishes. One property, two faces.
The response was not to patch the algorithm into something less itself. The
shift tests were rewritten onto voice-like material (a normalized
band-limited sawtooth: ±8 cents across ratios 0.5–2.0 at healthy level),
and the pure-tone behavior got its own pinning test,
PureToneOctaveUpThinsOut, so that if this property ever changes, someone
is forced to explain why. The header now opens with the warning label:
know what PSOLA is; feed it harmonics; for pure tones use a
waveform-preserving shifter.
Latency is fixed at 2·max_period + 2 samples — the price of grains that
must be fully received before they can be laid back down.
Finding 2 — the naive phase vocoder loses half its level: pvoc.h
The textbook pitch shifter looks like four honest lines: STFT with Hann
windows at 4× overlap; per-bin instantaneous frequency from the
frame-to-frame phase increment; remap each analysis bin k to synthesis
bin round(k·ratio); accumulate each synthesis bin's phase at its scaled
frequency and inverse-transform. It is in tutorials everywhere. Measured on
a unit sine, it delivers 0.14–0.46 of the input level at fractional
ratios — more than half the signal simply gone — and its "identity" at
ratio 1 is a sine of the right frequency with the wrong waveform.
Two structural reasons. First, a single partial does not live in one bin;
it lives in a Hann mainlobe pattern across four-ish bins, and
round(k·ratio) scatters that pattern (220 Hz × 1.5 lands on bins
{5, 6, 8, 9} — nothing at the true target, 7.04). Second, free-running
per-bin phase accumulators destroy the phase relationships across the lobe,
and the overlap-add — which is a resampling filter with real opinions —
partially cancels what remains.
The shipping design is Laroche–Dolson peak-region shifting. Find the spectral peaks (local maxima over ±2 bins, gated 80 dB below the frame's strongest bin so the noise floor cannot claim regions). Split the spectrum into regions around them. Translate each region rigidly by an integer bin offset — the lobe pattern survives intact, phase relationships and all — and rotate the whole region by a single accumulated per-hop phase ψ.
And here is the bug that cost an afternoon and earned its own comment
block: ψ must accumulate the full per-hop frequency difference,
ψ += 2π·hop·f·(r−1)/N, not the sub-bin residual left after the integer
shift. An integer bin shift is implemented, in effect, by a modulator
e^(2πi·shift·n/N) — but n is the frame-relative sample index, so that
modulator restarts every frame and contributes nothing to frame-to-frame
phase advance. Subtract the shift from ψ (the "obvious" refinement) and
every frame disagrees with the last about where the shifted partial's phase
should be; the overlap-add quietly shreds the signal. With ψ carrying the
full difference, the measured contracts land: sub-cent frequency accuracy
at every tested ratio, ~0.95 level everywhere, and — because at ratio 1
every shift and every ψ increment is exactly zero and the analysis phases
pass straight through — exact waveform identity, one frame late, to
7.8 × 10⁻¹⁶.
The envelope as a filter: LPC formant preservation
set_formant(true) adds the classic source-filter correction. Per analysis
frame: autocorrelate the windowed time frame to lag 48, run Levinson–Durbin
(always in double — an order-48 recursion in float32 is not a place to
economize), and evaluate the prediction polynomial's magnitude over all
bins with one extra FFT of its coefficients — the same transform engine,
one more call. That gives a spectral envelope E(k) = 1/|A(e^jωk)|, and
every relocated bin trades envelopes: content moving from bin k to bin
j is scaled by E(j)/E(k) (clamped to ±24 dB so a near-zero envelope
cannot mint gain). The excitation moves; the envelope stays. Measured: a
synthetic 800 Hz formant on a shifted-up-a-fifth voice stays at 800 Hz
with the flag on (band-energy ratio 62:1) and dutifully chipmunks to
1200 Hz with it off. At ratio 1 the correction is E(k)/E(k) — exactly
unity — so the identity contract survives the feature untouched. The
method is implemented from the published literature only, which in this
corner of DSP is a policy statement, not just a citation habit.
The house pattern
All three files repeat the fft.h discipline because it keeps paying:
basic_*<Sample> templates with double as the golden model and float
as the embedded profile, cross-precision agreement pinned by tests;
geometry fixed at construction and every buffer allocated there;
noexcept, allocation-free processing; latency as a number in the header,
not a vibe; and the expensive inner loops (YIN's difference function above
all) written as plain contiguous arithmetic so a Helium or HVX backend can
slot in behind the same contract with the scalar build remaining the
oracle.
Checkpoint
A detector that measures failure-to-match instead of match, normalized so short lags stop cheating, refined below the sample grid — sub-cent, octave- safe, honest about the one synthetic that fools its first-dip rule. A grain shifter whose deepest property — resampling the spectral envelope — is both its celebrated feature and its pure-tone failure, pinned from both faces. A phase vocoder that works because peaks move as rigid families with one phase register each, carrying the full frequency difference — since the integer shift's modulator restarts with every frame. And an LPC envelope trade that lets the excitation move while the mouth stays. Three claims about what a pitched sound is; three sets of receipts.