Download Source/Plugin/PluginBridgeProcessor.cpp from RAM2118/pluginbridge: direct link, hf CLI and curl.
- Browser
- Download file 32.5 kB
-
https://huggingface.co/RAM2118/pluginbridge/resolve/fdc4e418d34e7f32ef31422f1e9aae1de08f4e86/Source/Plugin/PluginBridgeProcessor.cpp
- Command line
-
hf download hf://RAM2118/pluginbridge@fdc4e418d34e7f32ef31422f1e9aae1de08f4e86/Source/Plugin/PluginBridgeProcessor.cpp
-
curl -L -o PluginBridgeProcessor.cpp https://huggingface.co/RAM2118/pluginbridge/resolve/fdc4e418d34e7f32ef31422f1e9aae1de08f4e86/Source/Plugin/PluginBridgeProcessor.cpp
32.5 kB
| extern char** environ; | |
| 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(); | |
| } | |