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:
2026-07-27 21:54:03 -04:00
parent 885b7f29a2
commit cc64def2d0
5 changed files with 274 additions and 21 deletions
+121
View File
@@ -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();