Audio: song loudness normalization, in-process VSTs (perf), and stereo routing (#24)

* audio: flush denormals in the RT path + normalize the backing track

Two realtime-audio fixes (engine only — no change to amp/effect DSP):

1. Denormal flush (FTZ/DAZ). The signal path is full of IIR state (NAM, cab
   IRs, VST amp/EQ/comp chains); after each note that state decays toward zero
   and lands in the denormal range, where each float op is 10-100x slower. That
   produced sporadic CPU spikes -> buffer underruns heard as random "scratches"
   plus frame stutter (worse with larger buffers, independent of song/tone).
   Add a scoped juce::ScopedNoDenormals at the three RT entry points:
     - AudioEngine::audioDeviceIOCallbackWithContext (whole callback)
     - SignalChain::process (the plugin chain)
     - the sandbox worker's plugin processBlock in src/vst-host/main.cpp
       (VST3s run OUT-OF-PROCESS, so the host-side FTZ doesn't reach them)
   Denormals are sub -300 dBFS, so this is inaudible — CPU only, no tone change.

2. Backing-track loudness normalizer (BackingLeveler.h). Brings each song's
   backing to a consistent -12 LUFS so songs don't jump in level, applied in
   renderBackingBlockLocked BEFORE the mixer's backing-volume fader (so the
   fader still attenuates). Short-term BS.1770 K-weighted AGC (slow, no pumping)
   + a -1 dBFS brickwall limiter. RT-safe (no allocation in process()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: extend denormal flush to the split-output path + reuse chain MidiBuffer

Opt-1 low-risk RT tidy-ups (no DSP/tone change):
- ScopedNoDenormals in audioOutputCallback (the split-mode output clock that
  renders the backing track + phase-vocoder + leveler) — the primary callback's
  scope doesn't reach this separate output thread, leaving an IIR/decay path
  unprotected (a remaining source of the periodic "scratches").
- SignalChain::process reuses one juce::MidiBuffer across slots instead of
  copy-constructing it per slot per block (avoids RT-thread allocation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: per-slot pan + parallel branch routing (St-1 stereo, engine side)

Adds pan-only stereo to the signal chain so the node editor can place one amp
left and another right, pan effects, and let stereo plugins pass true L/R.

ProcessorSlot gains two fields:
  - pan    : -1..+1 constant-power, applied to that slot's output (0 = no-op)
  - branch : 0 = trunk (serial), >=1 = a parallel branch id

SignalChain::process keeps a bit-identical serial fast path when no slot has a
branch. When branches exist it runs the trunk-pre slots in place, snapshots that
as the split source, processes each branch on its own pre-allocated scratch
buffer, pans it, sums the branches into a merge bus, then runs any trunk-post
slots on the merged signal. Scratch is sized in prepare() (never on the RT
thread); falls back to serial for a non-stereo / oversized block.

The dual-mono amp output + post-amp pan is what yields "amp A left, amp B right"
without touching NAM or amp DSP. Preset schema emits pan/branch only when
non-default (mono presets unchanged); N-API gains setPan/setBranch and
getChainState/loadPreset round-trip them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: per-branch source channel (St-2) — feed a split L/R into separate branches

Extends the parallel-branch model so a stereo-out gear (e.g. a stereo delay) can
send its L output to one branch and its R to another. ProcessorSlot gains
branchSrc (0 = both, 1 = L, 2 = R); when seeding a branch from the split source,
L-only / R-only mono-izes that channel into the branch. Read from any slot in the
branch. N-API setBranchSrc + getChainState/preset round-trip it. Default 0 keeps
existing routing identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio-bridge: expose setPan/setBranch/setBranchSrc to the renderer

The engine N-API gained the stereo routing setters (setPan/setBranch/
setBranchSrc) but the main-process IPC handlers + the preload bridge didn't
forward them, so window.slopsmithDesktop.audio.setPan was undefined and the
node editor's stereo controls no-op'd. Wire all three through audio:setPan /
setBranch / setBranchSrc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: run scanned VSTs in-process + forward params + cut RT stalls

Big CPU/latency win for chains with VST plugins, plus the missing parameter
path. The out-of-process sandbox exists to crash-isolate the SCAN of unknown
plugins; a plugin only reaches a chain after it scanned cleanly, so paying the
per-block IPC cost (N serial round-trips, memcpy, poll waits) for every block
of playback was pure overhead.

- shouldSandbox(): default VST3 playback to IN-PROCESS. The runtime crash
  blocklist + launch sentinel still route a faulting plugin back through the
  sandbox on its next load, so it self-heals; only genuinely crash-prone gear
  keeps paying for isolation. Eliminates the IPC round-trips + the per-load
  subprocess spawn that caused the load-time "scratches".
- SignalChain::clear(): detach slots under a brief lock, destroy them OFF the
  lock. Sandbox teardown is slow; doing it under `lock` starved the RT
  ScopedTryLock and dropped audio blocks on every chain reload.
- AudioChannel::popBlock(): bounded busy-spin on the write index before the
  blocking poll() — a fast plugin's output lands within microseconds, so we
  skip the syscall + doorbell wakeup latency; a slow plugin falls through to the
  efficient wait (correctness + heavy-chain cost unchanged).
- SandboxedProcessor::setSandboxedParameter() + SignalChain::setParameter()
  route param changes to a sandboxed plugin over the control pipe (kSetParameter)
  — the JUCE getParameters() proxy layer isn't wired, so without this a
  sandboxed plugin's knobs/preset never reached it and it played at defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* audio: PR #24 review follow-ups — POSIX fault guard + routing/spin/leveler fixes

Follow-up fixes from review of PR #24.

== POSIX in-process plugin fault guard (the main one) ==
PR #24 makes scanned VSTs run in-process by default. invokePlugin()'s catch(...)
only catches a plugin fault on Windows (where /EHa maps the SEH access violation
to a C++ exception); on macOS/Linux a plugin SIGSEGV during playback took down
the whole app, breaking the fail-soft-audio + cross-platform guarantees.

Add a POSIX fault guard in SignalChain.cpp: install chained SIGSEGV/SIGBUS/
SIGFPE/SIGILL handlers; while a guarded plugin call is live on the current
thread (thread-local, initial-exec TLS so the handler stays async-signal-safe),
siglongjmp() back into invokePlugin() and take the SAME blocklist+leak+survive
path as Windows. Faults outside a guarded call chain to the previously-installed
handler (V8/ASan/default), so real crashes and sanitizers are never masked. The
guard's armed flag is restored on EVERY exit from the guarded region — normal
return, signal-fault longjmp, and a normal C++ exception from the plugin — so a
thread is never left armed with a stale landing pad. Known limit: stack-overflow
faults aren't reliably caught (no sigaltstack on JUCE audio threads).

Comments in SandboxFactory_shared.cpp updated to match the kept in-process
default (the stale 'every VST3 sandboxes' / 'diagnostic tagging only' notes).

== Smaller correctness/quality fixes ==
- SignalChain parallel path: a branch==0 (trunk) slot interleaved inside the
  branch region was run by none of the loops -> silently dropped. Detect the
  region first and fall back to a serial chain (jassertfalse in debug) so no
  slot is lost if the node-editor contiguity invariant breaks.
- AudioChannel pop busy-spin: add a cpuRelax() (_mm_pause / arm yield) hint.
- BackingLeveler: reset AGC/limiter state on loadBackingTrack so a new song
  doesn't inherit the previous track's gain follower and briefly mis-level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: integration test for the in-process plugin fault guard

Drives deliberately-faulting in-process AudioProcessors through a real
SignalChain::process() and asserts the host survives, the processor is released,
and it's added to the crash blocklist (shouldSandbox() then routes it
out-of-process). Covers BOTH fault kinds: a hardware SIGSEGV (POSIX guard /
Windows SEH) and a normal C++ exception (the path that must leave the guard
disarmed). End-to-end counterpart to the standalone mechanism check — exercises
the actual invokePlugin() guard.

Lives in the POSIX-only sandbox e2e harness (already links juce_audio_processors
+ the full sandbox set). Leak detection is disabled for the target because the
guard leaks the faulting processor by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
This commit is contained in:
Jorge Fritis
2026-06-19 23:08:05 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Jafz2001 Byron Gamatos
parent facac93659
commit 7671385ba8
15 changed files with 803 additions and 29 deletions
+306 -19
View File
@@ -1,11 +1,115 @@
#include "SignalChain.h"
#include "Sandbox/SandboxedProcessor.h"
#if ! JUCE_WINDOWS
#include <csetjmp>
#include <csignal>
#include <mutex>
#endif
namespace {
// Thrown after the POSIX fault guard longjmps back, to reuse the catch below.
struct PluginFaulted {};
#if ! JUCE_WINDOWS
// ── POSIX in-process plugin fault guard ─────────────────────────────────────
// On Windows, /EHa maps a plugin's structured exception (access violation) onto
// a C++ exception that invokePlugin()'s catch(...) handles. POSIX has no such
// mapping: a faulting in-process plugin raises SIGSEGV/SIGBUS/SIGFPE/SIGILL and,
// uncaught, kills the whole app. These handlers, *only* while a guarded plugin
// call is live on the current thread, siglongjmp() back into invokePlugin() so
// the fault takes the same handled path as Windows — blocklist + leak + survive.
//
// Scope & limits:
// • The armed flag + landing pad are thread-local with the initial-exec TLS
// model, so the handler never takes the non-async-signal-safe lazy-TLS path.
// Only the thread actually inside a plugin call is redirected.
// • A fault anywhere else (e.g. a V8/GC SIGSEGV trap on a JS thread, or a
// sanitizer's handler) is chained to the handler we replaced, so we never
// mask a real crash. Installation happens on the first plugin call, well
// after V8/Node init, so the chained handler is theirs.
// • A stack-overflow fault is not reliably caught (no sigaltstack on JUCE's
// audio threads). The common plugin crash — a bad-pointer dereference — is.
#if defined(__GNUC__) || defined(__clang__)
#define SC_TLS_IE __attribute__((tls_model("initial-exec")))
#else
#define SC_TLS_IE
#endif
thread_local SC_TLS_IE sigjmp_buf g_pluginFaultPad;
// volatile sig_atomic_t (not bool): this flag is read+written from the async
// signal handler, where only a volatile sig_atomic_t (or lock-free atomic) is
// well-defined to access. Per-thread (the signal is delivered to the faulting
// thread), initial-exec TLS so the handler never hits lazy-TLS allocation.
thread_local SC_TLS_IE volatile sig_atomic_t g_pluginGuardArmed = 0;
struct SavedSigactions { struct sigaction segv, bus, fpe, ill; };
SavedSigactions g_prevHandlers;
std::once_flag g_handlerOnce;
const struct sigaction* previousHandlerFor(int sig) noexcept
{
switch (sig)
{
case SIGSEGV: return &g_prevHandlers.segv;
case SIGBUS: return &g_prevHandlers.bus;
case SIGFPE: return &g_prevHandlers.fpe;
case SIGILL: return &g_prevHandlers.ill;
default: return nullptr;
}
}
void pluginFaultHandler(int sig, siginfo_t* info, void* ctx)
{
if (g_pluginGuardArmed)
{
g_pluginGuardArmed = 0;
siglongjmp(g_pluginFaultPad, sig); // async-signal-safe; restores mask
}
// Not inside a guarded plugin call → a genuine fault elsewhere. Chain to the
// handler we replaced rather than mask it.
if (const struct sigaction* prev = previousHandlerFor(sig))
{
if ((prev->sa_flags & SA_SIGINFO) && prev->sa_sigaction != nullptr)
{
prev->sa_sigaction(sig, info, ctx);
return;
}
if (! (prev->sa_flags & SA_SIGINFO))
{
if (prev->sa_handler == SIG_IGN) return;
if (prev->sa_handler != SIG_DFL && prev->sa_handler != nullptr)
{
prev->sa_handler(sig);
return;
}
}
}
// Default disposition: restore it and re-raise so the process crashes for real.
signal(sig, SIG_DFL);
raise(sig);
}
void installPluginFaultHandlers()
{
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = pluginFaultHandler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, &g_prevHandlers.segv);
sigaction(SIGBUS, &sa, &g_prevHandlers.bus);
sigaction(SIGFPE, &sa, &g_prevHandlers.fpe);
sigaction(SIGILL, &sa, &g_prevHandlers.ill);
}
#endif // ! JUCE_WINDOWS
// Catch a plugin fault — access violation, heap corruption, C++ exception —
// rather than let it kill the host process. /EHa on this TU makes catch(...)
// catch SEH on Windows too; on other platforms it covers C++ exceptions only.
// rather than let it kill the host process. On Windows, /EHa on this TU makes
// catch(...) catch the SEH access violation directly; on POSIX the crash arrives
// as a signal, so we arm the thread-local fault guard above to convert it into
// the same handled path.
//
// On fault: route future loads of the offending plugin through the
// out-of-process sandbox (via the runtime crash blocklist), and *leak* the
@@ -17,12 +121,44 @@ template <typename Fn>
inline void invokePlugin(ProcessorSlot& slot, Fn&& fn) noexcept
{
if (! slot.processor) return;
#if ! JUCE_WINDOWS
std::call_once(g_handlerOnce, installPluginFaultHandlers);
// Captured before the try so the catch can restore it on every exit path.
// Set before the sigsetjmp and never mutated after, so it is well-defined
// post-longjmp (the setjmp indeterminate-value rule only bites locals that
// ARE modified between setjmp and longjmp).
const sig_atomic_t wasArmed = g_pluginGuardArmed;
#endif
try
{
#if ! JUCE_WINDOWS
// Arm the POSIX crash landing pad around the plugin call. sigsetjmp(.,1)
// saves the signal mask so the crash signal is unblocked again when the
// handler longjmps back. A non-zero return means the plugin faulted;
// route it into the shared catch below (which restores the guard state).
if (sigsetjmp(g_pluginFaultPad, 1) != 0)
throw PluginFaulted{};
g_pluginGuardArmed = 1;
#endif
fn(*slot.processor);
#if ! JUCE_WINDOWS
g_pluginGuardArmed = wasArmed;
#endif
}
catch (...)
{
#if ! JUCE_WINDOWS
// Restore the guard on EVERY exit from the guarded region. A normal C++
// exception from fn() bypasses the restore above and would otherwise
// leave this thread armed with a stale landing pad — so a later unrelated
// SIGSEGV/SIGBUS/… could be misread as a plugin fault and longjmp into a
// dead frame. (The signal-fault path arrives here too, via PluginFaulted.)
g_pluginGuardArmed = wasArmed;
#endif
// Best-effort blocklist update — addCrashedPlugin allocates (juce path
// canonicalisation, StringArray.add) and locks a mutex, both of which
// can throw under OOM or corruption. Swallow any exception here so the
@@ -34,6 +170,21 @@ inline void invokePlugin(ProcessorSlot& slot, Fn&& fn) noexcept
}
}
// Constant-power pan applied to a stereo buffer in place. pan: -1 (L) .. +1 (R);
// 0 = centre = unity on both channels (so the default leaves the signal
// untouched). For the dual-mono amp output this acts as a normal pan-pot; for
// genuinely stereo content it's an equal-power balance.
inline void applyPan(juce::AudioBuffer<float>& buf, int numSamples, float pan) noexcept
{
if (pan == 0.0f || buf.getNumChannels() < 2 || numSamples <= 0) return;
pan = juce::jlimit(-1.0f, 1.0f, pan);
const float theta = (pan + 1.0f) * 0.5f * juce::MathConstants<float>::halfPi; // 0..pi/2
const float gainL = std::cos(theta) * juce::MathConstants<float>::sqrt2; // centre -> 1.0
const float gainR = std::sin(theta) * juce::MathConstants<float>::sqrt2;
buf.applyGain(0, 0, numSamples, gainL);
buf.applyGain(1, 0, numSamples, gainR);
}
} // namespace
// ── ProcessorSlot ─────────────────────────────────────────────────────────────
@@ -67,6 +218,13 @@ void SignalChain::prepare(double sampleRate, int blockSize)
currentSampleRate = sampleRate;
currentBlockSize = blockSize;
// Size the parallel-branch scratch once, off the audio thread. Stereo, the
// chain's fixed channel layout. avoidReallocating=true keeps the storage
// stable so the RT path never allocates.
splitScratch.setSize(2, blockSize, false, false, true);
branchScratch.setSize(2, blockSize, false, false, true);
accumScratch.setSize(2, blockSize, false, false, true);
const juce::ScopedLock sl(lock);
for (auto* slot : slots)
{
@@ -93,6 +251,10 @@ void SignalChain::releaseResources()
void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
{
// FTZ/DAZ for the plugin chain — IIR tails decaying to denormals here are a
// major source of sporadic CPU spikes (see AudioEngine's RT callback note).
const juce::ScopedNoDenormals noDenormals;
const juce::ScopedTryLock sl(lock);
if (!sl.isLocked()) return;
@@ -109,23 +271,107 @@ void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& mi
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex2 + i].targetSlotId,
midiRingBuffer[(size_t)scope.startIndex2 + i].msg };
for (auto* slot : slots)
// Reused across slots so the per-slot MIDI buffer isn't heap-allocated on
// the RT thread every block (it was copy-constructed per slot before).
juce::MidiBuffer slotMidi;
const int numSamples = buffer.getNumSamples();
// Process one slot in place on `buf`: build its MIDI, run it, apply its pan.
auto runSlot = [&](ProcessorSlot* slot, juce::AudioBuffer<float>& buf)
{
if (slot->processor && !slot->bypassed)
{
// Build per-slot MIDI buffer from drained messages
juce::MidiBuffer slotMidi(midi); // start with pass-through MIDI
for (int i = 0; i < numDrained; ++i)
{
if (drained[i].slotId == slot->id || drained[i].slotId == -1)
slotMidi.addEvent(drained[i].msg, 0);
}
invokePlugin(*slot, [&](juce::AudioProcessor& p)
{
p.processBlock(buffer, slotMidi);
});
}
if (!slot->processor || slot->bypassed) return;
slotMidi.clear();
slotMidi.addEvents(midi, 0, -1, 0);
for (int i = 0; i < numDrained; ++i)
if (drained[i].slotId == slot->id || drained[i].slotId == -1)
slotMidi.addEvent(drained[i].msg, 0);
invokePlugin(*slot, [&](juce::AudioProcessor& p) { p.processBlock(buf, slotMidi); });
applyPan(buf, numSamples, slot->pan);
};
// Fast path: no parallel branch → plain serial chain. Behaviour is unchanged
// vs before (pan defaults to 0, so applyPan is a no-op and existing tones are
// bit-identical).
bool hasBranch = false;
for (auto* slot : slots) if (slot->branch != 0) { hasBranch = true; break; }
if (!hasBranch)
{
for (auto* slot : slots) runSlot(slot, buffer);
return;
}
// Parallel path. Slot order (the node editor guarantees it):
// [ trunk-pre (branch 0) ][ parallel branches (branch >=1) ][ trunk-post (branch 0) ]
// Trunk-pre runs in place and its result is the split source every branch
// reads; each branch runs on its own copy, is panned, and summed into the
// merge bus; trunk-post then runs on the merged signal. Stereo scratch only —
// fall back to serial for a non-stereo or oversized block (can't pan/sum).
if (buffer.getNumChannels() < 2 || numSamples > currentBlockSize)
{
for (auto* slot : slots) runSlot(slot, buffer);
return;
}
// Locate the parallel region first, without processing anything yet (this
// only reads slot->branch): trunk-pre is the leading run of branch==0 slots
// [0,idx); the branch region is [idx,regionEnd); trunk-post is the rest.
int idx = 0;
while (idx < slots.size() && slots[idx]->branch == 0) ++idx;
int regionEnd = idx, maxBranch = 0; // end just past last branch slot
for (int k = idx; k < slots.size(); ++k)
if (slots[k]->branch != 0) { regionEnd = k + 1; maxBranch = juce::jmax(maxBranch, slots[k]->branch); }
// Well-formedness guard: the node editor lays branches out contiguously, so
// every slot in [idx,regionEnd) must belong to a branch. A stray branch==0
// (trunk) slot interleaved here would be run by none of the loops below —
// silent signal loss. If that invariant is ever violated, fall back to a
// plain serial chain so no slot is dropped. Nothing has been processed in
// place yet, so the fallback is exact.
for (int k = idx; k < regionEnd; ++k)
if (slots[k]->branch == 0)
{
jassertfalse; // malformed branch layout — see comment above
for (auto* slot : slots) runSlot(slot, buffer);
return;
}
// Match the scratch length to this block (no realloc: capacity == blockSize).
branchScratch.setSize(2, numSamples, false, false, true);
for (int k = 0; k < idx; ++k) runSlot(slots[k], buffer); // trunk-pre, in place
for (int ch = 0; ch < 2; ++ch) splitScratch.copyFrom(ch, 0, buffer, ch, 0, numSamples);
accumScratch.clear(0, numSamples);
for (int b = 1; b <= maxBranch; ++b)
{
// Which channel of the split source this branch reads (St-2): 0 = stereo,
// 1 = L only (→ both), 2 = R only (→ both). From the first slot that sets
// it; lets a stereo-out gear feed its L to one branch and R to another.
int bSrc = 0;
for (int k = idx; k < regionEnd; ++k)
if (slots[k]->branch == b && slots[k]->branchSrc != 0) { bSrc = slots[k]->branchSrc; break; }
const int srcL = (bSrc == 2) ? 1 : 0;
const int srcR = (bSrc == 1) ? 0 : 1;
bool any = false;
for (int k = idx; k < regionEnd; ++k)
{
if (slots[k]->branch != b) continue;
if (!any)
{
branchScratch.copyFrom(0, 0, splitScratch, srcL, 0, numSamples);
branchScratch.copyFrom(1, 0, splitScratch, srcR, 0, numSamples);
any = true;
}
runSlot(slots[k], branchScratch);
}
if (any)
for (int ch = 0; ch < 2; ++ch) accumScratch.addFrom(ch, 0, branchScratch, ch, 0, numSamples);
}
for (int ch = 0; ch < 2; ++ch) buffer.copyFrom(ch, 0, accumScratch, ch, 0, numSamples);
for (int k = regionEnd; k < slots.size(); ++k) // trunk-post on the merged bus
runSlot(slots[k], buffer);
}
void SignalChain::queueMidiMessage(int targetSlotId, const juce::MidiMessage& msg)
@@ -202,10 +448,36 @@ void SignalChain::setMultiBypass(const juce::Array<std::pair<int, bool>>& change
}
}
void SignalChain::clear()
void SignalChain::setPan(int slotId, float pan)
{
const juce::ScopedLock sl(lock);
slots.clear();
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->pan = juce::jlimit(-1.0f, 1.0f, pan);
}
void SignalChain::setBranch(int slotId, int branch)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->branch = juce::jmax(0, branch);
}
void SignalChain::setBranchSrc(int slotId, int src)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->branchSrc = juce::jlimit(0, 2, src);
}
void SignalChain::clear()
{
// Detach the slots under a BRIEF lock, then destroy them OFF the lock. The
// destructors tear down sandbox subprocesses (IPC + waits) which is slow;
// doing that while holding `lock` starved the RT process() ScopedTryLock and
// dropped audio blocks → the "scratches" heard whenever a chain reloads.
juce::OwnedArray<ProcessorSlot> dead;
{ const juce::ScopedLock sl(lock); slots.swapWith(dead); }
dead.clear();
}
int SignalChain::getNumSlots() const
@@ -263,6 +535,15 @@ void SignalChain::setParameter(int slotId, int paramIndex, float value)
auto* proc = slots[idx]->processor.get();
if (!proc) return;
// Out-of-process VSTs expose no JUCE parameter proxies, so forward the change
// over the control pipe — otherwise knob/preset automation never reaches the
// sandboxed plugin and tones play at their defaults.
if (auto* sp = dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(proc))
{
sp->setSandboxedParameter(paramIndex, value);
return;
}
auto& params = proc->getParameters();
if (paramIndex >= 0 && paramIndex < params.size())
params[paramIndex]->setValue(value);
@@ -295,6 +576,12 @@ juce::String SignalChain::savePreset() const
slotObj->setProperty("path", slot->path);
slotObj->setProperty("bypassed", slot->bypassed);
// Stereo routing (St-1) — only emitted when non-default so existing mono
// presets are byte-for-byte unchanged.
if (slot->pan != 0.0f) slotObj->setProperty("pan", slot->pan);
if (slot->branch != 0) slotObj->setProperty("branch", slot->branch);
if (slot->branchSrc != 0) slotObj->setProperty("branchSrc", slot->branchSrc);
// Save processor state as base64
auto state = slot->getState();
if (state.getSize() > 0)