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:
+118
-4
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user