Files
feedBack-desktop/src/audio/BackingLeveler.h
T
7671385ba8 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>
2026-06-19 23:08:05 +02:00

114 lines
4.8 KiB
C++

#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];
};