RAM2118 commited on
Commit
2eb08b2
Β·
verified Β·
1 Parent(s): 199d2e1

Sprint 5: route audio through in-process plugin for SAFE plugins

Browse files

NEXT PUSH:
TASK: Pro-Q 4 spectrum analyzer fix β€” audio now flows through in-process instance
FILES CHANGING:
- PluginBridgeProcessor.cpp (processBlock routing + prepareToPlay in loadInProcessPlugin)
FILES NOT TOUCHING:
- PluginBridgeEditor.mm βœ…
- PluginPickerComponent.mm βœ…
- HelperPluginHost.mm βœ…
- HelperConnection.cpp βœ…
NEW FILES: none
APIS USED:
- inProcessPlugin->processBlock() β€” verified (AudioProcessor inheritance)
- inProcessPlugin->prepareToPlay() β€” verified safe (only SAFE plugins reach here)

Changes:
1. loadInProcessPlugin(): added prepareToPlay(currentSampleRate, currentBlockSize)
after setBusesLayout(), before state restore + addListener()
2. processBlock(): named MidiBuffer param, added in-process audio path:
if inProcessPlugin exists β†’ processBlock directly (skips Helper shm)
else β†’ existing Helper shm path unchanged
3. AudioAnalyser runs on the output regardless of which path is used"

Source/Plugin/PluginBridgeProcessor.cpp CHANGED
@@ -31,8 +31,6 @@ PluginBridgeProcessor::PluginBridgeProcessor()
31
  {
32
  PBLOG("Processor", "⚠️ Helper crashed β€” clearing plugin state");
33
 
34
- // Mark the crashing plugin as unsafe so it loads audio-only next time
35
- // instead of respawning a Helper that will crash again.
36
  if (pluginPath.isNotEmpty())
37
  {
38
  PBLOG("Processor", "⚠️ Marking \"" + pluginName + "\" as UNSAFE (Helper crash)");
@@ -62,7 +60,6 @@ PluginBridgeProcessor::PluginBridgeProcessor()
62
  });
63
  };
64
 
65
- // Wire audio analysis to MCP get_analysis tool
66
  mcpServer->getAnalysisCallback = [this]() { return analyser.getCompactAnalysis(); };
67
 
68
  mcpServer->start();
