362 lines
14 KiB
C++
362 lines
14 KiB
C++
// native_bridge/src/SandboxVst2Host.cpp
|
|
#include "SandboxVst2Host.h"
|
|
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <vector>
|
|
|
|
// 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<uint32_t> 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<char> 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::seconds>(
|
|
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];
|
|
}
|
|
}
|