diff --git a/README.md b/README.md index 40ef4e0..16a2ae1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Terion visualizes the stereo field of any audio signal using a mid/side coordina - **Phosphor trail** - configurable fade time from 0.1 to 10 seconds for persistent visual history - **Real-time stereo metrics** - correlation, stereo width, mid/side levels, balance, phase angle, crest factor, and peak/RMS levels - **Freeze** - capture and hold the current display for inspection -- **Resizable UI** - scales from 420×420 up to 1200×1200, with preset scale factors (100/125/150/200/300%) +- **Resizable UI** - scales from 630×420 up to 1890×1890, with preset scale factors (100/125/150/200/250/300%) - **Custom arc knobs** - clean rotary controls with teal arc indicators - **VST3 and Audio Unit** formats @@ -59,8 +59,9 @@ make install # build, codesign, un-quarantine, install to ~/Library/Audio/Plu |---|---|---| | Fade | 0.1 – 10 s | Trail persistence time | | Sensitivity | -12 – +24 dB | Input gain for M/S decode | -| Dot size | 0.5 – 3.0 px | Size of each plotted point | +| Dot size | 0.5 – 8.0 px | Size of each plotted point | | Smoothing | 0 – 0.99 | One-pole smoothing on M/S signals | +| Afterglow | 0 – 5 s | Extra lingers of the trail after the fade time | | Freeze | on/off | Stop capturing new points | ## Project structure diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 32f05c5..3958a9c 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -7,7 +7,8 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) fadeKnob(*p.apvts.getParameter("fadeTime")), sensitivityKnob(*p.apvts.getParameter("sensitivity")), dotSizeKnob(*p.apvts.getParameter("dotSize")), - smoothingKnob(*p.apvts.getParameter("smoothing")) + smoothingKnob(*p.apvts.getParameter("smoothing")), + afterglowKnob(*p.apvts.getParameter("afterglow")) { setSize(kBaseWidth, kBaseHeight); setResizable(true, true); @@ -28,7 +29,7 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) // Dot size knob addAndMakeVisible(dotSizeKnob); - dotSizeKnob.setRange(0.5, 3.0); + dotSizeKnob.setRange(0.5, 8.0); dotSizeAttachment = std::make_unique( processorRef.apvts, "dotSize", dotSizeKnob); @@ -38,6 +39,12 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) smoothingAttachment = std::make_unique( processorRef.apvts, "smoothing", smoothingKnob); + // Afterglow knob + addAndMakeVisible(afterglowKnob); + afterglowKnob.setRange(0.0, 5.0); + afterglowAttachment = std::make_unique( + processorRef.apvts, "afterglow", afterglowKnob); + // Freeze button addAndMakeVisible(freezeButton); freezeButton.setButtonText("FRZ"); @@ -60,8 +67,9 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) scaleBox.addItem("125%", 2); scaleBox.addItem("150%", 3); scaleBox.addItem("200%", 4); - scaleBox.addItem("300%", 5); - scaleBox.setSelectedId(2, juce::dontSendNotification); + scaleBox.addItem("250%", 5); + scaleBox.addItem("300%", 6); + scaleBox.setSelectedId(3, juce::dontSendNotification); scaleBox.onChange = [this] { scaleChanged(); }; scaleChanged(); @@ -75,7 +83,6 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) addAndMakeVisible(label); label.setText(text, juce::dontSendNotification); label.setJustificationType(juce::Justification::centredTop); - label.setFont(juce::Font(juce::FontOptions(9.0f))); label.setColour(juce::Label::textColourId, juce::Colour(0xff888899)); label.setInterceptsMouseClicks(false, false); }; @@ -83,6 +90,7 @@ TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p) setupLabel(sensitivityLabel, sensitivityKnob.getParameterName()); setupLabel(dotSizeLabel, dotSizeKnob.getParameterName()); setupLabel(smoothingLabel, smoothingKnob.getParameterName()); + setupLabel(afterglowLabel, afterglowKnob.getParameterName()); } TerionProcessorEditor::~TerionProcessorEditor() {} @@ -96,12 +104,14 @@ void TerionProcessorEditor::scaleChanged() case 2: currentScale = 1.25f; break; case 3: currentScale = 1.5f; break; case 4: currentScale = 2.0f; break; - case 5: currentScale = 3.0f; break; - default: currentScale = 1.25f; break; + case 5: currentScale = 2.5f; break; + case 6: currentScale = 3.0f; break; + default: currentScale = 1.5f; break; } const int maxDim = static_cast(kBaseWidth * kMaxScale); setResizeLimits(kBaseWidth, kBaseHeight, maxDim, maxDim); + scope.setScale(currentScale); setSize(static_cast(kBaseWidth * currentScale), static_cast(kBaseHeight * currentScale)); } @@ -151,21 +161,32 @@ void TerionProcessorEditor::resized() // Scope fills the remaining area scope.setBounds(bounds); - // Controls layout: top-right corner, horizontal row - const int startX = bounds.getWidth() - (4 * (knobs + margin) + knobs + margin); - const int startY = static_cast(10.0f * s); + // Controls layout: knob row sits 8px above the footer, FRZ stays top-right + const int gap = static_cast(8.0f * s); + const int numKnobs = 5; + const int rowH = knobs + static_cast(12.0f * s) - static_cast(6.0f * s); + const int startX = bounds.getWidth() - (numKnobs * (knobs + margin) + knobs + margin); + const int startY = bounds.getHeight() - rowH - gap; fadeKnob.setBounds(startX, startY, knobs, knobs); sensitivityKnob.setBounds(startX + (knobs + margin), startY, knobs, knobs); dotSizeKnob.setBounds(startX + 2 * (knobs + margin), startY, knobs, knobs); smoothingKnob.setBounds(startX + 3 * (knobs + margin), startY, knobs, knobs); - freezeButton.setBounds(startX + 4 * (knobs + margin), startY + static_cast(10.0f * s), + afterglowKnob.setBounds(startX + 4 * (knobs + margin), startY, knobs, knobs); + freezeButton.setBounds(bounds.getWidth() - knobs - margin, static_cast(20.0f * s), knobs, knobs - static_cast(20.0f * s)); const int labelY = startY + knobs - static_cast(6.0f * s); const int labelH = static_cast(12.0f * s); + const float labelFont = 9.0f * s; fadeLabel.setBounds(fadeKnob.getX(), labelY, knobs, labelH); + fadeLabel.setFont(juce::Font(juce::FontOptions(labelFont))); sensitivityLabel.setBounds(sensitivityKnob.getX(), labelY, knobs, labelH); + sensitivityLabel.setFont(juce::Font(juce::FontOptions(labelFont))); dotSizeLabel.setBounds(dotSizeKnob.getX(), labelY, knobs, labelH); + dotSizeLabel.setFont(juce::Font(juce::FontOptions(labelFont))); smoothingLabel.setBounds(smoothingKnob.getX(), labelY, knobs, labelH); + smoothingLabel.setFont(juce::Font(juce::FontOptions(labelFont))); + afterglowLabel.setBounds(afterglowKnob.getX(), labelY, knobs, labelH); + afterglowLabel.setFont(juce::Font(juce::FontOptions(labelFont))); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index e618a99..e22420c 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -23,20 +23,22 @@ private: ArcKnob sensitivityKnob; ArcKnob dotSizeKnob; ArcKnob smoothingKnob; + ArcKnob afterglowKnob; juce::ToggleButton freezeButton; juce::ToggleButton monoButton; juce::ComboBox scaleBox; - juce::Label fadeLabel, sensitivityLabel, dotSizeLabel, smoothingLabel; + juce::Label fadeLabel, sensitivityLabel, dotSizeLabel, smoothingLabel, afterglowLabel; std::unique_ptr fadeAttachment; std::unique_ptr sensitivityAttachment; std::unique_ptr dotSizeAttachment; std::unique_ptr smoothingAttachment; + std::unique_ptr afterglowAttachment; std::unique_ptr freezeAttachment; std::unique_ptr monoAttachment; float currentScale = 1.0f; - static constexpr int kBaseWidth = 420; + static constexpr int kBaseWidth = 630; static constexpr int kBaseHeight = 420; static constexpr int kFooterHeight = 28; static constexpr int kKnobSize = 54; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 8039c99..2e852b2 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -19,7 +19,7 @@ TerionProcessor::TerionProcessor() std::make_unique( juce::ParameterID{"dotSize", 3}, "Dot Size", - juce::NormalisableRange(0.5f, 3.0f, 0.1f, 0.5f), + juce::NormalisableRange(0.5f, 8.0f, 0.1f, 0.5f), 1.0f), std::make_unique( juce::ParameterID{"smoothing", 4}, @@ -33,7 +33,12 @@ TerionProcessor::TerionProcessor() std::make_unique( juce::ParameterID{"mono", 6}, "Mono", - false) + false), + std::make_unique( + juce::ParameterID{"afterglow", 7}, + "Afterglow", + juce::NormalisableRange(0.0f, 5.0f, 0.01f, 0.5f), + 0.5f) }) { points.resize(kMaxPoints); diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 1097123..761bdd7 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -68,7 +68,8 @@ public: dotSize = 3, smoothing = 4, freeze = 5, - mono = 6 + mono = 6, + afterglow = 7 }; private: diff --git a/Source/VectorScope.cpp b/Source/VectorScope.cpp index caf7ec5..f86eb3a 100644 --- a/Source/VectorScope.cpp +++ b/Source/VectorScope.cpp @@ -39,42 +39,56 @@ void VectorScope::paint(juce::Graphics& g) 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) + // 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()) { - 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 (frozen < 0.5f) { - if (pt.age > fadeTime) - continue; + 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; - float normAge = pt.age / fadeTime; - float alpha = 1.0f - normAge; - alpha = alpha * alpha; + 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); + } - float x = cx + pt.side * radius * 2.0f; - float y = cy - pt.mid * radius * 2.0f; + // 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(); - float phase = std::abs(pt.side) / (std::abs(pt.mid) + 0.0001f); - phase = juce::jmin(phase, 1.0f); + juce::Graphics trailG(trail); - juce::Colour color = juce::Colour::fromFloatRGBA( - 0.2f + phase * 0.8f, - 1.0f - phase * 0.4f, - 1.0f - phase * 0.6f, - alpha * 0.9f); + for (const auto& pt : points) + { + if (pt.age > newAge) + continue; - trailG.setColour(color); - trailG.fillRect(x - dotSize * 0.5f, y - dotSize * 0.5f, dotSize, dotSize); + 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); + } } } @@ -96,9 +110,10 @@ void VectorScope::paint(juce::Graphics& g) // dB label at top-right of each circle g.setColour(juce::Colour(0xff444455)); - g.setFont(9.0f); + g.setFont(juce::Font(juce::FontOptions(9.0f * currentScale))); juce::String dbLabel = juce::String(dBValues[i], 0) + "dB"; - g.drawText(dbLabel, cx + r + 3, cy - r - 2, 40, 14, juce::Justification::centredLeft); + 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)); } @@ -126,7 +141,7 @@ 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)); + g.setColour(juce::Colour(0xff9999b3)); // Horizontal g.drawLine(bounds.getX() + 8.0f, cy, bounds.getRight() - 8.0f, cy, 0.5f); @@ -140,50 +155,59 @@ void VectorScope::drawHairCross(juce::Graphics& g, float cx, float cy) void VectorScope::drawLegends(juce::Graphics& g, float cx, float cy, float radius) { + const float s = currentScale; g.setColour(juce::Colour(0xff666677)); - g.setFont(10.0f); + g.setFont(juce::Font(juce::FontOptions(10.0f * s))); // Side label (X axis, right side) - g.drawText("Side", cx + radius + 4.0f, cy + 4.0f, 40, 14, juce::Justification::centredLeft); + 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, cy - radius - 16.0f, 40, 14, juce::Justification::centredLeft); + 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(8.0f); + 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, cy + radius * 0.71f, 30, 12, juce::Justification::centredLeft); + 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, cy - radius * 0.71f - 12.0f, 30, 12, juce::Justification::centredLeft); + 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 = processor.getMetrics(); - const int margin = 10; - const int lineHeight = 14; + 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 - 5, 120, 7 * lineHeight + 10); + 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(9.0f); + 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() + 5, y, 50, lineHeight, juce::Justification::centredLeft); + 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() + 55, y, 60, lineHeight, juce::Justification::centredRight); + 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)); }; diff --git a/Source/VectorScope.h b/Source/VectorScope.h index c3def3c..a7b2d51 100644 --- a/Source/VectorScope.h +++ b/Source/VectorScope.h @@ -14,9 +14,11 @@ public: void timerCallback() override; float getRadius() const; + void setScale(float s) { currentScale = s; } private: TerionProcessor& processor; + float currentScale = 1.0f; juce::Image trail; void drawLegends(juce::Graphics& g, float cx, float cy, float radius);