Γ-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.
This commit is contained in:
2026-08-01 19:06:06 -04:00
parent e589addc54
commit 589a8e078b
10 changed files with 873 additions and 71 deletions
+34 -5
View File
@@ -1,11 +1,16 @@
#pragma once
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// 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). Source and output are
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// `readPos_ += ratio_` resample path).
// 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
@@ -89,6 +94,13 @@ public:
// 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
@@ -103,6 +115,20 @@ public:
// 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().
@@ -137,7 +163,9 @@ private:
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).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
// `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
@@ -161,6 +189,7 @@ private:
// 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