This commit is contained in:
Armin 2026-09-16 17:47:45 +02:00
commit 5d4134966f
11 changed files with 996 additions and 0 deletions

92
CMakeLists.txt Normal file
View file

@ -0,0 +1,92 @@
cmake_minimum_required(VERSION 3.22)
project(Terion VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Locate JUCE: prefer an explicit TERION_JUCE_DIR, then ~/JUCE, then the
# vendored copy at third_party/JUCE (populated by `make juce`).
if(TERION_JUCE_DIR)
set(JUCE_DIR_SELECTED "${TERION_JUCE_DIR}")
elseif(EXISTS "$ENV{HOME}/JUCE/CMakeLists.txt")
set(JUCE_DIR_SELECTED "$ENV{HOME}/JUCE")
elseif(EXISTS "${CMAKE_SOURCE_DIR}/third_party/JUCE/CMakeLists.txt")
set(JUCE_DIR_SELECTED "${CMAKE_SOURCE_DIR}/third_party/JUCE")
else()
message(FATAL_ERROR "JUCE not found. Run 'make juce' to clone it into third_party/JUCE, "
"or pass -DTERION_JUCE_DIR=/path/to/JUCE.")
endif()
message(STATUS "Using JUCE at: ${JUCE_DIR_SELECTED}")
# Git version info
execute_process(
COMMAND git log -1 --format=%h
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(NOT GIT_HASH)
set(GIT_HASH "unknown")
endif()
execute_process(
COMMAND git rev-list --count HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_LOCAL_COMMITS
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(NOT GIT_LOCAL_COMMITS)
set(GIT_LOCAL_COMMITS "0")
endif()
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
configure_file(
${CMAKE_SOURCE_DIR}/Source/Version.h.in
${CMAKE_BINARY_DIR}/Version.h
@ONLY
)
add_subdirectory("${JUCE_DIR_SELECTED}" "${CMAKE_BINARY_DIR}/JUCE")
juce_add_plugin(Terion
PRODUCT_NAME "Terion"
COMPANY_NAME "Terion"
BUNDLE_ID "com.terion.stereo-analyzer"
PLUGIN_MANUFACTURER_CODE Teri
PLUGIN_CODE TeAn
FORMATS VST3 AU
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
EDITOR_WANTS_KEYBOARD_FOCUS FALSE
COPY_PLUGIN_AFTER_BUILD TRUE
VST3_CATEGORIES Analyzer
)
target_sources(Terion PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
Source/VectorScope.cpp
Source/ArcKnob.cpp
)
target_compile_definitions(Terion PUBLIC
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0
)
target_link_libraries(Terion PRIVATE
juce::juce_audio_utils
juce::juce_dsp
juce::juce_opengl
juce::juce_graphics
juce::juce_gui_basics
juce::juce_gui_extra
)
target_include_directories(Terion PRIVATE ${CMAKE_BINARY_DIR})

108
Makefile Normal file
View file

@ -0,0 +1,108 @@
# Terion — stereo analyzer VST3/AU
# A thin wrapper around CMake, with macOS signing / un-quarantining helpers.
BUILD_DIR ?= build
LOCAL_JUCE ?= third_party/JUCE
HOME_JUCE := $(HOME)/JUCE
CMAKE ?= cmake
CONFIG ?= Release
JUCE_REPO ?= https://github.com/juce-framework/JUCE.git
JOBS ?= $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)
# Codesign identity: leave empty for ad-hoc ("-") signing.
CODESIGN_ID ?=
VST3_DIR := $(HOME)/Library/Audio/Plug-Ins/VST3/Terion.vst3
AU_DIR := $(HOME)/Library/Audio/Plug-Ins/Components/Terion.component
# Prefer ~/JUCE if present, otherwise use the vendored copy in the repo.
ifeq ($(strip $(JUCE_DIR)),)
ifneq ($(wildcard $(HOME_JUCE)/CMakeLists.txt),)
JUCE_DIR := $(HOME_JUCE)
else
JUCE_DIR := $(LOCAL_JUCE)
endif
endif
.PHONY: all juce configure build install sign unquarantine uninstall clean deepclean help
all: configure
$(CMAKE) --build $(BUILD_DIR) --config $(CONFIG) -j$(JOBS)
# Ensure a JUCE source tree is available: use ~/JUCE, else clone into third_party/.
juce:
@if [ ! -f "$(JUCE_DIR)/CMakeLists.txt" ]; then \
if [ ! -d "$(dir $(JUCE_DIR))" ]; then mkdir -p "$(dir $(JUCE_DIR))"; fi; \
echo "JUCE not found at $(JUCE_DIR) — cloning into it..."; \
git clone --depth 1 "$(JUCE_REPO)" "$(JUCE_DIR)"; \
else \
echo "JUCE already present at $(JUCE_DIR)."; \
fi
# The build target auto-installs plugins via JUCE's COPY_PLUGIN_AFTER_BUILD.
configure: juce
$(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(CONFIG) -DTERION_JUCE_DIR=$(JUCE_DIR)
build:
$(CMAKE) --build $(BUILD_DIR) --config $(CONFIG) -j$(JOBS)
@echo "Plugins built into $(BUILD_DIR)/Terion_artefacts/$(CONFIG)."
# Ad-hoc (or identity) codesign both plugin bundles, recursively.
sign:
@if [ -d "$(VST3_DIR)" ]; then \
echo "Signing VST3 ..."; \
codesign --force --deep --sign "$(if $(CODESIGN_ID),$(CODESIGN_ID),-)" "$(VST3_DIR)"; \
else \
echo "VST3 bundle not found at $(VST3_DIR) — run 'make build' first."; \
fi
@if [ -d "$(AU_DIR)" ]; then \
echo "Signing AU ..."; \
codesign --force --deep --sign "$(if $(CODESIGN_ID),$(CODESIGN_ID),-)" "$(AU_DIR)"; \
else \
echo "AU bundle not found at $(AU_DIR) — run 'make build' first."; \
fi
# Strip Gatekeeper quarantine / provenance attributes from the installed bundles.
unquarantine:
@if [ -d "$(VST3_DIR)" ]; then \
echo "Removing quarantine from VST3 ..."; \
xattr -dr com.apple.quarantine "$(VST3_DIR)" 2>/dev/null || true; \
xattr -dr com.apple.provenance "$(VST3_DIR)" 2>/dev/null || true; \
fi
@if [ -d "$(AU_DIR)" ]; then \
echo "Removing quarantine from AU ..."; \
xattr -dr com.apple.quarantine "$(AU_DIR)" 2>/dev/null || true; \
xattr -dr com.apple.provenance "$(AU_DIR)" 2>/dev/null || true; \
fi
install: build sign unquarantine
@echo "Terion VST3/AU installed, signed, and un-quarantined."
@echo " VST3: $(VST3_DIR)"
@echo " AU: $(AU_DIR)"
uninstall:
rm -rf "$(VST3_DIR)"
rm -rf "$(AU_DIR)"
@echo "Uninstalled Terion from the user Audio Plug-Ins directories."
clean:
rm -rf $(BUILD_DIR)
# Fully remove generated artefacts, including the vendored JUCE copy.
deepclean: clean
rm -rf build Terion_artefacts third_party
help:
@echo "Targets:"
@echo " all configure + build"
@echo " juce clone JUCE into third_party/JUCE if missing (else use ~/JUCE)"
@echo " configure run CMake configure against $(BUILD_DIR)"
@echo " build compile the configured tree"
@echo " sign codesign both installed plugin bundles (ad-hoc by default)"
@echo " unquarantine strip Gatekeeper quarantine/provenance xattrs"
@echo " install build + sign + unquarantine into ~/Library/Audio/Plug-Ins"
@echo " uninstall remove installed plugins"
@echo " clean remove $(BUILD_DIR)"
@echo " deepclean remove build dir, artefacts, and third_party/"
@echo ""
@echo "Variables: BUILD_DIR= JUCE_DIR= CONFIG= JOBS= CMAKE= JUCE_REPO="
@echo " CODESIGN_ID= (e.g. 'Developer ID Application: X' — empty = ad-hoc)"

50
Source/ArcKnob.cpp Normal file
View file

@ -0,0 +1,50 @@
#include "ArcKnob.h"
ArcKnob::ArcKnob(juce::RangedAudioParameter& param)
: juce::Slider(juce::Slider::RotaryHorizontalVerticalDrag,
juce::Slider::NoTextBox),
parameter(param)
{
}
void ArcKnob::paint(juce::Graphics& g)
{
auto bounds = getLocalBounds().toFloat();
float diameter = juce::jmin(bounds.getWidth(), bounds.getHeight());
float radius = diameter * 0.42f;
float cx = bounds.getCentreX();
float cy = bounds.getCentreY();
const float startAngle = juce::MathConstants<float>::pi * 1.25f;
const float endAngle = juce::MathConstants<float>::pi * 2.75f;
const float angleRange = endAngle - startAngle;
float rangeMin = static_cast<float>(getMinimum());
float rangeMax = static_cast<float>(getMaximum());
float value = static_cast<float>(getValue());
float normalizedValue = (rangeMax > rangeMin)
? juce::jlimit(0.0f, 1.0f, (value - rangeMin) / (rangeMax - rangeMin))
: 0.0f;
float valueAngle = startAngle + normalizedValue * angleRange;
// Background arc (dim)
juce::Path bgArc;
bgArc.addArc(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f,
startAngle, endAngle, true);
g.setColour(juce::Colour(0xff333340));
g.strokePath(bgArc, juce::PathStrokeType(1.5f));
// Value arc
if (normalizedValue > 0.001f)
{
juce::Path valueArc;
valueArc.addArc(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f,
startAngle, valueAngle, true);
g.setColour(juce::Colour(0xff7ecfcf));
g.strokePath(valueArc, juce::PathStrokeType(1.5f));
}
// Center dot
g.setColour(juce::Colour(0xff7ecfcf));
g.fillEllipse(cx - 2.0f, cy - 2.0f, 4.0f, 4.0f);
}

17
Source/ArcKnob.h Normal file
View file

@ -0,0 +1,17 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
class ArcKnob : public juce::Slider
{
public:
explicit ArcKnob(juce::RangedAudioParameter& param);
juce::String getParameterName() const { return parameter.getName(10); }
void paint(juce::Graphics& g) override;
private:
juce::RangedAudioParameter& parameter;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ArcKnob)
};

146
Source/PluginEditor.cpp Normal file
View file

@ -0,0 +1,146 @@
#include "PluginEditor.h"
TerionProcessorEditor::TerionProcessorEditor(TerionProcessor& p)
: AudioProcessorEditor(p),
processorRef(p),
scope(p),
fadeKnob(*p.apvts.getParameter("fadeTime")),
sensitivityKnob(*p.apvts.getParameter("sensitivity")),
dotSizeKnob(*p.apvts.getParameter("dotSize")),
smoothingKnob(*p.apvts.getParameter("smoothing"))
{
setSize(kBaseWidth, kBaseHeight);
setResizable(true, true);
setResizeLimits(kBaseWidth, kBaseHeight, 1200, 1200);
addAndMakeVisible(scope);
// Fade knob
addAndMakeVisible(fadeKnob);
fadeKnob.setRange(0.1, 10.0);
fadeAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.apvts, "fadeTime", fadeKnob);
// Sensitivity knob
addAndMakeVisible(sensitivityKnob);
sensitivityKnob.setRange(-12.0, 24.0);
sensitivityAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.apvts, "sensitivity", sensitivityKnob);
// Dot size knob
addAndMakeVisible(dotSizeKnob);
dotSizeKnob.setRange(0.5, 3.0);
dotSizeAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.apvts, "dotSize", dotSizeKnob);
// Smoothing knob
addAndMakeVisible(smoothingKnob);
smoothingKnob.setRange(0.0, 0.99);
smoothingAttachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processorRef.apvts, "smoothing", smoothingKnob);
// Freeze button
addAndMakeVisible(freezeButton);
freezeButton.setButtonText("FRZ");
freezeButton.setColour(juce::ToggleButton::textColourId, juce::Colour(0xff888899));
freezeButton.setColour(juce::ToggleButton::tickColourId, juce::Colour(0xff7ecfcf));
freezeAttachment = std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment>(
processorRef.apvts, "freeze", freezeButton);
// Scale dropdown
addAndMakeVisible(scaleBox);
scaleBox.addItem("100%", 1);
scaleBox.addItem("125%", 2);
scaleBox.addItem("150%", 3);
scaleBox.addItem("200%", 4);
scaleBox.addItem("300%", 5);
scaleBox.setSelectedId(2, juce::dontSendNotification);
scaleBox.onChange = [this] { scaleChanged(); };
scaleChanged();
// Knob labels (separate from knobs to ensure correct z-order)
auto setupLabel = [this](juce::Label& label, const juce::String& text)
{
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);
};
setupLabel(fadeLabel, fadeKnob.getParameterName());
setupLabel(sensitivityLabel, sensitivityKnob.getParameterName());
setupLabel(dotSizeLabel, dotSizeKnob.getParameterName());
setupLabel(smoothingLabel, smoothingKnob.getParameterName());
}
TerionProcessorEditor::~TerionProcessorEditor() {}
void TerionProcessorEditor::scaleChanged()
{
int id = scaleBox.getSelectedId();
switch (id)
{
case 1: currentScale = 1.0f; break;
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;
}
setTransform(juce::AffineTransform::scale(currentScale));
setSize(static_cast<int>(kBaseWidth * currentScale),
static_cast<int>(kBaseHeight * currentScale));
}
void TerionProcessorEditor::paint(juce::Graphics& g)
{
g.fillAll(juce::Colour(0xff0a0a0f));
// Footer
auto bounds = getLocalBounds();
auto footer = bounds.removeFromBottom(kFooterHeight);
g.setColour(juce::Colour(0xff15151e));
g.fillRect(footer);
g.setColour(juce::Colour(0xff555566));
g.setFont(9.0f);
juce::String footerText = "Terion "
+ juce::String(TERION_GIT_HASH)
+ " | " + juce::String(TERION_BUILD_TIME)
+ " | " + juce::String(TERION_GIT_LOCAL_COMMITS) + " local";
g.drawFittedText(footerText, footer.reduced(10, 0),
juce::Justification::centredLeft, 1);
}
void TerionProcessorEditor::resized()
{
auto bounds = getLocalBounds();
auto footer = bounds.removeFromBottom(kFooterHeight);
// Footer: scale dropdown on the right
scaleBox.setBounds(footer.getWidth() - 60, 1, 56, kFooterHeight - 2);
// Scope fills the remaining area
scope.setBounds(bounds);
// Controls layout: top-right corner, horizontal row
const int startX = bounds.getWidth() - (4 * (kKnobSize + kKnobMargin) + kKnobSize + kKnobMargin);
const int startY = 10;
fadeKnob.setBounds(startX, startY, kKnobSize, kKnobSize);
sensitivityKnob.setBounds(startX + (kKnobSize + kKnobMargin), startY, kKnobSize, kKnobSize);
dotSizeKnob.setBounds(startX + 2 * (kKnobSize + kKnobMargin), startY, kKnobSize, kKnobSize);
smoothingKnob.setBounds(startX + 3 * (kKnobSize + kKnobMargin), startY, kKnobSize, kKnobSize);
freezeButton.setBounds(startX + 4 * (kKnobSize + kKnobMargin), startY + 10, kKnobSize, kKnobSize - 20);
const int labelY = startY + kKnobSize - 6;
fadeLabel.setBounds(fadeKnob.getX(), labelY, kKnobSize, 12);
sensitivityLabel.setBounds(sensitivityKnob.getX(), labelY, kKnobSize, 12);
dotSizeLabel.setBounds(dotSizeKnob.getX(), labelY, kKnobSize, 12);
smoothingLabel.setBounds(smoothingKnob.getX(), labelY, kKnobSize, 12);
}

