Merge pS-ga-voicesteal: declick every takeover (mono retrig + poly at-cap steal), gated on envelope complement; over-cap steal proven exactly-one
This commit is contained in:
@@ -1925,6 +1925,260 @@ static void testVoiceCountBoundsPolyphony() {
|
||||
CHECK(e0.maxVoices() == 1); // documented degenerate: clamped to 1
|
||||
}
|
||||
|
||||
// GA declick (bug 2): a MONO Retrigger TAKEOVER hard-cuts the sounding tone (read head +
|
||||
// envelope restart in one frame) — pre-fix the output stepped from the old level to the new
|
||||
// attack's ~0 in one sample, the audible click. With the engine's takeoverDeclick opt-in
|
||||
// the boundary frame carries the old level and every later frame moves by a bounded small
|
||||
// delta while the compensation decays under the new attack.
|
||||
static void testMonoRetrigTakeoverDeclicksRestart() {
|
||||
SampleData s = dcSample(200000, 60);
|
||||
s.play.adsr.attackFrames = 100; // real attack: the new tone starts near 0
|
||||
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); // past the attack: sustained at 1.0
|
||||
CHECK(approx(pre.back(), 1.0, 1e-6));
|
||||
|
||||
eng.noteOn(64, 127); // Retrigger takeover: hard restart
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 400);
|
||||
// No step at the boundary: the first post-takeover frame still carries the old level
|
||||
// (pre-fix it was the new attack's ~0 — a full-scale step).
|
||||
CHECK(approx(post[0], 1.0, 0.06));
|
||||
// Bounded slope everywhere across the takeover: max per-frame delta is the declick decay
|
||||
// step (~0.05) + the attack slope (0.01), never a click-sized jump.
|
||||
double prev = static_cast<double>(pre.back());
|
||||
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);
|
||||
}
|
||||
CHECK(maxDelta < 0.07);
|
||||
// The compensation dies out: the tail is the new note's sustain alone.
|
||||
CHECK(approx(post.back(), 1.0, 1e-3));
|
||||
}
|
||||
|
||||
// Peer restart site (peer-symmetry): the Retrigger FALLBACK on note-off — the most-recent
|
||||
// still-held note re-strikes the voice — is the same hard cut and gets the same declick.
|
||||
static void testMonoRetrigFallbackDeclicksRestart() {
|
||||
SampleData s = dcSample(200000, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
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> a;
|
||||
eng.render(a, 400); // 60 sustains at 1.0
|
||||
eng.noteOn(64, 127); // takeover (declicked, settles back to 1.0)
|
||||
std::vector<AudioSample> b;
|
||||
eng.render(b, 400);
|
||||
CHECK(approx(b.back(), 1.0, 1e-3));
|
||||
|
||||
eng.noteOff(64); // FALLBACK re-strikes held 60 — hard restart
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 400);
|
||||
CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step
|
||||
double prev = static_cast<double>(b.back());
|
||||
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);
|
||||
}
|
||||
CHECK(maxDelta < 0.07);
|
||||
CHECK(approx(post.back(), 1.0, 1e-3));
|
||||
}
|
||||
|
||||
// The declick is TAKEOVER-only: a fresh mono start (idle voice — first note of a phrase, or
|
||||
// a re-press after a full gate-off) must NOT ramp from a stale last output; the attack starts
|
||||
// at ~0 exactly as before.
|
||||
static void testMonoDeclickOnlyOnTakeover() {
|
||||
SampleData s = dcSample(200000, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
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);
|
||||
|
||||
// First note of the phrase: no phantom compensation, attack from ~0.
|
||||
eng.noteOn(60, 127);
|
||||
CHECK(probeFrame(eng) < 0.02);
|
||||
std::vector<AudioSample> a;
|
||||
eng.render(a, 400); // sustain 1.0 (lastOut is now nonzero)
|
||||
// Full gate-off (release 0 -> instant idle): the next start is FRESH, not a takeover.
|
||||
eng.noteOff(60);
|
||||
std::vector<AudioSample> gap;
|
||||
eng.render(gap, 4);
|
||||
CHECK(approx(gap.back(), 0.0, 1e-9));
|
||||
eng.noteOn(62, 127);
|
||||
CHECK(probeFrame(eng) < 0.02); // no declick from the stale last output
|
||||
}
|
||||
|
||||
// Peer restart site (peer-symmetry): a POLY at-cap STEAL is the same hard cut as the mono
|
||||
// retrig takeover — read head + envelope restart on a SOUNDING voice — and gets the same
|
||||
// declick ramp. Pool of 1 makes the steal deterministic: the second note-on must steal the
|
||||
// only (sounding) voice, and with the opt-in the boundary carries the old level instead of
|
||||
// stepping to the new attack's ~0.
|
||||
static void testPolyStealDeclicksRestart() {
|
||||
SampleData s = dcSample(200000, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
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::Poly, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> pre;
|
||||
eng.render(pre, 200); // past the attack: sustained at 1.0
|
||||
CHECK(approx(pre.back(), 1.0, 1e-6));
|
||||
|
||||
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // at cap: steals the sounding voice
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 400);
|
||||
CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step
|
||||
double prev = static_cast<double>(pre.back());
|
||||
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);
|
||||
}
|
||||
CHECK(maxDelta < 0.07);
|
||||
CHECK(approx(post.back(), 1.0, 1e-3)); // compensation dies out; new note sustains
|
||||
}
|
||||
|
||||
// SAME-BLOCK double takeover: two steals of the same voice with NO frame rendered between
|
||||
// (a two-note chord arriving at cap in one block). The second start() must re-seed the ramp
|
||||
// from the same pre-cut output level — if start() zeroed lastOut, the pending ramp would be
|
||||
// dropped and the click would return on exactly this edge.
|
||||
static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
|
||||
SampleData s = dcSample(200000, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
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::Poly, 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); // steal #1 (no render yet)
|
||||
eng.noteOn(67, 127); // steal #2, same block
|
||||
std::vector<AudioSample> post;
|
||||
eng.render(post, 400);
|
||||
CHECK(approx(post[0], 1.0, 0.06)); // seed survived the double restart
|
||||
double prev = static_cast<double>(pre.back());
|
||||
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);
|
||||
}
|
||||
CHECK(maxDelta < 0.07);
|
||||
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
|
||||
// (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
|
||||
// symptom was every tone cutting out. Configured like the live instrument: Preserve engine
|
||||
// (product default), a real OLA window, sine PCM, default-ish AHDSR (3 ms attack, sustain 1,
|
||||
// 60 ms release), rendered stereo between events like process() does.
|
||||
static void testOverCapChordStealsExactlyOne() {
|
||||
SampleData s = sineSample(96000, 2000.0, 60); // ~2 s at 48k
|
||||
s.play.adsr.attackFrames = 144; // 3 ms @ 48k
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
// Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
|
||||
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400);
|
||||
|
||||
// The chord: three note-ons at one sample time (same block, no render between).
|
||||
CHECK(eng.noteOn(60, 100) != VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(64, 100) != VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(67, 100) != VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 3);
|
||||
|
||||
// Ring for a while (stereo, like the negotiated bus) — all three still sounding and finite.
|
||||
std::vector<AudioSample> l(4800, 0.0f), r(4800, 0.0f);
|
||||
eng.render(l.data(), r.data(), l.size());
|
||||
CHECK(eng.activeVoiceCount() == 3);
|
||||
bool finite = true;
|
||||
for (AudioSample v : l) { if (!std::isfinite(v)) { finite = false; break; } }
|
||||
CHECK(finite);
|
||||
|
||||
// The 4th note: must steal exactly ONE voice (the oldest = note 60) — never all.
|
||||
CHECK(eng.noteOn(62, 100) != VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 3);
|
||||
|
||||
// Note 60 was the stolen one: its note-off finds no voice (count unchanged after the
|
||||
// release window). Notes 64 and 67 must still hold their voices — each note-off drops
|
||||
// the count by one once the 60 ms release tail has run out.
|
||||
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
|
||||
eng.noteOff(60);
|
||||
eng.render(l.data(), r.data(), l.size()); // 4800 frames > 2880 release
|
||||
CHECK(eng.activeVoiceCount() == 3); // 60 no longer owns a voice: no-op
|
||||
|
||||
eng.noteOff(64);
|
||||
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
|
||||
eng.render(l.data(), r.data(), l.size());
|
||||
CHECK(eng.activeVoiceCount() == 2); // 64 was still ringing — ONE voice released
|
||||
|
||||
eng.noteOff(67);
|
||||
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
|
||||
eng.render(l.data(), r.data(), l.size());
|
||||
CHECK(eng.activeVoiceCount() == 1); // 67 was still ringing too
|
||||
|
||||
eng.noteOff(62);
|
||||
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
|
||||
eng.render(l.data(), r.data(), l.size());
|
||||
CHECK(eng.activeVoiceCount() == 0); // the stolen-into 4th note releases last
|
||||
}
|
||||
|
||||
// PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a
|
||||
// preview, and pool stealing never touches the ringing preview. The two sum independently.
|
||||
static void testPreviewCardIsolatedFromPool() {
|
||||
@@ -2064,6 +2318,13 @@ int main() {
|
||||
testMonoLegatoSameNoteRepressReattacks();
|
||||
testMonoOutOfRangeNotesRejected();
|
||||
testVoiceCountBoundsPolyphony();
|
||||
testOverCapChordStealsExactlyOne();
|
||||
testMonoRetrigTakeoverDeclicksRestart();
|
||||
testMonoRetrigFallbackDeclicksRestart();
|
||||
testMonoDeclickOnlyOnTakeover();
|
||||
testPolyStealDeclicksRestart();
|
||||
testSameBlockDoubleTakeoverKeepsDeclickSeed();
|
||||
testZeroAttackTakeoverNeverExceedsFullScale();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user