#include "PluginBridgeProcessor.h" #include "PluginBridgeEditor.h" #include "IPCProtocol.h" #include "PBLog.h" #include #include #include #include #include #include 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(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& 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 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()); 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()); 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 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 PluginBridgeProcessor::getInstalledPluginFiles() const { juce::Array results; std::set 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 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 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("", false, false) .upToFirstOccurrenceOf("", false, false) .trim(); } juce::String PluginBridgeProcessor::getPluginManufacturer(const juce::File& f) const { auto pathKey = f.getFullPathName().toStdString(); { std::lock_guard 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 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()), 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(); }