@@ -86,6 +83,13 @@ void PluginBridgeProcessor::prepareToPlay(double sampleRate, int samplesPerBlock
86
 
87
  analyser.prepareToPlay(sampleRate, samplesPerBlock);
88
 
 
 
 
 
 
 
 
89
  if (helper.isHelperRunning())
90
  {
91
  json cmd = {{"cmd", "configure"},
@@ -106,14 +110,25 @@ void PluginBridgeProcessor::prepareToPlay(double sampleRate, int samplesPerBlock
106
 
107
  void PluginBridgeProcessor::releaseResources() {}
108
 
109
- void PluginBridgeProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
110
  {
111
  juce::ScopedNoDenormals noDenormals;
112
 
113
  for (auto i = getTotalNumInputChannels(); i < getTotalNumOutputChannels(); ++i)
114
  buffer.clear(i, 0, buffer.getNumSamples());
115
 
116
- if (!pluginLoaded || !helper.isHelperRunning()) return;
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  auto& shm = helper.getSharedAudio();
119
  if (!shm.isValid()) return;
@@ -151,7 +166,7 @@ juce::AudioProcessorEditor* PluginBridgeProcessor::createEditor()
151
  return new PluginBridgeEditor(*this);
152
  }
153
 
154
- // ── In-process plugin (GUI instance) ─────────────────────────────────────────
155
 
156
  void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
157
  {
@@ -195,11 +210,7 @@ void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
195
  return;
196
  }
197
 
198
- // Bus layout β€” needed so the plugin knows its I/O configuration for GUI display.
199
- // We do NOT call prepareToPlay() β€” the in-process plugin is GUI-only and never
200
- // processes audio. Calling prepareToPlay() causes some plugins (iZotope Neutron,
201
- // inter-plugin comms plugins) to spin up internal audio threads that then race
202
- // with unloadInProcessPlugin() and crash the audio thread.
203
  inProcessPlugin->enableAllBuses();
204
  auto layout = juce::AudioProcessor::BusesLayout();
205
  layout.inputBuses.add(juce::AudioChannelSet::stereo());
@@ -207,6 +218,11 @@ void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
207
  if (inProcessPlugin->checkBusesLayoutSupported(layout))
208
  inProcessPlugin->setBusesLayout(layout);
209
 
 
 
 
 
 
210
  auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
211
  try {
212
  auto j = json::parse(response);
@@ -223,8 +239,8 @@ void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
223
  } catch (...) {}
224
 
225
  inProcessPlugin->addListener(this);
226
- PBLOG("Processor", "loadInProcessPlugin: SUCCESS β€” \"" + inProcessPlugin->getName() + "\"");
227
- DBG("PluginBridgeProcessor: in-process plugin loaded for GUI: " + inProcessPlugin->getName());
228
  }
229
 
230
  void PluginBridgeProcessor::unloadInProcessPlugin()
@@ -266,7 +282,6 @@ void PluginBridgeProcessor::loadPlugin(const juce::String& path)
266
  return;
267
  }
268
 
269
- // ── Safety check ──────────────────────────────────────────────────────────
270
  auto safety = safetyDB.getSafety(path);
271
  PBLOG("Processor", juce::String("loadPlugin: safety = ")
272
  + (safety == PluginSafetyDB::Safety::Safe ? "SAFE"
@@ -286,15 +301,12 @@ void PluginBridgeProcessor::loadPlugin(const juce::String& path)
286
  return;
287
  }
288
 
289
- // ── Notify editor to detach BEFORE we tear down the old plugin ────────────
290
- // This prevents use-after-free when switching between plugins.
291
  if (onPluginWillLoad)
292
  {
293
  PBLOG("Processor", "loadPlugin: firing onPluginWillLoad β†’ editor will detach old GUI");
294
  onPluginWillLoad();
295
  }
296
 
297
- // ── Start Helper ──────────────────────────────────────────────────────────
298
  if (!helper.isHelperRunning())
299
  {
300
  PBLOG("Processor", "loadPlugin: Helper not running β€” spawning");
@@ -353,7 +365,7 @@ void PluginBridgeProcessor::loadPlugin(const juce::String& path)
353
  PBLOG("Processor", "loadPlugin: ❌ Helper refused load: "
354
  + juce::String(j.value("error", "unknown")));
355
  pluginLoaded = false;
356
- pluginPath.clear(); // prevent onHelperCrashed stamping the wrong plugin
357
  blocklist.addBlocked(path);
358
  if (onLoadFailed)
359
  onLoadFailed("Not compatible: " + juce::String(j.value("error", "unknown")));
@@ -363,7 +375,7 @@ void PluginBridgeProcessor::loadPlugin(const juce::String& path)
363
  {
364
  PBLOG("Processor", "loadPlugin: ❌ exception parsing Helper response");
365
  pluginLoaded = false;
366
- pluginPath.clear(); // prevent onHelperCrashed stamping the wrong plugin
367
  blocklist.addBlocked(path);
368
  if (onLoadFailed) onLoadFailed("Helper communication error");
369
  }
@@ -389,9 +401,6 @@ void PluginBridgeProcessor::unloadPlugin()
389
  }
390
 
391
  // ── Plugin Scanner ────────────────────────────────────────────────────────────
392
- // Spawns Helper with --scan <path> in an isolated subprocess.
393
- // stderr is piped back so we capture every step the scanner logged.
394
- // If the plugin crashes the subprocess, waitpid() catches WTERMSIG. Ableton is safe.
395
 
396
  static juce::String signalName(int sig)
397
  {
@@ -407,14 +416,11 @@ static juce::String signalName(int sig)
407
  }
408
  }
409
 
410
- static void writeScanLog(const juce::String& pluginPath,
411
- bool safe,
412
- const juce::String& reason,
413
- const juce::String& captured)
414
  {
415
  auto logFile = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
416
- .getChildFile("PluginBridge")
417
- .getChildFile("scan.log");
418
  logFile.getParentDirectory().createDirectory();
419
 
420
  auto ts = juce::Time::getCurrentTime().formatted("%Y-%m-%d %H:%M:%S");
@@ -425,39 +431,29 @@ static void writeScanLog(const juce::String& pluginPath,
425
  << ts << " " << name << "\n"
426
  << "Path : " << pluginPath << "\n"
427
  << "Result : " << (safe ? "SAFE βœ“" : "UNSAFE βœ—") << "\n";
428
-
429
- if (!reason.isEmpty())
430
- entry << "Reason : " << reason << "\n";
431
-
432
  if (captured.isNotEmpty())
433
  {
434
  entry << "Scanner output:\n";
435
  for (auto& line : juce::StringArray::fromLines(captured))
436
- if (line.isNotEmpty())
437
- entry << " " << line << "\n";
438
  }
439
-
440
  entry << "\n";
441
  logFile.appendText(entry);
442
  }
443
 
444
  bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
445
  {
446
- // Resolve Helper binary (same logic as HelperConnection::spawnHelper)
447
  auto helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile)
448
- .getParentDirectory()
449
- .getParentDirectory()
450
- .getChildFile("Resources")
451
- .getChildFile("PluginBridgeHelper.app")
452
- .getChildFile("Contents")
453
- .getChildFile("MacOS")
454
  .getChildFile(PluginBridgeConstants::kHelperBinaryName);
455
 
456
  if (!helperApp.exists())
457
  {
458
  helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile)
459
- .getParentDirectory()
460
- .getParentDirectory()
461
  .getChildFile("Resources")
462
  .getChildFile(PluginBridgeConstants::kHelperBinaryName);
463
  }
@@ -468,36 +464,25 @@ bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
468
  return false;
469
  }
470
 
471
- // Create pipe to capture scanner's stderr
472
  int pipeFd[2];
473
- if (pipe(pipeFd) != 0)
474
- {
475
- writeScanLog(path, false, "pipe() failed", {});
476
- return false;
477
- }
478
- // Make read end non-blocking so we can drain it safely
479
  fcntl(pipeFd[0], F_SETFL, O_NONBLOCK);
480
 
481
  auto helperPathStr = helperApp.getFullPathName().toStdString();
482
  auto pluginPathStr = path.toStdString();
483
 
484
- // Redirect child stderr β†’ pipe write end
485
  posix_spawn_file_actions_t actions;
486
  posix_spawn_file_actions_init(&actions);
487
  posix_spawn_file_actions_adddup2(&actions, pipeFd[1], STDERR_FILENO);
488
  posix_spawn_file_actions_addclose(&actions, pipeFd[0]);
489
 
490
  pid_t pid;
491
- char* argv[] = {
492
- (char*)helperPathStr.c_str(),
493
- (char*)"--scan",
494
- (char*)pluginPathStr.c_str(),
495
- nullptr
496
- };
497
 
498
  int spawnResult = posix_spawn(&pid, helperPathStr.c_str(), &actions, nullptr, argv, environ);
499
  posix_spawn_file_actions_destroy(&actions);
500
- close(pipeFd[1]); // Parent never writes
501
 
502
  if (spawnResult != 0)
503
  {
@@ -506,10 +491,8 @@ bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
506
  return false;
507
  }
508
 
509
- // Poll with 10s timeout
510
  bool timedOut = true;
511
- int finalStatus = 0;
512
-
513
  auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
514
  while (std::chrono::steady_clock::now() < deadline)
515
  {
@@ -518,54 +501,25 @@ bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
518
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
519
  }
520
 
521
- if (timedOut)
522
- {
523
- kill(pid, SIGKILL);
524
- waitpid(pid, &finalStatus, 0);
525
- }
526
 
527
- // Drain pipe β€” read whatever the scanner managed to write before dying
528
  juce::String captured;
529
- {
530
- char buf[8192];
531
- ssize_t n;
532
- while ((n = read(pipeFd[0], buf, sizeof(buf) - 1)) > 0)
533
- {
534
- buf[n] = '\0';
535
- captured += buf;
536
- }
537
- }
538
  close(pipeFd[0]);
539
 
540
- // Interpret result
541
  bool safe = false;
542
  juce::String reason;
543
-
544
- if (timedOut)
545
- {
546
- reason = "Timed out after 10s (hung on load or init)";
547
- }
548
- else if (WIFEXITED(finalStatus))
549
- {
550
- int code = WEXITSTATUS(finalStatus);
551
- safe = (code == 0);
552
- reason = safe ? "" : "Exited with code " + juce::String(code);
553
- }
554
- else if (WIFSIGNALED(finalStatus))
555
- {
556
- reason = "Crashed: " + signalName(WTERMSIG(finalStatus));
557
- }
558
 
559
  writeScanLog(path, safe, reason, captured);
560
- DBG("runPluginScan: " + juce::File(path).getFileName()
561
- + (safe ? " β†’ SAFE" : " β†’ UNSAFE") + (reason.isEmpty() ? "" : " (" + reason + ")"));
562
-
563
  return safe;
564
  }
