#pragma once // pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" // engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES // (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts // out of its safe delay band it is relocated by a nominal window jump REFINED BY A // CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new // taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and // output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same // wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path. // // WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the // naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head // distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs // carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency- // dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint // nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine // came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity // freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump // is snapped to the best waveform match within a bounded lag search — a pure sine's jump // lands on an integer period count and the output stays a single shifted tone. // // WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was // route (a) `WDL_SimplePitchShifter`. But its include chain // (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> // #include ` unconditionally, which CANNOT enter the pure sampler_core module // (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither // SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native // pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same // PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at // the SHELL, never in the pure core. // // PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. // Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / // wav_trim do the same). // // RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio // thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state // latency is reached before the first real sample (no cold-start click). `process()` does // NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time // correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag // range) that fires once per splice cadence (window / |ratio-1| frames), never per frame. #include #include #include #include "peaks.h" // AudioSample (float) namespace reasampler { // A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel; // a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching // the S7 "one read head, per-channel value" idiom of the core. // // The default-constructed shifter is INERT: with no configure() it passes input through // unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is // byte-identical to the pre-S16 engine. class PitchShifter { public: // Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x // that for splice/search headroom) and derive the fade/search geometry. `windowFrames` // <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by // zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running // state. A larger window = fewer splices and a deeper alignment search but more latency // (steady-state latency stays window/2); the shell picks it from kPreserveWindowMs. void configure(std::int64_t windowFrames); // Pre-fill the ring with silence (one full window of zero writes) so the read tap reaches // steady state before the first real sample. Removes the cold-start seam (the S16 "onset // click absent" requirement) — call once at voice allocation after configure(). No-op when // unconfigured (pass-through needs no warm-up). void warm(); // The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. // 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is // fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored // (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it. void setShiftRatio(double ratio); // Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). // RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured // (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write // head, reads the active tap (crossfading against the outgoing tap while a splice fade is // live), then advances the write head by one and the tap(s) by the shift ratio. When the // active tap leaves its safe delay band, a correlation-aligned splice is scheduled. AudioSample process(AudioSample in); // Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded) // WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window. void reset(); // True once configure() sized a real ring (window > 1). A pass-through shifter is false. bool configured() const { return window_ > 1; } std::int64_t window() const { return window_; } private: double readTap(double pos) const; // fractional ring read, linear interp void splice(std::int64_t nominalJump); // relocate the active tap, start the fade std::vector ring_; // delay line, length `ringLen_` == 2 * window_ std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through std::int64_t ringLen_ = 0; // ring length (2 * window_): splice + search headroom std::int64_t writePos_ = 0; // integer write head into the ring (source rate) double posA_ = 0.0; // active read tap (advances at the shift ratio) double posB_ = 0.0; // outgoing tap during a splice crossfade bool fading_ = false; // a splice crossfade is in flight std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_) std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4) std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice — // ratio-scaled at splice time so an up-shift's outgoing // tap can never drain into the writer mid-fade std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so // the reference read forward from the tap stays behind // the writer BY CONSTRUCTION at an up-splice) std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift) std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift) double ratio_ = 1.0; // current shift ratio (>0) }; } // namespace reasampler