fix(voice): Trigger legato keys on held-stack depth, CC123 allNotesOff clears mono stack, voice-param edits rebuild from decoded PCM (no re-decode), mono sizes 1 shifter ring
This commit is contained in:
@@ -1699,6 +1699,121 @@ static void testMonoIgnoresPreserveCap() {
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
}
|
||||
|
||||
// A ramp sample (output value == read position) so a re-attack (read restarts at 0) is
|
||||
// directly distinguishable from a legato retune (read continues) on the rendered value.
|
||||
static SampleData rampSample(std::size_t frames, int rootNote) {
|
||||
SampleData s;
|
||||
s.frames.resize(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) s.frames[i] = static_cast<float>(i);
|
||||
s.rootNote = rootNote;
|
||||
s.play.adsr = flatAdsr();
|
||||
return s;
|
||||
}
|
||||
|
||||
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
||||
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
||||
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
||||
// silently RETUNED it in place. The correct predicate is the HELD-STACK depth: with no other
|
||||
// key down, the next note is a fresh phrase and must restart the read head.
|
||||
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // unity: read advances 1/frame
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10);
|
||||
eng.noteOff(60); // Trigger ignores the gate: keeps ringing...
|
||||
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // ...read head still advancing past 10
|
||||
eng.noteOn(62, 127); // NO key held -> fresh phrase: RE-ATTACK
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (a retune would read ~11)
|
||||
// And it is genuinely playing from the top at the new pitch (ratio 2^(2/12) ~ 1.1225),
|
||||
// not merely silent: the next frame reads at the advanced position.
|
||||
CHECK(approx(probeFrame(eng), std::pow(2.0, 2.0 / 12.0), 1e-3));
|
||||
}
|
||||
|
||||
// Companion boundary: with another key STILL physically held, a same-sample Trigger takeover
|
||||
// under Legato still RETUNES (read continues) — the held-stack predicate matches the old
|
||||
// behavior everywhere except the ringing-but-unheld case above.
|
||||
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10);
|
||||
eng.noteOn(62, 127); // 60 still held -> legato takeover
|
||||
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack
|
||||
}
|
||||
|
||||
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
|
||||
static void testAllNotesOffReleasesPolyVoices() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127);
|
||||
eng.noteOn(64, 127);
|
||||
CHECK(eng.activeVoiceCount() == 3);
|
||||
eng.allNotesOff();
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant)
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
|
||||
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
|
||||
static void testAllNotesOffClearsMonoHeldStack() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
|
||||
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
|
||||
eng.allNotesOff(); // PANIC
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
|
||||
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
|
||||
static void testPreviewCardReleaseAll() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
PreviewCard card(km);
|
||||
card.noteOn(70, 127);
|
||||
CHECK(card.active());
|
||||
card.releaseAll(); // no note argument: quiets whatever rings
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 0.0, 1e-9));
|
||||
CHECK(!card.active());
|
||||
}
|
||||
|
||||
// GREEN: out-of-range notes are rejected at BOTH mono entry points. The held stack stores
|
||||
// uint8, so an unguarded off for note 256 (== 0 mod 256) would alias-evict held note 0 —
|
||||
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
|
||||
static void testMonoOutOfRangeNotesRejected() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
eng.noteOn(0, 127); // hold the aliasing target (note 0)
|
||||
eng.noteOn(62, 127); // 62 takes the voice; 0 held beneath
|
||||
eng.noteOff(256); // MUST NOT alias-evict held note 0
|
||||
eng.noteOff(-256); // likewise for the negative wrap
|
||||
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // 62 undisturbed
|
||||
eng.noteOff(62); // falls back to STILL-HELD note 0
|
||||
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // (alias-evicted pre-fix -> silence here)
|
||||
eng.noteOff(0);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
}
|
||||
|
||||
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
|
||||
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
||||
static void testVoiceCountBoundsPolyphony() {
|
||||
@@ -1850,6 +1965,12 @@ int main() {
|
||||
testMonoLegatoCrossSampleRestarts();
|
||||
testMonoLegatoAfterReleaseReattacks();
|
||||
testMonoIgnoresPreserveCap();
|
||||
testMonoLegatoTriggerReattacksAfterKeyUp();
|
||||
testMonoLegatoTriggerHeldKeyStillRetunes();
|
||||
testAllNotesOffReleasesPolyVoices();
|
||||
testAllNotesOffClearsMonoHeldStack();
|
||||
testPreviewCardReleaseAll();
|
||||
testMonoOutOfRangeNotesRejected();
|
||||
testVoiceCountBoundsPolyphony();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
|
||||
Reference in New Issue
Block a user