44
Source/PluginEditor.h Normal file
View file

@ -0,0 +1,44 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include "PluginProcessor.h"
#include "VectorScope.h"
#include "ArcKnob.h"
#include "Version.h"
class TerionProcessorEditor : public juce::AudioProcessorEditor
{
public:
explicit TerionProcessorEditor(TerionProcessor&);
~TerionProcessorEditor() override;
void paint(juce::Graphics&) override;
void resized() override;
private:
void scaleChanged();
TerionProcessor& processorRef;
VectorScope scope;
ArcKnob fadeKnob;
ArcKnob sensitivityKnob;
ArcKnob dotSizeKnob;
ArcKnob smoothingKnob;
juce::ToggleButton freezeButton;
juce::ComboBox scaleBox;
juce::Label fadeLabel, sensitivityLabel, dotSizeLabel, smoothingLabel;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> fadeAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> sensitivityAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> dotSizeAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> smoothingAttachment;
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> freezeAttachment;
float currentScale = 1.0f;
static constexpr int kBaseWidth = 420;
static constexpr int kBaseHeight = 420;
static constexpr int kFooterHeight = 28;
static constexpr int kKnobSize = 54;
static constexpr int kKnobMargin = 6;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TerionProcessorEditor)
};

212
Source/PluginProcessor.cpp Normal file
View file

