The time-domain ratio pitch shifter — the one that warbles. More...
#include <pulp/signal/crossfade.hpp>#include <pulp/signal/dc_blocker.hpp>#include <pulp/signal/delay_line.hpp>#include <pulp/signal/denormal.hpp>#include <pulp/signal/interpolator.hpp>#include <pulp/signal/rng.hpp>#include <pulp/signal/slew_limiter.hpp>#include <pulp/signal/units.hpp>#include <algorithm>#include <cmath>#include <cstddef>#include <cstdint>Go to the source code of this file.
Classes | |
| class | pulp::signal::PitchShifterT< SampleType > |
| Time-domain ratio pitch shifter: dual-tap crossfaded modulated delay. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::PitchShifter = PitchShifterT<float> |
| using | pulp::signal::PitchShifter64 = PitchShifterT<double> |
Enumerations | |
| enum class | pulp::signal::PedalMode : std::uint8_t { pulp::signal::whammy , pulp::signal::harmony , pulp::signal::detune , pulp::signal::dive } |
| How the expression pedal maps to a shift target. More... | |
| enum class | pulp::signal::PitchInterp : std::uint8_t { pulp::signal::linear , pulp::signal::cubic } |
| Fractional-delay read quality for the two taps. More... | |
| enum class | pulp::signal::ShiftSource : std::uint8_t { pulp::signal::pedal , pulp::signal::direct } |
| Which control feeds the shift target. More... | |
The time-domain ratio pitch shifter — the one that warbles.
RT contract: prepare(sample_rate) allocates the circular delay buffer once and may be called off the audio thread; set_*, process, and reset never allocate, never lock, and never throw. State is POD apart from the delay storage that prepare owns (the same arrangement DelayLineT has); a default-constructed instance is valid but must see prepare before process. — USE: the time-domain, warble-forward pitch shifter — expression-pedal whammy bends, dive-bombs, fixed-interval harmony, and detune-thickening. It does no pitch tracking: it shifts the whole spectrum by r = 2^(semitones/12), so a single note tracks cleanly and a chord intermodulates (that grain is the effect, not a defect). Reach for the phase-vocoder shifter instead when you need warble-free, formant-preserved shifting; reach for the MIDI-side harmonizer when you need scale-aware diatonic harmony.
Lineage: Dattorro's delay-line-modulation pitch shifter (Jon Dattorro, "Effect Design, Part 2: Delay-Line Modulation and Chorus," JAES 45(10), pp. 764–788, October 1997). A read tap at delay d(n) walks the input stream at 1 − d(d)/dn samples per output sample, so ramping the delay linearly at 1 − r resamples the input by exactly r:
d(d)/dn = 1 − r (Eq. 3.1) r = 2^(semitones/12) = 2^(cents/1200) (Eq. 3.2)
A finite line cannot ramp forever, so the delay is a sawtooth over a window of L samples with phase p ∈ [0,1), d = p·L, and
dφ = (1 − r) / L per sample, signed (Eq. 3.3)
Each sawtooth reset is a delay discontinuity. Two taps share the ramp half a window apart and are crossfaded with a raised-cosine window that is zero at each tap's own reset:
pA = p pB = frac(p + 0.5) wA = sin²(π·pA) wB = sin²(π·pB) = cos²(π·pA) (Eq. 3.4) wet = wA·tapA + wB·tapB
wA + wB ≡ 1 at every sample, so the pair is a convex combination and the wet leg can never exceed the buffer's peak.
The sawtooth — and therefore the crossfade — completes one cycle per window traversal. fs cancels (series law 7):
f_warble = |1 − r| · 1000 / window_ms [Hz] (Eq. 3.5)
Short windows buzz, long windows smear. A 12-cent detune at an 80 ms window warbles at 0.087 Hz — a built-in chorus with no LFO. A dive toward r → 0 drives |1−r| → 1 and the warble climbs, which is why the bottom of a dive-bomb turns to grain. None of this is a tuned curve; it falls out of the topology, and warble_hz() reports it.
Non-obvious and worth knowing before measuring anything: the two taps read the same stream half a window apart, so for an input partial at f they are separated by the phase
ψ = π · f · L / fs = π · f · window_ms / 1000 [rad]
Write q = f · window_ms / 1000 = ψ/π. When q is an EVEN integer the taps are exactly in phase, the crossfade sums two identical signals, and that partial is shifted with no warble at all — a single clean spectral line at r·f. When q is an ODD integer the taps are in antiphase, the line at r·f is fully suppressed, and all the energy sits in two lines at r·f ± f_warble, each at exactly half the input amplitude. Every other q lands somewhere between. So the wet output of a broadband source is a comb of lines at f + k·(1−r)·1000/window_ms whose weights sweep with q: the warble depth is a function of frequency, not a constant. tap_phase_pi() exposes q because a measurement that ignores it will read a correct implementation as broken (see the suite's characterisation cases).
crossfade.hpp says equal-power avoids the mid-fade dip when the two legs are DECORRELATED, and equal-gain avoids a level bump when they are correlated. The two taps here are the same signal at different delays — decorrelated in phase for most content — so equal-power is what that guidance would pick. This module uses equal-gain anyway, because the −3 dB dip equal-power exists to remove is precisely the audible warble this effect is made of. Equal-power taps would produce a quieter, smoother, and wrong shifter. The dry/wet mix has no such requirement and uses equal-power as specified. Both laws come from crossfade_gains; neither is hand-rolled.
No oversampling and no anti-alias pre-filter, and — unlike most blocks that say "law 4 does not apply" — this one does alias. There is no nonlinearity anywhere in the signal path (the taps, the crossfade, and the mix are all linear), so there is no nonlinear aliasing to oversample away. But a ratio shifter is a RESAMPLER: at r > 1 it decimates, so input content above fs / (2r) folds back into the band. At 48 kHz and r = 2 everything above 12 kHz folds; a 15 kHz partial arrives at 18 kHz, not 30 kHz. Linear interpolation's mild delay-dependent lowpass attenuates but does not remove it. This is the documented behaviour of the topology and of the pedals it descends from, it is measured rather than assumed (see the aliasing case in the suite), and it is left in: band-limiting the input to fs/(2r) would dull every up-shift to protect a partial most sources do not have. Callers who need alias-free up-shift should band-limit ahead of this block or use a spectral shifter.
No phase vocoder (different algorithm, different sound — the warble is the point), no pitch tracking or scale-aware harmony (an analysis problem that belongs with the MIDI FX side; PedalMode::harmony is fixed-interval and honestly non-tracking), no formant correction (the coupled formant shift is this shifter's character), and no feedback. The last one is load-bearing: with no regeneration path the peak output is analytically bounded — both legs are bounded by the input peak and the equal-power mix sums them at worst at the 50/50 point — so worst_case_gain = √2 ≈ 1.4142. Adding regeneration would make that number a guess and it would have to be re-derived from a tested invariant (series law 8).
Published: r = 2^(s/12) and the interval table are 12-tone equal temperament, definitional rather than fitted. Everything else — window length, glide times, detent snap width, dive floor, drift depth — is a design parameter carrying a default and a range on its own constant. HONEST GAP: no citable literature exists for the crossfade-window length, detent table, glide defaults, or dive floor of a pedal-style shifter; commercial products do not publish them. The topology is Dattorro's; the calibration consists of explicit original design choices.