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
+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