Files
SonicForgeStudio/native_bridge/src/RenderFxJob.cpp
T

2099 lines
88 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// native_bridge/src/RenderFxJob.cpp
// Offline audio-FX render mode (Phase 0+1 of PLAN_MASTERBUS_FX_RACK_VST.md):
// `daw_vst_bridge --render-fx <job.json> --in <input.wav> --out <output.wav>`
// processes an existing WAV through a VST3 effect chain (audio-in/audio-out,
// no MIDI) plus builtin slots (gain/normalize). Also `--scan <dir>` which
// classifies VST3 modules as instrument/effect.
//
// Same JSON/b64/WAV helpers as RenderJob.cpp (sheredom/json.h vendored with
// the VST3 SDK). The VST3 host pattern is the Vst3Instrument.cpp one minus
// MIDI/GUI — component + controller, audio buses, processData with input
// buffers copied directly (setChannelBuffers is unusable: prepare() owns them).
#include "RenderFxJob.h"
// sheredom/json.h (public domain) — vendored copy; SDK copy as fallback.
#if __has_include("sheredom_json.h")
#include "sheredom_json.h"
#elif __has_include("vst3sdk/public.sdk/source/vst/moduleinfo/json.h")
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
#endif
#ifdef HAVE_VST3SDK
#include "public.sdk/source/vst/hosting/module.h"
#include "public.sdk/source/vst/hosting/hostclasses.h"
#include "public.sdk/source/vst/hosting/processdata.h"
#include "public.sdk/source/vst/hosting/parameterchanges.h"
#include "public.sdk/source/vst/vstpresetfile.h"
#include "public.sdk/source/common/memorystream.h"
#include "pluginterfaces/vst/ivstaudioprocessor.h"
#include "pluginterfaces/vst/ivstcomponent.h"
#include "pluginterfaces/vst/ivsteditcontroller.h"
#include "pluginterfaces/vst/ivstprocesscontext.h"
#include "pluginterfaces/vst/ivstmessage.h"
#include "pluginterfaces/gui/iplugview.h"
#endif
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
#include "FxRealtimeIPC.h"
#include "BuiltinFxChain.h"
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <condition_variable>
#include <functional>
#include <iterator>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace {
// --- sheredom/json.h helpers (same as RenderJob.cpp) ------------------------
const json_object_element_s* member(const json_object_s* o, const char* key) {
for (const json_object_element_s* e = o ? o->start : nullptr; e; e = e->next)
if (e->name && e->name->string && std::strcmp(e->name->string, key) == 0)
return e;
return nullptr;
}
const json_value_s* memberValue(const json_object_s* o, const char* key) {
const json_object_element_s* m = member(o, key);
return m ? m->value : nullptr;
}
std::string memberString(const json_object_s* o, const char* key, const std::string& def) {
const json_value_s* v = memberValue(o, key);
if (v && v->type == json_type_string) {
const auto* s = static_cast<const json_string_s*>(v->payload);
return std::string(s->string, s->string_size);
}
return def;
}
bool memberNumber(const json_object_s* o, const char* key, double& out) {
const json_value_s* v = memberValue(o, key);
if (v && v->type == json_type_number) {
out = std::atof(static_cast<const json_number_s*>(v->payload)->number);
return true;
}
return false;
}
int64_t memberInt(const json_object_s* o, const char* key, int64_t def) {
double d;
return memberNumber(o, key, d) ? (int64_t)d : def;
}
bool memberBool(const json_object_s* o, const char* key, bool def) {
const json_value_s* v = memberValue(o, key);
if (v && v->type == json_type_true) return true;
if (v && v->type == json_type_false) return false;
return def;
}
// --- WAV writer (stdlib only, IEEE Float32 stereo, little-endian) ------------
// Lossless: keeps the full float32 render buffer bit-exact. No PCM truncation
// and no dither here — dither/quantization belongs ONLY to a final export
// stage (lossless-audio-compliance skill, Rules 4 & 7). Format tag 3.
void writeU16(std::ofstream& f, uint16_t v) {
char b[2] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF) };
f.write(b, 2);
}
void writeU32(std::ofstream& f, uint32_t v) {
char b[4] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF),
(char)((v >> 16) & 0xFF), (char)((v >> 24) & 0xFF) };
f.write(b, 4);
}
void writeWavHeader(std::ofstream& f, uint32_t sampleRate) {
f.write("RIFF", 4);
writeU32(f, 36);
f.write("WAVE", 4);
f.write("fmt ", 4);
writeU32(f, 16);
writeU16(f, 3); // IEEE float
writeU16(f, 2);
writeU32(f, sampleRate);
writeU32(f, sampleRate * 8); // byte rate = sr * 2ch * 4B
writeU16(f, 8);
writeU16(f, 32);
f.write("data", 4);
writeU32(f, 0);
}
void finishWav(std::ofstream& f, uint64_t dataBytes) {
f.seekp(4);
writeU32(f, (uint32_t)(36 + dataBytes));
f.seekp(40);
writeU32(f, (uint32_t)dataBytes);
f.flush();
}
void writeFrames(std::ofstream& f, const float* L, const float* R, uint32_t n) {
// IEEE float32 little-endian, bit-exact: no clamp, no quantization.
for (uint32_t i = 0; i < n; ++i) {
f.write(reinterpret_cast<const char*>(&L[i]), 4);
f.write(reinterpret_cast<const char*>(&R[i]), 4);
}
}
// --- WAV reader (stdlib only): PCM 16/24/32-bit + IEEE float32, mono/stereo
// → stereo float32. Returns false on any parse error (job/argument error). ---
struct WavIn {
uint32_t sampleRate = 0;
uint16_t channels = 0;
uint16_t bits = 0;
std::vector<float> L, R; // same length
};
uint32_t readU32le(const uint8_t* p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
uint16_t readU16le(const uint8_t* p) {
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
bool readWav(const std::string& path, WavIn& out) {
std::ifstream f(path, std::ios::binary);
if (!f) { std::cerr << "[RenderFx] cannot read input: " << path << std::endl; return false; }
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44) return false;
if (std::memcmp(buf.data(), "RIFF", 4) != 0 || std::memcmp(buf.data() + 8, "WAVE", 4) != 0)
return false;
uint16_t fmtTag = 0, channels = 0, bits = 0;
uint32_t sampleRate = 0;
size_t dataOff = 0, dataLen = 0;
size_t p = 12;
bool foundFmt = false, foundData = false;
while (p + 8 <= buf.size()) {
const char* id = reinterpret_cast<const char*>(buf.data() + p);
uint32_t sz = readU32le(buf.data() + p + 4);
size_t chunkStart = p + 8;
if (chunkStart + sz > buf.size()) break; // truncated chunk — stop
if (std::memcmp(id, "fmt ", 4) == 0 && sz >= 16) {
fmtTag = readU16le(buf.data() + chunkStart);
channels = readU16le(buf.data() + chunkStart + 2);
sampleRate = readU32le(buf.data() + chunkStart + 4);
bits = readU16le(buf.data() + chunkStart + 14);
foundFmt = true;
} else if (std::memcmp(id, "data", 4) == 0) {
dataOff = chunkStart;
dataLen = sz;
foundData = true;
break;
}
p = chunkStart + sz + (sz & 1);
}
if (!foundFmt || !foundData) return false;
// Supported: PCM 16/24/32 and IEEE float 32; mono or stereo.
const bool isFloat = (fmtTag == 3);
if (!(fmtTag == 1 || isFloat)) {
std::cerr << "[RenderFx] unsupported WAV format tag " << fmtTag << " (need PCM/float)" << std::endl;
return false;
}
if (channels < 1 || channels > 2) {
std::cerr << "[RenderFx] unsupported WAV channel count " << channels << std::endl;
return false;
}
const uint16_t bytes = (bits + 7) / 8;
if (bytes != 2 && bytes != 3 && bytes != 4) return false;
const size_t frameBytes = (size_t)channels * bytes;
const size_t nFrames = dataLen / frameBytes;
out.sampleRate = sampleRate;
out.channels = channels;
out.bits = bits;
out.L.assign(nFrames, 0.0f);
out.R.assign(nFrames, 0.0f);
const uint8_t* d = buf.data() + dataOff;
for (size_t i = 0; i < nFrames; ++i) {
float l = 0.f, r = 0.f;
for (uint16_t c = 0; c < channels; ++c) {
const uint8_t* s = d + (i * channels + c) * bytes;
float v = 0.f;
if (isFloat && bytes == 4) {
uint32_t u = readU32le(s);
float fv;
std::memcpy(&fv, &u, 4);
v = fv;
} else if (bytes == 2) {
v = (float)(int16_t)((uint16_t)s[0] | ((uint16_t)s[1] << 8)) / 32768.f;
} else if (bytes == 3) {
int32_t iv = (int32_t)s[0] | ((int32_t)s[1] << 8) | ((int32_t)s[2] << 16);
if (iv & 0x800000) iv |= ~0xFFFFFF; // sign extend
v = (float)iv / 8388608.f;
} else { // bytes == 4, PCM int32
int32_t iv = (int32_t)readU32le(s);
v = (float)iv / 2147483648.f;
}
if (c == 0) l = v; else r = v;
}
out.L[i] = l;
out.R[i] = (channels == 1) ? l : r;
}
return true;
}
// --- base64 (RFC 4648) — same helpers as RenderJob.cpp ----------------------
static const char* kBase64Tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static std::string base64Encode(const uint8_t* data, size_t len) {
std::string out;
out.reserve(((len + 2) / 3) * 4);
for (size_t i = 0; i < len; i += 3) {
uint32_t n = (uint32_t)data[i] << 16;
if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8;
if (i + 2 < len) n |= (uint32_t)data[i + 2];
out += kBase64Tbl[(n >> 18) & 63];
out += kBase64Tbl[(n >> 12) & 63];
out += (i + 1 < len) ? kBase64Tbl[(n >> 6) & 63] : '=';
out += (i + 2 < len) ? kBase64Tbl[n & 63] : '=';
}
return out;
}
// Append-only variant (no return temporary) so it can run inside an SEH
// __try frame (C2712: __try functions cannot have destructor temporaries).
static void base64EncodeTo(const uint8_t* data, size_t len, std::string& out) {
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t cap = ((len + 2) / 3) * 4;
out.clear();
out.reserve(cap);
for (size_t i = 0; i < len; i += 3) {
uint32_t n = (uint32_t)data[i] << 16;
if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8;
if (i + 2 < len) n |= (uint32_t)data[i + 2];
out += tbl[(n >> 18) & 63];
out += tbl[(n >> 12) & 63];
out += (i + 1 < len) ? tbl[(n >> 6) & 63] : '=';
out += (i + 2 < len) ? tbl[n & 63] : '=';
}
}
static int b64val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
static bool base64Decode(const std::string& in, std::vector<uint8_t>& out) {
out.clear();
out.reserve((in.size() / 4) * 3);
uint32_t acc = 0;
int bits = 0;
for (unsigned char ch : in) {
if (ch == '=' || ch == '\n' || ch == '\r' || ch == ' ') continue;
int v = b64val(ch);
if (v < 0) return false;
acc = (acc << 6) | (uint32_t)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back((uint8_t)((acc >> bits) & 0xFF));
}
}
return true;
}
// --- VST3 audio-effect host (offline; no MIDI, no GUI) ----------------------
#ifdef HAVE_VST3SDK
using Steinberg::tresult;
using Steinberg::kResultOk;
using Steinberg::kResultTrue;
using Steinberg::kResultFalse;
using Steinberg::kNoInterface;
using Steinberg::FUnknownPtr;
using Steinberg::IPtr;
using Steinberg::owned;
using Steinberg::FIDString;
using Steinberg::int32;
using Steinberg::uint32;
using Steinberg::Vst::IComponent;
using Steinberg::Vst::IEditController;
using Steinberg::Vst::IAudioProcessor;
using Steinberg::Vst::IComponentHandler;
using Steinberg::Vst::IConnectionPoint;
using Steinberg::Vst::ProcessSetup;
using Steinberg::Vst::ProcessContext;
using Steinberg::Vst::HostProcessData;
using Steinberg::Vst::HostApplication;
using Steinberg::Vst::BusInfo;
using Steinberg::Vst::kAudio;
using Steinberg::Vst::kEvent;
using Steinberg::Vst::kInput;
using Steinberg::Vst::kOutput;
using Steinberg::Vst::kRealtime;
using Steinberg::Vst::kSample32;
class HostComponentHandlerFx : public IComponentHandler {
public:
tresult queryInterface(const char*, void** v) override { *v = nullptr; return kNoInterface; }
Steinberg::uint32 addRef() override { return 1; }
Steinberg::uint32 release() override { return 1; }
tresult beginEdit(Steinberg::Vst::ParamID) override { return kResultOk; }
// Forward automation from the plugin's native GUI into the controller so
// controller-driven meters/UI update with the edited parameter. Null until
// the controller is attached (set in vst3FxLoadInner).
Steinberg::Vst::IEditController* controller = nullptr;
tresult performEdit(Steinberg::Vst::ParamID id, Steinberg::Vst::ParamValue v) override {
if (controller) controller->setParamNormalized(id, v);
return kResultOk;
}
tresult endEdit(Steinberg::Vst::ParamID) override { return kResultOk; }
tresult restartComponent(Steinberg::int32) override { return kResultOk; }
};
using Steinberg::IPlugView;
using Steinberg::kPlatformTypeHWND;
// Minimal IPlugFrame so the plugin can resize its editor view.
class HostPlugFrameFx : public Steinberg::IPlugFrame {
public:
tresult queryInterface(const char*, void** v) override {
*v = nullptr;
return kNoInterface;
}
Steinberg::uint32 addRef() override { return 1; }
Steinberg::uint32 release() override { return 1; }
tresult resizeView(Steinberg::IPlugView* view, Steinberg::ViewRect* newSize) override {
if (view && newSize) view->onSize(newSize);
return kResultOk;
}
};
struct Vst3FxState {
VST3::Hosting::Module::Ptr module; // destroyed LAST (owns the factory)
IPtr<IComponent> component;
IPtr<IEditController> controller;
IPtr<HostApplication> hostApp;
HostProcessData processData;
ProcessContext processContext;
HostComponentHandlerFx componentHandler;
int32 inputChannels = 2;
int32 outputChannels = 2;
int32 latencySamples = 0; // PDC (REPORT_LATENCY, Phase 2.8)
bool controllerIsComponent = false;
Steinberg::IPlugFrame* plugFrame = nullptr; // owned; freed in closeEditor
IPtr<IPlugView> view; // editor view while GUI open
Steinberg::Vst::ParameterChanges paramChanges; // live setParam queue -> inputParameterChanges
std::mutex paramMutex; // setParam (loop/worker) vs processAudio
std::map<std::string, Steinberg::Vst::ParamID> paramByName; // lower(title/shortTitle) -> id
};
// Class selection for FX: any kVstAudioEffectClass whose subcategory does NOT
// contain "Instrument" (Ozone/ScalerAudio et al.). Pick the first match.
bool pickFxClass(const VST3::Hosting::PluginFactory& factory,
VST3::Hosting::ClassInfo& out) {
auto infos = factory.classInfos(); // temporary vector — copy, never keep pointer
for (const auto& ci : infos) {
if (ci.category() != kVstAudioEffectClass) continue;
if (ci.subCategoriesString().find("Instrument") != std::string::npos) continue;
out = ci;
return true;
}
return false;
}
// Must live OUTSIDE any __try (C2712: no C++ unwinding inside SEH frames).
// Windows DLL search: LoadLibraryW (VST3 SDK) does NOT search the plugin's own
// directory for dependency DLLs -> ERROR_MOD_NOT_FOUND (0x7E) when a VST3 needs
// helper DLLs next to it (bundle Contents\x86_64-win or sidecar files). Point
// the DLL search path at the plugin directory before Module::create.
#ifdef _WIN32
static void setPluginSearchPath(const std::string& path) {
if (path.empty()) return;
std::string dir;
// Folder bundle (.vst3 dir): deps live in Contents\<arch>\
// Single file (.vst3/.dll): deps live next to it.
size_t baseSlash = path.find_last_of("/\\");
std::string base = baseSlash == std::string::npos ? "" : path.substr(baseSlash + 1);
std::string parent = baseSlash == std::string::npos ? "" : path.substr(0, baseSlash);
if (!base.empty() && base.size() > 4 &&
base.substr(base.size() - 4) == ".vst3") {
// Could be a folder bundle OR a single .vst3 file. Check folder first.
DWORD attr = GetFileAttributesA(path.c_str());
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY)) {
dir = path + "\\Contents\\x86_64-win";
DWORD dAttr = GetFileAttributesA(dir.c_str());
if (dAttr == INVALID_FILE_ATTRIBUTES)
dir = path + "\\Contents";
} else {
dir = parent;
}
} else {
dir = parent;
}
if (dir.empty()) return;
// SetDllDirectoryW is process-global, fine here: bridge loads one plugin
// at a time (render slots serial; realtime chain loads on its own thread).
std::wstring wdir(dir.begin(), dir.end());
SetDllDirectoryW(wdir.c_str());
std::cerr << "[RenderFx] dll search dir: " << dir << std::endl;
}
#endif
bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate,
int32 maxBlockSize, std::string& err) {
using namespace VST3::Hosting;
#ifdef _WIN32
setPluginSearchPath(path);
#endif
Module::Ptr module = Module::create(path, err);
if (!module) { std::cerr << "[RenderFx] STEP module.create FAILED: " << err << std::endl; return false; }
std::cerr << "[RenderFx] STEP module.create OK" << std::endl;
const PluginFactory& factory = module->getFactory();
ClassInfo chosen;
if (!pickFxClass(factory, chosen)) {
err = "no audio-effect class in " + path;
std::cerr << "[RenderFx] STEP pickFxClass FAILED: " << err << std::endl;
return false;
}
std::cerr << "[RenderFx] STEP pickFxClass OK: " << chosen.name() << std::endl;
IPtr<IComponent> component = factory.createInstance<IComponent>(chosen.ID());
if (!component) { std::cerr << "[RenderFx] STEP createInstance FAILED" << std::endl; err = "createInstance<IComponent> failed"; return false; }
std::cerr << "[RenderFx] STEP createInstance OK" << std::endl;
IPtr<HostApplication> hostApp = owned(new HostApplication());
FUnknownPtr<Steinberg::IPluginBase> plugBase(component.get());
if (!plugBase || plugBase->initialize(hostApp) != kResultOk) {
err = "component initialize failed";
std::cerr << "[RenderFx] STEP initialize FAILED" << std::endl;
return false;
}
std::cerr << "[RenderFx] STEP initialize OK" << std::endl;
IPtr<IEditController> controller;
bool isSingle = false;
if (component->queryInterface(IEditController::iid, (void**)&controller) == kResultTrue) {
isSingle = true;
} else {
Steinberg::TUID cid = {};
tresult cidRes = component->getControllerClassId(cid);
if (cidRes == kResultTrue || cidRes == kResultOk) {
controller = factory.createInstance<IEditController>(VST3::UID(cid));
if (controller) {
FUnknownPtr<Steinberg::IPluginBase> ctrlBase(controller.get());
if (!ctrlBase || ctrlBase->initialize(hostApp) != kResultOk) controller = nullptr;
}
}
}
if (!controller) { err = "no edit controller"; std::cerr << "[RenderFx] STEP controller FAILED" << std::endl; return false; }
std::cerr << "[RenderFx] STEP controller OK" << std::endl;
s->controllerIsComponent = isSingle;
controller->setComponentHandler(&s->componentHandler);
s->componentHandler.controller = controller.get();
FUnknownPtr<IConnectionPoint> compCP(component);
FUnknownPtr<IConnectionPoint> ctrlCP(controller);
if (compCP && ctrlCP) { compCP->connect(ctrlCP); ctrlCP->connect(compCP); }
std::cerr << "[RenderFx] STEP connection points OK" << std::endl;
int32 numAudioInputs = component->getBusCount(kAudio, kInput);
for (int32 i = 0; i < numAudioInputs; ++i) component->activateBus(kAudio, kInput, i, true);
int32 numAudioOutputs = component->getBusCount(kAudio, kOutput);
for (int32 i = 0; i < numAudioOutputs; ++i) component->activateBus(kAudio, kOutput, i, true);
int32 numEventInputs = component->getBusCount(kEvent, kInput);
for (int32 i = 0; i < numEventInputs; ++i) component->activateBus(kEvent, kInput, i, true);
int32 numEventOutputs = component->getBusCount(kEvent, kOutput);
for (int32 i = 0; i < numEventOutputs; ++i) component->activateBus(kEvent, kOutput, i, true);
std::cerr << "[RenderFx] STEP buses activated (ain=" << numAudioInputs << " aout=" << numAudioOutputs << ")" << std::endl;
if (numAudioInputs < 1 || numAudioOutputs < 1) {
err = "plugin has no audio in/out (instrument?)"; return false;
}
FUnknownPtr<IAudioProcessor> processor(component);
if (!processor) { err = "no IAudioProcessor"; return false; }
ProcessSetup setup{kRealtime, kSample32, maxBlockSize, sampleRate};
std::cerr << "[RenderFx] STEP setupProcessing begin" << std::endl;
if (processor->setupProcessing(setup) != kResultOk) { err = "setupProcessing failed"; return false; }
std::cerr << "[RenderFx] STEP setupProcessing OK" << std::endl;
std::cerr << "[RenderFx] STEP setActive begin" << std::endl;
if (component->setActive(true) != kResultOk) { err = "setActive failed"; return false; }
std::cerr << "[RenderFx] STEP setActive OK" << std::endl;
processor->setProcessing(true);
std::cerr << "[RenderFx] STEP setProcessing OK" << std::endl;
s->latencySamples = processor->getLatencySamples();
std::cerr << "[RenderFx] STEP getLatencySamples = " << s->latencySamples << std::endl;
if (!s->processData.prepare(*component, maxBlockSize, kSample32)) {
err = "processData.prepare failed"; return false;
}
std::cerr << "[RenderFx] STEP processData.prepare OK" << std::endl;
BusInfo bi = {};
if (component->getBusInfo(kAudio, kInput, 0, bi) == kResultOk && bi.channelCount > 0)
s->inputChannels = bi.channelCount;
bi = {};
if (component->getBusInfo(kAudio, kOutput, 0, bi) == kResultOk && bi.channelCount > 0)
s->outputChannels = bi.channelCount;
// Live setParam: map lowercased title/shortTitle -> ParamID so the
// realtime chain can address VST3 params by name (no UI-side tags).
// Titles are UTF-16 (String128); keep the ASCII subset lowercased.
for (int32 pi = 0; pi < controller->getParameterCount(); ++pi) {
Steinberg::Vst::ParameterInfo info = {};
if (controller->getParameterInfo(pi, info) != kResultOk) continue;
auto addName = [&](const Steinberg::Vst::TChar* nm) {
if (!nm) return;
std::string k;
for (const Steinberg::Vst::TChar* p = nm; p && *p && k.size() < 64; ++p) {
if (*p < 0x80) k.push_back((char)std::tolower((unsigned char)*p));
}
if (k.empty()) return;
s->paramByName[k] = info.id;
};
addName(info.title);
addName(info.shortTitle);
}
s->module = std::move(module);
s->component = std::move(component);
s->controller = std::move(controller);
s->hostApp = std::move(hostApp);
s->processContext.sampleRate = sampleRate;
s->processContext.tempo = 120.0;
s->processContext.timeSigNumerator = 4;
s->processContext.timeSigDenominator = 4;
s->processContext.state = ProcessContext::kPlaying | ProcessContext::kTempoValid |
ProcessContext::kTimeSigValid | ProcessContext::kProjectTimeMusicValid;
std::cout << "[RenderFx] VST3 FX loaded " << path << " (" << chosen.name()
<< ") in=" << s->inputChannels << " out=" << s->outputChannels << std::endl;
return true;
}
#endif // HAVE_VST3SDK
class Vst3Fx {
public:
Vst3Fx() = default;
~Vst3Fx() {
#ifdef HAVE_VST3SDK
if (!state_) return;
auto* s = static_cast<Vst3FxState*>(state_);
if (s->component) {
FUnknownPtr<IAudioProcessor> processor(s->component);
if (processor) processor->setProcessing(false);
s->component->setActive(false);
s->component->terminate();
}
if (s->controller && !s->controllerIsComponent) s->controller->terminate();
s->processData.unprepare();
delete s;
state_ = nullptr;
#endif
}
bool load(const std::string& path, double sampleRate, int32 maxBlockSize) {
#ifndef HAVE_VST3SDK
(void)path; (void)sampleRate; (void)maxBlockSize;
return false;
#else
if (state_) return true;
auto* s = new Vst3FxState();
std::string err;
if (!vst3FxLoadInner(s, path, sampleRate, maxBlockSize, err)) {
std::cerr << "[RenderFx] VST3 FX load FAILED: " << err << std::endl;
delete s;
return false;
}
state_ = s;
return true;
#endif
}
// Audio in → audio out, one block. Must be SEH-wrapped by the caller.
void processAudio(const float* inL, const float* inR,
float* outL, float* outR, uint32_t n) {
#ifndef HAVE_VST3SDK
(void)inL; (void)inR; (void)outL; (void)outR; (void)n;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s || !s->component || n == 0) return;
FUnknownPtr<IAudioProcessor> processor(s->component);
if (!processor) return;
s->processData.processMode = kRealtime;
s->processData.numSamples = (int32)n;
s->processData.inputEvents = nullptr;
{
std::lock_guard<std::mutex> lk(s->paramMutex);
s->processData.inputParameterChanges = &s->paramChanges;
}
s->processData.processContext = &s->processContext;
s->processContext.projectTimeSamples += n;
s->processContext.projectTimeMusic =
(double)s->processContext.projectTimeSamples / s->processContext.sampleRate *
(s->processContext.tempo / 60.0);
// Copy input into the prepared (owned) input buffers — direct write,
// setChannelBuffers is unusable (channelBufferOwner=true after prepare).
if (s->processData.numInputs > 0) {
const Steinberg::Vst::AudioBusBuffers& ib = s->processData.inputs[0];
for (int32 c = 0; c < ib.numChannels; ++c) {
float* dst = ib.channelBuffers32[c];
if (!dst) continue;
const float* src = (c == 0) ? inL : (c == 1 ? inR : inL);
std::memcpy(dst, src, n * sizeof(float));
}
}
// Host buffers must be zeroed before process (plugins skip output
// leave garbage otherwise — same rule as Vst3Instrument).
if (s->processData.numOutputs > 0) {
for (int32 b = 0; b < s->processData.numOutputs; ++b) {
const Steinberg::Vst::AudioBusBuffers& ob = s->processData.outputs[b];
for (int32 c = 0; c < ob.numChannels; ++c)
if (ob.channelBuffers32[c])
std::memset(ob.channelBuffers32[c], 0, n * sizeof(float));
}
}
tresult pr = processor->process(s->processData);
if (pr >= 0 && s->processData.numOutputs > 0) {
const Steinberg::Vst::AudioBusBuffers& out = s->processData.outputs[0];
const float* buf0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
const float* buf1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
if (buf0) std::memcpy(outL, buf0, n * sizeof(float));
else std::memset(outL, 0, n * sizeof(float));
if (buf1) std::memcpy(outR, buf1, n * sizeof(float));
else if (buf0) std::memcpy(outR, buf0, n * sizeof(float)); // mono → stereo
else std::memset(outR, 0, n * sizeof(float));
} else {
std::memset(outL, 0, n * sizeof(float));
std::memset(outR, 0, n * sizeof(float));
}
{
// Each block consumes the queue once; clear so the next setParam
// writes fresh points (adelay reads the LAST point).
std::lock_guard<std::mutex> lk(s->paramMutex);
s->paramChanges.clearQueue();
}
#endif
}
// Apply preset: base64 of a raw .vstpreset FILE (VST3 magic + chunk list)
// OR of the bridge state blob [4B BE compLen][comp][4B BE ctrlLen][ctrl].
bool applyPreset(const std::string& b64) {
#ifndef HAVE_VST3SDK
(void)b64;
return false;
#else
std::vector<uint8_t> bytes;
if (!base64Decode(b64, bytes) || bytes.empty()) return false;
std::vector<uint8_t> comp, ctrl;
if (bytes.size() >= 4 && std::memcmp(bytes.data(), "VST3", 4) == 0) {
// Raw .vstpreset file: parse with the SDK PresetFile.
Steinberg::MemoryStream stream;
if (stream.write(bytes.data(), (int32)bytes.size(), nullptr) != Steinberg::kResultOk) return false;
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
Steinberg::Vst::PresetFile pf(&stream);
if (!pf.readChunkList()) return false;
auto readChunk = [&](Steinberg::Vst::ChunkType which, std::vector<uint8_t>& outc) -> bool {
const Steinberg::Vst::PresetFile::Entry* e = pf.getEntry(which);
if (!e) return true;
bool seeked = (which == Steinberg::Vst::kComponentState)
? pf.seekToComponentState() : pf.seekToControllerState();
if (!seeked) return false;
outc.resize((size_t)e->size);
if (e->size == 0) return true;
int32 got = 0;
return stream.read(outc.data(), (int32)e->size, &got) == Steinberg::kResultOk &&
got == e->size;
};
if (!readChunk(Steinberg::Vst::kComponentState, comp) ||
!readChunk(Steinberg::Vst::kControllerState, ctrl))
return false;
} else {
// Bridge state blob.
auto read32 = [&bytes](size_t off) -> uint32_t {
return ((uint32_t)bytes[off] << 24) | ((uint32_t)bytes[off + 1] << 16) |
((uint32_t)bytes[off + 2] << 8) | (uint32_t)bytes[off + 3];
};
if (bytes.size() < 8) return false;
size_t off = 0;
uint32_t clen = read32(off); off += 4;
if (off + clen > bytes.size()) return false;
comp.assign(bytes.begin() + off, bytes.begin() + off + clen);
off += clen;
if (off + 4 > bytes.size()) return false;
uint32_t klen = read32(off); off += 4;
if (off + klen > bytes.size()) return false;
ctrl.assign(bytes.begin() + off, bytes.begin() + off + klen);
}
auto* s = static_cast<Vst3FxState*>(state_);
if (!s) return false;
if (!comp.empty()) {
Steinberg::MemoryStream stream;
stream.write(comp.data(), (int32)comp.size(), nullptr);
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
if (!s->component || s->component->setState(&stream) != kResultOk) return false;
}
if (!ctrl.empty()) {
Steinberg::MemoryStream stream;
stream.write(ctrl.data(), (int32)ctrl.size(), nullptr);
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
if (!s->controller || s->controller->setState(&stream) != kResultOk) return false;
}
std::cout << "[RenderFx] preset applied component=" << comp.size()
<< " controller=" << ctrl.size() << std::endl;
return true;
#endif
}
// Capture the plugin's CURRENT state (component + edit controller) as the
// bridge blob [4B BE compLen][comp][4B BE ctrlLen][ctrl] base64 — same
// format applyPreset accepts. Lets the GUI bridge report knob tweaks so a
// saved project can restore them. Must run on the thread owning the plugin.
void captureState(std::string& out) {
#ifndef HAVE_VST3SDK
(void)out;
return;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s) return;
std::vector<uint8_t> comp, ctrl;
{
Steinberg::MemoryStream stream;
if (s->component && s->component->getState(&stream) == kResultOk && stream.getSize() > 0)
comp.assign(stream.getData(), stream.getData() + stream.getSize());
}
if (!s->controllerIsComponent) {
Steinberg::MemoryStream stream;
if (s->controller && s->controller->getState(&stream) == kResultOk && stream.getSize() > 0)
ctrl.assign(stream.getData(), stream.getData() + stream.getSize());
}
if (comp.empty() && ctrl.empty()) return;
std::vector<uint8_t> blob;
auto append32 = [&blob](uint32_t v) {
blob.push_back((uint8_t)(v >> 24));
blob.push_back((uint8_t)(v >> 16));
blob.push_back((uint8_t)(v >> 8));
blob.push_back((uint8_t)v);
};
append32((uint32_t)comp.size());
blob.insert(blob.end(), comp.begin(), comp.end());
append32((uint32_t)ctrl.size());
blob.insert(blob.end(), ctrl.begin(), ctrl.end());
base64EncodeTo(blob.data(), blob.size(), out);
#endif
}
// Live param change (realtime chain, NO restart): address by lowercase
// title/shortTitle or numeric ParamID; value normalized 0..1. Delivered
// via setParamNormalized (controller/UI state) + inputParameterChanges
// queue (split plugins read the queue in process() — same rule as
// Vst3Instrument). ponytail: beginEdit/endEdit automation gestures when a
// plugin ignores setParamNormalized (fallback: restart with new preset).
bool setParam(const std::string& key, double value) {
#ifndef HAVE_VST3SDK
(void)key; (void)value;
return false;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s || !s->controller) return false;
Steinberg::Vst::ParamID tag = 0;
bool found = false;
try { // numeric ParamID passes through
size_t consumed = 0;
unsigned long num = std::stoul(key, &consumed);
if (consumed == key.size()) { tag = (Steinberg::Vst::ParamID)num; found = true; }
} catch (...) {}
if (!found) {
std::string k = key;
std::transform(k.begin(), k.end(), k.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
auto it = s->paramByName.find(k);
if (it == s->paramByName.end()) {
std::cerr << "[RealtimeFx] vst3 setParam unknown key: " << key << std::endl;
return false;
}
tag = it->second;
}
Steinberg::Vst::ParamValue v =
(Steinberg::Vst::ParamValue)std::max(0.0, std::min(1.0, value));
{
std::lock_guard<std::mutex> lk(s->paramMutex);
s->controller->setParamNormalized(tag, v);
int32 idx = 0;
if (Steinberg::Vst::IParamValueQueue* q =
s->paramChanges.addParameterData(tag, idx))
q->addPoint(0, v, idx);
}
std::cout << "[RealtimeFx] vst3 setParam '" << key << "' -> tag " << tag
<< " = " << v << std::endl;
return true;
#endif
}
// Native editor: attach the plugin's IPlugView to a host window (must run
// on the thread that will pump its messages — same rule as Vst3Instrument).
bool openEditor(void* parentWindowHandle) {
#ifndef HAVE_VST3SDK
(void)parentWindowHandle;
return false;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s || !s->controller || !parentWindowHandle) return false;
if (s->plugFrame) return true; // already attached
s->plugFrame = new HostPlugFrameFx();
IPlugView* rawView = nullptr;
s->controller->queryInterface(IPlugView::iid, (void**)&rawView);
FUnknownPtr<IPlugView> view(rawView);
if (!view) {
// Some plugins expose IPlugView on the component instead.
IPlugView* rawViewC = nullptr;
s->component->queryInterface(IPlugView::iid, (void**)&rawViewC);
view = FUnknownPtr<IPlugView>(rawViewC);
}
if (!view) {
// Official editorhost.cpp pattern: IEditController::createView.
// JUCE-based plugins (Scaler2) expose the editor only this way.
view = owned(s->controller->createView(Steinberg::Vst::ViewType::kEditor));
}
if (!view) {
delete s->plugFrame;
s->plugFrame = nullptr;
return false;
}
view->setFrame(s->plugFrame);
view->isPlatformTypeSupported(kPlatformTypeHWND);
if (view->attached(parentWindowHandle, kPlatformTypeHWND) != kResultOk) {
view->setFrame(nullptr);
delete s->plugFrame;
s->plugFrame = nullptr;
return false;
}
s->view = view;
#ifdef _WIN32
HWND hwnd = (HWND)parentWindowHandle;
Steinberg::ViewRect rect;
if (view->getSize(&rect) == kResultOk) {
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
RECT r = { 0, 0, w, h };
// Size to the window's own style: borderless (WS_POPUP) needs no
// caption/border adjustment.
AdjustWindowRect(&r, GetWindowLongPtrA(hwnd, GWL_STYLE), FALSE);
SetWindowPos(hwnd, nullptr, 0, 0, r.right - r.left, r.bottom - r.top,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
view->onSize(&rect); // some Skia/OpenGL editors skip first paint
}
#endif
return true;
#endif
}
// Detach editor. Must run on the SAME thread that attached (the GUI message
// loop has exited). removed() is intentionally NOT called — Vst3Instrument
// documents a deadlock when the pump is not running.
void closeEditor() {
#ifndef HAVE_VST3SDK
return;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s) return;
if (s->view) {
s->view->setFrame(nullptr);
s->view = nullptr; // IPtr releases the view
}
if (s->plugFrame) {
delete s->plugFrame;
s->plugFrame = nullptr;
}
#endif
}
// Resize the editor view to w x h (VST3 host resize flow: checkSizeConstraint
// lets the plugin clamp to a supported size, host resizes the window, then
// onSize tells the plugin -- it moves/resizes its own child window). Runs on
// the GUI pump thread (WM_FXGUI_RESIZE) -- plugin calls must not come from
// the HTTP worker thread.
bool resizeEditor(void* parentWindowHandle, int w, int h) {
#ifndef HAVE_VST3SDK
(void)parentWindowHandle; (void)w; (void)h;
return false;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s || !s->view || w <= 0 || h <= 0) return false;
Steinberg::ViewRect vr = {0, 0, w, h};
s->view->checkSizeConstraint(&vr); // plugin clamps to a supported size
const int cw = vr.right - vr.left;
const int ch = vr.bottom - vr.top;
if (cw <= 0 || ch <= 0) return false;
#ifdef _WIN32
RECT r = {0, 0, cw, ch};
AdjustWindowRectEx(&r,
(DWORD)GetWindowLongPtrA((HWND)parentWindowHandle, GWL_STYLE),
FALSE,
(DWORD)GetWindowLongPtrA((HWND)parentWindowHandle, GWL_EXSTYLE));
SetWindowPos((HWND)parentWindowHandle, nullptr, 0, 0,
r.right - r.left, r.bottom - r.top,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
#endif
s->view->onSize(&vr);
return true;
#endif
}
bool loaded() const { return state_ != nullptr; }
// Plugin-reported latency samples (PDC) — 0 nếu plugin không báo.
int32 latencySamples() const {
#ifndef HAVE_VST3SDK
return 0;
#else
return state_ ? static_cast<Vst3FxState*>(state_)->latencySamples : 0;
#endif
}
private:
void* state_ = nullptr;
};
// One FX slot from the job. builtin id: "gain" | "normalize" (legacy) hoặc 8
// DSP (PLAN_DAW_A Phase 2) — id ∈ eq|eqpro|imager|maximizer|compressor|limiter
// |exciter|rebalance → bfx (BuiltinFxChain).
struct FxSlot {
bool vst = false; // true = VST3, false = builtin
std::string path; // vst only
std::string presetB64; // vst only
bool bypass = false;
std::string builtinId; // builtin only
double db = 0.0; // gain param
double peak = 0.95; // normalize param
std::unique_ptr<Vst3Fx> fx;
std::unique_ptr<BuiltinFx> bfx; // 8 DSP builtin (Phase 2)
};
} // namespace
// SEH-guarded single-slot process call. Kept OUT of run_render_fx_job's frame
// (C2712: __try cannot live in a function whose C++ objects need unwinding).
// This frame holds only references/raw pointers — nothing to unwind.
static bool runFxSlotSafe(const FxSlot& sl, const float* inL, const float* inR,
float* outL, float* outR, uint32_t n, uint32_t* crashCode) {
#ifdef _WIN32
__try {
sl.fx->processAudio(inL, inR, outL, outR, n);
return true;
} __except (*crashCode = (uint32_t)GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER) {
return false;
}
#else
sl.fx->processAudio(inL, inR, outL, outR, n);
return true;
#endif
}
// SEH-guarded builtin DSP slot (in-place). Frame chỉ raw pointers.
static bool runBuiltinSlotSafe(BuiltinFx* fx, float* L, float* R, uint32_t n,
uint32_t* crashCode) {
#ifdef _WIN32
__try {
fx->process(L, R, n);
return true;
} __except (*crashCode = (uint32_t)GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER) {
return false;
}
#else
fx->process(L, R, n);
return true;
#endif
}
int run_render_fx_job(const std::string& jobPath, const std::string& inPath,
const std::string& outPath) {
#ifdef _WIN32
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
#endif
int rc = 1;
std::ofstream wav;
auto fail = [&](const std::string& msg) {
std::cerr << "[RenderFx] " << msg << std::endl;
if (wav.is_open()) wav.close();
std::remove(outPath.c_str());
};
// 1. Read input WAV.
WavIn in;
if (!readWav(inPath, in)) { fail("cannot read input WAV: " + inPath); return rc; }
if (in.L.empty()) { fail("input WAV is empty"); return rc; }
// 2. Parse job.
std::ifstream jf(jobPath, std::ios::binary);
if (!jf) { fail("cannot read job file: " + jobPath); return rc; }
std::string data((std::istreambuf_iterator<char>(jf)), std::istreambuf_iterator<char>());
json_parse_result_s pres = {};
json_value_s* root = json_parse_ex(data.data(), data.size(), json_parse_flags_default,
nullptr, nullptr, &pres);
if (!root) { fail("job JSON parse error (code " + std::to_string(pres.error) + ")"); return rc; }
struct RootHolder { json_value_s* p = nullptr; ~RootHolder() { if (p) std::free(p); } } rootH;
rootH.p = root;
if (root->type != json_type_object) { fail("job root must be a JSON object"); return rc; }
const json_object_s* job = static_cast<const json_object_s*>(root->payload);
double srD = (double)in.sampleRate;
memberNumber(job, "sample_rate", srD);
if (srD < 8000.0 || srD > 192000.0) { fail("bad sample_rate: " + std::to_string(srD)); return rc; }
const uint32_t sampleRate = (uint32_t)srD;
if (in.sampleRate != sampleRate) {
fail("input WAV sr (" + std::to_string(in.sampleRate) + ") != job sample_rate ("
+ std::to_string(sampleRate) + ")"); return rc;
}
int64_t block = memberInt(job, "block_size", 512);
block = std::max<int64_t>(32, std::min<int64_t>(block, 4096));
const uint32_t chunk = (uint32_t)block;
// 3. Build slot list.
std::vector<FxSlot> slots;
const json_value_s* chainV = memberValue(job, "fx_chain");
if (chainV && chainV->type == json_type_array) {
const json_array_s* chain = static_cast<const json_array_s*>(chainV->payload);
for (const json_array_element_s* el = chain->start; el; el = el->next) {
const json_value_s* v = el->value;
if (!v || v->type != json_type_object) continue;
const json_object_s* o = static_cast<const json_object_s*>(v->payload);
const std::string type = memberString(o, "type", "");
FxSlot sl;
sl.bypass = memberBool(o, "bypass", false);
if (type == "vst3" || type == "vst") {
sl.vst = true;
sl.path = memberString(o, "path", "");
sl.presetB64 = memberString(o, "preset_b64", "");
} else if (type == "builtin") {
sl.vst = false;
sl.builtinId = memberString(o, "id", "");
const json_object_s* po = nullptr;
const json_value_s* pv = memberValue(o, "params");
if (pv && pv->type == json_type_object)
po = static_cast<const json_object_s*>(pv->payload);
double d = sl.db; if (po) memberNumber(po, "db", d); sl.db = d;
double pk = sl.peak; if (po) memberNumber(po, "peak", pk); sl.peak = pk;
if (sl.builtinId != "gain" && sl.builtinId != "normalize") {
// 8 DSP (PLAN_DAW_A Phase 2) — khớp Python _apply_builtin_fx_chain.
sl.bfx = createBuiltinFx(sl.builtinId, po, srD);
if (!sl.bfx) {
fail("unknown builtin id: " + sl.builtinId); return rc;
}
}
} else {
fail("unknown fx_chain slot type: " + type); return rc;
}
slots.push_back(std::move(sl));
}
}
if (slots.empty()) { fail("fx_chain is empty — nothing to render"); return rc; }
// 4. Load VST3 slots (bypassed slots stay unloaded → passthrough).
for (auto& sl : slots) {
if (!sl.vst || sl.bypass) continue;
if (sl.path.empty()) { fail("vst3 slot missing path"); return rc; }
auto fx = std::make_unique<Vst3Fx>();
if (!fx->load(sl.path, sampleRate, (int32)chunk)) { rc = 2; fail("VST3 FX load failed: " + sl.path); return rc; }
if (!sl.presetB64.empty() && !fx->applyPreset(sl.presetB64)) {
fail("preset import FAILED for " + sl.path); return rc;
}
sl.fx = std::move(fx);
}
// 4b. Per-slot latency (PDC, Phase 3): vst3 = getLatencySamples() (0 nếu
// chưa load/bypass), builtin = 0. 1 dòng stdout để native_render parse →
// render_engine align track (cùng thứ tự mảng slots).
std::cout << "[RenderFx] FX_LATENCIES";
for (const auto& sl : slots) {
uint32_t lat = 0;
if (sl.vst && !sl.bypass && sl.fx) lat = (uint32_t)sl.fx->latencySamples();
std::cout << ' ' << lat;
}
std::cout << std::endl;
// 5. Process chain block-wise. Double-buffered: cur ← slot → nxt, swap.
std::vector<float> L0(chunk), R0(chunk), L1(chunk), R1(chunk);
std::vector<float> curL(chunk), curR(chunk);
bool normalizePending = false;
double normalizePeak = 0.95;
uint32_t crashCode = 0;
for (const auto& sl : slots) {
if (sl.bypass) continue;
if (sl.vst && !sl.fx) continue; // defensive: bypassed/unloaded
if (!sl.vst && sl.builtinId == "normalize") {
normalizePending = true;
normalizePeak = std::max(0.01, std::min(1.0, sl.peak));
continue; // global post-pass
}
const bool isVst = sl.vst;
const double gainLin = sl.vst ? 1.0 : std::pow(10.0, sl.db / 20.0);
for (uint64_t pos = 0; pos < in.L.size(); pos += chunk) {
const uint32_t n = (uint32_t)std::min<uint64_t>(chunk, in.L.size() - pos);
std::memcpy(curL.data(), in.L.data() + pos, n * sizeof(float));
std::memcpy(curR.data(), in.R.data() + pos, n * sizeof(float));
if (isVst) {
bool ok = runFxSlotSafe(sl, curL.data(), curR.data(), L0.data(), R0.data(),
n, &crashCode);
if (!ok) { rc = 3; fail("VST3 FX crashed inside processAudio (SEH code=0x"
+ std::to_string(crashCode) + ")"); return rc; }
std::memcpy(in.L.data() + pos, L0.data(), n * sizeof(float));
std::memcpy(in.R.data() + pos, R0.data(), n * sizeof(float));
} else if (sl.bfx) {
// Builtin DSP (8 loại) — in-place, xen kẽ VST3 đúng thứ tự UI.
bool ok = runBuiltinSlotSafe(sl.bfx.get(), in.L.data() + pos,
in.R.data() + pos, n, &crashCode);
if (!ok) {
rc = 3; fail("builtin DSP crashed (SEH code=0x"
+ std::to_string(crashCode) + ")"); return rc;
}
} else {
for (uint32_t i = 0; i < n; ++i) {
in.L[pos + i] = curL[i] * (float)gainLin;
in.R[pos + i] = curR[i] * (float)gainLin;
}
}
}
if (isVst) std::cout << "[RenderFx] slot processed: " << sl.path << std::endl;
else std::cout << "[RenderFx] slot processed: builtin " << sl.builtinId << std::endl;
}
// 6. Global normalize pass (only reduces when peak exceeds target — mirrors
// render_project's clip guard; the final mastering slot by convention).
if (normalizePending) {
float peak = 0.f;
for (float v : in.L) peak = std::max(peak, std::fabs(v));
for (float v : in.R) peak = std::max(peak, std::fabs(v));
if (peak > (float)normalizePeak && peak > 1e-9f) {
const float k = (float)normalizePeak / peak;
for (float& v : in.L) v *= k;
for (float& v : in.R) v *= k;
std::cout << "[RenderFx] normalize: peak " << peak << " → " << normalizePeak << std::endl;
} else {
std::cout << "[RenderFx] normalize: peak " << peak << " ≤ " << normalizePeak << " (no-op)" << std::endl;
}
}
// 7. Write output WAV.
wav.open(outPath, std::ios::binary);
if (!wav) { fail("cannot open output: " + outPath); return rc; }
writeWavHeader(wav, sampleRate);
uint64_t dataBytes = 0;
for (uint64_t pos = 0; pos < in.L.size(); pos += chunk) {
const uint32_t n = (uint32_t)std::min<uint64_t>(chunk, in.L.size() - pos);
writeFrames(wav, in.L.data() + pos, in.R.data() + pos, n);
dataBytes += (uint64_t)n * 8; // stereo float32: 2ch * 4B
}
finishWav(wav, dataBytes);
wav.close();
std::cout << "[RenderFx] wrote " << outPath << " (" << in.L.size() << " frames)" << std::endl;
rc = 0;
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** vst, BuiltinFx** bfx, 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 (bypass && bypass[i]) continue;
if (vst[i]) vst[i]->processAudio(inL, inR, inL, inR, n); // in-place safe
else if (bfx[i]) bfx[i]->process(inL, inR, n); // builtin in-place
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
std::cerr << "[RealtimeFx] chain crashed in process — slot bypassed" << std::endl;
}
#else
(void)vst; (void)bfx; (void)bypass; (void)count; (void)inL; (void)inR; (void)n;
#endif
}
} // namespace
struct RealtimeFxChain::Impl {
// VST3 (fx) hoặc builtin DSP (bfx) — 1 trong 2; xen kẽ đúng thứ tự UI.
struct Entry {
std::shared_ptr<Vst3Fx> fx;
std::unique_ptr<BuiltinFx> bfx;
bool bypass = false;
};
struct Chain { std::vector<Entry> entries; };
// SET_PARAM chờ chain swap (setParam tới trước setChain): worker áp sau
// khi swap — không mất param race lúc khởi động.
struct PendingParam { int slot; std::string key; double value; };
std::mutex pmutex_; // guards pending_
std::vector<PendingParam> pending_;
std::atomic<int> paramQueued_{0}; // wake worker: param chờ apply
std::mutex mutex_; // guards chain_ / retired_
std::shared_ptr<Chain> chain_;
std::shared_ptr<Chain> retired_; // dtor deferred to the worker thread
std::atomic<uint64_t> gen_{0}; // chain generation (Phase 2.8)
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;
// VST3 instance cache: giữ plugin đã load qua các lần buildChain để toggle
// bypass không phải load lại từ disk (tránh delay dry->wet). Key = path + #occ.
std::map<std::string, std::shared_ptr<Vst3Fx>> cache_;
std::map<std::string, std::string> lastPreset_; // preset b64 da ap (tranh ap lai)
std::map<std::string, int> occ_; // so lan xuat hien cua path trong chain
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* vst[kMaxSlots];
BuiltinFx* bfx[kMaxSlots];
bool bypass[kMaxSlots];
uint32_t count = 0;
for (const auto& e : c->entries) {
if (count >= kMaxSlots) break;
vst[count] = e.fx.get();
bfx[count] = e.bfx.get();
bypass[count] = e.bypass;
++count;
}
realtimeRunChain(vst, bfx, bypass, count, inL, inR, n);
}
void RealtimeFxChain::setParam(int slot, const std::string& key, double value) {
// SET_PARAM (SHM control ring, Phase 2.8): KHÔNG gọi VST API trên audio
// thread (chain.setParam chạy từ RealtimeFxLoop drain ctrl ring) —
// controller->setParamNormalized là COM call cross-thread với GUI pump,
// block ms→chục ms → out ring cạn → worklet underrun → crackle. Mọi
// param defer vào pending_, worker thread áp (builtin nhanh, VST3 an
// toàn) — audio thread chỉ push queue + notify.
if (!impl_) return;
{
std::lock_guard<std::mutex> lk(impl_->pmutex_);
impl_->pending_.push_back({slot, key, value});
}
impl_->paramQueued_.fetch_add(1, std::memory_order_release);
impl_->qcv_.notify_all();
}
uint64_t RealtimeFxChain::chainGen() {
return impl_ ? impl_->gen_.load(std::memory_order_acquire) : 0;
}
std::vector<uint32_t> RealtimeFxChain::entryLatencies() {
std::vector<uint32_t> out;
if (!impl_) return out;
std::shared_ptr<Impl::Chain> c;
{ std::lock_guard<std::mutex> lk(impl_->mutex_); c = impl_->chain_; }
if (!c) return out;
out.reserve(c->entries.size());
for (const auto& e : c->entries)
out.push_back((!e.bypass && e.fx) ? (uint32_t)e.fx->latencySamples() : 0u);
return out;
}
std::shared_ptr<RealtimeFxChain::Impl::Chain> RealtimeFxChain::Impl::buildChain(const std::string& json) {
auto chain = std::make_shared<Chain>();
occ_.clear(); // key theo vị trí slot trong CHAIN NÀY — reset mỗi build
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 type = memberString(o, "type", "");
if (type == "builtin") {
// Builtin DSP (Phase 2): id ∈ 8 loại — xen kẽ VST3 đúng thứ tự.
const std::string id = memberString(o, "id", "");
if (id.empty()) continue;
const json_object_s* po = nullptr;
const json_value_s* pv = memberValue(o, "params");
if (pv && pv->type == json_type_object)
po = static_cast<const json_object_s*>(pv->payload);
Entry ent;
ent.bypass = memberBool(o, "bypass", false);
ent.bfx = createBuiltinFx(id, po, sampleRate_);
if (!ent.bfx) {
std::cerr << "[RealtimeFx] unknown builtin id — slot skipped: " << id << std::endl;
continue;
}
chain->entries.push_back(std::move(ent));
continue;
}
const std::string path = memberString(o, "path", "");
if (path.empty()) continue;
Entry ent;
ent.bypass = memberBool(o, "bypass", false);
const std::string preset = memberString(o, "preset_b64", "");
// Key ổn định theo vị trí slot: đếm MỌI lần path xuất hiện (kể cả
// bypassed) — toggle bypass không đổi key → không reload nhầm instance.
const std::string ckey = path + "#" + std::to_string(occ_[path]++);
if (!ent.bypass) {
auto it = cache_.find(ckey);
if (it != cache_.end()) {
ent.fx = it->second; // đã load: reuse ngay, khong reload
const std::string last = lastPreset_[ckey];
if (!preset.empty() && preset != last) {
if (ent.fx->applyPreset(preset))
std::cerr << "[RealtimeFx] preset applied (cached): " << path << std::endl;
else
std::cerr << "[RealtimeFx] preset apply FAILED (default kept): " << path << std::endl;
lastPreset_[ckey] = preset;
}
if (preset.empty()) lastPreset_[ckey] = "";
}
else {
auto fx = std::make_shared<Vst3Fx>();
if (fx->load(path, sampleRate_, blockSize_)) {
if (!preset.empty()) {
if (fx->applyPreset(preset))
std::cerr << "[RealtimeFx] preset applied: " << path << std::endl;
else
std::cerr << "[RealtimeFx] preset apply FAILED (default kept): " << path << std::endl;
}
ent.fx = fx;
cache_[ckey] = fx;
lastPreset_[ckey] = preset;
}
else {
std::cerr << "[RealtimeFx] load failed — slot bypassed: " << path << std::endl;
ent.bypass = true;
occ_[path]--; // khong dem lan that bai
}
}
}
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() || paramQueued_.load(std::memory_order_acquire) > 0;
});
if (quit_) break;
if (!q_.empty()) {
job = std::move(q_.front());
q_.erase(q_.begin());
}
}
if (!job.empty()) {
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);
}
gen_.fetch_add(1, std::memory_order_release); // loop báo lại latency
}
{
// Áp param chờ trên WORKER thread (audio thread không bao giờ gọi
// VST API): queue trước swap áp vào chain mới; queue trong lúc
// chain tồn tại áp ngay, không cần đợi setChain.
std::vector<Impl::PendingParam> toApply;
{
std::lock_guard<std::mutex> lk(pmutex_);
if (!pending_.empty()) {
toApply.swap(pending_);
paramQueued_.store(0, std::memory_order_release);
}
}
if (!toApply.empty()) {
std::shared_ptr<Chain> c;
{ std::lock_guard<std::mutex> lk(mutex_); c = chain_; }
if (c) {
for (const auto& p : toApply) {
if (p.slot < 0 || (size_t)p.slot >= c->entries.size()) continue;
auto& e = c->entries[(size_t)p.slot];
if (e.bfx) e.bfx->setParam(p.key, p.value);
else if (e.fx) e.fx->setParam(p.key, p.value);
}
} else {
// Chain chưa tồn tại — stash lại, áp sau swap.
std::lock_guard<std::mutex> lk(pmutex_);
pending_.insert(pending_.begin(), toApply.begin(), toApply.end());
paramQueued_.fetch_add((int)toApply.size(), std::memory_order_release);
}
}
}
}
#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
// outside the anonymous namespace: the definition sits after the namespace.
static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h);
static void fxGuiCaptureSafe(void* fx, std::string& out);
namespace {
const char* kFxGuiWndClass = "SonicForge_FX_GUI_Class";
struct FxGuiCtx {
bool closing = false;
};
// Parent watchdog: engine spawn fx-gui voi SF_PARENT_PID=<engine pid>. Neu
// engine chet (crash/quit) ma bridge fx-gui khong tu thoat -> cua so topmost
// van lo lung, khong tat duoc. Watchdog thread: parent mat -> WM_CLOSE.
static uint32_t fxGuiParentPid() {
const char* e = std::getenv("SF_PARENT_PID");
return e ? (uint32_t)std::atoi(e) : 0;
}
static bool fxGuiParentAlive(uint32_t pid) {
if (pid == 0) return true;
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!h) return false;
CloseHandle(h);
return true;
}
static void fxGuiStartWatchdog(HWND hwnd, uint32_t parentPid) {
if (parentPid == 0) return;
std::thread([hwnd, parentPid]() {
while (IsWindow(hwnd)) {
Sleep(2000);
if (!fxGuiParentAlive(parentPid)) {
PostMessageA(hwnd, WM_CLOSE, 0, 0); // pump exits -> process returns
return;
}
}
}).detach();
}
// Worker (HTTP) thread -> pump thread editor-resize request. The plugin's
// checkSizeConstraint/onSize must run on the thread that owns the editor.
#define WM_FXGUI_RESIZE (WM_APP + 0x51)
// --- preset capture (GUI state -> project save) ------------------------
// getState must run on the pump thread that owns the plugin, so the HTTP
// worker posts WM_FXGUI_CAPTURE and waits (up to timeoutMs) for the pump
// thread to park the base64 blob. Empty outB64 = timeout or no state.
#define WM_FXGUI_CAPTURE (WM_APP + 0x52)
static std::mutex& gCapMutex() { static std::mutex m; return m; }
static std::condition_variable& gCapCv() { static std::condition_variable cv; return cv; }
static std::string& gCapB64() { static std::string s; return s; }
static bool& gCapDone() { static bool b = false; return b; }
LRESULT CALLBACK FxGuiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
FxGuiCtx* ctx = (FxGuiCtx*)GetWindowLongPtrA(hwnd, GWLP_USERDATA);
switch (msg) {
case WM_CLOSE:
DestroyWindow(hwnd);
return 0;
case WM_DESTROY:
if (ctx) ctx->closing = true;
return 0;
case WM_FXGUI_RESIZE:
fxGuiResizeSafe((void*)GetPropA(hwnd, "FXGUI_FX"), hwnd, (int)wp, (int)lp);
return 0;
case WM_FXGUI_CAPTURE: {
std::string b64;
fxGuiCaptureSafe((void*)GetPropA(hwnd, "FXGUI_FX"), b64);
{
std::lock_guard<std::mutex> lk(gCapMutex());
gCapB64() = std::move(b64);
gCapDone() = true;
}
gCapCv().notify_one();
return 0;
}
default:
return DefWindowProcA(hwnd, msg, wp, lp);
}
}
} // namespace
// SEH frames hold only raw pointers / trivials (C2712 — no unwinding locals).
static bool fxGuiAttachSafe(void* fx, HWND hwnd) {
#ifdef HAVE_VST3SDK
__try { return static_cast<Vst3Fx*>(fx)->openEditor(hwnd); }
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
#else
(void)fx; (void)hwnd;
return false;
#endif
}
static void fxGuiDetachSafe(void* fx) {
#ifdef HAVE_VST3SDK
__try { static_cast<Vst3Fx*>(fx)->closeEditor(); }
__except (EXCEPTION_EXECUTE_HANDLER) {}
#else
(void)fx;
#endif
}
static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h) {
#ifdef HAVE_VST3SDK
__try { static_cast<Vst3Fx*>(fx)->resizeEditor(hwnd, w, h); }
__except (EXCEPTION_EXECUTE_HANDLER) {}
#else
(void)fx; (void)hwnd; (void)w; (void)h;
#endif
}
static void fxGuiCaptureSafe(void* fx, std::string& out) {
#ifdef HAVE_VST3SDK
__try { static_cast<Vst3Fx*>(fx)->captureState(out); }
__except (EXCEPTION_EXECUTE_HANDLER) {}
#else
(void)fx; (void)out;
#endif
}
// Worker (HTTP) thread asks the GUI pump thread to resize the plugin view.
void fxGuiRequestResize(HWND hwnd, int w, int h) {
PostMessageA(hwnd, WM_FXGUI_RESIZE, (WPARAM)w, (LPARAM)h);
}
// Capture the GUI plugin state on the pump thread; blocks up to timeoutMs.
void fxGuiRequestCapture(void* hwndV, std::string& outB64, int timeoutMs) {
HWND hwnd = (HWND)hwndV;
{
std::lock_guard<std::mutex> lk(gCapMutex());
gCapDone() = false;
gCapB64().clear();
}
PostMessageA(hwnd, WM_FXGUI_CAPTURE, 0, 0);
{
std::unique_lock<std::mutex> lk(gCapMutex());
if (!gCapCv().wait_for(lk, std::chrono::milliseconds(timeoutMs), [] { return gCapDone(); }))
return; // timeout - outB64 stays empty
outB64 = gCapB64();
}
}
// --- SHM audio feeder (GUI meters) -----------------------------------------
// The GUI process PEEKS the SAME input ring the realtime session uses (never
// consumes -> no contention with RealtimeFxLoop) and runs fx->processAudio()
// on the GUI plugin instance so its own meters/UI move with real audio.
// Meters show the PRE-chain mix (input ring peek); per-plugin position in the
// chain is approximated (ponytail: feed from a per-plugin output ring if exact
// post-FX metering is ever needed).
struct FxGuiShmCtl {
std::mutex m;
std::string name; // "" = no live session to feed
};
static std::shared_ptr<FxGuiShmCtl>& fxGuiShmCtlRef() {
static std::shared_ptr<FxGuiShmCtl> p;
return p;
}
// Called from FxGuiServer /shm route (HTTP worker thread) when the frontend
// reports which realtime session is live (or "" after it stopped/restarted).
void fxGuiSetShm(const std::string& name) {
auto p = fxGuiShmCtlRef();
if (!p) return;
std::lock_guard<std::mutex> lk(p->m);
p->name = name;
}
struct FxGuiShmView { void* map = nullptr; void* view = nullptr; };
static FxGuiShmView* fxGuiOpenShm(const std::string& name, size_t size) {
int wlen = MultiByteToWideChar(CP_UTF8, 0, name.c_str(), -1, nullptr, 0);
std::wstring wname(wlen, L'\0');
MultiByteToWideChar(CP_UTF8, 0, name.c_str(), -1, &wname[0], wlen);
HANDLE map = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, wname.c_str());
if (!map) return nullptr;
void* view = MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size);
if (!view) { CloseHandle(map); return nullptr; }
FxGuiShmView* v = new FxGuiShmView();
v->map = map;
v->view = view;
return v;
}
static void fxGuiCloseShm(FxGuiShmView* v) {
if (!v) return;
if (v->view) UnmapViewOfFile(v->view);
if (v->map) CloseHandle((HANDLE)v->map);
delete v;
}
// SEH frame holds only raw pointers / trivials (C2712 - no unwinding locals).
static void fxGuiFeedAudioSafe(Vst3Fx* fx, const float* inL, const float* inR,
float* outL, float* outR, uint32_t n) {
#ifdef _WIN32
__try { fx->processAudio(inL, inR, outL, outR, n); }
__except (EXCEPTION_EXECUTE_HANDLER) {
std::cerr << "[FxGui] plugin crashed in audio feed" << std::endl;
}
#else
(void)fx; (void)inL; (void)inR; (void)outL; (void)outR; (void)n;
#endif
}
static void fxGuiFeedLoop(Vst3Fx* fx, HWND hwnd, std::shared_ptr<FxGuiShmCtl> ctl) {
FxGuiShmView* v = nullptr;
FxRealtimeIPC* ipc = nullptr;
std::string curName;
uint64_t lastWrite = 0; // feeder-local: newest inWrite already peeked
auto nextTick = std::chrono::steady_clock::now();
while (IsWindow(hwnd)) {
std::string name;
{
std::lock_guard<std::mutex> lk(ctl->m);
name = ctl->name;
}
if (name != curName) {
if (v) { fxGuiCloseShm(v); v = nullptr; ipc = nullptr; }
curName = name;
lastWrite = 0; // session (re)start -> re-anchor
}
if (!v && !name.empty()) { // retry open each pass: mapping may appear late
v = fxGuiOpenShm(name, sizeof(FxRealtimeIPC));
ipc = v ? static_cast<FxRealtimeIPC*>(v->view) : nullptr;
}
if (ipc && ipc->h.magic == FXRT_MAGIC &&
ipc->h.inWrite > lastWrite) {
if (ipc->h.inSlots == FXRT_IN_SLOTS && ipc->h.blockSize == FXRT_BLOCK) {
const uint32_t slot = (ipc->h.inWrite - 1) & (ipc->h.inSlots - 1);
float L[FXRT_BLOCK], R[FXRT_BLOCK], oL[FXRT_BLOCK], oR[FXRT_BLOCK];
std::memcpy(L, ipc->inL[slot], FXRT_BLOCK * sizeof(float));
std::memcpy(R, ipc->inR[slot], FXRT_BLOCK * sizeof(float));
fxGuiFeedAudioSafe(fx, L, R, oL, oR, FXRT_BLOCK);
lastWrite = ipc->h.inWrite;
}
}
// Pace the feeder to realtime (one FXRT_BLOCK per its audio duration,
// 44.1k fallback) instead of Sleep(2) busy-spin (~3x realtime CPU).
// Less CPU in the GUI process = less contention with the pump thread,
// which was stalling /input (dead clicks) and /preset (crackle reloads).
const uint32_t sr = (ipc && ipc->h.sampleRate > 0) ? ipc->h.sampleRate : 44100u;
nextTick += std::chrono::microseconds(1000000ULL * FXRT_BLOCK / sr);
const auto now = std::chrono::steady_clock::now();
if (now > nextTick + std::chrono::milliseconds(50))
nextTick = now; // fell behind (plugin hiccup): resync, don't burst
std::this_thread::sleep_until(nextTick);
}
if (v) fxGuiCloseShm(v);
}
// Split feeder setup: fxGuiShmSetName runs EARLY (before the server loop) so
// /shm pushes from the frontend are not lost, while the detached thread starts
// only AFTER load+attach succeeded — a feeder calling processAudio() mid-load
// can crash/corrupt a heavy plugin and break the editor attach.
// ponytail: feeder feeds the raw live pre-chain mix; add a separate audio path
// if GUI processing of the full insert chain is wanted later.
static void fxGuiShmSetName(const std::string& initialShm) {
auto p = fxGuiShmCtlRef();
if (!p) { p = std::make_shared<FxGuiShmCtl>(); fxGuiShmCtlRef() = p; }
std::lock_guard<std::mutex> lk(p->m);
p->name = initialShm;
}
static void fxGuiStartFeederThread(Vst3Fx* fx, HWND hwnd) {
auto p = fxGuiShmCtlRef();
if (!p) { p = std::make_shared<FxGuiShmCtl>(); fxGuiShmCtlRef() = p; }
std::thread([fx, hwnd, p]() { fxGuiFeedLoop(fx, hwnd, p); }).detach();
}
// --- 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::string& shm, std::string& presetB64) {
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>());
json_parse_result_s pres = {};
json_value_s* root = json_parse_ex(data.data(), data.size(), json_parse_flags_default,
nullptr, nullptr, &pres);
if (!root) { std::cerr << "[FxGui] job JSON parse error (code " << std::to_string(pres.error) << ")" << std::endl; return 1; }
struct RootHolder { json_value_s* p = nullptr; ~RootHolder() { if (p) std::free(p); } } rootH;
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);
path = memberString(job, "path", "");
name = memberString(job, "name", "");
shm = memberString(job, "shm", "");
presetB64 = memberString(job, "preset_b64", "");
if (path.empty()) { std::cerr << "[FxGui] job missing path" << std::endl; return 1; }
return 0;
}
// 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);
wc.lpszClassName = kFxGuiWndClass;
RegisterClassA(&wc);
const int x = offscreen ? -20000 : CW_USEDEFAULT;
const int y = offscreen ? -20000 : CW_USEDEFAULT;
const DWORD exStyle = offscreen ? (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE) : 0;
// Borderless (WS_POPUP): GetWindowRect == client rect, so anchored
// placement over the embed panel is exact. A captioned/bordered window
// (WS_OVERLAPPEDWINDOW) put the client area ~31px lower/right of the
// requested rect -> cursor jumped on hover and clicks landed on the webview.
const DWORD style = (offscreen ? WS_POPUP : WS_OVERLAPPEDWINDOW)
| WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS;
HWND hwnd = CreateWindowExA(exStyle, kFxGuiWndClass,
(name.empty() ? "VST FX" : name).c_str(),
style,
x, y, 800, 600,
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;
}
// 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, const std::string& presetB64) {
// 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; }
// Khôi phục preset GUI capture cuối (hide/show mất settings bug): áp
// preset NGAY SAU load — trước attach editor để GUI hiện đúng cài đặt.
if (!presetB64.empty() && !fx->applyPreset(presetB64))
std::cerr << "[FxGui] preset apply FAILED (default kept): " << path << std::endl;
else if (!presetB64.empty())
std::cerr << "[FxGui] preset applied: " << path << std::endl;
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;
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& shmOut) {
std::string path, name, presetB64;
int rc = fxGuiParseJob(jobPath, path, name, shmOut, presetB64);
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, presetB64);
}
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.
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
Vst3Fx fx;
HWND hwnd = nullptr;
FxGuiCtx ctx;
std::string shm;
const int rc = fxGuiSetup(jobPath, &fx, &hwnd, /*offscreen=*/false, (LONG_PTR)&ctx, shm);
if (rc) return rc;
fxGuiStartWatchdog(hwnd, fxGuiParentPid());
fxGuiShmSetName(shm);
fxGuiStartFeederThread(&fx, hwnd);
// 5. Pump messages until the window is destroyed.
MSG msg;
while (!ctx.closing) {
BOOL r = GetMessageA(&msg, nullptr, 0, 0);
if (r <= 0) break;
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
// 6. Detach + exit.
fxGuiDetachSafe(&fx);
std::cout << "[FxGui] editor closed" << std::endl;
return 0;
}
// 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, const std::function<void()>& onStarted);
int run_fx_gui_server(const std::string& jobPath) {
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
Vst3Fx fx;
HWND hwnd = nullptr;
// 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, shm, presetB64;
int rc = fxGuiParseJob(jobPath, path, name, shm, presetB64);
if (rc) return rc;
rc = fxGuiCreateWindow(name, /*offscreen=*/true, 0, &hwnd);
if (rc) return rc;
fxGuiStartWatchdog(hwnd, fxGuiParentPid());
SetPropA(hwnd, "FXGUI_FX", &fx); // for WM_FXGUI_RESIZE on the pump thread
fxGuiShmSetName(shm); // EARLY — frontend may /shm push before load finishes
int loadRc = 0;
const int srv = fxGuiServerLoop(hwnd, [&]() {
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd, presetB64);
if (loadRc == 0) fxGuiStartFeederThread(&fx, hwnd); // after load+attach only
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 loadRc ? loadRc : srv;
}
#else // !_WIN32
int run_open_fx_gui(const std::string& jobPath) {
(void)jobPath;
std::cerr << "[FxGui] GUI mode chỉ hỗ trợ Windows" << std::endl;
return 1;
}
int run_fx_gui_server(const std::string& jobPath) {
(void)jobPath;
std::cerr << "[FxGui] GUI mode chỉ hỗ trợ Windows" << std::endl;
return 1;
}
#endif // _WIN32
// --- --scan: classify VST3 modules as instrument/effect ----------------------
namespace {
std::string jsonEscape(const std::string& s) {
std::string o;
o.reserve(s.size() + 8);
for (char ch : s) {
switch (ch) {
case '"': o += "\\\""; break;
case '\\': o += "\\\\"; break;
case '\n': o += "\\n"; break;
case '\r': o += "\\r"; break;
case '\t': o += "\\t"; break;
default: o += ch;
}
}
return o;
}
#ifdef HAVE_VST3SDK
struct ScanEntry {
std::string path;
std::string name;
bool isFx = false;
bool isInstrument = false;
};
// C++ locals live here (no __try in this frame) — a crashing module loader
// is caught by safeScanModule's SEH and skipped, not fatal to the whole scan.
bool scanModuleInner(const std::string& path, std::vector<ScanEntry>& out) {
using namespace VST3::Hosting;
#ifdef _WIN32
setPluginSearchPath(path); // deps cạnh plugin (0x7E fix) — scan cũng load module
#endif
std::string err;
Module::Ptr module = Module::create(path, err);
if (!module) { std::cerr << "[Scan] skip (module load failed): " << path << " — " << err << std::endl; return false; }
const PluginFactory& factory = module->getFactory();
auto infos = factory.classInfos();
int added = 0;
for (const auto& ci : infos) {
if (ci.category() != kVstAudioEffectClass) continue;
const bool isInstr = ci.subCategoriesString().find("Instrument") != std::string::npos;
ScanEntry e;
e.path = path;
e.name = ci.name();
e.isFx = !isInstr;
e.isInstrument = isInstr;
out.push_back(std::move(e));
++added;
}
if (added == 0)
std::cerr << "[Scan] " << path << ": no audio classes" << std::endl;
return added > 0;
}
// Per-module scan context for the timeout worker thread.
struct ScanCtx {
const std::string* path;
std::vector<ScanEntry>* out;
bool ok = false;
};
static DWORD WINAPI scanWorker(LPVOID p) {
auto* ctx = static_cast<ScanCtx*>(p);
__try { ctx->ok = scanModuleInner(*ctx->path, *ctx->out); }
__except (EXCEPTION_EXECUTE_HANDLER) {
std::cerr << "[Scan] skip (crashed module): " << *ctx->path << std::endl;
ctx->ok = false;
}
return 0;
}
bool safeScanModule(const std::string& path, std::vector<ScanEntry>& out) {
#ifdef _WIN32
// Run the VST3 loader on a dedicated thread so a HANGING module (e.g.
// Ozone 11 Clarity.vst3 — Module::create blocks forever inside the SDK)
// cannot stall the whole --scan. SEH only catches crashes, not hangs, so
// a watchdog timeout + TerminateThread is required. Safe here: --scan is
// a one-shot process; the worker only touches the VST3 SDK loader and the
// process exits right after the scan completes.
ScanCtx ctx{&path, &out};
HANDLE hThread = CreateThread(nullptr, 0, scanWorker, &ctx, 0, nullptr);
if (!hThread) { std::cerr << "[Scan] skip (thread fail): " << path << std::endl; return false; }
const DWORD timeoutMs = 4000; // 4s/module — module hợp lệ load 1-3s (đo
// thực tế); module treo thì treo vĩnh viễn nên 4s đủ phân biệt. Trước đây
// 12s × ~15 module Ozone 11 treo = 180s+ vượt timeout Python → mất CẢ dir
// → MASTERING PANEL rỗng.
DWORD wait = WaitForSingleObject(hThread, timeoutMs);
if (wait == WAIT_TIMEOUT) {
TerminateThread(hThread, 0);
CloseHandle(hThread);
std::cerr << "[Scan] skip (timeout 12s): " << path << std::endl;
return false;
}
CloseHandle(hThread);
return ctx.ok;
#else
return scanModuleInner(path, out);
#endif
}
#endif // HAVE_VST3SDK
} // namespace
int run_scan_dir(const std::string& dirPath) {
#ifdef _WIN32
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
#endif
std::vector<std::string> vst3Paths; // folders or files ending .vst3
std::vector<std::string> vst2Paths; // .dll / .so (cannot introspect)
namespace fs = std::filesystem;
auto isExt = [](const std::string& p, const char* ext) {
const size_t n = std::strlen(ext);
return p.size() >= n && p.compare(p.size() - n, n, ext) == 0;
};
try {
fs::recursive_directory_iterator it(dirPath), end;
for (; it != end; ++it) {
const std::string p = it->path().string();
std::string low = p;
for (auto& c : low) c = (char)std::tolower((unsigned char)c);
if (it->is_directory()) {
if (isExt(low, ".vst3")) {
vst3Paths.push_back(p);
it.disable_recursion_pending(); // bundle: don't walk Contents/
}
} else if (it->is_regular_file()) {
if (isExt(low, ".vst3")) vst3Paths.push_back(p);
else if (isExt(low, ".dll") || isExt(low, ".so")) vst2Paths.push_back(p);
}
}
} catch (...) { std::cerr << "[Scan] walk failed: " << dirPath << std::endl; }
std::cout << "{\"plugins\":[";
bool first = true;
auto emit = [&](const std::string& path, const std::string& name,
bool isFx, bool isInstr, const char* type) {
if (!first) std::cout << ",";
first = false;
std::cout << "{\"path\":\"" << jsonEscape(path)
<< "\",\"name\":\"" << jsonEscape(name)
<< "\",\"is_fx\":" << (isFx ? "true" : "false")
<< ",\"is_instrument\":" << (isInstr ? "true" : "false")
<< ",\"type\":\"" << type << "\"}";
};
#ifdef HAVE_VST3SDK
for (const auto& p : vst3Paths) {
std::vector<ScanEntry> entries;
if (!safeScanModule(p, entries)) {
// Module unreadable/crashed/hung (e.g. Ozone 11 Clarity): emit
// entry UNKNOWN (is_fx=false, is_instrument=false) — trước đây
// emit is_instrument=true làm VST FX lọt vào danh sách instrument
// (nút Synth). Backend loại entry unknown khỏi vst_instruments:
// không xác định được loại → không xếp bừa vào instrument.
emit(p, p.substr(p.find_last_of("/\\") + 1), false, false, "VST3");
continue;
}
for (const auto& e : entries) emit(e.path, e.name, e.isFx, e.isInstrument, "VST3");
}
#else
for (const auto& p : vst3Paths) {
std::string name = p.substr(p.find_last_of("/\\") + 1);
emit(p, name, false, true, "VST3");
}
#endif
for (const auto& p : vst2Paths) {
std::string name = p.substr(p.find_last_of("/\\") + 1);
emit(p, name, false, true, "VST2");
}
std::cout << "]}" << std::endl;
return 0;
}