pluginbridge / Source /Plugin /PluginBridgeProcessor.cpp
RAM2118's picture
Sprint 7: wire param callbacks in loadInProcessPlugin, clear in unloadInProcessPlugin
4d61c1f verified
Raw
History Blame Contribute Delete
24.8 kB
#include "PluginBridgeProcessor.h"
#include "PluginBridgeEditor.h"
#include "IPCProtocol.h"
#include "PBLog.h"
#include "InstanceRegistry.h"
#include <sys/wait.h>
#include <spawn.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <chrono>
#include <algorithm>
extern char** environ;
#include "json.hpp"
using json = nlohmann::json;
PluginBridgeProcessor::PluginBridgeProcessor()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true)),
mcpServer(std::make_unique<McpServer>(helper))
{
PBLog::startSession("Processor");
PBLOG("Processor", "Constructor β€” PluginBridgeProcessor created");
inProcessFormatManager.addFormat(new juce::VST3PluginFormat());
inProcessFormatManager.addFormat(new juce::AudioUnitPluginFormat());
helper.onHelperCrashed = [this]()
{
PBLOG("Processor", "⚠️ Helper crashed β€” clearing plugin state");
if (pluginPath.isNotEmpty())
{
PBLOG("Processor", "⚠️ Marking \"" + pluginName + "\" as UNSAFE (Helper crash)");
safetyDB.setSafety(pluginPath, PluginSafetyDB::Safety::Unsafe);
}
pluginLoaded = false;
pluginName.clear();
pluginParamCount = 0;
unloadInProcessPlugin();
};
helper.onParamChanged = [this](int idx, float val)
{
if (!inProcessPlugin) return;
auto& params = inProcessPlugin->getParameters();
if (idx < 0 || idx >= params.size()) return;
suppressParamSync.store(true);
juce::MessageManager::callAsync([this, idx, val]()
{
if (!inProcessPlugin) return;
auto& p = inProcessPlugin->getParameters();
if (idx < p.size())
p[idx]->setValueNotifyingHost(juce::jlimit(0.0f, 1.0f, val));
suppressParamSync.store(false);
});
};
mcpServer->getAnalysisCallback = [this]() { return analyser.getCompactAnalysis(); };
mcpServer->channelNameCallback = [this]() { return channelName.toStdString(); };
mcpServer->start();
InstanceRegistry::registerInstance(channelName.toStdString(), mcpServer->getPort());
PBLOG("Processor", "MCP server on port " + juce::String(mcpServer->getPort())
+ " β€” channel: " + channelName);
startBackgroundScan();
}
PluginBridgeProcessor::~PluginBridgeProcessor()
{
PBLOG("Processor", "Destructor β€” tearing down");
stopBackgroundScan();
InstanceRegistry::unregisterInstance(channelName.toStdString());
mcpServer->stop();
unloadInProcessPlugin();
helper.killHelper();
PBLOG("Processor", "Destructor β€” done");
}
void PluginBridgeProcessor::setChannelName(const juce::String& name)
{
if (name == channelName) return;
InstanceRegistry::unregisterInstance(channelName.toStdString());
channelName = name;
InstanceRegistry::registerInstance(channelName.toStdString(), mcpServer->getPort());
PBLOG("Processor", "Channel renamed to: " + channelName);
}
void PluginBridgeProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
currentSampleRate = sampleRate;
currentBlockSize = samplesPerBlock;
analyser.prepareToPlay(sampleRate, samplesPerBlock);
if (inProcessPlugin)
{
inProcessPlugin->releaseResources();
inProcessPlugin->prepareToPlay(sampleRate, samplesPerBlock);
}
if (helper.isHelperRunning())
{
json cmd = {{"cmd","configure"},{"sampleRate",sampleRate},{"blockSize",samplesPerBlock},{"channels",2}};
helper.sendCommand(cmd.dump());
auto* header = helper.getSharedAudio().getHeader();
if (header) { header->sampleRate.store((uint32_t)sampleRate); header->blockSize.store((uint32_t)samplesPerBlock); header->numChannels.store(2); }
}
}
void PluginBridgeProcessor::releaseResources() {}
void PluginBridgeProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages)
{
juce::ScopedNoDenormals noDenormals;
for (auto i = getTotalNumInputChannels(); i < getTotalNumOutputChannels(); ++i)
buffer.clear(i, 0, buffer.getNumSamples());
if (!pluginLoaded) return;
if (inProcessPlugin != nullptr)
{
inProcessPlugin->processBlock(buffer, midiMessages);
analyser.processBlock(buffer);
return;
}
if (!helper.isHelperRunning()) return;
auto& shm = helper.getSharedAudio();
if (!shm.isValid()) return;
int numChannels = buffer.getNumChannels(), numSamples = buffer.getNumSamples();
const float* inputPtrs[2] = { buffer.getReadPointer(0), numChannels > 1 ? buffer.getReadPointer(1) : buffer.getReadPointer(0) };
shm.writeInput(inputPtrs, numChannels, numSamples);
auto* header = shm.getHeader();
header->blockSize.store((uint32_t)numSamples); header->numChannels.store((uint32_t)numChannels); header->state.store(kStateInputReady);
helper.getInputSem().signal();
if (helper.getOutputSem().wait(IPC::kProcessBlockTimeoutMs))
{
float* outputPtrs[2] = { buffer.getWritePointer(0), numChannels > 1 ? buffer.getWritePointer(1) : buffer.getWritePointer(0) };
shm.readOutput(outputPtrs, numChannels, numSamples);
header->state.store(kStateIdle);
analyser.processBlock(buffer);
}
}
juce::AudioProcessorEditor* PluginBridgeProcessor::createEditor() { return new PluginBridgeEditor(*this); }
// ── In-process plugin ─────────────────────────────────────────────────────────
void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
{
PBLOG("Processor", "loadInProcessPlugin: " + juce::File(path).getFileName());
unloadInProcessPlugin();
juce::File pluginFile(path);
if (!pluginFile.exists()) { PBLOG("Processor", "loadInProcessPlugin: file not found"); return; }
juce::OwnedArray<juce::PluginDescription> typesFound;
for (int i = 0; i < inProcessFormatManager.getNumFormats(); ++i)
{
auto* format = inProcessFormatManager.getFormat(i);
bool isVST3 = pluginFile.getFileExtension() == ".vst3";
bool isAU = pluginFile.getFileExtension() == ".component";
if (isVST3 && format->getName() != "VST3") continue;
if (isAU && format->getName() != "AudioUnit") continue;
format->findAllTypesForFile(typesFound, pluginFile.getFullPathName());
if (typesFound.size() > 0) break;
}
if (typesFound.isEmpty()) { PBLOG("Processor", "loadInProcessPlugin: no types found"); return; }
juce::String err;
inProcessPlugin = inProcessFormatManager.createPluginInstance(*typesFound[0], currentSampleRate, currentBlockSize, err);
if (!inProcessPlugin) { PBLOG("Processor", "loadInProcessPlugin: FAILED β€” " + err); return; }
inProcessPlugin->enableAllBuses();
auto layout = juce::AudioProcessor::BusesLayout();
layout.inputBuses.add(juce::AudioChannelSet::stereo());
layout.outputBuses.add(juce::AudioChannelSet::stereo());
if (inProcessPlugin->checkBusesLayoutSupported(layout))
inProcessPlugin->setBusesLayout(layout);
if (currentSampleRate > 0 && currentBlockSize > 0)
inProcessPlugin->prepareToPlay(currentSampleRate, currentBlockSize);
// Sync state from Helper
auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
try { auto j = json::parse(response); if (j.value("ok",false)) {
auto stateB64 = juce::String(j["state"].get<std::string>());
if (stateB64.isNotEmpty()) { juce::MemoryBlock d; if (d.fromBase64Encoding(stateB64))
inProcessPlugin->setStateInformation(d.getData(), (int)d.getSize()); }
}} catch (...) {}
inProcessPlugin->addListener(this);
// ── Wire MCP param callbacks to in-process plugin ─────────────────────────
mcpServer->searchParamCallback = [this](const std::string& keyword) -> std::string {
if (!inProcessPlugin) return "[]";
auto& params = inProcessPlugin->getParameters();
json results = json::array();
std::string kw = keyword;
std::transform(kw.begin(), kw.end(), kw.begin(), ::tolower);
for (int i = 0; i < params.size(); ++i)
{
std::string name = params[i]->getName(128).toStdString();
std::string nameLow = name;
std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower);
if (kw.empty() || nameLow.find(kw) != std::string::npos)
{
results.push_back({{"i", i}, {"name", name},
{"value", std::round(params[i]->getValue() * 1000.0f) / 1000.0f},
{"display", params[i]->getText(params[i]->getValue(), 64).toStdString()}});
if (results.size() >= 50) break;
}
}
return results.dump();
};
mcpServer->getParamsCallback = [this](const std::vector<int>& ids) -> std::string {
if (!inProcessPlugin) return "{}";
auto& params = inProcessPlugin->getParameters();
json values = json::object();
for (int idx : ids)
if (idx >= 0 && idx < params.size())
values[std::to_string(idx)] = std::round(params[idx]->getValue() * 1000.0f) / 1000.0f;
return values.dump();
};
mcpServer->setParamsCallback = [this](const std::map<int,float>& values) -> bool {
if (!inProcessPlugin) return false;
juce::MessageManager::callAsync([this, values]() {
if (!inProcessPlugin) return;
auto& p = inProcessPlugin->getParameters();
for (auto& [idx, val] : values)
if (idx >= 0 && idx < p.size())
p[idx]->setValueNotifyingHost(juce::jlimit(0.0f, 1.0f, val));
});
return true;
};
PBLOG("Processor", "loadInProcessPlugin: SUCCESS β€” \"" + inProcessPlugin->getName() + "\" + MCP param callbacks wired");
}
void PluginBridgeProcessor::unloadInProcessPlugin()
{
// Clear MCP param callbacks BEFORE destroying plugin
mcpServer->searchParamCallback = nullptr;
mcpServer->getParamsCallback = nullptr;
mcpServer->setParamsCallback = nullptr;
if (inProcessPlugin)
{
PBLOG("Processor", "unloadInProcessPlugin: " + inProcessPlugin->getName());
inProcessPlugin->removeListener(this);
inProcessPlugin->releaseResources();
inProcessPlugin.reset();
}
}
void PluginBridgeProcessor::audioProcessorParameterChanged(juce::AudioProcessor*, int paramIdx, float value)
{
if (suppressParamSync.load()) return;
if (!helper.isHelperRunning()) return;
auto cmdStr = json({{"cmd","set_params"},{"values",{{std::to_string(paramIdx), value}}}}).dump();
std::thread([this, cmdStr]() { helper.sendCommand(cmdStr); }).detach();
}
// ── Plugin Loading ────────────────────────────────────────────────────────────
void PluginBridgeProcessor::loadPlugin(const juce::String& path)
{
PBLOG("Processor", "loadPlugin: " + juce::File(path).getFileName());
if (blocklist.isBlocked(path)) { if (onLoadFailed) onLoadFailed("Not compatible (crashed previously)"); return; }
auto safety = safetyDB.getSafety(path);
PBLOG("Processor", "loadPlugin: safety = " + juce::String(safety == PluginSafetyDB::Safety::Safe ? "SAFE" : safety == PluginSafetyDB::Safety::Unsafe ? "UNSAFE" : "UNKNOWN"));
if (safety == PluginSafetyDB::Safety::Unknown)
{
if (onLoadFailed) onLoadFailed("Scanning plugin...");
std::thread([this, path]() {
bool safe = runPluginScan(path);
safetyDB.setSafety(path, safe ? PluginSafetyDB::Safety::Safe : PluginSafetyDB::Safety::Unsafe);
juce::MessageManager::callAsync([this, path]() { loadPlugin(path); });
}).detach();
return;
}
if (onPluginWillLoad) onPluginWillLoad();
if (!helper.isHelperRunning()) {
if (!helper.spawnHelper()) { if (onLoadFailed) onLoadFailed("Failed to start helper"); return; }
json cfg = {{"cmd","configure"},{"sampleRate",currentSampleRate},{"blockSize",currentBlockSize},{"channels",2}};
helper.sendCommand(cfg.dump());
}
json loadCmd = {{"cmd","load"},{"path",path.toStdString()},{"sampleRate",currentSampleRate},{"blockSize",currentBlockSize}};
auto response = helper.sendCommand(loadCmd.dump());
try {
auto j = json::parse(response);
if (j.value("ok",false)) {
pluginLoaded = true;
pluginName = juce::String(j["name"].get<std::string>());
pluginParamCount = j.value("params",0);
pluginPath = path;
PBLOG("Processor", "loadPlugin: loaded \"" + pluginName + "\"");
if (safety == PluginSafetyDB::Safety::Safe)
juce::MessageManager::callAsync([this, path]() { loadInProcessPlugin(path); if (onPluginLoaded) onPluginLoaded(); });
else
juce::MessageManager::callAsync([this]() { if (onPluginLoaded) onPluginLoaded(); });
} else {
pluginLoaded = false; pluginPath.clear(); blocklist.addBlocked(path);
if (onLoadFailed) onLoadFailed("Not compatible: " + juce::String(j.value("error","unknown")));
}
} catch (...) {
pluginLoaded = false; pluginPath.clear(); blocklist.addBlocked(path);
if (onLoadFailed) onLoadFailed("Helper communication error");
}
}
void PluginBridgeProcessor::unloadPlugin()
{
PBLOG("Processor", "unloadPlugin: \"" + pluginName + "\"");
if (helper.isHelperRunning()) helper.sendCommand("{\"cmd\":\"unload\"}");
pluginLoaded = false; pluginName.clear(); pluginPath.clear(); pluginParamCount = 0;
unloadInProcessPlugin();
if (onPluginUnloaded) juce::MessageManager::callAsync([this]() { if (onPluginUnloaded) onPluginUnloaded(); });
}
// ── Scanner ───────────────────────────────────────────────────────────────────
static juce::String signalName(int sig) {
switch (sig) { case SIGSEGV: return "SIGSEGV"; case SIGABRT: return "SIGABRT"; case SIGBUS: return "SIGBUS"; case SIGILL: return "SIGILL"; case SIGFPE: return "SIGFPE"; case SIGKILL: return "SIGKILL"; default: return "signal " + juce::String(sig); }
}
static void writeScanLog(const juce::String& pluginPath, bool safe, const juce::String& reason, const juce::String& captured) {
auto logFile = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory).getChildFile("PluginBridge").getChildFile("scan.log");
logFile.getParentDirectory().createDirectory();
auto ts = juce::Time::getCurrentTime().formatted("%Y-%m-%d %H:%M:%S");
juce::String entry; entry << "───\n" << ts << " " << juce::File(pluginPath).getFileName() << "\nResult: " << (safe ? "SAFE" : "UNSAFE");
if (!reason.isEmpty()) entry << " (" << reason << ")";
if (captured.isNotEmpty()) entry << "\nOutput: " << captured.substring(0, 500);
entry << "\n\n"; logFile.appendText(entry);
}
bool PluginBridgeProcessor::runPluginScan(const juce::String& path) {
auto helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory().getParentDirectory().getChildFile("Resources").getChildFile("PluginBridgeHelper.app").getChildFile("Contents").getChildFile("MacOS").getChildFile(PluginBridgeConstants::kHelperBinaryName);
if (!helperApp.exists()) helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory().getParentDirectory().getChildFile("Resources").getChildFile(PluginBridgeConstants::kHelperBinaryName);
if (!helperApp.exists()) { writeScanLog(path, false, "Helper not found", {}); return false; }
int pipeFd[2]; if (pipe(pipeFd) != 0) return false; fcntl(pipeFd[0], F_SETFL, O_NONBLOCK);
auto hStr = helperApp.getFullPathName().toStdString(); auto pStr = path.toStdString();
posix_spawn_file_actions_t actions; posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_adddup2(&actions, pipeFd[1], STDERR_FILENO); posix_spawn_file_actions_addclose(&actions, pipeFd[0]);
pid_t pid; char* argv[] = { (char*)hStr.c_str(), (char*)"--scan", (char*)pStr.c_str(), nullptr };
int sr = posix_spawn(&pid, hStr.c_str(), &actions, nullptr, argv, environ);
posix_spawn_file_actions_destroy(&actions); close(pipeFd[1]);
if (sr != 0) { close(pipeFd[0]); return false; }
bool timedOut = true; int status = 0;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
while (std::chrono::steady_clock::now() < deadline) { if (waitpid(pid, &status, WNOHANG) == pid) { timedOut = false; break; } std::this_thread::sleep_for(std::chrono::milliseconds(100)); }
if (timedOut) { kill(pid, SIGKILL); waitpid(pid, &status, 0); }
juce::String captured; { char buf[4096]; ssize_t n; while ((n = read(pipeFd[0], buf, sizeof(buf)-1)) > 0) { buf[n]=0; captured += buf; } } close(pipeFd[0]);
bool safe = !timedOut && WIFEXITED(status) && WEXITSTATUS(status) == 0;
juce::String reason = timedOut ? "Timeout" : WIFSIGNALED(status) ? signalName(WTERMSIG(status)) : (!safe ? "Exit " + juce::String(WEXITSTATUS(status)) : "");
writeScanLog(path, safe, reason, captured); return safe;
}
void PluginBridgeProcessor::startBackgroundScan() {
if (scanRunning.load()) return; scanRunning.store(true);
scanThread = std::thread([this]() {
auto files = getInstalledPluginFiles();
for (int i = 0; i < files.size() && scanRunning.load(); ++i) {
auto path = files[i].getFullPathName();
if (safetyDB.getSafety(path) != PluginSafetyDB::Safety::Unknown) continue;
while (helper.isHelperRunning() && scanRunning.load()) std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (!scanRunning.load()) break;
bool safe = runPluginScan(path);
safetyDB.setSafety(path, safe ? PluginSafetyDB::Safety::Safe : PluginSafetyDB::Safety::Unsafe);
if (onScanProgress) juce::MessageManager::callAsync([this, path, safe]() { if (onScanProgress) onScanProgress(path, safe); });
}
scanRunning.store(false);
});
}
void PluginBridgeProcessor::stopBackgroundScan() { scanRunning.store(false); if (scanThread.joinable()) scanThread.join(); }
// ── Plugin List ───────────────────────────────────────────────────────────────
juce::Array<juce::File> PluginBridgeProcessor::getInstalledPluginFiles() const {
juce::Array<juce::File> results; std::set<std::string> seen;
struct D { juce::File dir; const char* pat; };
std::vector<D> specs = { { juce::File("/Library/Audio/Plug-Ins/VST3"), "*.vst3" }, { juce::File::getSpecialLocation(juce::File::userHomeDirectory).getChildFile("Library/Audio/Plug-Ins/VST3"), "*.vst3" }, { juce::File("/Library/Audio/Plug-Ins/Components"), "*.component" }, { juce::File::getSpecialLocation(juce::File::userHomeDirectory).getChildFile("Library/Audio/Plug-Ins/Components"), "*.component" } };
for (auto& s : specs) { if (!s.dir.isDirectory()) continue; for (auto& f : s.dir.findChildFiles(juce::File::findDirectories, false, s.pat)) if (seen.insert(f.getFileNameWithoutExtension().toStdString()).second) results.add(f); }
results.sort(); return results;
}
bool PluginBridgeProcessor::isBlocked(const juce::String& path) const { return blocklist.isBlocked(path); }
// ── Manufacturer ──────────────────────────────────────────────────────────────
static juce::String cleanManufacturer(const juce::String& raw) {
auto s = raw.trim(); if (s.isEmpty() || s.containsOnly("0123456789.")) return {};
if (s.toLowerCase().startsWith("copyright")) { auto r = s.substring(9).trim(); if (r.startsWithIgnoreCase("(c)")) r = r.substring(3).trim(); else if (r.startsWith(juce::String::fromUTF8("\xc2\xa9"))) r = r.substring(juce::String::fromUTF8("\xc2\xa9").length()).trim(); auto sp = r.indexOfChar(' '); if (sp > 0) r = r.substring(sp+1).trim(); return r.isNotEmpty() ? r : juce::String(); }
return s;
}
static juce::String plistValue(const juce::String& t, const juce::String& k) { auto p = t.fromFirstOccurrenceOf(k, false, true); if (p.isEmpty()) return {}; return p.fromFirstOccurrenceOf("<string>", false, false).upToFirstOccurrenceOf("</string>", false, false).trim(); }
juce::String PluginBridgeProcessor::getPluginManufacturer(const juce::File& f) const {
auto pk = f.getFullPathName().toStdString();
{ std::lock_guard<std::mutex> l(manufacturerCacheMutex); auto it = manufacturerCache.find(pk); if (it != manufacturerCache.end()) return juce::String(it->second); }
juce::String mfr;
if (f.getFileExtension().toLowerCase() == ".vst3") {
auto ip = f.getChildFile("Contents").getChildFile("Info.plist");
if (ip.existsAsFile()) { auto t = ip.loadFileAsString(); mfr = cleanManufacturer(plistValue(t, "NSHumanReadableCopyright"));
if (mfr.isEmpty()) { auto is = plistValue(t, "CFBundleGetInfoString"); if (is.contains(",")) { auto bc = is.upToFirstOccurrenceOf(",",false,false).trim(); auto fw = bc.upToFirstOccurrenceOf(" ",false,false).trim(); if (fw.isNotEmpty() && !fw.containsOnly("0123456789.")) mfr = fw; } if (mfr.isEmpty()) mfr = cleanManufacturer(is); }
if (mfr.isEmpty()) { auto bid = plistValue(t, "CFBundleIdentifier"); auto segs = juce::StringArray::fromTokens(bid,".",""); if (segs.size()>=2) { auto sl = segs[1].trim(); if (sl.isNotEmpty()&&sl!="apple"&&sl!="com") mfr = sl.substring(0,1).toUpperCase()+sl.substring(1); } } }
if (mfr.isEmpty()) { auto mi = f.getChildFile("Contents").getChildFile("moduleinfo.json"); if (mi.existsAsFile()) { try { auto j = json::parse(mi.loadFileAsString().toStdString()); mfr = cleanManufacturer(juce::String(j.value("vendor",""))); } catch(...){} } }
}
if (mfr.isEmpty()) { auto n = f.getFileNameWithoutExtension(); auto fw = n.upToFirstOccurrenceOf(" ",false,false); mfr = (fw.isNotEmpty()&&!fw.containsOnly("0123456789.")) ? fw : n; }
if (mfr.isEmpty()) mfr = "Other";
std::lock_guard<std::mutex> l(manufacturerCacheMutex); manufacturerCache[pk] = mfr.toStdString(); return mfr;
}
// ── State ─────────────────────────────────────────────────────────────────────
void PluginBridgeProcessor::getStateInformation(juce::MemoryBlock& destData) {
juce::ValueTree state("PluginBridgeState"); state.setProperty("pluginPath", pluginPath, nullptr); state.setProperty("channelName", channelName, nullptr);
if (pluginLoaded && helper.isHelperRunning()) { auto r = helper.sendCommand("{\"cmd\":\"get_state\"}"); try { auto j = json::parse(r); if (j.value("ok",false)) state.setProperty("pluginState", juce::String(j["state"].get<std::string>()), nullptr); } catch (...) {} }
juce::MemoryOutputStream stream(destData, false); state.writeToStream(stream);
}
void PluginBridgeProcessor::setStateInformation(const void* data, int sizeInBytes) {
auto state = juce::ValueTree::readFromData(data, (size_t)sizeInBytes); if (!state.isValid()) return;
auto savedChannel = state.getProperty("channelName", "Unnamed").toString(); setChannelName(savedChannel);
auto path = state.getProperty("pluginPath").toString(); if (path.isEmpty()) return;
loadPlugin(path);
auto pluginState = state.getProperty("pluginState").toString();
if (pluginState.isNotEmpty() && helper.isHelperRunning()) { json cmd = {{"cmd","set_state"},{"state",pluginState.toStdString()}}; helper.sendCommand(cmd.dump()); }
}
// ── MCP ───────────────────────────────────────────────────────────────────────
bool PluginBridgeProcessor::isMcpServerRunning() const { return mcpServer && mcpServer->isRunning(); }
int PluginBridgeProcessor::getMcpServerPort() const { return mcpServer ? mcpServer->getPort() : 0; }
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new PluginBridgeProcessor(); }