From 33006f47c92cf27ce506ae8e0e35ea6a3c32365d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 23:41:59 +0000 Subject: [PATCH] v0.6.4: state-set undo/AB/copy, Mono Maker Mix fix, slider/anim polish, tooltip revert 6. Preset/Undo/Redo/A-B now carry a full STATE SET (params + preset name + dirty baseline), not just parameters, so the displayed name and dirty-star always match the restored sound. PresetManager gains baseline()/setMeta(); the processor stores StateSet in every undo entry and A/B slot, and undo/redo, A/B switch and Copy restore the metadata alongside the params. Verified against the six feedback cases (A-E). State (incl. per-slot name/baseline) is serialised with backward-compat for older sessions. 5. Mono Maker: the driven low band no longer bypasses Mix. The un-driven mono low is kept as the dry low and dry/wet-blended with the SAME per-sample Mix as the highs, so Mix=0 yields the un-driven mono low (no Drive roar) and Mix=1 the driven one; the low's mono-ing still applies at all Mix values. 1. UI Animations toggle adopts its new state immediately on click, so turning it ON plays the switch's own slide and turning it OFF snaps -- the user feels the difference on the switch itself. 2. Slider thumb body fill now brightens in two distinct levels (hover vs press), like the knob face. 3. Knob/slider position only eases during a short window opened by a preset / A-B / undo / algorithm change; scroll-wheel and host automation edits snap so they never lag or mislead. 4. Reverted the tooltip fade-out (it could leave tooltips not showing); back to the plain instant tooltip window. https://claude.ai/code/session_01Y38PtwPxh2geBLta6yuUwv --- CMakeLists.txt | 2 +- src/PluginEditor.cpp | 32 +++++++--- src/PluginEditor.h | 32 ++-------- src/PluginProcessor.cpp | 124 +++++++++++++++++++++++++++---------- src/PluginProcessor.h | 19 +++++- src/PresetManager.h | 11 ++++ src/dsp/AnamorphEngine.cpp | 59 ++++++++++-------- src/dsp/AnamorphEngine.h | 9 ++- src/gui/LookAndFeel.cpp | 4 +- 9 files changed, 188 insertions(+), 104 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 147d562..04a3258 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.3 LANGUAGES C CXX) +project(Anamorph VERSION 0.6.4 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 4ed210c..05aa262 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -263,7 +263,7 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess addAndMakeVisible (titleButton); abControl.getActive = [this] { return processor.abActiveSlot(); }; - abControl.onToggle = [this] { processor.abSwitchTo (processor.abActiveSlot() == 0 ? 1 : 0); repaint(); }; + abControl.onToggle = [this] { processor.abSwitchTo (processor.abActiveSlot() == 0 ? 1 : 0); knobSweepTime = 0.45; refreshPresetDisplay(); repaint(); }; abControl.setTooltip ("A/B Compare"); // #17 (no period) addAndMakeVisible (abControl); copyButton.onClick = [this] { processor.abCopyToOther(); }; @@ -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); refreshPresetDisplay(); }; - presetNext.onClick = [this] { processor.getPresets().step (+1); refreshPresetDisplay(); }; + presetPrev.onClick = [this] { processor.getPresets().step (-1); knobSweepTime = 0.45; refreshPresetDisplay(); }; + presetNext.onClick = [this] { processor.getPresets().step (+1); knobSweepTime = 0.45; refreshPresetDisplay(); }; presetName.onClick = [this] { showPresetMenu(); }; addAndMakeVisible (presetPrev); addAndMakeVisible (presetNext); @@ -326,8 +326,8 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess redoButton.setComponentID ("icon"); undoButton.setTooltip ("Undo"); redoButton.setTooltip ("Redo"); - undoButton.onClick = [this] { processor.undo(); }; - redoButton.onClick = [this] { processor.redo(); }; + undoButton.onClick = [this] { processor.undo(); knobSweepTime = 0.45; refreshPresetDisplay(); }; + redoButton.onClick = [this] { processor.redo(); knobSweepTime = 0.45; refreshPresetDisplay(); }; addAndMakeVisible (undoButton); addAndMakeVisible (redoButton); @@ -343,7 +343,7 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess // --- WIDEN module (the Simple-mode core) --- setupCombo (algorithmBox, pid::algorithm, "The stereo-widening algorithm."); // #4 - algorithmBox.onChange = [this] { updateAlgoControls(); resized(); }; + algorithmBox.onChange = [this] { knobSweepTime = 0.45; updateAlgoControls(); resized(); }; algorithmLabel.setText ("WIDEN", juce::dontSendNotification); algorithmLabel.setJustificationType (juce::Justification::centredLeft); algorithmLabel.setColour (juce::Label::textColourId, colours::textDim); @@ -552,6 +552,11 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess animToggle.setTooltip (tidyTip ("Smooth micro-animations on hovers, presses and switches")); // no F3 ref (#4) settingsBackdrop.addAndMakeVisible (animToggle); buttonAtts.add (new ButtonAttachment (processor.getAPVTS(), pid::uiAnimations, animToggle)); + // Adopt the new state IMMEDIATELY on click (not on the next 24 Hz tick): so + // turning it ON makes uiAnimOn true before the next frame and the toggle's own + // slide plays, while turning it OFF makes it false first so the toggle snaps -- + // the user feels the on/off difference on the switch itself (#1). + animToggle.onClick = [this] { uiAnimOn = animToggle.getToggleState(); }; // Initial cached view-state from the (recalled) parameters. advanced = advancedToggle.getToggleState(); @@ -586,13 +591,11 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess lastFrameTime = t; stepMeterReveal (dt); stepMicroAnims (dt); - tooltips.step ((float) dt); // tooltip fade-out (#4) }); } AnamorphAudioProcessorEditor::~AnamorphAudioProcessorEditor() { - tooltips.animOn = false; // no fade stepping during teardown (#4) stopTimer(); openGLContext.detach(); channelModeBox.setLookAndFeel (nullptr); @@ -847,7 +850,6 @@ void AnamorphAudioProcessorEditor::timerCallback() updateMsLabels(); uiAnimOn = animToggle.getToggleState(); // micro-anims follow the Settings switch (F3) - tooltips.animOn = uiAnimOn; // tooltip fade follows the same switch (#4) // Whole-window scale: follow the parameter (Settings combo, state recall, F4). if (uiScaleBox.getSelectedItemIndex() != lastScaleIdx) @@ -976,6 +978,12 @@ void AnamorphAudioProcessorEditor::stepMicroAnims (double dt) const float rOn = uiAnimOn ? 1.0f - std::exp (-(float) dt / 0.055f) : 1.0f; const float rPos = uiAnimOn ? 1.0f - std::exp (-(float) dt / 0.090f) : 1.0f; + // Knob/slider position only EASES while a sweep window is open (preset / A-B / + // undo / algorithm change); outside it, a value jump from the scroll wheel or + // host automation snaps instantly so it never lags or misleads (#3). + if (knobSweepTime > 0.0) knobSweepTime -= dt; + const bool sweeping = uiAnimOn && knobSweepTime > 0.0; + for (auto* c : animated) { auto& props = c->getProperties(); @@ -1015,7 +1023,7 @@ void AnamorphAudioProcessorEditor::stepMicroAnims (double dt) // ease out of the instant the value jumps. const float realPos = (float) s->valueToProportionOfLength (s->getValue()); const float curr = (float) (double) props.getWithDefault (vpos, (double) realPos); - float vp = (interacting || ! uiAnimOn) ? realPos + float vp = (interacting || ! sweeping) ? realPos : curr + (realPos - curr) * rPos; if (std::abs (vp - realPos) < 0.0015f) vp = realPos; if (std::abs (vp - curr) > 0.0004f) c->repaint(); @@ -1136,6 +1144,7 @@ void AnamorphAudioProcessorEditor::showPresetMenu() if (r == 10001) { showSavePreset (true); return; } if (r == 10002) { showLoadPreset(); return; } processor.getPresets().load (r - 1); + knobSweepTime = 0.45; // sweep the knobs to the preset (#3) refreshPresetDisplay(); }); } @@ -1152,7 +1161,10 @@ void AnamorphAudioProcessorEditor::showLoadPreset() { const auto file = fc.getResult(); if (file.existsAsFile() && processor.getPresets().loadFile (file)) + { + knobSweepTime = 0.45; // sweep the knobs to the preset (#3) refreshPresetDisplay(); + } }); } diff --git a/src/PluginEditor.h b/src/PluginEditor.h index 2c40599..853f7c3 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -51,32 +51,6 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, void paint (juce::Graphics& g) override { g.fillAll (juce::Colour (0x66090b0e)); } }; - // Tooltip window with a weak, fast non-linear fade-out so hints don't blink - // out abruptly (#4). JUCE hides a tooltip by calling setVisible(false); we - // intercept that and ramp the window alpha down over ~90 ms instead, stepped - // from the editor's vblank. Off (instant hide) when UI Animations is off. - struct FadingTooltip : public juce::TooltipWindow - { - FadingTooltip() : juce::TooltipWindow (nullptr, 600) {} - bool animOn = true; - bool fading = false; - float a = 1.0f; - void setVisible (bool shouldShow) override - { - if (shouldShow) { fading = false; a = 1.0f; setAlpha (1.0f); juce::TooltipWindow::setVisible (true); } - else if (! animOn) { juce::TooltipWindow::setVisible (false); } - else if (isVisible() && ! fading) { fading = true; a = 1.0f; } // begin fade, stay up - // (already fading: ignore; step() will hide it) - } - void step (float dt) - { - if (! fading) return; - a -= dt / 0.09f; // ~90 ms - if (a <= 0.0f) { fading = false; a = 1.0f; setAlpha (1.0f); juce::TooltipWindow::setVisible (false); } - else setAlpha (a * a); // ease-out (non-linear) tail - } - }; - // A/B control: shows "A / B" with the active letter bright, the other dim, // a single click toggles (FabFilter-style). Wrapped in a racetrack/stadium // frame with a micro-gradient + edge glow to match the design language (#6). @@ -120,7 +94,7 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, anamorph::gui::CompactComboLookAndFeel compactCombo; // smaller list for Input combos (#12) anamorph::gui::SimpleComboLookAndFeel simpleCombo; // bigger text for Simple-mode Widen combos (#17) juce::OpenGLContext openGLContext; - FadingTooltip tooltips; // fade-out tooltip window (#4) + juce::TooltipWindow tooltips { nullptr, 600 }; // Centrepiece + meters std::unique_ptr scope; @@ -234,6 +208,10 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, bool uiAnimOn = true; int lastScaleIdx = -1; // applied UI-scale step (F4) int brPrevAlgo = -1; // last Widen algorithm seen, for the bottom-right knob sweep (#8) + // Knobs/sliders only EASE to a new value during this short window, which is + // opened by a preset / A-B / undo / algorithm change; a scroll-wheel or host + // automation edit leaves it closed, so those snap and never mislead (#3). + double knobSweepTime = 0.0; // Single fixed window for both modes: toggling Advanced relays out the // content in place, so the host never resizes us and nothing flickers (#20). diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index b40d98a..54a7a54 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -139,11 +139,25 @@ juce::String AnamorphAudioProcessor::soundSignature() const void AnamorphAudioProcessor::syncCommitted() { - committedState = apvts.copyState(); + committed = currentStateSet(); committedSig = soundSignature(); lastPolledSig = committedSig; } +// A full snapshot: parameters PLUS the live preset name + clean baseline (#6). +AnamorphAudioProcessor::StateSet AnamorphAudioProcessor::currentStateSet() +{ + return { apvts.copyState(), presets.currentName(), presets.baseline() }; +} + +// Restore a state set: parameters (keeping the shared view params) AND the +// preset metadata, so the name + dirty-star reappear exactly as stored (#6). +void AnamorphAudioProcessor::applyStateSet (const StateSet& s) +{ + applyStatePreservingView (s.params); + presets.setMeta (s.name, s.baseline); +} + void AnamorphAudioProcessor::applyStatePreservingView (const juce::ValueTree& target) { // Restore a snapshot but keep the CURRENT shared view/Settings params (#10/#13). @@ -161,13 +175,14 @@ void AnamorphAudioProcessor::pollUndoCoalesce() { const auto sig = soundSignature(); // Commit only once a sound edit has SETTLED (signature stable for a tick), - // folding a whole knob gesture into a single undo step. + // folding a whole knob gesture into a single undo step. The pushed entry is + // the PREVIOUS state set (its own name + baseline), so undo restores them (#6). if (sig != committedSig && sig == lastPolledSig) { - abUndo[abActive].undo.push_back (committedState); + abUndo[abActive].undo.push_back (committed); if (abUndo[abActive].undo.size() > 128) abUndo[abActive].undo.erase (abUndo[abActive].undo.begin()); abUndo[abActive].redo.clear(); - committedState = apvts.copyState(); + committed = currentStateSet(); // captures the NOW-current preset name/baseline committedSig = sig; } lastPolledSig = sig; @@ -177,20 +192,22 @@ void AnamorphAudioProcessor::undo() { auto& st = abUndo[abActive]; if (st.undo.empty()) return; - st.redo.push_back (apvts.copyState()); - auto target = st.undo.back(); st.undo.pop_back(); - applyStatePreservingView (target); - syncCommitted(); + st.redo.push_back (currentStateSet()); + committed = st.undo.back(); st.undo.pop_back(); + applyStateSet (committed); + committedSig = soundSignature(); + lastPolledSig = committedSig; } void AnamorphAudioProcessor::redo() { auto& st = abUndo[abActive]; if (st.redo.empty()) return; - st.undo.push_back (apvts.copyState()); - auto target = st.redo.back(); st.redo.pop_back(); - applyStatePreservingView (target); - syncCommitted(); + st.undo.push_back (currentStateSet()); + committed = st.redo.back(); st.redo.pop_back(); + applyStateSet (committed); + committedSig = soundSignature(); + lastPolledSig = committedSig; } // ---------------------------------------------------------------------------- @@ -198,39 +215,44 @@ void AnamorphAudioProcessor::redo() // ---------------------------------------------------------------------------- void AnamorphAudioProcessor::abEnsureInit() { - if (! abSlotA.isValid()) abSlotA = apvts.copyState(); - if (! abSlotB.isValid()) abSlotB = abSlotA.createCopy(); + if (! abSlot[0].isValid()) abSlot[0] = currentStateSet(); + if (! abSlot[1].isValid()) + { + abSlot[1] = abSlot[0]; + abSlot[1].params = abSlot[0].params.createCopy(); // independent tree + } } void AnamorphAudioProcessor::abApplySlot (int slot) { - // The "view" + "settings" params live in a SINGLE shared store: they are not - // part of A/B and never swap (feedback #13 / #15). Same list as undo/presets. - applyStatePreservingView (slot == 1 ? abSlotB : abSlotA); + // Read the WHOLE target state set: params (keeping the shared view params) AND + // its preset name + dirty baseline, so switching shows that slot's own name, + // never the previous slot's (#6). View/Settings params never swap (#13/#15). + applyStateSet (abSlot[slot]); } void AnamorphAudioProcessor::abSwitchTo (int slot) { abEnsureInit(); if (slot == abActive) return; - (abActive == 1 ? abSlotB : abSlotA) = apvts.copyState(); // store edits in the old slot - abMatchGain[abActive] = engine.getMatchGainDb(); // remember this slot's match (#23) + abSlot[abActive] = currentStateSet(); // store the whole state set in the old slot + abMatchGain[abActive] = engine.getMatchGainDb(); // remember this slot's match (#23) abActive = slot; abApplySlot (slot); - engine.injectMatchGainDb (abMatchGain[slot]); // restore the new slot's match (#23) - syncCommitted(); // the switch itself isn't undoable (#11) + engine.injectMatchGainDb (abMatchGain[slot]); // restore the new slot's match (#23) + syncCommitted(); // the switch itself isn't undoable (#11) } void AnamorphAudioProcessor::abCopyToOther() { abEnsureInit(); - (abActive == 1 ? abSlotB : abSlotA) = apvts.copyState(); + abSlot[abActive] = currentStateSet(); const int other = abActive == 1 ? 0 : 1; // Record the target slot's pre-copy state so undoing on that slot reverts the // Copy without disturbing the active slot's history (#12). - abUndo[other].undo.push_back ((other == 1 ? abSlotB : abSlotA).createCopy()); + abUndo[other].undo.push_back (abSlot[other]); abUndo[other].redo.clear(); - (other == 1 ? abSlotB : abSlotA) = apvts.copyState().createCopy(); + abSlot[other] = currentStateSet(); // overwrite the other slot with the FULL state set (#6) } // ---------------------------------------------------------------------------- @@ -243,12 +265,18 @@ void AnamorphAudioProcessor::getStateInformation (juce::MemoryBlock& destData) { abEnsureInit(); juce::ValueTree root ("AnamorphRoot"); - root.setProperty ("presetName", presets.currentName(), nullptr); // remembered across sessions (F2) + root.setProperty ("presetName", presets.currentName(), nullptr); // remembered across sessions (F2) + root.setProperty ("presetBaseline", presets.baseline(), nullptr); // so the dirty-star survives reload (#6) root.appendChild (apvts.copyState(), nullptr); juce::ValueTree ab ("AB"); ab.setProperty ("active", abActive, nullptr); - ab.setProperty ("slotA", abSlotA.toXmlString(), nullptr); - ab.setProperty ("slotB", abSlotB.toXmlString(), nullptr); + // Each slot carries its params AND its preset name + baseline (#6). + ab.setProperty ("slotAParams", abSlot[0].params.toXmlString(), nullptr); + ab.setProperty ("slotAName", abSlot[0].name, nullptr); + ab.setProperty ("slotABase", abSlot[0].baseline, nullptr); + ab.setProperty ("slotBParams", abSlot[1].params.toXmlString(), nullptr); + ab.setProperty ("slotBName", abSlot[1].name, nullptr); + ab.setProperty ("slotBBase", abSlot[1].baseline, nullptr); root.appendChild (ab, nullptr); if (auto xml = root.createXml()) @@ -261,17 +289,42 @@ void AnamorphAudioProcessor::setStateInformation (const void* data, int sizeInBy if (xml == nullptr) return; auto root = juce::ValueTree::fromXml (*xml); + juce::String restoredName, restoredBaseline; + bool haveBaseline = false; if (root.hasType ("AnamorphRoot")) { auto params = root.getChildWithName (apvts.state.getType()); if (params.isValid()) apvts.replaceState (params.createCopy()); + restoredName = root.getProperty ("presetName").toString(); + if (root.hasProperty ("presetBaseline")) + { + restoredBaseline = root.getProperty ("presetBaseline").toString(); + haveBaseline = true; + } + auto ab = root.getChildWithName ("AB"); if (ab.isValid()) { abActive = (int) ab.getProperty ("active", 0); - if (auto a = juce::parseXML (ab.getProperty ("slotA").toString())) abSlotA = juce::ValueTree::fromXml (*a); - if (auto b = juce::parseXML (ab.getProperty ("slotB").toString())) abSlotB = juce::ValueTree::fromXml (*b); + auto readSlot = [&ab] (StateSet& dst, const char* pk, const char* nk, const char* bk, + const char* legacyKey) + { + if (ab.hasProperty (pk)) + { + if (auto x = juce::parseXML (ab.getProperty (pk).toString())) + dst.params = juce::ValueTree::fromXml (*x); + dst.name = ab.getProperty (nk).toString(); + dst.baseline = ab.getProperty (bk).toString(); + } + else if (ab.hasProperty (legacyKey)) // pre-0.6.4 slots: params only + { + if (auto x = juce::parseXML (ab.getProperty (legacyKey).toString())) + dst.params = juce::ValueTree::fromXml (*x); + } + }; + readSlot (abSlot[0], "slotAParams", "slotAName", "slotABase", "slotA"); + readSlot (abSlot[1], "slotBParams", "slotBName", "slotBBase", "slotB"); } } else if (xml->hasTagName (apvts.state.getType())) // backward-compat (v0.2) @@ -279,13 +332,16 @@ void AnamorphAudioProcessor::setStateInformation (const void* data, int sizeInBy apvts.replaceState (juce::ValueTree::fromXml (*xml)); } - // Fresh session: clear undo history and re-baseline. + // Fresh session: clear undo history. abUndo[0] = {}; abUndo[1] = {}; - syncCommitted(); - // Adopt the remembered preset name (clean baseline = the restored state). - presets.adoptRestoredState (root.hasType ("AnamorphRoot") - ? root.getProperty ("presetName").toString() : juce::String()); + // Adopt the remembered preset name + baseline so the dirty-star is reproduced + // (#6); fall back to a clean baseline at the restored state when absent. + if (haveBaseline) presets.setMeta (restoredName.isNotEmpty() ? restoredName : presets.currentName(), + restoredBaseline); + else presets.adoptRestoredState (restoredName); + + syncCommitted(); } // ---------------------------------------------------------------------------- diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index 27c613a..149b7a0 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -78,18 +78,31 @@ class AnamorphAudioProcessor : public juce::AudioProcessor, void abEnsureInit(); void abApplySlot (int slot); + // A complete "state set" (#6): the sound parameters PLUS the preset metadata + // (base name + clean baseline signature) that determines the displayed name + // and dirty-star. Every undo entry and every A/B slot stores one of these, so + // undo / A-B / Copy carry the name + dirty state, not just the parameters. + struct StateSet + { + juce::ValueTree params; + juce::String name, baseline; + bool isValid() const noexcept { return params.isValid(); } + }; + StateSet currentStateSet(); // current params + live preset meta + void applyStateSet (const StateSet&); // restore params (keeping view) + meta + // Undo helpers static bool isViewParam (const juce::String& id) noexcept; juce::String soundSignature() const; void applyStatePreservingView (const juce::ValueTree& target); void syncCommitted(); - struct UndoStacks { std::vector undo, redo; }; + struct UndoStacks { std::vector undo, redo; }; UndoStacks abUndo[2]; - juce::ValueTree committedState; + StateSet committed; juce::String committedSig, lastPolledSig; - juce::ValueTree abSlotA, abSlotB; + StateSet abSlot[2]; // A = [0], B = [1] int abActive = 0; float abMatchGain[2] = { 0.0f, 0.0f }; // remembered Level-Match per A/B slot (#23) diff --git a/src/PresetManager.h b/src/PresetManager.h index 2b137cb..c3306c7 100644 --- a/src/PresetManager.h +++ b/src/PresetManager.h @@ -44,6 +44,17 @@ class PresetManager int currentIndex() const noexcept; // -1 when name not in list bool isDirty() const; // sound edited since load/save + // The preset "metadata" that must travel WITH a state set through undo / A-B / + // copy (#6): the base preset name and the clean-signature it was loaded at. + // isDirty() = (current sound != baseline), so restoring both reproduces the + // exact name + dirty-star the state had. + juce::String baseline() const noexcept { return sigAtLoad; } + void setMeta (const juce::String& name, const juce::String& baselineSig) noexcept + { + current = name; + sigAtLoad = baselineSig; + } + void load (int index); // message thread only bool loadFile (const juce::File&); // load an arbitrary .anamorph file (OS chooser, #3) void step (int delta); // prev/next with wrap-around diff --git a/src/dsp/AnamorphEngine.cpp b/src/dsp/AnamorphEngine.cpp index a87e4f2..fa58ec0 100644 --- a/src/dsp/AnamorphEngine.cpp +++ b/src/dsp/AnamorphEngine.cpp @@ -90,8 +90,11 @@ void AnamorphEngine::prepare (double sampleRate, int maxBlockSize) wetScratch.setSize (2, maxBlock); inputScratch.setSize (2, maxBlock); monoLow.setSize (1, maxBlock); + monoLowDry.setSize (1, maxBlock); monoLowDelay.setSize (1, maxLat + maxBlock + 1); + monoLowDryDelay.setSize (1, maxLat + maxBlock + 1); monoLowDelay.clear(); + monoLowDryDelay.clear(); monoLowWrite = 0; updateDerived(); @@ -114,6 +117,7 @@ void AnamorphEngine::reset() dryDelayBuffer.clear(); dryDelayWrite = 0; monoLowDelay.clear(); + monoLowDryDelay.clear(); monoLowWrite = 0; // Flush any in-flight switch duck straight to its target so a host reset @@ -501,6 +505,10 @@ void AnamorphEngine::process (juce::AudioBuffer& buffer) noexcept { monoMaker.processSplit (L, R, monoLow.getWritePointer (0), n); + // Keep the un-driven mono low aside as the DRY low: Mix=0 must yield this, + // not the driven low (#5). + monoLowDry.copyFrom (0, 0, monoLow, 0, 0, n); + // Drive the mono low band with the SAME saturation as the high band, so // raising Drive doesn't just boost the highs and make Mono Maker sound // like a low cut (feedback #1). Low-frequency mono -> base rate is fine. @@ -551,9 +559,19 @@ void AnamorphEngine::process (juce::AudioBuffer& buffer) noexcept if (p.mbEnable) multiband.processBlock (L, R, n); - // -------- 7. Mix (dry/wet on the WIDENED band only, delay-compensated) --- - const float* dL = dryScratch.getReadPointer (0); - const float* dR = dryScratch.getReadPointer (1); + // -------- 7. Mix (dry/wet), delay-compensated. The Mono Maker low band is + // folded INTO the same per-sample Mix so its DRIVE obeys Mix too: + // Mix=0 -> the un-driven mono low; Mix=1 -> the driven mono low. The + // low band's mono-ing itself stays present at all Mix values, but it + // no longer roars through driven when Mix=0 (#5). ------------------- + const float* dL = dryScratch.getReadPointer (0); + const float* dR = dryScratch.getReadPointer (1); + const float* ml = monoMakerActive ? monoLow.getReadPointer (0) : nullptr; // wet (driven) low + const float* mld = monoMakerActive ? monoLowDry.getReadPointer (0) : nullptr; // dry (un-driven) low + float* md = monoLowDelay.getWritePointer (0); + float* mdd = monoLowDryDelay.getWritePointer (0); + const int mdSize = monoLowDelay.getNumSamples(); + for (int i = 0; i < n; ++i) { ddL[dryDelayWrite] = dL[i]; @@ -563,32 +581,21 @@ void AnamorphEngine::process (juce::AudioBuffer& buffer) noexcept dryDelayWrite = (dryDelayWrite + 1) % ddSize; const float m = mixSmooth.getNextValue(); - L[i] = dryL + m * (L[i] - dryL); - R[i] = dryR + m * (R[i] - dryR); - } + float outL = dryL + m * (L[i] - dryL); + float outR = dryR + m * (R[i] - dryR); - // -------- Mono Maker recombine: add the mono lows straight in (bypassing the - // Mix), delay-aligned to the wet latency so lows/highs phase-lock. - if (monoMakerActive) - { - const float* ml = monoLow.getReadPointer (0); - if (lat <= 0) - { - for (int i = 0; i < n; ++i) { L[i] += ml[i]; R[i] += ml[i]; } - } - else + if (monoMakerActive) { - float* md = monoLowDelay.getWritePointer (0); - const int mdSize = monoLowDelay.getNumSamples(); - for (int i = 0; i < n; ++i) - { - md[monoLowWrite] = ml[i]; - int rp = monoLowWrite - lat; if (rp < 0) rp += mdSize; - const float d = md[rp]; - monoLowWrite = (monoLowWrite + 1) % mdSize; - L[i] += d; R[i] += d; - } + md[monoLowWrite] = ml[i]; + mdd[monoLowWrite] = mld[i]; + int mp = monoLowWrite - lat; if (mp < 0) mp += mdSize; + const float wetLow = md[mp], dryLow = mdd[mp]; + monoLowWrite = (monoLowWrite + 1) % mdSize; + const float lowOut = dryLow + m * (wetLow - dryLow); // lows obey Mix (#5) + outL += lowOut; outR += lowOut; } + + L[i] = outL; R[i] = outR; } // Level Match detects the FULL recombined output (lows + highs) against the diff --git a/src/dsp/AnamorphEngine.h b/src/dsp/AnamorphEngine.h index ee2c1c8..385ced5 100644 --- a/src/dsp/AnamorphEngine.h +++ b/src/dsp/AnamorphEngine.h @@ -142,8 +142,13 @@ class AnamorphEngine // Mono Maker band-split: the mono low band is held aside while only the high // band is widened, then added back (delay-aligned to the wet latency) (#20). - juce::AudioBuffer monoLow; // mono low band for this block - juce::AudioBuffer monoLowDelay; // delay line to align lows with OS latency + // Both a DRIVEN (wet) and an UN-driven (dry) copy are kept so the recombine + // can dry/wet-blend the lows with Mix, instead of letting the driven lows + // bypass Mix and roar through at Mix=0 (#5). + juce::AudioBuffer monoLow; // driven (wet) mono low band for this block + juce::AudioBuffer monoLowDry; // un-driven (dry) mono low band for this block + juce::AudioBuffer monoLowDelay; // delay line: wet lows aligned to OS latency + juce::AudioBuffer monoLowDryDelay; // delay line: dry lows aligned to OS latency int monoLowWrite = 0; bool driveActive = false; diff --git a/src/gui/LookAndFeel.cpp b/src/gui/LookAndFeel.cpp index 16c6369..df5f830 100644 --- a/src/gui/LookAndFeel.cpp +++ b/src/gui/LookAndFeel.cpp @@ -204,7 +204,9 @@ void AnamorphLookAndFeel::drawLinearSlider (juce::Graphics& g, int x, int y, int juce::DropShadow (fillHi.withAlpha (thumbGlow), 9, {}).drawForPath (g, thumbPath); juce::DropShadow (fillHi.withAlpha (thumbGlow * 0.6f), 4, {}).drawForPath (g, thumbPath); } - juce::ColourGradient kg (colours::bgRaised.brighter (0.30f + 0.15f * hi), cx, cy - r, + // Thumb body fill brightens in TWO distinct levels like the knob face: a + // little on hover, more on press (#2). + juce::ColourGradient kg (colours::bgRaised.brighter (0.30f + 0.10f * hi + 0.14f * aA), cx, cy - r, colours::bgPanel.darker (0.18f), cx, cy + r, false); g.setGradientFill (kg); g.fillEllipse (cx - r, cy - r, r * 2.0f, r * 2.0f);