Add TILT spectral-tilt knob plus grid zoom/fade and curving controls

- TILT knob (-1..1, 0=flat) boosts highs / cuts lows about the log-frequency
  centre so high frequencies become more visible in the display
- Add GRID ZOOM, GRID FADE, and CURVING knobs; refine BARS/LEDS snapping
- Fix README MODE knob placement description
This commit is contained in:
Armin 2026-08-14 02:55:28 +02:00
commit ca9555af13
7 changed files with 176 additions and 15 deletions

View file

@ -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

View file

@ -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<double> (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<double> (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<float> (norm);

View file

@ -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;
};

View file

@ -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<float> (smooth);
gridAmount = static_cast<float> (gridVis);
gridZoom = static_cast<float> (gridZoomV);
gridFade = static_cast<float> (gridFadeV);
curveAmount = static_cast<float> (curveV);
analyser.update (1.0 / 60.0);
repaint();
@ -68,10 +74,21 @@ public:
const float bandWidth = (plot.getWidth() - gap * (n + 1)) / static_cast<float> (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<float> 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<int> (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<int> (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<float> (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<float> (leds);
g.setColour (lf.getTeal().withMultipliedBrightness (0.35f + 0.65f * frac));
g.fillRect (x, blockTop, bandWidth, blockLen);
g.fillRoundedRectangle (juce::Rectangle<float> (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<float> (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)
};

View file

@ -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<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.getParametersState(), "grid", gridSlider);
gridzoomAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.getParametersState(), "gridzoom", gridzoomSlider);
gridfadeAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.getParametersState(), "gridfade", gridfadeSlider);
curvingAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.getParametersState(), "curving", curvingSlider);
tiltAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
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));

View file

@ -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<juce::AudioProcessorValueTreeState::SliderAttachment> ledsAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> smoothAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> gridAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> gridzoomAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> gridfadeAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> curvingAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> tiltAttachment;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BeamgridAudioProcessorEditor)
};

View file

@ -51,12 +51,33 @@ BeamgridAudioProcessor::createParameterLayout()
juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f), 0.32f,
juce::AudioParameterFloatAttributes().withLabel ("")));
params.push_back (std::make_unique<juce::AudioParameterFloat>(
"gridzoom", "Grid Zoom",
juce::NormalisableRange<float> (0.98f, 1.02f, 0.001f), 1.0f,
juce::AudioParameterFloatAttributes().withLabel ("")));
params.push_back (std::make_unique<juce::AudioParameterFloat>(
"gridfade", "Grid Fade",
juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f), 0.0f,
juce::AudioParameterFloatAttributes().withLabel ("")));
params.push_back (std::make_unique<juce::AudioParameterFloat>(
"curving", "Curving",
juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f), 0.0f,
juce::AudioParameterFloatAttributes().withLabel ("")));
params.push_back (std::make_unique<juce::AudioParameterFloat>(
"tilt", "Tilt",
juce::NormalisableRange<float> (-1.0f, 1.0f, 0.001f), 0.0f,
juce::AudioParameterFloatAttributes().withLabel ("")));
juce::NormalisableRange<float> 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<juce::AudioParameterInt>(
"mode", "Mode", 1, 3, 1));
params.push_back (std::make_unique<juce::AudioParameterInt>(
"leds", "LEDs/Bar", 4, 48, 16));
juce::NormalisableRange<float> 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<juce::AudioParameterFloat>(
"leds", "LEDs/Bar", ledsRange, 16.0f));
return { params.begin(), params.end() };
}
@ -107,6 +149,8 @@ void BeamgridAudioProcessor::processBlock (juce::AudioBuffer<float>& 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());