Γ-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
+71 -41
View File
@@ -19,6 +19,7 @@
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/time_stretch.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler {
@@ -108,7 +109,14 @@ public:
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
// the difference-seeded declick compensation on the first frame after the restart (see
// kDeclickDecay above). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
//
// `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame,
// clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an
// argument, not a member set separately) because the loop fold and the contour scale it
// composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the
// read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
double stretchRate = 1.0);
// Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
@@ -441,56 +449,75 @@ private:
// and the amp envelope shapes the filtered result (drive included).
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// Feed the shifters the source stream at unity rate (duration held) and transpose
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
// rings were primed with that window at start()), under the same sustain-loop wrap
// rule, reading integer source frames (nothing to interpolate). Past the last real
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
if (loop.active) {
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
}
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// the source is exhausted — feeding the held last sample instead would give the
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
// cadence, growing toward the note end). Freezing the shifter's writer means no
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames (nothing to interpolate).
const bool stereoOut = stereo && haveR && shiftR_.configured();
// The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
// Once the feed reaches that bound the source is exhausted — feeding the held last
// sample instead would give the splice correlation a DC plateau it can't align on
// (periodic troughs at the splice cadence, growing toward the note end). Freezing the
// shifter's writer means no padding ever enters the ring, so the splice machinery
// keeps recycling the frozen all-real tail — a continuous tone through the voice's
// own end. The sustain-loop path never gets here: the wrap keeps the cursor inside
// the loop forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
if (stereoOut) shiftR_.setShiftRatio(shift);
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
// written without producing output; the last rides the ordinary 1-in-1-out
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const std::int64_t due = stretch_.due(stretchRate_);
AudioSample feedL = 0.0f, feedR = 0.0f;
bool fed = false;
for (std::int64_t k = 0; k < due; ++k) {
if (fed) { // an earlier frame of this batch: write-only, no output
shiftL_.writeFrame(feedL);
if (stereoOut) shiftR_.writeFrame(feedR);
}
const std::int64_t q = stretch_.next(loop);
if (q >= feedBound) {
shiftL_.freezeTail(); // idempotent; input ignored while frozen
if (stereoOut) shiftR_.freezeTail();
feedL = feedR = 0.0f;
} else {
const double xw = crossfadeWeight(loop, static_cast<double>(q));
feedL = crossfadedSource(pcm, loop, q, xw);
if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
}
fed = true;
}
const double shiftedL =
fed ? static_cast<double>(shiftL_.process(feedL))
: static_cast<double>(shiftL_.processNoInput());
outL = shiftedL;
if (stereo) {
if (haveR && shiftR_.configured()) {
if (stereoOut) {
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
// splice decisions via processLinked — one correlation search, one lag, one
// splice schedule for both channels (standard stereo SOLA). An independent
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
// every splice: stereo image wander at the splice cadence + mono-sum
// combing. Each shifter is still processed EXACTLY ONCE per output frame
// (never twice — that would advance its heads twice and corrupt the state).
// (never twice — that would advance its heads twice and corrupt the state);
// the batch's earlier frames go through writeFrame, which produces none.
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
// primes it for genuinely stereo samples, and a stale un-primed ring must
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
fed ? static_cast<double>(
shiftR_.processLinked(feedR, shiftL_.lastSplice()))
: static_cast<double>(
shiftR_.processNoInputLinked(shiftL_.lastSplice()));
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
// shifted value from the mono feed; mirror it to R. Do NOT call
@@ -498,9 +525,10 @@ private:
outRlocal = shiftedL;
}
}
++feedPos_;
// Preserve advances the read head at the SOURCE rate (duration preserved).
ratio_ = 1.0;
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
ratio_ = stretchRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -676,9 +704,10 @@ private:
//
// The shifter rings are primed at start() with the first window of the actual upcoming
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// silence, and splices always land in real history. feedPos_ is the integer source frame
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// silence, and splices always land in real history. stretch_ is the integer source frame
// fed to the shifters next plus the fractional rate debt; it runs one window ahead of
// readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
// window apart at every stretch. Once it passes the last real frame (Gate: sample end;
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// primeBuf_ is the presized scratch the prime stream is assembled into.
@@ -686,7 +715,8 @@ private:
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::int64_t feedPos_ = 0;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()