565
 
566
  // ── Background Scanner ────────────────────────────────────────────────────────
567
- // Scans all installed plugins whose safety is Unknown, one at a time.
568
- // Results are stored in safetyDB and broadcast via onScanProgress.
569
 
570
  void PluginBridgeProcessor::startBackgroundScan()
571
  {
@@ -575,45 +529,30 @@ void PluginBridgeProcessor::startBackgroundScan()
575
  scanThread = std::thread([this]()
576
  {
577
  auto files = getInstalledPluginFiles();
578
-
579
  for (int i = 0; i < files.size() && scanRunning.load(); ++i)
580
  {
581
  auto path = files[i].getFullPathName();
582
- if (safetyDB.getSafety(path) != PluginSafetyDB::Safety::Unknown)
583
- continue;
584
 
585
- // CRITICAL: wait while the Helper is actively running a plugin.
586
- // The scan subprocess and the live Helper share the same shared-memory
587
- // name. Running both simultaneously causes the scan to crash β†’
588
- // valid plugins get wrongly stamped UNSAFE.
589
  while (helper.isHelperRunning() && scanRunning.load())
590
  std::this_thread::sleep_for(std::chrono::milliseconds(500));
591
-
592
  if (!scanRunning.load()) break;
593
 
594
  bool safe = runPluginScan(path);
595
  safetyDB.setSafety(path, safe ? PluginSafetyDB::Safety::Safe
596
  : PluginSafetyDB::Safety::Unsafe);
597
-
598
  if (onScanProgress)
599
- {
600
  juce::MessageManager::callAsync([this, path, safe]()
601
- {
602
- if (onScanProgress) onScanProgress(path, safe);
603
- });
604
- }
605
  }
606
-
607
  scanRunning.store(false);
608
- DBG("PluginBridgeProcessor: background scan complete");
609
  });
610
  }
611
 
612
  void PluginBridgeProcessor::stopBackgroundScan()
613
  {
614
  scanRunning.store(false);
615
- if (scanThread.joinable())
616
- scanThread.join();
617
  }
618
 
619
  // ── Plugin List ───────────────────────────────────────────────────────────────
@@ -621,19 +560,16 @@ void PluginBridgeProcessor::stopBackgroundScan()
621
  juce::Array<juce::File> PluginBridgeProcessor::getInstalledPluginFiles() const
