diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 358e193..c6a5bab 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -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().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)); diff --git a/src/audio/SignalChain.cpp b/src/audio/SignalChain.cpp index 8f85c8a..78724a7 100644 --- a/src/audio/SignalChain.cpp +++ b/src/audio/SignalChain.cpp @@ -339,8 +339,11 @@ void SignalChain::processLocked(juce::AudioBuffer& 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(); diff --git a/src/audio/SignalChain.h b/src/audio/SignalChain.h index 77e04a9..ab63dd6 100644 --- a/src/audio/SignalChain.h +++ b/src/audio/SignalChain.h @@ -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 diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index c2d1866..c6d7001 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -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(); diff --git a/src/main/preload.ts b/src/main/preload.ts index b1f0467..37ecbe8 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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'),