From cc64def2d0d5b469eb9034ae6b0bd28dac1d7fb0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 21:54:03 -0400 Subject: [PATCH] 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 --- src/vst/reasampler_processor.cpp | 90 +++++++++++++++++++---- src/vst/reasampler_processor.h | 23 +++++- src/vst/sampler_core.cpp | 47 ++++++++++-- src/vst/sampler_core.h | 14 +++- tests/test_sampler_core.cpp | 121 +++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 21 deletions(-) diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 8cd228e..a9c546a 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -13,6 +13,7 @@ #include "pluginterfaces/vst/ivstaudioprocessor.h" #include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate) #include "pluginterfaces/vst/ivstevents.h" +#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" #include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr) @@ -314,10 +315,12 @@ void ReaSamplerProcessor::setVoiceCount(int count) { if (voiceCount_ == count) return; // no-op: don't churn a rebuild voiceCount_ = count; } - // Rebuild the engine OFF-thread through the drain-slot swap (the FA1 machinery): the - // displaced instrument keeps rendering its ringing tails, so a polyphony change never - // cuts a sounding note. Same contract for the mode/trigger setters below. - reloadFromBank(); + // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from + // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change + // touches no audio data) and the displaced instrument keeps rendering its ringing tails, + // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every + // zone from disk. Same contract for the mode/trigger setters below. + rebuildVoiceEngine(); } VoiceMode ReaSamplerProcessor::voiceMode() { @@ -331,7 +334,7 @@ void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { if (voiceMode_ == mode) return; voiceMode_ = mode; } - reloadFromBank(); + rebuildVoiceEngine(); } MonoTrigger ReaSamplerProcessor::monoTrigger() { @@ -345,7 +348,7 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { if (monoTrigger_ == trigger) return; monoTrigger_ = trigger; } - reloadFromBank(); + rebuildVoiceEngine(); } void ReaSamplerProcessor::previewNoteOn(int note) { @@ -525,12 +528,20 @@ std::string ReaSamplerProcessor::reloadFromBank() { // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted // pointer is re-owned by the graveyard. // - // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is - // the minimum installedAt process() published over the pointers it holds. Both - // slots are monotone in installedAt, so seen is monotone and any future process() - // load yields installedAt >= seen — an entry below seen is provably unreachable - // (see the header proof). Remaining entries drain at setActive(false) / terminate() - // when process is guaranteed stopped. + publishBuiltLocked(std::move(built)); + return resolvedId; +} + +void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { + // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by + // reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance. + // + // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is + // the minimum installedAt process() published over the pointers it holds. Both + // slots are monotone in installedAt, so seen is monotone and any future process() + // load yields installedAt >= seen — an entry below seen is provably unreachable + // (see the header proof). Remaining entries drain at setActive(false) / terminate() + // when process is guaranteed stopped. const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), @@ -541,7 +552,40 @@ std::string ReaSamplerProcessor::reloadFromBank() { LoadedInstrument* prev = live_.exchange(built.release()); LoadedInstrument* evicted = draining_.exchange(prev); if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); - return resolvedId; +} + +void ReaSamplerProcessor::rebuildVoiceEngine() { + // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: + // a voice-param change touches NO audio data, so this rebuilds the engine + preview card + // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — + // and publishes through the same drain-slot swap, so ringing tails survive. + std::lock_guard lock(reloadMutex_); + LoadedInstrument* cur = live_.load(std::memory_order_acquire); + if (!cur) return; // nothing loaded: the new params bake into the next real reload. + + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + + // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap + // is immutable after construction, and under reloadMutex_ nobody can free `cur`. + Keymap km = cur->keymap; + auto built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + publishBuiltLocked(std::move(built)); } void ReaSamplerProcessor::retireIdleDrain() { @@ -721,6 +765,26 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } else if (e.type == Event::kNoteOffEvent) { if (inst) inst->engine.noteOff(e.noteOff.pitch); if (drain) drain->engine.noteOff(e.noteOff.pitch); + } else if (e.type == Event::kLegacyMIDICCOutEvent) { + // PANIC (Phase S voice-review Major #2): CC 123 (All Notes Off) / CC 120 (All + // Sound Off) reset the engine — clear the mono held stack and release every + // voice, live AND drain, engine AND preview card — so a phantom held-stack + // entry left by a lost note-off can never be resurrected by the mono fallback + // and sustain forever. REAPER delivers raw input MIDI CC to a VST3 instrument + // as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism — the type + // is nominally an output event; DAW-verify, see handoff). allNotesOff / + // releaseAll are RT-safe (no allocation, bounded scans). + const auto cc = static_cast(e.midiCCOut.controlNumber); + if (cc == kCtrlAllNotesOff || cc == kCtrlAllSoundsOff) { + if (inst) { + inst->engine.allNotesOff(); + inst->preview.releaseAll(); + } + if (drain) { + drain->engine.allNotesOff(); + drain->preview.releaseAll(); + } + } } } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 526390c..c228dc3 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -200,8 +200,9 @@ public: // --- Phase S voice-system parameters (per-instance, persisted in component state v7) --- // Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded // by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine - // OFF-thread through reloadFromBank's drain-slot swap, so changing polyphony / mode / the - // retrigger toggle never cuts a ringing tail (the same path FA1 added for curve edits). + // OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no + // bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap, + // so changing polyphony / mode / the retrigger toggle never cuts a ringing tail. int voiceCount(); void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode(); @@ -240,6 +241,24 @@ private: // proof (see below) covers the free. void retireIdleDrain(); + // Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine + preview + // card around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no + // filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot + // swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the + // full reloadFromBank (which re-decodes every zone WAV from disk on the UI thread) was + // pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe: + // it is immutable after construction and, under reloadMutex_, the live instrument can + // neither be swapped nor freed while we read it. When nothing is loaded this is a no-op — + // the new params bake into the next real reload. Off the audio thread only. + void rebuildVoiceEngine(); + + // Publish `built` (null = install silence) into live_: prune the graveyard by the last + // process()-published generation, swap `built` into live_, displace the previous live into + // the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES + // reloadMutex_ held — factored out so reloadFromBank and rebuildVoiceEngine share the ONE + // safety-critical swap dance (see the handoff proof below). + void publishBuiltLocked(std::unique_ptr built); + ReaperBridge bridge_; // --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) -- diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index ca3f07d..f885c9d 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -538,7 +538,15 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, // note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one // allocation point for the shifter rings across the engine's lifetime. if (preserveWindowFrames > 1) { - for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames); + // MONO drives voices_[0] only, and the mode is immutable per engine (a change + // rebuilds via the processor's drain-slot reload), so sizing the other voices' + // shifter rings would waste up to (maxVoices-1) windows on voices that can + // never start. Poly sizes the whole pool as before. + const std::size_t ringCount = + voiceMode_ == VoiceMode::Mono ? 1 : voices_.size(); + for (std::size_t i = 0; i < ringCount; ++i) { + voices_[i].presizePreserveShifters(preserveWindowFrames); + } } } @@ -589,6 +597,10 @@ void VoiceEngine::removeHeld(int note) { } std::size_t VoiceEngine::monoNoteOn(int note, int velocity) { + // Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a + // uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real + // held note and corrupt the stack. Mirrored in monoNoteOff. + if (note < 0 || note > 127) return kNoVoice; const ZoneResolution res = keymap_.resolve(note, velocity); if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack. const KeyZone& zone = keymap_.zones[res.zoneIndex]; @@ -605,10 +617,14 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) { } Voice& v = voices_[0]; - // LEGATO takeover: another note is sounding (active + not releasing — a releasing voice's - // note has left the stack, so a fresh phrase after release always re-attacks) AND the new - // note resolves to the SAME sample. Retune in place: pitch moves, no re-attack. - if (v.active() && !v.releasing() && monoTrigger_ == MonoTrigger::Legato && + // LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2 + // means another note was already physically held — the exact "takeover within a phrase" + // predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones: + // Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot + // still ringing after the last key-up was silently RETUNED in place instead of + // re-attacked. Equivalent in every Gate case — a gated, non-releasing voice always has + // its note on the stack; a released note has left it.) Same-sample requirement unchanged. + if (v.active() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) { v.retune(note, zone.rootNote, zone.keyTrack); return 0; @@ -620,6 +636,9 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) { } void VoiceEngine::monoNoteOff(int note) { + // Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an + // unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note. + if (note < 0 || note > 127) return; removeHeld(note); Voice& v = voices_[0]; // Releasing a note that is not the sounding one (a lower held note, an already-released @@ -697,6 +716,18 @@ void VoiceEngine::noteOff(int note) { if (target != kNoVoice) voices_[target].release(); } +void VoiceEngine::allNotesOff() { + // PANIC / CC 123. Clear the mono held stack FIRST so no fallback can resurrect a phantom + // note (the stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's + // fallback restarts and sustains forever), then gate off every active voice. Gate voices + // enter their release tail; Trigger one-shots ignore release by design and play through + // their bounded play length. RT-safe: no allocation, bounded by the pool size. + heldCount_ = 0; + for (Voice& v : voices_) { + if (v.active()) v.release(); + } +} + void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { // Real-time safe: no allocation, no resize — mix straight into the caller's buffer. // The VST3 process callback hands us the host's output channel buffer here, so the @@ -778,6 +809,12 @@ void PreviewCard::noteOff(int note) { if (voice_.active() && voice_.note() == note) voice_.release(); } +void PreviewCard::releaseAll() { + // Panic peer of VoiceEngine::allNotesOff: unconditional release, whatever note rings. + // Gate enters release; Trigger plays through (bounded, cannot be stuck). RT-safe. + if (voice_.active()) voice_.release(); +} + void PreviewCard::render(AudioSample* out, std::size_t frameCount) { if (out == nullptr || frameCount == 0 || !voice_.active()) return; for (std::size_t f = 0; f < frameCount; ++f) { diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 4adf04b..e8c06f0 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -568,6 +568,14 @@ public: // the older tail to ring — matches hardware behavior). No-op if none match. void noteOff(int note); + // PANIC / MIDI All-Notes-Off (CC 123). Clears the MONO held stack and releases EVERY + // active voice: Gate voices enter their release tail; Trigger one-shots ignore release + // by design and play through their bounded play length (they can never be stuck). This + // is the mono stack's ONLY reset path — a phantom entry left by a lost note-off would + // otherwise be resurrected by the next fallback and sustain forever with no key held. + // RT-safe (no allocation, bounded by maxVoices); callable from the audio thread. + void allNotesOff(); + // REAL-TIME render (S4): sums all active voices into the caller-provided buffer // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — // this never touches memory it does not own and NEVER allocates). This is the @@ -619,7 +627,8 @@ private: struct HeldNote { std::uint8_t note; std::uint8_t velocity; }; // Mono note-on: push to the stack and take the voice over (legato retune on a same-sample - // takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone. + // takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone + // or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8). // The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter, // inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover. std::size_t monoNoteOn(int note, int velocity); @@ -671,6 +680,9 @@ public: // Release the preview IF `note` is the one sounding (a stale off for a replaced note is a // no-op). Gate zones enter release; Trigger zones ignore note-off and play through. void noteOff(int note); + // PANIC peer of VoiceEngine::allNotesOff: release the ringing preview UNCONDITIONALLY, + // whatever note it is on. Gate enters release; Trigger plays through (bounded). RT-safe. + void releaseAll(); bool active() const { return voice_.active(); } diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4663a0f..e638361 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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(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 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 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 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();