622
  {
623
  juce::Array<juce::File> results;
624
- std::set<std::string> seen; // deduplicate by stem name β€” VST3 wins over AU
625
 
626
- // Priority order: system VST3 β†’ user VST3 β†’ system AU β†’ user AU.
627
- // System paths match the existing safety DB entries, so VST3 is preferred
628
- // and same-named AU duplicates are automatically skipped.
629
  struct DirSpec { juce::File dir; const char* pattern; };
630
  std::vector<DirSpec> specs = {
631
- { juce::File("/Library/Audio/Plug-Ins/VST3"), "*.vst3" },
632
  { juce::File::getSpecialLocation(juce::File::userHomeDirectory)
633
- .getChildFile("Library/Audio/Plug-Ins/VST3"), "*.vst3" },
634
- { juce::File("/Library/Audio/Plug-Ins/Components"), "*.component" },
635
  { juce::File::getSpecialLocation(juce::File::userHomeDirectory)
636
- .getChildFile("Library/Audio/Plug-Ins/Components"), "*.component" }
637
  };
638
 
639
  for (auto& spec : specs)
@@ -643,8 +579,7 @@ juce::Array<juce::File> PluginBridgeProcessor::getInstalledPluginFiles() const
643
  for (auto& f : files)
644
  {
645
  auto stem = f.getFileNameWithoutExtension().toStdString();
646
- if (seen.insert(stem).second) // first time we see this name β†’ keep it
647
- results.add(f);
648
  }
649
  }
650
 
@@ -652,149 +587,102 @@ juce::Array<juce::File> PluginBridgeProcessor::getInstalledPluginFiles() const
652
  return results;
653
  }
654
 
655
- bool PluginBridgeProcessor::isBlocked(const juce::String& path) const
656
- {
657
- return blocklist.isBlocked(path);
658
- }
659
 
660
  // ── Manufacturer name ─────────────────────────────────────────────────────────
661
- // Fast: reads VST3 Info.plist (no plugin loading). Cached after first call.
662
 
663
- // Strip copyright preamble and return the actual company name.
664
- // Handles: "Copyright Β© 2002-2025 FabFilter" β†’ "FabFilter"
665
- // "Copyright (C) 2017-2021 Soundtheory" β†’ "Soundtheory"
666
- // "16.7.33.200" (version as vendor) β†’ "" (trigger fallback)
667
  static juce::String cleanManufacturer(const juce::String& raw)
668
  {
669
  auto s = raw.trim();
670
  if (s.isEmpty()) return {};
671
-
672
- // Pure version number used as vendor (e.g. "16.7.33.200") β†’ reject
673
  if (s.containsOnly("0123456789.")) return {};
674
 
675
  auto lower = s.toLowerCase();
676
  if (lower.startsWith("copyright"))
677
  {
678
- // Skip the word "Copyright"
679
  auto rest = s.substring(9).trim();
680
- // Skip "(C)", "(c)"
681
- if (rest.startsWithIgnoreCase("(c)"))
682
- rest = rest.substring(3).trim();
683
- // Skip "Β©" (UTF-8 0xC2 0xA9, or the Unicode character directly)
684
  else if (rest.startsWith(juce::String::fromUTF8("\xc2\xa9")))
685
  rest = rest.substring(juce::String::fromUTF8("\xc2\xa9").length()).trim();
686
  else if (rest.startsWith(juce::CharPointer_UTF8("\xc2\xa9")))
687
  rest = rest.substring(1).trim();
688
- // Skip year token(s): "2025" or "2002-2025"
689
  auto spacePos = rest.indexOfChar(' ');
690
- if (spacePos > 0)
691
- rest = rest.substring(spacePos + 1).trim();
692
  if (rest.isNotEmpty()) return rest;
693
  return {};
694
  }
695
-
696
  return s;
697
  }
698
 
699
- // Extract the plist <string> value for a given key from raw plist XML text.
700
  static juce::String plistValue(const juce::String& text, const juce::String& key)
701
  {
702
  auto pos = text.fromFirstOccurrenceOf(key, false, true);
703
  if (pos.isEmpty()) return {};
704
  return pos.fromFirstOccurrenceOf("<string>", false, false)
705
- .upToFirstOccurrenceOf("</string>", false, false)
706
- .trim();
707
  }
708
 
709
  juce::String PluginBridgeProcessor::getPluginManufacturer(const juce::File& f) const
