fix(preserve): GA3 tail wind-down — freeze the SOLA writer at source exhaustion so the tail recycles frozen real content (no DC-splice chop through the final window + release)

This commit is contained in:
2026-07-28 09:50:28 -04:00
parent 1d2da9d456
commit 917626a287
6 changed files with 353 additions and 28 deletions
+45 -9
View File
@@ -47,6 +47,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
return;
}
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
@@ -95,6 +96,25 @@ void PitchShifter::reset() {
}
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
}
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
// window) content mid-fade. `+1` keeps fadeLen_ > fadePos_, so t stays < 1 in process().
if (fading_) {
double dB = static_cast<double>(writePos_) - posB_;
const double len = static_cast<double>(ringLen_);
while (dB < 0.0) dB += len;
while (dB >= len) dB -= len;
const double left = (dB - 2.0) / ratio_;
const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1;
fadeLen_ = std::min(fadeLen_, fadePos_ + leftFrames);
}
}
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
@@ -113,6 +133,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
fadePos_ = 0;
fadeLen_ = 0;
filled_ = count;
tailFrozen_ = false; // a fresh note-on always starts with a live writer
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
}
@@ -129,6 +150,7 @@ void PitchShifter::warm() {
fadePos_ = 0;
fadeLen_ = 0;
filled_ = window_;
tailFrozen_ = false;
}
void PitchShifter::setShiftRatio(double ratio) {
@@ -259,13 +281,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
// mid-fade is covered by the same margin for any realistic per-frame bias.
//
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
// included: splices fire in the frozen tail because the delay now drains at unity too).
fadeLen_ = fadeFrames_;
if (ratio_ > 1.0) {
const double headroom = static_cast<double>(dLow_) - (ratio_ - 1.0) - 2.0;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
// at very high sample rates (where headroom/(ratio_-1.0) could overflow int64).
// at very high sample rates (where headroom/drainRate could overflow int64).
const double safeDbl = headroom > 0.0
? std::min(headroom / (ratio_ - 1.0), static_cast<double>(fadeFrames_))
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
: 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
}
@@ -278,8 +306,13 @@ AudioSample PitchShifter::process(AudioSample in) {
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
// the read/splice/fade machinery below runs unchanged over the frozen content.
if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
@@ -306,9 +339,12 @@ AudioSample PitchShifter::process(AudioSample in) {
}
}
// 4. Advance heads: write head one frame (source rate), tap(s) by the shift ratio.
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
// tap(s) by the shift ratio.
if (!tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
}
const double len = static_cast<double>(ringLen_);
posA_ += ratio_;
while (posA_ >= len) posA_ -= len;
+33
View File
@@ -40,6 +40,17 @@
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence.
//
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
// Splices landing in or referenced against it were unalignable, so the tap alternated
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
// displaced real ring history (the DAW report: periodic troughs "almost like ring
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
// own note end. See freezeTail() below.
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same).
@@ -107,6 +118,23 @@ public:
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
// their input and write nothing, but read, splice, and crossfade exactly as before over
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
// note end as the plateau displaced real history). With the writer frozen the padding
// never enters the ring: every splice stays waveform-aligned against real content and
// the output remains a continuous tone — the final <= one window recycles the frozen
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
// RT-safe (flag + bounded arithmetic, no allocation); 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,
@@ -148,6 +176,11 @@ private:
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0)
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
// recycles the ring's frozen real tail, splices still
// aligned. With the writer parked, a tap drains toward it
// at ratio_ (not ratio_-1) per frame — splice() scales the
// live fade by that rate.
};
} // namespace reasampler
+19 -17
View File
@@ -562,29 +562,30 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
// there is nothing to interpolate). Past the sample end the stream is silence — the
// shifter keeps transposing the real tail it already holds.
// there is nothing to interpolate). Past the last real frame the shifter's writer is
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
}
// validThrough_ tail clamp (symmetric with the onset filled_ clamp in pitch_shift).
// feedPos_ runs one window AHEAD of readPos_; past the last real source frame the feed
// would write zeros into the ring, letting splices land in a silent tail — the same
// burst/gap/burst stutter as the onset zero-gap (just at note END for up-shifts).
// For Trigger mode the last real frame is playEnd_-1 (the user's chosen stop); for Gate
// it is frameCount-1 (the sample's own end). When feedPos_ overruns this bound, clamp
// to the last real frame — the shifter holds that frame's content rather than ingesting
// silence, so splices always land in real-content history at BOTH ends of the note.
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
// troughs at the splice cadence, growing toward the note end as the plateau displaced
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
// still waveform-aligned — a continuous tone through the final window and the release,
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
// Clamped read position: feedPos_ may legitimately exceed feedBound (it just tracks
// where we "would" be), so clamp only the read, not the counter itself.
const std::int64_t clampedFeedPos =
(feedPos_ < feedBound) ? feedPos_ : (feedBound - 1);
const bool feedOk = (clampedFeedPos >= 0 && clampedFeedPos < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(clampedFeedPos)] : 0.0f;
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -596,7 +597,8 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// heads twice and corrupt the OLA state). 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 into this one.
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(clampedFeedPos)] : 0.0f;
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
} else {
+5 -2
View File
@@ -568,8 +568,11 @@ private:
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
// primeBuf_ is the presized scratch the prime stream is assembled into (never touched
// outside start()).
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
// Trigger: playEnd_) the shifters' writers are FROZEN — 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
// (never touched outside start()).
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;