mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 03:09:56 +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());
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user