audio: per-slot postGain for parallel-branch loudness leveling (#58)

Adds an optional per-slot output gain (ProcessorSlot.postGain, default 1.0 = no-op) applied in SignalChain::runSlot after processBlock + pan, plumbed through setPostGain / IPC / preload / loadPreset / getChainState (mirroring setPan). Gives each parallel branch its own loudness trim.

Review hardening (multi-angle + Codex): savePreset() now serializes postGain (it was read back but never written → save/load dropped it); setPostGain() rejects non-finite input (NaN would poison the buffer); new signalchain_postgain_test.cpp asserts gain scaling, NaN rejection, and serialization.

Verified locally: build:audio clean; signalchain_postgain_test 5/5 pass. (Org CI runners failed to start — infra/billing, unrelated to the change; changes are platform-neutral C++.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jorge Fritis
2026-07-02 12:50:47 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 27e8f56ad8
commit c8415113c8
7 changed files with 201 additions and 0 deletions
+16
View File
@@ -2539,6 +2539,18 @@ static Napi::Value SetPan(const Napi::CallbackInfo& info)
return info.Env().Undefined();
}
static Napi::Value SetPostGain(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2)
{
int slotId = info[0].As<Napi::Number>().Int32Value();
float gain = (float) info[1].As<Napi::Number>().DoubleValue();
liveEngine->getSignalChain().setPostGain(slotId, gain);
}
return info.Env().Undefined();
}
static Napi::Value SetBranch(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
@@ -2586,6 +2598,7 @@ static Napi::Value GetChainState(const Napi::CallbackInfo& info)
obj.Set("pan", slots[i]->pan);
obj.Set("branch", slots[i]->branch);
obj.Set("branchSrc", slots[i]->branchSrc);
obj.Set("postGain", slots[i]->postGain);
obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor());
result.Set((uint32_t)i, obj);
}
@@ -3221,6 +3234,8 @@ public:
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
if (slotObj->hasProperty("branch"))
liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("branch"));
if (slotObj->hasProperty("postGain"))
liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain"));
if (slotObj->hasProperty("branchSrc"))
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc"));
}
@@ -3486,6 +3501,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
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("setPostGain", Napi::Function::New(env, SetPostGain));
exports.Set("setBranchSrc", Napi::Function::New(env, SetBranchSrc));
exports.Set("clearChain", Napi::Function::New(env, ClearChain));
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
+18
View File
@@ -1,5 +1,6 @@
#include "SignalChain.h"
#include "Sandbox/SandboxedProcessor.h"
#include <cmath> // std::isfinite (postGain sanitisation)
#if ! JUCE_WINDOWS
#include <csetjmp>
@@ -287,6 +288,8 @@ void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& mi
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
@@ -548,6 +551,18 @@ void SignalChain::setPan(int slotId, float pan)
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);
@@ -674,6 +689,9 @@ juce::String SignalChain::savePreset() const
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();
+4
View File
@@ -30,6 +30,9 @@ struct ProcessorSlot
float pan = 0.0f;
int branch = 0;
int branchSrc = 0;
// Linear output gain applied right after the processor (and pan). Carries
// the per-amp loudness trim / per-branch level; 1.0 (default) = no-op.
float postGain = 1.0f;
// For VST plugins — their state as base64 for preset save/load
juce::MemoryBlock getState() const;
@@ -81,6 +84,7 @@ public:
void setPan(int slotId, float pan);
void setBranch(int slotId, int branch);
void setBranchSrc(int slotId, int src);
void setPostGain(int slotId, float gain);
void clear();
// Info