feat(audio): per-slot polarity flip (setPhase) for parallel rigs

Two amps whose circuits invert differently (e.g. a 3-stage vs a 4-stage
preamp) partially CANCEL when their parallel branches merge. setPhase(slotId,
inverted) flips one slot's polarity: the Oe factor rides the same multiply as
postGain in runSlot, is saved/restored with the chain state (phaseInv,
only emitted when set), and is exposed through NodeAddon + the audio bridge +
preload like the other St-1/St-2 routing calls. Default off = bit-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
IfritisZ
2026-07-14 09:00:51 -04:00
co-authored by Claude Opus 4.8
parent 68f9bc5fad
commit d8356d2ada
5 changed files with 39 additions and 2 deletions
+17
View File
@@ -2707,6 +2707,20 @@ static Napi::Value SetBranch(const Napi::CallbackInfo& info)
return info.Env().Undefined();
}
// setPhase(slotId, inverted): polarity flip (Ø) — de-phases one amp of a
// parallel rig whose circuit inverts relative to the other.
static Napi::Value SetPhase(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2)
{
int slotId = info[0].As<Napi::Number>().Int32Value();
bool inv = info[1].ToBoolean().Value();
liveEngine->getSignalChain().setPhase(slotId, inv);
}
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)
{
@@ -3386,6 +3400,8 @@ public:
liveEngine->getSignalChain().setPostGain(slotId, (float)(double)slotObj->getProperty("postGain"));
if (slotObj->hasProperty("branchSrc"))
liveEngine->getSignalChain().setBranchSrc(slotId, (int)slotObj->getProperty("branchSrc"));
if (slotObj->hasProperty("phaseInv"))
liveEngine->getSignalChain().setPhase(slotId, (bool)slotObj->getProperty("phaseInv"));
}
// Restore processor state (JUCE-format base64; IR/NAM slots also
@@ -3660,6 +3676,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
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("setPhase", Napi::Function::New(env, SetPhase));
exports.Set("clearChain", Napi::Function::New(env, ClearChain));
exports.Set("getChainState", Napi::Function::New(env, GetChainState));
exports.Set("openPluginEditor", Napi::Function::New(env, OpenPluginEditor));
+13 -2
View File
@@ -339,8 +339,11 @@ void SignalChain::processLocked(juce::AudioBuffer<float>& buffer, juce::MidiBuff
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);
// Ø (phaseInv) rides the same multiply as the trim: a -1 factor flips
// the slot's polarity (de-phases one amp of a parallel rig).
const float post = slot->phaseInv ? -slot->postGain : slot->postGain;
if (post != 1.0f)
buf.applyGain(0, numSamples, post);
};
// Fast path: no parallel branch → plain serial chain. Behaviour is unchanged
@@ -676,6 +679,13 @@ void SignalChain::setBranchSrc(int slotId, int src)
if (idx >= 0) slots[idx]->branchSrc = juce::jlimit(0, 2, src);
}
void SignalChain::setPhase(int slotId, bool inverted)
{
const juce::ScopedLock sl(lock);
int idx = findSlotIndex(slotId);
if (idx >= 0) slots[idx]->phaseInv = inverted;
}
void SignalChain::clear()
{
// Detach the slots under a BRIEF lock, then destroy them OFF the lock. The
@@ -791,6 +801,7 @@ juce::String SignalChain::savePreset() const
// 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);
if (slot->phaseInv) slotObj->setProperty("phaseInv", true);
// Save processor state as base64
auto state = slot->getState();
+5
View File
@@ -33,6 +33,10 @@ struct ProcessorSlot
// 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;
// Polarity flip (Ø), applied with postGain. Lets one amp of a parallel rig
// be phase-inverted so two amps whose circuits invert differently (e.g. a
// 3-stage vs 4-stage preamp) don't cancel when their branches merge.
bool phaseInv = false;
// For VST plugins — their state as base64 for preset save/load
juce::MemoryBlock getState() const;
@@ -90,6 +94,7 @@ public:
void setBranch(int slotId, int branch);
void setBranchSrc(int slotId, int src);
void setPostGain(int slotId, float gain);
void setPhase(int slotId, bool inverted); // polarity flip (Ø), see slot.phaseInv
void clear();
// Info
+3
View File
@@ -1220,6 +1220,9 @@ export function initAudioBridge(): void {
ipcMain.handle('audio:setBranchSrc', (_event, slotId: number, src: number) => {
audio?.setBranchSrc?.(slotId, src);
});
ipcMain.handle('audio:setPhase', (_event, slotId: number, inverted: boolean) => {
audio?.setPhase?.(slotId, inverted);
});
ipcMain.handle('audio:clearChain', () => {
audio?.clearChain();
+1
View File
@@ -436,6 +436,7 @@ const feedBackDesktopApi = {
setBranch: (slotId: number, branch: number) => ipcRenderer.invoke('audio:setBranch', slotId, branch),
setBranchSrc: (slotId: number, src: number) => ipcRenderer.invoke('audio:setBranchSrc', slotId, src),
setPostGain: (slotId: number, gain: number) => ipcRenderer.invoke('audio:setPostGain', slotId, gain),
setPhase: (slotId: number, inverted: boolean) => ipcRenderer.invoke('audio:setPhase', slotId, inverted),
clearChain: () => ipcRenderer.invoke('audio:clearChain'),
getChainState: () => ipcRenderer.invoke('audio:getChainState'),