mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 03:09:56 +00:00
* fix(audio): prefer same-backend duplex routing * fix(audio): centre mono input; limit duplex to same-endpoint devices Two issues found while testing the USB-guitar-cable path on Windows: 1. Centre a mono input. SourceChain::processBlock fell into the pass-through branch for a 1-channel input, filling only min(inputChannels, outputChannels) = 1 output channel and zeroing the rest, so a mono USB guitar cable played out of the left speaker only. A single-channel input is now broadcast across every output channel. 2. Only attempt the combined (duplex) device when input and output are the SAME physical endpoint. Two different endpoints of the same backend (USB cable in + separate speakers out) are independent hardware clocks; routing them through one duplex device was unstable across the app lifecycle (no audio until an explicit Apply, then distortion / dropouts / silent-in-song on navigation). Different endpoints now use the split path, whose ring bridges the two clocks. Same-endpoint duplex (one interface for in and out) keeps the low-latency win. Low latency for the two-device case is a follow-up that needs the device-lifecycle work (startup restore + reconfigure on navigation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu * fix(audio): probe same-endpoint duplex the same way apply routes it Startup auto-apply (renderer init) fail-closes on probeDeviceOptionsDual's `compatible` verdict, but the probe still measured a COMBINED duplex device for any same-backend pair while setAudioDevices now opens split for different endpoints. That mismatch made the startup probe describe a config that isn't the one applied — surfacing as "no audio until I press Apply" for a USB cable + separate speakers. Gate the probe's duplex path on the same sameEndpointIntent (same type AND same device) the apply path uses, so a two-device pair is probed via the split path it will actually run on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu * fix(audio): never feed chain processors blocks larger than prepared size WASAPI shared mode can deliver oversized blocks right after a device start. The NAM core pre-allocates its conv ring/output buffers to the Reset() maxBufferSize and only asserts (release no-op) on larger blocks; one oversized block corrupts the conv ring state and garbles all subsequent audio until the next Reset() — the 'first start heavily distorted until tone reset / engine restart' bug. - NAMProcessor::processBlock: process in slices of at most the prepared block size. - SignalChain::process: slice oversized device blocks into prepared-size chunks before any slot (VST/NAM/IR) sees them. See docs/audio-distortion-first-start-investigation.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audio): close stale-format race when processors are added mid-reconfigure addProcessor/replaceProcessor prepare the incoming processor off the audio lock on N-API worker threads. A concurrent device reconfigure's SignalChain::prepare() can't see that processor (not slotted yet), so a slot could go live prepared at a stale sample rate / block size and stay wrong until the next device restart — heard as pitch-shifted/garbled monitoring when a chain loads while the device is being (re)opened (widest window: WASAPI exclusive mode's slower open). Re-check the chain's current format under the lock at insert/swap time and re-prepare if it moved; log the transition to stderr so tester logs show when the race fired. prepare() now publishes the format under the lock so the check can't tear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(effects): align executor plan schema with rebranded capability layer The rebrand left a three-way schema split: rig_builder sent the old 'slopsmith.audio_effects.chain_plan.v1', the renderer capability layer validated against the new 'feedBack.…' id (rejecting every plan), and this executor still expected the old one. Result: every song chain load fell back to legacy clearChain+loadPreset — a full multi-VST rebuild per currentSong poll cycle, heard as continuous distortion during playback (tester logs: 4-6 rebuilds/session, slot IDs into the 90s). Executor now uses the rebranded id and accepts the legacy one as an alias (matching the capability layer's new alias), so neither side of the handoff can break on old plugin bundles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audio): guard input callback against double registration Tester main-process log showed two consecutive 'startAudio: duplex=0' lines: a transient audioDeviceStopped() (WASAPI exclusive opens fire one mid-start) cleared audioRunning while the input callback stayed attached, so the second startAudio() re-added it. JUCE then dispatched the input callback twice per block: DSP ran twice and each block was pushed into the split ring twice — every sample played twice (half speed, one octave down, garbled). stopAudio()'s single removeAudioCallback left the duplicate registration alive, wedging the engine (restart no longer helped) and keeping the exclusive-mode device open even after app close. Mirror the existing outputCallbackRegistered guard for the input side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(audio): diagnostic instrumentation for tester repro builds Main-process stderr logging on every open lead, RT paths rate-limited (first-25 per anomaly + ~5s heartbeats per callback clock): - primary callback re-entrancy (duplicate registration detector) - oversized blocks on primary/output callbacks, SignalChain slicing, NAM chunking (pre-fix corruption trigger visibility) - ring fill + under/overflow counters (split-mode pacing) - device lifecycle: aboutToStart/stopped on both managers with sr/bs and callback-registration flags; startAudio guard-skip; stopAudio state - SourceChain.prepare format trace (stale-rate lead) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(audio): drop diagnostic heartbeats, keep anomaly detectors The 5s ring/format heartbeats served the distortion hunt and are noise now. Keep the cheap anomaly-only diagnostics (callback re-entrancy, oversized-block detectors, chain slicing/chunking, stale-format re-prepare, device lifecycle) — they log only on misbehavior and stay relevant for the exclusive-mode playback work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: keep investigation notes out of the PR (local working notes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(audio): gate lifecycle [diag] logs behind SLOPSMITH_SANDBOX_DEBUG Review follow-up (#86): the six lifecycle diagnostics (stopAudio, audioDeviceAboutToStart/Stopped, audioOutputAboutToStart/Stopped, SourceChain::prepare) printed unconditionally while the PR body claimed they were verbose-gated. Gate them behind the existing slopsmith_vst_trace::isEnabled() runtime flag (SLOPSMITH_SANDBOX_DEBUG — already flipped by the app's debug-logging switch, so tester debug runs still capture them). The RT-path anomaly detectors (primary re-entry, oversized-block) keep their firstN/anomaly bounds unchanged, as reviewed. VSTTrace.h now defines NOMINMAX/WIN32_LEAN_AND_MEAN before windows.h so including it from engine TUs doesn't clobber std::min. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
823 lines
33 KiB
C++
823 lines
33 KiB
C++
#include "SignalChain.h"
|
|
#include "Sandbox/SandboxedProcessor.h"
|
|
#include <cmath> // std::isfinite (postGain sanitisation)
|
|
|
|
#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. 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
|
|
// AudioPluginInstance — calling its destructor on a now-corrupted heap is
|
|
// its own crash hazard. A one-time leak per kill in exchange for a live app.
|
|
// The next iteration of any slot loop sees slot->processor == nullptr and
|
|
// skips the slot.
|
|
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
|
|
// outer noexcept boundary stays honest; release() is itself noexcept
|
|
// and never escapes the catch.
|
|
try { slopsmith::sandbox::addCrashedPlugin(slot.path); }
|
|
catch (...) { /* nothing useful to do on the noexcept boundary */ }
|
|
(void) slot.processor.release();
|
|
}
|
|
}
|
|
|
|
// 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 ─────────────────────────────────────────────────────────────
|
|
|
|
juce::MemoryBlock ProcessorSlot::getState() const
|
|
{
|
|
juce::MemoryBlock state;
|
|
if (processor)
|
|
processor->getStateInformation(state);
|
|
return state;
|
|
}
|
|
|
|
void ProcessorSlot::setState(const juce::MemoryBlock& state)
|
|
{
|
|
if (processor && state.getSize() > 0)
|
|
processor->setStateInformation(state.getData(), (int)state.getSize());
|
|
}
|
|
|
|
// ── SignalChain ───────────────────────────────────────────────────────────────
|
|
|
|
SignalChain::SignalChain() {}
|
|
|
|
SignalChain::~SignalChain()
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
slots.clear();
|
|
}
|
|
|
|
void SignalChain::prepare(double sampleRate, int 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);
|
|
// Published under the lock so addProcessor/replaceProcessor's under-lock
|
|
// stale-format check can't tear against a concurrent prepare.
|
|
currentSampleRate = sampleRate;
|
|
currentBlockSize = blockSize;
|
|
for (auto* slot : slots)
|
|
{
|
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
|
{
|
|
p.releaseResources();
|
|
p.setPlayConfigDetails(2, 2, sampleRate, blockSize);
|
|
p.prepareToPlay(sampleRate, blockSize);
|
|
});
|
|
}
|
|
}
|
|
|
|
void SignalChain::releaseResources()
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
for (auto* slot : slots)
|
|
{
|
|
invokePlugin(*slot, [](juce::AudioProcessor& p)
|
|
{
|
|
p.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;
|
|
|
|
// Never hand a slot a block larger than the one it was prepared for.
|
|
// prepareToPlay's samplesPerBlock is a hard contract for VST3s, and the NAM
|
|
// core sizes its conv ring/output buffers to it with only a release-no-op
|
|
// assert guarding overruns — one oversized block (WASAPI shared mode
|
|
// delivers them right after a device start) permanently garbles its state.
|
|
// Slice the block into prepared-size chunks instead; each slot processes
|
|
// each chunk in sequence, preserving slot ordering per sample.
|
|
const int totalSamples = buffer.getNumSamples();
|
|
const int maxChunk = currentBlockSize > 0 ? currentBlockSize : totalSamples;
|
|
if (totalSamples <= maxChunk)
|
|
{
|
|
processLocked(buffer, midi);
|
|
return;
|
|
}
|
|
|
|
// DIAG (first 25): an oversized device block reached the chain and is
|
|
// being sliced — records how often the pre-fix corruption path would
|
|
// have fired and with what sizes.
|
|
{
|
|
static std::atomic<uint32_t> sliceLogs{0};
|
|
if (sliceLogs.fetch_add(1, std::memory_order_relaxed) < 25)
|
|
fprintf(stderr, "[diag] SignalChain slicing oversized block: %d > prepared %d\n",
|
|
totalSamples, maxChunk);
|
|
}
|
|
|
|
constexpr int kMaxSliceChannels = 8;
|
|
const int numChannels = buffer.getNumChannels();
|
|
if (numChannels > kMaxSliceChannels)
|
|
{
|
|
// Shouldn't happen (the chain runs stereo) — keep the legacy whole-block
|
|
// behaviour rather than dropping channels.
|
|
processLocked(buffer, midi);
|
|
return;
|
|
}
|
|
|
|
juce::MidiBuffer emptyMidi;
|
|
float* slicePtrs[kMaxSliceChannels];
|
|
for (int offset = 0; offset < totalSamples; offset += maxChunk)
|
|
{
|
|
const int chunk = juce::jmin(maxChunk, totalSamples - offset);
|
|
for (int ch = 0; ch < numChannels; ++ch)
|
|
slicePtrs[ch] = buffer.getWritePointer(ch) + offset;
|
|
juce::AudioBuffer<float> slice(slicePtrs, numChannels, chunk);
|
|
// MIDI (all stamped at sample 0) goes to the first slice only.
|
|
processLocked(slice, offset == 0 ? midi : emptyMidi);
|
|
}
|
|
}
|
|
|
|
void SignalChain::processLocked(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
|
|
{
|
|
// Drain pending MIDI messages from the lock-free queue
|
|
struct DrainedMsg { int slotId; juce::MidiMessage msg; };
|
|
DrainedMsg drained[kMidiQueueSize];
|
|
int numDrained = 0;
|
|
|
|
const auto scope = midiQueueFifo.read(midiQueueFifo.getNumReady());
|
|
for (int i = 0; i < scope.blockSize1 && numDrained < kMidiQueueSize; ++i)
|
|
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex1 + i].targetSlotId,
|
|
midiRingBuffer[(size_t)scope.startIndex1 + i].msg };
|
|
for (int i = 0; i < scope.blockSize2 && numDrained < kMidiQueueSize; ++i)
|
|
drained[numDrained++] = { midiRingBuffer[(size_t)scope.startIndex2 + i].targetSlotId,
|
|
midiRingBuffer[(size_t)scope.startIndex2 + i].msg };
|
|
|
|
// 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) 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);
|
|
if (slot->postGain != 1.0f)
|
|
buf.applyGain(0, numSamples, slot->postGain);
|
|
};
|
|
|
|
// 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)
|
|
{
|
|
const auto scope = midiQueueFifo.write(1);
|
|
if (scope.blockSize1 > 0)
|
|
midiRingBuffer[(size_t)scope.startIndex1] = { targetSlotId, msg };
|
|
else if (scope.blockSize2 > 0)
|
|
midiRingBuffer[(size_t)scope.startIndex2] = { targetSlotId, msg };
|
|
// If queue full, message silently dropped (acceptable for PC messages)
|
|
}
|
|
|
|
// Shared prepare sequence for a processor entering the live chain — used by
|
|
// addProcessor and replaceProcessor so the channel config and prepare ordering
|
|
// stay identical between them. Call from inside invokePlugin's fault guard.
|
|
static void prepareForPlayback(juce::AudioProcessor& p, double sampleRate, int blockSize)
|
|
{
|
|
p.setPlayConfigDetails(2, 2, sampleRate, blockSize);
|
|
p.prepareToPlay(sampleRate, blockSize);
|
|
}
|
|
|
|
int SignalChain::addProcessor(std::unique_ptr<juce::AudioProcessor> processor,
|
|
ProcessorSlot::Type type,
|
|
const juce::String& name,
|
|
const juce::String& path)
|
|
{
|
|
if (!processor) return -1;
|
|
|
|
auto slot = std::make_unique<ProcessorSlot>();
|
|
slot->type = type;
|
|
slot->processor = std::move(processor);
|
|
slot->name = name;
|
|
slot->path = path;
|
|
slot->id = nextSlotId++;
|
|
|
|
// Prepare under the SEH-catching helper so a plugin that faults during
|
|
// prepareToPlay is blocklisted (next load routes to the sandbox) and the
|
|
// slot is dropped, rather than taking the app down. Snapshot the playback
|
|
// format we prepare against: this runs OFF the audio lock on an N-API
|
|
// worker thread, and a device reconfigure can run prepare() concurrently —
|
|
// its slot loop won't see this slot (not added yet), so if the format
|
|
// moved we must re-prepare under the lock below or the slot stays at a
|
|
// stale sample rate / block size until the next device restart (heard as
|
|
// pitch-shifted/garbled monitoring after first-open races).
|
|
const double prepSr = currentSampleRate;
|
|
const int prepBs = currentBlockSize;
|
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
|
{
|
|
prepareForPlayback(p, prepSr, prepBs);
|
|
});
|
|
if (! slot->processor) return -1;
|
|
|
|
int id = slot->id;
|
|
const juce::ScopedLock sl(lock);
|
|
if (currentSampleRate != prepSr || currentBlockSize != prepBs)
|
|
{
|
|
fprintf(stderr,
|
|
"[SignalChain] addProcessor: device format changed during prepare "
|
|
"(%.0f/%d -> %.0f/%d) — re-preparing '%s'\n",
|
|
prepSr, prepBs, currentSampleRate, currentBlockSize,
|
|
slot->name.toRawUTF8());
|
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
|
{
|
|
p.releaseResources();
|
|
prepareForPlayback(p, currentSampleRate, currentBlockSize);
|
|
});
|
|
if (! slot->processor) return -1;
|
|
}
|
|
slots.add(slot.release());
|
|
return id;
|
|
}
|
|
|
|
void SignalChain::removeProcessor(int slotId)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx >= 0) slots.remove(idx);
|
|
}
|
|
|
|
bool SignalChain::replaceProcessor(int slotId, std::unique_ptr<juce::AudioProcessor> processor,
|
|
const juce::String& newName, const juce::String& newPath)
|
|
{
|
|
if (!processor) return false;
|
|
|
|
// Copy the target slot's identity (type/name/path) onto the staging slot
|
|
// BEFORE preparing, so if the incoming processor faults during prepareToPlay
|
|
// invokePlugin's catch blocklists the RIGHT plugin path (addProcessor sets
|
|
// these before its own prepare for the same reason). Without this the staging
|
|
// path is empty and the fault is recorded against "".
|
|
ProcessorSlot staging;
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
const int idx = findSlotIndex(slotId);
|
|
if (idx < 0) return false; // nothing to replace
|
|
staging.type = slots[idx]->type;
|
|
staging.name = slots[idx]->name;
|
|
staging.path = slots[idx]->path;
|
|
}
|
|
|
|
// Prepare the incoming processor before it goes live, exactly as addProcessor
|
|
// does — under invokePlugin's SEH/signal guard so a fault in prepareToPlay is
|
|
// contained (the processor is dropped) rather than taking the app down.
|
|
staging.processor = std::move(processor);
|
|
const double prepSr = currentSampleRate;
|
|
const int prepBs = currentBlockSize;
|
|
invokePlugin(staging, [&](juce::AudioProcessor& p)
|
|
{
|
|
prepareForPlayback(p, prepSr, prepBs);
|
|
});
|
|
if (! staging.processor) return false; // faulted during prepare → leave the slot as-is
|
|
|
|
std::unique_ptr<juce::AudioProcessor> old;
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
const int idx = findSlotIndex(slotId);
|
|
if (idx < 0) return false; // slot was removed underneath us
|
|
// Same off-lock prepare race as addProcessor: a concurrent device
|
|
// reconfigure's prepare() couldn't have re-prepared the staging
|
|
// processor (it isn't in a slot yet). Re-prepare at the current format
|
|
// before it goes live.
|
|
if (currentSampleRate != prepSr || currentBlockSize != prepBs)
|
|
{
|
|
fprintf(stderr,
|
|
"[SignalChain] replaceProcessor: device format changed during prepare "
|
|
"(%.0f/%d -> %.0f/%d) — re-preparing '%s'\n",
|
|
prepSr, prepBs, currentSampleRate, currentBlockSize,
|
|
staging.name.toRawUTF8());
|
|
invokePlugin(staging, [&](juce::AudioProcessor& p)
|
|
{
|
|
p.releaseResources();
|
|
prepareForPlayback(p, currentSampleRate, currentBlockSize);
|
|
});
|
|
if (! staging.processor) return false;
|
|
}
|
|
auto* slot = slots[idx];
|
|
old = std::move(slot->processor);
|
|
slot->processor = std::move(staging.processor);
|
|
// Swap succeeded: adopt the new identity so getChainState()/preset save
|
|
// report the swapped-in processor, not the one it replaced. Only when
|
|
// provided — the sandbox-promotion caller passes none and keeps identity.
|
|
if (newName.isNotEmpty()) slot->name = newName;
|
|
if (newPath.isNotEmpty()) slot->path = newPath;
|
|
}
|
|
// Tear the old processor down OUTSIDE the audio lock: releaseResources() (and
|
|
// a VST3 destructor) can block, and must never stall process() on it.
|
|
if (old)
|
|
{
|
|
old->releaseResources();
|
|
old.reset();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool SignalChain::captureVstStateForPromotion(int slotId, juce::MemoryBlock& state)
|
|
{
|
|
// Hold the audio lock across the whole check+snapshot: hasEditor() and
|
|
// getStateInformation() are plugin calls on a LIVE processor, and process()
|
|
// runs processBlock on that same instance under this lock (ScopedTryLock, so
|
|
// it simply drops a block here rather than deadlocking). Doing the snapshot
|
|
// off-lock would be a data race with the audio thread.
|
|
const juce::ScopedLock sl(lock);
|
|
const int idx = findSlotIndex(slotId);
|
|
if (idx < 0) return false;
|
|
auto* slot = slots[idx];
|
|
if (! slot->processor) return false;
|
|
if (slot->type != ProcessorSlot::Type::VST) return false;
|
|
// Already out-of-process — nothing to promote (its editor path is safe).
|
|
if (dynamic_cast<slopsmith::sandbox::SandboxedProcessor*>(slot->processor.get()) != nullptr)
|
|
return false;
|
|
|
|
// Run the plugin calls under invokePlugin's SEH/signal guard: a plugin that
|
|
// faults in hasEditor()/getStateInformation() is contained + blocklisted +
|
|
// released (leaving slot->processor null), never fatal.
|
|
bool promotable = false;
|
|
invokePlugin(*slot, [&](juce::AudioProcessor& p)
|
|
{
|
|
if (! p.hasEditor()) return; // editor-less VST3 → nothing to open
|
|
p.getStateInformation(state);
|
|
promotable = true;
|
|
});
|
|
// slot->processor is null iff the guarded call faulted (invokePlugin released
|
|
// it). Don't promote from a released slot; the empty `state` is discarded.
|
|
if (! promotable || slot->processor == nullptr)
|
|
{
|
|
state.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void SignalChain::moveProcessor(int fromIndex, int toIndex)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
if (fromIndex >= 0 && fromIndex < slots.size() &&
|
|
toIndex >= 0 && toIndex < slots.size() && fromIndex != toIndex)
|
|
{
|
|
slots.move(fromIndex, toIndex);
|
|
}
|
|
}
|
|
|
|
void SignalChain::setBypass(int slotId, bool bypassed)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx >= 0) slots[idx]->bypassed = bypassed;
|
|
}
|
|
|
|
void SignalChain::setMultiBypass(const juce::Array<std::pair<int, bool>>& changes)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
for (auto& [slotId, bypassed] : changes)
|
|
{
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx >= 0) slots[idx]->bypassed = bypassed;
|
|
}
|
|
}
|
|
|
|
void SignalChain::setPan(int slotId, float pan)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx >= 0) slots[idx]->pan = juce::jlimit(-1.0f, 1.0f, pan);
|
|
}
|
|
|
|
void SignalChain::setPostGain(int slotId, float gain)
|
|
{
|
|
// Reject non-finite input: juce::jlimit passes NaN through unchanged (both
|
|
// of its comparisons are false for NaN), and a NaN gain would then multiply
|
|
// the slot buffer to NaN and poison the whole chain until the slot rebuilds.
|
|
if (! std::isfinite(gain)) return;
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
// Clamp to [0, 16] — a 0..+24 dB linear ceiling for the per-slot trim.
|
|
if (idx >= 0) slots[idx]->postGain = juce::jlimit(0.0f, 16.0f, gain);
|
|
}
|
|
|
|
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
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
return slots.size();
|
|
}
|
|
|
|
const ProcessorSlot* SignalChain::getSlot(int slotId) const
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
return idx >= 0 ? slots[idx] : nullptr;
|
|
}
|
|
|
|
juce::Array<const ProcessorSlot*> SignalChain::getAllSlots() const
|
|
{
|
|
juce::Array<const ProcessorSlot*> result;
|
|
const juce::ScopedLock sl(lock);
|
|
for (auto* slot : slots)
|
|
result.add(slot);
|
|
return result;
|
|
}
|
|
|
|
juce::Array<SignalChain::ParamInfo> SignalChain::getParameters(int slotId) const
|
|
{
|
|
juce::Array<ParamInfo> result;
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx < 0) return result;
|
|
|
|
auto* proc = slots[idx]->processor.get();
|
|
if (!proc) return result;
|
|
|
|
auto& params = proc->getParameters();
|
|
for (int i = 0; i < params.size(); ++i)
|
|
{
|
|
ParamInfo info;
|
|
info.index = i;
|
|
info.name = params[i]->getName(128);
|
|
info.value = params[i]->getValue();
|
|
info.label = params[i]->getLabel();
|
|
info.text = params[i]->getCurrentValueAsText();
|
|
result.add(info);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void SignalChain::setParameter(int slotId, int paramIndex, float value)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx < 0) return;
|
|
|
|
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);
|
|
}
|
|
|
|
void SignalChain::setSlotState(int slotId, const juce::MemoryBlock& state)
|
|
{
|
|
const juce::ScopedLock sl(lock);
|
|
int idx = findSlotIndex(slotId);
|
|
if (idx >= 0)
|
|
slots[idx]->setState(state); // ProcessorSlot::setState() is null/empty-safe
|
|
}
|
|
|
|
// ── Presets ───────────────────────────────────────────────────────────────────
|
|
|
|
juce::String SignalChain::savePreset() const
|
|
{
|
|
auto root = new juce::DynamicObject();
|
|
root->setProperty("version", 1);
|
|
|
|
juce::Array<juce::var> chainArray;
|
|
const juce::ScopedLock sl(lock);
|
|
|
|
for (auto* slot : slots)
|
|
{
|
|
auto slotObj = new juce::DynamicObject();
|
|
slotObj->setProperty("id", slot->id);
|
|
slotObj->setProperty("type", (int)slot->type);
|
|
slotObj->setProperty("name", slot->name);
|
|
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);
|
|
// Per-slot output trim (loudness leveling). LoadPresetWorker reads this
|
|
// back, so it must be written here or a save/load round-trip drops it.
|
|
if (slot->postGain != 1.0f) slotObj->setProperty("postGain", slot->postGain);
|
|
|
|
// Save processor state as base64
|
|
auto state = slot->getState();
|
|
if (state.getSize() > 0)
|
|
slotObj->setProperty("state", state.toBase64Encoding());
|
|
|
|
chainArray.add(juce::var(slotObj));
|
|
}
|
|
|
|
root->setProperty("chain", juce::var(chainArray));
|
|
return juce::JSON::toString(juce::var(root));
|
|
}
|
|
|
|
void SignalChain::loadPreset(const juce::String& json)
|
|
{
|
|
// Preset loading is handled at a higher level (NodeAddon) because
|
|
// it needs to re-instantiate processors (VSTs, NAMs, IRs) which
|
|
// requires the VSTHost and other components. The chain just needs
|
|
// to be rebuilt via addProcessor() calls followed by setState().
|
|
}
|
|
|
|
// ── Private ───────────────────────────────────────────────────────────────────
|
|
|
|
int SignalChain::findSlotIndex(int slotId) const
|
|
{
|
|
for (int i = 0; i < slots.size(); ++i)
|
|
if (slots[i]->id == slotId) return i;
|
|
return -1;
|
|
}
|