From b91e6d3875696422872c1c6f1f10c3b8ec8e350c Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 20 Aug 2026 19:16:43 +0700 Subject: [PATCH] bridge: fix Qin VST2 silent playback via per-child SHM sandbox - out-of-process VST2 sandbox: each Qin track spawns its own plugin_host.exe child with unique SHM (SonicForge_PluginHost2___), watchdog <=3 respawns - preset round-trip bridge<->child over SHM (presetIn/presetOut 64KB), stub rejection MIN_PRESET=100 - seed qin_default.b64 default preset into every fresh child; saved patch wins on restore - forward MIDI on channel 0 (Qin_RV only listens on ch0); one instrument per child - controls 4/5/6 = OPEN_GUI/CLOSE_GUI/QUIT - gitignore runtime carla_projects --- .gitignore | 1 + build/scripts/build_native_bridge.ps1 | 6 +- native_bridge/CMakeLists.txt | 3 + native_bridge/include/SharedMemoryIPC.h | 11 + native_bridge/src/NativeInstrumentEngine.cpp | 31 +- native_bridge/src/SandboxVst2Host.cpp | 297 +++++++++++++++++++ native_bridge/src/SandboxVst2Host.h | 75 +++++ native_bridge/src/main.cpp | 2 +- native_bridge/src/plugin_host_main.cpp | 195 ++++++++++-- src-tauri/tauri.conf.json | 2 +- 10 files changed, 589 insertions(+), 34 deletions(-) create mode 100644 native_bridge/src/SandboxVst2Host.cpp create mode 100644 native_bridge/src/SandboxVst2Host.h diff --git a/.gitignore b/.gitignore index fe1fcc6..4d18a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ src-tauri/vc_redist.x64.exe app/storage/plugin_dirs.json app/storage/sf_scan_state.json.bak-root app/storage/soundfonts/ +app/storage/carla_projects/ # build-time vendor trees & accidental artifacts (never commit) native_bridge/vcpkg_installed/ diff --git a/build/scripts/build_native_bridge.ps1 b/build/scripts/build_native_bridge.ps1 index 13a1cb1..972c211 100644 --- a/build/scripts/build_native_bridge.ps1 +++ b/build/scripts/build_native_bridge.ps1 @@ -79,7 +79,7 @@ cmake -S $NB_DIR -B $BUILD_DIR -G $VS_GEN -A $VS_ARCH ` -DCMAKE_BUILD_TYPE=Release ` -DVCPKG_TARGET_TRIPLET=x64-windows if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure failed" -ForegroundColor Red; exit 1 } -cmake --build $BUILD_DIR --config Release --target daw_vst_bridge fx_vst_bridge -- /m +cmake --build $BUILD_DIR --config Release --target daw_vst_bridge fx_vst_bridge plugin_host -- /m if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build failed" -ForegroundColor Red; exit 1 } # 6. Copy sidecar to Tauri externalBin location @@ -93,6 +93,10 @@ Copy-Item $EXE (Join-Path $BIN_DIR "daw_vst_bridge-x86_64-pc-windows-msvc.exe") $FX_EXE = Join-Path $BUILD_DIR "Release\fx_vst_bridge.exe" if (-not (Test-Path $FX_EXE)) { Write-Host "ERROR: $FX_EXE not found" -ForegroundColor Red; exit 1 } Copy-Item $FX_EXE (Join-Path $BIN_DIR "fx_vst_bridge-x86_64-pc-windows-msvc.exe") -Force +# plugin_host: VST sandbox child (VST3 crash isolation + jBridge VST2 per-process instances) +$PH_EXE = Join-Path $BUILD_DIR "Release\plugin_host.exe" +if (-not (Test-Path $PH_EXE)) { Write-Host "ERROR: $PH_EXE not found" -ForegroundColor Red; exit 1 } +Copy-Item $PH_EXE (Join-Path $BIN_DIR "plugin_host-x86_64-pc-windows-msvc.exe") -Force # 7. Copy runtime DLLs next to sidecar (Tauri externalBin bundles only the exe) Write-Host "== [7/7] Copy runtime DLLs (fluidsynth + jpeg) ==" diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index 894d6a0..ff2f5e3 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -66,6 +66,7 @@ add_executable(daw_vst_bridge src/Vst2Instrument.cpp src/StateStore.cpp src/SandboxVst3Host.cpp + src/SandboxVst2Host.cpp ) else() # Linux: offline --render only (SF2/SF3 + SFZ; VST3/VST2 hosting is @@ -133,6 +134,7 @@ add_executable(gui_probe src/Vst3Instrument.cpp src/Vst2Instrument.cpp src/SandboxVst3Host.cpp + src/SandboxVst2Host.cpp ) if(VST3_SDK_TARGET) target_compile_definitions(gui_probe PRIVATE HAVE_VST3SDK=1) @@ -151,6 +153,7 @@ add_executable(plugin_host src/plugin_host_main.cpp src/SharedMemoryIPC.cpp src/Vst3Instrument.cpp + src/Vst2Instrument.cpp ) if(VST3_SDK_TARGET) target_compile_definitions(plugin_host PRIVATE HAVE_VST3SDK=1) diff --git a/native_bridge/include/SharedMemoryIPC.h b/native_bridge/include/SharedMemoryIPC.h index 54bdd65..d08296b 100644 --- a/native_bridge/include/SharedMemoryIPC.h +++ b/native_bridge/include/SharedMemoryIPC.h @@ -87,11 +87,22 @@ struct SharedAudioBufferIPC { // The child renders into the slot it is NOT publishing (writeSlot flips), // then publishes; the bridge reads the published slot. A stale slot is safe // to read (bridge keeps last block), so a slow child never tears audio. +// G4.5: preset blob round-trip for VST2 sandbox children. jBridge romplers +// like Kong Audio Qin_RV render SILENT from factory state - they need their +// program/bank chunk restored (bridge_state presetBase64). presetIn is +// bridge->child (base64 text; presetInLen>0 = pending, child applies and +// zeroes it). presetOut is child->bridge: the child republishes its chunk +// after load / preset apply / GUI edit, so the bridge can persist it. +#define PRESET_BLOB_SIZE 65536 struct SandboxHostIPC { SharedAudioBufferIPC base; // MIDI/control/heartbeat, offsets unchanged volatile uint32_t writeSlot; // 0 or 1: slot the child finished writing float audioLeft[2][AUDIO_BLOCK_SIZE]; // 2 full blocks float audioRight[2][AUDIO_BLOCK_SIZE]; + char presetIn[PRESET_BLOB_SIZE]; // bridge -> child (base64 text) + volatile uint32_t presetInLen; // >0: pending; child applies + zeroes + char presetOut[PRESET_BLOB_SIZE]; // child -> bridge (base64 text) + volatile uint32_t presetOutLen; // 0 = none published yet }; // Shared-memory helpers (impl in SharedMemoryIPC.cpp). diff --git a/native_bridge/src/NativeInstrumentEngine.cpp b/native_bridge/src/NativeInstrumentEngine.cpp index 6a4c678..ba8e737 100644 --- a/native_bridge/src/NativeInstrumentEngine.cpp +++ b/native_bridge/src/NativeInstrumentEngine.cpp @@ -7,6 +7,7 @@ #include "Vst3Instrument.h" #include "Vst2Instrument.h" #include "SandboxVst3Host.h" +#include "SandboxVst2Host.h" #endif // void* members keep fluid types out of the public header; cast here. @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -181,6 +183,24 @@ void SfizzInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t // ----------------------------------------------------------------- // 3. MULTI-CHANNEL INSTRUMENT MANAGER (A10) // ----------------------------------------------------------------- +// jBridge stub detection: jBridge-wrapped VST2s (e.g. Qin_RV_x64.dll) embed +// the string "Bridger64.dll". The bundled wrapper allows only ONE instance +// of the same plugin per host process (2nd instance loses the shared audio +// buffer -> silent), so these MUST run in a sandbox child (one process per +// instance). Native VST2s (Nexus) host in-process with an embedded editor. +static bool is_jbridge_dll(const std::string& path) { + FILE* f = fopen(path.c_str(), "rb"); + if (!f) return false; + std::vector d; + char tmp[16384]; + size_t n; + while ((n = fread(tmp, 1, sizeof(tmp), f)) > 0) + d.insert(d.end(), tmp, tmp + n); + fclose(f); + static const char needle[] = "Bridger64.dll"; + return std::search(d.begin(), d.end(), needle, needle + sizeof(needle) - 1) != d.end(); +} + std::unique_ptr InstrumentEngineManager::create_instrument(InstrumentType type) { switch (type) { case InstrumentType::SOUNDFONT_SF2_SF3: return std::make_unique(); @@ -211,6 +231,12 @@ bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type, if (channel >= 16) return false; auto inst = create_instrument(type); if (!inst) return false; +#ifdef _WIN32 + // jBridge VST2 (Qin): sandbox in a child process - one Bridger64 + // instance per process. Native VST2 stays in-process. + if (type == InstrumentType::VST2 && is_jbridge_dll(path)) + inst = std::make_unique(); +#endif bool loaded = false; if (type == InstrumentType::SOUNDFONT_SF2_SF3) loaded = static_cast(inst.get())->loadSoundFontFile(path, sampleRate); @@ -227,6 +253,8 @@ bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type, if (auto* v2 = dynamic_cast(inst.get())) { v2->setChannel(channel); loaded = v2->loadPlugin(path, sampleRate); + } else if (auto* s2 = dynamic_cast(inst.get())) { + loaded = s2->loadPlugin(path, sampleRate, channel); } } if (type == InstrumentType::VST3) { @@ -344,7 +372,8 @@ bool InstrumentEngineManager::snapshot(BridgeStateSnapshot& out) { // here: mu_ is held, so the audio loop cannot be inside // process() on this instance while getState runs. i->captureState(); - e.presetBase64 = i->serializeState(); + std::string saved = i->serializeState(); + e.presetBase64 = saved.size() >= 100 ? saved : std::string(); e.bank = i->bankOf(ch); e.program = i->programOf(ch); } diff --git a/native_bridge/src/SandboxVst2Host.cpp b/native_bridge/src/SandboxVst2Host.cpp new file mode 100644 index 0000000..79e6c3d --- /dev/null +++ b/native_bridge/src/SandboxVst2Host.cpp @@ -0,0 +1,297 @@ +// native_bridge/src/SandboxVst2Host.cpp +#include "SandboxVst2Host.h" + +#include +#include +#include +#include +#include + +// Same escaping as SandboxVst3Host.cpp (F-PROC-1): CreateProcessA does not +// run a shell, but a path containing a double quote must not break out of +// --path "..." and inject arbitrary argv. +static std::string quote_arg(const std::string& s) { + std::string out = "\""; + size_t bs = 0; + for (char c : s) { + if (c == '\\') { ++bs; continue; } + if (c == '"') { out.append(bs * 2 + 1, '\\'); out += '"'; bs = 0; } + else { out.append(bs, '\\'); out += c; bs = 0; } + } + out.append(bs * 2, '\\'); + out += '"'; + return out; +} + +static bool proc_alive(HANDLE h) { +#ifdef _WIN32 + if (!h) return false; + DWORD code = 0; + if (!GetExitCodeProcess(h, &code)) return false; + return code == STILL_ACTIVE; +#else + return true; +#endif +} + +// Unique SHM name per LOAD, not per channel: a same-channel reload (frontend +// LOAD retry spam) runs while the OLD child + mapping still exist - a name +// keyed only on channel would reopen the old mapping and two children would +// fight over one audio handoff. A per-load counter keeps each child isolated. +static std::atomic g_seq{0}; + +SandboxVst2Host::SandboxVst2Host() {} + +SandboxVst2Host::~SandboxVst2Host() { + stop_.store(true); + if (watchdog_.joinable()) watchdog_.join(); +#ifdef _WIN32 + if (childProc_) { + TerminateProcess(childProc_, 0); + CloseHandle(childProc_); + childProc_ = nullptr; + } +#endif + if (shm_) { + shm_close(shm_); + shm_ = nullptr; + ipc_ = nullptr; + } +} + +bool SandboxVst2Host::loadPlugin(const std::string& path, double sampleRate, uint32_t channel) { + path_ = path; + sampleRate_ = sampleRate; + channel_ = channel; + char name[160]; + snprintf(name, sizeof(name), "SonicForge_PluginHost2_%lu_%u_%u", + (unsigned long)GetCurrentProcessId(), channel, g_seq.fetch_add(1)); + shmName_ = name; + if (!spawnChild()) return false; + seedDefaultPreset(); + watchdog_ = std::thread([this]() { watchdogLoop(); }); + return true; +} + +bool SandboxVst2Host::spawnChild() { + if (shm_) { + shm_close(shm_); + shm_ = nullptr; + ipc_ = nullptr; + } + shm_ = shm_create(shmName_.c_str(), sizeof(SandboxHostIPC)); + if (!shm_) { + std::cerr << "[SandboxVst2Host] shm_create failed ch=" << channel_ << std::endl; + return false; + } + ipc_ = shm_sandbox_ptr(shm_); + + // plugin_host.exe sits next to daw_vst_bridge.exe (same dir, install/). + char exePath[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string dir(exePath); + size_t slash = dir.find_last_of("\\/"); + std::string hostExe = (slash == std::string::npos) + ? "plugin_host.exe" + : dir.substr(0, slash + 1) + "plugin_host.exe"; + std::string cmd = "--open --vst2 --channel " + std::to_string(channel_) + + " --shm " + shmName_ + + " --path " + quote_arg(path_) + + " --sr " + std::to_string((int)sampleRate_) + + " --block " + std::to_string(block_) + + " --parent " + std::to_string((unsigned long)GetCurrentProcessId()); + std::cerr << "[SandboxVst2Host] spawn ch=" << channel_ << " " << cmd << std::endl; + + STARTUPINFOA si = {}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi = {}; + std::vector buf(cmd.begin(), cmd.end()); + buf.push_back('\0'); + if (!CreateProcessA(hostExe.c_str(), buf.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { + std::cerr << "[SandboxVst2Host] CreateProcessA failed err=" << (int)GetLastError() + << " ch=" << channel_ << std::endl; + return false; + } + CloseHandle(pi.hThread); +#ifdef _WIN32 + if (childProc_) CloseHandle(childProc_); + childProc_ = pi.hProcess; +#endif + + // Wait for the child heartbeat (Qin load spawns auxhost + engine, can + // take tens of seconds). + uint32_t hb = ipc_->base.heartbeat; + auto t0 = std::chrono::steady_clock::now(); + while (std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count() < 90) { + if (!proc_alive(childProc_)) { + std::cerr << "[SandboxVst2Host] child exited during load ch=" << channel_ << std::endl; + return false; + } + if (ipc_->base.heartbeat != hb) { + alive_.store(true); + return true; + } + Sleep(200); + } + std::cerr << "[SandboxVst2Host] child heartbeat timeout ch=" << channel_ << std::endl; + return false; +} + +void SandboxVst2Host::watchdogLoop() { + int fails = 0; + while (!stop_.load()) { + Sleep(500); + if (stop_.load()) break; + if (!alive_.load()) continue; + if (proc_alive(childProc_)) { + fails = 0; + continue; + } + alive_.store(false); + DWORD ec = 0; GetExitCodeProcess(childProc_, &ec); + std::cerr << "[SandboxVst2Host] child died ch=" << channel_ << " rc=" << ec << " - respawning" << std::endl; + ++fails; + if (fails >= 3) { + std::cerr << "[SandboxVst2Host] ch=" << channel_ + << " respawn limit hit - muted until reload" << std::endl; + continue; // stays dead; processAudioBlock returns silence + } + Sleep(1000); + if (stop_.load()) break; + if (spawnChild()) { + fails = 0; + std::cerr << "[SandboxVst2Host] ch=" << channel_ << " respawned" << std::endl; + } + } +} + +bool SandboxVst2Host::init(double sampleRate, uint32_t maxBlockSize) { + sampleRate_ = sampleRate; + block_ = maxBlockSize; + return true; +} + +void SandboxVst2Host::pushControl(uint32_t type, uint32_t arg0, uint32_t arg1) { + if (!alive_.load() || !ipc_) return; + if (ipc_->base.controlQueueCount >= 8) return; + auto& c = ipc_->base.controlQueue[ipc_->base.controlQueueCount]; + c.type = type; + c.arg0 = arg0; + c.arg1 = arg1; + c.channel = channel_; + std::memset(c.arg2, 0, sizeof(c.arg2)); + ipc_->base.controlQueueCount++; +} + +void SandboxVst2Host::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) { + if (!alive_.load() || !ipc_) return; + (void)channel; // one instrument per child; Qin listens only on MIDI ch 0 + shm_write_midi(shm_, 0x9, 0, (uint8_t)pitch, + (uint8_t)(velocity * 127.0f), sampleOffset); +} + +void SandboxVst2Host::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) { + if (!alive_.load() || !ipc_) return; + (void)channel; // see noteOn + shm_write_midi(shm_, 0x8, 0, (uint8_t)pitch, 0, sampleOffset); +} + +void SandboxVst2Host::controlChange(uint32_t channel, uint32_t cc, uint32_t value) { + if (!alive_.load() || !ipc_) return; + (void)channel; // see noteOn + shm_write_midi(shm_, 0xB, 0, (uint8_t)cc, 0, 0, (uint8_t)value, 0); +} + +void SandboxVst2Host::programChange(uint32_t channel, uint32_t program) { + if (!alive_.load() || !ipc_) return; + (void)channel; // see noteOn + shm_write_midi(shm_, 0xC, 0, 0, 0, 0, (uint8_t)program, 0); +} + +void SandboxVst2Host::pitchBend(uint32_t channel, uint32_t bend14) { + if (!alive_.load() || !ipc_) return; + (void)channel; // see noteOn + shm_write_midi(shm_, 0xE, 0, 0, 0, 0, + (uint8_t)(bend14 & 0x7F), (uint8_t)((bend14 >> 7) & 0x7F)); +} + +bool SandboxVst2Host::openGUI(void* parentWindowHandle) { + (void)parentWindowHandle; // child owns its window + if (!alive_.load() || !ipc_) return false; + pushControl(4); + guiOpen_.store(true); + return true; +} + +bool SandboxVst2Host::attachView(void* parentWindowHandle) { + return openGUI(parentWindowHandle); +} + +void SandboxVst2Host::closeGUI() { + if (!alive_.load() || !ipc_) return; + pushControl(5); + guiOpen_.store(false); +} + +void SandboxVst2Host::seedDefaultPreset() { + // Qin_RV renders SILENT from factory state (no instrument loaded). + // Seed a known-good captured chunk so preset-less loads still sound; + // the restore path writes the real saved preset to presetIn AFTER + // loadPlugin returns, so by ordering the real one wins. + if (!ipc_) return; + char buf[MAX_PATH]; + if (!GetEnvironmentVariableA("APPDATA", buf, MAX_PATH)) return; + std::string file = std::string(buf) + "\\SonicForgeDAW\\qin_default.b64"; + FILE* f = fopen(file.c_str(), "rb"); + if (!f) return; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || (size_t)sz >= PRESET_BLOB_SIZE) { fclose(f); return; } + std::string b64; + b64.resize((size_t)sz); + if (fread(&b64[0], 1, (size_t)sz, f) == (size_t)sz && !b64.empty()) { + memcpy((void*)ipc_->presetIn, b64.data(), b64.size()); + ipc_->presetIn[b64.size()] = 0; + ipc_->presetInLen = (uint32_t)b64.size(); + std::cerr << "[SandboxVst2Host] default preset seeded ch=" << channel_ + << " (" << b64.size() << "B)" << std::endl; + } + fclose(f); +} + +void SandboxVst2Host::loadSerializedState(const std::string& base64) { + if (!alive_.load() || !ipc_ || base64.empty()) return; + size_t n = base64.size(); + if (n >= PRESET_BLOB_SIZE) n = PRESET_BLOB_SIZE - 1; + memcpy((void*)ipc_->presetIn, base64.data(), n); + ipc_->presetIn[n] = 0; + ipc_->presetInLen = (uint32_t)n; + std::cerr << "[SandboxVst2Host] presetIn queued ch=" << channel_ + << " (" << n << "B)" << std::endl; +} + +std::string SandboxVst2Host::serializeState() const { + if (!ipc_) return std::string(); + uint32_t n = ipc_->presetOutLen; + if (n == 0 || n >= PRESET_BLOB_SIZE) return std::string(); + return std::string(ipc_->presetOut, n); +} + +void SandboxVst2Host::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) { + if (!alive_.load() || !ipc_) { + std::memset(outputL, 0, numSamples * sizeof(float)); + std::memset(outputR, 0, numSamples * sizeof(float)); + return; + } + // G4.3: the child publishes the slot it finished rendering (writeSlot). + // Copy that block. A stale slot means the child is still working on the + // other one - we repeat the previous block instead of tearing. + uint32_t slot = ipc_->writeSlot & 1u; + for (uint32_t i = 0; i < numSamples; ++i) { + outputL[i] = ipc_->audioLeft[slot][i]; + outputR[i] = ipc_->audioRight[slot][i]; + } +} diff --git a/native_bridge/src/SandboxVst2Host.h b/native_bridge/src/SandboxVst2Host.h new file mode 100644 index 0000000..49a2ea8 --- /dev/null +++ b/native_bridge/src/SandboxVst2Host.h @@ -0,0 +1,75 @@ +// native_bridge/src/SandboxVst2Host.h +// G4.4: VST2 instrument (jBridge stubs like Qin_RV) hosted in a CHILD process. +// jBridge 1.6b3 allows only ONE instance of a given plugin per host process - +// the second instance's shared audio buffer already exists, so it stays +// silent. Each child process is the "first" Bridger64 instance for its own +// process, so any number of Qin tracks produce audio. The child owns its GUI +// window (opened on control 4 = OPEN_GUI). +#ifndef SANDBOX_VST2_HOST_H +#define SANDBOX_VST2_HOST_H + +#include "INativeInstrument.h" +#include "SharedMemoryIPC.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +class SandboxVst2Host : public INativeInstrument { +public: + SandboxVst2Host(); + ~SandboxVst2Host() override; + + bool loadPlugin(const std::string& path, double sampleRate, uint32_t channel); + + bool init(double sampleRate, uint32_t maxBlockSize) override; + void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override {} + void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override; + void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override; + void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override; + void programChange(uint32_t channel, uint32_t program) override; + void pitchBend(uint32_t channel, uint32_t bend14) override; + // The child owns its GUI window (plugin_host opens it on OPEN_GUI control). + bool openGUI(void* parentWindowHandle) override; + bool canOpenGUI() const override { return true; } + bool attachView(void* parentWindowHandle) override; // bridge OPEN_GUI job calls this + bool hasAttachedView() const override { return guiOpen_.load(); } + void closeGUI() override; + void guiIdle() override {} // child pumps effEditIdle itself + // G4.5: preset state lives in the child. The child republishes its + // chunk into presetOut (after load / presetIn apply / GUI edits); + // serializeState reads it. loadSerializedState writes presetIn for + // the child to apply on its next loop pass. + void captureState() override {} + void restoreState() override {} + std::string serializeState() const override; + void loadSerializedState(const std::string& base64) override; + void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override; + +private: + bool spawnChild(); + void watchdogLoop(); + void pushControl(uint32_t type, uint32_t arg0 = 0, uint32_t arg1 = 0); + void seedDefaultPreset(); + + std::string path_; + std::string shmName_; + double sampleRate_ = 44100.0; + uint32_t block_ = AUDIO_BLOCK_SIZE; + uint32_t channel_ = 0; + ShmHandle* shm_ = nullptr; + SandboxHostIPC* ipc_ = nullptr; + std::atomic alive_{false}; + std::atomic stop_{false}; + std::atomic guiOpen_{false}; + std::thread watchdog_; +#ifdef _WIN32 + HANDLE childProc_ = nullptr; +#endif +}; + +#endif // SANDBOX_VST2_HOST_H diff --git a/native_bridge/src/main.cpp b/native_bridge/src/main.cpp index 20e51ae..56c9a50 100644 --- a/native_bridge/src/main.cpp +++ b/native_bridge/src/main.cpp @@ -1403,7 +1403,7 @@ int main(int argc, char* argv[]) { if (!inst2) { std::cerr << "[NativeBridge] restore lost inst ch=" << ch << std::endl; } else { - if ((t == InstrumentType::VST3 || t == InstrumentType::VST2) && !preset.empty()) + if ((t == InstrumentType::VST3 || t == InstrumentType::VST2) && preset.size() >= 100) inst2->loadSerializedState(preset); else inst2->selectProgram(ch, bank, program); diff --git a/native_bridge/src/plugin_host_main.cpp b/native_bridge/src/plugin_host_main.cpp index affd070..9922443 100644 --- a/native_bridge/src/plugin_host_main.cpp +++ b/native_bridge/src/plugin_host_main.cpp @@ -1,11 +1,19 @@ -// native_bridge/src/plugin_host_main.cpp - G4.1 sandbox VST3 host (child process). -// Proof-of-concept (NOT shipped): runs ONE VST3 out-of-process. The plugin's -// own crash can only kill this process, never the DAW/bridge. Audio + MIDI + -// heartbeat travel over a child-owned SHM mapping reusing the SharedAudioBufferIPC -// layout, so the bridge can later adopt the same struct without layout churn. +// native_bridge/src/plugin_host_main.cpp - G4.1 sandbox VST host (child process). +// Runs ONE VST out-of-process. The plugin's own crash can only kill this +// process, never the DAW/bridge. Audio + MIDI + heartbeat travel over a +// child-owned SHM mapping reusing the SharedAudioBufferIPC layout, so the +// bridge can later adopt the same struct without layout churn. // -// argv: --shm --path [--sr ] [--block ] [--parent ] +// VST3 mode (default): opens the plugin GUI at startup on its own window. +// VST2 mode (--vst2, jBridge stubs like Qin_RV): the GUI is deferred - the +// bridge sends control 4 (OPEN_GUI) / 5 (CLOSE_GUI) / 6 (QUIT) through the +// SHM control queue; the child polls it in its main loop. Deferral matters: +// loading a Qin must not pop a window for every track. +// +// argv: --shm --path [--vst2] [--channel ] [--sr ] [--block ] [--parent ] [--open] #include "Vst3Instrument.h" +#define MIN_PRESET 100 // b64 chars; real chunks are KBs +#include "Vst2Instrument.h" #include "SharedMemoryIPC.h" #ifdef _WIN32 @@ -30,12 +38,25 @@ static bool parent_alive(uint32_t pid) { #endif } +static HWND make_host_window() { + WNDCLASSEX wc = {}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProc; + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = "PluginHostClass"; + RegisterClassEx(&wc); + return CreateWindowEx(0, "PluginHostClass", "PluginHost", WS_OVERLAPPEDWINDOW, + 0, 0, 800, 600, nullptr, nullptr, wc.hInstance, nullptr); +} + int main(int argc, char* argv[]) { - std::string shmName, path; + std::string shmName, path, presetFile; double sr = 44100.0; uint32_t block = AUDIO_BLOCK_SIZE; uint32_t parentPid = 0; bool isOpen = false; + bool vst2 = false; + uint32_t channel = 0; for (int i = 1; i < argc; ++i) { if (strcmp(argv[i], "--shm") == 0 && i + 1 < argc) shmName = argv[++i]; else if (strcmp(argv[i], "--path") == 0 && i + 1 < argc) path = argv[++i]; @@ -43,9 +64,12 @@ int main(int argc, char* argv[]) { else if (strcmp(argv[i], "--block") == 0 && i + 1 < argc) block = (uint32_t)atoi(argv[++i]); else if (strcmp(argv[i], "--parent") == 0 && i + 1 < argc) parentPid = (uint32_t)strtoul(argv[++i], nullptr, 0); else if (strcmp(argv[i], "--open") == 0) isOpen = true; + else if (strcmp(argv[i], "--vst2") == 0) vst2 = true; + else if (strcmp(argv[i], "--channel") == 0 && i + 1 < argc) channel = (uint32_t)atoi(argv[++i]); + else if (strcmp(argv[i], "--presetfile") == 0 && i + 1 < argc) presetFile = argv[++i]; } if (shmName.empty() || path.empty()) { - printf("usage: plugin_host --shm --path [--sr rate] [--block n] [--parent pid]\n"); + printf("usage: plugin_host --shm --path [--vst2] [--channel n] [--sr rate] [--block n] [--parent pid] [--open]\n"); return 2; } @@ -53,8 +77,8 @@ int main(int argc, char* argv[]) { CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); timeBeginPeriod(1); // Sleep(1) really sleeps ~1ms, not the 15.6ms timer tick #endif - printf("[plugin_host] pid=%lu shm=%s path=%s sr=%.0f block=%u\n", - (unsigned long)GetCurrentProcessId(), shmName.c_str(), path.c_str(), sr, block); + printf("[plugin_host] pid=%lu shm=%s path=%s sr=%.0f block=%u vst2=%d ch=%u\n", + (unsigned long)GetCurrentProcessId(), shmName.c_str(), path.c_str(), sr, block, vst2 ? 1 : 0, channel); fflush(stdout); // --open: bridge already created the mapping (G4.2); default PoC: child owns. @@ -65,30 +89,72 @@ int main(int argc, char* argv[]) { } SandboxHostIPC* ipc = shm_sandbox_ptr(shm); - Vst3Instrument inst; - inst.init(sr, block); // sets sampleRate_/maxBlockSize_ used by setupProcessing - if (!inst.loadPlugin(path, sr)) { - printf("[plugin_host] loadPlugin failed\n"); - return 4; + Vst3Instrument instV3; + Vst2Instrument instV2; + if (vst2) { + instV2.setChannel(channel); + if (!instV2.loadPlugin(path, sr)) { + printf("[plugin_host] loadPlugin failed\n"); + return 4; + } + instV2.init(sr, block); // loadPlugin FIRST: init() opens effect (kEffectOpen+resume); before load it is a silent no-op + } else { + instV3.init(sr, block); // sets sampleRate_/maxBlockSize_ used by setupProcessing + if (!instV3.loadPlugin(path, sr)) { + printf("[plugin_host] loadPlugin failed\n"); + return 4; + } + } + INativeInstrument& inst = vst2 ? (INativeInstrument&)instV2 : (INativeInstrument&)instV3; + + // Optional preset restore: Qin_RV and other jBridge romplers render SILENT + // from their factory state - they need their program/bank chunk. The bridge + // forwards presetBase64 (base64 text, one line) via a temp file. + if (!presetFile.empty()) { + FILE* f = fopen(presetFile.c_str(), "rb"); + if (f) { + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string b64; + b64.resize(sz > 0 ? (size_t)sz : 0); + if (sz > 0 && fread(&b64[0], 1, (size_t)sz, f) == (size_t)sz) { + inst.loadSerializedState(b64); + printf("[plugin_host] preset restored from %s (%ldB)\n", presetFile.c_str(), sz); + } + fclose(f); + } else { + printf("[plugin_host] preset file open failed: %s\n", presetFile.c_str()); + } + fflush(stdout); } - // GUI on the main thread (editorhost pattern: attach then pump below). + // VST3: GUI on the main thread at startup (editorhost pattern: attach then + // pump below). VST2: deferred until control 4 (OPEN_GUI) so loading a Qin + // does not pop a window for every track. HWND hwnd = nullptr; - { - WNDCLASSEX wc = {}; - wc.cbSize = sizeof(wc); - wc.lpfnWndProc = DefWindowProc; - wc.hInstance = GetModuleHandle(nullptr); - wc.lpszClassName = "PluginHostClass"; - RegisterClassEx(&wc); - hwnd = CreateWindowEx(0, "PluginHostClass", "PluginHost", WS_OVERLAPPEDWINDOW, - 0, 0, 800, 600, nullptr, nullptr, wc.hInstance, nullptr); + if (!vst2) { + hwnd = make_host_window(); + printf("[plugin_host] ownHwnd=%p openGUI...\n", (void*)hwnd); + fflush(stdout); + bool gui = inst.openGUI(hwnd); + printf("[plugin_host] openGUI=%d\n", gui ? 1 : 0); + fflush(stdout); + } + + // G4.5: publish the initial chunk so the bridge can persist it even + // without a preset round-trip yet (factory state = the silent chunk; + // the GUI or a presetIn apply replaces it later). + inst.captureState(); + { + std::string out = inst.serializeState(); + if (!out.empty() && out.size() < PRESET_BLOB_SIZE) { + memcpy((void*)ipc->presetOut, out.data(), out.size()); + ipc->presetOutLen = (uint32_t)out.size(); + printf("[plugin_host] initial preset published ch=%u (%uB)\n", channel, ipc->presetOutLen); + fflush(stdout); + } } - printf("[plugin_host] ownHwnd=%p openGUI...\n", (void*)hwnd); - fflush(stdout); - bool gui = inst.openGUI(hwnd); - printf("[plugin_host] openGUI=%d\n", gui ? 1 : 0); - fflush(stdout); // Heartbeat ~10Hz, independent of the audio loop (G3.1 pattern). std::thread([ipc]() { @@ -100,12 +166,81 @@ int main(int argc, char* argv[]) { int activeNotes = 0; bool idle = false; + bool quit = false; + uint32_t lastCaptureTick = 0; + std::string lastOut; for (;;) { MSG msg; while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessageW(&msg); } + // VST2 controls: 4=OPEN_GUI, 5=CLOSE_GUI, 6=QUIT. This SHM is + // per-child - only the bridge-side SandboxVst2Host writes it, so no + // frontend saveStateJob spam like the bridge's own control queue. + if (vst2) { + uint32_t nctl = ipc->base.controlQueueCount > 8 ? 8 : ipc->base.controlQueueCount; + for (uint32_t i = 0; i < nctl; ++i) { + const auto& c = ipc->base.controlQueue[i]; + if (c.type == 4) { + if (!hwnd) hwnd = make_host_window(); + if (hwnd && !inst.hasAttachedView()) { + printf("[plugin_host] OPEN_GUI ch=%u\n", channel); + fflush(stdout); + inst.openGUI(hwnd); + ShowWindow(hwnd, SW_SHOW); + } + } else if (c.type == 5) { + inst.closeGUI(); + if (hwnd) ShowWindow(hwnd, SW_HIDE); + } else if (c.type == 6) { + quit = true; + } + } + ipc->base.controlQueueCount = 0; + if (quit) break; + } + // G4.5: preset round-trip. Apply bridge->child presetIn (restore path + // writes it right after spawn; the seed default too). Republish + // child->bridge presetOut when the chunk changed. + if (ipc->presetInLen > 0 && ipc->presetInLen < PRESET_BLOB_SIZE) { + // Reject tiny b64 (Qin's empty stub "KAM..." is 16 chars): the + // saved-state file can hold a stub captured before the plugin + // loaded an instrument; applying it would wipe the seeded + // default and leave the channel silent. + if (ipc->presetInLen < MIN_PRESET) { + printf("[plugin_host] preset rejected ch=%u (stub %uB)\n", channel, (unsigned)ipc->presetInLen); + fflush(stdout); + ipc->presetInLen = 0; + } else { + std::string b64(ipc->presetIn, ipc->presetInLen); + inst.loadSerializedState(b64); + printf("[plugin_host] preset applied from SHM ch=%u (%uB)\n", channel, ipc->presetInLen); + fflush(stdout); + ipc->presetInLen = 0; + // republish immediately so a just-after restore save has it + inst.captureState(); + std::string out = inst.serializeState(); + if (!out.empty() && out.size() < PRESET_BLOB_SIZE) { + memcpy((void*)ipc->presetOut, out.data(), out.size()); + ipc->presetOutLen = (uint32_t)out.size(); + lastOut = out; + } + } + } + if (++lastCaptureTick >= 200) { // ~2s of loop passes + lastCaptureTick = 0; + inst.captureState(); + std::string out = inst.serializeState(); + if (!out.empty() && out != lastOut && out.size() < PRESET_BLOB_SIZE) { + memcpy((void*)ipc->presetOut, out.data(), out.size()); + ipc->presetOutLen = (uint32_t)out.size(); + lastOut = out; + printf("[plugin_host] preset republished ch=%u (%uB)\n", channel, ipc->presetOutLen); + fflush(stdout); + } + } + inst.guiIdle(); // VST2 editors repaint via effEditIdle; no-op for VST3 uint32_t n = ipc->base.midiQueueCount > 64 ? 64 : ipc->base.midiQueueCount; bool midiActivity = n > 0; for (uint32_t i = 0; i < n; ++i) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1f5083d..4879e0c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ "binaries/libfluidsynth-3.dll": "libfluidsynth-3.dll", "binaries/jpeg62.dll": "jpeg62.dll" }, - "externalBin": ["binaries/daw_vst_bridge", "binaries/fx_vst_bridge"], + "externalBin": ["binaries/daw_vst_bridge", "binaries/fx_vst_bridge", "binaries/plugin_host"], "windows": { "nsis": { "installerHooks": "hooks.nsh"