#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"); 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); 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: void drawBars (juce::Graphics& g, int n, const juce::Rectangle& plot, float gap, float bandWidth, float baseY) { 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; g.setColour (lf.getTeal()); g.fillRect (x, y, bandWidth, h); const float peakY = baseY - peak * plot.getHeight(); g.setColour (lf.getPeak()); g.fillRect (x, peakY - 2.0f, bandWidth, 3.0f); } } 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(); // Brighter at the top, darker toward the bottom. juce::ColourGradient grad (lf.getTeal().brighter (0.35f), 0.0f, plot.getY(), lf.getTeal().darker (0.85f), 0.0f, plot.getBottom(), false); g.setGradientFill (grad); g.fillPath (wave); // Bright outline tracing the waveform. g.setColour (lf.getTeal().brighter (0.5f)); 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()); g.setColour (lf.getPeak()); 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) { const float frac = (b + 0.5f) / static_cast (leds); g.setColour (lf.getTeal().withMultipliedBrightness (0.35f + 0.65f * frac)); g.fillRoundedRectangle (juce::Rectangle (x, blockTop, bandWidth, blockLen), radius); } // Peak honours the same discrete blocks: draw the peak block in the // peak colour (a single, fully-filled block). if (b == peakBlock - 1) { g.setColour (lf.getPeak()); g.fillRoundedRectangle (juce::Rectangle (x, blockTop, bandWidth, blockLen), radius); } } } } 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; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AnalyserComponent) };