From 0a90cef17f990d5751331e3afc469cea5e4419bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:32:01 +0000 Subject: [PATCH 1/2] v0.6.8: Multiband UX overhaul, crossover slew-limit, A/B glitch purge, layout restructure DSP - Crossover drag no longer pitch-shifts: per-sample octaves/sec cutoff slew on every split, the same cap Mono Maker uses (#1). - A/B "weird sound": at the silent duck bottom a forced swap now resets EVERY stateful node (Haas/Velvet/Chorus delay lines, all oversamplers, band + mono crossovers), so a same-algorithm A/B with different delay/rate/Drive can't replay old buffer contents as the duck lifts (#22). Layout (#2/#3) - The scope/meter block returns to its previous size: Advanced now GROWS the window downward by a full-width Multiband bar instead of squeezing the scope. - Phase + balance meters hug the scope with an equal gap (cluster centred), so the scope-to-phase and scope-to-balance gaps always match, both modes. Multiband module - Renamed everywhere to Multiband; default ON, greys out when disabled (#20). - Variable bands stay; new interaction model: width drag only near the width line (#15), add hint over the rest of a band (#14), click-drag adds a split AND positions it in one gesture (#16). - Split delete x shows to the right of a hovered split (#6); reset clamps a split between its neighbours so it can't jump across one (#18). - Double-click a split's number to type a frequency (7.7k / 7.7 / 7700 / 0.5, bare <= 20 = kHz) (#5). - Scroll latch holds its target until the pointer really moves (3 px) (#4); band width scroll is 1 %/notch (#17). - Smoother analyser: Catmull-Rom interpolation kills the low-end stair-stepping (#11); the noise floor is sunk below the frame so silence shows no green line (#10). - Visual: split gets the same feathered glow as the width line (#7), an integrated rounded cap instead of a floating dot (#9), its bottom connects to the frame at rest and breaks only on hover (#12); the add "+" hugs the top edge and is larger (#13); a band-pass crossover hint while dragging a split (#19); shorter tooltip (#8). Knobs/sliders (#21) - A reset (double-click / Option-click) and a typed value now play the eased position sweep when Animations are on; a drag, the scroll wheel and host automation still snap. https://claude.ai/code/session_01Y38PtwPxh2geBLta6yuUwv --- src/PluginEditor.cpp | 169 +++++++------- src/PluginEditor.h | 36 +-- src/PluginParameters.cpp | 2 +- src/dsp/AnamorphEngine.cpp | 16 +- src/dsp/MultibandWidth.cpp | 31 ++- src/dsp/MultibandWidth.h | 8 + src/gui/SpectrumImager.cpp | 449 ++++++++++++++++++++++++------------- src/gui/SpectrumImager.h | 92 ++++---- 8 files changed, 514 insertions(+), 289 deletions(-) diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index 58ec5f4..25eb5f8 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -336,7 +336,7 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess metersToggle.onClick = [this] { metersOn = metersToggle.getToggleState(); }; // visibility via layoutScopeArea (#2) setupToggle (advancedToggle, pid::advancedMode, "Adv", "Advanced mode"); // #17 - advancedToggle.onClick = [this] { advanced = advancedToggle.getToggleState(); updateModeVisibility(); }; + advancedToggle.onClick = [this] { advanced = advancedToggle.getToggleState(); applyUiScale(); updateModeVisibility(); }; setupToggle (bypassToggle, pid::bypass, "Bypass", {}); bypassToggle.setComponentID ("bypass"); @@ -473,9 +473,7 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess setupToggle (mbEnableToggle, pid::mbEnable, "On", "Apply the per-band stereo widths to the sound"); imager = std::make_unique (processor.getEngine().getScopeBuffer(), processor.getAPVTS()); - imager->setTooltip ("Drag a split to move it. Drag up / down in a band to set its width. " - "Click the top strip to add a split, the x to remove one. " - "Scroll to fine-tune; double-click or Alt-click to reset."); + imager->setTooltip ("Drag a band for its width; drag a split to move it. Click to add, x to remove."); addAndMakeVisible (*imager); // --- Overlays --- @@ -575,9 +573,8 @@ AnamorphAudioProcessorEditor::AnamorphAudioProcessorEditor (AnamorphAudioProcess updateModeVisibility(); uiAnimOn = animToggle.getToggleState(); refreshPresetDisplay(); - setSize (kWidth, kHeight); // single fixed size for both modes (#20) setResizable (false, false); - applyUiScale(); // recalled XS..XL window scale (F4) + applyUiScale(); // sets the base size (mode-dependent height) + XS..XL scale startTimerHz (24); // One vblank drives every per-frame animation: the meter reveal (#6) and the // micro-animations (F3); both early-out to a few compares when idle. @@ -632,7 +629,20 @@ void AnamorphAudioProcessorEditor::attachSlider (juce::Slider& s, const char* id auto* p = processor.getAPVTS().getParameter (id); if (auto* k = dynamic_cast (&s); k != nullptr && p != nullptr) + { k->resetValue = p->getNormalisableRange().convertFrom0to1 (p->getDefaultValue()); // #6 + // A RESET (double-click / Option-click) sweeps the eased position (0.6.7 #21). + k->onSweep = [this] { if (uiAnimOn) knobSweepTime = 0.45; }; + } + + // A TEXT-box value entry should also sweep, but a drag, the scroll wheel, and + // host AUTOMATION must NOT: text entry is the only one of those that changes the + // value while the control holds keyboard focus and the mouse is up (0.6.7 #21). + if (! s.onValueChange) + s.onValueChange = [this, &s] { + if (uiAnimOn && ! s.isMouseButtonDown() && s.hasKeyboardFocus (true)) + knobSweepTime = 0.45; + }; // Tag the unit so the value box can show a bare number while editing (#36). const juce::String sid (id); @@ -833,6 +843,7 @@ void AnamorphAudioProcessorEditor::timerCallback() if (advancedToggle.getToggleState() != advanced) { advanced = advancedToggle.getToggleState(); + applyUiScale(); // resize for / against the Multiband bar (#2) updateModeVisibility(); } if (metersToggle.getToggleState() != metersOn) @@ -1044,9 +1055,12 @@ 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; + // Advanced extends the window downward for the full-width Multiband bar instead + // of compressing the scope (0.6.7 #2). The base (unscaled) size depends on the + // mode; the XS..XL transform scales it. + setSize (kWidth, kHeight + (advanced ? kMultiBarH : 0)); setTransform (juce::AffineTransform::scale (scales[idx])); if (openGLContext.isAttached()) openGLContext.triggerRepaint(); @@ -1199,8 +1213,8 @@ void AnamorphAudioProcessorEditor::paint (juce::Graphics& g) g.setFont (juce::Font (juce::FontOptions (10.0f)).withExtraKerningFactor (0.25f)); g.drawText ("STEREO TOOLS", 168, 0, 140, 46, juce::Justification::centredLeft); - // Right control panel - auto right = juce::Rectangle (getWidth() - 300, 46, 300, getHeight() - 46).toFloat().reduced (8.0f); + // Right control panel (spans the upper region only; the Multiband bar sits below) + auto right = juce::Rectangle (getWidth() - 300, 46, 300, kHeight - 46).toFloat().reduced (8.0f); g.setColour (colours::bgPanel.withAlpha (0.55f)); g.fillRoundedRectangle (right, 10.0f); @@ -1213,16 +1227,20 @@ void AnamorphAudioProcessorEditor::paint (juce::Graphics& g) g.setColour (colours::outline.withAlpha (0.6f)); g.drawLine (right.getX() + 12.0f, dy, right.getRight() - 12.0f, dy, 1.0f); - // Left column under the scope: a compact INPUT bar above a long, - // full-width MULTIBAND spectrum bar (0.6.6 #3/#9). + // INPUT strip at the bottom of the left column (inside the upper region), + // and a long, FULL-WIDTH MULTIBAND bar below the whole upper region (0.6.7 #2). const int leftW = getWidth() - 300; - auto inputPanel = juce::Rectangle (0, getHeight() - kStripHeight, leftW, kInputHeight) - .toFloat().reduced (8.0f, 6.0f); - auto multiPanel = juce::Rectangle (0, getHeight() - kMultiHeight, leftW, kMultiHeight) + auto inputPanel = juce::Rectangle (0, kHeight - kStripHeight, leftW, kStripHeight) .toFloat().reduced (8.0f, 6.0f); g.setColour (colours::bgPanel.withAlpha (0.5f)); g.fillRoundedRectangle (inputPanel, 10.0f); - g.fillRoundedRectangle (multiPanel, 10.0f); + + if (getHeight() > kHeight) + { + auto multiPanel = juce::Rectangle (0, kHeight, getWidth(), getHeight() - kHeight) + .toFloat().reduced (8.0f, 6.0f); + g.fillRoundedRectangle (multiPanel, 10.0f); + } } } @@ -1231,7 +1249,9 @@ void AnamorphAudioProcessorEditor::paint (juce::Graphics& g) // just this part per frame instead of the whole resized() (#6). void AnamorphAudioProcessorEditor::layoutScopeArea() { - auto leftArea = getLocalBounds().withTrimmedTop (46).withTrimmedRight (300); + // The scope/meter block lives in the upper region only (height kHeight), so the + // Multiband bar below never compresses it (0.6.7 #2). + auto leftArea = juce::Rectangle (0, 46, getWidth() - 300, kHeight - 46); if (advanced) leftArea.removeFromBottom (kStripHeight); auto sa = leftArea.reduced (16); @@ -1252,15 +1272,16 @@ void AnamorphAudioProcessorEditor::layoutScopeArea() sa.removeFromLeft (juce::roundToInt (12.0f * meterAnim)); } - auto vCol = sa.removeFromRight (26); - sa.removeFromRight (8); - auto hRow = sa.removeFromBottom (26); - sa.removeFromBottom (8); - const int side = juce::jmin (sa.getWidth(), sa.getHeight()); - auto sq = sa.withSizeKeepingCentre (side, side); + // Phase meter (right) + balance meter (bottom) hug the scope with an EQUAL gap, + // and the whole scope+meter cluster is centred in the area -- so the + // scope-to-phase and scope-to-balance gaps always match (0.6.7 #3). + const int gap = 8, barW = 26; + const int side = juce::jmin (sa.getWidth() - barW - gap, sa.getHeight() - barW - gap); + auto cluster = sa.withSizeKeepingCentre (side + gap + barW, side + gap + barW); + auto sq = juce::Rectangle (cluster.getX(), cluster.getY(), side, side); scope->setBounds (sq); - corrMeter->setBounds (vCol.withHeight (side).withY (sq.getY())); - balanceMeter->setBounds (hRow.withWidth (side).withX (sq.getX())); + corrMeter->setBounds (sq.getRight() + gap, sq.getY(), barW, side); + balanceMeter->setBounds (sq.getX(), sq.getBottom() + gap, side, barW); } void AnamorphAudioProcessorEditor::resized() @@ -1321,6 +1342,12 @@ void AnamorphAudioProcessorEditor::resized() auto r = getLocalBounds(); + // Reserve the full-width MULTIBAND bar at the very bottom (advanced only); the + // upper region above it keeps its original 940x720 layout (0.6.7 #2). + juce::Rectangle multiBar; + if (advanced && r.getHeight() > kHeight) + multiBar = r.removeFromBottom (r.getHeight() - kHeight); + // ---- Top bar ---- auto top = r.removeFromTop (46); { @@ -1440,62 +1467,54 @@ void AnamorphAudioProcessorEditor::resized() } } - // ---- Advanced bottom rows: a wide INPUT bar over a long MULTIBAND bar ---- + // ---- INPUT strip (full left width) ---- if (advanced) { - auto inputArea = stripArea.removeFromTop (kInputHeight); - auto multiArea = stripArea; // remaining kMultiHeight + auto a = stripArea.reduced (8, 6).reduced (12, 8); + inputModuleLabel.setBounds (a.removeFromTop (15)); + a.removeFromTop (6); - // INPUT: a wide, short bar -- combos on the left, the five toggles in the - // middle, the Balance knob on the right (0.6.6 #3 layout change). - { - auto a = inputArea.reduced (8, 6).reduced (12, 8); - inputModuleLabel.setBounds (a.removeFromTop (15)); - a.removeFromTop (4); - - // Balance knob block on the far right. - auto bal = a.removeFromRight (96); - auto blk = bal.withSizeKeepingCentre (bal.getWidth(), juce::jmin (bal.getHeight(), 92)); - balanceL.setBounds (blk.removeFromBottom (14)); - balanceK.setBounds (blk.reduced (8, 0)); - a.removeFromRight (14); - - // Two combos (Channel + Solo) side by side on the left. - auto combos = a.removeFromLeft (310); - { - auto cm = combos.removeFromLeft (150); - auto cmBlk = cm.withSizeKeepingCentre (cm.getWidth(), 43); - channelModeLabel.setBounds (cmBlk.removeFromTop (14)); - cmBlk.removeFromTop (3); - channelModeBox.setBounds (cmBlk.removeFromTop (26)); - combos.removeFromLeft (10); - auto soBlk = combos.withSizeKeepingCentre (combos.getWidth(), 43); - soloLabel.setBounds (soBlk.removeFromTop (14)); - soBlk.removeFromTop (3); - soloBox.setBounds (soBlk.removeFromTop (26)); - } - a.removeFromLeft (14); - - // Five compact toggles spread across the remaining middle, vertically - // centred so they line up with the combos. - auto tog = a.withSizeKeepingCentre (a.getWidth(), 42); - const int tw = tog.getWidth() / 5; - monoToggle.setBounds (tog.removeFromLeft (tw)); - swapToggle.setBounds (tog.removeFromLeft (tw)); - msToggle.setBounds (tog.removeFromLeft (tw)); - polLToggle.setBounds (tog.removeFromLeft (tw)); - polRToggle.setBounds (tog); - } + // Centre a single horizontal row: combos | five toggles | Balance knob. + auto row = a.withSizeKeepingCentre (a.getWidth(), juce::jmin (a.getHeight(), 96)); - // MULTIBAND: the long spectral band editor fills the full-width bar under - // its header (label + On toggle). + auto bal = row.removeFromRight (100); + auto blk = bal.withSizeKeepingCentre (bal.getWidth(), juce::jmin (bal.getHeight(), 92)); + balanceL.setBounds (blk.removeFromBottom (14)); + balanceK.setBounds (blk.reduced (10, 0)); + row.removeFromRight (16); + + auto combos = row.removeFromLeft (320); { - auto m = multiArea.reduced (8, 6); - auto head = m.removeFromTop (18).reduced (6, 0); - multibandLabel.setBounds (head.removeFromLeft (head.getWidth() - 56)); - mbEnableToggle.setBounds (head.removeFromRight (52)); - m.removeFromTop (2); - if (imager) imager->setBounds (m.reduced (2, 0)); + auto cm = combos.removeFromLeft (152); + auto cmBlk = cm.withSizeKeepingCentre (cm.getWidth(), 43); + channelModeLabel.setBounds (cmBlk.removeFromTop (14)); + cmBlk.removeFromTop (3); + channelModeBox.setBounds (cmBlk.removeFromTop (26)); + combos.removeFromLeft (12); + auto soBlk = combos.withSizeKeepingCentre (combos.getWidth(), 43); + soloLabel.setBounds (soBlk.removeFromTop (14)); + soBlk.removeFromTop (3); + soloBox.setBounds (soBlk.removeFromTop (26)); } + row.removeFromLeft (16); + + auto tog = row.withSizeKeepingCentre (row.getWidth(), 44); + const int tw = tog.getWidth() / 5; + monoToggle.setBounds (tog.removeFromLeft (tw)); + swapToggle.setBounds (tog.removeFromLeft (tw)); + msToggle.setBounds (tog.removeFromLeft (tw)); + polLToggle.setBounds (tog.removeFromLeft (tw)); + polRToggle.setBounds (tog); + } + + // ---- MULTIBAND: the long, full-width spectral band editor ---- + if (advanced && ! multiBar.isEmpty()) + { + auto m = multiBar.reduced (8, 6); + auto head = m.removeFromTop (18).reduced (8, 0); + multibandLabel.setBounds (head.removeFromLeft (head.getWidth() - 56)); + mbEnableToggle.setBounds (head.removeFromRight (52)); + m.removeFromTop (2); + if (imager) imager->setBounds (m.reduced (2, 0)); } } diff --git a/src/PluginEditor.h b/src/PluginEditor.h index 5ceebe9..858a814 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -113,15 +113,27 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, // Preset browser (F2): ‹ name ›, the name opens the preset menu. juce::TextButton presetPrev, presetNext, presetName; - // Knob: a slider that resets to its default on a clean double-click only -- - // a triple-or-more click no longer resets on every extra click (feedback #6). + // Knob: a slider that resets to its default on a clean double-click OR an + // Option/Alt-click (#6 / 0.6.7 #21). onSweep lets the editor play the eased + // position travel when a RESET happens (but not on a drag). struct Knob : public juce::Slider { double resetValue = 0.0; + std::function onSweep; + + void doReset() + { + setValue (resetValue, juce::sendNotificationSync); + if (onSweep) onSweep(); + } + void mouseDown (const juce::MouseEvent& e) override + { + if (e.mods.isAltDown()) { doReset(); return; } // Option/Alt-click reset + juce::Slider::mouseDown (e); + } void mouseDoubleClick (const juce::MouseEvent& e) override { - if (e.getNumberOfClicks() == 2) - setValue (resetValue, juce::sendNotificationSync); + if (e.getNumberOfClicks() == 2) doReset(); } }; @@ -214,15 +226,13 @@ class AnamorphAudioProcessorEditor : public juce::AudioProcessorEditor, // 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). - static constexpr int kWidth = 940; - static constexpr int kHeight = 720; - // Advanced bottom rows under the scope (left column): a compact INPUT bar - // above a long, full-width MULTIBAND spectrum bar (0.6.6 #3/#9). - static constexpr int kInputHeight = 150; - static constexpr int kMultiHeight = 176; - static constexpr int kStripHeight = kInputHeight + kMultiHeight; // total reserved + // The upper region is the same 940x720 in both modes, with the scope/meter + // block at its original size (0.6.7 #2). Advanced GROWS the window downward by + // kMultiBarH for a full-width MULTIBAND bar, instead of squeezing the scope. + static constexpr int kWidth = 940; + static constexpr int kHeight = 720; // top bar + scope/INPUT + right panel + static constexpr int kStripHeight = 200; // INPUT strip at the bottom of the left column + static constexpr int kMultiBarH = 196; // full-width Multiband bar, advanced only JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AnamorphAudioProcessorEditor) }; diff --git a/src/PluginParameters.cpp b/src/PluginParameters.cpp index 9cd62ac..c560f23 100644 --- a/src/PluginParameters.cpp +++ b/src/PluginParameters.cpp @@ -121,7 +121,7 @@ juce::AudioProcessorValueTreeState::ParameterLayout createAnamorphLayout() floatParam (pid::width, "Width", { 0.0f, 2.0f, 0.001f }, 1.0f, pct, pctFrom); // --- Multiband (1..4 bands, up to 3 crossovers) --- - layout.add (std::make_unique (ParameterID { pid::mbEnable, kVersion }, "Multiband Enable", false)); + layout.add (std::make_unique (ParameterID { pid::mbEnable, kVersion }, "Multiband Enable", true)); layout.add (std::make_unique (ParameterID { pid::mbBands, kVersion }, "Multiband Bands", 1, 4, 4)); floatParam (pid::mbFreqLow, "Multiband Split 1", logFreqRange (30.0f, 600.0f), 180.0f, hz, hzFrom); floatParam (pid::mbFreqMid, "Multiband Split 2", logFreqRange (150.0f, 3000.0f), 800.0f, hz, hzFrom); diff --git a/src/dsp/AnamorphEngine.cpp b/src/dsp/AnamorphEngine.cpp index a043882..d7fed2a 100644 --- a/src/dsp/AnamorphEngine.cpp +++ b/src/dsp/AnamorphEngine.cpp @@ -478,11 +478,21 @@ void AnamorphEngine::process (juce::AudioBuffer& buffer) noexcept if (pendingForced) { snapSmoothers(); - // Clear the band/mono crossover state too: their IIR filters still hold - // the OLD signal, and a big crossover/width jump would otherwise ring as - // the duck fades back in -- audible as the residual "burst" (0.6.6 #1). + // Clear EVERY stateful node so the fade-in plays the new state from a + // clean slate. Even a SAME-algorithm A/B can move Haas delay / Chorus + // rate / Drive far enough that the old delay-line + filter + oversampler + // contents replay as a short glitch right as the duck lifts -- the + // intermittent A/B "weird sound" (0.6.7 #22). At the silent bottom these + // resets are inaudible. multiband.reset(); monoMaker.reset(); + haas.reset(); + velvet.reset(); + chorus.reset(); + if (os2) os2->reset(); + if (os4) os4->reset(); + if (os8) os8->reset(); + pendingAlgoReset = false; // already handled by the wholesale reset above const float inj = matchInject.exchange (kNoInject, std::memory_order_relaxed); if (inj > kNoInject + 1.0f) { diff --git a/src/dsp/MultibandWidth.cpp b/src/dsp/MultibandWidth.cpp index 8b8d1a1..ba121be 100644 --- a/src/dsp/MultibandWidth.cpp +++ b/src/dsp/MultibandWidth.cpp @@ -1,17 +1,26 @@ #include "MultibandWidth.h" #include "MidSide.h" +#include namespace anamorph { void MultibandWidth::prepare (double sampleRate, int maxBlock) { + sr = sampleRate; juce::dsp::ProcessSpec spec { sampleRate, (juce::uint32) juce::jmax (1, maxBlock), 2 }; for (auto* x : { &x1, &x2, &x3 }) { x->prepare (spec); x->setType (juce::dsp::LinkwitzRileyFilterType::lowpass); } + // ~8 octaves/sec slew cap (matches Mono Maker), so a quick split drag never + // modulates the LR cutoff fast enough to pitch-shift (0.6.7 #1). + glideCoeff = std::exp2 (8.0f / (float) sr); + for (int i = 0; i < 3; ++i) { currentF[i] = targetF[i]; } + x1.setCutoffFrequency (currentF[0]); + x2.setCutoffFrequency (currentF[1]); + x3.setCutoffFrequency (currentF[2]); reset(); } @@ -25,12 +34,13 @@ void MultibandWidth::reset() void MultibandWidth::setCrossovers (float f1, float f2, float f3) noexcept { // Keep the three crossovers strictly ordered with a little separation, so a - // drag can't cross them over. + // drag can't cross them over. These are TARGETS; the cutoffs glide toward them + // per sample in processBlock (0.6.7 #1). f2 = juce::jmax (f2, f1 * 1.1f); f3 = juce::jmax (f3, f2 * 1.1f); - x1.setCutoffFrequency (f1); - x2.setCutoffFrequency (f2); - x3.setCutoffFrequency (f3); + targetF[0] = f1; + targetF[1] = f2; + targetF[2] = f3; } void MultibandWidth::processBlock (float* left, float* right, int numSamples) noexcept @@ -48,6 +58,19 @@ void MultibandWidth::processBlock (float* left, float* right, int numSamples) no for (int n = 0; n < numSamples; ++n) { + // Glide each active cutoff toward its target, capped at a fixed + // octaves/second rate so a quick split drag never chirps (0.6.7 #1). + for (int i = 0; i < crossovers; ++i) + { + if (std::abs (currentF[i] - targetF[i]) > 0.05f) + { + currentF[i] = targetF[i] > currentF[i] + ? juce::jmin (targetF[i], currentF[i] * glideCoeff) + : juce::jmax (targetF[i], currentF[i] / glideCoeff); + xs[i]->setCutoffFrequency (currentF[i]); + } + } + float curL = left[n], curR = right[n]; float accL = 0.0f, accR = 0.0f; diff --git a/src/dsp/MultibandWidth.h b/src/dsp/MultibandWidth.h index 2f6cc34..4ab2edb 100644 --- a/src/dsp/MultibandWidth.h +++ b/src/dsp/MultibandWidth.h @@ -50,6 +50,14 @@ class MultibandWidth juce::dsp::LinkwitzRileyFilter x2; // f2: band2 vs rest juce::dsp::LinkwitzRileyFilter x3; // f3: band3 vs band4 + // Per-sample multiplicative slew on each crossover, exactly like Mono Maker: + // a fast drag of a split can no longer sweep the IIR quickly enough to chirp / + // pitch-shift (0.6.7 #1). + double sr = 44100.0; + float glideCoeff = 0.0f; + float targetF[3] { 180.0f, 800.0f, 3000.0f }; + float currentF[3] { 180.0f, 800.0f, 3000.0f }; + int bands = 4; float w[4] { 1.0f, 1.0f, 1.0f, 1.0f }; }; diff --git a/src/gui/SpectrumImager.cpp b/src/gui/SpectrumImager.cpp index bd4a765..841568b 100644 --- a/src/gui/SpectrumImager.cpp +++ b/src/gui/SpectrumImager.cpp @@ -8,6 +8,7 @@ namespace anamorph::gui static constexpr float kFreqLo = 20.0f, kFreqHi = 20000.0f; static constexpr float kMinDb = -90.0f, kMaxDb = 0.0f; +static constexpr float kWidthGrab = 8.0f; // px tolerance to grab a width line (#15) namespace { @@ -18,6 +19,12 @@ namespace { 2000.0f, "2k", false }, { 5000.0f, "5k", false }, { 10000.0f, "10k", true }, { 20000.0f, "20k", false } }; + + juce::String freqText (float f) + { + return f >= 1000.0f ? juce::String (f / 1000.0f, 2) + "k" + : juce::String (juce::roundToInt (f)); + } } SpectrumImager::SpectrumImager (anamorph::ScopeBuffer& s, juce::AudioProcessorValueTreeState& a) @@ -32,12 +39,14 @@ SpectrumImager::SpectrumImager (anamorph::ScopeBuffer& s, juce::AudioProcessorVa widthP[2] = dynamic_cast (apvts.getParameter (pid::mbWidthHiMid)); widthP[3] = dynamic_cast (apvts.getParameter (pid::mbWidthHigh)); animOnP = apvts.getRawParameterValue (pid::uiAnimations); + enableP = apvts.getRawParameterValue (pid::mbEnable); fifoL.assign ((size_t) fftSize, 0.0f); fifoR.assign ((size_t) fftSize, 0.0f); fftData.assign ((size_t) fftSize * 2, 0.0f); mags.assign ((size_t) fftSize / 2 + 1, kMinDb); + enaA = enabled() ? 1.0f : 0.0f; setInterceptsMouseClicks (true, false); startTimerHz (60); } @@ -47,10 +56,7 @@ SpectrumImager::~SpectrumImager() { stopTimer(); } // ---------------------------------------------------------------------------- // Geometry // ---------------------------------------------------------------------------- -juce::Rectangle SpectrumImager::plot() const noexcept -{ - return getLocalBounds().toFloat().reduced (1.0f); -} +juce::Rectangle SpectrumImager::plot() const noexcept { return getLocalBounds().toFloat().reduced (1.0f); } float SpectrumImager::freqToX (float hz) const noexcept { @@ -66,10 +72,9 @@ float SpectrumImager::xToFreq (float x) const noexcept return kFreqLo * std::pow (kFreqHi / kFreqLo, t); } -float SpectrumImager::yThird() const noexcept { auto r = plot(); return r.getY() + r.getHeight() * (1.0f / 3.0f); } float SpectrumImager::rulerY() const noexcept { return plot().getBottom() - 14.0f; } -float SpectrumImager::laneTop() const noexcept { return plot().getY() + 6.0f; } -float SpectrumImager::laneBot() const noexcept { return rulerY() - 8.0f; } +float SpectrumImager::laneTop() const noexcept { return plot().getY() + 8.0f; } +float SpectrumImager::laneBot() const noexcept { return rulerY() - 10.0f; } float SpectrumImager::widthToY (float w) const noexcept { @@ -91,32 +96,28 @@ int SpectrumImager::bandCount() const noexcept if (auto* p = bandsP) return juce::jlimit (1, 4, (int) std::lround (p->convertFrom0to1 (p->getValue()))); return 4; } - float SpectrumImager::crossover (int i) const noexcept { if (i >= 0 && i < 3) if (auto* p = freqP[i]) return p->convertFrom0to1 (p->getValue()); return kFreqLo; } - float SpectrumImager::bandWidth (int i) const noexcept { if (i >= 0 && i < 4) if (auto* p = widthP[i]) return p->convertFrom0to1 (p->getValue()); return 1.0f; } +bool SpectrumImager::enabled() const noexcept { return enableP == nullptr || enableP->load() > 0.5f; } -float SpectrumImager::bandLeftX (int b) const noexcept -{ - return b <= 0 ? plot().getX() : freqToX (crossover (b - 1)); -} +float SpectrumImager::bandLeftX (int b) const noexcept { return b <= 0 ? plot().getX() : freqToX (crossover (b - 1)); } +float SpectrumImager::bandRightX (int b) const noexcept { return b >= bandCount() - 1 ? plot().getRight() : freqToX (crossover (b)); } -float SpectrumImager::bandRightX (int b) const noexcept +juce::Rectangle SpectrumImager::deleteBox (int i) const noexcept { - return b >= bandCount() - 1 ? plot().getRight() : freqToX (crossover (b)); + return { freqToX (crossover (i)) + 6.0f, plot().getY() + 4.0f, 13.0f, 13.0f }; } - -juce::Rectangle SpectrumImager::deleteRect (int b) const noexcept +juce::Rectangle SpectrumImager::numberChip (int i) const noexcept { - return { bandLeftX (b) + 4.0f, rulerY() - 20.0f, 14.0f, 14.0f }; + return { freqToX (crossover (i)) - 22.0f, rulerY() - 7.0f, 44.0f, 14.0f }; } int SpectrumImager::bandAtX (float x) const noexcept @@ -131,7 +132,7 @@ int SpectrumImager::bandAtX (float x) const noexcept int SpectrumImager::handleNearX (float x) const noexcept { const int N = bandCount(); - int best = -1; float bestD = 7.0f; // px tolerance + int best = -1; float bestD = 7.0f; for (int i = 0; i < N - 1; ++i) { const float d = std::abs (x - freqToX (crossover (i))); @@ -140,12 +141,16 @@ int SpectrumImager::handleNearX (float x) const noexcept return best; } +bool SpectrumImager::nearWidthLine (juce::Point p, int b) const noexcept +{ + return std::abs (p.y - widthToY (bandWidth (b))) < kWidthGrab; +} + int SpectrumImager::deleteHit (juce::Point p) const noexcept { const int N = bandCount(); - if (N <= 1) return -1; - for (int b = 0; b < N; ++b) - if (deleteRect (b).contains (p)) return b; + for (int i = 0; i < N - 1; ++i) + if (deleteBox (i).contains (p)) return i; return -1; } @@ -172,57 +177,137 @@ void SpectrumImager::setBands (int n) } } -void SpectrumImager::addBandAt (float hz) +// Reset a split to its default, but CLAMPED between its neighbours so it can never +// jump across an adjacent split (0.6.7 #18). +void SpectrumImager::resetCrossover (int i) { + auto* p = (i >= 0 && i < 3) ? freqP[i] : nullptr; + if (p == nullptr) return; const int N = bandCount(); - if (N >= 4) return; + float def = p->convertFrom0to1 (p->getDefaultValue()); + if (i > 0) def = juce::jmax (def, crossover (i - 1) * 1.05f); + if (i < N - 2) def = juce::jmin (def, crossover (i + 1) * 0.95f); + p->beginChangeGesture(); + p->setValueNotifyingHost (p->convertTo0to1 (juce::jlimit (kFreqLo, kFreqHi, def))); + p->endChangeGesture(); +} + +int SpectrumImager::addBandAt (float hz) +{ + const int N = bandCount(); + if (N >= 4) return -1; hz = juce::jlimit (kFreqLo, kFreqHi, hz); float fr[3], wd[4]; for (int i = 0; i < 3; ++i) fr[i] = crossover (i); for (int i = 0; i < 4; ++i) wd[i] = bandWidth (i); - int ins = 0; // insertion index among the active crossovers + int ins = 0; while (ins < N - 1 && fr[ins] < hz) ++ins; if (ins > 0) hz = juce::jmax (hz, fr[ins - 1] * 1.05f); if (ins < N - 1) hz = juce::jmin (hz, fr[ins] * 0.95f); float nf[3], nw[4]; - for (int i = 0; i < N; ++i) nf[i] = (i < ins) ? fr[i] : (i == ins ? hz : fr[i - 1]); // N crossovers - for (int i = 0; i <= N; ++i) nw[i] = (i <= ins) ? wd[i] : wd[i - 1]; // N+1 widths (dup split band) + for (int i = 0; i < N; ++i) nf[i] = (i < ins) ? fr[i] : (i == ins ? hz : fr[i - 1]); + for (int i = 0; i <= N; ++i) nw[i] = (i <= ins) ? wd[i] : wd[i - 1]; - for (int i = 0; i <= N; ++i) setParam (widthP[i], nw[i]); - for (int i = 0; i < N; ++i) setParam (freqP[i], nf[i]); + for (int i = 0; i <= N; ++i) setParam (widthP[i], nw[i]); + for (int i = 0; i < N; ++i) setParam (freqP[i], nf[i]); setBands (N + 1); + return ins; } -void SpectrumImager::deleteBand (int b) +void SpectrumImager::removeCrossover (int i) { const int N = bandCount(); - if (N <= 1) return; - b = juce::jlimit (0, N - 1, b); - const int c = (b == N - 1) ? b - 1 : b; // crossover to remove (merge with the neighbour) + if (N <= 1 || i < 0 || i > N - 2) return; float fr[3], wd[4]; - for (int i = 0; i < 3; ++i) fr[i] = crossover (i); - for (int i = 0; i < 4; ++i) wd[i] = bandWidth (i); + for (int k = 0; k < 3; ++k) fr[k] = crossover (k); + for (int k = 0; k < 4; ++k) wd[k] = bandWidth (k); float nf[3], nw[4]; - for (int i = 0; i < N - 2; ++i) nf[i] = (i < c) ? fr[i] : fr[i + 1]; // drop crossover c - for (int i = 0; i < N - 1; ++i) nw[i] = (i <= c) ? wd[i] : wd[i + 1]; // drop width c+1, keep left band + for (int k = 0; k < N - 2; ++k) nf[k] = (k < i) ? fr[k] : fr[k + 1]; + for (int k = 0; k < N - 1; ++k) nw[k] = (k <= i) ? wd[k] : wd[k + 1]; - for (int i = 0; i < N - 1; ++i) setParam (widthP[i], nw[i]); - for (int i = 0; i < N - 2; ++i) setParam (freqP[i], nf[i]); + for (int k = 0; k < N - 1; ++k) setParam (widthP[k], nw[k]); + for (int k = 0; k < N - 2; ++k) setParam (freqP[k], nf[k]); setBands (N - 1); } +// ---------------------------------------------------------------------------- +// Frequency text editor (#5) +// ---------------------------------------------------------------------------- +float SpectrumImager::parseFreq (const juce::String& t) +{ + auto s = t.toLowerCase().trim(); + const bool k = s.containsChar ('k'); + const float v = s.removeCharacters ("khz ").getFloatValue(); + if (k) return v * 1000.0f; // "7.7k" -> 7700 + return (v <= 20.0f) ? v * 1000.0f : v; // bare <= 20 means kHz: "0.5" -> 500, "7.7" -> 7700 +} + +void SpectrumImager::openFreqEditor (int i) +{ + if (i < 0 || i >= bandCount() - 1) return; + editingHandle = i; + + if (freqEditor == nullptr) + { + freqEditor = std::make_unique(); + freqEditor->setJustification (juce::Justification::centred); + freqEditor->setBorder (juce::BorderSize (1)); + freqEditor->setColour (juce::TextEditor::backgroundColourId, colours::bgPanel); + freqEditor->setColour (juce::TextEditor::outlineColourId, colours::accent.withAlpha (0.7f)); + freqEditor->setColour (juce::TextEditor::focusedOutlineColourId, colours::accent); + freqEditor->setColour (juce::TextEditor::textColourId, colours::text); + freqEditor->setColour (juce::TextEditor::highlightColourId, colours::accent.withAlpha (0.4f)); + freqEditor->setFont (juce::Font (juce::FontOptions (11.0f))); + freqEditor->setSelectAllWhenFocused (true); + freqEditor->onReturnKey = [this] { commitFreqEditor(); }; + freqEditor->onEscapeKey = [this] { closeFreqEditor(); }; + freqEditor->onFocusLost = [this] { commitFreqEditor(); }; + addAndMakeVisible (*freqEditor); + } + + auto chip = numberChip (i).expanded (6.0f, 3.0f); + freqEditor->setBounds (chip.toNearestInt()); + freqEditor->setText (freqText (crossover (i)), juce::dontSendNotification); + freqEditor->setVisible (true); + freqEditor->grabKeyboardFocus(); + freqEditor->selectAll(); +} + +void SpectrumImager::commitFreqEditor() +{ + if (editingHandle < 0) return; + const int i = editingHandle; + const int N = bandCount(); + float hz = parseFreq (freqEditor->getText()); + if (i > 0) hz = juce::jmax (hz, crossover (i - 1) * 1.05f); + if (i < N - 2) hz = juce::jmin (hz, crossover (i + 1) * 0.95f); + if (auto* p = freqP[i]) + { + p->beginChangeGesture(); + p->setValueNotifyingHost (p->convertTo0to1 (juce::jlimit (kFreqLo, kFreqHi, hz))); + p->endChangeGesture(); + } + closeFreqEditor(); +} + +void SpectrumImager::closeFreqEditor() +{ + editingHandle = -1; + if (freqEditor) freqEditor->setVisible (false); +} + // ---------------------------------------------------------------------------- // Analyser // ---------------------------------------------------------------------------- void SpectrumImager::pushFFT() { const int got = scope.readLatest (fifoL.data(), fifoR.data(), fftSize); - if (got < fftSize) return; // not enough history yet + if (got < fftSize) return; for (int i = 0; i < fftSize; ++i) fftData[(size_t) i] = 0.5f * (fifoL[(size_t) i] + fifoR[(size_t) i]); @@ -236,33 +321,37 @@ void SpectrumImager::pushFFT() { const float db = juce::Decibels::gainToDecibels (fftData[(size_t) k] * norm, kMinDb); float& m = mags[(size_t) k]; - m = db > m ? db : m + (db - m) * 0.25f; // fast rise, gentle fall + m = db > m ? db : m + (db - m) * 0.25f; } } +float SpectrumImager::magCubic (float binPos) const noexcept +{ + const int kmax = fftSize / 2; + const int i = (int) std::floor (binPos); + const float t = binPos - (float) i; + auto m = [&] (int j) { return mags[(size_t) juce::jlimit (0, kmax, j)]; }; + const float m0 = m (i - 1), m1 = m (i), m2 = m (i + 1), m3 = m (i + 2); + // Catmull-Rom: smooth curve THROUGH the bin points (kills the low-end stairs, #11). + return 0.5f * ((2.0f * m1) + + (-m0 + m2) * t + + (2.0f * m0 - 5.0f * m1 + 4.0f * m2 - m3) * t * t + + (-m0 + 3.0f * m1 - 3.0f * m2 + m3) * t * t * t); +} + float SpectrumImager::magForColumn (float xa, float xb) const noexcept { const float binHz = (float) sampleRate / (float) fftSize; const int kmax = fftSize / 2; const float fa = xToFreq (juce::jmin (xa, xb)); const float fb = xToFreq (juce::jmax (xa, xb)); + const float span = (fb - fa) / binHz; - int ka = (int) std::floor (fa / binHz); - int kb = (int) std::ceil (fb / binHz); - ka = juce::jlimit (0, kmax, ka); - kb = juce::jlimit (0, kmax, kb); - - if (kb <= ka) - { - // Sub-bin column (the LOW end): interpolate so the curve slopes between - // bins instead of stair-stepping (0.6.6 #8). - const float bin = 0.5f * (fa + fb) / binHz; - const int i0 = juce::jlimit (0, kmax - 1, (int) std::floor (bin)); - const float fr = juce::jlimit (0.0f, 1.0f, bin - (float) i0); - return mags[(size_t) i0] + (mags[(size_t) (i0 + 1)] - mags[(size_t) i0]) * fr; - } + if (span < 1.5f) + return magCubic (0.5f * (fa + fb) / binHz); // few bins per column -> interpolate - // Several bins per column (the HIGH end): average for a clean, smooth line. + int ka = juce::jlimit (0, kmax, (int) std::floor (fa / binHz)); + int kb = juce::jlimit (0, kmax, (int) std::ceil (fb / binHz)); float sum = 0.0f; for (int k = ka; k <= kb; ++k) sum += mags[(size_t) k]; return sum / (float) (kb - ka + 1); @@ -273,8 +362,6 @@ void SpectrumImager::timerCallback() sampleRate = apvts.processor.getSampleRate() > 0.0 ? apvts.processor.getSampleRate() : 48000.0; pushFFT(); - // Ease the hover/press activity the same way the rest of the UI does: a faster - // fade in than out, snapping when UI Animations are off (0.6.6 #11). const bool animOn = animOnP == nullptr || animOnP->load() > 0.5f; const float dt = 1.0f / 60.0f; const float rIn = animOn ? 1.0f - std::exp (-dt / 0.075f) : 1.0f; @@ -282,9 +369,10 @@ void SpectrumImager::timerCallback() auto ease = [&] (float& v, float t) { v += (t - v) * (t > v ? rIn : rOut); }; for (int i = 0; i < 3; ++i) ease (handleA[i], (i == dragHandle || i == hoverHandle) ? 1.0f : 0.0f); - for (int i = 0; i < 4; ++i) ease (bandA[i], (i == dragBand || i == hoverBand) ? 1.0f : 0.0f); - for (int i = 0; i < 4; ++i) ease (delA[i], (i == hoverDelete) ? 1.0f : 0.0f); - ease (addA, hoverAdd ? 1.0f : 0.0f); + for (int i = 0; i < 4; ++i) ease (widthA[i], (i == dragBand || i == hoverWidth) ? 1.0f : 0.0f); + for (int i = 0; i < 3; ++i) ease (delA[i], (i == hoverDelete) ? 1.0f : 0.0f); + ease (addA, hoverAdd >= 0 ? 1.0f : 0.0f); + ease (enaA, enabled() ? 1.0f : 0.0f); repaint(); } @@ -303,18 +391,19 @@ void SpectrumImager::paint (juce::Graphics& g) const int N = bandCount(); const juce::Colour bandLo (0xff5aa6ff), bandHi (0xff35d0c0); + const juce::Colour xoverCol = colours::accent; // --- band tints ----------------------------------------------------- for (int b = 0; b < N; ++b) { const float x0 = bandLeftX (b), x1 = bandRightX (b); const float w = bandWidth (b); - const float a = 0.04f + 0.05f * juce::jlimit (0.0f, 2.0f, w) * 0.5f + 0.06f * bandA[b]; + const float a = 0.04f + 0.05f * juce::jlimit (0.0f, 2.0f, w) * 0.5f + 0.06f * widthA[b]; g.setColour (bandLo.interpolatedWith (bandHi, juce::jlimit (0.0f, 1.0f, w * 0.5f)).withAlpha (a)); g.fillRect (juce::Rectangle (x0, r.getY(), juce::jmax (0.0f, x1 - x0), r.getHeight())); } - // --- frequency grid (ruler ticks) ----------------------------------- + // --- frequency grid ------------------------------------------------- for (auto& t : kTicks) { const float x = freqToX (t.f); @@ -322,17 +411,14 @@ void SpectrumImager::paint (juce::Graphics& g) g.drawVerticalLine (juce::roundToInt (x), r.getY(), rulerY() + 2.0f); } - // --- the 1/3 "add" reference line ----------------------------------- + // --- spectrum curve + fill (floor SUNK below the frame so a silent signal + // leaves no green line, #10; cubic-smoothed low end, #11) ------------- { - const float y = yThird(); - float dl[2] = { 2.0f, 3.0f }; - g.setColour (colours::outline.withAlpha (0.22f)); - g.drawDashedLine ({ { r.getX(), y }, { r.getRight(), y } }, dl, 2, 1.0f); - } - - // --- spectrum curve + fill ------------------------------------------ - { - auto dbToY = [&] (float db) { return r.getBottom() - (juce::jlimit (kMinDb, kMaxDb, db) - kMinDb) / (kMaxDb - kMinDb) * r.getHeight(); }; + auto dbToY = [&] (float db) + { + const float t = (juce::jlimit (kMinDb, kMaxDb, db) - kMinDb) / (kMaxDb - kMinDb); + return (r.getBottom() + 8.0f) - t * (r.getHeight() + 8.0f); + }; juce::Path spec; bool started = false; for (float x = r.getX(); x <= r.getRight(); x += 1.0f) @@ -342,13 +428,13 @@ void SpectrumImager::paint (juce::Graphics& g) else spec.lineTo (x, y); } juce::Path fillPath (spec); - fillPath.lineTo (r.getRight(), r.getBottom()); - fillPath.lineTo (r.getX(), r.getBottom()); + fillPath.lineTo (r.getRight(), r.getBottom() + 2.0f); + fillPath.lineTo (r.getX(), r.getBottom() + 2.0f); fillPath.closeSubPath(); - g.setGradientFill (juce::ColourGradient (colours::accent.withAlpha (0.22f), 0.0f, r.getY(), - colours::accent.withAlpha (0.015f), 0.0f, r.getBottom(), false)); + g.setGradientFill (juce::ColourGradient (xoverCol.withAlpha (0.22f), 0.0f, r.getY(), + xoverCol.withAlpha (0.015f), 0.0f, r.getBottom(), false)); g.fillPath (fillPath); - g.setColour (colours::accent.withAlpha (0.62f)); + g.setColour (xoverCol.withAlpha (0.6f)); g.strokePath (spec, juce::PathStrokeType (1.4f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); } @@ -360,14 +446,14 @@ void SpectrumImager::paint (juce::Graphics& g) g.drawDashedLine ({ { r.getX(), y }, { r.getRight(), y } }, d, 2, 1.0f); } - // --- per-band width lines ------------------------------------------- + // --- per-band width lines (the look the user liked: feathered glow) --- g.setFont (juce::Font (juce::FontOptions (10.0f))); for (int b = 0; b < N; ++b) { const float w = bandWidth (b); const float y = widthToY (w); const float x0 = bandLeftX (b), x1 = bandRightX (b); - const float act = bandA[b]; + const float act = widthA[b]; const auto col = bandLo.interpolatedWith (bandHi, juce::jlimit (0.0f, 1.0f, w * 0.5f)); if (act > 0.01f) @@ -400,63 +486,103 @@ void SpectrumImager::paint (juce::Graphics& g) g.drawText (t.label, juce::Rectangle (x - 20.0f, rulerY() - 6.0f, 40.0f, 13.0f), juce::Justification::centred); } - // --- crossover handles (active only) -------------------------------- + // --- crossover-drag band-pass hint (#19) ---------------------------- + if (dragHandle >= 0 && dragHandle < N - 1) + { + const int i = dragHandle; + const float fx = freqToX (crossover (i)); + const float lx = bandLeftX (i), rx = bandRightX (i + 1); + const float yHi = r.getCentreY() + 6.0f; // band "shelf" level (lower half) + const float yLo = laneBot() + 2.0f; // crossover dip + juce::Path lo, hi; + lo.startNewSubPath (lx, yHi); + lo.quadraticTo (juce::jmax (lx, fx - (fx - lx) * 0.5f), yHi, fx, yLo); + hi.startNewSubPath (fx, yLo); + hi.quadraticTo (juce::jmin (rx, fx + (rx - fx) * 0.5f), yHi, rx, yHi); + g.setColour (bandLo.withAlpha (0.5f)); + g.strokePath (lo, juce::PathStrokeType (1.6f)); + g.setColour (bandHi.withAlpha (0.5f)); + g.strokePath (hi, juce::PathStrokeType (1.6f)); + } + + // --- crossover splits ------------------------------------------------ for (int i = 0; i < N - 1; ++i) { const float x = freqToX (crossover (i)); const float act = handleA[i]; - g.setColour (colours::text.withAlpha (0.5f).interpolatedWith (colours::accent, juce::jlimit (0.0f, 1.0f, act))); - g.drawLine (x, r.getY(), x, rulerY() - 9.0f, 1.2f + 0.9f * act); - g.setColour (act > 0.5f ? colours::accent : colours::text.withAlpha (0.6f)); - g.fillEllipse (x - 3.5f, r.getY() + 1.0f, 7.0f, 7.0f); + const bool editing = (editingHandle == i); + // Connected to the bottom edge at rest; breaks for the number on hover (#12). + const float bottomY = (act > 0.05f || editing) ? rulerY() - 9.0f : r.getBottom() - 2.0f; + const float capTop = r.getY() + 1.0f, capH = 12.0f; - if (act > 0.05f) + // Feathered glow like the width line (#7). + if (act > 0.01f) + juce::DropShadow (xoverCol.withAlpha (0.55f * act), 9, {}) + .drawForRectangle (g, juce::Rectangle ((int) x - 1, (int) capTop, 3, (int) (bottomY - capTop))); + + g.setColour (colours::text.withAlpha (0.45f).interpolatedWith (xoverCol, juce::jlimit (0.0f, 1.0f, act))); + g.drawLine (x, capTop + capH * 0.4f, x, bottomY, 1.2f + 0.9f * act); + + // Integrated cap: a rounded "bead" the line runs into, not a floating dot (#9). + auto cap = juce::Rectangle (x - 5.0f, capTop, 10.0f, capH); + g.setGradientFill (juce::ColourGradient (xoverCol.brighter (0.25f + 0.3f * act), 0.0f, cap.getY(), + xoverCol.withMultipliedBrightness (0.7f), 0.0f, cap.getBottom(), false)); + g.fillRoundedRectangle (cap, 4.0f); + g.setColour (juce::Colours::white.withAlpha (0.25f + 0.4f * act)); + g.drawRoundedRectangle (cap, 4.0f, 1.0f); + + // Freq readout chip in the bottom break (hidden while typing). + if (act > 0.05f && ! editing) { - const float f = crossover (i); - const juce::String t = f >= 1000.0f ? juce::String (f / 1000.0f, 2) + "k" : juce::String (juce::roundToInt (f)); - auto nb = juce::Rectangle (x - 22.0f, rulerY() - 7.0f, 44.0f, 14.0f); + auto nb = numberChip (i); g.setColour (colours::bgPanel.withAlpha (0.85f * act)); g.fillRoundedRectangle (nb, 3.0f); g.setFont (juce::Font (juce::FontOptions (10.0f))); g.setColour (colours::text.brighter (0.3f).withAlpha (act)); - g.drawText (t, nb, juce::Justification::centred); + g.drawText (freqText (crossover (i)), nb, juce::Justification::centred); + } + + // Delete x to the split's right (#6). + const float da = delA[i]; + if (da > 0.02f) + { + auto db = deleteBox (i); + g.setColour (colours::bgPanel.withAlpha (0.6f * da)); + g.fillEllipse (db); + g.setColour (colours::textDim.withAlpha (0.9f * da)); + const float pad = 3.5f; + g.drawLine (db.getX() + pad, db.getY() + pad, db.getRight() - pad, db.getBottom() - pad, 1.4f); + g.drawLine (db.getRight() - pad, db.getY() + pad, db.getX() + pad, db.getBottom() - pad, 1.4f); } } - // --- add-band hint (top strip, bands < 4) --------------------------- - if (addA > 0.02f) + // --- add-band hint (#13: full-height dashed line + big "+" at the top) --- + if (addA > 0.02f && N < 4) { - const float x = juce::jlimit (r.getX(), r.getRight(), addX); - const float a = addA; + const float x = juce::jlimit (r.getX(), r.getRight(), addX); + const float a = addA; float dl[2] = { 3.0f, 3.0f }; - g.setColour (colours::accent.withAlpha (0.55f * a)); - g.drawDashedLine ({ { x, yThird() }, { x, rulerY() - 9.0f } }, dl, 2, 1.2f); + g.setColour (xoverCol.withAlpha (0.55f * a)); + g.drawDashedLine ({ { x, r.getY() + 2.0f }, { x, rulerY() - 9.0f } }, dl, 2, 1.3f); - const float py = yThird() - 9.0f; // the "+" above the line - g.setColour (colours::accent.withAlpha (0.9f * a)); - g.drawLine (x - 4.0f, py, x + 4.0f, py, 1.6f); - g.drawLine (x, py - 4.0f, x, py + 4.0f, 1.6f); + const float py = r.getY() + 9.0f, arm = 6.0f; // big "+" hugging the top edge + g.setColour (xoverCol.withAlpha (0.95f * a)); + g.drawLine (x - arm, py, x + arm, py, 2.0f); + g.drawLine (x, py - arm, x, py + arm, 2.0f); - const float f = xToFreq (x); - const juce::String t = f >= 1000.0f ? juce::String (f / 1000.0f, 2) + "k" : juce::String (juce::roundToInt (f)); - auto nb = juce::Rectangle (x - 22.0f, rulerY() - 7.0f, 44.0f, 14.0f); + auto nb = numberChip (0).withX (x - 22.0f); g.setColour (colours::bgPanel.withAlpha (0.85f * a)); g.fillRoundedRectangle (nb, 3.0f); g.setFont (juce::Font (juce::FontOptions (10.0f))); - g.setColour (colours::accent.brighter (0.3f).withAlpha (a)); - g.drawText (t, nb, juce::Justification::centred); + g.setColour (xoverCol.brighter (0.3f).withAlpha (a)); + g.drawText (freqText (xToFreq (x)), nb, juce::Justification::centred); } - // --- delete affordances (dim, like a reference line) ---------------- - for (int b = 0; b < N; ++b) + // --- disabled wash (#20) -------------------------------------------- + if (enaA < 0.999f) { - const float a = delA[b]; - if (a < 0.02f) continue; - auto dr = deleteRect (b); - const float pad = 3.0f; - g.setColour (colours::textDim.withAlpha (0.75f * a)); - g.drawLine (dr.getX() + pad, dr.getY() + pad, dr.getRight() - pad, dr.getBottom() - pad, 1.4f); - g.drawLine (dr.getRight() - pad, dr.getY() + pad, dr.getX() + pad, dr.getBottom() - pad, 1.4f); + g.setColour (colours::bg.withAlpha (0.5f * (1.0f - enaA))); + g.fillRect (r); } } @@ -466,65 +592,74 @@ void SpectrumImager::paint (juce::Graphics& g) void SpectrumImager::updateHover (juce::Point p) { const int N = bandCount(); - const int h = handleNearX (p.x); - const int dHit = deleteHit (p); - const bool inAddZone = (N < 4) && (p.y < yThird()) && h < 0 && dHit < 0; + hoverHandle = hoverWidth = hoverAdd = hoverDelete = -1; - int delShow = dHit; - if (delShow < 0 && N > 1 && h < 0 && p.y > plot().getY() + plot().getHeight() * (2.0f / 3.0f)) - delShow = bandAtX (p.x); + const int overX = deleteHit (p); + const int h = handleNearX (p.x); + const int b = bandAtX (p.x); - hoverHandle = h; - hoverBand = (h >= 0 || inAddZone) ? -1 : bandAtX (p.x); - hoverDelete = delShow; - hoverAdd = inAddZone; - addX = p.x; + if (overX >= 0) { hoverHandle = overX; hoverDelete = overX; } + else if (h >= 0) { hoverHandle = h; if (N > 1) hoverDelete = h; } + else if (nearWidthLine (p, b)) hoverWidth = b; + else if (N < 4) { hoverAdd = b; addX = p.x; } - if (h >= 0) setMouseCursor (juce::MouseCursor::LeftRightResizeCursor); - else if (dHit >= 0) setMouseCursor (juce::MouseCursor::PointingHandCursor); - else if (inAddZone) setMouseCursor (juce::MouseCursor::PointingHandCursor); - else setMouseCursor (juce::MouseCursor::UpDownResizeCursor); + if (hoverHandle >= 0) setMouseCursor (juce::MouseCursor::LeftRightResizeCursor); + else if (hoverWidth >= 0) setMouseCursor (juce::MouseCursor::UpDownResizeCursor); + else if (hoverAdd >= 0) setMouseCursor (juce::MouseCursor::PointingHandCursor); + else setMouseCursor (juce::MouseCursor::NormalCursor); } void SpectrumImager::mouseMove (const juce::MouseEvent& e) { - scrollHandle = scrollBand = -1; // a real pointer move releases the scroll latch (#5) + if ((scrollHandle >= 0 || scrollBand >= 0) && e.position.getDistanceFrom (scrollAnchor) > 3.0f) + scrollHandle = scrollBand = -1; // a real move releases the scroll latch (#4) updateHover (e.position); } void SpectrumImager::mouseExit (const juce::MouseEvent&) { - hoverHandle = hoverBand = hoverDelete = -1; - hoverAdd = false; + hoverHandle = hoverWidth = hoverAdd = hoverDelete = -1; scrollHandle = scrollBand = -1; } void SpectrumImager::mouseDown (const juce::MouseEvent& e) { + if (editingHandle >= 0) commitFreqEditor(); + const auto p = e.position; const int N = bandCount(); + const bool alt = e.mods.isAltDown(); + + if (! alt) + if (const int overX = deleteHit (p); overX >= 0) { removeCrossover (overX); updateHover (p); return; } + const int h = handleNearX (p.x); - // Option/alt-click resets, the same gesture the knobs and sliders use (#6). - if (e.mods.isAltDown()) + if (alt) // Option/Alt-click resets, like the knobs (#6 prior / #18 clamp) { - if (h >= 0) resetParam (freqP[h]); - else resetParam (widthP[bandAtX (p.x)]); - repaint(); + if (h >= 0) resetCrossover (h); + else { const int b = bandAtX (p.x); if (nearWidthLine (p, b)) resetParam (widthP[b]); } return; } - if (const int dB = deleteHit (p); dB >= 0) { deleteBand (dB); return; } - if (h >= 0) { dragHandle = h; dragBand = -1; beginGesture (freqP[h]); repaint(); return; } - if (N < 4 && p.y < yThird()) { addBandAt (xToFreq (p.x)); return; } - const int b = bandAtX (p.x); - dragBand = b; dragHandle = -1; - beginGesture (widthP[b]); - setParam (widthP[b], yToWidth (p.y)); - repaint(); + if (nearWidthLine (p, b)) // width drag only near the line (#15) + { + dragBand = b; dragHandle = -1; + beginGesture (widthP[b]); + setParam (widthP[b], yToWidth (p.y)); + repaint(); + return; + } + + if (N < 4) // anywhere else in a band = add a split, and keep dragging it (#14/#16) + { + const int idx = addBandAt (xToFreq (p.x)); + if (idx >= 0) { dragHandle = idx; dragBand = -1; beginGesture (freqP[idx]); } + repaint(); + } } void SpectrumImager::mouseDrag (const juce::MouseEvent& e) @@ -554,38 +689,42 @@ void SpectrumImager::mouseUp (const juce::MouseEvent&) void SpectrumImager::mouseDoubleClick (const juce::MouseEvent& e) { - const int h = handleNearX ((float) e.position.x); - if (h >= 0) resetParam (freqP[h]); - else resetParam (widthP[bandAtX ((float) e.position.x)]); - repaint(); + const auto p = e.position; + const int N = bandCount(); + + for (int i = 0; i < N - 1; ++i) // double-click the number -> type a frequency (#5) + if (numberChip (i).contains (p)) { openFreqEditor (i); return; } + + const int h = handleNearX (p.x); + if (h >= 0) resetCrossover (h); + else { const int b = bandAtX (p.x); if (nearWidthLine (p, b)) resetParam (widthP[b]); } } void SpectrumImager::mouseWheelMove (const juce::MouseEvent& e, const juce::MouseWheelDetails& wheel) { const int N = bandCount(); - - // Latch the target on the first notch; the latch holds (even as the split - // slides out from under the cursor) until the pointer next moves (#5). if (scrollHandle < 0 && scrollBand < 0) { const int h = handleNearX ((float) e.position.x); if (h >= 0) scrollHandle = h; else scrollBand = bandAtX ((float) e.position.x); + scrollAnchor = e.position; } const float dy = (wheel.isReversed ? -1.0f : 1.0f) * wheel.deltaY; if (std::abs (dy) < 1.0e-4f) return; + const float sgn = dy > 0.0f ? 1.0f : -1.0f; if (scrollHandle >= 0 && scrollHandle < N - 1) { - float f = crossover (scrollHandle) * std::pow (2.0f, dy * 0.5f); // ~half octave / notch + float f = crossover (scrollHandle) * std::pow (2.0f, dy * 0.3f); if (scrollHandle > 0) f = juce::jmax (f, crossover (scrollHandle - 1) * 1.05f); if (scrollHandle < N - 2) f = juce::jmin (f, crossover (scrollHandle + 1) * 0.95f); setParam (freqP[scrollHandle], juce::jlimit (kFreqLo, kFreqHi, f)); } - else if (scrollBand >= 0 && scrollBand < N) + else if (scrollBand >= 0 && scrollBand < N) // 1 %/notch (#17) { - setParam (widthP[scrollBand], juce::jlimit (0.0f, 2.0f, bandWidth (scrollBand) + dy * 0.05f)); + setParam (widthP[scrollBand], juce::jlimit (0.0f, 2.0f, bandWidth (scrollBand) + sgn * 0.01f)); } repaint(); } diff --git a/src/gui/SpectrumImager.h b/src/gui/SpectrumImager.h index a1228d1..eb3a9a3 100644 --- a/src/gui/SpectrumImager.h +++ b/src/gui/SpectrumImager.h @@ -12,14 +12,18 @@ namespace anamorph::gui // // An Ozone-Imager / FabFilter Pro-Q style band editor: a live FFT spectrum on a // long horizontal panel, split by up to THREE draggable crossover handles into -// 1..4 bands, each carrying its own stereo width (drag UP = wider / DOWN = -// narrower inside the band). Bands are added by clicking the top "add" strip and -// removed via the per-band x; scrolling fine-tunes a split (on a line) or a -// band's width (off a line). Reads the lock-free ScopeBuffer for the analyser -// and drives the parameters directly, so automation, undo and A/B all track. +// 1..4 bands, each carrying its own stereo width. // -// Hover / press states ease in and out (the same non-linear fade as the rest of -// the UI) when UI Animations are on, and snap when they are off. +// Interaction (0.6.7): +// * Drag a split to move it; click-drag in empty band space ADDS a split and +// keeps dragging it. +// * Hover a band (away from its width line) -> a "+" add hint; hover the width +// line -> drag it up/down for the band width. +// * Hover a split -> a x appears to its right to remove it. +// * Scroll a split = frequency; scroll a band = width (1 %/notch); the wheel +// latches its target until the pointer really moves. +// * Double-click a split's number to TYPE a frequency; double-click / Alt-click +// elsewhere resets. // ============================================================================ class SpectrumImager : public juce::Component, public juce::SettableTooltipClient, @@ -44,15 +48,14 @@ class SpectrumImager : public juce::Component, void pushFFT(); // --- geometry helpers (component-local) ------------------------------ - juce::Rectangle plot() const noexcept; // graph area inside the frame + juce::Rectangle plot() const noexcept; float freqToX (float hz) const noexcept; float xToFreq (float x) const noexcept; float widthToY (float w) const noexcept; float yToWidth (float y) const noexcept; - float yThird() const noexcept; // the "add band" line, 1/3 down - float rulerY() const noexcept; // baseline for the frequency numbers - float laneTop() const noexcept; // width = 2.0 maps here - float laneBot() const noexcept; // width = 0.0 maps here + float rulerY() const noexcept; + float laneTop() const noexcept; + float laneBot() const noexcept; // current plain values pulled from the parameters int bandCount() const noexcept; // 1..4 @@ -60,18 +63,23 @@ class SpectrumImager : public juce::Component, float bandWidth (int i) const noexcept; // 0..3 float bandLeftX (int b) const noexcept; float bandRightX (int b) const noexcept; - juce::Rectangle deleteRect (int b) const noexcept; + bool enabled() const noexcept; - int bandAtX (float x) const noexcept; // which of the active bands a column is in - int handleNearX (float x) const noexcept; // active crossover under the cursor, or -1 - int deleteHit (juce::Point) const noexcept; // band whose x is under the cursor, or -1 + int bandAtX (float x) const noexcept; + int handleNearX (float x) const noexcept; + bool nearWidthLine (juce::Point p, int b) const noexcept; + juce::Rectangle deleteBox (int i) const noexcept; // the x to a split's right + juce::Rectangle numberChip (int i) const noexcept; // the freq readout / edit box + int deleteHit (juce::Point) const noexcept; - // spectrum magnitude (dB) for the pixel column [xa, xb], averaging / interpolating bins + // spectrum magnitude (dB) for the pixel column [xa, xb] float magForColumn (float xa, float xb) const noexcept; + float magCubic (float bin) const noexcept; - // structural edits (write the parameters, engine ducks the swap) - void addBandAt (float hz); - void deleteBand (int b); + // structural / value edits (engine ducks the swap) + int addBandAt (float hz); // returns the NEW crossover index, or -1 + void removeCrossover (int i); + void resetCrossover (int i); void beginGesture (juce::RangedAudioParameter*); void setParam (juce::RangedAudioParameter*, float plain); @@ -81,43 +89,51 @@ class SpectrumImager : public juce::Component, void updateHover (juce::Point); + void openFreqEditor (int i); + void commitFreqEditor(); + void closeFreqEditor(); + static float parseFreq (const juce::String&); + anamorph::ScopeBuffer& scope; juce::AudioProcessorValueTreeState& apvts; juce::RangedAudioParameter* bandsP { nullptr }; juce::RangedAudioParameter* freqP[3] { nullptr, nullptr, nullptr }; juce::RangedAudioParameter* widthP[4] { nullptr, nullptr, nullptr, nullptr }; - std::atomic* animOnP { nullptr }; + std::atomic* animOnP { nullptr }; + std::atomic* enableP { nullptr }; + + std::unique_ptr freqEditor; + int editingHandle = -1; - // FFT analyser (GUI thread). A large window keeps the LOW end resolved so the - // bottom octaves stop looking blocky / stair-stepped (0.6.6 #8). + // FFT analyser (GUI thread). A large window + cubic interpolation keeps the LOW + // end smooth (0.6.7 #11). static constexpr int fftOrder = 13; static constexpr int fftSize = 1 << fftOrder; // 8192 juce::dsp::FFT fft { fftOrder }; juce::dsp::WindowingFunction window { (size_t) fftSize, juce::dsp::WindowingFunction::hann }; - std::vector fifoL, fifoR, fftData, mags; // mags: 0..fftSize/2 smoothed dB + std::vector fifoL, fifoR, fftData, mags; double sampleRate = 48000.0; - int dragHandle = -1; // crossover 0..2 being dragged, else -1 - int dragBand = -1; // band 0..3 being width-dragged, else -1 + int dragHandle = -1; + int dragBand = -1; int hoverHandle = -1; - int hoverBand = -1; - int hoverDelete = -1; // band whose x affordance is showing - bool hoverAdd = false; // cursor is in the top "add" strip (and bands < 4) - float addX = 0.0f; // cursor x for the add hint - - // Scroll latches a target (the split or band under the cursor) and KEEPS it - // until the pointer next moves, so a split scrolled out from under the cursor - // still responds to the wheel (0.6.6 #5). + int hoverWidth = -1; // band whose width line is hovered + int hoverAdd = -1; // band showing the add hint + int hoverDelete = -1; // crossover showing its delete x + float addX = 0.0f; + + // Scroll latches its target and holds it until the pointer really moves (#4). int scrollHandle = -1; int scrollBand = -1; + juce::Point scrollAnchor; - // Eased hover/press activity (0.6.6 #11): one per crossover, per band, plus the - // add strip and each band's delete affordance. + // Eased hover/press activity (#11 / 0.6.6 #11). float handleA[3] { 0, 0, 0 }; - float bandA[4] { 0, 0, 0, 0 }; - float delA[4] { 0, 0, 0, 0 }; + float widthA[4] { 0, 0, 0, 0 }; + float delA[3] { 0, 0, 0 }; float addA = 0.0f; + float enaA = 1.0f; // eased enabled (1) / disabled (0) wash (#20) JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SpectrumImager) }; From 4bf1c0f635e71ed2f2c79ea04b756a2d0ad7a04b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:35:29 +0000 Subject: [PATCH 2/2] v0.6.8: bump project version https://claude.ai/code/session_01Y38PtwPxh2geBLta6yuUwv --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1eada4d..d3bc3e6 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.7 LANGUAGES C CXX) +project(Anamorph VERSION 0.6.8 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)