710
  {
711
  auto pathKey = f.getFullPathName().toStdString();
712
-
713
- {
714
- std::lock_guard<std::mutex> lock(manufacturerCacheMutex);
715
- auto it = manufacturerCache.find(pathKey);
716
- if (it != manufacturerCache.end())
717
- return juce::String(it->second);
718
- }
719
 
720
  juce::String mfr;
721
 
722
- // ── VST3: Info.plist fields (no plugin loading needed) ───────────────────
723
  if (f.getFileExtension().toLowerCase() == ".vst3")
724
  {
725
  auto infoPlist = f.getChildFile("Contents").getChildFile("Info.plist");
726
  if (infoPlist.existsAsFile())
727
  {
728
  auto text = infoPlist.loadFileAsString();
729
-
730
- // 1) NSHumanReadableCopyright β†’ "Copyright Β© 2002-2025 FabFilter" β†’ "FabFilter"
731
  auto copyright = plistValue(text, "NSHumanReadableCopyright");
732
  mfr = cleanManufacturer(copyright);
733
 
734
- // 2) CFBundleGetInfoString β†’ "FabFilter Pro-Q 4.02, Copyright Β© …"
735
- // Take the part before the comma (= "FabFilter Pro-Q 4.02"),
736
- // then its first space-delimited word (= "FabFilter")
737
  if (mfr.isEmpty())
738
  {
739
  auto infoStr = plistValue(text, "CFBundleGetInfoString");
740
  if (infoStr.contains(","))
741
  {
742
  auto beforeComma = infoStr.upToFirstOccurrenceOf(",", false, false).trim();
743
- // First word of "FabFilter Pro-Q 4.02" is "FabFilter"
744
  auto first = beforeComma.upToFirstOccurrenceOf(" ", false, false).trim();
745
- if (first.isNotEmpty() && !first.containsOnly("0123456789."))
746
- mfr = first;
747
  }
748
- if (mfr.isEmpty())
749
- mfr = cleanManufacturer(infoStr);
750
  }
751
 
752
- // 3) CFBundleIdentifier β†’ "com.fabfilter.Pro-Q.4" β†’ second segment
753
  if (mfr.isEmpty())
754
  {
755
  auto bid = plistValue(text, "CFBundleIdentifier");
756
- // "com.fabfilter.Pro-Q.4" β†’ segments: com | fabfilter | …
757
  auto segs = juce::StringArray::fromTokens(bid, ".", "");
758
  if (segs.size() >= 2)
759
  {
760
- // segment[1] is usually the company slug β€” capitalise it
761
  auto slug = segs[1].trim();
762
  if (slug.isNotEmpty() && slug != "apple" && slug != "com")
763
- {
764
- slug = slug.substring(0, 1).toUpperCase() + slug.substring(1);
765
- mfr = slug;
766
- }
767
  }
768
  }
769
  }
770
 
771
- // 4) moduleinfo.json "vendor" (VST3 SDK 3.7+) β€” clean copyright if needed
772
  if (mfr.isEmpty())
773
  {
774
  auto moduleInfo = f.getChildFile("Contents").getChildFile("moduleinfo.json");
775
  if (moduleInfo.existsAsFile())
776
  {
777
- try
778
- {
779
- auto j = json::parse(moduleInfo.loadFileAsString().toStdString());
780
- auto vendor = cleanManufacturer(juce::String(j.value("vendor", "")));
781
- if (vendor.isNotEmpty()) mfr = vendor;
782
- }
783
- catch (...) {}
784
  }
785
  }
786
  }
787
 
788
- // ── Filename heuristic (AU or VST3 fallback) ──────────────────────────────
789
- // "FabFilter Pro-Q 4" β†’ "FabFilter", "ValhallaDelay" β†’ "ValhallaDelay"
790
  if (mfr.isEmpty())
791
  {
792
  auto name = f.getFileNameWithoutExtension();
793
  auto first = name.upToFirstOccurrenceOf(" ", false, false);
794
- if (first.isNotEmpty() && !first.containsOnly("0123456789."))
795
- mfr = first;
796
- else
797
- mfr = name;
798
  }
799
 
800
  if (mfr.isEmpty()) mfr = "Other";
@@ -814,11 +702,9 @@ void PluginBridgeProcessor::getStateInformation(juce::MemoryBlock& destData)
814
  if (pluginLoaded && helper.isHelperRunning())
815
  {
816
  auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
817
- try {
818
- auto j = json::parse(response);
819
- if (j.value("ok", false))
820
- state.setProperty("pluginState",
821
- juce::String(j["state"].get<std::string>()), nullptr);
822
  } catch (...) {}
823
  }
824
 
 
31
  {
32
  PBLOG("Processor", "⚠️ Helper crashed β€” clearing plugin state");
33
 
 
 
34
  if (pluginPath.isNotEmpty())
35
  {
36
  PBLOG("Processor", "⚠️ Marking \"" + pluginName + "\" as UNSAFE (Helper crash)");
 
60
  });
61
  };
62
 
 
63
  mcpServer->getAnalysisCallback = [this]() { return analyser.getCompactAnalysis(); };
64
 
65
  mcpServer->start();
 
83
 
84
  analyser.prepareToPlay(sampleRate, samplesPerBlock);
85
 
86
+ // If in-process plugin is already loaded, update its sample rate/block size too
87
+ if (inProcessPlugin)
88
+ {
89
+ inProcessPlugin->releaseResources();
90
+ inProcessPlugin->prepareToPlay(sampleRate, samplesPerBlock);
91
+ }
92
+
93
  if (helper.isHelperRunning())
