fix(voice): CC 120 hard-stops voices (incl. Trigger); Mono sizes voices_ to 1; comments corrected

CC 120 -> allSoundsOff/hardStop (immediate silence, stops Trigger one-shots); CC 123 -> allNotesOff/releaseAll (release, unchanged). Mono VoiceEngine sizes voices_ to 1 structurally. Legato same-note re-press edge documented. Three new tests.
This commit is contained in:
2026-07-27 22:08:56 -04:00
parent cc64def2d0
commit 080a7be8ca
4 changed files with 146 additions and 37 deletions
+62
View File
@@ -1793,6 +1793,65 @@ static void testPreviewCardReleaseAll() {
CHECK(!card.active());
}
// CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to
// its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff
// (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop.
static void testAllSoundsOffStopsTriggerOneShot() {
// A Trigger sample with a very long play length (all-1 DC, flat velocity). After noteOn the
// voice is active and ringing; allSoundsOff must silence it immediately.
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
CHECK(eng.activeVoiceCount() == 1);
// CC 123 (release) must be a NO-OP on a Trigger voice — the one-shot plays through.
eng.allNotesOff();
CHECK(eng.activeVoiceCount() == 1); // still ringing (Trigger ignores release)
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(out[0] > 0.5f); // still sounding
// CC 120 (hard-stop) must silence it instantly.
eng.allSoundsOff();
CHECK(eng.activeVoiceCount() == 0); // immediately idle
out.clear();
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-9)); // silent
}
// CC 123 (allNotesOff) still releases Gate voices — the existing release behavior is unchanged.
static void testAllNotesOffStillReleasesGateVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
// Default Gate mode, instant release (releaseFrames 0).
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
CHECK(eng.activeVoiceCount() == 2);
eng.allNotesOff();
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-9)); // Gate with 0-release: instant silence
CHECK(eng.activeVoiceCount() == 0);
}
// MONO LEGATO same-note re-press (one-held-note edge case): with only that note on the
// stack, heldCount_ after the re-push is 1 (not >= 2), so it falls through to re-attack
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
static void testMonoLegatoSameNoteRepressReattacks() {
SampleData s = rampSample(200000, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // first press; read starts at 0
std::vector<AudioSample> out;
eng.render(out, 10); // advance the read head to ~10
// Re-press the SAME note while it is the only held note: heldCount_ after removeHeld+push = 1
// -> does NOT satisfy heldCount_ >= 2 -> re-attack (not a legato retune).
eng.noteOn(60, 127);
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (re-attack, not retune)
}
// 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.
@@ -1970,6 +2029,9 @@ int main() {
testAllNotesOffReleasesPolyVoices();
testAllNotesOffClearsMonoHeldStack();
testPreviewCardReleaseAll();
testAllSoundsOffStopsTriggerOneShot();
testAllNotesOffStillReleasesGateVoices();
testMonoLegatoSameNoteRepressReattacks();
testMonoOutOfRangeNotesRejected();
testVoiceCountBoundsPolyphony();
testPreviewCardIsolatedFromPool();