diff --git a/README.md b/README.md index 4121b7d..4fa54ba 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ and a configurable display. ## Features -- **Three display modes** (top-right `MODE` knob): +- **Three display modes** (`MODE` knob): - **BARS** — classic log-spaced spectrum bars with peak-hold caps. - **SPECTRUM** — a smooth (quadratic-spline) waveform trace of the spectrum, with a matching smooth peak-hold wave. Use the `SMOOTH` knob to go from @@ -30,6 +30,10 @@ and a configurable display. | LEDS | 4 – 48 | LEDs per bar in LED mode | | SMOOTH | 0 – 1 | Waveform smoothing amount in SPECTRUM mode | | GRID | 0 – 1 | Grid (frequency/level) visibility / brightness| +| ZOOM | 98.0% – 102.0% | Grid zoom about the plot centre | +| GRID FADE | 0 – 1 | Fades inner grid lines; outer border stays | +| CURVING | 0 – 1 | LED corner radius (0 = square, 1 = rounded) | +| TILT | -1 – 1 | Spectral tilt: + boosts highs / cuts lows, - does the reverse (0 = flat) | ## Building diff --git a/Source/Analyser.cpp b/Source/Analyser.cpp index d15ce9d..a6766d7 100644 --- a/Source/Analyser.cpp +++ b/Source/Analyser.cpp @@ -95,7 +95,14 @@ void Analyser::computeFFT() // to ~full scale, otherwise the raw magnitudes sit ~66 dB too hot. const double normalized = avg / static_cast (fftSize); const double db = 20.0 * std::log10 (normalized + 1e-9); - double norm = (db - minDb) / (maxDb - minDb); + + // Spectral tilt: boost highs / cut lows (or the reverse) about the + // logarithmic centre of the spectrum. The per-band fraction spans + // 0 (lowest band) to 1 (highest band), so (frac - 0.5) * 2 is -1..+1. + const double frac = (static_cast (b) + 0.5) / numBands; + const double tiltDb = tilt * (frac - 0.5) * 2.0 * maxTiltDb; + + double norm = (db + tiltDb - minDb) / (maxDb - minDb); norm = juce::jlimit (0.0, 1.0, norm); targets[b] = static_cast (norm); diff --git a/Source/Analyser.h b/Source/Analyser.h index 48c0d8e..0f878b9 100644 --- a/Source/Analyser.h +++ b/Source/Analyser.h @@ -31,6 +31,8 @@ public: void setFalloffRate (double rate) noexcept { falloffRate = rate; } void setBarReleaseTau (double seconds) noexcept { barReleaseTau = seconds; } void setNumBands (int n) noexcept; + void setTilt (double amount) noexcept { tilt = amount; } + double getTilt() const noexcept { return tilt; } double getGraceSeconds() const noexcept { return grace; } double getFalloffRate() const noexcept { return falloffRate; } @@ -71,4 +73,10 @@ private: double barReleaseTau = 0.2; // seconds for the live bar to fall (release) double minDb = -48.0; // dBFS-equivalent floor (bands below this -> 0) double maxDb = 0.0; // dBFS-equivalent ceiling (bands at/above -> 1) + + // Spectral tilt: +1 fully boosts the highs and cuts the lows, -1 does the + // opposite, 0 is flat. The gain is applied symmetrically about the band at + // the centre of the log-frequency range. + static constexpr double maxTiltDb = 18.0; + double tilt = 0.0; }; diff --git a/Source/AnalyserComponent.h b/Source/AnalyserComponent.h index 9650a43..042a078 100644 --- a/Source/AnalyserComponent.h +++ b/Source/AnalyserComponent.h @@ -35,6 +35,9 @@ public: const int leds = juce::roundToInt (params.getRawParameterValue ("leds")->load()); const double smooth = *params.getRawParameterValue ("smooth"); const double gridVis = *params.getRawParameterValue ("grid"); + const double gridZoomV = *params.getRawParameterValue ("gridzoom"); + const double gridFadeV = *params.getRawParameterValue ("gridfade"); + const double curveV = *params.getRawParameterValue ("curving"); analyser.setGraceSeconds (graceMs / 1000.0); analyser.setFalloffRate (0.1 + falloff * 5.0); @@ -49,6 +52,9 @@ public: ledCount = leds; smoothAmount = static_cast (smooth); gridAmount = static_cast (gridVis); + gridZoom = static_cast (gridZoomV); + gridFade = static_cast (gridFadeV); + curveAmount = static_cast (curveV); analyser.update (1.0 / 60.0); repaint(); @@ -68,10 +74,21 @@ public: const float bandWidth = (plot.getWidth() - gap * (n + 1)) / static_cast (n); const float baseY = plot.getBottom(); - const juce::Colour gridColour = lf.transform (juce::Colours::grey.brighter (0.2f)).withAlpha (gridAmount); - const juce::Colour labelColour = gridColour; + const juce::Colour gridBase = lf.transform (juce::Colours::grey.brighter (0.2f)); + const juce::Colour labelColour = gridBase.withAlpha (gridAmount); // --- Gridlines (drawn first; labels drawn last so they stay readable) --- + // The grid is zoomed between 90% and 100% about the plot centre via the + // ZOOM knob; the spectrum content itself is not affected. + const juce::Point gridCentre = plot.getCentre(); + const juce::AffineTransform gridXform = + juce::AffineTransform::translation (-gridCentre.x, -gridCentre.y) + .followedBy (juce::AffineTransform::scale (gridZoom)) + .followedBy (juce::AffineTransform::translation (gridCentre.x, gridCentre.y)); + + g.saveState(); + g.addTransform (gridXform); + const double freqTicks[] = { 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000 }; for (double f : freqTicks) { @@ -80,7 +97,10 @@ public: const float frac = analyser.freqToFraction (f); const float x = plot.getX() + gap + frac * (plot.getWidth() - 2.0f * gap); - g.setColour (gridColour); + // The outermost (border) lines stay; inner lines fade with GRID FADE. + const bool isBorder = (frac <= 0.0f || frac >= 1.0f); + const float a = isBorder ? gridAmount : gridAmount * (1.0f - gridFade); + g.setColour (gridBase.withAlpha (a)); g.drawVerticalLine (static_cast (x), plot.getY(), plot.getBottom()); } @@ -89,10 +109,14 @@ public: { const float frac = analyser.dbToFraction (db); const float y = baseY - frac * plot.getHeight(); - g.setColour (gridColour); + const bool isBorder = (frac <= 0.0f || frac >= 1.0f); + const float a = isBorder ? gridAmount : gridAmount * (1.0f - gridFade); + g.setColour (gridBase.withAlpha (a)); g.drawHorizontalLine (static_cast (y), plot.getX(), plot.getRight()); } + g.restoreState(); + // --- Spectrum content (mode dependent) --- if (displayMode == ModeWaveform) drawWaveform (g, n, plot, gap, bandWidth, baseY, smoothAmount); @@ -101,7 +125,9 @@ public: else drawBars (g, n, plot, gap, bandWidth, baseY); - // --- Axis labels (on top of everything) --- + // --- Axis labels (on top of everything; share the grid zoom) --- + g.saveState(); + g.addTransform (gridXform); g.setFont (juce::FontOptions (10.0f)); g.setColour (labelColour); for (double f : freqTicks) @@ -122,6 +148,7 @@ public: g.drawText (juce::String (db, 0), area.getX(), y - 7.0f, 36.0f, 14.0f, juce::Justification::centredRight, false); } + g.restoreState(); } static juce::String formatFreq (double f) @@ -271,6 +298,9 @@ private: const float segH = plot.getHeight() / static_cast (leds); const float blockLen = juce::jmax (1.0f, segH - 1.0f); + // CURVING knob rounds the LED corners; 1.0 = fully rounded (pill). + const float radius = curveAmount * juce::jmin (bandWidth, blockLen) * 0.5f; + for (int i = 0; i < n; ++i) { const float level = analyser.getLevel (i); @@ -290,7 +320,7 @@ private: { const float frac = (b + 0.5f) / static_cast (leds); g.setColour (lf.getTeal().withMultipliedBrightness (0.35f + 0.65f * frac)); - g.fillRect (x, blockTop, bandWidth, blockLen); + g.fillRoundedRectangle (juce::Rectangle (x, blockTop, bandWidth, blockLen), radius); } // Peak honours the same discrete blocks: draw the peak block in the @@ -298,7 +328,7 @@ private: if (b == peakBlock - 1) { g.setColour (lf.getPeak()); - g.fillRect (x, blockTop, bandWidth, blockLen); + g.fillRoundedRectangle (juce::Rectangle (x, blockTop, bandWidth, blockLen), radius); } } } @@ -312,6 +342,9 @@ private: int ledCount = 16; float smoothAmount = 0.35f; float gridAmount = 0.32f; + float gridZoom = 1.0f; + float gridFade = 0.0f; + float curveAmount = 0.0f; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AnalyserComponent) }; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index b74983f..112267d 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -37,6 +37,10 @@ BeamgridAudioProcessorEditor::BeamgridAudioProcessorEditor (BeamgridAudioProcess setupKnob (ledsSlider, ""); setupKnob (smoothSlider, ""); setupKnob (gridSlider, ""); + setupKnob (gridzoomSlider, ""); + setupKnob (gridfadeSlider, ""); + setupKnob (curvingSlider, ""); + setupKnob (tiltSlider, ""); barsSlider.setNumDecimalPlacesToDisplay (0); modeSlider.setNumDecimalPlacesToDisplay (0); ledsSlider.setNumDecimalPlacesToDisplay (0); @@ -86,6 +90,26 @@ BeamgridAudioProcessorEditor::BeamgridAudioProcessorEditor (BeamgridAudioProcess gridLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); addAndMakeVisible (gridLabel); + gridzoomLabel.setText ("GRID ZOOM", juce::dontSendNotification); + gridzoomLabel.setJustificationType (juce::Justification::centred); + gridzoomLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (gridzoomLabel); + + gridfadeLabel.setText ("GRID FADE", juce::dontSendNotification); + gridfadeLabel.setJustificationType (juce::Justification::centred); + gridfadeLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (gridfadeLabel); + + curvingLabel.setText ("CURVING", juce::dontSendNotification); + curvingLabel.setJustificationType (juce::Justification::centred); + curvingLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (curvingLabel); + + tiltLabel.setText ("TILT", juce::dontSendNotification); + tiltLabel.setJustificationType (juce::Justification::centred); + tiltLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (tiltLabel); + scaleBox.addItemList (juce::StringArray ("75%", "100%", "150%", "200%", "250%", "300%"), 1); scaleBox.setSelectedItemIndex (1); // 100% scaleBox.setColour (juce::ComboBox::backgroundColourId, juce::Colours::black.brighter (0.15f)); @@ -135,6 +159,14 @@ BeamgridAudioProcessorEditor::BeamgridAudioProcessorEditor (BeamgridAudioProcess processorRef.getParametersState(), "smooth", smoothSlider); gridAttachment = std::make_unique( processorRef.getParametersState(), "grid", gridSlider); + gridzoomAttachment = std::make_unique( + processorRef.getParametersState(), "gridzoom", gridzoomSlider); + gridfadeAttachment = std::make_unique( + processorRef.getParametersState(), "gridfade", gridfadeSlider); + curvingAttachment = std::make_unique( + processorRef.getParametersState(), "curving", curvingSlider); + tiltAttachment = std::make_unique( + processorRef.getParametersState(), "tilt", tiltSlider); // Set these AFTER the attachments: SliderAttachment installs its own // textFromValueFunction, so assigning afterwards makes ours take effect. @@ -151,10 +183,23 @@ BeamgridAudioProcessorEditor::BeamgridAudioProcessorEditor (BeamgridAudioProcess : juce::String ("BARS"); }; + ledsSlider.textFromValueFunction = [] (double v) + { + return juce::String (juce::roundToInt (v)); + }; + + tiltSlider.textFromValueFunction = [] (double v) + { + return (v >= 0.0 ? juce::String ("+") : juce::String ("")) + + juce::String (v, 2); + }; + // Force an immediate repaint of the text boxes now that our formatters // are installed (otherwise they keep the attachment's initial text). barsSlider.updateText(); modeSlider.updateText(); + ledsSlider.updateText(); + tiltSlider.updateText(); processorRef.getParametersState().addParameterListener ("hue", this); @@ -203,7 +248,6 @@ void BeamgridAudioProcessorEditor::paint (juce::Graphics& g) void BeamgridAudioProcessorEditor::resized() { const int panelH = 114; - const int knob = 64; const int y = getHeight() - panelH + 14; // 32px blank bar across the very top of the plugin (the dropdown sits @@ -215,11 +259,16 @@ void BeamgridAudioProcessorEditor::resized() const int margin = 24; const int gap = 14; - const int count = 9; - const int titleW = 220; + const int count = 13; + const int titleW = 180; // Available width for the knob row (leave room on the right for the title). const int avail = getWidth() - margin * 2 - titleW; + + // Shrink the knobs only if necessary so all `count` of them fit within the + // available width down to the 800px minimum; otherwise keep them at 64. + const int knob = juce::jlimit (40, 64, (avail - (count - 1) * 4) / count); + int spacing = knob + gap; const int minSpacing = knob + 4; if ((count - 1) * spacing + knob > avail) @@ -236,6 +285,10 @@ void BeamgridAudioProcessorEditor::resized() { ledsSlider, ledsLabel }, { smoothSlider, smoothLabel }, { gridSlider, gridLabel }, + { gridzoomSlider, gridzoomLabel }, + { gridfadeSlider, gridfadeLabel }, + { curvingSlider, curvingLabel }, + { tiltSlider, tiltLabel }, }; const juce::Font labelFont (juce::FontOptions (11.0f)); diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index fa5a8d7..f3be7a4 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -31,6 +31,10 @@ private: juce::Slider ledsSlider; juce::Slider smoothSlider; juce::Slider gridSlider; + juce::Slider gridzoomSlider; + juce::Slider gridfadeSlider; + juce::Slider curvingSlider; + juce::Slider tiltSlider; juce::Label graceLabel; juce::Label falloffLabel; @@ -41,6 +45,10 @@ private: juce::Label ledsLabel; juce::Label smoothLabel; juce::Label gridLabel; + juce::Label gridzoomLabel; + juce::Label gridfadeLabel; + juce::Label curvingLabel; + juce::Label tiltLabel; juce::Label titleLabel; juce::Label statusLabel; @@ -55,6 +63,10 @@ private: std::unique_ptr ledsAttachment; std::unique_ptr smoothAttachment; std::unique_ptr gridAttachment; + std::unique_ptr gridzoomAttachment; + std::unique_ptr gridfadeAttachment; + std::unique_ptr curvingAttachment; + std::unique_ptr tiltAttachment; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BeamgridAudioProcessorEditor) }; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index d37ea56..58118ca 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -51,12 +51,33 @@ BeamgridAudioProcessor::createParameterLayout() juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.32f, juce::AudioParameterFloatAttributes().withLabel (""))); + params.push_back (std::make_unique( + "gridzoom", "Grid Zoom", + juce::NormalisableRange (0.98f, 1.02f, 0.001f), 1.0f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + params.push_back (std::make_unique( + "gridfade", "Grid Fade", + juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.0f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + params.push_back (std::make_unique( + "curving", "Curving", + juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.0f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + params.push_back (std::make_unique( + "tilt", "Tilt", + juce::NormalisableRange (-1.0f, 1.0f, 0.001f), 0.0f, + juce::AudioParameterFloatAttributes().withLabel (""))); + juce::NormalisableRange barsRange (8.0f, 128.0f, [] (float start, float end, float v) { return start + v * (end - start); }, // convertFrom0To1 [] (float start, float end, float v) { return (v - start) / (end - start); }, // convertTo0To1 [] (float, float, float v) // snapToLegalValue { - const float steps[] = { 8.0f, 16.0f, 32.0f, 64.0f, 128.0f }; + const float steps[] = { 8.0f, 16.0f, 24.0f, 32.0f, 40.0f, 48.0f, 56.0f, 64.0f, + 72.0f, 80.0f, 88.0f, 96.0f, 104.0f, 112.0f, 120.0f, 128.0f }; float best = steps[0]; float bestDist = std::abs (v - best); for (float s : steps) @@ -77,8 +98,29 @@ BeamgridAudioProcessor::createParameterLayout() params.push_back (std::make_unique( "mode", "Mode", 1, 3, 1)); - params.push_back (std::make_unique( - "leds", "LEDs/Bar", 4, 48, 16)); + juce::NormalisableRange ledsRange (4.0f, 48.0f, + [] (float start, float end, float v) { return start + v * (end - start); }, // convertFrom0To1 + [] (float start, float end, float v) { return (v - start) / (end - start); }, // convertTo0To1 + [] (float, float, float v) // snapToLegalValue + { + const float steps[] = { 4.0f, 8.0f, 12.0f, 16.0f, 20.0f, 24.0f, + 28.0f, 32.0f, 36.0f, 40.0f, 44.0f, 48.0f }; + float best = steps[0]; + float bestDist = std::abs (v - best); + for (float s : steps) + { + const float dist = std::abs (v - s); + if (dist < bestDist) + { + bestDist = dist; + best = s; + } + } + return best; + }); + + params.push_back (std::make_unique( + "leds", "LEDs/Bar", ledsRange, 16.0f)); return { params.begin(), params.end() }; } @@ -107,6 +149,8 @@ void BeamgridAudioProcessor::processBlock (juce::AudioBuffer& buffer, { juce::ScopedNoDenormals noDenormals; + analyser.setTilt (*params.getRawParameterValue ("tilt")); + // Analyse the first channel; pass the audio through unchanged. if (buffer.getNumChannels() > 0) analyser.push (buffer.getReadPointer (0), buffer.getNumSamples());