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