audio: replaceIR(slotId, path, gain) for in-place cab/IR swap (#83)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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: <name>" + the new path, mirroring LoadIRWorker.

Built (npm run build:audio) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jafz2001 <ignacio.fritis@mundotelecomunicaciones.cl>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
Jorge Fritis
2026-07-08 22:20:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Jafz2001 byrongamatos
parent a53fd38732
commit 56e929da4e
5 changed files with 85 additions and 2 deletions
+66
View File
@@ -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<IRLoader>();
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<Napi::Number>().Int32Value();
const auto irPath = info[1].As<Napi::String>().Utf8Value();
const float gain = (info.Length() >= 3 && info[2].IsNumber())
? info[2].As<Napi::Number>().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));
+7 -1
View File
@@ -432,7 +432,8 @@ void SignalChain::removeProcessor(int slotId)
if (idx >= 0) slots.remove(idx);
}
bool SignalChain::replaceProcessor(int slotId, std::unique_ptr<juce::AudioProcessor> processor)
bool SignalChain::replaceProcessor(int slotId, std::unique_ptr<juce::AudioProcessor> processor,
const juce::String& newName, const juce::String& newPath)
{
if (!processor) return false;
@@ -469,6 +470,11 @@ bool SignalChain::replaceProcessor(int slotId, std::unique_ptr<juce::AudioProces
auto* slot = slots[idx];
old = std::move(slot->processor);
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.
+6 -1
View File
@@ -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<juce::AudioProcessor> 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<juce::AudioProcessor> 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
+4
View File
@@ -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);
+2
View File
@@ -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),