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
+77 -13
View File
@@ -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<LoadedInstrument> 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<LoadedInstrument>(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<std::mutex> 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<std::mutex> 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<std::int64_t>(
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<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(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<int>(e.midiCCOut.controlNumber);
if (cc == kCtrlAllNotesOff || cc == kCtrlAllSoundsOff) {
if (inst) {
inst->engine.allNotesOff();
inst->preview.releaseAll();
}
if (drain) {
drain->engine.allNotesOff();
drain->preview.releaseAll();
}
}
}
}
}