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:
+45
-9
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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_;
|
||||
|
||||
Reference in New Issue
Block a user