From 56e929da4ed2f8d28f8cf7be6041e319a7634476 Mon Sep 17 00:00:00 2001 From: Jorge Fritis <120731233+Jafz2001@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:20:06 -0400 Subject: [PATCH] audio: replaceIR(slotId, path, gain) for in-place cab/IR swap (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * audio: add replaceIR(slotId, path, gain) for in-place cab/IR swap Swap an existing convolution slot's IR without a full loadPreset, so the rest of the chain — the amp VST above all — is not torn down and rebuilt (that teardown is the ~1-2 s wait when changing cabs / mic position). Mirrors the existing loadIR worker but calls SignalChain::replaceProcessor(slotId, ...); optional gain updates the slot post-gain (the cab makeup). Co-Authored-By: Claude Opus 4.8 (1M context) * audio: expose replaceIR over the IPC bridge Wire the native replaceIR(slotId, path, gain) through audio-bridge (ipcMain handle) + preload, so renderers get feedBackDesktop.audio.replaceIR. Lets the rig-builder cab room swap a cab's IRs in place instead of a full loadPreset + param re-apply (that re-apply was the brief 'can't move the mic yet' lag). Co-Authored-By: Claude Opus 4.8 (1M context) * audio: replaceIR updates slot name/path so getChainState reflects the swap replaceProcessor deliberately preserves the target slot's name/path during the prepare/fault window (so a fault in prepareToPlay is blocklisted against the right plugin path). It kept them even on success, so after a successful replaceIR the slot's audio was the new IR but getChainState()/preset-save still reported the OLD IR name+path — a footgun for any consumer that persists a chain read back from getChainState(). Add optional newName/newPath to replaceProcessor, applied under the swap lock ONLY on success (empty = keep, so the sandbox-promotion caller is unchanged). ReplaceIRWorker passes "IR: " + the new path, mirroring LoadIRWorker. Built (npm run build:audio) clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Jafz2001 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: byrongamatos --- src/audio/NodeAddon.cpp | 66 +++++++++++++++++++++++++++++++++++++++ src/audio/SignalChain.cpp | 8 ++++- src/audio/SignalChain.h | 7 ++++- src/main/audio-bridge.ts | 4 +++ src/main/preload.ts | 2 ++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index d6d63ff..971554d 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -2474,6 +2474,71 @@ static Napi::Value LoadIR(const Napi::CallbackInfo& info) return deferred.Promise(); } +// Replace the IR of an EXISTING convolution slot in place (cab swap / mic move), +// so the rest of the chain — the amp VST above all — is NOT torn down and rebuilt. +// Mirrors LoadIRWorker but calls SignalChain::replaceProcessor(slotId, …) instead +// of addProcessor. Optional `gain` (>=0) updates the slot's post-gain (the cab +// makeup); a negative gain leaves the existing post-gain untouched. +class ReplaceIRWorker : public Napi::AsyncWorker +{ +public: + ReplaceIRWorker(Napi::Env env, Napi::Promise::Deferred deferred, + int slotId, std::string path, float gain) + : Napi::AsyncWorker(env), deferred_(deferred), + slotId_(slotId), irPath_(std::move(path)), gain_(gain) {} + + void Execute() override + { + auto liveEngine = snapshotEngine(); + if (!liveEngine) { ok_ = false; return; } + + const auto sr = loadSafeSampleRate(*liveEngine); + const auto bs = loadSafeBlockSize(*liveEngine); + auto processor = std::make_unique(); + processor->setPlayConfigDetails(2, 2, sr, bs); + processor->prepareToPlay(sr, bs); + if (! processor->loadIR(juce::File(juce::String(irPath_)))) { ok_ = false; return; } + + auto name = processor->getIRName(); + ok_ = liveEngine->getSignalChain().replaceProcessor( + slotId_, std::move(processor), + "IR: " + name, juce::String(irPath_)); + if (ok_ && gain_ >= 0.0f) + liveEngine->getSignalChain().setPostGain(slotId_, gain_); + } + + void OnOK() override { deferred_.Resolve(Napi::Boolean::New(Env(), ok_)); } + void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); } + +private: + Napi::Promise::Deferred deferred_; + int slotId_; + std::string irPath_; + float gain_; + bool ok_ = false; +}; + +static Napi::Value ReplaceIR(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (!snapshotEngine() || info.Length() < 2 + || !info[0].IsNumber() || !info[1].IsString()) { + deferred.Resolve(Napi::Boolean::New(env, false)); + return deferred.Promise(); + } + + const int slotId = info[0].As().Int32Value(); + const auto irPath = info[1].As().Utf8Value(); + const float gain = (info.Length() >= 3 && info[2].IsNumber()) + ? info[2].As().FloatValue() : -1.0f; + + auto worker = new ReplaceIRWorker(env, deferred, slotId, irPath, gain); + worker->Queue(); + return deferred.Promise(); +} + static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) { auto liveEngine = snapshotEngine(); @@ -3496,6 +3561,7 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("loadVST", Napi::Function::New(env, LoadVST)); exports.Set("loadNAMModel", Napi::Function::New(env, LoadNAMModel)); exports.Set("loadIR", Napi::Function::New(env, LoadIR)); + exports.Set("replaceIR", Napi::Function::New(env, ReplaceIR)); exports.Set("removeProcessor", Napi::Function::New(env, RemoveProcessor)); exports.Set("moveProcessor", Napi::Function::New(env, MoveProcessor)); exports.Set("setBypass", Napi::Function::New(env, SetBypass)); diff --git a/src/audio/SignalChain.cpp b/src/audio/SignalChain.cpp index 0a42e17..e152948 100644 --- a/src/audio/SignalChain.cpp +++ b/src/audio/SignalChain.cpp @@ -432,7 +432,8 @@ void SignalChain::removeProcessor(int slotId) if (idx >= 0) slots.remove(idx); } -bool SignalChain::replaceProcessor(int slotId, std::unique_ptr processor) +bool SignalChain::replaceProcessor(int slotId, std::unique_ptr processor, + const juce::String& newName, const juce::String& newPath) { if (!processor) return false; @@ -469,6 +470,11 @@ bool SignalChain::replaceProcessor(int slotId, std::unique_ptrprocessor); slot->processor = std::move(staging.processor); + // Swap succeeded: adopt the new identity so getChainState()/preset save + // report the swapped-in processor, not the one it replaced. Only when + // provided — the sandbox-promotion caller passes none and keeps identity. + if (newName.isNotEmpty()) slot->name = newName; + if (newPath.isNotEmpty()) slot->path = newPath; } // Tear the old processor down OUTSIDE the audio lock: releaseResources() (and // a VST3 destructor) can block, and must never stall process() on it. diff --git a/src/audio/SignalChain.h b/src/audio/SignalChain.h index 7495e4d..a3b7d55 100644 --- a/src/audio/SignalChain.h +++ b/src/audio/SignalChain.h @@ -63,7 +63,12 @@ public: // swaps under the audio lock; the old processor is torn down off the lock. // Returns false if the slot is gone or the incoming processor faulted in // prepareToPlay (in which case the existing processor is left untouched). - bool replaceProcessor(int slotId, std::unique_ptr processor); + // The OLD slot name/path are preserved during the prepare/fault window (so a + // fault is blocklisted against the right path); on SUCCESS, if newName/newPath + // are non-empty they replace the slot's identity so getChainState()/preset + // metadata reflect the swapped-in processor (used by replaceIR for cab swaps). + bool replaceProcessor(int slotId, std::unique_ptr processor, + const juce::String& newName = {}, const juce::String& newPath = {}); // Snapshot a slot's state for sandbox promotion, SAFELY. Runs hasEditor() // and getStateInformation() under the audio lock (so they can't race // process()'s processBlock on the same instance) and under the SEH/signal diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index f4e14f1..691df6f 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -1080,6 +1080,10 @@ export function initAudioBridge(): void { return await audio?.loadIR(irPath) ?? -1; }); + ipcMain.handle('audio:replaceIR', async (_event, slotId: number, irPath: string, gain?: number) => { + return await audio?.replaceIR(slotId, irPath, typeof gain === 'number' ? gain : -1) ?? false; + }); + ipcMain.handle('audio:removeProcessor', (_event, slotId: number) => { audio?.removeProcessor(slotId); vstSlotPaths.delete(slotId); diff --git a/src/main/preload.ts b/src/main/preload.ts index 9a360b3..40ef9f6 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -402,6 +402,8 @@ const feedBackDesktopApi = { loadVST: (pluginPath: string) => ipcRenderer.invoke('audio:loadVST', pluginPath), loadNAMModel: (modelPath: string) => ipcRenderer.invoke('audio:loadNAMModel', modelPath), loadIR: (irPath: string) => ipcRenderer.invoke('audio:loadIR', irPath), + replaceIR: (slotId: number, irPath: string, gain?: number) => + ipcRenderer.invoke('audio:replaceIR', slotId, irPath, gain), removeProcessor: (slotId: number) => ipcRenderer.invoke('audio:removeProcessor', slotId), moveProcessor: (from: number, to: number) => ipcRenderer.invoke('audio:moveProcessor', from, to), setBypass: (slotId: number, bypassed: boolean) => ipcRenderer.invoke('audio:setBypass', slotId, bypassed),