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
+32
View File
@@ -1301,6 +1301,14 @@ bool AudioEngine::loadBackingTrack(const juce::File& file)
cachedBackingDuration.store(backingTransport->getLengthInSeconds());
cachedBackingPosition.store(0.0);
backingHeardPositionSec.store(0.0, std::memory_order_relaxed);
// Reset the loudness leveler for the new song: clearing the cached sample
// rate forces renderBackingBlockLocked() to re-prepare() it on the next
// block, dropping the previous track's AGC gain + limiter state. Otherwise
// the ~300 ms gain follower would carry over and briefly mis-level the start
// of a much louder/quieter next song. Safe here — loadBackingTrack holds
// backingLock, the same lock the render path runs under.
backingLevelerSr = 0.0;
std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate
<< " len=" << readerLengthInSamples
<< std::endl;
@@ -1678,6 +1686,17 @@ int AudioEngine::renderBackingBlockLocked(int numSamples)
if (!backingTransport->isPlaying())
backingPlaying.store(false);
// Normalize the backing track to a consistent target loudness (-12 LUFS)
// BEFORE the mixer's backing-volume fader is applied (later in the RT
// callback), so every song sits at the same level while the fader still
// attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall
// limiter to keep boosted peaks safe. RT-safe (no allocation).
if (outSamples > 0 && sr > 0.0)
{
if (sr != backingLevelerSr) { backingLeveler.prepare(sr); backingLevelerSr = sr; }
backingLeveler.process(backingBuffer, outSamples, -12.0f);
}
return outSamples;
}
@@ -1861,6 +1880,13 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
float* const* outputData, int numOutputChannels,
int numSamples, const juce::AudioIODeviceCallbackContext&)
{
// Flush denormals (FTZ/DAZ) for the ENTIRE realtime callback. The chain is
// full of IIR state (NAM, cab IRs, VST amps/EQ/comp); after each note that
// state decays toward zero and lands in the denormal range, where every op
// is 10-100× slower — producing sporadic CPU spikes → buffer underruns heard
// as random "scratches" + frame stutter. Scoped so it only affects this path.
const juce::ScopedNoDenormals noDenormals;
// Publish that the callback body is executing so removeSource() and deferred-
// release reclamation know when no source is being processed (the body is
// quiescent) and a removed source can be safely released. Index 0 = primary.
@@ -2437,6 +2463,12 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
int numOutputChannels,
int numSamples)
{
// Split-mode output clock: this callback renders the backing track (phase
// vocoder + loudness leveler) and mixes it with the chain output. Those
// carry IIR/decay state too, so flush denormals here as well — the primary
// callback's ScopedNoDenormals does NOT reach this separate output thread.
const juce::ScopedNoDenormals noDenormals;
juce::AudioBuffer<float> buffer(outputData, numOutputChannels, numSamples);
if (numOutputChannels <= 0)
return;
+5
View File
@@ -1,5 +1,6 @@
#pragma once
#include "SourceChain.h"
#include "BackingLeveler.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
@@ -406,6 +407,10 @@ private:
// Master output (post-mix) — engine-global, not per-source.
std::atomic<float> outputGain{1.0f};
std::atomic<float> backingVolume{0.7f};
// Per-song loudness normalizer for the backing track (applied in
// renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread.
BackingLeveler backingLeveler;
double backingLevelerSr = 0.0;
std::atomic<float> currentOutputLevel{0.0f};
// Per-block RMS of the backing-track mix bus, written by the audio thread
// and read on the main/JS thread via getBackingLevel(). Computed after the
+113
View File
@@ -0,0 +1,113 @@
#pragma once
#include <juce_audio_basics/juce_audio_basics.h>
#include <cmath>
// ── Backing-track loudness normalizer ───────────────────────────────────────
// Brings the SONG's backing track to a target loudness (default -12 LUFS) so
// every song sits at the same level, BEFORE the mixer's backing-volume fader
// (so lowering that fader still lowers it). Short-term BS.1770 K-weighted AGC
// (slow, no pumping) + a brickwall limiter to keep boosted peaks safe.
// RT-safe: no allocation in process(). Standard K-weighting here (full-mix
// music) — unlike the per-tone leveler which is flattened for bass fidelity.
class BackingLeveler
{
public:
void prepare(double sampleRate)
{
sr = (sampleRate > 0.0) ? sampleRate : 48000.0;
designKWeighting(sr);
msEnv = 0.0;
currentGainDb = 0.0;
limGain = 1.0f;
for (int ch = 0; ch < 2; ++ch) { kPre[ch].reset(); kRlb[ch].reset(); }
}
// Normalize `buf` (first `numSamples`) in place toward `targetLufs`.
void process(juce::AudioBuffer<float>& buf, int numSamples, float targetLufs)
{
const int nc = juce::jmin(2, buf.getNumChannels());
if (nc <= 0 || numSamples <= 0) return;
// Short-term (~400 ms) K-weighted mean-square, integrated per sample.
const double rmsCoef = 1.0 - std::exp(-1.0 / (0.400 * sr));
for (int i = 0; i < numSamples; ++i)
{
double sq = 0.0;
for (int ch = 0; ch < nc; ++ch)
{
const double w = kRlb[ch].process(kPre[ch].process((double) buf.getReadPointer(ch)[i]));
sq += w * w;
}
sq /= (double) nc;
msEnv += rmsCoef * (sq - msEnv);
}
const double lufs = (msEnv > 1e-12) ? (-0.691 + 10.0 * std::log10(msEnv)) : -120.0;
const bool hasSignal = lufs > -50.0; // gate: don't lift silence/noise
double wantedDb = currentGainDb;
if (hasSignal)
wantedDb = juce::jlimit(-24.0, 24.0, (double) targetLufs - lufs);
// Slow gain follower (~300 ms) so it normalizes loudness without pumping.
const double smCoef = 1.0 - std::exp(-(double) numSamples / (0.300 * sr));
currentGainDb += (wantedDb - currentGainDb) * juce::jlimit(0.0, 1.0, smCoef);
const float g = (float) juce::Decibels::decibelsToGain(currentGainDb);
// Brickwall limiter (-1 dBFS ceiling): instant attack, ~100 ms release.
const float ceil = juce::Decibels::decibelsToGain(-1.0f);
const float relCoef = 1.0f - std::exp(-1.0f / (0.100f * (float) sr));
for (int i = 0; i < numSamples; ++i)
{
float pk = 0.0f;
for (int ch = 0; ch < nc; ++ch)
pk = juce::jmax(pk, std::abs(buf.getReadPointer(ch)[i]) * g);
const float need = (pk > ceil && pk > 0.0f) ? (ceil / pk) : 1.0f;
if (need < limGain) limGain = need;
else limGain += relCoef * (need - limGain);
const float tot = g * limGain;
for (int ch = 0; ch < nc; ++ch)
buf.getWritePointer(ch)[i] *= tot;
}
}
private:
struct Biquad {
double b0 = 1, b1 = 0, b2 = 0, a1 = 0, a2 = 0, z1 = 0, z2 = 0;
void reset() { z1 = z2 = 0; }
inline double process(double x) {
const double y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
return y;
}
};
void designKWeighting(double fs)
{
{ // Stage 1 — +4 dB high-shelf (standard BS.1770)
const double f0 = 1681.974450955533, G = 3.999843853973347, Q = 0.7071752369554196;
const double K = std::tan(juce::MathConstants<double>::pi * f0 / fs);
const double Vh = std::pow(10.0, G / 20.0), Vb = std::pow(Vh, 0.4996667741545416);
const double a0 = 1.0 + K / Q + K * K;
Biquad b;
b.b0 = (Vh + Vb * K / Q + K * K) / a0;
b.b1 = 2.0 * (K * K - Vh) / a0;
b.b2 = (Vh - Vb * K / Q + K * K) / a0;
b.a1 = 2.0 * (K * K - 1.0) / a0;
b.a2 = (1.0 - K / Q + K * K) / a0;
kPre[0] = b; kPre[1] = b;
}
{ // Stage 2 — RLB high-pass at 38 Hz (standard BS.1770)
const double f0 = 38.13547087602444, Q = 0.5003270373238773;
const double K = std::tan(juce::MathConstants<double>::pi * f0 / fs);
const double a0 = 1.0 + K / Q + K * K;
Biquad b;
b.b0 = 1.0; b.b1 = -2.0; b.b2 = 1.0;
b.a1 = 2.0 * (K * K - 1.0) / a0;
b.a2 = (1.0 - K / Q + K * K) / a0;
kRlb[0] = b; kRlb[1] = b;
}
}
double sr = 48000.0, msEnv = 0.0, currentGainDb = 0.0;
float limGain = 1.0f;
Biquad kPre[2], kRlb[2];
};
+56
View File
@@ -2385,6 +2385,44 @@ static Napi::Value ClearChain(const Napi::CallbackInfo& info)
return info.Env().Undefined();
}
// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1).
static Napi::Value SetPan(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2)
{
int slotId = info[0].As<Napi::Number>().Int32Value();
float pan = (float) info[1].As<Napi::Number>().DoubleValue();
liveEngine->getSignalChain().setPan(slotId, pan);
}
return info.Env().Undefined();
}
static Napi::Value SetBranch(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2)
{
int slotId = info[0].As<Napi::Number>().Int32Value();
int branch = info[1].As<Napi::Number>().Int32Value();
liveEngine->getSignalChain().setBranch(slotId, branch);
}
return info.Env().Undefined();
}
// setBranchSrc(slotId, 0=both/1=L/2=R): channel a branch reads from the split.
static Napi::Value SetBranchSrc(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2)
{
int slotId = info[0].As<Napi::Number>().Int32Value();
int src = info[1].As<Napi::Number>().Int32Value();
liveEngine->getSignalChain().setBranchSrc(slotId, src);
}
return info.Env().Undefined();
}
// ── Chain State ───────────────────────────────────────────────────────────────
static Napi::Value GetChainState(const Napi::CallbackInfo& info)
@@ -2404,6 +2442,9 @@ static Napi::Value GetChainState(const Napi::CallbackInfo& info)
obj.Set("name", slots[i]->name.toStdString());
obj.Set("path", slots[i]->path.toStdString());
obj.Set("bypassed", slots[i]->bypassed);
obj.Set("pan", slots[i]->pan);
obj.Set("branch", slots[i]->branch);
obj.Set("branchSrc", slots[i]->branchSrc);
obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor());
result.Set((uint32_t)i, obj);
}
@@ -2871,6 +2912,18 @@ public:
if (bypassed && slotId >= 0)
liveEngine->getSignalChain().setBypass(slotId, true);
// Stereo routing (St-1). Absent keys read back as 0 (= default), so
// mono presets restore exactly as before.
if (slotId >= 0)
{
if (slotObj->hasProperty("pan"))
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
if (slotObj->hasProperty("branch"))
liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch"));
if (slotObj->hasProperty("branchSrc"))
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc"));
}
// Restore processor state
if (stateB64.isNotEmpty() && slotId >= 0)
{
@@ -3110,6 +3163,9 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
exports.Set("removeProcessor", Napi::Function::New(env, RemoveProcessor));
exports.Set("moveProcessor", Napi::Function::New(env, MoveProcessor));
exports.Set("setBypass", Napi::Function::New(env, SetBypass));
exports.Set("setPan", Napi::Function::New(env, SetPan));
exports.Set("setBranch", Napi::Function::New(env, SetBranch));
exports.Set("setBranchSrc", Napi::Function::New(env, SetBranchSrc));
exports.Set("clearChain", Napi::Function::New(env, ClearChain));
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor));
+29 -1
View File
@@ -18,9 +18,23 @@
#include <atomic>
#include <cstring>
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#include <emmintrin.h> // _mm_pause for the cpuRelax() spin hint below
#endif
namespace slopsmith::sandbox {
// CPU "relax" hint for short bounded spins: yields the pipeline to a
// hyper-threaded sibling and lowers power vs. a bare load loop. No effect on
// correctness — purely a spin-politeness hint, no-op where unavailable.
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
static inline void cpuRelax() noexcept { _mm_pause(); }
#elif defined(__aarch64__) || defined(__arm__)
static inline void cpuRelax() noexcept { __asm__ __volatile__("yield" ::: "memory"); }
#else
static inline void cpuRelax() noexcept {}
#endif
AudioChannel::AudioChannel() : impl(std::make_unique<Impl>()) {}
AudioChannel::~AudioChannel() { close(); }
@@ -158,7 +172,21 @@ bool AudioChannel::popBlock(bool isOutputRing, juce::AudioBuffer<float>& dst,
uint64_t w = writeIdx.load(std::memory_order_acquire);
if (w == r)
{
if (!impl->waitEvent(isOutputRing, timeoutMs))
// Bounded busy-spin before the blocking wait: a fast plugin lands its
// output a few microseconds after we checked, so spinning on the write
// index catches the common case without paying the poll() syscall + the
// cross-process doorbell wakeup latency — which, multiplied across an
// N-plugin chain, is a big slice of the per-block budget. A slow plugin
// exits the (short) spin still empty and falls through to the efficient
// blocking wait, so correctness and CPU cost for heavy chains are unchanged.
constexpr int kPopSpinIters = 2000;
for (int s = 0; s < kPopSpinIters; ++s)
{
w = writeIdx.load(std::memory_order_acquire);
if (w != r) break;
cpuRelax(); // don't starve the HT sibling / burn power while spinning
}
if (w == r && !impl->waitEvent(isOutputRing, timeoutMs))
{
atomicAt(impl->header->dropouts).fetch_add(1, std::memory_order_relaxed);
return false;
+25 -9
View File
@@ -37,11 +37,17 @@ juce::StringArray g_crashedPlugins;
} // anonymous
// Routing policy: every VST3 plugin loads via the out-of-process sandbox.
// Non-VST3 processors (NAM, IR) stay in-process. (See the long rationale in the
// git history / docs: plugins assume the host's message thread is the OS main
// thread with STA COM — which the sandbox child provides and Electron's
// background JUCE thread does not.)
// Routing policy: by default VST3 plugins now load IN-PROCESS for playback (see
// the rationale on the default return at the bottom of this function); only
// previously-crashed or pre-seeded plugins are forced through the out-of-process
// sandbox. Non-VST3 processors (NAM, IR) always stay in-process.
//
// In-process faults are made non-fatal by the guard in SignalChain.cpp (SEH on
// Windows, a siglongjmp signal guard on POSIX): a faulting plugin is blocklisted
// so its next load routes here to the sandbox. The sandbox child also provides
// an OS-main-thread / STA-COM host that a few plugins assume and that Electron's
// background JUCE thread does not — another reason a misbehaving plugin can be
// pinned back to it via the blocklist.
bool shouldSandbox(const juce::PluginDescription& desc)
{
const auto path = juce::File(desc.fileOrIdentifier);
@@ -50,7 +56,8 @@ bool shouldSandbox(const juce::PluginDescription& desc)
if (!path.getFileName().endsWithIgnoreCase(".vst3"))
return false;
// Runtime crash blocklist — diagnostic tagging only under sandbox-by-default.
// Runtime crash blocklist: a plugin that previously faulted in-process is
// forced back to the out-of-process sandbox on every subsequent load.
{
const std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
const auto canonical = path.getFullPathName();
@@ -62,7 +69,7 @@ bool shouldSandbox(const juce::PluginDescription& desc)
}
}
// Pre-seed filename match — diagnostic tagging only.
// Pre-seed match: plugins known to need isolation are forced to the sandbox.
const auto basename = path.getFileNameWithoutExtension();
for (auto& needle : kDefaultNeedsSandboxFilenames)
{
@@ -74,9 +81,18 @@ bool shouldSandbox(const juce::PluginDescription& desc)
}
}
VST_TRACE("shouldSandbox: %s — default policy (every VST3 sandboxes)",
// Default: load in-process for PLAYBACK. A plugin reaches a chain only after
// it scanned cleanly (the sandbox's real job is crash-isolating the SCAN of
// unknown plugins), so the common case is known-good and the out-of-process
// IPC (N serial round-trips/block, memcpy, poll waits) is pure overhead and
// latency. Anything that DOES fault is caught by the SignalChain fault guard
// (SEH on Windows, a siglongjmp signal guard on POSIX) and added to the
// runtime crash blocklist (or the launch sentinel) above, so it falls back to
// the sandbox on its next load. Net: known-good gear runs at native cost;
// only the genuinely crash-prone keeps paying for isolation.
VST_TRACE("shouldSandbox: %s — default policy: in-process (scanned/known-good)",
desc.fileOrIdentifier.toRawUTF8());
return true;
return false;
}
std::unique_ptr<juce::AudioProcessor> tryLoadSandboxed(
+9
View File
@@ -413,6 +413,15 @@ void SandboxedProcessor::requestCloseEditor()
control->postNoReply(op::kCloseEditor, {});
}
void SandboxedProcessor::setSandboxedParameter(int index, float value)
{
if (!control || !isAlive() || index < 0) return;
juce::DynamicObject::Ptr args = new juce::DynamicObject();
args->setProperty("index", index);
args->setProperty("value", (double) value);
control->postNoReply(op::kSetParameter, juce::var(args.get()));
}
bool SandboxedProcessor::isAlive() const noexcept
{
return alive.load(std::memory_order_acquire);
+3
View File
@@ -65,6 +65,9 @@ public:
// this and inserts silence rather than blocking.
bool isAlive() const noexcept;
// Forward a parameter change to the sandboxed plugin over the control pipe.
void setSandboxedParameter(int index, float value);
// Callback fired when the subprocess unexpectedly exits or its control
// pipe breaks. Always invoked from a background thread; mutex-guarded
// so concurrent assignment from the owner thread + read from the I/O
+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)
+27
View File
@@ -16,6 +16,21 @@ struct ProcessorSlot
bool bypassed = false;
int id = 0;
// Stereo routing (pan-only stereo, St-1).
// pan : -1 = full left … 0 = centre … +1 = full right (constant-power),
// applied to this slot's output. 0 (default) = no-op → unchanged.
// branch : 0 = trunk (serial). >=1 = a parallel branch id. Slots sharing a
// branch id form one parallel path; all branches read the same
// pre-split signal and their (panned) outputs are summed at merge.
// With no slot in a branch the chain runs exactly as before.
// branchSrc : which channel of the split source this branch reads — 0 =
// both (stereo), 1 = Left only, 2 = Right only. Lets a stereo-out
// gear feed its L to one branch and R to another (St-2). Read from
// any slot in the branch; 0 = full stereo (default).
float pan = 0.0f;
int branch = 0;
int branchSrc = 0;
// For VST plugins — their state as base64 for preset save/load
juce::MemoryBlock getState() const;
void setState(const juce::MemoryBlock& state);
@@ -40,6 +55,11 @@ public:
void moveProcessor(int fromIndex, int toIndex);
void setBypass(int slotId, bool bypassed);
void setMultiBypass(const juce::Array<std::pair<int, bool>>& changes);
// Stereo routing (St-1/St-2). pan: -1..+1. branch: 0 = trunk, >=1 = parallel
// id. branchSrc: 0 = both, 1 = L, 2 = R (channel the branch reads from split).
void setPan(int slotId, float pan);
void setBranch(int slotId, int branch);
void setBranchSrc(int slotId, int src);
void clear();
// Info
@@ -81,6 +101,13 @@ private:
double currentSampleRate = 48000.0;
int currentBlockSize = 256;
// Pre-allocated scratch for parallel-branch mixing (St-1). Sized in
// prepare(); never resized on the audio thread. Only touched when the chain
// actually has a parallel branch — the all-trunk path uses none of these.
juce::AudioBuffer<float> splitScratch; // snapshot of the pre-split signal
juce::AudioBuffer<float> branchScratch; // working buffer for one branch
juce::AudioBuffer<float> accumScratch; // summed branch outputs (merge bus)
// Lock-free SPSC MIDI queue (N-API thread writes, audio thread reads)
struct PendingMidiMessage { int targetSlotId = -1; juce::MidiMessage msg; };
static constexpr int kMidiQueueSize = 64;
+11
View File
@@ -1004,6 +1004,17 @@ export function initAudioBridge(): void {
audio?.setBypass(slotId, bypassed);
});
// Stereo routing (St-1/St-2).
ipcMain.handle('audio:setPan', (_event, slotId: number, pan: number) => {
audio?.setPan?.(slotId, pan);
});
ipcMain.handle('audio:setBranch', (_event, slotId: number, branch: number) => {
audio?.setBranch?.(slotId, branch);
});
ipcMain.handle('audio:setBranchSrc', (_event, slotId: number, src: number) => {
audio?.setBranchSrc?.(slotId, src);
});
ipcMain.handle('audio:clearChain', () => {
audio?.clearChain();
vstSlotPaths.clear();
+4
View File
@@ -366,6 +366,10 @@ const slopsmithDesktopApi = {
removeProcessor: (slotId: number) => ipcRenderer.invoke('audio:removeProcessor', slotId),
moveProcessor: (from: number, to: number) => ipcRenderer.invoke('audio:moveProcessor', from, to),
setBypass: (slotId: number, bypassed: boolean) => ipcRenderer.invoke('audio:setBypass', slotId, bypassed),
// Stereo routing (St-1/St-2): per-slot pan + parallel branch + L/R source.
setPan: (slotId: number, pan: number) => ipcRenderer.invoke('audio:setPan', slotId, pan),
setBranch: (slotId: number, branch: number) => ipcRenderer.invoke('audio:setBranch', slotId, branch),
setBranchSrc: (slotId: number, src: number) => ipcRenderer.invoke('audio:setBranchSrc', slotId, src),
clearChain: () => ipcRenderer.invoke('audio:clearChain'),
getChainState: () => ipcRenderer.invoke('audio:getChainState'),
+7
View File
@@ -671,7 +671,14 @@ void runAudioThread(HostState& st)
continue;
}
if (auto* p = st.plugin.get())
{
// VST3s run OUT-OF-PROCESS here, so the host engine's RT FTZ/DAZ does
// NOT reach this DSP — flush denormals in the sandbox worker too, or
// amp/EQ/comp IIR tails spike CPU and stutter the audio. (Key fix for
// the user's all-VST chains.)
const juce::ScopedNoDenormals noDenormals;
p->processBlock(buffer, midi);
}
st.audio.pushBlock(/*isOutputRing=*/true, buffer, currentBlockSize);
}
// Defensive: any control-thread AudioPauseGuard waiting on us at the time