diff --git a/native_bridge/include/Vst3Instrument.h b/native_bridge/include/Vst3Instrument.h index 0e2350e..0b09916 100644 --- a/native_bridge/include/Vst3Instrument.h +++ b/native_bridge/include/Vst3Instrument.h @@ -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(); }; diff --git a/native_bridge/src/NativeInstrumentEngine.cpp b/native_bridge/src/NativeInstrumentEngine.cpp index 78c5b04..d8fe6c2 100644 --- a/native_bridge/src/NativeInstrumentEngine.cpp +++ b/native_bridge/src/NativeInstrumentEngine.cpp @@ -168,6 +168,8 @@ bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type, loaded = static_cast(inst.get())->loadSfzFile(path, sampleRate); else if (type == InstrumentType::VST3) loaded = static_cast(inst.get())->loadPlugin(path, sampleRate); + if (type == InstrumentType::VST3) + static_cast(inst.get())->setChannel(channel); if (!loaded) return false; // init() AFTER load: FluidSynth creates its synth inside loadSoundFontFile. if (!inst->init(sampleRate, blockSize)) return false; diff --git a/native_bridge/src/Vst3Instrument.cpp b/native_bridge/src/Vst3Instrument.cpp index ee4070c..186b048 100644 --- a/native_bridge/src/Vst3Instrument.cpp +++ b/native_bridge/src/Vst3Instrument.cpp @@ -133,7 +133,11 @@ struct Vst3HostState { IPtr controller; IPtr 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 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 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(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;