mirror of
https://codeberg.org/armin/beamgrid.git
synced 2026-09-01 20:30:47 +02:00
413 lines
17 KiB
C++
413 lines
17 KiB
C++
#pragma once
|
|
|
|
#include "JuceHeader.h"
|
|
#include "Analyser.h"
|
|
#include "BeamgridLookAndFeel.h"
|
|
|
|
// Black spectrum visualizer: teal analyzer bands and peak-colored markers.
|
|
class AnalyserComponent : public juce::Component,
|
|
public juce::Timer
|
|
{
|
|
public:
|
|
enum { ModeBars = 1, ModeWaveform = 2, ModeLED = 3 };
|
|
|
|
AnalyserComponent (Analyser& analyserToUse,
|
|
juce::AudioProcessorValueTreeState& paramsToUse,
|
|
BeamgridLookAndFeel& lookAndFeelToUse)
|
|
: analyser (analyserToUse), params (paramsToUse), lf (lookAndFeelToUse)
|
|
{
|
|
startTimerHz (60);
|
|
}
|
|
|
|
~AnalyserComponent() override
|
|
{
|
|
stopTimer();
|
|
}
|
|
|
|
void timerCallback() override
|
|
{
|
|
const double graceMs = *params.getRawParameterValue ("grace");
|
|
const double falloff = *params.getRawParameterValue ("falloff");
|
|
const double barfall = *params.getRawParameterValue ("barfalloff");
|
|
const int bars = juce::roundToInt (params.getRawParameterValue ("bars")->load());
|
|
const double hue = *params.getRawParameterValue ("hue");
|
|
const int mode = juce::roundToInt (params.getRawParameterValue ("mode")->load());
|
|
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");
|
|
const double depthV = *params.getRawParameterValue ("depth3d");
|
|
const double gradAngV = *params.getRawParameterValue ("gradangle");
|
|
|
|
analyser.setGraceSeconds (graceMs / 1000.0);
|
|
analyser.setFalloffRate (0.1 + falloff * 5.0);
|
|
// Bar falloff: higher knob -> faster bar descent (smaller release tau).
|
|
analyser.setBarReleaseTau (2.0 - barfall * 1.98);
|
|
analyser.setNumBands (bars);
|
|
|
|
// HUE knob: 12 o'clock (0.5) = no rotation; left/right rotate +/- 180 deg.
|
|
lf.setHueTurns (static_cast<float> (hue - 0.5));
|
|
|
|
displayMode = mode;
|
|
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);
|
|
depthAmount = static_cast<float> (depthV);
|
|
gradAngle = static_cast<float> (gradAngV);
|
|
|
|
analyser.update (1.0 / 60.0);
|
|
repaint();
|
|
}
|
|
|
|
void paint (juce::Graphics& g) override
|
|
{
|
|
const auto area = getLocalBounds().toFloat().reduced (8.0f);
|
|
const int n = analyser.getNumBands();
|
|
|
|
// Reserve margins for the axis legends.
|
|
juce::Rectangle<float> plot = area;
|
|
plot.setLeft (area.getX() + 40.0f);
|
|
plot.setBottom (area.getBottom() - 20.0f);
|
|
|
|
const float gap = 2.0f;
|
|
const float bandWidth = (plot.getWidth() - gap * (n + 1)) / static_cast<float> (n);
|
|
const float baseY = plot.getBottom();
|
|
|
|
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)
|
|
{
|
|
if (f < analyser.getMinFreq() || f > analyser.getMaxFreq())
|
|
continue;
|
|
|
|
const float frac = analyser.freqToFraction (f);
|
|
const float x = plot.getX() + gap + frac * (plot.getWidth() - 2.0f * gap);
|
|
// 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());
|
|
}
|
|
|
|
const double dbTicks[] = { 0.0, -6.0, -12.0, -24.0, -36.0, -48.0 };
|
|
for (double db : dbTicks)
|
|
{
|
|
const float frac = analyser.dbToFraction (db);
|
|
const float y = baseY - frac * plot.getHeight();
|
|
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);
|
|
else if (displayMode == ModeLED)
|
|
drawLED (g, n, plot, gap, bandWidth, baseY);
|
|
else
|
|
drawBars (g, n, plot, gap, bandWidth, baseY);
|
|
|
|
// --- 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)
|
|
{
|
|
if (f < analyser.getMinFreq() || f > analyser.getMaxFreq())
|
|
continue;
|
|
|
|
const float frac = analyser.freqToFraction (f);
|
|
const float x = plot.getX() + gap + frac * (plot.getWidth() - 2.0f * gap);
|
|
g.drawText (formatFreq (f), x - 18.0f, plot.getBottom() + 3.0f, 36.0f, 14.0f,
|
|
juce::Justification::centredTop, false);
|
|
}
|
|
|
|
for (double db : dbTicks)
|
|
{
|
|
const float frac = analyser.dbToFraction (db);
|
|
const float y = baseY - frac * plot.getHeight();
|
|
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)
|
|
{
|
|
if (f >= 1000.0)
|
|
return juce::String (f / 1000.0, 1, false) + "k";
|
|
return juce::String (static_cast<int> (f));
|
|
}
|
|
|
|
private:
|
|
// Build a top-lit 3D gradient across `bounds`. `angleDeg` rotates the
|
|
// gradient direction (0 = vertical/bright-top, +/-90 = horizontal), `bright`
|
|
// and `dark` scale the highlight/shadow strength (typically 0..1).
|
|
juce::ColourGradient make3DGradient (juce::Colour base, const juce::Rectangle<float>& bounds,
|
|
float angleDeg, float bright, float dark)
|
|
{
|
|
const float rad = juce::degreesToRadians (angleDeg);
|
|
const float dx = std::sin (rad);
|
|
const float dy = std::cos (rad);
|
|
const juce::Point<float> c = bounds.getCentre();
|
|
const float half = 0.5f * std::sqrt (bounds.getWidth() * bounds.getWidth()
|
|
+ bounds.getHeight() * bounds.getHeight());
|
|
const juce::Point<float> p1 = c - juce::Point<float> (dx, dy) * half;
|
|
const juce::Point<float> p2 = c + juce::Point<float> (dx, dy) * half;
|
|
juce::ColourGradient grad (base.brighter (bright), p1.x, p1.y,
|
|
base.darker (dark), p2.x, p2.y, false);
|
|
grad.addColour (0.5, base);
|
|
return grad;
|
|
}
|
|
|
|
// Fill a rounded rectangle with a top-lit 3D gradient. `depth` (0..1) blends
|
|
// between a flat solid fill and a fully raised/beveled look, angled by the
|
|
// G-ANG knob, so the two knobs control the 3D appearance.
|
|
void fill3D (juce::Graphics& g, juce::Colour base, const juce::Rectangle<float>& r,
|
|
float radius, float depth)
|
|
{
|
|
if (depth <= 0.001f)
|
|
{
|
|
g.setColour (base);
|
|
g.fillRoundedRectangle (r, radius);
|
|
return;
|
|
}
|
|
|
|
g.setGradientFill (make3DGradient (base, r, gradAngle, depth, depth));
|
|
g.fillRoundedRectangle (r, radius);
|
|
}
|
|
|
|
void drawBars (juce::Graphics& g, int n, const juce::Rectangle<float>& plot,
|
|
float gap, float bandWidth, float baseY)
|
|
{
|
|
const float peakH = 3.0f;
|
|
const float peakRadius = curveAmount * juce::jmin (bandWidth, peakH) * 0.5f;
|
|
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const float level = analyser.getLevel (i);
|
|
const float peak = analyser.getPeak (i);
|
|
|
|
const float x = plot.getX() + gap + i * (bandWidth + gap);
|
|
const float h = level * plot.getHeight();
|
|
const float y = baseY - h;
|
|
|
|
const float barRadius = curveAmount * juce::jmin (bandWidth, h, 8.0f) * 0.5f;
|
|
|
|
fill3D (g, lf.getTeal(), juce::Rectangle<float> (x, y, bandWidth, h), barRadius, depthAmount);
|
|
|
|
const float peakY = baseY - peak * plot.getHeight();
|
|
fill3D (g, lf.getPeak(), juce::Rectangle<float> (x, peakY - 2.0f, bandWidth, peakH), peakRadius, depthAmount);
|
|
}
|
|
}
|
|
|
|
void drawWaveform (juce::Graphics& g, int n, const juce::Rectangle<float>& plot,
|
|
float gap, float bandWidth, float baseY, float smooth)
|
|
{
|
|
// Gather the band levels and apply 1-2-1 smoothing passes. `smooth`
|
|
// (0..1) blends each pass toward the averaged value, so 0 = untouched
|
|
// (edgy/spiky) and 1 = heavily smoothed.
|
|
const float t = juce::jlimit (0.0f, 1.0f, smooth);
|
|
|
|
juce::HeapBlock<float> levels (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
levels[i] = analyser.getLevel (i);
|
|
|
|
for (int pass = 0; pass < 4; ++pass)
|
|
{
|
|
juce::HeapBlock<float> tmp (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const float a = levels[juce::jmax (0, i - 1)];
|
|
const float b = levels[i];
|
|
const float c = levels[juce::jmin (n - 1, i + 1)];
|
|
const float avg = a * 0.25f + b * 0.5f + c * 0.25f;
|
|
tmp[i] = b * (1.0f - t) + avg * t;
|
|
}
|
|
for (int i = 0; i < n; ++i)
|
|
levels[i] = tmp[i];
|
|
}
|
|
|
|
// Trace the curve. `smooth` controls how much the band levels are
|
|
// averaged (above); the path itself is a quadratic spline through the
|
|
// band centres (same technique as the peak wave), so at smooth = 0 it
|
|
// stays raw/edgy and at 1 it is rounded by the heavy level smoothing.
|
|
// The curve is anchored to the first/last band value right at the plot
|
|
// edges so it reaches the full width instead of ramping down to the
|
|
// baseline at the sides.
|
|
juce::HeapBlock<juce::Point<float>> wpts (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const float x = plot.getX() + gap + i * (bandWidth + gap) + bandWidth * 0.5f;
|
|
const float y = baseY - levels[i] * plot.getHeight();
|
|
wpts[i] = { x, y };
|
|
}
|
|
|
|
juce::Path wave;
|
|
wave.startNewSubPath (plot.getX(), wpts[0].y);
|
|
wave.lineTo (wpts[0]);
|
|
for (int i = 0; i < n - 1; ++i)
|
|
{
|
|
const juce::Point<float> mid = { (wpts[i].x + wpts[i + 1].x) * 0.5f,
|
|
(wpts[i].y + wpts[i + 1].y) * 0.5f };
|
|
wave.quadraticTo (wpts[i], mid);
|
|
}
|
|
wave.quadraticTo (wpts[n - 1], wpts[n - 1]);
|
|
wave.lineTo (plot.getRight(), wpts[n - 1].y);
|
|
wave.lineTo (plot.getRight(), baseY);
|
|
wave.lineTo (plot.getX(), baseY);
|
|
wave.closeSubPath();
|
|
|
|
// 3D: a top-lit gradient (bright edge fading to a darker underside),
|
|
// angled by the G-ANG knob. `depthAmount` blends from a flat fill (0)
|
|
// to a fully raised look (1).
|
|
const juce::Colour base = lf.getTeal();
|
|
if (depthAmount <= 0.001f)
|
|
{
|
|
g.setColour (base);
|
|
g.fillPath (wave);
|
|
}
|
|
else
|
|
{
|
|
g.setGradientFill (make3DGradient (base, plot, gradAngle, 0.7f * depthAmount, depthAmount));
|
|
g.fillPath (wave);
|
|
}
|
|
|
|
// Bright top-edge outline (the "lit" highlight), stronger with depth.
|
|
g.setColour (base.brighter (0.3f + 0.5f * depthAmount));
|
|
g.strokePath (wave, juce::PathStrokeType (1.5f));
|
|
|
|
// Smooth peak-hold wave: per-band peaks, smoothed with the same
|
|
// amount as the main curve and traced as a matching spline.
|
|
juce::HeapBlock<float> peaks (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
peaks[i] = analyser.getPeak (i);
|
|
|
|
for (int pass = 0; pass < 4; ++pass)
|
|
{
|
|
juce::HeapBlock<float> tmp (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const float a = peaks[juce::jmax (0, i - 1)];
|
|
const float b = peaks[i];
|
|
const float c = peaks[juce::jmin (n - 1, i + 1)];
|
|
const float avg = a * 0.25f + b * 0.5f + c * 0.25f;
|
|
tmp[i] = b * (1.0f - t) + avg * t;
|
|
}
|
|
for (int i = 0; i < n; ++i)
|
|
peaks[i] = tmp[i];
|
|
}
|
|
|
|
juce::HeapBlock<juce::Point<float>> ppts (static_cast<size_t> (n));
|
|
for (int i = 0; i < n; ++i)
|
|
{
|
|
const float x = plot.getX() + gap + i * (bandWidth + gap) + bandWidth * 0.5f;
|
|
const float y = baseY - peaks[i] * plot.getHeight();
|
|
ppts[i] = { x, y };
|
|
}
|
|
|
|
juce::Path peakWave;
|
|
peakWave.startNewSubPath (plot.getX(), baseY - peaks[0] * plot.getHeight());
|
|
peakWave.lineTo (ppts[0]);
|
|
for (int i = 0; i < n - 1; ++i)
|
|
{
|
|
const juce::Point<float> mid = { (ppts[i].x + ppts[i + 1].x) * 0.5f,
|
|
(ppts[i].y + ppts[i + 1].y) * 0.5f };
|
|
peakWave.quadraticTo (ppts[i], mid);
|
|
}
|
|
peakWave.quadraticTo (ppts[n - 1], ppts[n - 1]);
|
|
peakWave.lineTo (plot.getRight(), baseY - peaks[n - 1] * plot.getHeight());
|
|
// Peak wave gets the same top-lit 3D shading.
|
|
const juce::Colour pbase = lf.getPeak();
|
|
if (depthAmount <= 0.001f)
|
|
{
|
|
g.setColour (pbase);
|
|
}
|
|
else
|
|
{
|
|
g.setGradientFill (make3DGradient (pbase, plot, gradAngle, 0.7f * depthAmount, depthAmount));
|
|
}
|
|
g.strokePath (peakWave, juce::PathStrokeType (2.0f));
|
|
}
|
|
|
|
void drawLED (juce::Graphics& g, int n, const juce::Rectangle<float>& plot,
|
|
float gap, float bandWidth, float baseY)
|
|
{
|
|
const int leds = juce::jmax (1, ledCount);
|
|
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);
|
|
const float peak = analyser.getPeak (i);
|
|
|
|
const int litCount = juce::jlimit (0, leds, juce::roundToInt (level * leds));
|
|
const int peakBlock = juce::jlimit (0, leds, juce::roundToInt (peak * leds)); // top lit block (1-based)
|
|
|
|
const float x = plot.getX() + gap + i * (bandWidth + gap);
|
|
|
|
for (int b = 0; b < leds; ++b)
|
|
{
|
|
const float blockBottom = baseY - b * segH;
|
|
const float blockTop = blockBottom - blockLen;
|
|
|
|
if (b < litCount)
|
|
{
|
|
// Flat base colour; the 3D knob (fill3D) is the only source
|
|
// of shading so LEDs stay solid until 3D is turned up.
|
|
fill3D (g, lf.getTeal(), juce::Rectangle<float> (x, blockTop, bandWidth, blockLen), radius, depthAmount);
|
|
}
|
|
|
|
// Peak honours the same discrete blocks: draw the peak block in the
|
|
// peak colour (a single, fully-filled block).
|
|
if (b == peakBlock - 1)
|
|
{
|
|
fill3D (g, lf.getPeak(), juce::Rectangle<float> (x, blockTop, bandWidth, blockLen), radius, depthAmount);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Analyser& analyser;
|
|
juce::AudioProcessorValueTreeState& params;
|
|
BeamgridLookAndFeel& lf;
|
|
|
|
int displayMode = ModeBars;
|
|
int ledCount = 16;
|
|
float smoothAmount = 0.35f;
|
|
float gridAmount = 0.32f;
|
|
float gridZoom = 1.0f;
|
|
float gridFade = 0.0f;
|
|
float curveAmount = 0.0f;
|
|
float depthAmount = 0.0f;
|
|
float gradAngle = 0.0f;
|
|
|
|
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AnalyserComponent)
|
|
};
|