diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index a2cd775..c0d4eed 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -272,8 +272,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // the same pre-cut output level — zeroing would drop the pending ramp and bring the // click back on that edge. The next rendered frame overwrites lastOut anyway. if (declickTakeover && active_) { - declickL_ = lastOutL_; - declickR_ = lastOutR_; + // Clamp the seed to ±1.0: closes the theoretical same-frame-repeat accumulation edge + // (successive starts before any frame is rendered cannot grow the seed above full scale). + declickL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_; + declickR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_; declickActive_ = (declickL_ > kDeclickFloor || declickL_ < -kDeclickFloor || declickR_ > kDeclickFloor || declickR_ < -kDeclickFloor); } else { @@ -530,9 +532,16 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { // takeover/steal start() so the restart's hard cut has no step. Engine-agnostic — applied // after either pitch-engine branch, on the shared epilogue. Inactive (the common case) // costs one branch. + // + // Gate by (1 - amp): the compensation fills only the HOLE the new attack leaves. When amp + // is near 0 (slow attack, typical 3 ms) the gate is ~1 — full compensation, no change in + // feel. When amp is 1 (zero-attack, instant sustain) the gate is 0 — no compensation added, + // so there is no +6 dB blip. For long attacks the gate tapers the compensation proportionally, + // removing the notch that arose when both the old tail and the new level were present in full. if (declickActive_) { - outL += declickL_; - if (stereo) outRlocal += declickR_; + const double gate = 1.0 - amp; + outL += declickL_ * gate; + if (stereo) outRlocal += declickR_ * gate; declickL_ *= kDeclickDecay; declickR_ *= kDeclickDecay; if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor && diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index bcb9fd3..44ce9a3 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -2063,6 +2063,35 @@ 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). +static void testZeroAttackTakeoverNeverExceedsFullScale() { + SampleData s = dcSample(200000, 60); + s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame + 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 pre; + eng.render(pre, 200); // sustained at 1.0 + CHECK(approx(pre.back(), 1.0, 1e-6)); + + eng.noteOn(64, 127); // zero-attack takeover: amp hits 1 on frame 0 + std::vector post; + eng.render(post, 400); + // Every output frame must stay within [-1, 1]: no +6 dB blip. + for (AudioSample v : post) { + CHECK(v <= 1.0f + 1e-4f && v >= -1.0f - 1e-4f); + } + // The zero-attack note settles at sustain 1.0 immediately. + CHECK(approx(post[0], 1.0, 1e-4)); +} + // 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 @@ -2265,6 +2294,7 @@ int main() { testMonoDeclickOnlyOnTakeover(); testPolyStealDeclicksRestart(); testSameBlockDoubleTakeoverKeepsDeclickSeed(); + testZeroAttackTakeoverNeverExceedsFullScale(); testPreviewCardIsolatedFromPool(); testPreviewCardReplaceStaleOffAndOutOfZone();