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);