Files
reasampler/src/core/instrument/engine/pitch_shift.h
T
daniel 589a8e078b Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch
Generalizes the correlation-aligned SOLA delay line so the feed and the shift are
independent rates over one ring. Unity is bit-identical to the shipped read, asserted
against a hash baseline captured pre-change.
2026-08-02 13:47:19 -04:00

202 lines
13 KiB
C++

#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 <windows.h> 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 <cstddef>
#include <cstdint>
#include <vector>
#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<AudioSample> 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