#include "VectorScope.h" VectorScope::VectorScope(TerionProcessor& p) : processor(p) { startTimerHz(30); } VectorScope::~VectorScope() { stopTimer(); } void VectorScope::timerCallback() { 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(); // Rebuild the trail from live points each frame (only if not frozen). // The trail is fully cleared first, then every point with age < fadeTime // is redrawn with its age-based alpha, so dots past fadeTime disappear // completely instead of leaving an asymptotic dark-grey residue. if (trail.isValid() && frozen < 0.5f) { juce::Graphics trailG(trail); trailG.setColour(juce::Colours::black); trailG.fillRect(trail.getBounds().toFloat()); const auto& points = processor.getPoints(); const float fadeTime = processor.apvts.getRawParameterValue("fadeTime")->load(); for (const auto& pt : points) { if (pt.age > fadeTime) continue; float normAge = pt.age / fadeTime; float alpha = 1.0f - normAge; alpha = alpha * alpha; 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, alpha * 0.9f); 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(9.0f); juce::String dbLabel = juce::String(dBValues[i], 0) + "dB"; g.drawText(dbLabel, cx + r + 3, cy - r - 2, 40, 14, 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(0xff666680)); // 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) { g.setColour(juce::Colour(0xff666677)); g.setFont(10.0f); // Side label (X axis, right side) g.drawText("Side", cx + radius + 4.0f, cy + 4.0f, 40, 14, juce::Justification::centredLeft); // Mid label (Y axis, top) g.drawText("Mid", cx + 4.0f, cy - radius - 16.0f, 40, 14, juce::Justification::centredLeft); // Diagonal labels: in M/S space these are the pure-channel loci g.setColour(juce::Colour(0xff444455)); g.setFont(8.0f); // L = 0 (right channel only): line y = x through centre float diagX = cx + radius * 0.71f; g.drawText("R only", diagX + 4.0f, cy + radius * 0.71f, 30, 12, juce::Justification::centredLeft); // R = 0 (left channel only): line y = -x through centre g.drawText("L only", diagX + 4.0f, cy - radius * 0.71f - 12.0f, 30, 12, juce::Justification::centredLeft); } void VectorScope::drawMetricsOverlay(juce::Graphics& g, const juce::Rectangle& bounds) { const auto& metrics = processor.getMetrics(); const int margin = 10; const int lineHeight = 14; const int startY = bounds.getBottom() - margin - (7 * lineHeight); // Background panel juce::Rectangle panelRect(bounds.getX() + margin, startY - 5, 120, 7 * lineHeight + 10); g.setColour(juce::Colour(0xff0a0a0f).withAlpha(0.8f)); g.fillRect(panelRect); g.setColour(juce::Colour(0xff333340)); g.drawRect(panelRect, 1); // Metrics text g.setFont(9.0f); g.setColour(juce::Colour(0xff888899)); auto drawMetric = [&](const juce::String& label, float value, const juce::String& unit, int y) { g.drawText(label, panelRect.getX() + 5, y, 50, lineHeight, juce::Justification::centredLeft); g.setColour(juce::Colour(0xffccccdd)); g.drawText(juce::String(value, 1) + unit, panelRect.getX() + 55, y, 60, 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, "\u00B0", y); y += lineHeight; drawMetric("Crest", metrics.crestFactor, "dB", y); y += lineHeight; }