@ -0,0 +1,212 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
TerionProcessor::TerionProcessor()
: AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true)),
apvts(*this, nullptr, "Parameters", {
std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"fadeTime", 1},
"Fade Time",
juce::NormalisableRange<float>(0.1f, 10.0f, 0.01f, 0.4f),
2.0f),
std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"sensitivity", 2},
"Sensitivity",
juce::NormalisableRange<float>(-12.0f, 24.0f, 0.1f, 0.5f),
0.0f),
std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"dotSize", 3},
"Dot Size",
juce::NormalisableRange<float>(0.5f, 3.0f, 0.1f, 0.5f),
1.0f),
std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"smoothing", 4},
"Smoothing",
juce::NormalisableRange<float>(0.0f, 0.99f, 0.01f, 0.5f),
0.0f),
std::make_unique<juce::AudioParameterBool>(
juce::ParameterID{"freeze", 5},
"Freeze",
false)
})
{
points.resize(kMaxPoints);
}
TerionProcessor::~TerionProcessor() {}
void TerionProcessor::prepareToPlay(double, int) {}
void TerionProcessor::releaseResources() {}
void TerionProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const int numSamples = buffer.getNumSamples();
const float* left = buffer.getReadPointer(0);
const float* right = buffer.getNumChannels() > 1 ? buffer.getReadPointer(1) : left;
const float fadeTime = apvts.getRawParameterValue("fadeTime")->load();
const float agePerSample = 1.0f / (fadeTime * static_cast<float>(getSampleRate()));
const float blockSize = static_cast<float>(numSamples);
const float sensGain = juce::Decibels::decibelsToGain(apvts.getRawParameterValue("sensitivity")->load());
const float smoothCoef = apvts.getRawParameterValue("smoothing")->load();
const float frozen = apvts.getRawParameterValue("freeze")->load();
// Reset metrics for this block
sumMid = 0.0f;
sumSide = 0.0f;
sumMidSide = 0.0f;
sumMid2 = 0.0f;
sumSide2 = 0.0f;
peakMid = 0.0f;
peakSide = 0.0f;
metricsCount = 0;
for (int i = 0; i < numSamples; ++i)
{
float mid = (left[i] + right[i]) * 0.5f * sensGain;
float side = (left[i] - right[i]) * 0.5f * sensGain;
// Apply smoothing
if (smoothCoef > 0.0f)
{
float alpha = std::exp(-1.0f / (smoothCoef * 0.05f * static_cast<float>(getSampleRate())));
midSmoothed += alpha * (mid - midSmoothed);
sideSmoothed += alpha * (side - sideSmoothed);
mid = midSmoothed;
side = sideSmoothed;
}
// Update metrics accumulation
updateMetrics(mid, side, left[i], right[i]);
// Add point if not frozen
if (frozen < 0.5f)
{
addPoint(mid, side, agePerSample);
}
}
// Calculate final metrics for this block
if (metricsCount > 0)
{
float avgMid = sumMid / static_cast<float>(metricsCount);
float avgSide = sumSide / static_cast<float>(metricsCount);
float avgMidSide = sumMidSide / static_cast<float>(metricsCount);
float avgMid2 = sumMid2 / static_cast<float>(metricsCount);
float avgSide2 = sumSide2 / static_cast<float>(metricsCount);
// Correlation (Pearson coefficient)
float denom = std::sqrt((avgMid2 - avgMid * avgMid) * (avgSide2 - avgSide * avgSide));
metrics.correlation = (denom > 1e-6f) ? (avgMidSide - avgMid * avgSide) / denom : 0.0f;
metrics.correlation = juce::jlimit(-1.0f, 1.0f, metrics.correlation);
// Stereo width (M/S ratio in dB)
float midRms = std::sqrt(avgMid2);
float sideRms = std::sqrt(avgSide2);
metrics.midLevel = juce::Decibels::gainToDecibels(midRms, -100.0f);
metrics.sideLevel = juce::Decibels::gainToDecibels(sideRms, -100.0f);
metrics.stereoWidth = metrics.sideLevel - metrics.midLevel;
// Balance (L-R difference)
float lRms = std::sqrt((metrics.rmsL * metrics.rmsL)); // Already in dB, need linear
float rRms = std::sqrt((metrics.rmsR * metrics.rmsR));
metrics.balance = metrics.rmsL - metrics.rmsR;
// Phase angle (degrees)
float phaseRad = std::atan2(sideRms, midRms);
metrics.phaseAngle = phaseRad * (180.0f / juce::MathConstants<float>::pi);
// Crest factor (peak vs RMS)
float peakLevel = juce::jmax(peakMid, peakSide);
float rmsLevel = std::sqrt((avgMid2 + avgSide2) * 0.5f);
metrics.crestFactor = juce::Decibels::gainToDecibels(peakLevel / (rmsLevel + 1e-6f), 0.0f);
}
// Age all points (only if not frozen)
if (frozen < 0.5f)
{
for (auto& p : points)
p.age += agePerSample * blockSize;
}
}
void TerionProcessor::updateMetrics(float mid, float side, float left, float right)
{
sumMid += mid;
sumSide += side;
sumMidSide += mid * side;
sumMid2 += mid * mid;
sumSide2 += side * side;
float absMid = std::abs(mid);
float absSide = std::abs(side);
peakMid = juce::jmax(peakMid, absMid);
peakSide = juce::jmax(peakSide, absSide);
// Peak and RMS for L/R
float absL = std::abs(left);
float absR = std::abs(right);
metrics.peakL = juce::jmax(metrics.peakL, absL);
metrics.peakR = juce::jmax(metrics.peakR, absR);
// RMS accumulation (using running sum of squares)
static thread_local float sumL2 = 0.0f;
static thread_local float sumR2 = 0.0f;
static thread_local int count = 0;
sumL2 += left * left;
sumR2 += right * right;
count++;
if (count >= 1024) // Update RMS every 1024 samples
{
metrics.rmsL = juce::Decibels::gainToDecibels(std::sqrt(sumL2 / static_cast<float>(count)), -100.0f);
metrics.rmsR = juce::Decibels::gainToDecibels(std::sqrt(sumR2 / static_cast<float>(count)), -100.0f);
sumL2 = 0.0f;
sumR2 = 0.0f;
count = 0;
}
metricsCount++;
}
void TerionProcessor::addPoint(float mid, float side, float age)
{
auto& p = points[writePos];
p.mid = mid;
p.side = side;
p.age = age;
writePos = (writePos + 1) % kMaxPoints;
if (writePos == 0)
filled = true;
}
juce::AudioProcessorEditor* TerionProcessor::createEditor()
{
return new TerionProcessorEditor(*this);
}
void TerionProcessor::getStateInformation(juce::MemoryBlock& destData)
{
auto state = apvts.copyState();
std::unique_ptr<juce::XmlElement> xml(state.createXml());
copyXmlToBinary(*xml, destData);
}
void TerionProcessor::setStateInformation(const void* data, int sizeInBytes)
{
std::unique_ptr<juce::XmlElement> xml(getXmlFromBinary(data, sizeInBytes));
if (xml && xml->hasTagName(apvts.state.getType()))
apvts.replaceState(juce::ValueTree::fromXml(*xml));
}
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new TerionProcessor();
}

