fix(bridge): stop leaves sound looping — EventList maxSize=50 drops STOP sweep note-offs

Root cause: Steinberg EventList defaults to maxSize=50 events. The STOP
all-notes-off sweep queues 128 note-offs per channel (16ch x 128 pitch),
so addEvent beyond 50 fails silently and the plugin never receives the
remaining note-offs — voices keep sustaining and the transport loops
forever until the bridge process is killed.

Fix: raise eventList.setMaxSize(4096) in loadPlugin. Also assign unique
noteIds per overlapping note-on (stack per pitch) so note-off releases
every voice at that pitch, and log addEvent result + channel in debug
output for verification.
This commit is contained in:
2026-08-14 09:45:50 +07:00
parent 29f7fecb35
commit f7f20e3a0a
3 changed files with 76 additions and 21 deletions
+2
View File
@@ -28,6 +28,7 @@ public:
bool reloadForGUI() override;
bool attachView(void* parentWindowHandle) override;
void closeGUI() override;
void setChannel(uint32_t ch) { channel_ = ch; }
bool needsReload() const override { return hasAttachedOnce_ && !guiAttached_; }
void setReloading(bool on) override { reloading_ = on; }
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
@@ -42,6 +43,7 @@ private:
bool guiAttached_;
bool hasAttachedOnce_;
bool reloading_; // guarded by InstrumentEngineManager::mu_ (set via setReloading)
uint32_t channel_ = 0;
bool reload();
};
@@ -168,6 +168,8 @@ bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type,
loaded = static_cast<SfizzInstrument*>(inst.get())->loadSfzFile(path, sampleRate);
else if (type == InstrumentType::VST3)
loaded = static_cast<Vst3Instrument*>(inst.get())->loadPlugin(path, sampleRate);
if (type == InstrumentType::VST3)
static_cast<Vst3Instrument*>(inst.get())->setChannel(channel);
if (!loaded) return false;
// init() AFTER load: FluidSynth creates its synth inside loadSoundFontFile.
if (!inst->init(sampleRate, blockSize)) return false;
+72 -21
View File
@@ -133,7 +133,11 @@ struct Vst3HostState {
IPtr<IEditController> controller;
IPtr<HostApplication> hostApp;
HostProcessData processData;
EventList eventList;
EventList eventList; // SDK default maxSize=50 — allNotesOff sweep adds
// 128 note-offs per channel per stop; beyond 50
// addEvent FAILS silently and the notes never
// reach the plugin (voices keep sustaining).
// Raised here via setMaxSize in loadPlugin.
ParameterChanges paramChanges;
ProcessContext processContext;
IPtr<IPlugView> view;
@@ -141,6 +145,14 @@ struct Vst3HostState {
HostComponentHandler componentHandler;
int32 outputChannels = 2;
bool controllerIsComponent = false; // single-component plugin: controller == component
// noteId per active voice. VST3 plugins match note-off to note-on by
// (pitch, noteId); a constant id (old code used 0) means two overlapping
// note-ons of the same pitch share one id, and a single note-off only
// releases ONE of them — the other voice sustains forever (the "treo
// loop" bug: sound keeps looping after STOP). Track a stack per pitch so
// every overlapping voice gets a unique id and note-off releases them all.
int32 nextNoteId = 1;
std::vector<int32> noteIdsByPitch[128];
};
// Resolve the plugin's ParamID for a MIDI CC / pitch-bend, preferring the
@@ -350,6 +362,10 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) {
}
std::cerr << "[dbg] loadPlugin: prepare OK, wiring state" << std::endl;
// SDK EventList default maxSize=50 is far too small for the STOP
// all-notes-off sweep (128 note-offs per channel). addEvent beyond the
// cap fails SILENTLY → notes never released → stuck sound loop.
s->eventList.setMaxSize(4096);
s->module = std::move(module);
s->component = std::move(component);
s->controller = std::move(controller);
@@ -403,33 +419,68 @@ void Vst3Instrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, ui
e.noteOn.tuning = 0.f;
e.noteOn.velocity = velocity;
e.noteOn.length = 0;
e.noteOn.noteId = 0; // some plugins (DUNE3) track notes by id; -1 may be ignored
// Unique noteId per voice: overlapping note-ons of the same pitch MUST
// carry distinct ids, otherwise one note-off cannot release all voices
// (Nexus/DUNE3 keep the extra voice forever → sound loops after STOP).
int32 id = s->nextNoteId++;
if (id <= 0) { id = 1; s->nextNoteId = 2; } // wrap: never reuse 0
if (pitch < 128) s->noteIdsByPitch[pitch].push_back(id);
e.noteOn.noteId = id;
s->eventList.addEvent(e);
std::cerr << "[midi] pid=" << (int)GetCurrentProcessId() << " ON ch=" << channel
<< " p=" << pitch << " id=" << id << " v=" << velocity << std::endl;
#endif
}
void Vst3Instrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
(void)channel; (void)pitch; (void)sampleOffset;
(void)channel; (void)sampleOffset;
#ifndef HAVE_VST3SDK
return;
#else
auto* s = static_cast<Vst3HostState*>(state_);
if (!s || !s->component) return;
Event e = {};
e.busIndex = 0;
e.sampleOffset = (int32)sampleOffset;
e.ppqPosition = 0;
e.flags = Event::kIsLive;
e.type = Event::kNoteOffEvent;
e.noteOff.channel = 0; // Force to MIDI Channel 1 (0) for VSTi compatibility
e.noteOff.pitch = (int16)pitch;
e.noteOff.velocity = 0.f;
// noteId phai MATCH noteOn (0): Nexus/DUNE3 track notes bang id, -1
// khong khop voi note-on -> plugin giu note vo han (am thanh khong dung
// cho den khi kill bridge).
e.noteOff.noteId = 0;
e.noteOff.tuning = 0.f;
s->eventList.addEvent(e);
// Release EVERY active voice on this pitch (each had a distinct noteId).
// MIDI note-off semantics: all voices at that pitch stop. Without this,
// a second overlapping voice (stack depth >1) keeps sustaining.
if (pitch < 128 && !s->noteIdsByPitch[pitch].empty()) {
for (int32 id : s->noteIdsByPitch[pitch]) {
Event e = {};
e.busIndex = 0;
e.sampleOffset = (int32)sampleOffset;
e.ppqPosition = 0;
e.flags = Event::kIsLive;
e.type = Event::kNoteOffEvent;
e.noteOff.channel = 0;
e.noteOff.pitch = (int16)pitch;
e.noteOff.velocity = 0.f;
e.noteOff.noteId = id;
e.noteOff.tuning = 0.f;
s->eventList.addEvent(e);
}
std::string ids;
for (int32 id : s->noteIdsByPitch[pitch]) { if (!ids.empty()) ids += ","; ids += std::to_string(id); }
std::cerr << "[midi] pid=" << (int)GetCurrentProcessId() << " OFF ch=" << channel
<< " p=" << pitch << " ids=[" << ids << "]" << std::endl;
s->noteIdsByPitch[pitch].clear();
} else {
// No tracked voice: send a best-effort note-off with the last id
// (id 0 now always means "untracked" — plugins may still release by
// pitch). Never blocks the audio thread.
Event e = {};
e.busIndex = 0;
e.sampleOffset = (int32)sampleOffset;
e.ppqPosition = 0;
e.flags = Event::kIsLive;
e.type = Event::kNoteOffEvent;
e.noteOff.channel = 0;
e.noteOff.pitch = (int16)pitch;
e.noteOff.velocity = 0.f;
e.noteOff.noteId = 0;
e.noteOff.tuning = 0.f;
tresult ar = s->eventList.addEvent(e);
std::cerr << "[midi] pid=" << (int)GetCurrentProcessId() << " OFF ch=" << channel
<< " p=" << pitch << " untracked(0) add=" << (int)ar << std::endl;
}
#endif
}
@@ -646,7 +697,7 @@ void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t
(s->processContext.tempo / 60.0);
static uint32_t dbgN = 0;
static int dbgMaxShown = 0;
static int dbgMaxShown = 0; // ev>0 blocks printed (raised: sweep must be visible)
// VST3: host buffers must be zeroed (silence) before process — plugins
// that skip output leave garbage otherwise (EZkeys 2 -> huge noise).
if (s->processData.numOutputs > 0) {
@@ -682,9 +733,9 @@ void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t
const Event* e0 = s->eventList.getEventByIndex(0);
evt = e0 ? (int)e0->type : -2;
}
if ((++dbgN % 250) == 0 || (evc > 0 && dbgMaxShown < 30)) {
if ((++dbgN % 250) == 0 || (evc > 0 && dbgMaxShown < 5000)) {
if (evc > 0) ++dbgMaxShown;
std::cerr << "[dbg] n=" << numSamples << " ev=" << evc << " evt=" << evt
std::cerr << "[dbg] pid=" << (int)GetCurrentProcessId() << " ch=" << channel_ << " n=" << numSamples << " ev=" << evc << " evt=" << evt
<< " pr=" << (int)pr << " nOut=" << s->processData.numOutputs
<< " mx=" << mx << " ts=" << s->processContext.projectTimeSamples
<< " nch=" << (s->processData.numOutputs > 0 ? s->processData.outputs[0].numChannels : -1) << std::endl;