diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..70081e5 --- /dev/null +++ b/CMakeLists.txt @@ -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}) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..62342b3 --- /dev/null +++ b/Makefile @@ -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)" \ No newline at end of file diff --git a/Source/ArcKnob.cpp b/Source/ArcKnob.cpp new file mode 100644 index 0000000..3c7395d --- /dev/null +++ b/Source/ArcKnob.cpp @@ -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::pi * 1.25f; + const float endAngle = juce::MathConstants::pi * 2.75f; + const float angleRange = endAngle - startAngle; + + float rangeMin = static_cast(getMinimum()); + float rangeMax = static_cast(getMaximum()); + float value = static_cast(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); +} diff --git a/Source/ArcKnob.h b/Source/ArcKnob.h new file mode 100644 index 0000000..d42f582 --- /dev/null +++ b/Source/ArcKnob.h @@ -0,0 +1,17 @@ +#pragma once +#include + +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) +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..db52d29 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -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( + processorRef.apvts, "fadeTime", fadeKnob); + + // Sensitivity knob + addAndMakeVisible(sensitivityKnob); + sensitivityKnob.setRange(-12.0, 24.0); + sensitivityAttachment = std::make_unique( + processorRef.apvts, "sensitivity", sensitivityKnob); + + // Dot size knob + addAndMakeVisible(dotSizeKnob); + dotSizeKnob.setRange(0.5, 3.0); + dotSizeAttachment = std::make_unique( + processorRef.apvts, "dotSize", dotSizeKnob); + + // Smoothing knob + addAndMakeVisible(smoothingKnob); + smoothingKnob.setRange(0.0, 0.99); + smoothingAttachment = std::make_unique( + 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( + 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(kBaseWidth * currentScale), + static_cast(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); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..93be438 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,44 @@ +#pragma once +#include +#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 fadeAttachment; + std::unique_ptr sensitivityAttachment; + std::unique_ptr dotSizeAttachment; + std::unique_ptr smoothingAttachment; + std::unique_ptr 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) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..e26f834 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -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::ParameterID{"fadeTime", 1}, + "Fade Time", + juce::NormalisableRange(0.1f, 10.0f, 0.01f, 0.4f), + 2.0f), + std::make_unique( + juce::ParameterID{"sensitivity", 2}, + "Sensitivity", + juce::NormalisableRange(-12.0f, 24.0f, 0.1f, 0.5f), + 0.0f), + std::make_unique( + juce::ParameterID{"dotSize", 3}, + "Dot Size", + juce::NormalisableRange(0.5f, 3.0f, 0.1f, 0.5f), + 1.0f), + std::make_unique( + juce::ParameterID{"smoothing", 4}, + "Smoothing", + juce::NormalisableRange(0.0f, 0.99f, 0.01f, 0.5f), + 0.0f), + std::make_unique( + juce::ParameterID{"freeze", 5}, + "Freeze", + false) + }) +{ + points.resize(kMaxPoints); +} + +TerionProcessor::~TerionProcessor() {} + +void TerionProcessor::prepareToPlay(double, int) {} + +void TerionProcessor::releaseResources() {} + +void TerionProcessor::processBlock(juce::AudioBuffer& 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(getSampleRate())); + const float blockSize = static_cast(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(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(metricsCount); + float avgSide = sumSide / static_cast(metricsCount); + float avgMidSide = sumMidSide / static_cast(metricsCount); + float avgMid2 = sumMid2 / static_cast(metricsCount); + float avgSide2 = sumSide2 / static_cast(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::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(count)), -100.0f); + metrics.rmsR = juce::Decibels::gainToDecibels(std::sqrt(sumR2 / static_cast(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 xml(state.createXml()); + copyXmlToBinary(*xml, destData); +} + +void TerionProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + std::unique_ptr 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(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..beaabcb --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,97 @@ +#pragma once +#include +#include +#include +#include + +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&, 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& 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 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) +}; diff --git a/Source/VectorScope.cpp b/Source/VectorScope.cpp new file mode 100644 index 0000000..caf7ec5 --- /dev/null +++ b/Source/VectorScope.cpp @@ -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& 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; +} diff --git a/Source/VectorScope.h b/Source/VectorScope.h new file mode 100644 index 0000000..c3def3c --- /dev/null +++ b/Source/VectorScope.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#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& bounds); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(VectorScope) +}; diff --git a/Source/Version.h.in b/Source/Version.h.in new file mode 100644 index 0000000..46e042b --- /dev/null +++ b/Source/Version.h.in @@ -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@"