mirror of
https://codeberg.org/armin/terion.git
synced 2026-10-11 16:41:53 +02:00
init
This commit is contained in:
parent
7e21684497
commit
5d4134966f
11 changed files with 996 additions and 0 deletions
92
CMakeLists.txt
Normal file
92
CMakeLists.txt
Normal 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
108
Makefile
Normal 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
50
Source/ArcKnob.cpp
Normal 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
17
Source/ArcKnob.h
Normal 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
146
Source/PluginEditor.cpp
Normal 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
44
Source/PluginEditor.h
Normal 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
212
Source/PluginProcessor.cpp
Normal 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
97
Source/PluginProcessor.h
Normal 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
198
Source/VectorScope.cpp
Normal 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
27
Source/VectorScope.h
Normal 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
5
Source/Version.h.in
Normal 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@"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue