#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 (hue - 0.5)); displayMode = mode; ledCount = leds; smoothAmount = static_cast (smooth); gridAmount = static_cast (gridVis); gridZoom = static_cast (gridZoomV); gridFade = static_cast (gridFadeV); curveAmount = static_cast (curveV); depthAmount = static_cast (depthV); gradAngle = static_cast (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 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 (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 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 (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 (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 (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& 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 c = bounds.getCentre(); const float half = 0.5f * std::sqrt (bounds.getWidth() * bounds.getWidth() + bounds.getHeight() * bounds.getHeight()); const juce::Point p1 = c - juce::Point (dx, dy) * half; const juce::Point p2 = c + juce::Point (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& 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& 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 (x, y, bandWidth, h), barRadius, depthAmount); const float peakY = baseY - peak * plot.getHeight(); fill3D (g, lf.getPeak(), juce::Rectangle (x, peakY - 2.0f, bandWidth, peakH), peakRadius, depthAmount); } } void drawWaveform (juce::Graphics& g, int n, const juce::Rectangle& 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 levels (static_cast (n)); for (int i = 0; i < n; ++i) levels[i] = analyser.getLevel (i); for (int pass = 0; pass < 4; ++pass) { juce::HeapBlock tmp (static_cast (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> wpts (static_cast (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 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 peaks (static_cast (n)); for (int i = 0; i < n; ++i) peaks[i] = analyser.getPeak (i); for (int pass = 0; pass < 4; ++pass) { juce::HeapBlock tmp (static_cast (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> ppts (static_cast (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 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& plot, float gap, float bandWidth, float baseY) { const int leds = juce::jmax (1, ledCount); 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); 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 (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 (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) };