mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-14 04:31:20 +00:00
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:
co-authored by
Claude Opus 4.8
parent
27e8f56ad8
commit
c8415113c8
@@ -2539,6 +2539,18 @@ static Napi::Value SetPan(const Napi::CallbackInfo& info)
|
|||||||
return info.Env().Undefined();
|
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)
|
static Napi::Value SetBranch(const Napi::CallbackInfo& info)
|
||||||
{
|
{
|
||||||
auto liveEngine = snapshotEngine();
|
auto liveEngine = snapshotEngine();
|
||||||
@@ -2586,6 +2598,7 @@ static Napi::Value GetChainState(const Napi::CallbackInfo& info)
|
|||||||
obj.Set("pan", slots[i]->pan);
|
obj.Set("pan", slots[i]->pan);
|
||||||
obj.Set("branch", slots[i]->branch);
|
obj.Set("branch", slots[i]->branch);
|
||||||
obj.Set("branchSrc", slots[i]->branchSrc);
|
obj.Set("branchSrc", slots[i]->branchSrc);
|
||||||
|
obj.Set("postGain", slots[i]->postGain);
|
||||||
obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor());
|
obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor());
|
||||||
result.Set((uint32_t)i, obj);
|
result.Set((uint32_t)i, obj);
|
||||||
}
|
}
|
||||||
@@ -3221,6 +3234,8 @@ public:
|
|||||||
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
|
liveEngine->getSignalChain().setPan(slotId, (float)(double)slotObj->getProperty("pan"));
|
||||||
if (slotObj->hasProperty("branch"))
|
if (slotObj->hasProperty("branch"))
|
||||||
liveEngine->getSignalChain().setBranch(slotId, (int)slotObj->getProperty("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"))
|
if (slotObj->hasProperty("branchSrc"))
|
||||||
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("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("setBypass", Napi::Function::New(env, SetBypass));
|
||||||
exports.Set("setPan", Napi::Function::New(env, SetPan));
|
exports.Set("setPan", Napi::Function::New(env, SetPan));
|
||||||
exports.Set("setBranch", Napi::Function::New(env, SetBranch));
|
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("setBranchSrc", Napi::Function::New(env, SetBranchSrc));
|
||||||
exports.Set("clearChain", Napi::Function::New(env, ClearChain));
|
exports.Set("clearChain", Napi::Function::New(env, ClearChain));
|
||||||
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
|
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "SignalChain.h"
|
#include "SignalChain.h"
|
||||||
#include "Sandbox/SandboxedProcessor.h"
|
#include "Sandbox/SandboxedProcessor.h"
|
||||||
|
#include <cmath> // std::isfinite (postGain sanitisation)
|
||||||
|
|
||||||
#if ! JUCE_WINDOWS
|
#if ! JUCE_WINDOWS
|
||||||
#include <csetjmp>
|
#include <csetjmp>
|
||||||
@@ -287,6 +288,8 @@ void SignalChain::process(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& mi
|
|||||||
slotMidi.addEvent(drained[i].msg, 0);
|
slotMidi.addEvent(drained[i].msg, 0);
|
||||||
invokePlugin(*slot, [&](juce::AudioProcessor& p) { p.processBlock(buf, slotMidi); });
|
invokePlugin(*slot, [&](juce::AudioProcessor& p) { p.processBlock(buf, slotMidi); });
|
||||||
applyPan(buf, numSamples, slot->pan);
|
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
|
// 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);
|
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)
|
void SignalChain::setBranch(int slotId, int branch)
|
||||||
{
|
{
|
||||||
const juce::ScopedLock sl(lock);
|
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->pan != 0.0f) slotObj->setProperty("pan", slot->pan);
|
||||||
if (slot->branch != 0) slotObj->setProperty("branch", slot->branch);
|
if (slot->branch != 0) slotObj->setProperty("branch", slot->branch);
|
||||||
if (slot->branchSrc != 0) slotObj->setProperty("branchSrc", slot->branchSrc);
|
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
|
// Save processor state as base64
|
||||||
auto state = slot->getState();
|
auto state = slot->getState();
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ struct ProcessorSlot
|
|||||||
float pan = 0.0f;
|
float pan = 0.0f;
|
||||||
int branch = 0;
|
int branch = 0;
|
||||||
int branchSrc = 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
|
// For VST plugins — their state as base64 for preset save/load
|
||||||
juce::MemoryBlock getState() const;
|
juce::MemoryBlock getState() const;
|
||||||
@@ -81,6 +84,7 @@ public:
|
|||||||
void setPan(int slotId, float pan);
|
void setPan(int slotId, float pan);
|
||||||
void setBranch(int slotId, int branch);
|
void setBranch(int slotId, int branch);
|
||||||
void setBranchSrc(int slotId, int src);
|
void setBranchSrc(int slotId, int src);
|
||||||
|
void setPostGain(int slotId, float gain);
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
|
|||||||
@@ -1100,6 +1100,9 @@ export function initAudioBridge(): void {
|
|||||||
ipcMain.handle('audio:setBranch', (_event, slotId: number, branch: number) => {
|
ipcMain.handle('audio:setBranch', (_event, slotId: number, branch: number) => {
|
||||||
audio?.setBranch?.(slotId, branch);
|
audio?.setBranch?.(slotId, branch);
|
||||||
});
|
});
|
||||||
|
ipcMain.handle('audio:setPostGain', (_event, slotId: number, gain: number) => {
|
||||||
|
audio?.setPostGain?.(slotId, gain);
|
||||||
|
});
|
||||||
ipcMain.handle('audio:setBranchSrc', (_event, slotId: number, src: number) => {
|
ipcMain.handle('audio:setBranchSrc', (_event, slotId: number, src: number) => {
|
||||||
audio?.setBranchSrc?.(slotId, src);
|
audio?.setBranchSrc?.(slotId, src);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ const feedBackDesktopApi = {
|
|||||||
setPan: (slotId: number, pan: number) => ipcRenderer.invoke('audio:setPan', slotId, pan),
|
setPan: (slotId: number, pan: number) => ipcRenderer.invoke('audio:setPan', slotId, pan),
|
||||||
setBranch: (slotId: number, branch: number) => ipcRenderer.invoke('audio:setBranch', slotId, branch),
|
setBranch: (slotId: number, branch: number) => ipcRenderer.invoke('audio:setBranch', slotId, branch),
|
||||||
setBranchSrc: (slotId: number, src: number) => ipcRenderer.invoke('audio:setBranchSrc', slotId, src),
|
setBranchSrc: (slotId: number, src: number) => ipcRenderer.invoke('audio:setBranchSrc', slotId, src),
|
||||||
|
setPostGain: (slotId: number, gain: number) => ipcRenderer.invoke('audio:setPostGain', slotId, gain),
|
||||||
clearChain: () => ipcRenderer.invoke('audio:clearChain'),
|
clearChain: () => ipcRenderer.invoke('audio:clearChain'),
|
||||||
getChainState: () => ipcRenderer.invoke('audio:getChainState'),
|
getChainState: () => ipcRenderer.invoke('audio:getChainState'),
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,30 @@ target_compile_definitions(signalchain_fault_test PRIVATE
|
|||||||
JUCE_CHECK_MEMORY_LEAKS=0)
|
JUCE_CHECK_MEMORY_LEAKS=0)
|
||||||
add_test(NAME signalchain_fault_test COMMAND signalchain_fault_test)
|
add_test(NAME signalchain_fault_test COMMAND signalchain_fault_test)
|
||||||
|
|
||||||
|
# --- per-slot postGain unit test (PR #58) ---
|
||||||
|
# Drives a REAL SignalChain::process() with an identity in-process processor and
|
||||||
|
# asserts postGain scales output (all channels), rejects NaN, and is serialized
|
||||||
|
# by savePreset(). Same link set as signalchain_fault_test (SignalChain.cpp pulls
|
||||||
|
# in the sandbox blocklist symbols via invokePlugin's fault path).
|
||||||
|
add_executable(signalchain_postgain_test
|
||||||
|
signalchain_postgain_test.cpp
|
||||||
|
"${REPO_ROOT}/src/audio/SignalChain.cpp"
|
||||||
|
"${SANDBOX}/SandboxedProcessor.cpp"
|
||||||
|
"${SANDBOX}/SandboxFactory_shared.cpp" "${SANDBOX}/SandboxFactory_posix.cpp"
|
||||||
|
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp"
|
||||||
|
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
|
||||||
|
"${SANDBOX}/SubprocessHandle_posix.cpp"
|
||||||
|
"${SANDBOX}/Protocol.cpp")
|
||||||
|
target_include_directories(signalchain_postgain_test PRIVATE "${REPO_ROOT}/src/audio")
|
||||||
|
target_link_libraries(signalchain_postgain_test PRIVATE
|
||||||
|
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
|
||||||
|
juce::juce_audio_processors juce::juce_core juce::juce_data_structures
|
||||||
|
juce::juce_dsp juce::juce_events)
|
||||||
|
target_compile_definitions(signalchain_postgain_test PRIVATE
|
||||||
|
JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0
|
||||||
|
JUCE_STANDALONE_APPLICATION=0 JUCE_REPORT_APP_USAGE=0)
|
||||||
|
add_test(NAME signalchain_postgain_test COMMAND signalchain_postgain_test)
|
||||||
|
|
||||||
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
|
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
|
||||||
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
|
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
|
||||||
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
|
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Unit test for the per-slot postGain feature in SignalChain (PR #58).
|
||||||
|
//
|
||||||
|
// Drives a REAL SignalChain::process() with an identity in-process processor
|
||||||
|
// and asserts:
|
||||||
|
// 1. postGain scales the slot output on every channel (all-channel applyGain),
|
||||||
|
// 2. a non-finite gain (NaN) is REJECTED by setPostGain (it must never reach
|
||||||
|
// the audio buffer — an unclamped NaN would poison the whole chain),
|
||||||
|
// 3. savePreset() serializes postGain (so a save/load round-trip preserves it;
|
||||||
|
// the omission was the bug this PR's review caught), and only when it is
|
||||||
|
// non-default (matching the pan/branch "emit non-default only" convention).
|
||||||
|
//
|
||||||
|
// No subprocess / VST3 fixture — the processor is a trivial in-process
|
||||||
|
// AudioProcessor, mirroring signalchain_fault_test.cpp.
|
||||||
|
|
||||||
|
#include "SignalChain.h"
|
||||||
|
|
||||||
|
#include <juce_audio_processors/juce_audio_processors.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Identity: leaves the buffer untouched, so the test isolates SignalChain's
|
||||||
|
// post-gain application from any plugin DSP.
|
||||||
|
class IdentityProcessor : public juce::AudioProcessor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
IdentityProcessor()
|
||||||
|
: juce::AudioProcessor(BusesProperties()
|
||||||
|
.withInput("In", juce::AudioChannelSet::stereo(), true)
|
||||||
|
.withOutput("Out", juce::AudioChannelSet::stereo(), true)) {}
|
||||||
|
|
||||||
|
const juce::String getName() const override { return "Identity"; }
|
||||||
|
void prepareToPlay(double, int) override {}
|
||||||
|
void releaseResources() override {}
|
||||||
|
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override {}
|
||||||
|
double getTailLengthSeconds() const override { return 0.0; }
|
||||||
|
bool acceptsMidi() const override { return false; }
|
||||||
|
bool producesMidi() const override { return false; }
|
||||||
|
bool isMidiEffect() const override { return false; }
|
||||||
|
juce::AudioProcessorEditor* createEditor() override { return nullptr; }
|
||||||
|
bool hasEditor() const override { return false; }
|
||||||
|
int getNumPrograms() override { return 1; }
|
||||||
|
int getCurrentProgram() override { return 0; }
|
||||||
|
void setCurrentProgram(int) override {}
|
||||||
|
const juce::String getProgramName(int) override { return {}; }
|
||||||
|
void changeProgramName(int, const juce::String&) override {}
|
||||||
|
void getStateInformation(juce::MemoryBlock&) override {}
|
||||||
|
void setStateInformation(const void*, int) override {}
|
||||||
|
};
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
void check(bool cond, const char* msg)
|
||||||
|
{
|
||||||
|
std::printf("%s %s\n", cond ? "ok " : "FAIL", msg);
|
||||||
|
if (! cond) ++g_failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool approx(float a, float b) { return std::fabs(a - b) < 1.0e-5f; }
|
||||||
|
|
||||||
|
int addIdentity(SignalChain& chain)
|
||||||
|
{
|
||||||
|
return chain.addProcessor(std::make_unique<IdentityProcessor>(),
|
||||||
|
ProcessorSlot::Type::VST, "id", "/tmp/identity.vst3");
|
||||||
|
}
|
||||||
|
|
||||||
|
juce::AudioBuffer<float> unityStereo(int numSamples)
|
||||||
|
{
|
||||||
|
juce::AudioBuffer<float> buf(2, numSamples);
|
||||||
|
for (int ch = 0; ch < 2; ++ch)
|
||||||
|
for (int i = 0; i < numSamples; ++i)
|
||||||
|
buf.setSample(ch, i, 1.0f);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
constexpr double kSampleRate = 48000.0;
|
||||||
|
constexpr int kBlockSize = 128;
|
||||||
|
|
||||||
|
// 1. postGain scales the slot output on both channels.
|
||||||
|
{
|
||||||
|
SignalChain chain;
|
||||||
|
chain.prepare(kSampleRate, kBlockSize);
|
||||||
|
const int id = addIdentity(chain);
|
||||||
|
check(id >= 0, "addProcessor returns a valid slot id");
|
||||||
|
chain.setPostGain(id, 0.5f);
|
||||||
|
|
||||||
|
auto buf = unityStereo(kBlockSize);
|
||||||
|
juce::MidiBuffer midi;
|
||||||
|
chain.process(buf, midi);
|
||||||
|
check(approx(buf.getSample(0, 0), 0.5f) && approx(buf.getSample(1, 0), 0.5f),
|
||||||
|
"postGain 0.5 halves both channels (all-channel applyGain)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. A NaN gain is rejected — the prior gain stays, and audio stays finite.
|
||||||
|
{
|
||||||
|
SignalChain chain;
|
||||||
|
chain.prepare(kSampleRate, kBlockSize);
|
||||||
|
const int id = addIdentity(chain);
|
||||||
|
chain.setPostGain(id, 2.0f);
|
||||||
|
chain.setPostGain(id, std::nanf("")); // must be ignored, not stored
|
||||||
|
|
||||||
|
auto buf = unityStereo(kBlockSize);
|
||||||
|
juce::MidiBuffer midi;
|
||||||
|
chain.process(buf, midi);
|
||||||
|
check(std::isfinite(buf.getSample(0, 0)) && approx(buf.getSample(0, 0), 2.0f),
|
||||||
|
"NaN gain rejected: prior 2.0 retained, output finite");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. savePreset serializes postGain only when non-default.
|
||||||
|
{
|
||||||
|
SignalChain chain;
|
||||||
|
chain.prepare(kSampleRate, kBlockSize);
|
||||||
|
const int id = addIdentity(chain);
|
||||||
|
|
||||||
|
check(! chain.savePreset().contains("postGain"),
|
||||||
|
"default postGain (1.0) is NOT emitted (byte-stable presets)");
|
||||||
|
|
||||||
|
chain.setPostGain(id, 0.25f);
|
||||||
|
const juce::String preset = chain.savePreset();
|
||||||
|
check(preset.contains("postGain"),
|
||||||
|
"non-default postGain is serialized by savePreset (round-trip fix)");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\n%s (%d failure%s)\n",
|
||||||
|
g_failures ? "TESTS FAILED" : "all tests passed",
|
||||||
|
g_failures, g_failures == 1 ? "" : "s");
|
||||||
|
return g_failures ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user