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:
+57
-9
@@ -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).
|
||||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
|
||||||
if (filled_ < ringLen_) ++filled_;
|
// 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.
|
// 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),
|
||||||
++writePos_;
|
// tap(s) by the shift ratio.
|
||||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
if (!tailFrozen_) {
|
||||||
|
++writePos_;
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user