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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user