From dc77cd90e4eb11fb56c18f8091ae2ba6dd23988b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:39:34 +0000 Subject: [PATCH] v0.6.5: short masking duck on A/B / preset / undo switches to kill the level pop A large A/B or preset jump can move many parameters (and instantly re-inject the per-slot Level-Match gain) at once, popping. The engine already masks discrete switches with a ~4 ms raised-cosine duck; it now also accepts a forced-duck request so the SAME masking runs for a bulk swap even when only continuous controls changed. The wrapper requests the duck BEFORE the parameters change -- in abSwitchTo / undo / redo, and from the editor before a preset load / step / file load -- so the duck is already running when the new values arrive. https://claude.ai/code/session_01Y38PtwPxh2geBLta6yuUwv --- CMakeLists.txt | 2 +- src/PluginEditor.cpp | 15 ++++++++++----- src/PluginProcessor.cpp | 3 +++ src/dsp/AnamorphEngine.cpp | 11 ++++++++--- src/dsp/AnamorphEngine.h | 8 ++++++++ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 04a3258..943b84b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ cmake_minimum_required(VERSION 3.22) # cmake --build build --config Release # ============================================================================ -project(Anamorph VERSION 0.6.4 LANGUAGES C CXX) +project(Anamorph VERSION 0.6.5 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index 05aa262..f5fa37c 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -282,8 +282,8 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess presetPrev.setTooltip ("Previous preset"); presetNext.setTooltip ("Next preset"); presetName.setTooltip ("Presets"); // short, no period (#12) - presetPrev.onClick = [this] { processor.getPresets().step (-1); knobSweepTime = 0.45; refreshPresetDisplay(); }; - presetNext.onClick = [this] { processor.getPresets().step (+1); knobSweepTime = 0.45; refreshPresetDisplay(); }; + presetPrev.onClick = [this] { processor.getEngine().requestDuck(); processor.getPresets().step (-1); knobSweepTime = 0.45; refreshPresetDisplay(); }; + presetNext.onClick = [this] { processor.getEngine().requestDuck(); processor.getPresets().step (+1); knobSweepTime = 0.45; refreshPresetDisplay(); }; presetName.onClick = [this] { showPresetMenu(); }; addAndMakeVisible (presetPrev); addAndMakeVisible (presetNext); @@ -1143,6 +1143,7 @@ void AnamorphAudioProcessorEditor::showPresetMenu() if (r == 0) return; if (r == 10001) { showSavePreset (true); return; } if (r == 10002) { showLoadPreset(); return; } + processor.getEngine().requestDuck(); // mask the level jump (#1, 0.6.4) processor.getPresets().load (r - 1); knobSweepTime = 0.45; // sweep the knobs to the preset (#3) refreshPresetDisplay(); @@ -1160,10 +1161,14 @@ void AnamorphAudioProcessorEditor::showLoadPreset() [this] (const juce::FileChooser& fc) { const auto file = fc.getResult(); - if (file.existsAsFile() && processor.getPresets().loadFile (file)) + if (file.existsAsFile()) { - knobSweepTime = 0.45; // sweep the knobs to the preset (#3) - refreshPresetDisplay(); + processor.getEngine().requestDuck(); // mask the level jump (#1, 0.6.4) + if (processor.getPresets().loadFile (file)) + { + knobSweepTime = 0.45; // sweep the knobs to the preset (#3) + refreshPresetDisplay(); + } } }); } diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index 54a7a54..abef255 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -192,6 +192,7 @@ void AnamorphAudioProcessor::undo() { auto& st = abUndo[abActive]; if (st.undo.empty()) return; + engine.requestDuck(); // mask the level jump (#1, 0.6.4) st.redo.push_back (currentStateSet()); committed = st.undo.back(); st.undo.pop_back(); applyStateSet (committed); @@ -203,6 +204,7 @@ void AnamorphAudioProcessor::redo() { auto& st = abUndo[abActive]; if (st.redo.empty()) return; + engine.requestDuck(); // mask the level jump (#1, 0.6.4) st.undo.push_back (currentStateSet()); committed = st.redo.back(); st.redo.pop_back(); applyStateSet (committed); @@ -235,6 +237,7 @@ void AnamorphAudioProcessor::abSwitchTo (int slot) { abEnsureInit(); if (slot == abActive) return; + engine.requestDuck(); // mask the level jump (#1, 0.6.4) abSlot[abActive] = currentStateSet(); // store the whole state set in the old slot abMatchGain[abActive] = engine.getMatchGainDb(); // remember this slot's match (#23) abActive = slot; diff --git a/src/dsp/AnamorphEngine.cpp b/src/dsp/AnamorphEngine.cpp index fa58ec0..33f6e0f 100644 --- a/src/dsp/AnamorphEngine.cpp +++ b/src/dsp/AnamorphEngine.cpp @@ -185,9 +185,13 @@ void AnamorphEngine::copyContinuous (EngineParameters& dst, const EngineParamete void AnamorphEngine::setParameters (const EngineParameters& np) noexcept { + // A bulk swap (A/B, preset, undo) asks for a masking duck even when only + // continuous controls move, so a big level jump can't pop (#1, 0.6.4). + const bool forceDuck = duckRequest.exchange (0, std::memory_order_relaxed) != 0; + if (switchState == SwitchState::Normal) { - if (discreteDiffers (np, p)) + if (forceDuck || discreteDiffers (np, p)) { pendingP = np; pendingAlgoReset = (np.algorithm != p.algorithm); @@ -205,9 +209,10 @@ void AnamorphEngine::setParameters (const EngineParameters& np) noexcept { // Mid-duck: remember the latest target and keep continuous controls live. pendingP = np; - if (switchState == SwitchState::FadeIn && discreteDiffers (np, p)) + if (switchState == SwitchState::FadeIn && (forceDuck || discreteDiffers (np, p))) { - // A new discrete change arrived as we were fading back in: duck again. + // A new discrete change (or a forced bulk swap) arrived as we were + // fading back in: duck again. pendingAlgoReset = (np.algorithm != p.algorithm); switchState = SwitchState::FadeOut; } diff --git a/src/dsp/AnamorphEngine.h b/src/dsp/AnamorphEngine.h index 385ced5..14c0ae1 100644 --- a/src/dsp/AnamorphEngine.h +++ b/src/dsp/AnamorphEngine.h @@ -75,6 +75,13 @@ class AnamorphEngine // remembered match value on a slot switch; consumed on the audio thread. void injectMatchGainDb (float db) noexcept { matchInject.store (db, std::memory_order_relaxed); } + // Request a short raised-cosine output duck around the NEXT parameter swap, + // even if it's continuous-only (#1, 0.6.4 feedback): an A/B or preset jump can + // move many params at once and pop, so the wrapper asks for the same masking + // duck the engine already uses for discrete switches. Call BEFORE changing the + // parameters so the duck is already running when the new values arrive. + void requestDuck() noexcept { duckRequest.store (1, std::memory_order_relaxed); } + private: void updateDerived(); void applyInputConditioning (float* L, float* R, int n) noexcept; @@ -130,6 +137,7 @@ class AnamorphEngine static constexpr float kNoInject = -1000.0f; std::atomic matchInject { kNoInject }; // pending per-slot match restore (#23) + std::atomic duckRequest { 0 }; // force a duck around a bulk param swap (#1, 0.6.4) // Dry-path delay (integer) to align dry with wet latency in the mix. juce::AudioBuffer dryDelayBuffer;