// 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; // G4.6: respawned child starts from FACTORY state — re-queue the // last preset (real patch or seed) so it does not publish/sound // silent factory and clobber the state file. if (!lastPreset_.empty()) { queuePreset(lastPreset_); std::cerr << "[SandboxVst2Host] preset re-queued after respawn ch=" << channel_ << " (" << lastPreset_.size() << "B)" << std::endl; } 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; std::cerr << "[Vst2Sandbox] ON ch=" << channel << " p=" << pitch << " v=" << (int)(velocity * 127.0f) << std::endl; (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; std::cerr << "[Vst2Sandbox] OFF ch=" << channel << " p=" << pitch << std::endl; (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; std::cerr << "[Vst2Sandbox] CC ch=" << channel << " cc=" << cc << " v=" << value << std::endl; (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)); } void SandboxVst2Host::allNotesOff(uint32_t /*channel*/) { if (!alive_.load() || !ipc_) return; std::cerr << "[Vst2Sandbox] ALL_NOTES_OFF ch=" << channel_ << std::endl; pushControl(7); // plugin_host panics locally - no 64-cap MIDI flood } 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()) { lastPreset_ = b64; queuePreset(b64); std::cerr << "[SandboxVst2Host] default preset seeded ch=" << channel_ << " (" << b64.size() << "B)" << std::endl; } fclose(f); } void SandboxVst2Host::queuePreset(const std::string& base64, bool waitApply) { if (!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; // waitApply: block until the child has APPLIED this preset (presetOut // echoes the chunk) before returning. The restore path queues the real // saved preset right after spawn; without this wait a saveStateJob can // fire while the child is still applying the seed default and persist the // seed over the real chunk (restore clobber). Qin round-trips // byte-identical; 20s timeout guards a stuck child. if (!waitApply) return; if (ipc_->presetOutLen == (uint32_t)n && memcmp(ipc_->presetOut, base64.data(), n) == 0) return; auto t0 = std::chrono::steady_clock::now(); while (std::chrono::steady_clock::now() - t0 < std::chrono::seconds(20)) { if (ipc_->presetOutLen == (uint32_t)n && memcmp(ipc_->presetOut, base64.data(), n) == 0) break; Sleep(50); } bool confirmed = ipc_->presetOutLen == (uint32_t)n && memcmp(ipc_->presetOut, base64.data(), n) == 0; std::cerr << "[SandboxVst2Host] preset apply waited ch=" << channel_ << " (" << base64.size() << "B)" << (confirmed ? " confirmed" : " TIMEOUT") << std::endl; } void SandboxVst2Host::loadSerializedState(const std::string& base64) { if (base64.empty()) return; lastPreset_ = base64; // keep for watchdog respawn re-queue if (!alive_.load() || !ipc_) return; // V45.2: non-blocking. The old byte-identical echo wait (memcmp presetOut==input) // NEVER matched for Qin — the child republishes its own round-trip chunk (differs // at byte 144) so waitApply always burned the full 20s TIMEOUT. VST2 restore runs // on the audio thread (runOnMain), so every bridge startup stalled ALL audio 20s — // the user's first play pressed in that window = silence. Bridge now holds // reloading + defers saves until the child republishes (see main.cpp doRestore). queuePreset(base64, false); std::cerr << "[SandboxVst2Host] presetIn queued ch=" << channel_ << " (" << base64.size() << "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.7: copy the newest child block into a LOCAL buffer once per slot // change, then serve every segment of this DAW block from it. The old // direct read tore when the unpaced child (~50x real-time) overwrote // the slot mid-read, and mixed samples from several child blocks across // the segments of one DAW block. With consumer-sync pacing the child // publishes exactly one block per DAW block, so the slot only changes // at a block boundary. ponytail: a child publish landing between two // segments of one DAW block (sub-ms race) would still copy mid-block; // impossible in practice - the child is asleep in Sleep(1) when the // DAW dispatches segments (<100us). Upgrade path: pass the DAW block // index into processAudioBlock and copy once per block. uint32_t slot = ipc_->writeSlot & 1u; if (slot != lastWriteSlot_) { std::memcpy(lastLeft_, ipc_->audioLeft[slot], sizeof(lastLeft_)); std::memcpy(lastRight_, ipc_->audioRight[slot], sizeof(lastRight_)); ipc_->bridgeConsumed[slot]++; lastWriteSlot_ = slot; } for (uint32_t i = 0; i < numSamples; ++i) { outputL[i] = lastLeft_[i]; outputR[i] = lastRight_[i]; } }