fix(sampler_core): bounded-blend declick (no overshoot), soundingNote() gates cap + legato

Replaces frozen-difference declick with bounded blend (out*(1-w)+ref*w, bounded by max(|ref|,|out|)). soundingNote() gates activePreserveVoices and mono-Legato retune away from ramp-only past-end voices. Magnitude-bound test asserts |v|<=1 across the ramp.
This commit is contained in:
2026-07-28 09:10:04 -04:00
parent 5d3289f0bc
commit 3e9f5b3b48
3 changed files with 118 additions and 41 deletions
+45
View File
@@ -2335,6 +2335,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();
@@ -2439,6 +2483,7 @@ int main() {
testZeroAttackGateRetrigNoStep();
testPreviewRetriggerDeclicksRestart();
testPreviewDefaultOffKeepsHardCutBaseline();
testDeclickBoundedBlendNoOvershoot();
testPreviewCardIsolatedFromPool();
testPreviewCardReplaceStaleOffAndOutOfZone();