mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-12 11:49:27 +00:00
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:
co-authored by
Claude Opus 4.8
Jafz2001
Byron Gamatos
parent
facac93659
commit
7671385ba8
@@ -1301,6 +1301,14 @@ bool AudioEngine::loadBackingTrack(const juce::File& file)
|
|||||||
cachedBackingDuration.store(backingTransport->getLengthInSeconds());
|
cachedBackingDuration.store(backingTransport->getLengthInSeconds());
|
||||||
cachedBackingPosition.store(0.0);
|
cachedBackingPosition.store(0.0);
|
||||||
backingHeardPositionSec.store(0.0, std::memory_order_relaxed);
|
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
|
std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate
|
||||||
<< " len=" << readerLengthInSamples
|
<< " len=" << readerLengthInSamples
|
||||||
<< std::endl;
|
<< std::endl;
|
||||||
@@ -1678,6 +1686,17 @@ int AudioEngine::renderBackingBlockLocked(int numSamples)
|
|||||||
if (!backingTransport->isPlaying())
|
if (!backingTransport->isPlaying())
|
||||||
backingPlaying.store(false);
|
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;
|
return outSamples;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1861,6 +1880,13 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
|
|||||||
float* const* outputData, int numOutputChannels,
|
float* const* outputData, int numOutputChannels,
|
||||||
int numSamples, const juce::AudioIODeviceCallbackContext&)
|
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-
|
// Publish that the callback body is executing so removeSource() and deferred-
|
||||||
// release reclamation know when no source is being processed (the body is
|
// release reclamation know when no source is being processed (the body is
|
||||||
// quiescent) and a removed source can be safely released. Index 0 = primary.
|
// 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 numOutputChannels,
|
||||||
int numSamples)
|
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);
|
juce::AudioBuffer<float> buffer(outputData, numOutputChannels, numSamples);
|
||||||
if (numOutputChannels <= 0)
|
if (numOutputChannels <= 0)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "SourceChain.h"
|
#include "SourceChain.h"
|
||||||
|
#include "BackingLeveler.h"
|
||||||
#include "signalsmith-stretch.h"
|
#include "signalsmith-stretch.h"
|
||||||
#include <juce_audio_devices/juce_audio_devices.h>
|
#include <juce_audio_devices/juce_audio_devices.h>
|
||||||
#include <juce_audio_formats/juce_audio_formats.h>
|
#include <juce_audio_formats/juce_audio_formats.h>
|
||||||
@@ -406,6 +407,10 @@ private:
|
|||||||
// Master output (post-mix) — engine-global, not per-source.
|
// Master output (post-mix) — engine-global, not per-source.
|
||||||
std::atomic<float> outputGain{1.0f};
|
std::atomic<float> outputGain{1.0f};
|
||||||
std::atomic<float> backingVolume{0.7f};
|
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};
|
std::atomic<float> currentOutputLevel{0.0f};
|
||||||
// Per-block RMS of the backing-track mix bus, written by the audio thread
|
// 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
|
// and read on the main/JS thread via getBackingLevel(). Computed after the
|
||||||
|
|||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -2385,6 +2385,44 @@ static Napi::Value ClearChain(const Napi::CallbackInfo& info)
|
|||||||
return info.Env().Undefined();
|
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 ───────────────────────────────────────────────────────────────
|
// ── Chain State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static Napi::Value GetChainState(const Napi::CallbackInfo& info)
|
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("name", slots[i]->name.toStdString());
|
||||||
obj.Set("path", slots[i]->path.toStdString());
|
obj.Set("path", slots[i]->path.toStdString());
|
||||||
obj.Set("bypassed", slots[i]->bypassed);
|
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());
|
obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor());
|
||||||
result.Set((uint32_t)i, obj);
|
result.Set((uint32_t)i, obj);
|
||||||
}
|
}
|
||||||
@@ -2871,6 +2912,18 @@ public:
|
|||||||
if (bypassed && slotId >= 0)
|
if (bypassed && slotId >= 0)
|
||||||
liveEngine->getSignalChain().setBypass(slotId, true);
|
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
|
// Restore processor state
|
||||||
if (stateB64.isNotEmpty() && slotId >= 0)
|
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("removeProcessor", Napi::Function::New(env, RemoveProcessor));
|
||||||
exports.Set("moveProcessor", Napi::Function::New(env, MoveProcessor));
|
exports.Set("moveProcessor", Napi::Function::New(env, MoveProcessor));
|
||||||
exports.Set("setBypass", Napi::Function::New(env, SetBypass));
|
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("clearChain", Napi::Function::New(env, ClearChain));
|
||||||
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
|
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
|
||||||
exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor));
|
exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor));
|
||||||
|
|||||||
@@ -18,9 +18,23 @@
|
|||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstring>
|
#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 {
|
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() : impl(std::make_unique<Impl>()) {}
|
||||||
|
|
||||||
AudioChannel::~AudioChannel() { close(); }
|
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);
|
uint64_t w = writeIdx.load(std::memory_order_acquire);
|
||||||
if (w == r)
|
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);
|
atomicAt(impl->header->dropouts).fetch_add(1, std::memory_order_relaxed);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ juce::StringArray g_crashedPlugins;
|
|||||||
|
|
||||||
} // anonymous
|
} // anonymous
|
||||||
|
|
||||||
// Routing policy: every VST3 plugin loads via the out-of-process sandbox.
|
// Routing policy: by default VST3 plugins now load IN-PROCESS for playback (see
|
||||||
// Non-VST3 processors (NAM, IR) stay in-process. (See the long rationale in the
|
// the rationale on the default return at the bottom of this function); only
|
||||||
// git history / docs: plugins assume the host's message thread is the OS main
|
// previously-crashed or pre-seeded plugins are forced through the out-of-process
|
||||||
// thread with STA COM — which the sandbox child provides and Electron's
|
// sandbox. Non-VST3 processors (NAM, IR) always stay in-process.
|
||||||
// background JUCE thread does not.)
|
//
|
||||||
|
// 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)
|
bool shouldSandbox(const juce::PluginDescription& desc)
|
||||||
{
|
{
|
||||||
const auto path = juce::File(desc.fileOrIdentifier);
|
const auto path = juce::File(desc.fileOrIdentifier);
|
||||||
@@ -50,7 +56,8 @@ bool shouldSandbox(const juce::PluginDescription& desc)
|
|||||||
if (!path.getFileName().endsWithIgnoreCase(".vst3"))
|
if (!path.getFileName().endsWithIgnoreCase(".vst3"))
|
||||||
return false;
|
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 std::lock_guard<std::mutex> lock(g_crashedPluginsMutex);
|
||||||
const auto canonical = path.getFullPathName();
|
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();
|
const auto basename = path.getFileNameWithoutExtension();
|
||||||
for (auto& needle : kDefaultNeedsSandboxFilenames)
|
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());
|
desc.fileOrIdentifier.toRawUTF8());
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<juce::AudioProcessor> tryLoadSandboxed(
|
std::unique_ptr<juce::AudioProcessor> tryLoadSandboxed(
|
||||||
|
|||||||
@@ -413,6 +413,15 @@ void SandboxedProcessor::requestCloseEditor()
|
|||||||
control->postNoReply(op::kCloseEditor, {});
|
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
|
bool SandboxedProcessor::isAlive() const noexcept
|
||||||
{
|
{
|
||||||
return alive.load(std::memory_order_acquire);
|
return alive.load(std::memory_order_acquire);
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ public:
|
|||||||
// this and inserts silence rather than blocking.
|
// this and inserts silence rather than blocking.
|
||||||
bool isAlive() const noexcept;
|
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
|
// Callback fired when the subprocess unexpectedly exits or its control
|
||||||
// pipe breaks. Always invoked from a background thread; mutex-guarded
|
// pipe breaks. Always invoked from a background thread; mutex-guarded
|
||||||
// so concurrent assignment from the owner thread + read from the I/O
|
// so concurrent assignment from the owner thread + read from the I/O
|
||||||
|
|||||||
+306
-19
@@ -1,11 +1,115 @@
|
|||||||
#include "SignalChain.h"
|
#include "SignalChain.h"
|
||||||
#include "Sandbox/SandboxedProcessor.h"
|
#include "Sandbox/SandboxedProcessor.h"
|
||||||
|
|
||||||
|
#if ! JUCE_WINDOWS
|
||||||
|
#include <csetjmp>
|
||||||
|
#include <csignal>
|
||||||
|
#include <mutex>
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace {
|
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 —
|
// Catch a plugin fault — access violation, heap corruption, C++ exception —
|
||||||
// rather than let it kill the host process. /EHa on this TU makes catch(...)
|
// rather than let it kill the host process. On Windows, /EHa on this TU makes
|
||||||
// catch SEH on Windows too; on other platforms it covers C++ exceptions only.
|
// 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
|
// On fault: route future loads of the offending plugin through the
|
||||||
// out-of-process sandbox (via the runtime crash blocklist), and *leak* 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
|
inline void invokePlugin(ProcessorSlot& slot, Fn&& fn) noexcept
|
||||||
{
|
{
|
||||||
if (! slot.processor) return;
|
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
|
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);
|
fn(*slot.processor);
|
||||||
|
|
||||||
|
#if ! JUCE_WINDOWS
|
||||||
|
g_pluginGuardArmed = wasArmed;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
catch (...)
|
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
|
// Best-effort blocklist update — addCrashedPlugin allocates (juce path
|
||||||
// canonicalisation, StringArray.add) and locks a mutex, both of which
|
// canonicalisation, StringArray.add) and locks a mutex, both of which
|
||||||
// can throw under OOM or corruption. Swallow any exception here so the
|
// 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
|
} // namespace
|
||||||
|
|
||||||
// ── ProcessorSlot ─────────────────────────────────────────────────────────────
|
// ── ProcessorSlot ─────────────────────────────────────────────────────────────
|
||||||
@@ -67,6 +218,13 @@ void SignalChain::prepare(double sampleRate, int blockSize)
|
|||||||
currentSampleRate = sampleRate;
|
currentSampleRate = sampleRate;
|
||||||
currentBlockSize = blockSize;
|
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);
|
const juce::ScopedLock sl(lock);
|
||||||
for (auto* slot : slots)
|
for (auto* slot : slots)
|
||||||
{
|
{
|
||||||
@@ -93,6 +251,10 @@ void SignalChain::releaseResources()
|
|||||||
|
|
||||||
void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
|
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);
|
const juce::ScopedTryLock sl(lock);
|
||||||
if (!sl.isLocked()) return;
|
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,
|
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex2 + i].targetSlotId,
|
||||||
midiRingBuffer[(size_t)scope.startIndex2 + i].msg };
|
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)
|
if (!slot->processor || slot->bypassed) return;
|
||||||
{
|
slotMidi.clear();
|
||||||
// Build per-slot MIDI buffer from drained messages
|
slotMidi.addEvents(midi, 0, -1, 0);
|
||||||
juce::MidiBuffer slotMidi(midi); // start with pass-through MIDI
|
for (int i = 0; i < numDrained; ++i)
|
||||||
for (int i = 0; i < numDrained; ++i)
|
if (drained[i].slotId == slot->id || drained[i].slotId == -1)
|
||||||
{
|
slotMidi.addEvent(drained[i].msg, 0);
|
||||||
if (drained[i].slotId == slot->id || drained[i].slotId == -1)
|
invokePlugin(*slot, [&](juce::AudioProcessor& p) { p.processBlock(buf, slotMidi); });
|
||||||
slotMidi.addEvent(drained[i].msg, 0);
|
applyPan(buf, numSamples, slot->pan);
|
||||||
}
|
};
|
||||||
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
|
||||||
{
|
// Fast path: no parallel branch → plain serial chain. Behaviour is unchanged
|
||||||
p.processBlock(buffer, slotMidi);
|
// 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)
|
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);
|
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
|
int SignalChain::getNumSlots() const
|
||||||
@@ -263,6 +535,15 @@ void SignalChain::setParameter(int slotId, int paramIndex, float value)
|
|||||||
auto* proc = slots[idx]->processor.get();
|
auto* proc = slots[idx]->processor.get();
|
||||||
if (!proc) return;
|
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();
|
auto& params = proc->getParameters();
|
||||||
if (paramIndex >= 0 && paramIndex < params.size())
|
if (paramIndex >= 0 && paramIndex < params.size())
|
||||||
params[paramIndex]->setValue(value);
|
params[paramIndex]->setValue(value);
|
||||||
@@ -295,6 +576,12 @@ juce::String SignalChain::savePreset() const
|
|||||||
slotObj->setProperty("path", slot->path);
|
slotObj->setProperty("path", slot->path);
|
||||||
slotObj->setProperty("bypassed", slot->bypassed);
|
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
|
// Save processor state as base64
|
||||||
auto state = slot->getState();
|
auto state = slot->getState();
|
||||||
if (state.getSize() > 0)
|
if (state.getSize() > 0)
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ struct ProcessorSlot
|
|||||||
bool bypassed = false;
|
bool bypassed = false;
|
||||||
int id = 0;
|
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
|
// For VST plugins — their state as base64 for preset save/load
|
||||||
juce::MemoryBlock getState() const;
|
juce::MemoryBlock getState() const;
|
||||||
void setState(const juce::MemoryBlock& state);
|
void setState(const juce::MemoryBlock& state);
|
||||||
@@ -40,6 +55,11 @@ public:
|
|||||||
void moveProcessor(int fromIndex, int toIndex);
|
void moveProcessor(int fromIndex, int toIndex);
|
||||||
void setBypass(int slotId, bool bypassed);
|
void setBypass(int slotId, bool bypassed);
|
||||||
void setMultiBypass(const juce::Array<std::pair<int, bool>>& changes);
|
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();
|
void clear();
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
@@ -81,6 +101,13 @@ private:
|
|||||||
double currentSampleRate = 48000.0;
|
double currentSampleRate = 48000.0;
|
||||||
int currentBlockSize = 256;
|
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)
|
// Lock-free SPSC MIDI queue (N-API thread writes, audio thread reads)
|
||||||
struct PendingMidiMessage { int targetSlotId = -1; juce::MidiMessage msg; };
|
struct PendingMidiMessage { int targetSlotId = -1; juce::MidiMessage msg; };
|
||||||
static constexpr int kMidiQueueSize = 64;
|
static constexpr int kMidiQueueSize = 64;
|
||||||
|
|||||||
@@ -1004,6 +1004,17 @@ export function initAudioBridge(): void {
|
|||||||
audio?.setBypass(slotId, bypassed);
|
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', () => {
|
ipcMain.handle('audio:clearChain', () => {
|
||||||
audio?.clearChain();
|
audio?.clearChain();
|
||||||
vstSlotPaths.clear();
|
vstSlotPaths.clear();
|
||||||
|
|||||||
@@ -366,6 +366,10 @@ const slopsmithDesktopApi = {
|
|||||||
removeProcessor: (slotId: number) => ipcRenderer.invoke('audio:removeProcessor', slotId),
|
removeProcessor: (slotId: number) => ipcRenderer.invoke('audio:removeProcessor', slotId),
|
||||||
moveProcessor: (from: number, to: number) => ipcRenderer.invoke('audio:moveProcessor', from, to),
|
moveProcessor: (from: number, to: number) => ipcRenderer.invoke('audio:moveProcessor', from, to),
|
||||||
setBypass: (slotId: number, bypassed: boolean) => ipcRenderer.invoke('audio:setBypass', slotId, bypassed),
|
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'),
|
clearChain: () => ipcRenderer.invoke('audio:clearChain'),
|
||||||
getChainState: () => ipcRenderer.invoke('audio:getChainState'),
|
getChainState: () => ipcRenderer.invoke('audio:getChainState'),
|
||||||
|
|
||||||
|
|||||||
@@ -671,7 +671,14 @@ void runAudioThread(HostState& st)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (auto* p = st.plugin.get())
|
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);
|
p->processBlock(buffer, midi);
|
||||||
|
}
|
||||||
st.audio.pushBlock(/*isOutputRing=*/true, buffer, currentBlockSize);
|
st.audio.pushBlock(/*isOutputRing=*/true, buffer, currentBlockSize);
|
||||||
}
|
}
|
||||||
// Defensive: any control-thread AudioPauseGuard waiting on us at the time
|
// Defensive: any control-thread AudioPauseGuard waiting on us at the time
|
||||||
|
|||||||
@@ -96,6 +96,35 @@ add_test(NAME sandbox_e2e_test
|
|||||||
"$<TARGET_FILE:slopsmith-vst-host>"
|
"$<TARGET_FILE:slopsmith-vst-host>"
|
||||||
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
|
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
|
||||||
|
|
||||||
|
# --- in-process plugin fault-guard integration test ---
|
||||||
|
# Builds the REAL SignalChain against the full sandbox set and drives a
|
||||||
|
# deliberately-segfaulting in-process plugin through SignalChain::process();
|
||||||
|
# asserts invokePlugin()'s fault guard (POSIX siglongjmp / Windows SEH) keeps the
|
||||||
|
# host alive, releases the processor, and blocklists it. No subprocess / VST3
|
||||||
|
# fixture needed — the faulting plugin is an in-process AudioProcessor.
|
||||||
|
add_executable(signalchain_fault_test
|
||||||
|
signalchain_fault_test.cpp
|
||||||
|
"${REPO_ROOT}/src/audio/SignalChain.cpp"
|
||||||
|
"${SANDBOX}/SandboxedProcessor.cpp"
|
||||||
|
"${SANDBOX}/SandboxFactory_shared.cpp" "${SANDBOX}/SandboxFactory_posix.cpp"
|
||||||
|
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp"
|
||||||
|
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
|
||||||
|
"${SANDBOX}/SubprocessHandle_posix.cpp"
|
||||||
|
"${SANDBOX}/Protocol.cpp")
|
||||||
|
target_include_directories(signalchain_fault_test PRIVATE "${REPO_ROOT}/src/audio")
|
||||||
|
target_link_libraries(signalchain_fault_test PRIVATE
|
||||||
|
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
|
||||||
|
juce::juce_audio_processors juce::juce_core juce::juce_data_structures
|
||||||
|
juce::juce_dsp juce::juce_events)
|
||||||
|
target_compile_definitions(signalchain_fault_test PRIVATE
|
||||||
|
JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0
|
||||||
|
JUCE_STANDALONE_APPLICATION=0 JUCE_REPORT_APP_USAGE=0
|
||||||
|
# The guard INTENTIONALLY leaks the faulting processor (destructing it on a
|
||||||
|
# corrupted heap is the very hazard it avoids), so JUCE's leak detector would
|
||||||
|
# fire a Debug assertion at shutdown for a by-design leak. Disable it here.
|
||||||
|
JUCE_CHECK_MEMORY_LEAKS=0)
|
||||||
|
add_test(NAME signalchain_fault_test COMMAND signalchain_fault_test)
|
||||||
|
|
||||||
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
|
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
|
||||||
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
|
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
|
||||||
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
|
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Integration test for the in-process plugin fault guard in SignalChain.cpp.
|
||||||
|
//
|
||||||
|
// Drives a deliberately-segfaulting in-process AudioProcessor through a REAL
|
||||||
|
// SignalChain::process() and asserts that:
|
||||||
|
// 1. the host process SURVIVES the plugin's SIGSEGV (POSIX guard / SEH),
|
||||||
|
// 2. the faulting processor is released from its slot,
|
||||||
|
// 3. the plugin is added to the runtime crash blocklist, so shouldSandbox()
|
||||||
|
// now routes it out-of-process on its next load (self-healing), and
|
||||||
|
// 4. a subsequent process() call is safe (the dead slot is skipped).
|
||||||
|
//
|
||||||
|
// This is the end-to-end counterpart to the standalone mechanism check: it
|
||||||
|
// exercises the actual invokePlugin() guard, not a copy. POSIX-only harness
|
||||||
|
// (matches the sandbox e2e job); on Windows the same path is covered by the
|
||||||
|
// /EHa SEH catch.
|
||||||
|
|
||||||
|
#include "SignalChain.h"
|
||||||
|
#include "Sandbox/SandboxedProcessor.h" // slopsmith::sandbox::{shouldSandbox,addCrashedPlugin}
|
||||||
|
|
||||||
|
#include <juce_audio_processors/juce_audio_processors.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Base fixture: a minimal in-process AudioProcessor. Subclasses decide how
|
||||||
|
// processBlock() fails.
|
||||||
|
class FaultFixture : public juce::AudioProcessor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FaultFixture()
|
||||||
|
: juce::AudioProcessor(BusesProperties()
|
||||||
|
.withInput("In", juce::AudioChannelSet::stereo(), true)
|
||||||
|
.withOutput("Out", juce::AudioChannelSet::stereo(), true)) {}
|
||||||
|
|
||||||
|
const juce::String getName() const override { return "FaultGuardFixture"; }
|
||||||
|
void prepareToPlay(double, int) override {}
|
||||||
|
void releaseResources() override {}
|
||||||
|
|
||||||
|
double getTailLengthSeconds() const override { return 0.0; }
|
||||||
|
bool acceptsMidi() const override { return false; }
|
||||||
|
bool producesMidi() const override { return false; }
|
||||||
|
bool isMidiEffect() const override { return false; }
|
||||||
|
|
||||||
|
juce::AudioProcessorEditor* createEditor() override { return nullptr; }
|
||||||
|
bool hasEditor() const override { return false; }
|
||||||
|
|
||||||
|
int getNumPrograms() override { return 1; }
|
||||||
|
int getCurrentProgram() override { return 0; }
|
||||||
|
void setCurrentProgram(int) override {}
|
||||||
|
const juce::String getProgramName(int) override { return {}; }
|
||||||
|
void changeProgramName(int, const juce::String&) override {}
|
||||||
|
|
||||||
|
void getStateInformation(juce::MemoryBlock&) override {}
|
||||||
|
void setStateInformation(const void*, int) override {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Faults with a hardware signal (SIGSEGV) — the POSIX guard / Windows SEH path.
|
||||||
|
class SignalFaultingProcessor : public FaultFixture
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override
|
||||||
|
{
|
||||||
|
// Deliberate null dereference → SIGSEGV. volatile so the compiler can't
|
||||||
|
// elide it; the asm barrier keeps it from being hoisted/removed under -O2.
|
||||||
|
volatile int* p = nullptr;
|
||||||
|
*p = 0xC0FFEE;
|
||||||
|
asm volatile("" ::: "memory");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Faults with a normal C++ exception — the path that left the POSIX guard armed
|
||||||
|
// with a stale landing pad before the fix (catch must restore the guard).
|
||||||
|
class ThrowingProcessor : public FaultFixture
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override
|
||||||
|
{
|
||||||
|
throw std::runtime_error("deliberate plugin exception");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool fail(const char* msg) { std::printf("FAIL: %s\n", msg); return false; }
|
||||||
|
|
||||||
|
// Drive one faulting processor through a fresh chain; assert the host survives,
|
||||||
|
// the processor is released, and the plugin is blocklisted.
|
||||||
|
template <typename ProcT>
|
||||||
|
bool runFaultScenario(const char* label, const char* fileName)
|
||||||
|
{
|
||||||
|
// A unique on-disk path ending in .vst3 so shouldSandbox() treats it as a
|
||||||
|
// VST3 (non-VST3 paths always stay in-process and skip the blocklist).
|
||||||
|
const juce::String fakePath = juce::File::getCurrentWorkingDirectory()
|
||||||
|
.getChildFile(fileName).getFullPathName();
|
||||||
|
juce::PluginDescription desc;
|
||||||
|
desc.fileOrIdentifier = fakePath;
|
||||||
|
|
||||||
|
if (slopsmith::sandbox::shouldSandbox(desc))
|
||||||
|
return fail("fixture was already blocklisted before the fault");
|
||||||
|
|
||||||
|
SignalChain chain;
|
||||||
|
chain.prepare(48000.0, 256);
|
||||||
|
const int slotId = chain.addProcessor(std::make_unique<ProcT>(),
|
||||||
|
ProcessorSlot::Type::VST, label, fakePath);
|
||||||
|
if (chain.getNumSlots() != 1)
|
||||||
|
return fail("processor was not added to the chain");
|
||||||
|
|
||||||
|
juce::AudioBuffer<float> buffer(2, 256);
|
||||||
|
buffer.clear();
|
||||||
|
juce::MidiBuffer midi;
|
||||||
|
|
||||||
|
// The crux: this drives the faulting plugin and MUST return rather than die.
|
||||||
|
chain.process(buffer, midi);
|
||||||
|
std::printf(" [%s] survived\n", label);
|
||||||
|
|
||||||
|
const ProcessorSlot* slot = chain.getSlot(slotId);
|
||||||
|
if (slot == nullptr) return fail("slot vanished after the fault");
|
||||||
|
if (slot->processor != nullptr) return fail("faulting processor was not released");
|
||||||
|
if (! slopsmith::sandbox::shouldSandbox(desc))
|
||||||
|
return fail("plugin was not blocklisted after faulting");
|
||||||
|
|
||||||
|
// A second block must be safe: the dead slot is simply skipped.
|
||||||
|
chain.process(buffer, midi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool runTest()
|
||||||
|
{
|
||||||
|
// Signal fault (SIGSEGV) and C++-exception fault, each through its own chain.
|
||||||
|
// Running both in one process also exercises that the C++-exception path
|
||||||
|
// leaves the per-thread POSIX guard in a clean (disarmed) state — a stale
|
||||||
|
// armed flag from the first scenario would corrupt the second.
|
||||||
|
if (! runFaultScenario<SignalFaultingProcessor>("signal-fault", "FaultGuardSignal.vst3"))
|
||||||
|
return false;
|
||||||
|
if (! runFaultScenario<ThrowingProcessor>("cxx-exception", "FaultGuardThrow.vst3"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
std::printf("OK: SignalChain survived both fault kinds; processors released + blocklisted\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
const bool ok = runTest();
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user