Merge pS-ga2-clicks: bounded-blend takeover declick (mono retrig + preview + poly steal), soundingNote() gating — click-free voice restarts
This commit is contained in:
+163
-4
@@ -2102,10 +2102,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
|
||||
@@ -2131,6 +2133,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
|
||||
@@ -2235,6 +2345,50 @@ static void testPreviewCardReplaceStaleOffAndOutOfZone() {
|
||||
CHECK(!card.active());
|
||||
}
|
||||
|
||||
// GA2 — bounded-blend overshoot regression: mid-ramp output must stay within full scale.
|
||||
//
|
||||
// Construction of the worst case (§1 reviewer finding): retrig a sine at a point where the
|
||||
// pre-cut level is ~1.0 (old ref ≈ 1). The new voice starts at sin(0) == 0, so the OLD
|
||||
// frozen-seed declick adds (ref − x₀) ≈ 1.0 to the compensation. The new sine has a short
|
||||
// period (8 frames) so outₙ reaches ~1.0 again within just 2 frames; at that moment the
|
||||
// frozen seed is still ~0.9 → outₙ + seed ≈ 1.9, roughly +3.8 dB over full scale.
|
||||
//
|
||||
// The bounded blend keeps every frame within max(|ref|, |outCurrent|) ≤ 1.0 + tol — this
|
||||
// test must FAIL against the rev-2 frozen-seed code and PASS with the bounded blend.
|
||||
static void testDeclickBoundedBlendNoOvershoot() {
|
||||
// A sine with 8-frame period so it peaks within the declick ramp window (~80 frames).
|
||||
// 48000 frames, 6000 cycles -> period = 8 frames; quarter period = 2 frames = the peak.
|
||||
const std::size_t kFrames = 48000;
|
||||
const double kCycles = 6000.0; // period = 8 frames
|
||||
SampleData s = sineSample(kFrames, kCycles, 60);
|
||||
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
// Start a voice and render to a quarter period so the sine is near its positive peak.
|
||||
// Period = 48000/6000 = 8 frames. Frame index 2 = sin(2π*6000*2/48000) = sin(π/2) = 1.0.
|
||||
// We render 3 frames (indices 0,1,2 are visited: readPos 0→1→2→3) so pre[2] reads
|
||||
// frame index 2 at the sine peak.
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> pre;
|
||||
eng.render(pre, 3); // frame index 2 (read on third render): sin(pi/2) ≈ 1.0
|
||||
CHECK(pre.back() > 0.99f); // at peak: ref ≈ 1.0 when we cut
|
||||
|
||||
// Retrigger: hard restart at sin(0) == 0, ref == ~1.0. The frozen-seed approach would
|
||||
// add ~0.9 to a new output of ~1.0 two frames later → ~1.9. The bounded blend must not.
|
||||
eng.noteOn(60, 127);
|
||||
const double tol = 1e-3;
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 200); // 200 frames covers the full ramp (~80 frames at kDeclickDecay=0.95)
|
||||
for (std::size_t i = 0; i < post.size(); ++i) {
|
||||
const double v = static_cast<double>(post[i]);
|
||||
CHECK(v <= 1.0 + tol && v >= -1.0 - tol);
|
||||
}
|
||||
// Boundary identity: first frame must reproduce the pre-cut level (±small tol).
|
||||
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testChromaticSingleRoot();
|
||||
testZonedRangesBoundaries();
|
||||
@@ -2335,6 +2489,11 @@ int main() {
|
||||
testPolyStealDeclicksRestart();
|
||||
testSameBlockDoubleTakeoverKeepsDeclickSeed();
|
||||
testZeroAttackTakeoverNeverExceedsFullScale();
|
||||
testMonoRetrigTriggerZoneDeclicksRestart();
|
||||
testZeroAttackGateRetrigNoStep();
|
||||
testPreviewRetriggerDeclicksRestart();
|
||||
testPreviewDefaultOffKeepsHardCutBaseline();
|
||||
testDeclickBoundedBlendNoOvershoot();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user