#include "VectorScope.h" VectorScope::VectorScope(TerionProcessor& p) : processor(p) { startTimerHz(30); } VectorScope::~VectorScope() { stopTimer(); } void VectorScope::timerCallback() { // Refresh the numeric readouts at kMetricsRateHz while keeping the trail // animation running at the full 30 Hz. const float frozen = processor.apvts.getRawParameterValue("freeze")->load(); if (frozen < 0.5f && ++metricFrame >= 30 / kMetricsRateHz) { metricFrame = 0; cachedMetrics = processor.getMetrics(); } repaint(); } float VectorScope::getRadius() const { auto bounds = getLocalBounds(); return juce::jmin(bounds.getWidth(), bounds.getHeight()) * 0.5f * 0.85f; } void VectorScope::resized() { auto bounds = getLocalBounds(); trail = juce::Image(juce::Image::ARGB, bounds.getWidth(), bounds.getHeight(), true); } void VectorScope::paint(juce::Graphics& g) { auto bounds = getLocalBounds(); const int w = bounds.getWidth(); const int h = bounds.getHeight(); const float cx = w * 0.5f; const float cy = h * 0.5f; const float radius = getRadius(); const float dotSize = processor.apvts.getRawParameterValue("dotSize")->load(); const float frozen = processor.apvts.getRawParameterValue("freeze")->load(); // Persistent glow trail: each frame the image itself is faded by a // decay factor (so it keeps dying off even after audio stops), then the // dots captured since the last frame are stamped in at full brightness. // Older history is retained purely in the image, so lowering the level // never erases the trail prematurely. if (trail.isValid()) { if (frozen < 0.5f) { const float fadeTime = processor.apvts.getRawParameterValue("fadeTime")->load(); const float afterglow = processor.apvts.getRawParameterValue("afterglow")->load(); const float totalLife = fadeTime + afterglow; const float fps = 30.0f; if (totalLife > 0.0f) { // Per-frame alpha multiplier: the trail falls to ~0.1% of its // brightness over fadeTime + afterglow seconds. const float decay = std::exp(-6.9f / (totalLife * fps)); trail.multiplyAllAlphas(decay); } // Stamp only points captured within roughly the last frame and a // half; everything older lives on in the decaying image above. const float newAge = 1.5f / (fps * fadeTime); const float newAlpha = 0.9f; const auto& points = processor.getPoints(); juce::Graphics trailG(trail); for (const auto& pt : points) { if (pt.age > newAge) continue; float x = cx + pt.side * radius * 2.0f; float y = cy - pt.mid * radius * 2.0f; float phase = std::abs(pt.side) / (std::abs(pt.mid) + 0.0001f); phase = juce::jmin(phase, 1.0f); juce::Colour color = juce::Colour::fromFloatRGBA( 0.2f + phase * 0.8f, 1.0f - phase * 0.4f, 1.0f - phase * 0.6f, newAlpha); trailG.setColour(color); trailG.fillRect(x - dotSize * 0.5f, y - dotSize * 0.5f, dotSize, dotSize); } } } // Draw background g.fillAll(juce::Colour(0xff0a0a0f)); // Draw the trail if (trail.isValid()) g.drawImageAt(trail, 0, 0); // Concentric circles with dB labels g.setColour(juce::Colour(0xff1a1a25)); const float dBValues[] = { -12.0f, -6.0f, 0.0f }; const float dBFactors[] = { 0.25f, 0.5f, 1.0f }; for (int i = 0; i < 3; ++i) { float r = radius * dBFactors[i]; g.drawEllipse(cx - r, cy - r, r * 2.0f, r * 2.0f, 0.5f); // dB label at top-right of each circle g.setColour(juce::Colour(0xff444455)); g.setFont(juce::Font(juce::FontOptions(9.0f * currentScale))); juce::String dbLabel = juce::String(dBValues[i], 0) + "dB"; g.drawText(dbLabel, cx + r + 3.0f * currentScale, cy - r - 2.0f * currentScale, 40.0f * currentScale, 14.0f * currentScale, juce::Justification::centredLeft); g.setColour(juce::Colour(0xff1a1a25)); } // 45-degree lines (L=R and L=-R) g.setColour(juce::Colour(0xff181822)); g.drawLine(cx - radius, cy - radius, cx + radius, cy + radius, 0.5f); g.drawLine(cx - radius, cy + radius, cx + radius, cy - radius, 0.5f); // Hair cross (brighter, thin) drawHairCross(g, cx, cy); // Legends drawLegends(g, cx, cy, radius); // Metrics overlay drawMetricsOverlay(g, bounds); // Border g.setColour(juce::Colour(0xff333340)); g.drawRect(bounds, 1); } void VectorScope::drawHairCross(juce::Graphics& g, float cx, float cy) { auto bounds = getLocalBounds().toFloat(); // Bright center cross — two short, bright strokes g.setColour(juce::Colour(0xff9999b3)); // Horizontal g.drawLine(bounds.getX() + 8.0f, cy, bounds.getRight() - 8.0f, cy, 0.5f); // Vertical g.drawLine(cx, bounds.getY() + 8.0f, cx, bounds.getBottom() - 8.0f, 0.5f); // Tiny center dot g.setColour(juce::Colour(0xff8888aa)); g.fillEllipse(cx - 2.0f, cy - 2.0f, 4.0f, 4.0f); } void VectorScope::drawLegends(juce::Graphics& g, float cx, float cy, float radius) { const float s = currentScale; g.setColour(juce::Colour(0xff666677)); g.setFont(juce::Font(juce::FontOptions(10.0f * s))); // Side label (X axis, right side) g.drawText("Side", cx + radius + 4.0f * s, cy + 4.0f * s, 40.0f * s, 14.0f * s, juce::Justification::centredLeft); // Mid label (Y axis, top) g.drawText("Mid", cx + 4.0f * s, cy - radius - 16.0f * s, 40.0f * s, 14.0f * s, juce::Justification::centredLeft); // Diagonal labels: in M/S space these are the pure-channel loci g.setColour(juce::Colour(0xff444455)); g.setFont(juce::Font(juce::FontOptions(8.0f * s))); // L = 0 (right channel only): line y = x through centre float diagX = cx + radius * 0.71f; g.drawText("R only", diagX + 4.0f * s, cy + radius * 0.71f, 30.0f * s, 12.0f * s, juce::Justification::centredLeft); // R = 0 (left channel only): line y = -x through centre g.drawText("L only", diagX + 4.0f * s, cy - radius * 0.71f - 12.0f * s, 30.0f * s, 12.0f * s, juce::Justification::centredLeft); } void VectorScope::drawMetricsOverlay(juce::Graphics& g, const juce::Rectangle& bounds) { const auto& metrics = cachedMetrics; const float s = currentScale; const int margin = static_cast(10.0f * s); const int lineHeight = static_cast(14.0f * s); const int startY = bounds.getBottom() - margin - (7 * lineHeight); // Background panel juce::Rectangle panelRect(bounds.getX() + margin, startY - static_cast(5.0f * s), static_cast(120.0f * s), 7 * lineHeight + static_cast(10.0f * s)); g.setColour(juce::Colour(0xff0a0a0f).withAlpha(0.8f)); g.fillRect(panelRect); g.setColour(juce::Colour(0xff333340)); g.drawRect(panelRect, 1); // Metrics text g.setFont(juce::Font(juce::FontOptions(9.0f * s))); g.setColour(juce::Colour(0xff888899)); auto drawMetric = [&](const juce::String& label, float value, const juce::String& unit, int y) { g.drawText(label, panelRect.getX() + static_cast(5.0f * s), y, static_cast(50.0f * s), lineHeight, juce::Justification::centredLeft); g.setColour(juce::Colour(0xffccccdd)); g.drawText(juce::String(value, 1) + unit, panelRect.getX() + static_cast(55.0f * s), y, static_cast(60.0f * s), lineHeight, juce::Justification::centredRight); g.setColour(juce::Colour(0xff888899)); }; int y = startY; drawMetric("Corr", metrics.correlation, "", y); y += lineHeight; drawMetric("Width", metrics.stereoWidth, "dB", y); y += lineHeight; drawMetric("Mid", metrics.midLevel, "dB", y); y += lineHeight; drawMetric("Side", metrics.sideLevel, "dB", y); y += lineHeight; drawMetric("Balance", metrics.balance, "dB", y); y += lineHeight; drawMetric("Phase", metrics.phaseAngle, "deg", y); y += lineHeight; drawMetric("Crest", metrics.crestFactor, "dB", y); y += lineHeight; }