Γ-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
+73
View File
@@ -0,0 +1,73 @@
#pragma once
// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a
// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs);
// the two rates are independent over one delay ring, and only their difference reaches the
// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed.
#include <cstdint>
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine {
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
// accepts. Two independent reasons they are here and not wider:
// - the ceiling is what bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame source
// frames), which is the RT-safety argument for feeding a variable count at all;
// - the splice search can only align a period it can see. The tap's delay drifts at
// |rate - shift| per frame, so a wide rate over a deep DOWN-shift splices faster than one
// period of the output tone and the correlation stops holding the pitch: measured at rate
// 4.0 with -24 st, the observed period came out 539 frames against 785 wanted.
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read
// bit-identical.
inline double clampStretchRate(double rate) {
if (!(rate > 0.0)) return 1.0;
if (rate < kStretchRateMin) return kStretchRateMin;
return rate > kStretchRateMax ? kStretchRateMax : rate;
}
// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT
// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output
// frame with no residue carried — bit for bit the pre-stretch feed.
class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
void start(std::int64_t frame) {
frame_ = frame;
debt_ = 0.0;
}
// Adds one output frame's worth of source at `rate` and returns how many whole source
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
// lives here rather than at the caller because this return value is the loop bound.
std::int64_t due(double rate) {
debt_ += clampStretchRate(rate);
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
debt_ -= static_cast<double>(whole);
return whole;
}
// The next due source frame, wrapped into the sustain loop, advancing the cursor past it.
// Advances even past the playable span — the caller freezes the shifter's writer there, and
// a cursor that stalled instead would re-feed one frame forever.
std::int64_t next(const loop::ResolvedLoop& lp) {
if (lp.active) {
while (frame_ >= lp.end) frame_ -= lp.length;
}
return frame_++;
}
std::int64_t frame() const { return frame_; }
private:
std::int64_t frame_ = 0;
double debt_ = 0.0; // fractional source frames carried into the next output frame
};
} // namespace reasampler::instrument::engine