94
  {
95
  json cmd = {{"cmd", "configure"},
 
110
 
111
  void PluginBridgeProcessor::releaseResources() {}
112
 
113
+ void PluginBridgeProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages)
114
  {
115
  juce::ScopedNoDenormals noDenormals;
116
 
117
  for (auto i = getTotalNumInputChannels(); i < getTotalNumOutputChannels(); ++i)
118
  buffer.clear(i, 0, buffer.getNumSamples());
119
 
120
+ if (!pluginLoaded) return;
121
+
122
+ // ── SAFE plugin path: audio goes through in-process instance ──────────────
123
+ if (inProcessPlugin != nullptr)
124
+ {
125
+ inProcessPlugin->processBlock(buffer, midiMessages);
126
+ analyser.processBlock(buffer);
127
+ return;
128
+ }
129
+
130
+ // ── UNSAFE plugin path: audio goes through Helper via shared memory ───────
131
+ if (!helper.isHelperRunning()) return;
132
 
133
  auto& shm = helper.getSharedAudio();
134
  if (!shm.isValid()) return;
 
166
  return new PluginBridgeEditor(*this);
167
  }
168
 
169
+ // ── In-process plugin (now handles BOTH audio + GUI for SAFE plugins) ─────────
170
 
171
  void PluginBridgeProcessor::loadInProcessPlugin(const juce::String& path)
172
  {
 
210
  return;
211
  }
212
 
213
+ // Configure buses
 
 
 
 
214
  inProcessPlugin->enableAllBuses();
215
  auto layout = juce::AudioProcessor::BusesLayout();
216
  layout.inputBuses.add(juce::AudioChannelSet::stereo());
 
218
  if (inProcessPlugin->checkBusesLayoutSupported(layout))
219
  inProcessPlugin->setBusesLayout(layout);
220
 
221
+ // Prepare for audio processing β€” safe for SAFE plugins (unsafe ones never reach here)
222
+ if (currentSampleRate > 0 && currentBlockSize > 0)
223
+ inProcessPlugin->prepareToPlay(currentSampleRate, currentBlockSize);
224
+
225
+ // Sync state from Helper (if it has state loaded already)
226
  auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
227
  try {
228
  auto j = json::parse(response);
 
239
  } catch (...) {}
240
 
241
  inProcessPlugin->addListener(this);
242
+ PBLOG("Processor", "loadInProcessPlugin: SUCCESS β€” \"" + inProcessPlugin->getName() + "\" (audio+GUI)");
243
+ DBG("PluginBridgeProcessor: in-process plugin loaded: " + inProcessPlugin->getName());
244
  }
245
 
246
  void PluginBridgeProcessor::unloadInProcessPlugin()
 
282
  return;
283
  }
284
 
 
285
  auto safety = safetyDB.getSafety(path);
286
  PBLOG("Processor", juce::String("loadPlugin: safety = ")
287
  + (safety == PluginSafetyDB::Safety::Safe ? "SAFE"
 
301
  return;
302
  }
303
 
 
 
304
  if (onPluginWillLoad)
305
  {
306
  PBLOG("Processor", "loadPlugin: firing onPluginWillLoad β†’ editor will detach old GUI");
307
  onPluginWillLoad();
308
  }
309
 
 
310
  if (!helper.isHelperRunning())
311
  {
312
  PBLOG("Processor", "loadPlugin: Helper not running β€” spawning");
 
365
  PBLOG("Processor", "loadPlugin: ❌ Helper refused load: "
366
  + juce::String(j.value("error", "unknown")));
367
  pluginLoaded = false;
368
+ pluginPath.clear();
369
  blocklist.addBlocked(path);
370
  if (onLoadFailed)
371
  onLoadFailed("Not compatible: " + juce::String(j.value("error", "unknown")));
 
375
  {
376
  PBLOG("Processor", "loadPlugin: ❌ exception parsing Helper response");
377
  pluginLoaded = false;
378
+ pluginPath.clear();
379
  blocklist.addBlocked(path);
380
  if (onLoadFailed) onLoadFailed("Helper communication error");
381
  }
 
401
  }
402
 
403
  // ── Plugin Scanner ────────────────────────────────────────────────────────────
 
 
 
404
 
405
  static juce::String signalName(int sig)
406
  {
 
416
  }
417
  }
418
 
419
+ static void writeScanLog(const juce::String& pluginPath, bool safe,
420
+ const juce::String& reason, const juce::String& captured)
 
 
421
  {
422
  auto logFile = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
423
+ .getChildFile("PluginBridge").getChildFile("scan.log");
 
424
  logFile.getParentDirectory().createDirectory();
425
 
426
  auto ts = juce::Time::getCurrentTime().formatted("%Y-%m-%d %H:%M:%S");
 
431
  << ts << " " << name << "\n"
432
  << "Path : " << pluginPath << "\n"
433
  << "Result : " << (safe ? "SAFE βœ“" : "UNSAFE βœ—") << "\n";
434
+ if (!reason.isEmpty()) entry << "Reason : " << reason << "\n";
 
 
 
435
  if (captured.isNotEmpty())
436
  {
437
  entry << "Scanner output:\n";
438
  for (auto& line : juce::StringArray::fromLines(captured))
439
+ if (line.isNotEmpty()) entry << " " << line << "\n";
 
440
  }
 
441
  entry << "\n";
442
  logFile.appendText(entry);
443
  }
444
 
445
  bool PluginBridgeProcessor::runPluginScan(const juce::String& path)
446
  {
 
447
  auto helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile)
448
+ .getParentDirectory().getParentDirectory()
449
+ .getChildFile("Resources").getChildFile("PluginBridgeHelper.app")
450
+ .getChildFile("Contents").getChildFile("MacOS")
 
 
 
451
  .getChildFile(PluginBridgeConstants::kHelperBinaryName);
452
 
453
  if (!helperApp.exists())
454
  {
455
  helperApp = juce::File::getSpecialLocation(juce::File::currentExecutableFile)
456
+ .getParentDirectory().getParentDirectory()
 
457
  .getChildFile("Resources")
458
  .getChildFile(PluginBridgeConstants::kHelperBinaryName);
459
  }
 
464
  return false;
465
  }
466
 
 
467
  int pipeFd[2];
