From 3702fc782ff7730d205ea5fc55ed82183e81b678 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 15:34:14 +0000 Subject: [PATCH] v0.6.3: meters centre on silence, slider glow/animation parity, tooltip fade 1/2. The correlation + L/R balance pointers now sit at 0 (centre) when there's no signal instead of jumping to +1 on open: the published atomics seeded to 0, plus a fast mean-square "energy" readout lets the meter detect silence and glide the pointer back to centre over a ~550 ms damped release (never a snap). Applies to both meters. 3. Window-scale change reverted to the instant single-step transform (a resize doesn't want an animation). 4. Tooltips fade out over a weak, fast (~90 ms) non-linear ramp instead of blinking off: a TooltipWindow subclass intercepts the hide and ramps window alpha from the editor's vblank. Follows the UI Animations switch. 5. Slider blue-teal glow now has two distinct levels like the knob arc -- hover glows, press glows more+wider -- as a gradient-opacity feather, not a hard widened stroke; the thumb halo is likewise two-level and the thumb ring keeps a faint glass micro-glow. 6. The un-filled (dark) part of a slider track lifts slightly on hover and more on press, matching the knob face's two-level brightening. 7. Linear sliders now ease to their new value on a preset / A-B change (eased vpos), like the knobs; a hand drag still tracks 1:1. 8. When the Widen algorithm swaps which knob occupies the bottom-right slot (Haas Delay <-> Velvet Density), the incoming knob inherits the outgoing knob's visual position so it sweeps continuously instead of teleporting; the two-knob Chorus layout keeps its own separate per-knob registers. https://claude.ai/code/session_01Y38PtwPxh2geBLta6yuUwv --- CMakeLists.txt | 2 +- src/PluginEditor.cpp | 88 ++++++++++++++++++------------------ src/PluginEditor.h | 34 ++++++++++++-- src/dsp/Correlation.h | 12 ++++- src/gui/CorrelationMeter.cpp | 12 ++++- src/gui/LookAndFeel.cpp | 78 ++++++++++++++++++++------------ 6 files changed, 144 insertions(+), 82 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 60d9bcd..147d562 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.2 LANGUAGES C CXX) +project(Anamorph VERSION 0.6.3 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 c564911..4ed210c 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -586,12 +586,13 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess lastFrameTime = t; stepMeterReveal (dt); stepMicroAnims (dt); - stepScaleAnim (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); @@ -698,6 +699,25 @@ void AnamorphAudioProcessorEditor::applyScopePersist() void AnamorphAudioProcessorEditor::updateAlgoControls() { const int algo = algorithmBox.getSelectedItemIndex(); + + // The bottom-right Widen slot hosts a DIFFERENT knob per algorithm (Haas + // Delay <-> Velvet Density). When the algorithm swaps which knob lives there, + // hand the OUTGOING knob's visual position to the INCOMING one so the slot's + // knob sweeps continuously to its new value instead of teleporting (#8). The + // Chorus layout has two knobs in that area and is deliberately left to their + // own separate vpos registers (#8). + auto getVp = [] (juce::Slider& s) + { + return (float) (double) s.getProperties().getWithDefault ( + "vpos", (double) s.valueToProportionOfLength (s.getValue())); + }; + float oldBR = -1.0f; + if (algo != brPrevAlgo && brPrevAlgo >= 0) + { + if (haasDelayK.isVisible()) oldBR = getVp (haasDelayK); // leaving Haas + else if (velvetK.isVisible()) oldBR = getVp (velvetK); // leaving Velvet + } + haasDelayK.setVisible (algo == 0); haasDelayL.setVisible (algo == 0); velvetK.setVisible (algo == 1); velvetL.setVisible (algo == 1); chorusRateK.setVisible (algo == 2); chorusRateL.setVisible (algo == 2); @@ -705,6 +725,13 @@ void AnamorphAudioProcessorEditor::updateAlgoControls() haasSideBox.setVisible (algo == 0); dimModeBox.setVisible (algo == 3); + if (oldBR >= 0.0f) + { + if (algo == 0) haasDelayK.getProperties().set ("vpos", (double) oldBR); // -> Haas Delay + else if (algo == 1) velvetK.getProperties().set ("vpos", (double) oldBR); // -> Velvet Density + } + brPrevAlgo = algo; + // Caption over the side/voicing combo (#9): one intuitive word per algorithm. algoOptLabel.setVisible (algo == 0 || algo == 3); algoOptLabel.setText (algo == 0 ? "FOCUS" : algo == 3 ? "STYLE" : juce::String(), // #13 @@ -820,6 +847,7 @@ 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) @@ -980,26 +1008,18 @@ void AnamorphAudioProcessorEditor::stepMicroAnims (double dt) || (bool) props.getWithDefault ("dragging", false); actT = interacting ? 1.0f : 0.0f; - // Rotary knobs ease their pointer/arc toward the live value, so a - // preset or A/B switch SWEEPS the knob instead of teleporting it (#5). - // While the user turns it, snap so the knob stays 1:1 (no lag). vpos is - // ALWAYS kept current (even at rest) so there's a real "from" position - // to ease out of the instant the value jumps. - const auto st = s->getSliderStyle(); - const bool rotary = st == juce::Slider::RotaryVerticalDrag - || st == juce::Slider::RotaryHorizontalDrag - || st == juce::Slider::RotaryHorizontalVerticalDrag - || st == juce::Slider::Rotary; - if (rotary) - { - const float realPos = (float) s->valueToProportionOfLength (s->getValue()); - const float curr = (float) (double) props.getWithDefault (vpos, (double) realPos); - float vp = (interacting || ! uiAnimOn) ? realPos - : curr + (realPos - curr) * rPos; - if (std::abs (vp - realPos) < 0.0015f) vp = realPos; - if (std::abs (vp - curr) > 0.0004f) c->repaint(); - props.set (vpos, vp); - } + // ROTARY knobs AND LINEAR sliders ease their drawn position toward the + // live value, so a preset / A-B switch SWEEPS them instead of teleporting + // (#5/#7). While the user turns the control, snap so it stays 1:1 (no + // lag). vpos is kept current at rest so there's a real "from" position to + // 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 + : curr + (realPos - curr) * rPos; + if (std::abs (vp - realPos) < 0.0015f) vp = realPos; + if (std::abs (vp - curr) > 0.0004f) c->repaint(); + props.set (vpos, vp); } else if (auto* t = dynamic_cast (c)) onT = t->getToggleState() ? 1.0f : 0.0f; @@ -1014,36 +1034,16 @@ void AnamorphAudioProcessorEditor::stepMicroAnims (double dt) // Whole-window scale (F4): the layout stays at its logical 940x720 and a plain // transform scales the editor; every control is vector-drawn, so the result is // crisp at any step and the composition cannot drift. The wrapper resizes the -// host window from the transformed bounds automatically. +// host window from the transformed bounds automatically. Applied as a single +// instant step -- a resize doesn't want an animation (#3). void AnamorphAudioProcessorEditor::applyUiScale() { static constexpr float scales[] = { 0.75f, 0.85f, 1.0f, 1.25f, 1.5f }; const int idx = juce::jlimit (0, 4, uiScaleBox.getSelectedItemIndex()); if (idx == lastScaleIdx) return; lastScaleIdx = idx; - uiScaleTarget = scales[idx]; - - // With animations off, snap. Otherwise stepScaleAnim eases the transform over - // a few frames so the window GROWS/SHRINKS smoothly in BOTH axes at once, - // which masks the host's two-step width-then-height resize -- the L-shape - // flash the single jump produced (#2). - if (! uiAnimOn || lastFrameTime <= 0.0) // also snap at construction (no vblank yet) - { - uiScaleCurrent = uiScaleTarget; - setTransform (juce::AffineTransform::scale (uiScaleCurrent)); - if (openGLContext.isAttached()) openGLContext.triggerRepaint(); - } -} - -void AnamorphAudioProcessorEditor::stepScaleAnim (double dt) -{ - if (std::abs (uiScaleCurrent - uiScaleTarget) < 0.0005f) return; // idle - - uiScaleCurrent += (uiScaleTarget - uiScaleCurrent) * (1.0f - (float) std::pow (0.30, dt * 24.0)); - if (std::abs (uiScaleCurrent - uiScaleTarget) < 0.003f) - uiScaleCurrent = uiScaleTarget; - setTransform (juce::AffineTransform::scale (uiScaleCurrent)); + setTransform (juce::AffineTransform::scale (scales[idx])); if (openGLContext.isAttached()) openGLContext.triggerRepaint(); } diff --git a/src/PluginEditor.h b/src/PluginEditor.h index f2e0c29..2c40599 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -51,6 +51,32 @@ 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). @@ -69,7 +95,6 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, void layoutScopeArea(); // scope + meter block; re-run per frame during the reveal (#6) void stepMeterReveal (double dt); // vsync-driven meter reveal animation (#6/#3) void stepMicroAnims (double dt); // eased hover/press/toggle micro-animations (F3) - void stepScaleAnim (double dt); // smooth window-scale grow/shrink (#13/#2) void registerAnimated (juce::Component&); void mouseWheelMove (const juce::MouseEvent&, const juce::MouseWheelDetails&) override; // Persist scroll reveal (#1) void applyUiScale(); // whole-window XS..XL transform scale (F4) @@ -95,7 +120,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; - juce::TooltipWindow tooltips { nullptr, 600 }; + FadingTooltip tooltips; // fade-out tooltip window (#4) // Centrepiece + meters std::unique_ptr scope; @@ -207,9 +232,8 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, // properties the LookAndFeel blends with; repaints fire only while moving. juce::Array animated; bool uiAnimOn = true; - int lastScaleIdx = -1; // selected UI-scale step (F4) - float uiScaleCurrent = 1.0f; // displayed scale, eased toward the target (#2) - float uiScaleTarget = 1.0f; + int lastScaleIdx = -1; // applied UI-scale step (F4) + int brPrevAlgo = -1; // last Widen algorithm seen, for the bottom-right knob sweep (#8) // 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/dsp/Correlation.h b/src/dsp/Correlation.h index f1a3a09..4395584 100644 --- a/src/dsp/Correlation.h +++ b/src/dsp/Correlation.h @@ -57,11 +57,16 @@ class CorrelationMeter float bal = sum > 1.0e-12f ? (rrSlow - llSlow) / sum : 0.0f; bal = bal < -1.0f ? -1.0f : (bal > 1.0f ? 1.0f : bal); balance.store (bal, std::memory_order_relaxed); + + // Fast mean-square energy so the GUI can tell "playing" from "silent" and + // glide the pointers back to centre when the input stops (#1/#2). + energy.store (llFast + rrFast, std::memory_order_relaxed); } float getFast() const noexcept { return fast.load (std::memory_order_relaxed); } float getSlow() const noexcept { return slow.load (std::memory_order_relaxed); } float getBalance() const noexcept { return balance.load (std::memory_order_relaxed); } + float getEnergy() const noexcept { return energy.load (std::memory_order_relaxed); } private: static float coeffFor (double sr, double ms) noexcept @@ -82,9 +87,12 @@ class CorrelationMeter float lrFast = 0, llFast = 0, rrFast = 0; float lrSlow = 0, llSlow = 0, rrSlow = 0; - std::atomic fast { 1.0f }; - std::atomic slow { 1.0f }; + // Correlation has no meaning without signal: idle / silent reads as 0 + // (decorrelated), not +1, so the meter sits centred at rest (#1). + std::atomic fast { 0.0f }; + std::atomic slow { 0.0f }; std::atomic balance { 0.0f }; + std::atomic energy { 0.0f }; }; } // namespace anamorph diff --git a/src/gui/CorrelationMeter.cpp b/src/gui/CorrelationMeter.cpp index e497637..371c973 100644 --- a/src/gui/CorrelationMeter.cpp +++ b/src/gui/CorrelationMeter.cpp @@ -14,8 +14,16 @@ StereoMeter::~StereoMeter() { stopTimer(); } void StereoMeter::timerCallback() { - const float target = (type == Type::Balance) ? source.getBalance() : source.getSlow(); - value += 0.165f * (target - value); // same ballistics as 0.3 @ 30 Hz, time-corrected + // When the input goes silent (or before any audio at all), both the phase + // correlation and the L/R balance lose all meaning, so the pointer glides + // gently back to centre (0) rather than holding at an extreme or jumping to + // +1 on open (#1/#2). Active motion stays snappy; the return-to-centre is a + // slow ~700 ms damped release so it reads as relaxing, never a snap. + const bool silent = source.getEnergy() < 6.0e-9f; // ~ -82 dBFS (sum of L^2 + R^2) + const float target = silent ? 0.0f + : (type == Type::Balance ? source.getBalance() : source.getSlow()); + const float rate = silent ? 0.030f : 0.165f; + value += rate * (target - value); repaint(); } diff --git a/src/gui/LookAndFeel.cpp b/src/gui/LookAndFeel.cpp index 7d54cae..16c6369 100644 --- a/src/gui/LookAndFeel.cpp +++ b/src/gui/LookAndFeel.cpp @@ -28,9 +28,13 @@ void AnamorphLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int const auto radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f; const auto centre = bounds.getCentre(); // Draw at the EASED visual position when the micro-anim driver is publishing - // one (preset / A-B sweep, #5); falls back to the live position otherwise. - if (const auto* v = s.getProperties().getVarPointer ("vpos")) - pos = juce::jlimit (0.0f, 1.0f, (float) (double) *v); + // one (preset / A-B sweep, #5); during a hand drag use the live position so + // the pointer tracks 1:1 with no lag. + const bool dragging = s.isMouseButtonDown() + || (bool) s.getProperties().getWithDefault ("dragging", false); + if (! dragging) + if (const auto* v = s.getProperties().getVarPointer ("vpos")) + pos = juce::jlimit (0.0f, 1.0f, (float) (double) *v); const auto angle = startAngle + pos * (endAngle - startAngle); const float thick = juce::jmax (3.0f, radius * 0.16f); @@ -123,36 +127,52 @@ void AnamorphLookAndFeel::drawLinearSlider (juce::Graphics& g, int x, int y, int const bool horizontal = (style == juce::Slider::LinearHorizontal || style == juce::Slider::LinearBar); auto bounds = juce::Rectangle ((float) x, (float) y, (float) w, (float) h); - // Recessed track with an inner gradient (#12: premium, not a flat line). + // Hover and press are DISTINCT eased levels, like the knob arc: hover glows, + // press glows MORE (#5). hi = "lit at all", aA = the extra press amount. + const bool hovB = s.isMouseOver (false); + const bool interacting = s.isMouseButtonDown() + || (bool) s.getProperties().getWithDefault ("dragging", false); + const float hA = animOr (s, "hovA", hovB); + const float aA = animOr (s, "actA", interacting); + const float hi = juce::jmax (hA, aA); + + // Recessed track. The un-filled (dark) portion lifts a touch on hover, more on + // press -- the same two-level brightening the knob face has (#6). const float trackThick = 6.0f; + const float trackLift = 0.05f * hi + 0.07f * aA; juce::Rectangle track = horizontal ? juce::Rectangle (bounds.getX(), bounds.getCentreY() - trackThick * 0.5f, bounds.getWidth(), trackThick) : juce::Rectangle (bounds.getCentreX() - trackThick * 0.5f, bounds.getY(), trackThick, bounds.getHeight()); - juce::ColourGradient tg (colours::bg.darker (0.25f), track.getX(), track.getY(), - colours::bgRaised, track.getX(), track.getBottom(), false); + juce::ColourGradient tg (colours::bg.darker (0.25f).brighter (trackLift), track.getX(), track.getY(), + colours::bgRaised.brighter (trackLift), track.getX(), track.getBottom(), false); g.setGradientFill (tg); g.fillRoundedRectangle (track, trackThick * 0.5f); - g.setColour (colours::outline); + g.setColour (colours::outline.brighter (0.10f * hi)); g.drawRoundedRectangle (track.reduced (0.5f), trackThick * 0.5f, 1.0f); - // The thumb travels on `pos` directly (1:1 with the cursor); the inset that - // keeps it on the track is done in getSliderLayout, so cursor / value / thumb - // all stay in sync (#5). + // Eased visual position (#7): a preset / A-B switch sweeps the fill + thumb + // instead of teleporting. During a hand drag we keep the real `pos` so the + // thumb tracks the cursor exactly 1:1 (the inset lives in getSliderLayout). + if (! interacting) + if (const auto* v = s.getProperties().getVarPointer ("vpos")) + { + const float vp = juce::jlimit (0.0f, 1.0f, (float) (double) *v); + pos = horizontal ? bounds.getX() + vp * bounds.getWidth() + : bounds.getBottom() - vp * bounds.getHeight(); + } + const float r = 8.0f; juce::Rectangle fill = horizontal ? track.withWidth (juce::jmax (0.0f, pos - bounds.getX())) : track.withTop (pos).withBottom (bounds.getBottom()); - // Filled portion: the softer palette blue->teal gradient (#1) with a MANY- - // layered glow so the halo's brightness falls off smoothly instead of in - // visible steps when zoomed in (#4). + // Filled portion: blue->teal gradient with a MANY-layered gradient-opacity + // halo (NOT a hard widened stroke). The halo is brighter + wider on hover and + // brighter + wider STILL on press, so the two levels clearly differ (#5). const juce::Colour fillLo (0xff5aa6ff), fillHi (0xff35d0c0); - const bool actB = s.isMouseOverOrDragging() || (bool) s.getProperties().getWithDefault ("dragging", false); - // Eased interaction level (F3): hover or drag, whichever is brighter. - const float act = juce::jmax (animOr (s, "hovA", actB), animOr (s, "actA", actB)); - const float glowPeak = 0.16f + 0.18f * act; - const float glowSpread = 2.8f + 1.8f * act; + const float glowPeak = 0.14f + 0.10f * hi + 0.16f * aA; // idle .14 / hover .24 / press .40 + const float glowSpread = 2.6f + 1.8f * hi + 2.0f * aA; // idle 2.6 / hover 4.4 / press 6.4 constexpr int nLayers = 9; for (int i = 0; i < nLayers; ++i) { @@ -172,25 +192,27 @@ void AnamorphLookAndFeel::drawLinearSlider (juce::Graphics& g, int x, int y, int g.setGradientFill (fg); g.fillRoundedRectangle (fill, trackThick * 0.5f); - // Glassy thumb: a neutral gray-white rim normally; on hover/drag it gets a - // REAL feathered cyan glow (a blurred drop-shadow halo around the circle, not a - // hard flat ring) (#8). + // Glassy thumb: a neutral gray-white rim at rest; on hover a feathered cyan + // halo, on press a stronger one -- a blurred drop-shadow (gradient opacity), + // never a hard ring (#5). Two clearly-different levels. const float cx = horizontal ? pos : bounds.getCentreX(); const float cy = horizontal ? bounds.getCentreY() : pos; juce::Path thumbPath; thumbPath.addEllipse (cx - r, cy - r, r * 2.0f, r * 2.0f); - if (act > 0.03f) + const float thumbGlow = 0.30f * hi + 0.35f * aA; // hover .30 / press .65 + if (thumbGlow > 0.02f) { - juce::DropShadow (fillHi.withAlpha (0.65f * act), 9, {}).drawForPath (g, thumbPath); - juce::DropShadow (fillHi.withAlpha (0.40f * act), 4, {}).drawForPath (g, thumbPath); + 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 * act), cx, cy - r, + juce::ColourGradient kg (colours::bgRaised.brighter (0.30f + 0.15f * hi), 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); - g.setColour (juce::Colour (0xffb8c2cf).interpolatedWith (fillHi, act)); // gray rim -> cyan on touch + g.setColour (juce::Colour (0xffb8c2cf).interpolatedWith (fillHi, hi)); // gray rim -> cyan on touch g.drawEllipse (cx - r, cy - r, r * 2.0f, r * 2.0f, 1.4f); - // Glass rim ON TOP, clearly visible: bright top-left arc + faint opposite (#16). - glass::drawCircleEdge (g, cx, cy, r, 1.5f); + // Glass rim micro-glow on the thumb ring, always faintly present and a touch + // brighter on interaction (#5). + glass::drawCircleEdge (g, cx, cy, r, 1.2f + 0.5f * hi); } juce::Slider::SliderLayout AnamorphLookAndFeel::getSliderLayout (juce::Slider& s)