From 6391b75d1506b9b665b2302c4453bb7fcebc2e49 Mon Sep 17 00:00:00 2001 From: Armin Date: Sun, 12 Jul 2026 14:18:24 +0200 Subject: [PATCH] init --- .gitignore | 14 + CMakeLists.txt | 64 ++++ LICENSE | 23 ++ Makefile | 47 +++ Source/CompressorDSP.h | 119 +++++++ Source/PluginEditor.cpp | 667 +++++++++++++++++++++++++++++++++++++ Source/PluginEditor.h | 105 ++++++ Source/PluginProcessor.cpp | 126 +++++++ Source/PluginProcessor.h | 65 ++++ 9 files changed, 1230 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 Source/CompressorDSP.h create mode 100644 Source/PluginEditor.cpp create mode 100644 Source/PluginEditor.h create mode 100644 Source/PluginProcessor.cpp create mode 100644 Source/PluginProcessor.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14993d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +build/ +build_juce/ +JUCE/ + +*.DS_Store +*.swp +*.swo +*~ +.cache/ +.vscode/ +.idea/ +*.xcworkspace +*.xcodeproj +DerivedData/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6ece384 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.22) +project(Saftpumpe VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(JUCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/JUCE") + +add_subdirectory("${JUCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/juce_build" EXCLUDE_FROM_ALL) + +juce_add_plugin(Saftpumpe + COMPANY_NAME "Saftpumpe" + PLUGIN_MANUFACTURER_CODE SfPm + PLUGIN_CODE SaFp + FORMATS VST3 Standalone AU + PRODUCT_NAME "Saftpumpe" + 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 + PLUGIN_AU_TYPE_PREFIX "aufx" + VST3_CATEGORIES "Fx" "Dynamics" +) + +target_sources(Saftpumpe PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp +) + +target_compile_definitions(Saftpumpe PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 + JUCE_DISPLAY_SPLASH_SCREEN=0 + JUCE_MODAL_LOOPS_PERMITTED=0 +) + +target_link_libraries(Saftpumpe + PRIVATE + juce::juce_audio_basics + juce::juce_audio_devices + juce::juce_audio_formats + juce::juce_audio_plugin_client + juce::juce_audio_processors + juce::juce_audio_utils + juce::juce_core + juce::juce_data_structures + juce::juce_events + juce::juce_graphics + juce::juce_gui_basics + juce::juce_gui_extra + PUBLIC + juce::juce_recommended_config_flags +) + +set_target_properties(Saftpumpe PROPERTIES + XCODE_ATTRIBUTE_CLANG_CXX_LIBRARY "libc++" + XCODE_ATTRIBUTE_CLANG_CXX_LANGUAGE_STANDARD "c++17" + XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_VALID_ARCHS "arm64" + XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "YES" +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..54bc799 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2026 Armin Jenewein + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0dfbe25 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: all build clean install uninstall rebuild run + +BUILD_DIR := build +BUILD_TYPE := Release +JOBS := $(shell sysctl -n hw.ncpu) + +all: build + +$(BUILD_DIR)/Makefile: + @mkdir -p $(BUILD_DIR) + cd $(BUILD_DIR) && cmake .. -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) + +build: $(BUILD_DIR)/Makefile + cmake --build $(BUILD_DIR) -j $(JOBS) + +clean: + rm -rf $(BUILD_DIR) + +reinstall: clean build + +uninstall: + rm -rf ~/Library/Audio/Plug-Ins/VST3/Saftpumpe.vst3 + rm -rf ~/Library/Audio/Plug-Ins/Components/Saftpumpe.component + +install: build + +run: build + open "$(BUILD_DIR)/Saftpumpe_artefacts/Release/Standalone/Saftpumpe.app" + +debug: + cmake --build $(BUILD_DIR) -j $(JOBS) 2>&1 | head -100 + +help: + @echo "Saftpumpe - Bus Compressor Plugin" + @echo "" + @echo "Targets:" + @echo " make - Build the plugin" + @echo " make clean - Remove build directory" + @echo " make reinstall - Clean and rebuild" + @echo " make uninstall - Remove installed plugins" + @echo " make run - Launch standalone app" + @echo " make debug - Build with verbose output" + @echo "" + @echo "Formats: VST3, AU, Standalone" + @echo "Install paths:" + @echo " VST3: ~/Library/Audio/Plug-Ins/VST3/" + @echo " AU: ~/Library/Audio/Plug-Ins/Components/" diff --git a/Source/CompressorDSP.h b/Source/CompressorDSP.h new file mode 100644 index 0000000..3d6df85 --- /dev/null +++ b/Source/CompressorDSP.h @@ -0,0 +1,119 @@ +#pragma once +#include +#include + +class CompressorDSP { +public: + CompressorDSP() = default; + + void prepare(double sampleRate, int samplesPerBlock) { + this->sampleRate = sampleRate; + envelope = 0.0; + gainReduction = 0.0; + } + + void reset() { + envelope = 0.0; + gainReduction = 0.0; + } + + struct Params { + float threshold = -20.0f; // dB + float ratio = 4.0f; // 2:1 to 8:1 + float attack = 0.03f; // seconds + float release = 0.3f; // seconds (auto mode uses program-dependent) + float knee = 6.0f; // dB soft knee + float makeup = 0.0f; // dB + bool autoRelease = true; + float mix = 1.0f; // dry/wet + }; + + void setParams(const Params& p) { + params = p; + // Convert times to coefficients with analog-style smoothing + attackCoeff = std::exp(-1.0 / (sampleRate * std::max(0.001f, params.attack))); + + // Auto release: program-dependent (faster for transients, slower for sustained) + if (params.autoRelease) { + releaseCoeff = std::exp(-1.0 / (sampleRate * 0.4)); + } else { + releaseCoeff = std::exp(-1.0 / (sampleRate * std::max(0.01f, params.release))); + } + } + + // Process a single sample (mono) + float processSample(float input) { + float absInput = std::abs(input); + + // Input level in dB + float inputDb = (absInput > 1e-10f) ? 20.0f * std::log10(absInput) : -100.0f; + + // Calculate desired gain reduction with soft knee + float gainDb = 0.0f; + float overThreshold = inputDb - params.threshold; + + if (overThreshold > -params.knee / 2.0f) { + if (overThreshold < params.knee / 2.0f) { + // Soft knee region - smooth transition + float x = overThreshold + params.knee / 2.0f; + gainDb = (1.0f / params.ratio - 1.0f) * (x * x) / (2.0f * params.knee); + } else { + // Above knee - full compression + gainDb = overThreshold * (1.0f / params.ratio - 1.0f); + } + } + + // Smooth envelope follower (analog-style ballistics) + float coeff = (gainDb < envelope) ? attackCoeff : releaseCoeff; + + // Auto release: faster release for small reductions, slower for big hits + if (params.autoRelease && gainDb < envelope) { + float reductionAmount = std::abs(envelope); + float autoReleaseAdj = std::clamp(reductionAmount / 12.0f, 0.2f, 1.0f); + coeff = std::exp(-1.0 / (sampleRate * 0.15 * autoReleaseAdj)); + } + + envelope = coeff * envelope + (1.0f - coeff) * gainDb; + + // Apply makeup gain and convert to linear + float totalGainDb = envelope + params.makeup; + float gain = std::pow(10.0f, totalGainDb / 20.0f); + + // Store for metering + gainReduction = envelope; + + // Apply compression with analog saturation + float compressed = input * gain; + + // Subtle analog saturation (soft clipping like VCA bus compressor) + compressed = softClip(compressed); + + // Dry/wet mix + float output = input * (1.0f - params.mix) + compressed * params.mix; + + return output; + } + + float getGainReduction() const { return gainReduction; } + +private: + double sampleRate = 44100.0; + Params params; + + float attackCoeff = 0.0f; + float releaseCoeff = 0.0f; + float envelope = 0.0f; + float gainReduction = 0.0f; + + // Analog-style soft clipping (tanh-like) + float softClip(float x) { + // Gentle saturation that adds warmth + float drive = 1.2f; // Subtle drive + x *= drive; + + // Tanh saturation for musical distortion + float out = std::tanh(x); + + return out; + } +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..6a04695 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,667 @@ +#include "PluginEditor.h" + +static juce::String formatTime(float seconds) { + if (seconds >= 1.0f) return juce::String(seconds, 2) + "s"; + if (seconds >= 0.001f) return juce::String(seconds * 1000.0f, 1) + "ms"; + return juce::String(seconds * 1000.0f, 1) + "ms"; +} + +const std::vector& SaftpumpeEditor::getPresets() { + static const std::vector presets = { + {"-- None --", 0.0f, 1.0f, 0.030f, 0.30f, 6.0f, 0.0f, 1.0f, 1.0f}, + {"Mastering 0dB", -2.0f, 2.0f, 0.020f, 0.20f, 6.0f, 0.0f, 1.0f, 1.0f}, + {"Mastering -1dB", -6.0f, 2.5f, 0.015f, 0.18f, 6.0f, 1.0f, 1.0f, 1.0f}, + {"Mastering -2dB", -10.0f, 3.0f, 0.010f, 0.15f, 8.0f, 1.5f, 1.0f, 1.0f}, + {"Mastering -3dB", -14.0f, 4.0f, 0.010f, 0.12f, 8.0f, 2.0f, 1.0f, 1.0f}, + {"Kick Punch", -18.0f, 6.0f, 0.003f, 0.10f, 10.0f, 3.0f, 0.8f, 0.0f}, + }; + return presets; +} + +void SaftpumpeEditor::applyPreset(int index) { + auto& presets = getPresets(); + if (index < 0 || index >= (int)presets.size()) return; + auto& p = presets[index]; + thresholdSlider.setValue(p.threshold, juce::sendNotificationSync); + ratioSlider.setValue(p.ratio, juce::sendNotificationSync); + attackSlider.setValue(p.attack, juce::sendNotificationSync); + releaseSlider.setValue(p.release, juce::sendNotificationSync); + kneeSlider.setValue(p.knee, juce::sendNotificationSync); + makeupSlider.setValue(p.makeup, juce::sendNotificationSync); + mixSlider.setValue(p.mix, juce::sendNotificationSync); + autoReleaseSlider.setValue(p.autoRelease, juce::sendNotificationSync); +} + +SaftpumpeEditor::SaftpumpeEditor(SaftpumpeProcessor& p) + : AudioProcessorEditor(&p), processorRef(p) +{ + setSize(700, 500); + setResizable(true, this); + setResizeLimits(650, 480, 1100, 800); + startTimerHz(30); + + auto setupKnob = [this](juce::Slider& s, juce::Label& l, const juce::String& text, + const juce::String& pid, float min, float max, float def) { + s.setLookAndFeel(&lnf); + s.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + s.setTextBoxStyle(juce::Slider::NoTextBox, true, 0, 0); + s.setRange(min, max, 0.001); + s.setValue(def, juce::dontSendNotification); + s.setPopupDisplayEnabled(true, true, this); + addAndMakeVisible(s); + + l.setText(text, juce::dontSendNotification); + l.setJustificationType(juce::Justification::centred); + l.setColour(juce::Label::textColourId, textColour); + l.setFont(juce::Font(juce::FontOptions(11.0f))); + addAndMakeVisible(l); + }; + + setupKnob(thresholdSlider, thresholdLabel, "THRESHOLD", "threshold", -48.0, 0.0, -20.0); + thresholdAttachment = std::make_unique(processorRef.parameters, "threshold", thresholdSlider); + + setupKnob(ratioSlider, ratioLabel, "RATIO", "ratio", 1.0, 20.0, 4.0); + ratioAttachment = std::make_unique(processorRef.parameters, "ratio", ratioSlider); + + setupKnob(attackSlider, attackLabel, "ATTACK", "attack", 0.001, 0.1, 0.03); + attackAttachment = std::make_unique(processorRef.parameters, "attack", attackSlider); + + setupKnob(releaseSlider, releaseLabel, "RELEASE", "release", 0.05, 1.5, 0.3); + releaseAttachment = std::make_unique(processorRef.parameters, "release", releaseSlider); + + setupKnob(kneeSlider, kneeLabel, "KNEE", "knee", 0.0, 12.0, 6.0); + kneeAttachment = std::make_unique(processorRef.parameters, "knee", kneeSlider); + + setupKnob(makeupSlider, makeupLabel, "MAKEUP", "makeup", 0.0, 24.0, 0.0); + makeupAttachment = std::make_unique(processorRef.parameters, "makeup", makeupSlider); + + setupKnob(mixSlider, mixLabel, "MIX", "mix", 0.0, 1.0, 1.0); + mixAttachment = std::make_unique(processorRef.parameters, "mix", mixSlider); + + setupKnob(autoReleaseSlider, autoReleaseLabel, "AUTO REL", "autoRelease", 0.0, 1.0, 1.0); + autoReleaseAttachment = std::make_unique(processorRef.parameters, "autoRelease", autoReleaseSlider); + + // LCD displays + auto setupLCD = [this](juce::Label& lcd) { + lcd.setJustificationType(juce::Justification::centred); + lcd.setColour(juce::Label::textColourId, lcdText); + lcd.setColour(juce::Label::backgroundColourId, lcdBg); + lcd.setFont(juce::Font(juce::FontOptions(12.0f * uiScale).withStyle("Bold"))); + addAndMakeVisible(lcd); + }; + setupLCD(thresholdLCD); + setupLCD(ratioLCD); + setupLCD(attackLCD); + setupLCD(releaseLCD); + setupLCD(kneeLCD); + setupLCD(makeupLCD); + setupLCD(mixLCD); + setupLCD(autoReleaseLCD); + + // Zoom dropdown + zoomLabel.setText("ZOOM", juce::dontSendNotification); + zoomLabel.setJustificationType(juce::Justification::centredRight); + zoomLabel.setColour(juce::Label::textColourId, textDim); + zoomLabel.setFont(juce::Font(juce::FontOptions(10.0f))); + addAndMakeVisible(zoomLabel); + + zoomBox.addItem("1.0x", 1); + zoomBox.addItem("1.5x", 2); + zoomBox.addItem("2.0x", 3); + zoomBox.addItem("2.5x", 4); + zoomBox.addItem("3.0x", 5); + zoomBox.setSelectedId(2, juce::dontSendNotification); + zoomBox.setColour(juce::ComboBox::backgroundColourId, grooveDark); + zoomBox.setColour(juce::ComboBox::textColourId, textColour); + zoomBox.setColour(juce::ComboBox::arrowColourId, textDim); + zoomBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff333338)); + zoomBox.addListener(this); + addAndMakeVisible(zoomBox); + + // Preset dropdown + presetLabel.setText("PRESET", juce::dontSendNotification); + presetLabel.setJustificationType(juce::Justification::centredRight); + presetLabel.setColour(juce::Label::textColourId, textDim); + presetLabel.setFont(juce::Font(juce::FontOptions(10.0f))); + addAndMakeVisible(presetLabel); + + auto& presets = getPresets(); + for (int i = 0; i < (int)presets.size(); ++i) + presetBox.addItem(presets[i].name, i + 1); + presetBox.setSelectedId(1, juce::dontSendNotification); + presetBox.setColour(juce::ComboBox::backgroundColourId, grooveDark); + presetBox.setColour(juce::ComboBox::textColourId, textColour); + presetBox.setColour(juce::ComboBox::arrowColourId, textDim); + presetBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff333338)); + presetBox.addListener(this); + addAndMakeVisible(presetBox); +} + +SaftpumpeEditor::~SaftpumpeEditor() { + thresholdSlider.setLookAndFeel(nullptr); + ratioSlider.setLookAndFeel(nullptr); + attackSlider.setLookAndFeel(nullptr); + releaseSlider.setLookAndFeel(nullptr); + kneeSlider.setLookAndFeel(nullptr); + makeupSlider.setLookAndFeel(nullptr); + mixSlider.setLookAndFeel(nullptr); + autoReleaseSlider.setLookAndFeel(nullptr); + zoomBox.removeListener(this); + presetBox.removeListener(this); + stopTimer(); +} + +void SaftpumpeEditor::comboBoxChanged(juce::ComboBox* box) { + if (box == &zoomBox) { + static const float scales[] = { 1.0f, 1.5f, 2.0f, 2.5f, 3.0f }; + int id = box->getSelectedId(); + if (id >= 1 && id <= 5) { + uiScale = scales[id - 1]; + setSize(static_cast(700 * uiScale), static_cast(500 * uiScale)); + } + } else if (box == &presetBox) { + int id = box->getSelectedId() - 1; + applyPreset(id); + } +} + +juce::String SaftpumpeEditor::formatValue(int idx, float val) { + switch (idx) { + case 0: return juce::String(val, 1) + " dB"; + case 1: return juce::String(val, 1) + ":1"; + case 2: return formatTime(val); + case 3: return formatTime(val); + case 4: return juce::String(val, 1) + " dB"; + case 5: return juce::String(val, 1) + " dB"; + case 6: return juce::String(static_cast(val * 100)) + "%"; + case 7: return val > 0.5f ? "ON" : "OFF"; + default: return {}; + } +} + +void SaftpumpeEditor::computeLayout() { + float s = uiScale; + auto bounds = getLocalBounds().toFloat(); + auto faceBounds = bounds.reduced(8.0f * s); + + // Header + float headerH = 28.0f * s + 16.0f * s + 8.0f * s; + float headerY = faceBounds.getY() + 6.0f * s; + float contentTop = headerY + headerH + 4.0f * s; + + // Meters — right column + float meterW = 56.0f * s; + meterArea = juce::Rectangle(faceBounds.getRight() - meterW - 14.0f * s, + contentTop, + meterW, + faceBounds.getBottom() - contentTop - 48.0f * s); + + // Knob grid area — 2 rows x 4 cols + float gridLeft = faceBounds.getX() + 14.0f * s; + float gridW = meterArea.getX() - gridLeft - 14.0f * s; + float gridH = meterArea.getHeight(); + float rowH = gridH / 2.0f; + + rowBounds[0] = juce::Rectangle(gridLeft, contentTop, gridW, rowH); + rowBounds[1] = juce::Rectangle(gridLeft, contentTop + rowH, gridW, rowH); + + // Compute knob positions within each row + int knobsPerRow = 4; + float colW = gridW / static_cast(knobsPerRow); + knobRadius = juce::jmin(colW * 0.30f, rowH * 0.28f); + + for (int row = 0; row < 2; ++row) { + for (int col = 0; col < knobsPerRow; ++col) { + int idx = row * knobsPerRow + col; + float cx = rowBounds[row].getX() + colW * static_cast(col) + colW / 2.0f; + float cy = rowBounds[row].getY() + rowH * 0.40f; + knobCentre[idx] = {cx, cy}; + knobBounds[idx] = juce::Rectangle(cx - knobRadius, cy - knobRadius, + knobRadius * 2.0f, knobRadius * 2.0f); + } + } + + // Bottom: scope + zoom + preset + float bottomY = meterArea.getBottom() + 8.0f * s; + float bottomH = faceBounds.getBottom() - bottomY - 10.0f * s; + float ctrlW = 110.0f * s; + float ctrlGap = 6.0f * s; + float rightX = faceBounds.getRight() - ctrlW - 14.0f * s; + presetArea = juce::Rectangle(rightX, bottomY, ctrlW, bottomH); + zoomArea = juce::Rectangle(rightX, bottomY + bottomH * 0.48f, ctrlW, bottomH * 0.48f); + scopeArea = juce::Rectangle(faceBounds.getX() + 14.0f * s, bottomY, + presetArea.getX() - faceBounds.getX() - 24.0f * s, bottomH); +} + +void SaftpumpeEditor::resized() { + computeLayout(); + + float s = uiScale; + float labelH = 14.0f * s; + float lcdH = 18.0f * s; + float knobDiam = knobRadius * 2.0f; + float arcMargin = 14.0f * s; + + auto setKnobBounds = [&](juce::Slider& slider, juce::Label& label, juce::Label& lcd, int idx) { + // Slider gets arc area (larger than knob body for arc meter) + float arcR = knobRadius + arcMargin; + auto arcB = juce::Rectangle(knobCentre[idx].getX() - arcR, knobCentre[idx].getY() - arcR, + arcR * 2.0f, arcR * 2.0f); + slider.setBounds(arcB.toNearestInt()); + + // Label above arc + auto lb = juce::Rectangle( + static_cast(knobCentre[idx].getX() - knobRadius), + static_cast(knobCentre[idx].getY() - knobRadius - 10.0f * s - 4.0f * s - labelH), + static_cast(knobDiam), + static_cast(labelH)); + label.setBounds(lb); + + // LCD below knob + auto lcdB = juce::Rectangle( + static_cast(knobCentre[idx].getX() - knobRadius * 0.85f), + static_cast(knobCentre[idx].getY() + knobRadius + 3.0f * s), + static_cast(knobRadius * 1.7f), + static_cast(lcdH)); + lcd.setBounds(lcdB); + }; + + setKnobBounds(thresholdSlider, thresholdLabel, thresholdLCD, 0); + setKnobBounds(ratioSlider, ratioLabel, ratioLCD, 1); + setKnobBounds(attackSlider, attackLabel, attackLCD, 2); + setKnobBounds(releaseSlider, releaseLabel, releaseLCD, 3); + setKnobBounds(kneeSlider, kneeLabel, kneeLCD, 4); + setKnobBounds(makeupSlider, makeupLabel, makeupLCD, 5); + setKnobBounds(mixSlider, mixLabel, mixLCD, 6); + setKnobBounds(autoReleaseSlider, autoReleaseLabel, autoReleaseLCD, 7); + + presetLabel.setBounds(static_cast(presetArea.getX()), static_cast(presetArea.getY()), + static_cast(presetArea.getWidth()), static_cast(14.0f * s)); + presetBox.setBounds(static_cast(presetArea.getX()), static_cast(presetArea.getY() + 14.0f * s), + static_cast(presetArea.getWidth()), static_cast(22.0f * s)); + + zoomLabel.setBounds(static_cast(zoomArea.getX()), static_cast(zoomArea.getY()), + static_cast(zoomArea.getWidth()), static_cast(14.0f * s)); + zoomBox.setBounds(static_cast(zoomArea.getX()), static_cast(zoomArea.getY() + 14.0f * s), + static_cast(zoomArea.getWidth()), static_cast(22.0f * s)); +} + +void SaftpumpeEditor::paint(juce::Graphics& g) { + if (rowBounds[0].getWidth() <= 0) computeLayout(); + + auto bounds = getLocalBounds().toFloat(); + float s = uiScale; + auto faceBounds = bounds.reduced(8.0f * s); + + // Dark bezel + g.setColour(bezelColour); + g.fillRect(bounds); + + // Faceplate + juce::ColourGradient faceGrad(faceplateTop, faceBounds.getX(), faceBounds.getY(), + faceplateBot, faceBounds.getX(), faceBounds.getBottom(), false); + g.setGradientFill(faceGrad); + g.fillRoundedRectangle(faceBounds, 6.0f * s); + + // Brushed texture + g.setColour(juce::Colours::white.withAlpha(0.02f)); + for (float y = faceBounds.getY(); y < faceBounds.getBottom(); y += 2.0f * s) + g.drawLine(faceBounds.getX(), y, faceBounds.getRight(), y, 0.4f); + + // Edge + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.drawLine(faceBounds.getX() + 4, faceBounds.getY() + 1, faceBounds.getRight() - 4, faceBounds.getY() + 1, 1.0f); + g.setColour(juce::Colours::black.withAlpha(0.4f)); + g.drawLine(faceBounds.getX() + 4, faceBounds.getBottom() - 1, faceBounds.getRight() - 4, faceBounds.getBottom() - 1, 1.0f); + + // Screws + float screwR = 3.5f * s; + float screwInset = 14.0f * s; + drawScrew(g, faceBounds.getX() + screwInset, faceBounds.getY() + screwInset, screwR); + drawScrew(g, faceBounds.getRight() - screwInset, faceBounds.getY() + screwInset, screwR); + drawScrew(g, faceBounds.getX() + screwInset, faceBounds.getBottom() - screwInset, screwR); + drawScrew(g, faceBounds.getRight() - screwInset, faceBounds.getBottom() - screwInset, screwR); + + // Title + auto titleArea = juce::Rectangle(faceBounds.getX() + 30.0f * s, faceBounds.getY() + 6.0f * s, + faceBounds.getWidth() - 60.0f * s, 28.0f * s); + g.setColour(textBright); + g.setFont(juce::Font(juce::FontOptions(22.0f * s).withStyle("Bold"))); + g.drawText("SAFTPUMPE", titleArea, juce::Justification::centred); + g.setColour(textDim.withAlpha(0.2f)); + g.drawText("SAFTPUMPE", titleArea.translated(0, 1.0f), juce::Justification::centred); + + // Subtitle + auto subArea = titleArea.translated(0, 22.0f * s).withHeight(16.0f * s); + g.setColour(textDim); + g.setFont(juce::Font(juce::FontOptions(11.0f * s))); + g.drawText("BUS COMPRESSOR", subArea, juce::Justification::centred); + + // Accent line + float lineY = subArea.getBottom() + 4.0f * s; + g.setColour(accentLine.withAlpha(0.25f)); + g.drawLine(faceBounds.getX() + 20.0f * s, lineY, faceBounds.getRight() - 20.0f * s, lineY, 1.0f); + + // Draw knobs + juce::Slider* sliders[] = { &thresholdSlider, &ratioSlider, &attackSlider, &releaseSlider, + &kneeSlider, &makeupSlider, &mixSlider, &autoReleaseSlider }; + juce::Label* lcds[] = { &thresholdLCD, &ratioLCD, &attackLCD, &releaseLCD, + &kneeLCD, &makeupLCD, &mixLCD, &autoReleaseLCD }; + const char* labels[] = { "THRESHOLD", "RATIO", "ATTACK", "RELEASE", + "KNEE", "MAKEUP", "MIX", "AUTO REL" }; + const juce::StringArray marks[] = { + {"0", "-12", "-24", "-36", "-48"}, + {"20:1", "10:1", "6:1", "4:1", "2:1", "1:1"}, + {"100m", "30m", "10m", "3m", "1m"}, + {"1.5s", "500m", "100m", "50m"}, + {"12", "6", "0"}, + {"24", "12", "0"}, + {"100", "50", "0"}, + {"OFF", "ON"} + }; + + for (int i = 0; i < 8; ++i) { + float norm = static_cast(sliders[i]->valueToProportionOfLength(sliders[i]->getValue())); + drawKnob(g, knobBounds[i], norm, norm); + } + + // Meters + float grH = meterArea.getHeight() * 0.42f; + auto grBounds = juce::Rectangle(meterArea.getX(), meterArea.getY(), meterArea.getWidth(), grH); + float gr = processorRef.getGainReductionMeter(); + drawMeter(g, grBounds, gr, 0.0f, 24.0f, true); + drawPeakHold(g, grBounds, grPeak.peak, true); + g.setColour(textDim); + g.setFont(juce::Font(juce::FontOptions(10.0f * s))); + g.drawText("GR", grBounds.translated(0, grBounds.getHeight() + 2.0f * s), juce::Justification::centredTop); + + float ioY = grBounds.getBottom() + 16.0f * s; + float ioH = meterArea.getBottom() - ioY - 14.0f * s; + float ioW = (meterArea.getWidth() - 3.0f * s) / 2.0f; + auto inB = juce::Rectangle(meterArea.getX(), ioY, ioW, ioH); + auto outB = juce::Rectangle(meterArea.getX() + ioW + 3.0f * s, ioY, ioW, ioH); + + drawMeter(g, inB, processorRef.getInputLevel(), -60.0f, 0.0f, false); + drawMeter(g, outB, processorRef.getOutputLevel(), -60.0f, 0.0f, false); + drawPeakHold(g, inB, inPeak.peak); + drawPeakHold(g, outB, outPeak.peak); + g.drawText("IN", inB.translated(0, ioH + 2.0f * s), juce::Justification::centredTop); + g.drawText("OUT", outB.translated(0, ioH + 2.0f * s), juce::Justification::centredTop); + + // Scope + drawScope(g, scopeArea); + + // Zoom + Preset area borders + g.setColour(juce::Colour(0xff333338)); + g.drawRoundedRectangle(zoomArea.reduced(2.0f * s), 2.0f * s, 0.5f); + g.drawRoundedRectangle(presetArea.reduced(2.0f * s), 2.0f * s, 0.5f); +} + +void SaftpumpeEditor::drawKnob(juce::Graphics& g, juce::Rectangle bounds, + float normalizedValue, float arcFillFrac) { + float s = uiScale; + float cx = bounds.getCentreX(); + float cy = bounds.getCentreY(); + float r = bounds.getWidth() / 2.0f; + + // Arc meter around the knob + float arcR = r + 10.0f * s; + float arcThickness = 3.0f * s; + + auto buildArc = [&](float fromFrac, float toFrac) { + juce::Path p; + float a0 = startAngle + fromFrac * (endAngle - startAngle); + float a1 = startAngle + toFrac * (endAngle - startAngle); + int steps = juce::jmax(2, (int) std::ceil(std::abs(a1 - a0) / 0.15f)); + float da = (a1 - a0) / (float) steps; + p.startNewSubPath(cx + std::cos(a0) * arcR, cy + std::sin(a0) * arcR); + for (int i = 1; i <= steps; ++i) { + float a = a0 + da * (float) i; + p.lineTo(cx + std::cos(a) * arcR, cy + std::sin(a) * arcR); + } + return p; + }; + + // Background arc track + g.setColour(juce::Colour(0xff3a3a42)); + g.strokePath(buildArc(0.0f, 1.0f), juce::PathStrokeType(arcThickness)); + + // Filled arc — colored by parameter zone + if (arcFillFrac > 0.001f) { + juce::Colour arcCol; + if (arcFillFrac < 0.5f) + arcCol = greenLed.interpolatedWith(amberLed, arcFillFrac * 2.0f); + else + arcCol = amberLed.interpolatedWith(redLed, (arcFillFrac - 0.5f) * 2.0f); + g.setColour(arcCol.withAlpha(0.9f)); + g.strokePath(buildArc(0.0f, arcFillFrac), juce::PathStrokeType(arcThickness)); + } + + // Knob shadow — soft outer + hard inner + g.setColour(juce::Colours::black.withAlpha(0.3f)); + g.fillEllipse(cx - r - 3.0f * s, cy - r + 3.0f * s, r * 2.0f + 6.0f * s, r * 2.0f + 6.0f * s); + g.setColour(juce::Colours::black.withAlpha(0.6f)); + g.fillEllipse(cx - r - 1.0f, cy - r + 2.0f * s, r * 2.0f + 2.0f * s, r * 2.0f + 2.0f * s); + + // Outer bevel ring — catches light on top, shadow on bottom + float bevelR = r + 1.0f * s; + { + juce::ColourGradient bevelGrad(juce::Colour(0xff5a5a60), cx - bevelR, cy - bevelR, + juce::Colour(0xff2a2a2e), cx + bevelR, cy + bevelR, true); + g.setGradientFill(bevelGrad); + g.fillEllipse(cx - bevelR, cy - bevelR, bevelR * 2.0f, bevelR * 2.0f); + } + + // Knob body — radial gradient for 3D dome effect + { + juce::ColourGradient bodyGrad(juce::Colour(0xff9a9aa0), cx, cy - r * 0.3f, + juce::Colour(0xff3a3a40), cx, cy + r, true); + bodyGrad.addColour(0.3, juce::Colour(0xff808086)); + bodyGrad.addColour(0.7, juce::Colour(0xff505056)); + g.setGradientFill(bodyGrad); + g.fillEllipse(bounds); + } + + // Knurling — fine radial lines around the edge + { + float knurlR = r - 1.0f * s; + float knurlInner = r - 4.0f * s; + int numTeeth = 40; + g.setColour(juce::Colours::black.withAlpha(0.12f)); + for (int i = 0; i < numTeeth; ++i) { + float a = (static_cast(i) / static_cast(numTeeth)) * juce::MathConstants::twoPi; + float ca = std::cos(a); + float sa = std::sin(a); + g.drawLine(cx + ca * knurlInner, cy + sa * knurlInner, + cx + ca * knurlR, cy + sa * knurlR, 0.6f); + } + } + + // Inner chamfer ring + g.setColour(juce::Colours::white.withAlpha(0.06f)); + g.drawEllipse(bounds.reduced(3.0f * s), 0.8f); + g.setColour(juce::Colours::black.withAlpha(0.15f)); + g.drawEllipse(bounds.reduced(1.5f * s), 0.8f); + + // Specular highlight — top-left crescent + { + juce::ColourGradient specGrad(juce::Colours::white.withAlpha(0.18f), cx - r * 0.2f, cy - r * 0.5f, + juce::Colours::white.withAlpha(0.0f), cx + r * 0.3f, cy + r * 0.1f, true); + g.setGradientFill(specGrad); + g.fillEllipse(cx - r * 0.6f, cy - r * 0.8f, r * 1.0f, r * 0.55f); + } + + // Bottom rim shadow inside the body + g.setColour(juce::Colours::black.withAlpha(0.12f)); + g.fillEllipse(cx - r * 0.7f, cy + r * 0.3f, r * 1.4f, r * 0.6f); + + // Pointer line — wider with subtle glow + float angle = startAngle + normalizedValue * (endAngle - startAngle); + float pIn = r * 0.22f; + float pOut = r * 0.72f; + float px1 = cx + std::cos(angle) * pIn; + float py1 = cy + std::sin(angle) * pIn; + float px2 = cx + std::cos(angle) * pOut; + float py2 = cy + std::sin(angle) * pOut; + + // Glow behind pointer + g.setColour(juce::Colours::white.withAlpha(0.12f)); + g.drawLine(px1, py1, px2, py2, 4.0f * s); + // Pointer itself + g.setColour(juce::Colours::white.withAlpha(0.85f)); + g.drawLine(px1, py1, px2, py2, 1.8f * s); + // Dark edge for definition + g.setColour(juce::Colours::black.withAlpha(0.2f)); + g.drawLine(px1, py1, px2, py2, 0.4f); +} + +void SaftpumpeEditor::drawLCD(juce::Graphics& g, juce::Rectangle bounds, const juce::String& text) { + float s = uiScale; + g.setColour(lcdBg); + g.fillRoundedRectangle(bounds, 2.0f * s); + g.setColour(juce::Colour(0xff1a2a1a)); + g.drawRoundedRectangle(bounds.expanded(0.5f), 2.0f * s, 1.0f); + + g.setColour(lcdText); + g.setFont(juce::Font(juce::FontOptions(12.0f * s).withStyle("Bold"))); + g.drawText(text, bounds, juce::Justification::centred); +} + +void SaftpumpeEditor::drawMeter(juce::Graphics& g, juce::Rectangle bounds, + float level, float minDb, float maxDb, bool isGainReduction) { + float s = uiScale; + g.setColour(grooveDark); + g.fillRoundedRectangle(bounds, 2.0f * s); + g.setColour(juce::Colours::black.withAlpha(0.5f)); + g.drawRoundedRectangle(bounds.expanded(0.5f), 2.0f * s, 1.0f); + + float norm = juce::jlimit(0.0f, 1.0f, (level - minDb) / (maxDb - minDb)); + if (norm > 0.005f) { + float h = bounds.getHeight() * norm; + float y = isGainReduction ? bounds.getY() : bounds.getBottom() - h; + juce::ColourGradient grad; + if (isGainReduction) { + grad = juce::ColourGradient(greenLed, 0.0f, y, redLed, 0.0f, y + h, false); + } else { + grad = juce::ColourGradient(greenLed, 0.0f, bounds.getBottom(), amberLed, 0.0f, y, false); + if (norm > 0.8f) grad.addColour(1.0, redLed); + } + g.setGradientFill(grad); + g.fillRect(bounds.getX() + 1.0f, y, bounds.getWidth() - 2.0f, h); + } +} + +void SaftpumpeEditor::drawPeakHold(juce::Graphics& g, juce::Rectangle bounds, + float peakNorm, bool fromTop) { + float s = uiScale; + if (peakNorm < 0.005f) return; + float y = fromTop ? bounds.getY() + bounds.getHeight() * peakNorm + : bounds.getBottom() - bounds.getHeight() * peakNorm; + g.setColour(juce::Colours::white.withAlpha(0.85f)); + g.fillRect(bounds.getX() + 1.0f, y - 1.0f, bounds.getWidth() - 2.0f, 2.0f); +} + +void SaftpumpeEditor::drawScope(juce::Graphics& g, juce::Rectangle bounds) { + float s = uiScale; + g.setColour(grooveDark); + g.fillRoundedRectangle(bounds, 3.0f * s); + g.setColour(juce::Colours::black.withAlpha(0.4f)); + g.drawRoundedRectangle(bounds.expanded(0.5f), 3.0f * s, 1.0f); + + g.setColour(accentLine.withAlpha(0.06f)); + float centerY = bounds.getCentreY(); + g.drawLine(bounds.getX() + 2.0f, centerY, bounds.getRight() - 2.0f, centerY, 0.5f); + for (int i = 1; i <= 4; ++i) { + float gx = bounds.getX() + bounds.getWidth() * (static_cast(i) / 4.0f); + g.drawLine(gx, bounds.getY() + 2.0f, gx, bounds.getBottom() - 2.0f, 0.3f); + } + + auto& inData = processorRef.inputScope; + auto& outData = processorRef.outputScope; + int numPoints = processorRef.scopeSize; + if (numPoints < 2) return; + + float width = bounds.getWidth() - 4.0f; + float halfH = bounds.getHeight() / 2.0f - 2.0f; + float xStep = width / static_cast(numPoints - 1); + float ox = bounds.getX() + 2.0f; + + { + juce::Path path; + float x = ox; + path.startNewSubPath(x, centerY - inData[0] * halfH); + for (int i = 1; i < numPoints; ++i) { x += xStep; path.lineTo(x, centerY - inData[i] * halfH); } + g.setColour(redLed.withAlpha(0.15f)); + g.strokePath(path, juce::PathStrokeType(4.0f)); + g.setColour(redLed.withAlpha(0.8f)); + g.strokePath(path, juce::PathStrokeType(1.5f)); + } + { + juce::Path path; + float x = ox; + path.startNewSubPath(x, centerY - outData[0] * halfH); + for (int i = 1; i < numPoints; ++i) { x += xStep; path.lineTo(x, centerY - outData[i] * halfH); } + g.setColour(greenLed.withAlpha(0.12f)); + g.strokePath(path, juce::PathStrokeType(3.0f)); + g.setColour(greenLed.withAlpha(0.65f)); + g.strokePath(path, juce::PathStrokeType(1.0f)); + } + + float legendY = bounds.getBottom() - 12.0f * s; + float legendX = bounds.getX() + 6.0f * s; + g.setFont(juce::Font(juce::FontOptions(9.0f * s))); + g.setColour(redLed.withAlpha(0.8f)); + g.drawText("IN", legendX, legendY, 20.0f * s, 10.0f * s, juce::Justification::centredLeft); + g.setColour(greenLed.withAlpha(0.65f)); + g.drawText("OUT", legendX + 24.0f * s, legendY, 26.0f * s, 10.0f * s, juce::Justification::centredLeft); +} + +void SaftpumpeEditor::drawScrew(juce::Graphics& g, float cx, float cy, float radius) { + juce::ColourGradient grad(juce::Colour(0xff3a3a40), cx - radius, cy - radius, + juce::Colour(0xff1a1a1e), cx + radius, cy + radius, true); + g.setGradientFill(grad); + g.fillEllipse(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f); + g.setColour(juce::Colour(0xff555558)); + float cs = radius * 0.6f; + g.drawLine(cx - cs, cy, cx + cs, cy, 0.8f); + g.drawLine(cx, cy - cs, cx, cy + cs, 0.8f); + g.setColour(juce::Colours::white.withAlpha(0.15f)); + g.fillEllipse(cx - radius * 0.3f, cy - radius * 0.5f, radius * 0.4f, radius * 0.25f); +} + +void SaftpumpeEditor::timerCallback() { + double now = juce::Time::getMillisecondCounterHiRes(); + + auto updatePeak = [&](PeakHold& pk, float level) { + if (level > pk.peak || (now - pk.peakTime) > PeakHold::holdMs) { + pk.peak = level; + pk.peakTime = now; + } + }; + + float grNorm = juce::jlimit(0.0f, 1.0f, + processorRef.getGainReductionMeter() / 24.0f); + float inNorm = juce::jlimit(0.0f, 1.0f, + (processorRef.getInputLevel() - (-60.0f)) / (0.0f - (-60.0f))); + float outNorm = juce::jlimit(0.0f, 1.0f, + (processorRef.getOutputLevel() - (-60.0f)) / (0.0f - (-60.0f))); + + updatePeak(grPeak, grNorm); + updatePeak(inPeak, inNorm); + updatePeak(outPeak, outNorm); + + // Update LCD displays + auto updateLCD = [](juce::Label& lcd, const juce::String& text) { + if (lcd.getText() != text) lcd.setText(text, juce::dontSendNotification); + }; + updateLCD(thresholdLCD, formatValue(0, static_cast(thresholdSlider.getValue()))); + updateLCD(ratioLCD, formatValue(1, static_cast(ratioSlider.getValue()))); + updateLCD(attackLCD, formatValue(2, static_cast(attackSlider.getValue()))); + updateLCD(releaseLCD, formatValue(3, static_cast(releaseSlider.getValue()))); + updateLCD(kneeLCD, formatValue(4, static_cast(kneeSlider.getValue()))); + updateLCD(makeupLCD, formatValue(5, static_cast(makeupSlider.getValue()))); + updateLCD(mixLCD, formatValue(6, static_cast(mixSlider.getValue()))); + updateLCD(autoReleaseLCD, formatValue(7, static_cast(autoReleaseSlider.getValue()))); + + repaint(); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..a6d9531 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,105 @@ +#pragma once +#include "PluginProcessor.h" +#include + +class SaftpumpeEditor : public juce::AudioProcessorEditor, + public juce::Timer, + public juce::ComboBox::Listener { +public: + SaftpumpeEditor(SaftpumpeProcessor&); + ~SaftpumpeEditor() override; + + void paint(juce::Graphics&) override; + void resized() override; + void timerCallback() override; + void comboBoxChanged(juce::ComboBox* box) override; + +private: + SaftpumpeProcessor& processorRef; + + float uiScale = 1.5f; + + // Dark industrial palette + juce::Colour bezelColour{0xff0a0a0c}; + juce::Colour faceplateTop{0xff222226}; + juce::Colour faceplateBot{0xff161618}; + juce::Colour knobLight{0xff72727a}; + juce::Colour knobDark{0xff3a3a40}; + juce::Colour grooveDark{0xff111114}; + juce::Colour greenLed{0xff00e676}; + juce::Colour redLed{0xffe63333}; + juce::Colour amberLed{0xffe6a600}; + juce::Colour textColour{0xffb0b0b6}; + juce::Colour textDim{0xff6a6a70}; + juce::Colour textBright{0xffe0e0e4}; + juce::Colour accentLine{0xff00e676}; + juce::Colour lcdBg{0xff080c08}; + juce::Colour lcdText{0xff00cc66}; + + juce::Slider thresholdSlider, ratioSlider, attackSlider, releaseSlider; + juce::Slider kneeSlider, makeupSlider, mixSlider, autoReleaseSlider; + + juce::Label thresholdLabel, ratioLabel, attackLabel, releaseLabel; + juce::Label kneeLabel, makeupLabel, mixLabel, autoReleaseLabel; + + // LCD value displays + juce::Label thresholdLCD, ratioLCD, attackLCD, releaseLCD; + juce::Label kneeLCD, makeupLCD, mixLCD, autoReleaseLCD; + + juce::ComboBox zoomBox; + juce::Label zoomLabel; + juce::ComboBox presetBox; + juce::Label presetLabel; + + using SliderAttachment = juce::AudioProcessorValueTreeState::SliderAttachment; + + std::unique_ptr thresholdAttachment, ratioAttachment, attackAttachment; + std::unique_ptr releaseAttachment, kneeAttachment, makeupAttachment; + std::unique_ptr mixAttachment, autoReleaseAttachment; + + juce::Rectangle meterArea; + juce::Rectangle scopeArea; + juce::Rectangle zoomArea; + juce::Rectangle rowBounds[2]; + juce::Rectangle knobBounds[8]; + juce::Point knobCentre[8]; + float knobRadius = 0.0f; + + struct PeakHold { + float peak = 0.0f; + double peakTime = 0.0; + static constexpr double holdMs = 1000.0; + } grPeak, inPeak, outPeak; + + void computeLayout(); + void drawKnob(juce::Graphics& g, juce::Rectangle bounds, float normalizedValue, + float arcFillFrac); + void drawLCD(juce::Graphics& g, juce::Rectangle bounds, const juce::String& text); + void drawMeter(juce::Graphics& g, juce::Rectangle bounds, + float level, float minDb, float maxDb, bool isGainReduction); + void drawPeakHold(juce::Graphics& g, juce::Rectangle bounds, + float peakNorm, bool fromTop = false); + void drawScope(juce::Graphics& g, juce::Rectangle bounds); + void drawScrew(juce::Graphics& g, float cx, float cy, float radius); + + static constexpr float startAngle = juce::MathConstants::pi * 0.75f; + static constexpr float endAngle = juce::MathConstants::pi * 2.25f; + + juce::String formatValue(int idx, float val); + + struct Preset { + const char* name; + float threshold, ratio, attack, release, knee, makeup, mix, autoRelease; + }; + static const std::vector& getPresets(); + void applyPreset(int index); + + juce::Rectangle presetArea; + + struct NoDotLookAndFeel : juce::LookAndFeel_V4 { + void drawRotarySlider(juce::Graphics&, int, int, int, int, + float, float, float, juce::Slider&) override {} + } lnf; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SaftpumpeEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..65ba982 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,126 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +SaftpumpeProcessor::SaftpumpeProcessor() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + parameters(*this, nullptr, "Parameters", { + std::make_unique( + thresholdID, "Threshold", juce::NormalisableRange(-48.0f, 0.0f, 0.1f), -20.0f), + std::make_unique( + ratioID, "Ratio", juce::NormalisableRange(1.0f, 20.0f, 0.1f, 0.5f), 4.0f), + std::make_unique( + attackID, "Attack", juce::NormalisableRange(0.001f, 0.1f, 0.001f, 0.5f), 0.03f), + std::make_unique( + releaseID, "Release", juce::NormalisableRange(0.05f, 1.5f, 0.01f, 0.5f), 0.3f), + std::make_unique( + kneeID, "Knee", juce::NormalisableRange(0.0f, 12.0f, 0.5f), 6.0f), + std::make_unique( + makeupID, "Makeup", juce::NormalisableRange(0.0f, 24.0f, 0.1f), 0.0f), + std::make_unique( + mixID, "Mix", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 1.0f), + std::make_unique( + autoReleaseID, "Auto Release", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 1.0f) + }) +{ + inputScope.fill(0.0f); + outputScope.fill(0.0f); +} + +SaftpumpeProcessor::~SaftpumpeProcessor() {} + +void SaftpumpeProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) { + compressorL.prepare(sampleRate, samplesPerBlock); + compressorR.prepare(sampleRate, samplesPerBlock); + compressorL.reset(); + compressorR.reset(); + scopeIndex = 0; +} + +void SaftpumpeProcessor::releaseResources() {} + +void SaftpumpeProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) { + juce::ScopedNoDenormals noDenormals; + + auto totalNumInputChannels = getTotalNumInputChannels(); + auto totalNumOutputChannels = getTotalNumOutputChannels(); + + for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i) + buffer.clear(i, 0, buffer.getNumSamples()); + + updateCompressorParams(); + + float maxInput = 0.0f; + float maxOutput = 0.0f; + float maxGR = 0.0f; + + int numSamples = buffer.getNumSamples(); + float* channelDataL = buffer.getWritePointer(0); + float* channelDataR = (totalNumInputChannels > 1) ? buffer.getWritePointer(1) : nullptr; + + for (int sample = 0; sample < numSamples; ++sample) { + float inL = channelDataL[sample]; + float inR = channelDataR ? channelDataR[sample] : inL; + + float absIn = std::max(std::abs(inL), std::abs(inR)); + if (absIn > maxInput) maxInput = absIn; + + inputScope[scopeIndex] = inL; + + float outL = compressorL.processSample(inL); + float outR = compressorR.processSample(inR); + + channelDataL[sample] = outL; + if (channelDataR) channelDataR[sample] = outR; + + float absOut = std::max(std::abs(outL), std::abs(outR)); + if (absOut > maxOutput) maxOutput = absOut; + + float gr = std::abs(compressorL.getGainReduction()); + if (gr > maxGR) maxGR = gr; + + outputScope[scopeIndex] = outL; + scopeIndex = (scopeIndex + 1) % scopeSize; + } + + currentInputLevel = currentInputLevel * 0.8f + (maxInput > 0.0001f ? 20.0f * std::log10(maxInput) : -100.0f) * 0.2f; + currentOutputLevel = currentOutputLevel * 0.8f + (maxOutput > 0.0001f ? 20.0f * std::log10(maxOutput) : -100.0f) * 0.2f; + currentGainReduction = currentGainReduction * 0.7f + maxGR * 0.3f; +} + +void SaftpumpeProcessor::updateCompressorParams() { + CompressorDSP::Params p; + p.threshold = parameters.getRawParameterValue(thresholdID)->load(); + p.ratio = parameters.getRawParameterValue(ratioID)->load(); + p.attack = parameters.getRawParameterValue(attackID)->load(); + p.release = parameters.getRawParameterValue(releaseID)->load(); + p.knee = parameters.getRawParameterValue(kneeID)->load(); + p.makeup = parameters.getRawParameterValue(makeupID)->load(); + p.mix = parameters.getRawParameterValue(mixID)->load(); + p.autoRelease = parameters.getRawParameterValue(autoReleaseID)->load() > 0.5f; + + compressorL.setParams(p); + compressorR.setParams(p); +} + +juce::AudioProcessorEditor* SaftpumpeProcessor::createEditor() { + return new SaftpumpeEditor(*this); +} + +void SaftpumpeProcessor::getStateInformation(juce::MemoryBlock& destData) { + auto state = parameters.copyState(); + std::unique_ptr xml(state.createXml()); + copyXmlToBinary(*xml, destData); +} + +void SaftpumpeProcessor::setStateInformation(const void* data, int sizeInBytes) { + std::unique_ptr xml(getXmlFromBinary(data, sizeInBytes)); + if (xml && xml->hasTagName(parameters.state.getType())) { + parameters.replaceState(juce::ValueTree::fromXml(*xml)); + } +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { + return new SaftpumpeProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..e8557ab --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include "CompressorDSP.h" + +class SaftpumpeProcessor : public juce::AudioProcessor { +public: + SaftpumpeProcessor(); + ~SaftpumpeProcessor() 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 JucePlugin_Name; } + + 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; + + static constexpr const char* thresholdID = "threshold"; + static constexpr const char* ratioID = "ratio"; + static constexpr const char* attackID = "attack"; + static constexpr const char* releaseID = "release"; + static constexpr const char* kneeID = "knee"; + static constexpr const char* makeupID = "makeup"; + static constexpr const char* mixID = "mix"; + static constexpr const char* autoReleaseID = "autoRelease"; + + juce::AudioProcessorValueTreeState parameters; + + float getGainReductionMeter() const { return currentGainReduction; } + float getInputLevel() const { return currentInputLevel; } + float getOutputLevel() const { return currentOutputLevel; } + + static constexpr int scopeSize = 512; + std::array inputScope{}; + std::array outputScope{}; + int scopeIndex = 0; + +private: + CompressorDSP compressorL; + CompressorDSP compressorR; + + float currentGainReduction = 0.0f; + float currentInputLevel = 0.0f; + float currentOutputLevel = 0.0f; + + void updateCompressorParams(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SaftpumpeProcessor) +};