From 917626a28745cf6a8b93bfbacf5b35c2e65f187c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 09:50:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(preserve):=20GA3=20tail=20wind-down=20?= =?UTF-8?q?=E2=80=94=20freeze=20the=20SOLA=20writer=20at=20source=20exhaus?= =?UTF-8?q?tion=20so=20the=20tail=20recycles=20frozen=20real=20content=20(?= =?UTF-8?q?no=20DC-splice=20chop=20through=20the=20final=20window=20+=20re?= =?UTF-8?q?lease)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/vst/pitch_shift.cpp | 54 +++++++++++--- src/vst/pitch_shift.h | 33 +++++++++ src/vst/sampler_core.cpp | 36 +++++----- src/vst/sampler_core.h | 7 +- tests/test_pitch_shift.cpp | 111 ++++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 140 ++++++++++++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 28 deletions(-) diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp index 72cd27b..682752e 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/vst/pitch_shift.cpp @@ -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(writePos_) - posB_; + const double len = static_cast(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(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(dLow_) - (ratio_ - 1.0) - 2.0; + const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0); + if (drainRate > 0.0) { + const double headroom = static_cast(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(fadeFrames_)) + ? std::min(headroom / drainRate, static_cast(fadeFrames_)) : 1.0; fadeLen_ = std::max(1, static_cast(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(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(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(ringLen_); posA_ += ratio_; while (posA_ >= len) posA_ -= len; diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h index f368496..5fdbf1d 100644 --- a/src/vst/pitch_shift.h +++ b/src/vst/pitch_shift.h @@ -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 diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 33c4bc5..9c3bba2 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -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(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(feedPos_)] : 0.0f; const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); const double shiftedL = static_cast(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(clampedFeedPos)] : 0.0f; + if (exhausted) shiftR_.freezeTail(); + const AudioSample feedR = feedOk ? pcmR[static_cast(feedPos_)] : 0.0f; shiftR_.setShiftRatio(shift); outRlocal = static_cast(shiftR_.process(feedR)) * gain; } else { diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index c6f893c..93df898 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -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_; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 371ac6b..d036d4d 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -342,6 +342,116 @@ static void testUnityBitExactAndLatency() { } } +// --- 7. Tail wind-down (GA3): freezeTail() at source exhaustion keeps the output a +// continuous, full-amplitude tone at the shifted frequency — the splice machinery +// recycles the ring's frozen ALL-REAL tail instead of chopping against held-DC +// padding (the DAW "ring modulation" troughs growing toward the note end). --- +static void testFreezeTailContinuousTone() { + const std::int64_t w = 2205; + const double f0 = 1.0 / 196.37; // non-integer period (the test-5 adversarial tone) + const std::size_t stream = 20000; // frames fed before exhaustion (several splice cycles) + const double ratios[] = {std::pow(2.0, 7.0 / 12.0), // +7 st (the DAW report regime) + 2.0, // octave up + std::pow(2.0, 24.0 / 12.0), // +24 st: fast frozen drain + std::pow(2.0, -5.0 / 12.0), // -5 st (down-shift tail) + 1.0}; // unity: frozen delay drains at 1 — + // splices NOW fire even at unity + for (double r : ratios) { + PitchShifter ps; + ps.configure(w); + std::vector src(stream + static_cast(w)); + for (std::size_t i = 0; i < src.size(); ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * f0 * static_cast(i))); + } + ps.prime(src.data(), w); + ps.setShiftRatio(r); + for (std::size_t i = 0; i < stream; ++i) { + (void)ps.process(src[i + static_cast(w)]); + } + // Source exhausted: freeze (idempotent) and keep producing for one full window — the + // longest a Voice runs frozen (its own note end lands within a window of exhaustion). + CHECK(!ps.tailFrozen()); + ps.freezeTail(); + ps.freezeTail(); // double-freeze harmless + CHECK(ps.tailFrozen()); + const std::size_t tail = static_cast(w); + std::vector out(tail); + for (std::size_t i = 0; i < tail; ++i) { + out[i] = static_cast(ps.process(0.0f)); // input ignored while frozen + CHECK(std::isfinite(out[i])); + } + // (a) No dead stretches: a unit-amplitude tone dwells below 0.05 only a few frames + // per zero crossing; the pre-GA3 DC chop ran hundreds. + std::size_t worstGap = 0, run = 0; + for (std::size_t i = 0; i < tail; ++i) { + if (std::fabs(out[i]) < 0.05) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 24); + // (b) Full amplitude throughout: every 256-frame block spans > a half period at all + // tested ratios, so a continuous tone peaks near 1.0 in each. + for (std::size_t b = 0; b + 256 <= tail; b += 256) { + double peak = 0.0; + for (std::size_t i = b; i < b + 256; ++i) { + if (std::fabs(out[i]) > peak) peak = std::fabs(out[i]); + } + CHECK(peak > 0.5); + CHECK(peak < 1.1); // aligned complementary fades: no cancellation, no bulge + } + } + + // Freeze landing MID-CROSSFADE: at ratio 2 from a fresh prime the tap drains from delay + // w at 1/frame, splices at w/4 (frame 3w/4), then fades for w/4 frames — so frame + // 3w/4 + w/8 is deterministically mid-fade. The frozen writer makes the outgoing tap + // close at the FULL ratio; the transition caps the live fade so it completes before + // reading lapped content — output must stay finite, gap-free, and bounded. + { + PitchShifter ps; + ps.configure(w); + std::vector src(4 * static_cast(w)); + for (std::size_t i = 0; i < src.size(); ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * f0 * static_cast(i))); + } + ps.prime(src.data(), w); + ps.setShiftRatio(2.0); + const std::size_t preFreeze = static_cast(3 * w / 4 + w / 8); + for (std::size_t i = 0; i < preFreeze; ++i) { + (void)ps.process(src[i + static_cast(w)]); + } + ps.freezeTail(); + std::size_t worstGap = 0, run = 0; + for (std::size_t i = 0; i < static_cast(w); ++i) { + const double o = static_cast(ps.process(0.0f)); + CHECK(std::isfinite(o)); + CHECK(std::fabs(o) < 1.1); + if (std::fabs(o) < 0.05) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 24); + // reset()/prime() clear the freeze: the shifter is fully reusable for the next + // note-on, and a primed unity run is STILL bit-exact zero-latency (no stale state). + ps.reset(); + CHECK(!ps.tailFrozen()); + ps.prime(src.data(), w); + ps.setShiftRatio(1.0); + std::size_t badZeroLat = 0; + for (std::size_t i = 0; i < 2000; ++i) { + if (ps.process(src[i + static_cast(w)]) != src[i]) ++badZeroLat; + } + CHECK(badZeroLat == 0); + } +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -349,6 +459,7 @@ int main() { testRtDisciplineAndPassthrough(); testRepitchSpectralPurityAndOnset(); testUnityBitExactAndLatency(); + testFreezeTailContinuousTone(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index f553cef..d282912 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -2389,6 +2389,141 @@ static void testDeclickBoundedBlendNoOvershoot() { CHECK(std::fabs(static_cast(post[0]) - static_cast(pre.back())) < 0.01); } +// =========================================================================== +// GA3 — Preserve tail wind-down: the final window (and the release riding over it) must be +// a gap-free tone. The GA2 tail clamp HELD THE LAST REAL SAMPLE as the shifter feed once the +// source ran out — a DC plateau with no waveform to correlate on. Splices landing in (or +// referenced against) that region were unalignable, so the tap alternated real-tone / dead-DC +// at the splice cadence: the DAW "periodic troughs, almost like ring modulation, stronger +// toward the end, ~1:20 tone-to-silence at the very end". GA3 freezes the WRITER instead +// (padding never enters the ring) and lets the aligned-splice machinery recycle the frozen +// real tail — these tests render to the natural end and assert the tone survives. +// =========================================================================== + +// A sine at explicit per-index frequency f0 (cycles/frame). Period is chosen NON-INTEGER +// (splice alignment must earn the sub-sample fit) but dividing `frames` exactly, so the +// source ENDS at a zero crossing — the held-DC value the GA2 clamp would feed is ~0, making +// the pre-GA3 dead stretches measurable as near-silence. +static SampleData tailSine(std::size_t frames, double f0, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + s.rootNote = rootNote; + return s; +} + +// Longest run of consecutive frames with |x| < thresh in [from, to). +static std::size_t worstQuietRun(const std::vector& out, std::size_t from, + std::size_t to, double thresh) { + std::size_t worst = 0, run = 0; + for (std::size_t i = from; i < to && i < out.size(); ++i) { + if (std::fabs(static_cast(out[i])) < thresh) { + ++run; + if (run > worst) worst = run; + } else { + run = 0; + } + } + return worst; +} + +// Peak |x| over [from, from+len). +static double blockPeak(const std::vector& out, std::size_t from, std::size_t len) { + double peak = 0.0; + for (std::size_t i = from; i < from + len && i < out.size(); ++i) { + const double a = std::fabs(static_cast(out[i])); + if (a > peak) peak = a; + } + return peak; +} + +// --- Gate no-loop, held to the natural end: the FINAL WINDOW carries the full-amplitude +// tone with no gaps, at up- AND down-shifts. Pre-GA3 this window chopped (RED without +// the writer freeze: quiet runs of hundreds of frames, block peaks collapsing to ~0.04). --- +static void testPreserveTailFinalWindowGapFree() { + const std::size_t frames = 8192; + const std::size_t w = 1024; + const double f0 = 1.0 / 163.84; // 50 exact cycles over 8192: ends at a zero crossing + const int notes[] = {67, 55}; // +7 st (ratio ~1.50) and -5 st (ratio ~0.75) + for (int note : notes) { + SampleData s = tailSine(frames, f0, 60); + s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end + s.play.adsr = flatAdsr(); // held: amp 1 to the end (isolates the DSP) + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(note, 127); + std::vector out; + eng.render(out, frames); // the voice frees exactly at the natural end + + // (a) No dead stretches: a unit sine at period ~164/ratio dwells below 0.05 for only + // a few frames per zero crossing; the pre-GA3 DC stretches ran hundreds. + CHECK(worstQuietRun(out, frames - w, frames, 0.05) < 24); + // (b) Full amplitude to the very end: every 128-frame block in the final window spans + // more than a half period at both ratios, so a clean tone peaks near 1.0 in each. + for (std::size_t b = frames - w; b + 128 <= frames; b += 128) { + CHECK(blockPeak(out, b, 128) > 0.5); + } + } +} + +// --- Gate release OVER the final window: the envelope scales amplitude smoothly; the +// underlying tone must stay continuous (no chop) while it fades. Adjacent-block peaks +// may only decay envelope-fast, never gap-fast. --- +static void testPreserveTailReleaseContinuous() { + const std::size_t frames = 8192; + const std::size_t w = 1024; + const double f0 = 1.0 / 163.84; + SampleData s = tailSine(frames, f0, 60); + s.play.pitchEngine = PitchEngine::Preserve; + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = static_cast(w); // release spans the final window + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(67, 127); + std::vector out; + eng.render(out, frames - w); // sustain up to one window before the end... + eng.noteOff(67); // ...then release exactly over the final window + eng.render(out, w); + + // First release block still near full level; thereafter each 128-frame block may lose at + // most envelope-rate level vs its predecessor (linear release loses 12.5% of full scale + // per block). A pre-GA3 chop collapses a mid-release block toward zero and fails the + // ratio bound; assert down to a floor where the fade itself bottoms out. + const std::size_t r0 = frames - w; + CHECK(blockPeak(out, r0, 128) > 0.5); + double prev = blockPeak(out, r0, 128); + for (std::size_t b = r0 + 128; b + 128 <= frames; b += 128) { + const double cur = blockPeak(out, b, 128); + if (prev >= 0.15) CHECK(cur >= 0.3 * prev); + prev = cur; + } +} + +// --- Trigger one-shot to its play end (lengthFraction < 1 exercises the playEnd_ feed bound): +// the final window BEFORE the stop point is gap-free at an off-root pitch. --- +static void testPreserveTriggerTailGapFree() { + const std::size_t frames = 8192; + const std::size_t w = 1024; + const double f0 = 1.0 / 163.84; + SampleData s = tailSine(frames, f0, 60); + s.play.pitchEngine = PitchEngine::Preserve; + s.play.playMode = PlayMode::Trigger; + s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero) + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(w)); + eng.noteOn(67, 127); + const std::size_t playEnd = 6554; // round(0.8 * 8192) + std::vector out; + eng.render(out, playEnd); + + CHECK(worstQuietRun(out, playEnd - w, playEnd, 0.05) < 24); + for (std::size_t b = playEnd - w; b + 128 <= playEnd; b += 128) { + CHECK(blockPeak(out, b, 128) > 0.5); + } +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -2497,6 +2632,11 @@ int main() { testPreviewCardIsolatedFromPool(); testPreviewCardReplaceStaleOffAndOutOfZone(); + // GA3 — Preserve tail wind-down (writer freeze at source exhaustion). + testPreserveTailFinalWindowGapFree(); + testPreserveTailReleaseContinuous(); + testPreserveTriggerTailGapFree(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0;