pluginbridge / Source /Plugin /PluginBridgeProcessor.cpp
Sitar118
fix: restore v0.5.2 architecture + wire AudioAnalyser correctly
fdc4e41
Raw History Blame
32.5 kB
#include "PluginBridgeProcessor.h"
#include "PluginBridgeEditor.h"
#include "IPCProtocol.h"
#include "PBLog.h"
#include <sys/wait.h>
#include <spawn.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <chrono>
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>(PluginBridgeConstants::kDefaultMcpPort, 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");
// Mark the crashing plugin as unsafe so it loads audio-only next time
// instead of respawning a Helper that will crash again.
if (pluginPath.isNotEmpty())
{
PBLOG("Processor", "⚠️ Marking \"" + pluginName + "\" as UNSAFE (Helper crash)");
safetyDB.setSafety(pluginPath, PluginSafetyDB::Safety::Unsafe);
}
pluginLoaded = false;
pluginName.clear();
pluginParamCount = 0;
unloadInProcessPlugin();
DBG("PluginBridgeProcessor: Helper crashed");
};
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);
});
};
// Wire audio analysis to MCP get_analysis tool
mcpServer->getAnalysisCallback = [this]() { return analyser.getCompactAnalysis(); };
mcpServer->start();
startBackgroundScan();
}
PluginBridgeProcessor::~PluginBridgeProcessor()
{
PBLOG("Processor", "Destructor β€” tearing down");
stopBackgroundScan();
mcpServer->stop();
unloadInProcessPlugin();
helper.killHelper();
PBLOG("Processor", "Destructor β€” done");
}
void PluginBridgeProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
currentSampleRate = sampleRate;
currentBlockSize = samplesPerBlock;
analyser.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&)
{
juce::ScopedNoDenormals noDenormals;
for (auto i = getTotalNumInputChannels(); i < getTotalNumOutputChannels(); ++i)
buffer.clear(i, 0, buffer.getNumSamples());
if (!pluginLoaded || !helper.isHelperRunning()) return;
auto& shm = helper.getSharedAudio();
if (!shm.isValid()) return;
int numChannels = buffer.getNumChannels();
int 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 (GUI instance) ─────────────────────────────────────────
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 β€” abort");
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;
}
PBLOG("Processor", "loadInProcessPlugin: creating instance \"" + typesFound[0]->name + "\"");
juce::String err;
inProcessPlugin = inProcessFormatManager.createPluginInstance(
*typesFound[0], currentSampleRate, currentBlockSize, err);
if (!inProcessPlugin)
{
PBLOG("Processor", "loadInProcessPlugin: createPluginInstance FAILED β€” " + err);
DBG("PluginBridgeProcessor: in-process load failed: " + err);
return;
}
// Bus layout β€” needed so the plugin knows its I/O configuration for GUI display.
// We do NOT call prepareToPlay() β€” the in-process plugin is GUI-only and never
// processes audio. Calling prepareToPlay() causes some plugins (iZotope Neutron,
// inter-plugin comms plugins) to spin up internal audio threads that then race
// with unloadInProcessPlugin() and crash the audio thread.
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);
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 stateData;
if (stateData.fromBase64Encoding(stateB64))
inProcessPlugin->setStateInformation(stateData.getData(), (int)stateData.getSize());
}
}
} catch (...) {}
inProcessPlugin->addListener(this);
PBLOG("Processor", "loadInProcessPlugin: SUCCESS β€” \"" + inProcessPlugin->getName() + "\"");
DBG("PluginBridgeProcessor: in-process plugin loaded for GUI: " + inProcessPlugin->getName());
}
void PluginBridgeProcessor::unloadInProcessPlugin()
{
if (inProcessPlugin)
{
PBLOG("Processor", "unloadInProcessPlugin: removing \"" + inProcessPlugin->getName() + "\"");
inProcessPlugin->removeListener(this);
inProcessPlugin->releaseResources();
inProcessPlugin.reset();
PBLOG("Processor", "unloadInProcessPlugin: done");
}
else
{
PBLOG("Processor", "unloadInProcessPlugin: nothing to unload");
}
}
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()
+ " (currently loaded: " + (pluginLoaded ? pluginName : "none") + ")");
if (blocklist.isBlocked(path))
{
PBLOG("Processor", "loadPlugin: BLOCKED β€” skipping");
if (onLoadFailed) onLoadFailed("Not compatible (crashed previously)");
return;
}
// ── Safety check ──────────────────────────────────────────────────────────
auto safety = safetyDB.getSafety(path);
PBLOG("Processor", juce::String("loadPlugin: safety = ")
+ (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;
}
// ── Notify editor to detach BEFORE we tear down the old plugin ────────────
// This prevents use-after-free when switching between plugins.
if (onPluginWillLoad)
{
PBLOG("Processor", "loadPlugin: firing onPluginWillLoad β†’ editor will detach old GUI");
onPluginWillLoad();
}
// ── Start Helper ──────────────────────────────────────────────────────────
if (!helper.isHelperRunning())
{
PBLOG("Processor", "loadPlugin: Helper not running β€” spawning");
if (!helper.spawnHelper())
{
PBLOG("Processor", "loadPlugin: ❌ spawnHelper FAILED");
if (onLoadFailed) onLoadFailed("Failed to start helper process");
return;
}
PBLOG("Processor", "loadPlugin: Helper spawned OK");
json configCmd = {{"cmd","configure"},{"sampleRate",currentSampleRate},
{"blockSize",currentBlockSize},{"channels",2}};
helper.sendCommand(configCmd.dump());
}
PBLOG("Processor", "loadPlugin: sending load cmd to Helper...");
json loadCmd = {{"cmd","load"},{"path",path.toStdString()},
{"sampleRate",currentSampleRate},{"blockSize",currentBlockSize}};
auto response = helper.sendCommand(loadCmd.dump());
PBLOG("Processor", "loadPlugin: Helper response: " + juce::String(response));
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: Helper loaded \"" + pluginName
+ "\" (" + juce::String(pluginParamCount) + " params)");
if (safety == PluginSafetyDB::Safety::Safe)
{
juce::MessageManager::callAsync([this, path]()
{
PBLOG("Processor", "loadPlugin: [msg thread] calling loadInProcessPlugin");
loadInProcessPlugin(path);
PBLOG("Processor", "loadPlugin: [msg thread] firing onPluginLoaded");
if (onPluginLoaded) onPluginLoaded();
});
}
else
{
PBLOG("Processor", "loadPlugin: UNSAFE β€” skipping in-process load, audio-only mode");
juce::MessageManager::callAsync([this]()
{
if (onPluginLoaded) onPluginLoaded();
});
}
}
else
{
PBLOG("Processor", "loadPlugin: ❌ Helper refused load: "
+ juce::String(j.value("error", "unknown")));
pluginLoaded = false;
pluginPath.clear(); // prevent onHelperCrashed stamping the wrong plugin
blocklist.addBlocked(path);
if (onLoadFailed)
onLoadFailed("Not compatible: " + juce::String(j.value("error", "unknown")));
}
}
catch (...)
{
PBLOG("Processor", "loadPlugin: ❌ exception parsing Helper response");
pluginLoaded = false;
pluginPath.clear(); // prevent onHelperCrashed stamping the wrong plugin
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();
PBLOG("Processor", "unloadPlugin: done");
if (onPluginUnloaded)
juce::MessageManager::callAsync([this]() { if (onPluginUnloaded) onPluginUnloaded(); });
}
// ── Plugin Scanner ────────────────────────────────────────────────────────────
// Spawns Helper with --scan <path> in an isolated subprocess.
// stderr is piped back so we capture every step the scanner logged.
// If the plugin crashes the subprocess, waitpid() catches WTERMSIG. Ableton is safe.
static juce::String signalName(int sig)
{
switch (sig)
{
case SIGSEGV: return "SIGSEGV (segmentation fault)";
case SIGABRT: return "SIGABRT (abort)";
case SIGBUS: return "SIGBUS (bus error)";
case SIGILL: return "SIGILL (illegal instruction)";
case SIGFPE: return "SIGFPE (floating point exception)";
case SIGKILL: return "SIGKILL (killed β€” timeout)";
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");
auto name = juce::File(pluginPath).getFileName();
juce::String entry;
entry << "────────────────────────────────────────\n"
<< ts << " " << name << "\n"
<< "Path : " << pluginPath << "\n"
<< "Result : " << (safe ? "SAFE βœ“" : "UNSAFE βœ—") << "\n";
if (!reason.isEmpty())
entry << "Reason : " << reason << "\n";
if (captured.isNotEmpty())
{
entry << "Scanner output:\n";
for (auto& line : juce::StringArray::fromLines(captured))
if (line.isNotEmpty())
entry << " " << line << "\n";
}
entry << "\n";
logFile.appendText(entry);
}
bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
{
// Resolve Helper binary (same logic as HelperConnection::spawnHelper)
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 binary not found", {});
return false;
}
// Create pipe to capture scanner's stderr
int pipeFd[2];
if (pipe(pipeFd) != 0)
{
writeScanLog(path, false, "pipe() failed", {});
return false;
}
// Make read end non-blocking so we can drain it safely
fcntl(pipeFd[0], F_SETFL, O_NONBLOCK);
auto helperPathStr = helperApp.getFullPathName().toStdString();
auto pluginPathStr = path.toStdString();
// Redirect child stderr β†’ pipe write end
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*)helperPathStr.c_str(),
(char*)"--scan",
(char*)pluginPathStr.c_str(),
nullptr
};
int spawnResult = posix_spawn(&pid, helperPathStr.c_str(), &actions, nullptr, argv, environ);
posix_spawn_file_actions_destroy(&actions);
close(pipeFd[1]); // Parent never writes
if (spawnResult != 0)
{
close(pipeFd[0]);
writeScanLog(path, false, "posix_spawn failed: " + juce::String(strerror(spawnResult)), {});
return false;
}
// Poll with 10s timeout
bool timedOut = true;
int finalStatus = 0;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
while (std::chrono::steady_clock::now() < deadline)
{
pid_t result = waitpid(pid, &finalStatus, WNOHANG);
if (result == pid) { timedOut = false; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
if (timedOut)
{
kill(pid, SIGKILL);
waitpid(pid, &finalStatus, 0);
}
// Drain pipe β€” read whatever the scanner managed to write before dying
juce::String captured;
{
char buf[8192];
ssize_t n;
while ((n = read(pipeFd[0], buf, sizeof(buf) - 1)) > 0)
{
buf[n] = '\0';
captured += buf;
}
}
close(pipeFd[0]);
// Interpret result
bool safe = false;
juce::String reason;
if (timedOut)
{
reason = "Timed out after 10s (hung on load or init)";
}
else if (WIFEXITED(finalStatus))
{
int code = WEXITSTATUS(finalStatus);
safe = (code == 0);
reason = safe ? "" : "Exited with code " + juce::String(code);
}
else if (WIFSIGNALED(finalStatus))
{
reason = "Crashed: " + signalName(WTERMSIG(finalStatus));
}
writeScanLog(path, safe, reason, captured);
DBG("runPluginScan: " + juce::File(path).getFileName()
+ (safe ? " β†’ SAFE" : " β†’ UNSAFE") + (reason.isEmpty() ? "" : " (" + reason + ")"));
return safe;
}
// ── Background Scanner ────────────────────────────────────────────────────────
// Scans all installed plugins whose safety is Unknown, one at a time.
// Results are stored in safetyDB and broadcast via onScanProgress.
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;
// CRITICAL: wait while the Helper is actively running a plugin.
// The scan subprocess and the live Helper share the same shared-memory
// name. Running both simultaneously causes the scan to crash β†’
// valid plugins get wrongly stamped UNSAFE.
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);
DBG("PluginBridgeProcessor: background scan complete");
});
}
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; // deduplicate by stem name β€” VST3 wins over AU
// Priority order: system VST3 β†’ user VST3 β†’ system AU β†’ user AU.
// System paths match the existing safety DB entries, so VST3 is preferred
// and same-named AU duplicates are automatically skipped.
struct DirSpec { juce::File dir; const char* pattern; };
std::vector<DirSpec> 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& spec : specs)
{
if (!spec.dir.isDirectory()) continue;
auto files = spec.dir.findChildFiles(juce::File::findDirectories, false, spec.pattern);
for (auto& f : files)
{
auto stem = f.getFileNameWithoutExtension().toStdString();
if (seen.insert(stem).second) // first time we see this name β†’ keep it
results.add(f);
}
}
results.sort();
return results;
}
bool PluginBridgeProcessor::isBlocked(const juce::String& path) const
{
return blocklist.isBlocked(path);
}
// ── Manufacturer name ─────────────────────────────────────────────────────────
// Fast: reads VST3 Info.plist (no plugin loading). Cached after first call.
// Strip copyright preamble and return the actual company name.
// Handles: "Copyright Β© 2002-2025 FabFilter" β†’ "FabFilter"
// "Copyright (C) 2017-2021 Soundtheory" β†’ "Soundtheory"
// "16.7.33.200" (version as vendor) β†’ "" (trigger fallback)
static juce::String cleanManufacturer(const juce::String& raw)
{
auto s = raw.trim();
if (s.isEmpty()) return {};
// Pure version number used as vendor (e.g. "16.7.33.200") β†’ reject
if (s.containsOnly("0123456789.")) return {};
auto lower = s.toLowerCase();
if (lower.startsWith("copyright"))
{
// Skip the word "Copyright"
auto rest = s.substring(9).trim();
// Skip "(C)", "(c)"
if (rest.startsWithIgnoreCase("(c)"))
rest = rest.substring(3).trim();
// Skip "Β©" (UTF-8 0xC2 0xA9, or the Unicode character directly)
else if (rest.startsWith(juce::String::fromUTF8("\xc2\xa9")))
rest = rest.substring(juce::String::fromUTF8("\xc2\xa9").length()).trim();
else if (rest.startsWith(juce::CharPointer_UTF8("\xc2\xa9")))
rest = rest.substring(1).trim();
// Skip year token(s): "2025" or "2002-2025"
auto spacePos = rest.indexOfChar(' ');
if (spacePos > 0)
rest = rest.substring(spacePos + 1).trim();
if (rest.isNotEmpty()) return rest;
return {};
}
return s;
}
// Extract the plist <string> value for a given key from raw plist XML text.
static juce::String plistValue(const juce::String& text, const juce::String& key)
{
auto pos = text.fromFirstOccurrenceOf(key, false, true);
if (pos.isEmpty()) return {};
return pos.fromFirstOccurrenceOf("<string>", false, false)
.upToFirstOccurrenceOf("</string>", false, false)
.trim();
}
juce::String PluginBridgeProcessor::getPluginManufacturer(const juce::File& f) const
{
auto pathKey = f.getFullPathName().toStdString();
{
std::lock_guard<std::mutex> lock(manufacturerCacheMutex);
auto it = manufacturerCache.find(pathKey);
if (it != manufacturerCache.end())
return juce::String(it->second);
}
juce::String mfr;
// ── VST3: Info.plist fields (no plugin loading needed) ───────────────────
if (f.getFileExtension().toLowerCase() == ".vst3")
{
auto infoPlist = f.getChildFile("Contents").getChildFile("Info.plist");
if (infoPlist.existsAsFile())
{
auto text = infoPlist.loadFileAsString();
// 1) NSHumanReadableCopyright β†’ "Copyright Β© 2002-2025 FabFilter" β†’ "FabFilter"
auto copyright = plistValue(text, "NSHumanReadableCopyright");
mfr = cleanManufacturer(copyright);
// 2) CFBundleGetInfoString β†’ "FabFilter Pro-Q 4.02, Copyright Β© …"
// Take the part before the comma (= "FabFilter Pro-Q 4.02"),
// then its first space-delimited word (= "FabFilter")
if (mfr.isEmpty())
{
auto infoStr = plistValue(text, "CFBundleGetInfoString");
if (infoStr.contains(","))
{
auto beforeComma = infoStr.upToFirstOccurrenceOf(",", false, false).trim();
// First word of "FabFilter Pro-Q 4.02" is "FabFilter"
auto first = beforeComma.upToFirstOccurrenceOf(" ", false, false).trim();
if (first.isNotEmpty() && !first.containsOnly("0123456789."))
mfr = first;
}
if (mfr.isEmpty())
mfr = cleanManufacturer(infoStr);
}
// 3) CFBundleIdentifier β†’ "com.fabfilter.Pro-Q.4" β†’ second segment
if (mfr.isEmpty())
{
auto bid = plistValue(text, "CFBundleIdentifier");
// "com.fabfilter.Pro-Q.4" β†’ segments: com | fabfilter | …
auto segs = juce::StringArray::fromTokens(bid, ".", "");
if (segs.size() >= 2)
{
// segment[1] is usually the company slug β€” capitalise it
auto slug = segs[1].trim();
if (slug.isNotEmpty() && slug != "apple" && slug != "com")
{
slug = slug.substring(0, 1).toUpperCase() + slug.substring(1);
mfr = slug;
}
}
}
}
// 4) moduleinfo.json "vendor" (VST3 SDK 3.7+) β€” clean copyright if needed
if (mfr.isEmpty())
{
auto moduleInfo = f.getChildFile("Contents").getChildFile("moduleinfo.json");
if (moduleInfo.existsAsFile())
{
try
{
auto j = json::parse(moduleInfo.loadFileAsString().toStdString());
auto vendor = cleanManufacturer(juce::String(j.value("vendor", "")));
if (vendor.isNotEmpty()) mfr = vendor;
}
catch (...) {}
}
}
}
// ── Filename heuristic (AU or VST3 fallback) ──────────────────────────────
// "FabFilter Pro-Q 4" β†’ "FabFilter", "ValhallaDelay" β†’ "ValhallaDelay"
if (mfr.isEmpty())
{
auto name = f.getFileNameWithoutExtension();
auto first = name.upToFirstOccurrenceOf(" ", false, false);
if (first.isNotEmpty() && !first.containsOnly("0123456789."))
mfr = first;
else
mfr = name;
}
if (mfr.isEmpty()) mfr = "Other";
std::lock_guard<std::mutex> lock(manufacturerCacheMutex);
manufacturerCache[pathKey] = mfr.toStdString();
return mfr;
}
// ── State ─────────────────────────────────────────────────────────────────────
void PluginBridgeProcessor::getStateInformation(juce::MemoryBlock& destData)
{
juce::ValueTree state("PluginBridgeState");
state.setProperty("pluginPath", pluginPath, nullptr);
if (pluginLoaded && helper.isHelperRunning())
{
auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
try {
auto j = json::parse(response);
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 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();
}