468
+ if (pipe(pipeFd) != 0) { writeScanLog(path, false, "pipe() failed", {}); return false; }
 
 
 
 
 
469
  fcntl(pipeFd[0], F_SETFL, O_NONBLOCK);
470
 
471
  auto helperPathStr = helperApp.getFullPathName().toStdString();
472
  auto pluginPathStr = path.toStdString();
473
 
 
474
  posix_spawn_file_actions_t actions;
475
  posix_spawn_file_actions_init(&actions);
476
  posix_spawn_file_actions_adddup2(&actions, pipeFd[1], STDERR_FILENO);
477
  posix_spawn_file_actions_addclose(&actions, pipeFd[0]);
478
 
479
  pid_t pid;
480
+ char* argv[] = { (char*)helperPathStr.c_str(), (char*)"--scan",
481
+ (char*)pluginPathStr.c_str(), nullptr };
 
 
 
 
482
 
483
  int spawnResult = posix_spawn(&pid, helperPathStr.c_str(), &actions, nullptr, argv, environ);
484
  posix_spawn_file_actions_destroy(&actions);
485
+ close(pipeFd[1]);
486
 
487
  if (spawnResult != 0)
488
  {
 
491
  return false;
492
  }
493
 
 
494
  bool timedOut = true;
495
+ int finalStatus = 0;
 
496
  auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
497
  while (std::chrono::steady_clock::now() < deadline)
498
  {
 
501
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
502
  }
503
 
504
+ if (timedOut) { kill(pid, SIGKILL); waitpid(pid, &finalStatus, 0); }
 
 
 
 
505
 
 
506
  juce::String captured;
507
+ { char buf[8192]; ssize_t n;
508
+ while ((n = read(pipeFd[0], buf, sizeof(buf)-1)) > 0) { buf[n]='\0'; captured += buf; } }
 
 
 
 
 
 
 
509
  close(pipeFd[0]);
510
 
 
511
  bool safe = false;
512
  juce::String reason;
513
+ if (timedOut) reason = "Timed out after 10s";
514
+ else if (WIFEXITED(finalStatus)) { int code = WEXITSTATUS(finalStatus); safe = (code==0);
515
+ reason = safe ? "" : "Exited with code " + juce::String(code); }
516
+ else if (WIFSIGNALED(finalStatus)) reason = "Crashed: " + signalName(WTERMSIG(finalStatus));
 
 
 
 
 
 
 
 
 
 
 
517
 
518
  writeScanLog(path, safe, reason, captured);
 
 
 
519
  return safe;
520
  }
521
 
522
  // ── Background Scanner ────────────────────────────────────────────────────────
 
 
523
 
524
  void PluginBridgeProcessor::startBackgroundScan()
525
  {
 
529
  scanThread = std::thread([this]()
530
  {
531
  auto files = getInstalledPluginFiles();
 
532
  for (int i = 0; i < files.size() && scanRunning.load(); ++i)
533
  {
534
  auto path = files[i].getFullPathName();
535
+ if (safetyDB.getSafety(path) != PluginSafetyDB::Safety::Unknown) continue;
 
536
 
 
 
 
 
537
  while (helper.isHelperRunning() && scanRunning.load())
538
  std::this_thread::sleep_for(std::chrono::milliseconds(500));
 
539
  if (!scanRunning.load()) break;
540
 
541
  bool safe = runPluginScan(path);
542
  safetyDB.setSafety(path, safe ? PluginSafetyDB::Safety::Safe
543
  : PluginSafetyDB::Safety::Unsafe);
 
544
  if (onScanProgress)
 
545
  juce::MessageManager::callAsync([this, path, safe]()
546
+ { if (onScanProgress) onScanProgress(path, safe); });
 
 
 
547
  }
 
548
  scanRunning.store(false);
 
549
  });
550
  }
551
 
552
  void PluginBridgeProcessor::stopBackgroundScan()
553
  {
554
  scanRunning.store(false);
555
+ if (scanThread.joinable()) scanThread.join();
 
556
  }
557
 
558
  // ── Plugin List ───────────────────────────────────────────────────────────────
 
560
  juce::Array<juce::File> PluginBridgeProcessor::getInstalledPluginFiles() const
561
  {
562
  juce::Array<juce::File> results;
563
+ std::set<std::string> seen;
564
 
 
 
 
565
  struct DirSpec { juce::File dir; const char* pattern; };
566
  std::vector<DirSpec> specs = {
567
+ { juce::File("/Library/Audio/Plug-Ins/VST3"), "*.vst3" },
568
  { juce::File::getSpecialLocation(juce::File::userHomeDirectory)
569
+ .getChildFile("Library/Audio/Plug-Ins/VST3"), "*.vst3" },
570
+ { juce::File("/Library/Audio/Plug-Ins/Components"), "*.component" },
571
  { juce::File::getSpecialLocation(juce::File::userHomeDirectory)
572
+ .getChildFile("Library/Audio/Plug-Ins/Components"), "*.component" }
573
  };
574
 
575
  for (auto& spec : specs)
 
579
  for (auto& f : files)
580
  {
581
  auto stem = f.getFileNameWithoutExtension().toStdString();
582
+ if (seen.insert(stem).second) results.add(f);
 
583
  }
584
  }
585
 
 
587
  return results;
588
  }
589
 
