#pragma once // pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core). // Time-domain delay-line 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 old/new taps crossfade (raised-cosine). // // The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio = // pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via // process() holds duration and moves pitch; feeding faster/slower via writeFrame() / // processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples // to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is // what the "WDL_Resampler is not a Preserve engine" invariant asks for. // // Regression history — do not revert any of these: // - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann // crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative // phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at // every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean. // Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired. // - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls unconditionally, // which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap // to WDL, if ever wanted, happens at the shell, never in this pure core. // - prime() fills the ring with real upcoming source before streaming starts, not silence: a // silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at // note onset. Since the caller owns the whole decoded sample up front, prime() can know the // future and gives output frame 0 == source frame 0 with zero structural latency at any ratio. // - freezeTail() parks the write head once the source is exhausted instead of feeding the last // real sample as a DC plateau: splices against a flat plateau are unalignable and produced // ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against // the ring's real frozen tail. // // RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only // copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no // locks; the correlation search is a bounded burst that fires once per splice cadence // (window / |ratio-1| frames), never per frame. #include #include #include #include "core/audio/peaks.h" // AudioSample (float) namespace reasampler::instrument::engine { using audio::AudioSample; // The splice decision made by the most recent process()/processLinked() call — the linked-lag // stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and // channel 1 as the follower: after the master's process() for a frame, the caller passes // master.lastSplice() to the follower's processLinked() for the same frame, and the follower // applies exactly this decision instead of running its own search. Both channels therefore // share one lag and one splice schedule — independent per-channel searches drew an inter-channel // offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on // a mono sum. struct SpliceEvent { bool fired = false; // a splice was scheduled on this frame std::int64_t jump = 0; // the clamped nominal jump actually applied (signed) std::int64_t lag = 0; // correlation best integer lag double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen }; // A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked: // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the // two rings stay sample-aligned. // // Default-constructed is inert: with no configure() it passes input through unchanged (ratio // 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change. class PitchShifter { public: // Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom). // <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state. // A larger window means fewer splices and a deeper alignment search; a primed shifter has no // added latency regardless of window size (see prime()). void configure(std::int64_t windowFrames); // Pre-fills the ring with the first `count` frames of the upcoming source stream and parks // the tap on src[0]; the caller then feeds process() the stream continuing at src[count]. // `count` is clamped to [0, window()]. If the playable source is shorter than one window, // prime only the real span and call freezeTail() immediately after — never pad with silence // and declare it valid; padded zeros are splice targets and reintroduce the onset gap. // RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched. void prime(const AudioSample* src, std::int64_t count); // Silence-prime: zero the ring, park the tap one window behind the writer, declare that // window silence as valid history. Kept for callers with no access to the upcoming stream // (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses // prime() instead). At unity a warmed shifter is a bit-exact window() delay. void warm(); // 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices // ever fire. Cheap enough to set per frame. 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); // Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY // to size a splice crossfade safely: the outgoing tap closes on the write head at // (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the // source is fed slower than the output runs and the tail of the fade reads lapped content. // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit. void setFeedRate(double rate); // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap // during a splice fade), then advances the write head and tap(s). When the active tap leaves // its safe delay band, a correlation-aligned splice is scheduled. AudioSample process(AudioSample in); // Follower-mode process: identical to process() except the splice decision isn't computed // here — when `master.fired` is true this frame splices with exactly the master's // jump/lag/frac/fadeLen. The caller must process the master channel first each frame and // pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so // their ring state advances in lockstep. RT-safe: same guarantees as process(). AudioSample processLinked(AudioSample in, const SpliceEvent& master); // Writes one source frame WITHOUT producing an output frame — the stretch path's surplus // input when the source is consumed faster than the output runs. No splice can fire here: // splices are decided on the read side. No-op while unconfigured or tail-frozen, and it // deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected. // RT-safe. void writeFrame(AudioSample in); // Produces one output frame WITHOUT consuming a source frame — the stretch path's starved // output frame when the source is consumed slower than the output runs. Identical to // process()/processLinked() in every other respect. Returns 0 while unconfigured (there is // no input to pass through). RT-safe. AudioSample processNoInput(); AudioSample processNoInputLinked(const SpliceEvent& master); const SpliceEvent& lastSplice() const { return lastSplice_; } // Call once the source stream is exhausted — no real frame remains to feed process(). // Freezes the write head: subsequent process() calls ignore input and write nothing, but // read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so // every late splice stays waveform-aligned against real content instead of a DC plateau. // Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm(). void freezeTail(); bool tailFrozen() const { return tailFrozen_; } // Reset running state to silence (ring zeroed, heads re-seeded mid-band, fill count zeroed) // WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window. // Follow with prime() (or warm()) before streaming: a bare reset has no declared history, // so an immediate up-shift would starve its splices. 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 // Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled // span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind // the writer (the caller just computed it for the trigger test). Records the decision in // lastSplice_ for a linked follower channel. void splice(std::int64_t nominalJump, double delay); // Applies a master channel's already-computed splice decision verbatim (no search) — // the follower half of the linked-lag contract. Mirrors it into lastSplice_. void applySplice(const SpliceEvent& ev); // Shared body of process()/processLinked(); `linked` null = master mode (own trigger + // search), non-null = follower mode (splice iff linked->fired, with linked's decision). // `write` false is the starved stretch frame: read/splice/advance the taps, but consume no // input and hold the write head (the same shape tail-freezing already takes). AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write); 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) std::int64_t filled_ = 0; // frames of valid history behind the writer; splice() // clamps its up-jump to this so it never lands in // unwritten silence double ratio_ = 1.0; // current shift ratio (>0) double feedRate_ = 1.0; // source frames written per output frame; splice-fade only SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared // at the top of every frame, set on a splice bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the // frozen real tail, drains toward the writer at ratio_ // (not ratio_-1) per frame — splice() scales the fade // by that rate }; } // namespace reasampler::instrument::engine