Files
SonicForgeStudio/native_bridge/src/plugin_host_main.cpp
T
admin d7756b08ab fix(bridge): đồng đều âm lượng VSTi jbridge, GUI native lên top, modal instrument lọc FX
- calibrate: probe sandbox (jbridge) theo real-time pacing — child render qua SHM
  consumer-sync, probe µs-speed chỉ đo được silence/attack → makeup=1.0 mọi channel.
  Thêm virtual probeNeedsRealtime() (SandboxVst2Host → true), calibrate() Sleep
  ~1 block/5.8ms để child kịp nhận MIDI + render steady-state → makeup đo peak thật
  (3 channel Qin ≈ 2.6, trước đây = 1.0 → âm lượng đồng đều giữa các track/VSTi).
- OPEN_GUI: SetWindowPos(HWND_TOPMOST) + SetForegroundWindow + BringWindowToTop
  → GUI VSTi hiển thị ở top window, tương tác ngay.
- /api/v1/plugins/available: filter vst_instruments + extra theo is_fx của
  fx_vst_bridge --scan (trước merge entry) — modal Select Instrument chỉ còn
  soundfont + VSTi, không hiện VST FX (90 → 33).
2026-08-21 10:18:25 +07:00

396 lines
18 KiB
C++

// 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.
//
// 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 <name> --path <vst> [--vst2] [--channel <n>] [--sr <rate>] [--block <n>] [--parent <pid>] [--open]
#include "Vst3Instrument.h"
#define MIN_PRESET 100 // b64 chars; real chunks are KBs
#include "Vst2Instrument.h"
#include "SharedMemoryIPC.h"
#ifdef _WIN32
#include <windows.h>
#include <mmsystem.h>
#endif
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
static bool parent_alive(uint32_t pid) {
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!h) return false;
CloseHandle(h);
return true;
#else
return pid == 0 || (kill(pid, 0) == 0);
#endif
}
// G4.7: the child owns the host window, so the user's X click lands HERE, not
// on the bridge. DefWindowProc destroys the window on WM_CLOSE while the VST2
// instrument still thinks its editor is attached (guiAttached_ stale true) -
// a second OPEN_GUI then can never re-show the GUI (bridge dedupes on
// hasAttachedView, and the child's openGUI guard skips too). Intercept:
// WM_CLOSE -> effEditClose + hide (keep the window alive, reopen works);
// WM_DESTROY (external destroy) -> forget the hwnd so the next OPEN_GUI
// creates a fresh window instead of attaching to a dead one.
static INativeInstrument* g_wndInst = nullptr;
static HWND g_hostHwnd = nullptr;
static LRESULT CALLBACK PluginHostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
switch (msg) {
case WM_CLOSE:
if (g_wndInst) {
try { g_wndInst->closeGUI(); } catch (...) {}
}
// effEditClose leaves the jBridge wrapper dialog + stale editor
// children alive; re-opening on the SAME window stacks a second
// dialog on top -> white background, editor never loads. Destroy
// the window so the next OPEN_GUI creates a fresh one.
g_hostHwnd = nullptr;
DestroyWindow(hwnd);
return 0; // swallow: we destroyed it ourselves
case WM_DESTROY:
if (g_wndInst) {
try { g_wndInst->closeGUI(); } catch (...) {}
}
g_hostHwnd = nullptr;
break;
default:
break;
}
return DefWindowProc(hwnd, msg, wParam, lParam);
}
static HWND make_host_window() {
WNDCLASSEX wc = {};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = PluginHostWndProc;
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, 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];
else if (strcmp(argv[i], "--sr") == 0 && i + 1 < argc) sr = atof(argv[++i]);
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 <name> --path <vst> [--vst2] [--channel n] [--sr rate] [--block n] [--parent pid] [--open]\n");
return 2;
}
#ifdef _WIN32
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 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.
ShmHandle* shm = isOpen ? shm_open(shmName.c_str(), sizeof(SandboxHostIPC)) : shm_create(shmName.c_str(), sizeof(SandboxHostIPC));
if (!shm) {
printf("[plugin_host] FAILED shm_create %s\n", shmName.c_str());
return 3;
}
SandboxHostIPC* ipc = shm_sandbox_ptr(shm);
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;
g_wndInst = &inst; // WM_CLOSE (user X) needs the live instrument
// G4.6 auto-play fix: a loaded chunk can embed a stuck/sustained note
// (factory state, seed, or a patch captured mid-note). Release everything
// right after a preset apply so the channel starts silent instead of
// sounding on app open (STOP later releases it too, but startup must not
// play).
auto panicAfterPreset = [&inst, channel]() {
inst.controlChange(0, 64, 0); // sustain pedal off
inst.controlChange(0, 123, 0); // all notes off
for (uint32_t p = 0; p < 128; ++p) inst.noteOff(0, p, 0);
printf("[plugin_host] panic after preset ch=%u\n", channel);
fflush(stdout);
};
// 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);
}
// 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.
g_hostHwnd = nullptr;
if (!vst2) {
g_hostHwnd = make_host_window();
printf("[plugin_host] ownHwnd=%p openGUI...\n", (void*)g_hostHwnd);
fflush(stdout);
bool gui = inst.openGUI(g_hostHwnd);
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).
std::string lastOut;
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;
printf("[plugin_host] initial preset published ch=%u (%uB)\n", channel, ipc->presetOutLen);
fflush(stdout);
}
// G4.6 auto-play fix (clobber-safe): release stuck notes AFTER the real
// chunk is already published, then absorb the panic-mutated chunk so the
// periodic republish never echoes it back over presetOut. Startup stays
// silent AND the state file keeps the real patch.
panicAfterPreset();
inst.captureState();
{
std::string postPanic = inst.serializeState();
if (!postPanic.empty()) lastOut = postPanic;
}
}
// Heartbeat ~10Hz, independent of the audio loop (G3.1 pattern).
std::thread([ipc]() {
for (;;) {
Sleep(100);
ipc->base.heartbeat++;
}
}).detach();
// G4.7: publish counter per slot (how many times this child published
// each slot). Paired with the bridge's bridgeConsumed[2] - see the
// wait after publish below.
uint32_t childPub[2] = {0, 0};
int activeNotes = 0;
bool idle = false;
bool quit = false;
uint32_t lastCaptureTick = 0;
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 (!g_hostHwnd) g_hostHwnd = make_host_window();
if (g_hostHwnd && !inst.hasAttachedView()) {
printf("[plugin_host] OPEN_GUI ch=%u\n", channel);
fflush(stdout);
inst.openGUI(g_hostHwnd);
ShowWindow(g_hostHwnd, SW_SHOW);
// Đưa GUI lên top để tương tác ngay: TOPMOST tạm thời
// (rồi hạ ngay) kéo window lên đỉnh z-order + focus.
SetWindowPos(g_hostHwnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetWindowPos(g_hostHwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE);
SetForegroundWindow(g_hostHwnd);
BringWindowToTop(g_hostHwnd);
}
} else if (c.type == 5) {
inst.closeGUI();
if (g_hostHwnd) ShowWindow(g_hostHwnd, 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 {
// consume-before-apply: copy and zero presetInLen FIRST so the
// bridge can safely queue the next preset while this plugin
// applies the current one (prevents the restore clobber race).
uint32_t plen = ipc->presetInLen;
std::string b64(ipc->presetIn, plen);
ipc->presetInLen = 0;
inst.loadSerializedState(b64);
printf("[plugin_host] preset applied from SHM ch=%u (%uB)\n", channel, (unsigned)plen);
fflush(stdout);
// 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;
}
panicAfterPreset();
// absorb the panic-mutated chunk so the ~2s periodic republish
// never overwrites presetOut with it
inst.captureState();
std::string postPanic = inst.serializeState();
if (!postPanic.empty()) lastOut = postPanic;
}
}
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) {
const SharedAudioBufferIPC::MidiEventIPC& e = ipc->base.midiQueue[i];
switch (e.command) {
case 0x9:
if (e.velocity > 0) { inst.noteOn(e.channel, e.pitch, e.velocity / 127.0f, 0); ++activeNotes; }
else { inst.noteOff(e.channel, e.pitch, 0); if (activeNotes > 0) --activeNotes; }
break;
case 0x8: inst.noteOff(e.channel, e.pitch, 0); if (activeNotes > 0) --activeNotes; break;
case 0xB: inst.controlChange(e.channel, e.pitch, e.data2); break;
case 0xC: inst.programChange(e.channel, e.data2); break;
case 0xE: inst.pitchBend(e.channel, e.data2 | (uint32_t(e.data3) << 7)); break;
default: break;
}
}
ipc->base.midiQueueCount = 0;
if (midiActivity) idle = false;
if (parentPid && !parent_alive(parentPid)) {
printf("[plugin_host] parent gone - exiting\n");
break;
}
if (idle && !inst.hasAttachedView()) { // GUI preview needs continuous rendering
Sleep(15);
continue;
}
// G4.3 double buffer: render into the slot we are NOT publishing, then
// publish. The bridge keeps the last published block, so a slow child
// never tears audio (it just repeats the previous block).
uint32_t slot = 1u - (ipc->writeSlot & 1u);
inst.processAudioBlock(ipc->audioLeft[slot], ipc->audioRight[slot], block);
ipc->writeSlot = slot;
childPub[slot]++;
ipc->base.bridgeWriteIndex++;
if (activeNotes == 0) {
float peak = 0.0f;
for (uint32_t i = 0; i < block; ++i) {
float l = ipc->audioLeft[slot][i];
if (l < 0.0f) l = -l;
float r = ipc->audioRight[slot][i];
if (r < 0.0f) r = -r;
if (l > peak) peak = l;
if (r > peak) peak = r;
}
if (peak < 0.0005f) idle = true;
}
// G4.7: consumer-sync pacing. The bridge (DAW) consumes exactly one
// block per real-time block; the old unpaced child rendered ~50x
// real-time, so the bridge skipped most child blocks -> audio jumps
// ("tạch tạch" instead of the instrument). Wait AFTER publishing
// until the bridge copied THIS block before rendering the next one.
// The bridge never stalls (DAW main loop renders + paces every
// iteration), so this wait is bounded (~5ms). If the bridge dies,
// parentPid points at it - the parent_alive check breaks the wait.
while (ipc->bridgeConsumed[slot] < childPub[slot]) {
if (parentPid && !parent_alive(parentPid)) break;
Sleep(1);
}
Sleep(0);
}
inst.closeGUI();
#ifdef _WIN32
timeEndPeriod(1);
CoUninitialize();
#endif
shm_close(shm);
return 0;
}