fix(vst): declick rev 2 — difference-seeded ungated takeover ramp (rev-1 (1-amp) gate zeroed it on Trigger/zero-attack); preview card opt-in

This commit is contained in:
2026-07-28 08:49:07 -04:00
parent e99bdcf264
commit 5d3289f0bc
4 changed files with 262 additions and 68 deletions
+118 -4
View File
@@ -2092,10 +2092,12 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
CHECK(approx(post.back(), 1.0, 1e-3));
}
// Declick with ZERO-ATTACK takeover: the new voice reaches full level on frame 0 (amp == 1),
// so the gated compensation adds nothing (gate = 1 - 1 = 0). Output on the boundary frame is
// exactly the new voice's level, never exceeding full scale. Without the (1-amp) gate a zero-
// attack takeover from a sustained voice produced newOnset + oldLevel -> up to 2x (+6 dB).
// Declick with a ZERO-ATTACK takeover onto the SAME DC level: the difference seed is
// (old level new first raw output) = (1.0 1.0) = 0, so NOTHING is added — output stays
// exactly full scale, never above it. (This is the case the retired rev-1 (1 amp) gate
// existed for: an ADDITIVE old-level ramp under an instant-unity attack summed to +6 dB.
// The difference seed makes the blip structurally impossible without any gate — and without
// the gate's fatal hole that kept the click on every instant-unity restart.)
static void testZeroAttackTakeoverNeverExceedsFullScale() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
@@ -2121,6 +2123,114 @@ static void testZeroAttackTakeoverNeverExceedsFullScale() {
CHECK(approx(post[0], 1.0, 1e-4));
}
// Shared discontinuity probe for the GA2 click tests: max sample-to-sample delta from the
// last pre-restart frame across the whole post-restart span. A hard cut shows up as a
// click-sized step (~ the old instantaneous level); a properly declicked restart moves by
// the signal's own slope plus the ≤5%-of-seed decay step per frame.
static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& post) {
double prev = lastPre;
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
return maxDelta;
}
// GA2 — the click that SURVIVED the rev-1 declick (DAW report: "mono retrigger STILL
// CLICKS"): a mono Retrigger takeover of a TRIGGER zone. Trigger with no fade-in is at FULL
// amplitude on frame 0, so the rev-1 compensation — gated by (1 amp) — was zeroed exactly
// here and the restart still hard-cut from the old instantaneous level (~1.0 at the sine
// peak) to the new onset's 0. The difference-seeded declick reproduces the old level on the
// boundary frame and bounds every later delta. A sine (not DC) so the test sees the real
// waveform-value jump the DC-sample rev-1 tests masked.
static void testMonoRetrigTriggerZoneDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame
s.play.playMode = PlayMode::Trigger; // default fades: NO fade-in -> amp 1 at frame 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 120); // quarter period: ringing at ~ the sine peak
CHECK(pre.back() > 0.99f); // the cut level is large — a real click pre-fix
eng.noteOn(60, 127); // hammer the same key: Retrigger takeover
std::vector<AudioSample> post;
eng.render(post, 400);
// Boundary continuity: the first post-restart frame reproduces the old level (pre-fix it
// stepped to the new onset's sin(0) == 0 — a full-scale discontinuity).
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
// Bounded slope across the whole restart: decay step (<= 0.05 of the seed) + sine slope.
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// GA2 peer: the same gate hole on a GATE zone with ZERO attack (amp == 1 on frame 0 — the
// default AdsrParams, and any user-dialed instant attack). Rev-1's (1 amp) gate zeroed the
// compensation here too; the difference seed closes it identically.
static void testZeroAttackGateRetrigNoStep() {
SampleData s = sineSample(48000, 100.0, 60);
s.play.adsr.attackFrames = 0; // instant-unity attack
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 120); // ringing at ~ the sine peak
CHECK(pre.back() > 0.99f);
eng.noteOn(60, 127); // zero-attack Retrigger takeover
std::vector<AudioSample> post;
eng.render(post, 400);
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// GA2 — the PREVIEW click: a preview fired over a RINGING preview replaces the card's single
// voice — the same hard cut as a mono takeover, previously entirely un-declicked (the card
// never passed the opt-in). With takeoverDeclick opted in at construction, the
// replace-restart runs the same difference-seeded ramp: boundary continuity + bounded slope.
static void testPreviewRetriggerDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km, 0, /*takeoverDeclick=*/true);
card.noteOn(60, 127);
std::vector<AudioSample> pre(120, 0.0f);
card.render(pre.data(), pre.size()); // ringing at ~ the sine peak
CHECK(pre.back() > 0.99f);
card.noteOn(60, 127); // audition again: replaces the ringing preview
std::vector<AudioSample> post(400, 0.0f);
card.render(post.data(), post.size());
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// The preview declick is OPT-IN: a default-constructed card keeps the pre-fix hard cut
// byte-identical — the replace-restart's first frame is the new voice's raw onset (sin(0)
// == 0 here), pinning the pure-core regression baseline the shell layers the opt-in above.
static void testPreviewDefaultOffKeepsHardCutBaseline() {
SampleData s = sineSample(48000, 100.0, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km); // declick NOT opted in
card.noteOn(60, 127);
std::vector<AudioSample> pre(120, 0.0f);
card.render(pre.data(), pre.size());
CHECK(pre.back() > 0.99f);
card.noteOn(60, 127);
std::vector<AudioSample> post(1, 0.0f);
card.render(post.data(), post.size());
CHECK(approx(post[0], 0.0, 1e-4)); // the raw hard cut: new onset, no ramp
}
// GA-VoiceSteal repro (DAW bug): voiceCount 3, a triad note-on'd at the SAME sample time
// (three note-ons in one block, no render between), then a 4th note. The steal must take
// EXACTLY ONE voice (the oldest, none releasing) and leave the other two RINGING — the DAW
@@ -2325,6 +2435,10 @@ int main() {
testPolyStealDeclicksRestart();
testSameBlockDoubleTakeoverKeepsDeclickSeed();
testZeroAttackTakeoverNeverExceedsFullScale();
testMonoRetrigTriggerZoneDeclicksRestart();
testZeroAttackGateRetrigNoStep();
testPreviewRetriggerDeclicksRestart();
testPreviewDefaultOffKeepsHardCutBaseline();
testPreviewCardIsolatedFromPool();
testPreviewCardReplaceStaleOffAndOutOfZone();