feat(native): realtime master FX chain + fx-gui fast start, hide/show, non-topmost

- FxGuiServer: HWND_TOP (no longer always-on-top), /hide + /show endpoints,
  onStarted callback prints SF_FXGUI_PORT before plugin load (fast connect)
- RenderFxJob: RealtimeFxChain (mutex+worker, SEH-guarded, bypass slots,
  deferred Vst3Fx dtor); --fx-gui split into parse/create/load-attach stages
- main: FreeConsole unless SF_KEEP_CONSOLE; control type 6 SET_FX_CHAIN feeds
  g_fxChain on the audio loop; shutdown before CoUninitialize
- plugins.py: CREATE_NO_WINDOW on both Popen paths
- lib.rs: set_fx_chain command -> push_control(6)
- app.jsx/nativeBridgeService: setFxChain sync from MasteringModal; panel close
  uses /hide + refs to fix closure bug; VstGuiEmbed rect refresh -> /show
This commit is contained in:
2026-08-19 09:59:13 +07:00
parent c6de0720f4
commit da0f8fa40e
11 changed files with 391 additions and 40 deletions
+51 -3
View File
@@ -283,7 +283,7 @@ void danceStart(HWND hwnd, HWND target, int x, int y) {
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER |
SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
SetWindowPos(hwnd, HWND_TOPMOST, g_embedRect.left, g_embedRect.top,
SetWindowPos(hwnd, HWND_TOP, g_embedRect.left, g_embedRect.top,
cw, ch, SWP_NOACTIVATE | SWP_SHOWWINDOW);
fxGuiRequestResize(hwnd, cw, ch);
Sleep(60); // let the compositor repaint so the editor sees itself active
@@ -306,7 +306,7 @@ void danceStart(HWND hwnd, HWND target, int x, int y) {
const int nx = wr.left + (cur.x - pt.x);
const int ny = wr.top + (cur.y - pt.y);
GetCursorPos(&g_savedCursor);
SetWindowPos(hwnd, HWND_TOPMOST, nx, ny, 0, 0,
SetWindowPos(hwnd, HWND_TOP, nx, ny, 0, 0,
SWP_NOSIZE | SWP_SHOWWINDOW);
Sleep(60); // let the compositor repaint so the editor sees itself active
g_dance = true;
@@ -516,6 +516,50 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
if (root) std::free(root);
sendResponse(c, 400, "application/json", "{\"error\":\"bad json\"}", 20);
}
} else if (req.path == "/hide") {
ShowWindow(hwnd, SW_HIDE);
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/show") {
// Optional body {rect:{x,y,w,h}} (screen px) — anchored placement
// over the embed panel; without a rect just show + top (never
// topmost — the GUI must not stay above other apps).
json_parse_result_s spres = {};
json_value_s* sroot = json_parse_ex(req.body.data(), req.body.size(),
json_parse_flags_default, nullptr, nullptr, &spres);
bool rectOk = false;
if (sroot && sroot->type == json_type_object) {
const json_object_s* so = static_cast<const json_object_s*>(sroot->payload);
const json_value_s* srv = memberValue(so, "rect");
if (srv && srv->type == json_type_object) {
const json_object_s* sro = static_cast<const json_object_s*>(srv->payload);
const LONG srw = (LONG)memberInt(sro, "w", 0);
const LONG srh = (LONG)memberInt(sro, "h", 0);
if (srw > 0 && srh > 0) {
g_embedRect.left = (LONG)memberInt(sro, "x", 0);
g_embedRect.top = (LONG)memberInt(sro, "y", 0);
g_embedRect.right = g_embedRect.left + srw;
g_embedRect.bottom = g_embedRect.top + srh;
rectOk = true;
}
}
}
if (sroot) std::free(sroot);
if (rectOk) {
SetWindowPos(hwnd, HWND_TOP, g_embedRect.left, g_embedRect.top,
g_embedRect.right - g_embedRect.left,
g_embedRect.bottom - g_embedRect.top,
SWP_NOACTIVATE | SWP_SHOWWINDOW);
fxGuiRequestResize(hwnd, g_embedRect.right - g_embedRect.left,
g_embedRect.bottom - g_embedRect.top);
g_dance = true;
g_anchored = true;
g_parkUntil = 0;
} else {
ShowWindow(hwnd, SW_SHOW);
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/close") {
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
stop.store(true);
@@ -534,9 +578,13 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
// Runs the embedded GUI server: starts the HTTP worker thread, then pumps
// messages on this (STA) thread until the host window is destroyed (user
// closed it or POST /close). Returns 0.
int fxGuiServerLoop(HWND hwnd) {
int fxGuiServerLoop(HWND hwnd, const std::function<void()>& onStarted) {
std::atomic<bool> stop{false};
std::thread http(runHttpServer, hwnd, std::ref(stop));
// onStarted runs on the pump (STA) thread AFTER the HTTP server is up and
// the port printed — used to defer the slow plugin load/attach in server
// mode so the caller (plugins.py) doesn't block on VST load.
if (onStarted) onStarted();
MSG msg;
while (IsWindow(hwnd)) {
BOOL r = GetMessageA(&msg, nullptr, 0, 0);
+218 -24
View File
@@ -47,9 +47,13 @@
#include <filesystem>
#include <fstream>
#include <iostream>
#include <condition_variable>
#include <functional>
#include <iterator>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace {
@@ -921,6 +925,168 @@ int run_render_fx_job(const std::string& jobPath, const std::string& inPath,
return rc;
}
// --- RealtimeFxChain (T2.4) ---------------------------------------------------
// Live VST3 FX on the bridge master mix. setChain() enqueues JSON; a worker
// thread (own COM init) parses + loads plugins then swaps the chain under the
// mutex. The audio thread copies the shared_ptr (cheap, uncontended) and runs
// the plugins in-place — Vst3Fx::processAudio copies input to owned buffers
// before writing output, so out==in is safe. Old chains are retired on the
// worker so plugin destructors never run on the audio thread.
namespace {
// SEH frame holds only raw pointers / trivials (C2712 — no unwinding locals).
static void realtimeRunChain(Vst3Fx** fxs, const bool* bypass, uint32_t count,
float* inL, float* inR, uint32_t n) {
#ifdef _WIN32
__try {
for (uint32_t i = 0; i < count; ++i) {
if (!fxs[i]) continue;
if (bypass && bypass[i]) continue;
fxs[i]->processAudio(inL, inR, inL, inR, n); // in-place safe
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
std::cerr << "[RealtimeFx] plugin crashed in process — chain bypassed" << std::endl;
}
#else
(void)fxs; (void)bypass; (void)count; (void)inL; (void)inR; (void)n;
#endif
}
} // namespace
struct RealtimeFxChain::Impl {
struct Entry { std::shared_ptr<Vst3Fx> fx; bool bypass = false; };
struct Chain { std::vector<Entry> entries; };
std::mutex mutex_; // guards chain_ / retired_
std::shared_ptr<Chain> chain_;
std::shared_ptr<Chain> retired_; // dtor deferred to the worker thread
std::mutex qmutex_; // guards q_ / quit_
std::condition_variable qcv_;
std::vector<std::string> q_;
bool quit_ = false;
std::thread worker_;
double sampleRate_ = 44100.0;
int32_t blockSize_ = 256;
std::shared_ptr<Chain> buildChain(const std::string& json);
void workerLoop();
};
RealtimeFxChain::RealtimeFxChain() : impl_(std::make_unique<Impl>()) {
impl_->worker_ = std::thread([this]() { impl_->workerLoop(); });
}
RealtimeFxChain::~RealtimeFxChain() { shutdown(); }
void RealtimeFxChain::shutdown() {
if (!impl_) return;
{
std::lock_guard<std::mutex> lk(impl_->qmutex_);
if (impl_->quit_) return;
impl_->quit_ = true;
}
impl_->qcv_.notify_all();
if (impl_->worker_.joinable()) impl_->worker_.join();
{
std::lock_guard<std::mutex> lk(impl_->mutex_);
impl_->chain_.reset();
impl_->retired_.reset();
}
}
void RealtimeFxChain::setChain(const std::string& json, double sampleRate, int32_t blockSize) {
if (!impl_) return;
{
std::lock_guard<std::mutex> lk(impl_->qmutex_);
if (sampleRate > 0) impl_->sampleRate_ = sampleRate;
if (blockSize > 0) impl_->blockSize_ = blockSize;
impl_->q_.push_back(json);
}
impl_->qcv_.notify_all();
}
void RealtimeFxChain::process(float* inL, float* inR, uint32_t n) {
if (!impl_ || n == 0) return;
std::shared_ptr<Impl::Chain> c;
{
std::lock_guard<std::mutex> lk(impl_->mutex_);
c = impl_->chain_;
}
if (!c || c->entries.empty()) return;
constexpr uint32_t kMaxSlots = 16; // v1 cap
Vst3Fx* fxs[kMaxSlots];
bool bypass[kMaxSlots];
uint32_t count = 0;
for (const auto& e : c->entries) {
if (count >= kMaxSlots) break;
fxs[count] = e.fx.get();
bypass[count] = e.bypass;
++count;
}
realtimeRunChain(fxs, bypass, count, inL, inR, n);
}
std::shared_ptr<RealtimeFxChain::Impl::Chain> RealtimeFxChain::Impl::buildChain(const std::string& json) {
auto chain = std::make_shared<Chain>();
json_parse_result_s pres = {};
json_value_s* root = json_parse_ex(json.data(), json.size(), json_parse_flags_default,
nullptr, nullptr, &pres);
if (!root || root->type != json_type_array) {
if (root) std::free(root);
std::cerr << "[RealtimeFx] chain JSON parse error" << std::endl;
return chain; // empty chain = passthrough
}
const json_array_s* arr = static_cast<const json_array_s*>(root->payload);
for (const json_array_element_s* e = arr->start; e; e = e->next) {
if (!e->value || e->value->type != json_type_object) continue;
const json_object_s* o = static_cast<const json_object_s*>(e->value->payload);
const std::string path = memberString(o, "path", "");
if (path.empty()) continue;
Entry ent;
ent.bypass = memberInt(o, "bypass", 0) != 0;
if (!ent.bypass) {
auto fx = std::make_shared<Vst3Fx>();
if (fx->load(path, sampleRate_, blockSize_)) ent.fx = std::move(fx);
else {
std::cerr << "[RealtimeFx] load failed — slot bypassed: " << path << std::endl;
ent.bypass = true;
}
}
chain->entries.push_back(std::move(ent));
}
std::free(root);
std::cout << "[RealtimeFx] chain applied: " << chain->entries.size() << " slot(s)" << std::endl;
return chain;
}
void RealtimeFxChain::Impl::workerLoop() {
#ifdef _WIN32
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
#endif
for (;;) {
std::string job;
{
std::unique_lock<std::mutex> lk(qmutex_);
qcv_.wait(lk, [&]() { return quit_ || !q_.empty(); });
if (quit_) break;
job = std::move(q_.front());
q_.erase(q_.begin());
}
std::shared_ptr<Chain> next = buildChain(job); // slow: plugin load
{
std::lock_guard<std::mutex> lk(mutex_);
retired_ = std::move(chain_); // old chain destroyed on THIS thread
chain_ = std::move(next);
}
}
#ifdef _WIN32
CoUninitialize();
#endif
}
// --- --open-fx-gui <job.json>: native editor window for one VST3 FX ----------
#ifdef _WIN32
// Worker (HTTP) thread -> pump thread editor-resize request. Declared
@@ -989,12 +1155,9 @@ void fxGuiRequestResize(HWND hwnd, int w, int h) {
PostMessageA(hwnd, WM_FXGUI_RESIZE, (WPARAM)w, (LPARAM)h);
}
// Shared setup for both GUI modes: parse job, load plugin, create host window
// (offscreen for embedded capture), attach editor (SEH-guarded). Returns 0 ok,
// 1 job/argument error, 2 plugin load or editor attach failure.
static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
bool offscreen, LONG_PTR userData) {
// 1. Parse job { path, name }.
// --- split setup for server mode (startup speed) -----------------------------
// 1. Parse job { path, name } — fast. Returns 0 ok, 1 job/argument error.
static int fxGuiParseJob(const std::string& jobPath, std::string& path, std::string& name) {
std::ifstream jf(jobPath, std::ios::binary);
if (!jf) { std::cerr << "[FxGui] cannot read job file: " << jobPath << std::endl; return 1; }
std::string data((std::istreambuf_iterator<char>(jf)), std::istreambuf_iterator<char>());
@@ -1006,20 +1169,21 @@ static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
rootH.p = root;
if (root->type != json_type_object) { std::cerr << "[FxGui] job root must be a JSON object" << std::endl; return 1; }
const json_object_s* job = static_cast<const json_object_s*>(root->payload);
const std::string path = memberString(job, "path", "");
const std::string name = memberString(job, "name", "");
path = memberString(job, "path", "");
name = memberString(job, "name", "");
if (path.empty()) { std::cerr << "[FxGui] job missing path" << std::endl; return 1; }
return 0;
}
// 2. Load the plugin (fixed 44.1k/512 — GUI only, no audio processed here).
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
// 3. Host window. Embedded mode parks it far off-screen (never steals
// focus; still visible to GDI so BitBlt captures real pixels).
// WS_EX_TOOLWINDOW: an off-screen visible window would otherwise sit in
// the taskbar; clicking its button "restores" it to -20000,-20000 and it
// looks minimized/unrestorable. Tool window has no taskbar button.
// WS_MINIMIZEBOX is dropped too (a minimized tool window is hard to get
// back while parked off-screen).
// 2. Create the host window — fast. Embedded mode parks it far off-screen
// (never steals focus; still visible to GDI so BitBlt captures real pixels).
// WS_EX_TOOLWINDOW: an off-screen visible window would otherwise sit in the
// taskbar; clicking its button "restores" it to -20000,-20000 and it looks
// minimized/unrestorable. Tool window has no taskbar button. WS_MINIMIZEBOX
// is dropped too (a minimized tool window is hard to get back while parked).
// Returns 0 ok, 1 window creation error.
static int fxGuiCreateWindow(const std::string& name, bool offscreen,
LONG_PTR userData, HWND* hwndOut) {
WNDCLASSA wc = {};
wc.lpfnWndProc = FxGuiWndProc;
wc.hInstance = GetModuleHandleA(nullptr);
@@ -1037,18 +1201,37 @@ static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
nullptr, nullptr, GetModuleHandleA(nullptr), nullptr);
if (!hwnd) { std::cerr << "[FxGui] create window failed" << std::endl; return 1; }
SetWindowLongPtrA(hwnd, GWLP_USERDATA, userData);
*hwndOut = hwnd;
return 0;
}
// 4. Attach the plugin editor (SEH-guarded — plugin GUI code may crash).
// 3. Load the plugin + attach the editor (SEH-guarded — plugin GUI code may
// crash). Slow — runs on the pump thread in server mode, after HTTP is up.
// Returns 0 ok, 2 plugin load or editor attach failure.
static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::string& name, HWND hwnd) {
// Fixed 44.1k/512 — GUI only, no audio processed here.
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
if (!fxGuiAttachSafe(fx, hwnd)) {
std::cerr << "[FxGui] editor attach failed for " << path << std::endl;
DestroyWindow(hwnd);
return 2;
}
std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl;
*hwndOut = hwnd;
return 0;
}
// Combined setup (legacy open mode): parse + load + window + attach, in order.
static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
bool offscreen, LONG_PTR userData) {
std::string path, name;
int rc = fxGuiParseJob(jobPath, path, name);
if (rc) return rc;
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
rc = fxGuiCreateWindow(name, offscreen, userData, hwndOut);
if (rc) return rc;
return fxGuiLoadAttach(fx, path, name, *hwndOut);
}
int run_open_fx_gui(const std::string& jobPath) {
// The editor window and its message pump live on this (STA) thread for the
// whole GUI session — VST3 editors need the COM apartment + pump together.
@@ -1078,7 +1261,7 @@ int run_open_fx_gui(const std::string& jobPath) {
// Embedded GUI server mode: same setup but off-screen, then hand the message
// pump + HTTP capture/input to FxGuiServer.cpp (see PLAN_MASTERBUS_VST_GUI_EMBED.md).
extern int fxGuiServerLoop(HWND hwnd);
extern int fxGuiServerLoop(HWND hwnd, const std::function<void()>& onStarted);
int run_fx_gui_server(const std::string& jobPath) {
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
@@ -1086,15 +1269,26 @@ int run_fx_gui_server(const std::string& jobPath) {
Vst3Fx fx;
HWND hwnd = nullptr;
const int rc = fxGuiSetup(jobPath, &fx, &hwnd, /*offscreen=*/true, 0);
// Fast path: parse job + create the host window, then start the HTTP
// server — SF_FXGUI_PORT prints immediately. The slow VST load + editor
// attach is deferred to the pump thread (onStarted), so plugins.py stops
// blocking on a multi-second plugin load (startup speed issue).
std::string path, name;
int rc = fxGuiParseJob(jobPath, path, name);
if (rc) return rc;
rc = fxGuiCreateWindow(name, /*offscreen=*/true, 0, &hwnd);
if (rc) return rc;
SetPropA(hwnd, "FXGUI_FX", &fx); // for WM_FXGUI_RESIZE on the pump thread
const int srv = fxGuiServerLoop(hwnd);
int loadRc = 0;
const int srv = fxGuiServerLoop(hwnd, [&]() {
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd);
if (loadRc != 0) PostMessageA(hwnd, WM_CLOSE, 0, 0); // exit; port already printed
});
RemovePropA(hwnd, "FXGUI_FX");
fxGuiDetachSafe(&fx);
std::cout << "[FxGuiServer] closed" << std::endl;
return srv;
return loadRc ? loadRc : srv;
}
#else // !_WIN32
+22
View File
@@ -13,6 +13,7 @@
#include <imm.h>
#include <mmsystem.h>
#include <process.h>
#include <cstdlib>
#include <thread>
#else
#include <unistd.h>
@@ -116,6 +117,9 @@ static std::mutex g_guiMutex;
static std::map<uint32_t, void*> g_guiWindows; // channel -> HWND (keep window alive)
static std::map<HWND, uint32_t> g_hwndToCh; // HWND -> channel (WM_DESTROY cleanup)
static ChannelWorker* g_uiWorker = nullptr;
// T2.4: realtime master FX chain (live audio through VST3 FX). Created in
// main() once sampleRate is known; fed via control queue type 6.
static std::unique_ptr<RealtimeFxChain> g_fxChain;
// Same-plugin-DLL reentrancy guards: two threads inside one VST3 DLL (Nexus)
// crash or deadlock. g_attachPaths = lowercase plugin paths whose reload/
@@ -574,6 +578,13 @@ static void post_close_gui(uint32_t ch, HWND hwnd) {
}
int main(int argc, char* argv[]) {
#ifdef _WIN32
// Background daemon: detach the console window unless SF_KEEP_CONSOLE is
// set. Console apps spawned from the GUI (tauri-shell, Popen) flash a
// console window otherwise. Redirected stdio (pipe to bridge.log / the
// fx-gui port reader) survives FreeConsole.
if (!std::getenv("SF_KEEP_CONSOLE")) FreeConsole();
#endif
std::cout << "[NativeBridge] Starting DAW Host Bridge Engine..." << std::endl;
#ifdef _WIN32
// Disable Power Throttling (Efficiency Mode) for this background process
@@ -758,6 +769,8 @@ int main(int argc, char* argv[]) {
if (const char* e = std::getenv("SF_STATE_FILE")) stateFile = e;
if (stateFile.empty()) stateFile = StateStore::defaultPath();
const uint32_t block = AUDIO_BLOCK_SIZE;
// T2.4: realtime master FX chain — created once, fed via control type 6.
g_fxChain = std::make_unique<RealtimeFxChain>();
std::atomic<uint64_t> playheadSamples{0};
// V8 bug 3: khi STOP da xu ly, bo qua NOTE_ON (velocity>0) den sau - JS
// note-on timer co the bay toi sau STOP (guardPlay tre do React re-render)
@@ -1374,6 +1387,8 @@ int main(int argc, char* argv[]) {
} else if (c.type == 5) { // DUMP_STATE (G2.2): Rust pre-kill flush
std::cout << "[NativeBridge] DUMP_STATE - flushing state now" << std::endl;
uiWorker.post([&]() { saveStateJob(); });
} else if (c.type == 6) { // SET_FX_CHAIN (T2.4): arg2 = JSON [{"path","bypass"},...]
if (g_fxChain) g_fxChain->setChain(std::string(c.arg2), sampleRate, (int32_t)block);
}
}
shmIPC->controlQueueCount = 0;
@@ -1479,6 +1494,12 @@ int main(int argc, char* argv[]) {
dispatch(evts[ei]);
}
if (cursor < block) renderSegment(cursor, block);
// T2.4: realtime master FX chain — apply to the just-rendered slot
// (in-place, owned buffers) before publishing.
if (g_fxChain) {
uint32_t slot = shmIPC->bridgeWriteIndex & shmIPC->ringMask;
g_fxChain->process(shmIPC->ringLeft[slot], shmIPC->ringRight[slot], block);
}
shmIPC->bridgeWriteIndex++;
// D. Parent died / window closed -> exit (no orphan process)
@@ -1509,6 +1530,7 @@ int main(int argc, char* argv[]) {
UnmapViewOfFile(shmIPC);
CloseHandle(hMapFile);
timeEndPeriod(1);
if (g_fxChain) g_fxChain->shutdown(); // destroy VST3 FX before COM teardown
CoUninitialize();
#else
std::free(shmIPC);