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