fix(sampler_core): gate takeover declick by (1-amp) to remove zero-attack +6 dB blip and long-attack notch; clamp seed to +/-1; add zero-attack test
This commit is contained in:
@@ -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
|
// 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.
|
// click back on that edge. The next rendered frame overwrites lastOut anyway.
|
||||||
if (declickTakeover && active_) {
|
if (declickTakeover && active_) {
|
||||||
declickL_ = lastOutL_;
|
// Clamp the seed to ±1.0: closes the theoretical same-frame-repeat accumulation edge
|
||||||
declickR_ = lastOutR_;
|
// (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 ||
|
declickActive_ = (declickL_ > kDeclickFloor || declickL_ < -kDeclickFloor ||
|
||||||
declickR_ > kDeclickFloor || declickR_ < -kDeclickFloor);
|
declickR_ > kDeclickFloor || declickR_ < -kDeclickFloor);
|
||||||
} else {
|
} 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
|
// 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)
|
// after either pitch-engine branch, on the shared epilogue. Inactive (the common case)
|
||||||
// costs one branch.
|
// 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_) {
|
if (declickActive_) {
|
||||||
outL += declickL_;
|
const double gate = 1.0 - amp;
|
||||||
if (stereo) outRlocal += declickR_;
|
outL += declickL_ * gate;
|
||||||
|
if (stereo) outRlocal += declickR_ * gate;
|
||||||
declickL_ *= kDeclickDecay;
|
declickL_ *= kDeclickDecay;
|
||||||
declickR_ *= kDeclickDecay;
|
declickR_ *= kDeclickDecay;
|
||||||
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor &&
|
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor &&
|
||||||
|
|||||||
@@ -2063,6 +2063,35 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
|
|||||||
CHECK(approx(post.back(), 1.0, 1e-3));
|
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<AudioSample> 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<AudioSample> 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
|
// 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
|
// (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
|
// EXACTLY ONE voice (the oldest, none releasing) and leave the other two RINGING — the DAW
|
||||||
@@ -2265,6 +2294,7 @@ int main() {
|
|||||||
testMonoDeclickOnlyOnTakeover();
|
testMonoDeclickOnlyOnTakeover();
|
||||||
testPolyStealDeclicksRestart();
|
testPolyStealDeclicksRestart();
|
||||||
testSameBlockDoubleTakeoverKeepsDeclickSeed();
|
testSameBlockDoubleTakeoverKeepsDeclickSeed();
|
||||||
|
testZeroAttackTakeoverNeverExceedsFullScale();
|
||||||
testPreviewCardIsolatedFromPool();
|
testPreviewCardIsolatedFromPool();
|
||||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user