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

This commit is contained in:
2026-07-28 09:50:28 -04:00
parent 1d2da9d456
commit 917626a287
6 changed files with 353 additions and 28 deletions
+111
View File
@@ -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<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);
for (std::size_t i = 0; i < preFreeze; ++i) {
(void)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);
}
}
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");
+140
View File
@@ -2389,6 +2389,141 @@ static void testDeclickBoundedBlendNoOvershoot() {
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() {
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;