590
+ bool PluginBridgeProcessor::isBlocked(const juce::String& path) const { return blocklist.isBlocked(path); }
 
 
 
591
 
592
  // ── Manufacturer name ─────────────────────────────────────────────────────────
 
593
 
 
 
 
 
594
  static juce::String cleanManufacturer(const juce::String& raw)
595
  {
596
  auto s = raw.trim();
597
  if (s.isEmpty()) return {};
 
 
598
  if (s.containsOnly("0123456789.")) return {};
599
 
600
  auto lower = s.toLowerCase();
601
  if (lower.startsWith("copyright"))
602
  {
 
603
  auto rest = s.substring(9).trim();
604
+ if (rest.startsWithIgnoreCase("(c)")) rest = rest.substring(3).trim();
 
 
 
605
  else if (rest.startsWith(juce::String::fromUTF8("\xc2\xa9")))
606
  rest = rest.substring(juce::String::fromUTF8("\xc2\xa9").length()).trim();
607
  else if (rest.startsWith(juce::CharPointer_UTF8("\xc2\xa9")))
608
  rest = rest.substring(1).trim();
 
609
  auto spacePos = rest.indexOfChar(' ');
610
+ if (spacePos > 0) rest = rest.substring(spacePos + 1).trim();
 
611
  if (rest.isNotEmpty()) return rest;
612
  return {};
613
  }
 
614
  return s;
615
  }
616
 
 
617
  static juce::String plistValue(const juce::String& text, const juce::String& key)
618
  {
619
  auto pos = text.fromFirstOccurrenceOf(key, false, true);
620
  if (pos.isEmpty()) return {};
621
  return pos.fromFirstOccurrenceOf("<string>", false, false)
622
+ .upToFirstOccurrenceOf("</string>", false, false).trim();
 
623
  }
624
 
625
  juce::String PluginBridgeProcessor::getPluginManufacturer(const juce::File& f) const
626
  {
627
  auto pathKey = f.getFullPathName().toStdString();
628
+ { std::lock_guard<std::mutex> lock(manufacturerCacheMutex);
629
+ auto it = manufacturerCache.find(pathKey);
630
+ if (it != manufacturerCache.end()) return juce::String(it->second); }
 
 
 
 
631
 
632
  juce::String mfr;
633
 
 
634
  if (f.getFileExtension().toLowerCase() == ".vst3")
635
  {
636
  auto infoPlist = f.getChildFile("Contents").getChildFile("Info.plist");
637
  if (infoPlist.existsAsFile())
638
  {
639
  auto text = infoPlist.loadFileAsString();
 
 
640
  auto copyright = plistValue(text, "NSHumanReadableCopyright");
641
  mfr = cleanManufacturer(copyright);
642
 
 
 
 
643
  if (mfr.isEmpty())
644
  {
645
  auto infoStr = plistValue(text, "CFBundleGetInfoString");
646
  if (infoStr.contains(","))
647
  {
648
  auto beforeComma = infoStr.upToFirstOccurrenceOf(",", false, false).trim();
 
649
  auto first = beforeComma.upToFirstOccurrenceOf(" ", false, false).trim();
650
+ if (first.isNotEmpty() && !first.containsOnly("0123456789.")) mfr = first;
 
651
  }
652
+ if (mfr.isEmpty()) mfr = cleanManufacturer(infoStr);
 
653
  }
654
 
 
655
  if (mfr.isEmpty())
656
  {
657
  auto bid = plistValue(text, "CFBundleIdentifier");
 
658
  auto segs = juce::StringArray::fromTokens(bid, ".", "");
659
  if (segs.size() >= 2)
660
  {
 
661
  auto slug = segs[1].trim();
662
  if (slug.isNotEmpty() && slug != "apple" && slug != "com")
663
+ { slug = slug.substring(0,1).toUpperCase() + slug.substring(1); mfr = slug; }
 
 
 
664
  }
665
  }
666
  }
667
 
 
668
  if (mfr.isEmpty())
669
  {
670
  auto moduleInfo = f.getChildFile("Contents").getChildFile("moduleinfo.json");
671
  if (moduleInfo.existsAsFile())
672
  {
673
+ try { auto j = json::parse(moduleInfo.loadFileAsString().toStdString());
674
+ auto vendor = cleanManufacturer(juce::String(j.value("vendor","")));
675
+ if (vendor.isNotEmpty()) mfr = vendor; } catch (...) {}
 
 
 
 
676
  }
677
  }
678
  }
679
 
 
 
680
  if (mfr.isEmpty())
681
  {
682
  auto name = f.getFileNameWithoutExtension();
683
  auto first = name.upToFirstOccurrenceOf(" ", false, false);
684
+ if (first.isNotEmpty() && !first.containsOnly("0123456789.")) mfr = first;
685
+ else mfr = name;
 
 
686
  }
687
 
688
  if (mfr.isEmpty()) mfr = "Other";
 
702
  if (pluginLoaded && helper.isHelperRunning())
703
  {
704
  auto response = helper.sendCommand("{\"cmd\":\"get_state\"}");
705
+ try { auto j = json::parse(response);
706
+ if (j.value("ok",false))
707
+ state.setProperty("pluginState", juce::String(j["state"].get<std::string>()), nullptr);
 
 
708
  } catch (...) {}
709
  }
710