97
Source/PluginProcessor.h Normal file
View file

@ -0,0 +1,97 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_dsp/juce_dsp.h>
#include <atomic>
#include <vector>
struct StereoSample
{
float mid = 0.0f;
float side = 0.0f;
float age = 0.0f;
};
struct StereoMetrics
{
float correlation = 0.0f; // -1 to +1
float stereoWidth = 0.0f; // dB
float midLevel = 0.0f; // dB
float sideLevel = 0.0f; // dB
float balance = 0.0f; // dB (L-R)
float phaseAngle = 0.0f; // degrees
float crestFactor = 0.0f; // dB
float peakL = 0.0f; // dB
float peakR = 0.0f; // dB
float rmsL = 0.0f; // dB
float rmsR = 0.0f; // dB
};
class TerionProcessor final : public juce::AudioProcessor
{
public:
static constexpr int kMaxPoints = 8192;
TerionProcessor();
~TerionProcessor() override;
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "Terion"; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
const std::vector<StereoSample>& getPoints() const { return points; }
const StereoMetrics& getMetrics() const { return metrics; }
juce::AudioProcessorValueTreeState apvts;
enum class ParameterID
{
fadeTime = 1,
sensitivity = 2,
dotSize = 3,
smoothing = 4,
freeze = 5
};
private:
std::vector<StereoSample> points;
StereoMetrics metrics;
int writePos = 0;
bool filled = false;
// Smoothing filters
float midSmoothed = 0.0f;
float sideSmoothed = 0.0f;
// Metrics calculation state
float sumMid = 0.0f;
float sumSide = 0.0f;
float sumMidSide = 0.0f;
float sumMid2 = 0.0f;
float sumSide2 = 0.0f;
float peakMid = 0.0f;
float peakSide = 0.0f;
int metricsCount = 0;
void addPoint(float mid, float side, float age);
void updateMetrics(float mid, float side, float left, float right);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TerionProcessor)
};

198
Source/VectorScope.cpp Normal file
View file

@ -0,0 +1,198 @@
#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<int>& 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<int> 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;
}

27
Source/VectorScope.h Normal file
View file

@ -0,0 +1,27 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_opengl/juce_opengl.h>
#include "PluginProcessor.h"
class VectorScope : public juce::Component, private juce::Timer
{
public:
explicit VectorScope(TerionProcessor& p);
~VectorScope() override;
void paint(juce::Graphics& g) override;
void resized() override;
void timerCallback() override;
float getRadius() const;
private:
TerionProcessor& processor;
juce::Image trail;
void drawLegends(juce::Graphics& g, float cx, float cy, float radius);
void drawHairCross(juce::Graphics& g, float cx, float cy);
void drawMetricsOverlay(juce::Graphics& g, const juce::Rectangle<int>& bounds);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(VectorScope)
};

5
Source/Version.h.in Normal file
View file

@ -0,0 +1,5 @@
#pragma once
#define TERION_GIT_HASH "@GIT_HASH@"
#define TERION_BUILD_TIME "@BUILD_TIME@"
#define TERION_GIT_LOCAL_COMMITS "@GIT_LOCAL_COMMITS@"