Merge pS-ga3-tail: Preserve tail wind-down — freeze SOLA writer at source end so the tail + release play clean (no DC-splice chop)

This commit is contained in:
2026-07-28 10:09:42 -04:00
6 changed files with 414 additions and 28 deletions
+53 -5
View File
@@ -47,6 +47,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0; fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
filled_ = 0; filled_ = 0;
ratio_ = 1.0; ratio_ = 1.0;
tailFrozen_ = false;
return; return;
} }
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side. // 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
@@ -95,6 +96,37 @@ void PitchShifter::reset() {
} }
filled_ = 0; filled_ = 0;
ratio_ = 1.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. fadePos_ is re-anchored to the same fractional t so gNew is
// continuous at the freeze frame (no gain step); see the re-anchor block below.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
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;
// Clamp in double before the int64 cast (matches splice() pattern; guards against UB
// when dB/ratio_ is very large, e.g. near-unity ratio at a high sample rate).
double left = (dB - 2.0) / ratio_;
if (left > static_cast<double>(fadeFrames_)) left = static_cast<double>(fadeFrames_);
const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1;
const std::int64_t newFadeLen = std::min(fadeLen_, fadePos_ + leftFrames);
// Re-anchor: tOld < 1 because we are mid-fade, so newFadePos < newFadeLen (still fading).
fadePos_ = static_cast<std::int64_t>(tOld * static_cast<double>(newFadeLen));
fadeLen_ = newFadeLen;
}
} }
void PitchShifter::prime(const AudioSample* src, std::int64_t count) { void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
@@ -113,6 +145,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
fadePos_ = 0; fadePos_ = 0;
fadeLen_ = 0; fadeLen_ = 0;
filled_ = count; 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. // ratio_ deliberately untouched: the voice sets it per frame around the prime.
} }
@@ -129,6 +162,7 @@ void PitchShifter::warm() {
fadePos_ = 0; fadePos_ = 0;
fadeLen_ = 0; fadeLen_ = 0;
filled_ = window_; filled_ = window_;
tailFrozen_ = false;
} }
void PitchShifter::setShiftRatio(double ratio) { void PitchShifter::setShiftRatio(double ratio) {
@@ -259,13 +293,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 // 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 // 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. // 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_; fadeLen_ = fadeFrames_;
if (ratio_ > 1.0) { const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
const double headroom = static_cast<double>(dLow_) - (ratio_ - 1.0) - 2.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 // 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 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; : 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl)); fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
} }
@@ -278,8 +318,13 @@ AudioSample PitchShifter::process(AudioSample in) {
// 1. Write the incoming sample at the write head (source rate). One more slot of the // 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 now holds valid history (capped at the ring length once it has wrapped).
// 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; ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_; if (filled_ < ringLen_) ++filled_;
}
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap. // 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 // Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
@@ -306,9 +351,12 @@ AudioSample PitchShifter::process(AudioSample in) {
} }
} }
// 4. Advance heads: write head one frame (source rate), tap(s) by the shift ratio. // 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
// tap(s) by the shift ratio.
if (!tailFrozen_) {
++writePos_; ++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0; if (writePos_ >= ringLen_) writePos_ = 0;
}
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
posA_ += ratio_; posA_ += ratio_;
while (posA_ >= len) posA_ -= len; 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 // ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence. // 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. // 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 / // Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same). // wav_trim do the same).
@@ -107,6 +118,23 @@ public:
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled. // active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in); 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) // 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. // 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, // 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 // its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix. // silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0) 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 } // 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 // 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 // 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 // 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 // there is nothing to interpolate). Past the last real frame the shifter's writer is
// shifter keeps transposing the real tail it already holds. // FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
if (loopUsable) { if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start; const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen; while (feedPos_ >= loop.end) feedPos_ -= loopLen;
} }
// validThrough_ tail clamp (symmetric with the onset filled_ clamp in pitch_shift). // GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
// feedPos_ runs one window AHEAD of readPos_; past the last real source frame the feed // window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
// would write zeros into the ring, letting splices land in a silent tail — the same // user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
// burst/gap/burst stutter as the onset zero-gap (just at note END for up-shifts). // reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
// For Trigger mode the last real frame is playEnd_-1 (the user's chosen stop); for Gate // a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
// it is frameCount-1 (the sample's own end). When feedPos_ overruns this bound, clamp // troughs at the splice cadence, growing toward the note end as the plateau displaced
// to the last real frame — the shifter holds that frame's content rather than ingesting // real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
// silence, so splices always land in real-content history at BOTH ends of the note. // 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 = const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount; ? playEnd_ : frameCount;
// Clamped read position: feedPos_ may legitimately exceed feedBound (it just tracks const bool exhausted = feedPos_ >= feedBound;
// where we "would" be), so clamp only the read, not the counter itself. if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
const std::int64_t clampedFeedPos = const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
(feedPos_ < feedBound) ? feedPos_ : (feedBound - 1); const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
const bool feedOk = (clampedFeedPos >= 0 && clampedFeedPos < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(clampedFeedPos)] : 0.0f;
const double shift = baseRatio_ * envFactor; const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift); shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL)); 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 // 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 // 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. // 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); shiftR_.setShiftRatio(shift);
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain; outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
} else { } else {
+5 -2
View File
@@ -568,8 +568,11 @@ private:
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_ // 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 // 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. // 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 // GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
// outside start()). // 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; PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_; PitchEnvelope pitchEnv_;
PitchShifter shiftL_; PitchShifter shiftL_;
+150
View File
@@ -342,6 +342,155 @@ 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<AudioSample> src(stream + static_cast<std::size_t>(w));
for (std::size_t i = 0; i < src.size(); ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * f0 * static_cast<double>(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<std::size_t>(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<std::size_t>(w);
std::vector<double> out(tail);
for (std::size_t i = 0; i < tail; ++i) {
out[i] = static_cast<double>(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<AudioSample> src(4 * static_cast<std::size_t>(w));
for (std::size_t i = 0; i < src.size(); ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
}
ps.prime(src.data(), w);
ps.setShiftRatio(2.0);
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8);
double lastPre = 0.0;
for (std::size_t i = 0; i < preFreeze; ++i) {
lastPre = static_cast<double>(ps.process(src[i + static_cast<std::size_t>(w)]));
}
ps.freezeTail();
std::size_t worstGap = 0, run = 0;
for (std::size_t i = 0; i < static_cast<std::size_t>(w); ++i) {
const double o = static_cast<double>(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<std::size_t>(w)]) != src[i]) ++badZeroLat;
}
CHECK(badZeroLat == 0);
}
// STEP-DETECTOR: freeze-transition continuity using a ramp source where tapA and tapB
// read values that differ by a predictable constant (≈ A * w / N), making the crossfade
// gain step directly visible in the output. With a ramp, the per-frame natural change is
// A/(N) * ratio ≈ 0.0002 per frame; the un-fixed gain step is ~0.247 * (w/N) ≈ 0.062 —
// roughly 300x the natural rate. A threshold of 0.02 clearly separates fixed from unfixed.
//
// The ramp also defeats correlation-alignment (all lags score equally on a linear ramp),
// so the splice jump of one window guarantees tapA - tapB = A*w/N regardless of lag.
{
PitchShifter ps;
ps.configure(w);
// Ramp from 0.0 to 1.0 over 4*w frames (same buffer size as the mid-crossfade case).
const std::size_t rampLen = 4 * static_cast<std::size_t>(w);
std::vector<AudioSample> ramp(rampLen);
for (std::size_t i = 0; i < rampLen; ++i) {
ramp[i] = static_cast<AudioSample>(static_cast<double>(i) /
static_cast<double>(rampLen - 1));
}
ps.prime(ramp.data(), w);
ps.setShiftRatio(2.0);
// Drive to the deterministic mid-fade freeze point: same preFreeze offset as above.
const std::size_t preFreeze = static_cast<std::size_t>(3 * w / 4 + w / 8);
double lastPre = 0.0;
for (std::size_t i = 0; i < preFreeze; ++i) {
lastPre = static_cast<double>(
ps.process(ramp[i + static_cast<std::size_t>(w)]));
}
ps.freezeTail();
// First frozen frame — if gNew steps at the freeze boundary the output jumps by
// ~deltaGain * (tapA - tapB) ≈ 0.247 * 0.25 = 0.062.
const double firstFrozen = static_cast<double>(ps.process(0.0f));
CHECK(std::isfinite(firstFrozen));
// Natural per-frame ramp advance at ratio 2 ≈ 2/(4*w - 1) ≈ 0.0002; the un-fixed
// step is ~0.062. Threshold 0.02 is 100x the natural rate but well below the step.
const double transitionStep = std::fabs(firstFrozen - lastPre);
CHECK(transitionStep < 0.02);
}
}
int main() { int main() {
testDurationInvariance(); testDurationInvariance();
testUnityRoughlyReproduces(); testUnityRoughlyReproduces();
@@ -349,6 +498,7 @@ int main() {
testRtDisciplineAndPassthrough(); testRtDisciplineAndPassthrough();
testRepitchSpectralPurityAndOnset(); testRepitchSpectralPurityAndOnset();
testUnityBitExactAndLatency(); testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
if (g_fail == 0) { if (g_fail == 0) {
std::printf("all pitch_shift tests passed\n"); std::printf("all pitch_shift tests passed\n");
+150
View File
@@ -1318,6 +1318,16 @@ static void testPreserveGateStereoLoopComposes() {
} }
CHECK(maxL > 0.05); CHECK(maxL > 0.05);
CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved
// Loop-never-freezes regression: the Preserve voice must NOT spuriously freeze when the
// read tap hits the sample end and the loop wraps it back. A spuriously frozen voice
// stops writing to the ring and the looped tail would go silent past the sample end.
// Check a block far past the sample end (sample = 400 frames; window = 512; well past
// any single-pass tail region) to catch any wrap-before-exhaustion ordering error.
double maxLFar = 0.0;
for (std::size_t i = 1800; i < 2000; ++i) {
if (std::fabs(left[i]) > maxLFar) maxLFar = std::fabs(left[i]);
}
CHECK(maxLFar > 0.05); // still alive at frame 1800 (4.5× the 400-frame sample length)
} }
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- // --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
@@ -2389,6 +2399,141 @@ static void testDeclickBoundedBlendNoOvershoot() {
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01); CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(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<float>(std::sin(2.0 * kPi * f0 * static_cast<double>(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<AudioSample>& 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<double>(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<AudioSample>& 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<double>(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<std::int64_t>(w));
eng.noteOn(note, 127);
std::vector<AudioSample> 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<std::int64_t>(w); // release spans the final window
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
std::vector<AudioSample> 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<std::int64_t>(w));
eng.noteOn(67, 127);
const std::size_t playEnd = 6554; // round(0.8 * 8192)
std::vector<AudioSample> 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() { int main() {
testChromaticSingleRoot(); testChromaticSingleRoot();
testZonedRangesBoundaries(); testZonedRangesBoundaries();
@@ -2497,6 +2642,11 @@ int main() {
testPreviewCardIsolatedFromPool(); testPreviewCardIsolatedFromPool();
testPreviewCardReplaceStaleOffAndOutOfZone(); testPreviewCardReplaceStaleOffAndOutOfZone();
// GA3 — Preserve tail wind-down (writer freeze at source exhaustion).
testPreserveTailFinalWindowGapFree();
testPreserveTailReleaseContinuous();
testPreserveTriggerTailGapFree();
if (g_fail == 0) { if (g_fail == 0) {
std::printf("all sampler_core tests passed\n"); std::printf("all sampler_core tests passed\n");
return 0; return 0;