2026-09-16 17:47:45 +02:00
|
|
|
#include "VectorScope.h"
|
|
|
|
|
|
|
|
|
|
VectorScope::VectorScope(TerionProcessor& p)
|
|
|
|
|
: processor(p)
|
|
|
|
|
{
|
|
|
|
|
startTimerHz(30);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VectorScope::~VectorScope()
|
|
|
|
|
{
|
|
|
|
|
stopTimer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VectorScope::timerCallback()
|
|
|
|
|
{
|
2026-09-17 11:29:01 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-09-16 17:47:45 +02:00
|
|
|
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();
|
|
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
// 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())
|
2026-09-16 17:47:45 +02:00
|
|
|
{
|
2026-09-16 21:16:37 +02:00
|
|
|
if (frozen < 0.5f)
|
2026-09-16 17:47:45 +02:00
|
|
|
{
|
2026-09-16 21:16:37 +02:00
|
|
|
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;
|
2026-09-16 17:47:45 +02:00
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-09-16 17:47:45 +02:00
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
// 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();
|
2026-09-16 17:47:45 +02:00
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
juce::Graphics trailG(trail);
|
2026-09-16 17:47:45 +02:00
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
for (const auto& pt : points)
|
|
|
|
|
{
|
|
|
|
|
if (pt.age > newAge)
|
|
|
|
|
continue;
|
2026-09-16 17:47:45 +02:00
|
|
|
|
2026-09-16 21:16:37 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-09-16 17:47:45 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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));
|
2026-09-16 21:16:37 +02:00
|
|
|
g.setFont(juce::Font(juce::FontOptions(9.0f * currentScale)));
|
2026-09-16 17:47:45 +02:00
|
|
|
juce::String dbLabel = juce::String(dBValues[i], 0) + "dB";
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText(dbLabel, cx + r + 3.0f * currentScale, cy - r - 2.0f * currentScale,
|
|
|
|
|
40.0f * currentScale, 14.0f * currentScale, juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
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
|
2026-09-16 21:16:37 +02:00
|
|
|
g.setColour(juce::Colour(0xff9999b3));
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
{
|
2026-09-16 21:16:37 +02:00
|
|
|
const float s = currentScale;
|
2026-09-16 17:47:45 +02:00
|
|
|
g.setColour(juce::Colour(0xff666677));
|
2026-09-16 21:16:37 +02:00
|
|
|
g.setFont(juce::Font(juce::FontOptions(10.0f * s)));
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// Side label (X axis, right side)
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText("Side", cx + radius + 4.0f * s, cy + 4.0f * s, 40.0f * s, 14.0f * s,
|
|
|
|
|
juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// Mid label (Y axis, top)
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText("Mid", cx + 4.0f * s, cy - radius - 16.0f * s, 40.0f * s, 14.0f * s,
|
|
|
|
|
juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// Diagonal labels: in M/S space these are the pure-channel loci
|
|
|
|
|
g.setColour(juce::Colour(0xff444455));
|
2026-09-16 21:16:37 +02:00
|
|
|
g.setFont(juce::Font(juce::FontOptions(8.0f * s)));
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// L = 0 (right channel only): line y = x through centre
|
|
|
|
|
float diagX = cx + radius * 0.71f;
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText("R only", diagX + 4.0f * s, cy + radius * 0.71f, 30.0f * s, 12.0f * s,
|
|
|
|
|
juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
|
|
|
|
|
// R = 0 (left channel only): line y = -x through centre
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText("L only", diagX + 4.0f * s, cy - radius * 0.71f - 12.0f * s, 30.0f * s, 12.0f * s,
|
|
|
|
|
juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VectorScope::drawMetricsOverlay(juce::Graphics& g, const juce::Rectangle<int>& bounds)
|
|
|
|
|
{
|
2026-09-17 11:29:01 +02:00
|
|
|
const auto& metrics = cachedMetrics;
|
2026-09-16 21:16:37 +02:00
|
|
|
const float s = currentScale;
|
|
|
|
|
const int margin = static_cast<int>(10.0f * s);
|
|
|
|
|
const int lineHeight = static_cast<int>(14.0f * s);
|
2026-09-16 17:47:45 +02:00
|
|
|
const int startY = bounds.getBottom() - margin - (7 * lineHeight);
|
|
|
|
|
|
|
|
|
|
// Background panel
|
2026-09-16 21:16:37 +02:00
|
|
|
juce::Rectangle<int> panelRect(bounds.getX() + margin, startY - static_cast<int>(5.0f * s),
|
|
|
|
|
static_cast<int>(120.0f * s), 7 * lineHeight + static_cast<int>(10.0f * s));
|
2026-09-16 17:47:45 +02:00
|
|
|
g.setColour(juce::Colour(0xff0a0a0f).withAlpha(0.8f));
|
|
|
|
|
g.fillRect(panelRect);
|
|
|
|
|
g.setColour(juce::Colour(0xff333340));
|
|
|
|
|
g.drawRect(panelRect, 1);
|
|
|
|
|
|
|
|
|
|
// Metrics text
|
2026-09-16 21:16:37 +02:00
|
|
|
g.setFont(juce::Font(juce::FontOptions(9.0f * s)));
|
2026-09-16 17:47:45 +02:00
|
|
|
g.setColour(juce::Colour(0xff888899));
|
|
|
|
|
|
|
|
|
|
auto drawMetric = [&](const juce::String& label, float value, const juce::String& unit, int y)
|
|
|
|
|
{
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText(label, panelRect.getX() + static_cast<int>(5.0f * s), y,
|
|
|
|
|
static_cast<int>(50.0f * s), lineHeight, juce::Justification::centredLeft);
|
2026-09-16 17:47:45 +02:00
|
|
|
g.setColour(juce::Colour(0xffccccdd));
|
2026-09-16 21:16:37 +02:00
|
|
|
g.drawText(juce::String(value, 1) + unit, panelRect.getX() + static_cast<int>(55.0f * s), y,
|
|
|
|
|
static_cast<int>(60.0f * s), lineHeight, juce::Justification::centredRight);
|
2026-09-16 17:47:45 +02:00
|
|
|
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;
|
2026-09-17 11:29:01 +02:00
|
|
|
drawMetric("Phase", metrics.phaseAngle, "deg", y); y += lineHeight;
|
2026-09-16 17:47:45 +02:00
|
|
|
drawMetric("Crest", metrics.crestFactor, "dB", y); y += lineHeight;
|
|
|
|
|
}
|