From 6c0d07246596ff9f10daf39852266331d7d67c24 Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 10 Aug 2026 12:55:02 +0200 Subject: [PATCH] update --- .gitignore | 16 ++ CMakeLists.txt | 76 +++++++ JUCE | 1 + Makefile | 74 +++++++ Source/HorizontEngine.cpp | 218 ++++++++++++++++++++ Source/HorizontEngine.h | 218 ++++++++++++++++++++ Source/HorizontLookAndFeel.cpp | 201 +++++++++++++++++++ Source/HorizontLookAndFeel.h | 25 +++ Source/Knob.cpp | 81 ++++++++ Source/Knob.h | 31 +++ Source/PitchShifter.cpp | 80 ++++++++ Source/PitchShifter.h | 23 +++ Source/PluginEditor.cpp | 349 +++++++++++++++++++++++++++++++++ Source/PluginEditor.h | 54 +++++ Source/PluginProcessor.cpp | 314 +++++++++++++++++++++++++++++ Source/PluginProcessor.h | 81 ++++++++ Source/Presets.h | 54 +++++ Source/SpectrumDisplay.cpp | 273 ++++++++++++++++++++++++++ Source/SpectrumDisplay.h | 43 ++++ 19 files changed, 2212 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 160000 JUCE create mode 100644 Makefile create mode 100644 Source/HorizontEngine.cpp create mode 100644 Source/HorizontEngine.h create mode 100644 Source/HorizontLookAndFeel.cpp create mode 100644 Source/HorizontLookAndFeel.h create mode 100644 Source/Knob.cpp create mode 100644 Source/Knob.h create mode 100644 Source/PitchShifter.cpp create mode 100644 Source/PitchShifter.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 create mode 100644 Source/Presets.h create mode 100644 Source/SpectrumDisplay.cpp create mode 100644 Source/SpectrumDisplay.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2164c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# macOS +.DS_Store + +# CMake build output +build +build-*/ + +# Auto-fetched JUCE source tree (downloaded by `make`) +JUCE/ + +# IDE / editor +.vscode/ +.idea/ +*.xcodeproj +*.xcworkspace +*.swp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e33e848 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.22) + +project(Horizont + VERSION 1.0.0 + LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +if(DEFINED JUCE_ROOT AND EXISTS "${JUCE_ROOT}/CMakeLists.txt") + message(STATUS "Using JUCE from ${JUCE_ROOT}") + add_subdirectory("${JUCE_ROOT}" "${CMAKE_BINARY_DIR}/JUCE") +elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/JUCE/CMakeLists.txt") + message(STATUS "Using JUCE from ${CMAKE_CURRENT_SOURCE_DIR}/JUCE") + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/JUCE") +else() + list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_SOURCE_DIR}/JUCE") + find_package(JUCE CONFIG REQUIRED) +endif() + +juce_add_plugin(Horizont + VERSION "1.0.0" + PRODUCT_NAME "Horizont" + COMPANY_NAME "Horizont" + BUNDLE_ID "com.horizont.Horizont" + PLUGIN_MANUFACTURER_CODE "ARMN" + PLUGIN_CODE "RVRB" + DESCRIPTION "Algorithmic stereo reverb with feedback pitch shifting" + IS_SYNTH FALSE + IS_MIDI_EFFECT FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + FORMATS "VST3" "AU" + COPY_PLUGIN_AFTER_BUILD FALSE +) + +juce_generate_juce_header(Horizont) + +# We only ship a VST3 (never released a VST2), so tell JUCE it doesn't need to +# keep parameter IDs compatible with a VST2 version of this plugin. +target_compile_definitions(Horizont PUBLIC JUCE_VST3_CAN_REPLACE_VST2=0) + +string(TIMESTAMP BUILD_TIMESTAMP "%Y-%m-%d__%H-%M-%S") + +set(HORIZONT_GIT_REVISION "unknown") +find_package(Git QUIET) +if(Git_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} describe --tags --always --dirty + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE HORIZONT_GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +endif() + +target_compile_definitions(Horizont PRIVATE + HORIZONT_VERSION="${PROJECT_VERSION}" + HORIZONT_GIT_REVISION="${HORIZONT_GIT_REVISION}" + HORIZONT_BUILD_TIMESTAMP="${BUILD_TIMESTAMP}") + +target_sources(Horizont PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/HorizontEngine.cpp + Source/PitchShifter.cpp + Source/HorizontLookAndFeel.cpp + Source/Knob.cpp + Source/SpectrumDisplay.cpp + Source/Presets.h +) + +target_link_libraries(Horizont PRIVATE juce::juce_dsp) diff --git a/JUCE b/JUCE new file mode 160000 index 0000000..91ad83a --- /dev/null +++ b/JUCE @@ -0,0 +1 @@ +Subproject commit 91ad83ae34a81e0833b1a2b0866f54846370ae53 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c79bb63 --- /dev/null +++ b/Makefile @@ -0,0 +1,74 @@ +# Horizont plugin - build and install (macOS) +# +# make fetch JUCE, configure and build (Release) +# make install build and install into ~/Library/Audio/Plug-Ins +# make uninstall remove the installed copies +# make clean delete the build directory +# make help list available targets + +JUCE_DIR := JUCE +JUCE_VERSION := 8.0.15 +JUCE_REPO := https://github.com/juce-framework/JUCE.git + +BUILD_DIR := build +ARTIFACTS := $(BUILD_DIR)/Horizont_artefacts +CONFIG := Release + +VST3 := $(ARTIFACTS)/$(CONFIG)/VST3/Horizont.vst3 +AU := $(ARTIFACTS)/$(CONFIG)/AU/Horizont.component + +PLUGINS_HOME := $(HOME)/Library/Audio/Plug-Ins +VST3_INSTALL := $(PLUGINS_HOME)/VST3/Horizont.vst3 +AU_INSTALL := $(PLUGINS_HOME)/Components/Horizont.component + +CMAKE := cmake +JUCE_ROOT := $(abspath $(CURDIR)/$(JUCE_DIR)) + +.PHONY: all help deps configure build install uninstall clean + +all: build + +help: + @echo "Horizont plugin targets:" + @echo " make build (fetch JUCE into ./$(JUCE_DIR) if missing)" + @echo " make install build + install into $(PLUGINS_HOME)" + @echo " make uninstall remove installed plugin" + @echo " make clean delete $(BUILD_DIR)" + +deps: + @if [ -f "$(JUCE_DIR)/CMakeLists.txt" ]; then \ + echo "==> JUCE already present in ./$(JUCE_DIR)"; \ + else \ + echo "==> Fetching JUCE $(JUCE_VERSION) into ./$(JUCE_DIR) ..."; \ + git clone --depth 1 --branch $(JUCE_VERSION) $(JUCE_REPO) $(JUCE_DIR) || { \ + echo "!! Could not fetch JUCE. Check network access."; exit 1; }; \ + fi + +configure: deps + @mkdir -p $(BUILD_DIR) + $(CMAKE) -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(CONFIG) -DJUCE_ROOT="$(JUCE_ROOT)" + +build: configure + $(CMAKE) --build $(BUILD_DIR) --target Horizont_All --config $(CONFIG) -j + +$(VST3) $(AU): build + +install: build + @echo "==> Installing Horizont into $(PLUGINS_HOME)" + @mkdir -p "$(PLUGINS_HOME)/VST3" "$(PLUGINS_HOME)/Components" + @rm -rf "$(VST3_INSTALL)" "$(AU_INSTALL)" + @cp -R "$(VST3)" "$(VST3_INSTALL)" + @cp -R "$(AU)" "$(AU_INSTALL)" + @codesign --force --deep --sign - "$(VST3_INSTALL)" >/dev/null 2>&1 || true + @codesign --force --deep --sign - "$(AU_INSTALL)" >/dev/null 2>&1 || true + @echo "==> Installed:" + @echo " $(VST3_INSTALL)" + @echo " $(AU_INSTALL)" + +uninstall: + @rm -rf "$(VST3_INSTALL)" "$(AU_INSTALL)" + @echo "==> Removed installed plugin." + +clean: + @rm -rf $(BUILD_DIR) + @echo "==> Cleaned $(BUILD_DIR)." diff --git a/Source/HorizontEngine.cpp b/Source/HorizontEngine.cpp new file mode 100644 index 0000000..9b789dc --- /dev/null +++ b/Source/HorizontEngine.cpp @@ -0,0 +1,218 @@ +#include "HorizontEngine.h" + +HorizontEngine::HorizontEngine() +{ + prepare (44100.0); +} + +void HorizontEngine::prepare (double sr) +{ + sampleRate = sr; + const float srScale = (float) (sr / 44100.0); + + for (int i = 0; i < 8; ++i) + { + baseLen[(size_t) i] = baseLen441[(size_t) i] * srScale; + baseLenR[(size_t) i] = (baseLen441[(size_t) i] + rightOffset[(size_t) i]) * srScale; + + const int maxL = (int) std::ceil (baseLen441[(size_t) i] * 2.0f * srScale) + 8; + combL[(size_t) i].prepare (maxL); + combR[(size_t) i].prepare (maxL); + } + + diffL1.prepare ((int) (221.0f * srScale)); + diffL2.prepare ((int) (113.0f * srScale)); + diffR1.prepare ((int) (229.0f * srScale)); + diffR2.prepare ((int) (107.0f * srScale)); + + shiftL.prepare (sr); + shiftR.prepare (sr); + + lowCutG.reset (sr, 0.04); + setLowCut (lowCutHz); + lowCutG.setCurrentAndTargetValue (lowCutG.getTargetValue()); + + highCutG.reset (sr, 0.04); + setHighCut (highCutHz); + highCutG.setCurrentAndTargetValue (highCutG.getTargetValue()); + + decayS.reset (sr, 0.06); + setDecay (decaySec); + decayS.setCurrentAndTargetValue (decayS.getTargetValue()); + + sizeS.reset (sr, 0.06); + setSize (sizeVal); + sizeS.setCurrentAndTargetValue (sizeS.getTargetValue()); + + diffS.reset (sr, 0.05); + setDiffusion (diffusionVal); + diffS.setCurrentAndTargetValue (diffS.getTargetValue()); + + dampS.reset (sr, 0.05); + setBrightness (brightnessVal); + dampS.setCurrentAndTargetValue (dampS.getTargetValue()); + + feedbackS.reset (sr, 0.06); + setFeedback (feedbackVal); + feedbackS.setCurrentAndTargetValue (feedbackS.getTargetValue()); + + ratioS.reset (sr, 0.08); + setPitch (pitchVal); + ratioS.setCurrentAndTargetValue (ratioS.getTargetValue()); + + wetS.reset (sr, 0.03); + setDryWet (dryWetVal); + wetS.setCurrentAndTargetValue (wetS.getTargetValue()); + + reset(); +} + +void HorizontEngine::reset() +{ + for (auto& c : combL) + c.reset(); + for (auto& c : combR) + c.reset(); + + diffL1.reset(); + diffL2.reset(); + diffR1.reset(); + diffR2.reset(); + + hpL.reset(); + lpL.reset(); + hpR.reset(); + lpR.reset(); + + dcL.reset(); + dcR.reset(); + + shiftL.reset(); + shiftR.reset(); + + lastWetL = 0.0f; + lastWetR = 0.0f; +} + +void HorizontEngine::setLowCut (float hz) +{ + lowCutHz = hz; + hz = juce::jmin (hz, (float) (sampleRate * 0.48)); + lowCutG.setTargetValue (std::tan (juce::MathConstants::pi * hz / (float) sampleRate)); +} + +void HorizontEngine::setHighCut (float hz) +{ + highCutHz = hz; + hz = juce::jmin (hz, (float) (sampleRate * 0.48)); + highCutG.setTargetValue (std::tan (juce::MathConstants::pi * hz / (float) sampleRate)); +} + +void HorizontEngine::setDecay (float seconds) +{ + decaySec = seconds; + decayS.setTargetValue (juce::jmax (0.1f, seconds)); +} + +void HorizontEngine::setSize (float size) +{ + sizeVal = size; + sizeS.setTargetValue (juce::jlimit (0.2f, 2.5f, size)); +} + +void HorizontEngine::setDiffusion (float diffusion) +{ + diffusionVal = diffusion; + diffS.setTargetValue (0.72f * juce::jlimit (0.0f, 1.0f, diffusion)); +} + +void HorizontEngine::setBrightness (float brightness) +{ + brightnessVal = brightness; + const float b = juce::jlimit (0.0f, 1.0f, brightness); + float fc = 500.0f + 19500.0f * std::pow (b, 1.5f); + fc = juce::jmin (fc, (float) (sampleRate * 0.48f)); + dampS.setTargetValue (1.0f - std::exp (-2.0f * juce::MathConstants::pi * fc / (float) sampleRate)); +} + +void HorizontEngine::setFeedback (float feedback) +{ + feedbackVal = feedback; + feedbackS.setTargetValue (juce::jlimit (0.0f, 0.99f, feedback)); +} + +void HorizontEngine::setPitch (float semitones) +{ + pitchVal = semitones; + ratioS.setTargetValue (std::pow (2.0f, semitones / 12.0f)); +} + +void HorizontEngine::setDryWet (float dryWet) +{ + dryWetVal = dryWet; + wetS.setTargetValue (juce::jlimit (0.0f, 1.0f, dryWet)); +} + +void HorizontEngine::processSample (float inL, float inR, float& outL, float& outR) +{ + const float sizeCur = sizeS.getNextValue(); + const float rtCur = decayS.getNextValue(); + const float dampC = dampS.getNextValue(); + const float diffG = diffS.getNextValue(); + const float fbGain = feedbackS.getNextValue(); + const float ratio = ratioS.getNextValue(); + const float wetVal = wetS.getNextValue(); + const float gLow = lowCutG.getNextValue(); + const float gHigh = highCutG.getNextValue(); + + hpL.setG (gLow); + hpL.process (inL); + lpL.setG (gHigh); + lpL.process (hpL.high); + + hpR.setG (gLow); + hpR.process (inR); + lpR.setG (gHigh); + lpR.process (hpR.high); + + const float fl = lpL.low; + const float fr = lpR.low; + + const float invRt = 1.0f / (rtCur * sizeCur * (float) sampleRate); + + const float loopInvRt = invRt * (1.0f - 0.7f * fbGain); + + const float gMax = std::exp (-6.907755f * baseLen[0] * sizeCur * loopInvRt); + + const float fbL = fl + fbGain * 0.5f * (1.0f - gMax) * shiftL.process (lastWetL, ratio); + const float fbR = fr + fbGain * 0.5f * (1.0f - gMax) * shiftR.process (lastWetR, ratio); + + const float dL = diffL2.process (diffL1.process (fbL, diffG), diffG); + const float dR = diffR2.process (diffR1.process (fbR, diffG), diffG); + + float wetL = 0.0f; + float wetR = 0.0f; + + for (int i = 0; i < 8; ++i) + { + const float lenL = baseLen[(size_t) i] * sizeCur; + const float gainL = std::exp (-6.907755f * lenL * loopInvRt); + wetL += 0.25f * combL[(size_t) i].process (dL, lenL, dampC, gainL); + + const float lenR = baseLenR[(size_t) i] * sizeCur; + const float gainR = std::exp (-6.907755f * lenR * loopInvRt); + wetR += 0.25f * combR[(size_t) i].process (dR, lenR, dampC, gainR); + } + + wetL = dcL.process (wetL); + wetR = dcR.process (wetR); + + wetL = std::tanh (wetL); + wetR = std::tanh (wetR); + + lastWetL = wetL; + lastWetR = wetR; + + outL = inL * (1.0f - wetVal) + wetL * wetVal; + outR = inR * (1.0f - wetVal) + wetR * wetVal; +} diff --git a/Source/HorizontEngine.h b/Source/HorizontEngine.h new file mode 100644 index 0000000..3d34cd0 --- /dev/null +++ b/Source/HorizontEngine.h @@ -0,0 +1,218 @@ +#pragma once + +#include + +#include + +#include "PitchShifter.h" + +class HorizontEngine +{ +public: + HorizontEngine(); + + void prepare (double sampleRate); + void reset(); + + void setLowCut (float hz); + void setHighCut (float hz); + void setDecay (float seconds); + void setSize (float size); + void setDiffusion (float diffusion); + void setBrightness (float brightness); + void setFeedback (float feedback); + void setPitch (float semitones); + void setDryWet (float dryWet); + + void processSample (float inL, float inR, float& outL, float& outR); + + float getLastWetL() const noexcept { return lastWetL; } + float getLastWetR() const noexcept { return lastWetR; } + +private: + struct SVF + { + void reset() + { + ic1 = 0.0f; + ic2 = 0.0f; + g = 0.0f; + } + + void setG (float gIn) + { + g = gIn; + a1 = 1.0f / (1.0f + g * (g + k)); + a2 = g * a1; + } + + void process (float x) + { + const float v3 = x - ic2; + const float v1 = a1 * ic1 + a2 * v3; + const float v2 = ic2 + g * v1; + ic1 = 2.0f * v1 - ic1; + ic2 = 2.0f * v2 - ic2; + low = v2; + high = x - k * v1 - v2; + } + + float g = 0.0f; + float k = 1.41421356f; + float a1 = 0.0f; + float a2 = 0.0f; + float ic1 = 0.0f; + float ic2 = 0.0f; + float low = 0.0f; + float high = 0.0f; + }; + + struct Allpass + { + void prepare (int length) + { + len = length; + xb.assign ((size_t) len + 1, 0.0f); + yb.assign ((size_t) len + 1, 0.0f); + writePos = 0; + } + + void reset() + { + std::fill (xb.begin(), xb.end(), 0.0f); + std::fill (yb.begin(), yb.end(), 0.0f); + writePos = 0; + } + + float process (float x, float g) + { + int rp = writePos - len; + if (rp < 0) + rp += len + 1; + + const float xd = xb[(size_t) rp]; + const float yd = yb[(size_t) rp]; + xb[(size_t) writePos] = x; + + const float y = -g * x + xd + g * yd; + yb[(size_t) writePos] = y; + + ++writePos; + if (writePos > len) + writePos = 0; + + return y; + } + + std::vector xb; + std::vector yb; + int len = 0; + int writePos = 0; + }; + + struct Comb + { + void prepare (int maxLength) + { + buffer.assign ((size_t) maxLength + 4, 0.0f); + maxLen = maxLength; + writePos = 0; + dampState = 0.0f; + } + + void reset() + { + std::fill (buffer.begin(), buffer.end(), 0.0f); + dampState = 0.0f; + writePos = 0; + } + + float process (float input, float delayLen, float dampCoef, float gain) + { + const float rp = (float) writePos - delayLen; + int i0 = (int) std::floor (rp); + const float frac = rp - (float) i0; + + i0 %= maxLen; + if (i0 < 0) + i0 += maxLen; + + int i1 = i0 + 1; + if (i1 >= maxLen) + i1 -= maxLen; + + const float y = buffer[(size_t) i0] + frac * (buffer[(size_t) i1] - buffer[(size_t) i0]); + + buffer[(size_t) writePos] = input + dampState * gain; + dampState += dampCoef * (y - dampState); + + ++writePos; + if (writePos >= maxLen) + writePos = 0; + + return dampState; + } + + std::vector buffer; + int maxLen = 0; + int writePos = 0; + float dampState = 0.0f; + }; + + struct DCBlock + { + void reset() + { + xm = 0.0f; + ym = 0.0f; + } + + float process (float x) + { + const float y = x - xm + 0.999f * ym; + xm = x; + ym = y; + return y; + } + + float xm = 0.0f; + float ym = 0.0f; + }; + + static constexpr std::array baseLen441 = { 1553.f, 1787.f, 2017.f, 2237.f, 2437.f, 2657.f, 2879.f, 3073.f }; + static constexpr std::array rightOffset = { 79.f, 67.f, 53.f, 41.f, 29.f, 23.f, 13.f, 7.f }; + + double sampleRate = 44100.0; + + juce::SmoothedValue lowCutG; + juce::SmoothedValue highCutG; + juce::SmoothedValue decayS; + juce::SmoothedValue sizeS; + juce::SmoothedValue diffS; + juce::SmoothedValue dampS; + juce::SmoothedValue feedbackS; + juce::SmoothedValue ratioS; + juce::SmoothedValue wetS; + + SVF hpL, lpL, hpR, lpR; + DCBlock dcL, dcR; + Allpass diffL1, diffL2, diffR1, diffR2; + PitchShifter shiftL, shiftR; + std::array combL; + std::array combR; + std::array baseLen; + std::array baseLenR; + + float lowCutHz = 40.0f; + float highCutHz = 14000.0f; + float decaySec = 3.5f; + float sizeVal = 1.0f; + float diffusionVal = 0.6f; + float brightnessVal = 0.75f; + float feedbackVal = 0.6f; + float pitchVal = 0.0f; + float dryWetVal = 0.35f; + + float lastWetL = 0.0f; + float lastWetR = 0.0f; +}; diff --git a/Source/HorizontLookAndFeel.cpp b/Source/HorizontLookAndFeel.cpp new file mode 100644 index 0000000..54e3557 --- /dev/null +++ b/Source/HorizontLookAndFeel.cpp @@ -0,0 +1,201 @@ +#include "HorizontLookAndFeel.h" + +namespace +{ + const juce::Colour tealAccent (0xFF3CE0C8); + const juce::Colour textMain (0xFFE8F6F4); + const juce::Colour textDim (0xFFB8CCCC); + const juce::Colour charcoal (0xFF0B0E10); +} + +HorizontLookAndFeel::HorizontLookAndFeel() +{ + setColour (juce::ComboBox::outlineColourId, juce::Colour (0xFF2A3A3D)); + setColour (juce::ComboBox::textColourId, textMain); + setColour (juce::ComboBox::arrowColourId, tealAccent); + setColour (juce::PopupMenu::backgroundColourId, juce::Colour (0xFF141B1E)); + setColour (juce::PopupMenu::highlightedBackgroundColourId, juce::Colour (0xFF1E3A3C)); + setColour (juce::PopupMenu::textColourId, textMain); + setColour (juce::TooltipWindow::textColourId, textMain); + setColour (juce::TooltipWindow::backgroundColourId, juce::Colour (0xFF122A2B)); + setColour (juce::TooltipWindow::outlineColourId, tealAccent); +} + +void HorizontLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, float rotaryEndAngle, + juce::Slider&) +{ + const auto bounds = juce::Rectangle ((float) x, (float) y, (float) width, (float) height); + const auto centre = bounds.getCentre(); + const float radius = jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f; + const float angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + + { + auto shadow = bounds.translated (0.0f, radius * 0.10f); + auto grad = juce::ColourGradient (juce::Colour (0x80000000), + centre.withY (centre.getY() + radius * 0.45f), + juce::Colour (0x00000000), + centre.withY (centre.getY() + radius * 0.45f).translated (radius * 1.1f, 0.0f), + true); + grad.addColour (0.55f, juce::Colour (0x30000000)); + g.setGradientFill (grad); + g.fillEllipse (shadow); + } + + { + auto disc = bounds.reduced (radius * 0.05f); + auto grad = juce::ColourGradient (juce::Colour (0xFFA9BBC2), + centre.translated (-radius * 0.35f, -radius * 0.45f), + juce::Colour (0xFF171B1E), + centre.translated (-radius * 0.35f, -radius * 0.45f).translated (radius * 1.25f, 0.0f), + true); + grad.addColour (0.35f, juce::Colour (0xFF6E7C84)); + grad.addColour (0.75f, juce::Colour (0xFF3A4247)); + g.setGradientFill (grad); + g.fillEllipse (disc); + } + + { + auto rim = bounds.reduced (radius * 0.03f); + g.setColour (charcoal); + g.drawEllipse (rim, jmax (1.0f, radius * 0.05f)); + } + + { + auto face = bounds.reduced (radius * 0.24f); + auto grad = juce::ColourGradient (juce::Colour (0xFF333C43), + centre.translated (0.0f, -radius * 0.10f), + juce::Colour (0xFF101316), + centre.translated (0.0f, -radius * 0.10f).translated (radius * 0.85f, 0.0f), + true); + grad.addColour (0.55f, juce::Colour (0xFF20262B)); + g.setGradientFill (grad); + g.fillEllipse (face); + + g.setColour (juce::Colour (0x383CE0C8)); + g.drawEllipse (face, 1.0f); + } + + { + const float tipDist = radius * 0.72f; + const float baseDist = radius * 0.34f; + const float halfWidth = radius * 0.085f; + + const auto tip = centre.getPointOnCircumference (tipDist, angle); + const auto base = centre.getPointOnCircumference (baseDist, angle); + const float perp = angle + juce::MathConstants::halfPi; + + const auto b1 = base.translated (std::cos (perp) * halfWidth, std::sin (perp) * halfWidth); + const auto b2 = base.translated (-std::cos (perp) * halfWidth, -std::sin (perp) * halfWidth); + + juce::Path notch; + notch.addTriangle (b1, b2, tip); + notch.closeSubPath(); + + g.setColour (tealAccent); + g.fillPath (notch); + g.setColour (tealAccent); + g.strokePath (notch, juce::PathStrokeType (1.0f)); + } + + { + auto hl = juce::Rectangle (radius * 0.9f, radius * 0.5f) + .withCentre (centre.translated (-radius * 0.28f, -radius * 0.32f)); + auto grad = juce::ColourGradient (juce::Colour (0x30FFFFFF), hl.getCentre(), + juce::Colour (0x00FFFFFF), hl.getCentre().translated (radius * 0.6f, 0.0f), + true); + g.setGradientFill (grad); + g.fillEllipse (hl); + } + + { + auto dot = juce::Rectangle (radius * 0.16f, radius * 0.16f).withCentre (centre); + auto grad = juce::ColourGradient (juce::Colour (0xFF5A666D), + centre.translated (-radius * 0.03f, -radius * 0.03f), + juce::Colour (0xFF171B1E), + centre.translated (-radius * 0.03f, -radius * 0.03f).translated (radius * 0.14f, 0.0f), + true); + g.setGradientFill (grad); + g.fillEllipse (dot); + } +} + +void HorizontLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, bool, + int buttonX, int buttonY, int buttonW, int buttonH, juce::ComboBox&) +{ + auto bounds = juce::Rectangle (0, 0, (float) width, (float) height); + auto grad = juce::ColourGradient (juce::Colour (0xFF22292E), { 0.0f, 0.0f }, + juce::Colour (0xFF0E1215), { 0.0f, (float) height }, + false); + g.setGradientFill (grad); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (juce::Colour (0xFF3A4A4D)); + g.drawRoundedRectangle (bounds.reduced (0.5f), 4.0f, 1.0f); + + auto arrowArea = juce::Rectangle ((float) buttonX, (float) buttonY, (float) buttonW, (float) buttonH); + juce::Path arrow; + arrow.startNewSubPath (arrowArea.getX() + arrowArea.getWidth() * 0.35f, arrowArea.getCentreY() - 2.0f); + arrow.lineTo (arrowArea.getCentreX(), arrowArea.getCentreY() + 3.0f); + arrow.lineTo (arrowArea.getX() + arrowArea.getWidth() * 0.65f, arrowArea.getCentreY() - 2.0f); + + g.setColour (tealAccent); + g.strokePath (arrow, juce::PathStrokeType (1.6f, + juce::PathStrokeType::JointStyle::beveled, + juce::PathStrokeType::EndCapStyle::rounded)); +} + +void HorizontLookAndFeel::drawPopupMenuBackground (juce::Graphics& g, int width, int height) +{ + g.setColour (juce::Colour (0xF0141B1E)); + g.fillRoundedRectangle (0.0f, 0.0f, (float) width, (float) height, 6.0f); + g.setColour (juce::Colour (0xFF3A4A4D)); + g.drawRoundedRectangle (0.5f, 0.5f, (float) width - 1.0f, (float) height - 1.0f, 6.0f, 1.0f); +} + +void HorizontLookAndFeel::drawPopupMenuItem (juce::Graphics& g, const juce::Rectangle& area, + bool isSeparator, bool isActive, bool isHighlighted, bool isTicked, + bool, const juce::String& text, const juce::String&, + const juce::Drawable*, const juce::Colour* textColour) +{ + if (isSeparator) + { + auto r = area.reduced (5, 0); + g.setColour (juce::Colour (0x334A5A5D)); + g.fillRect (r.removeFromBottom (1)); + return; + } + + if (isHighlighted && isActive) + { + g.setColour (juce::Colour (0xFF1E3A3C)); + g.fillRoundedRectangle (area.toFloat().reduced (2.0f), 4.0f); + } + + auto textArea = area.reduced (12, 0); + if (isTicked) + textArea.removeFromLeft (12); + + g.setColour (isActive ? (textColour != nullptr ? *textColour : textMain) : textDim); + g.setFont (juce::Font (juce::FontOptions (13.0f))); + g.drawText (text, textArea, juce::Justification::centredLeft, false); + + if (isTicked) + { + auto dot = juce::Rectangle (4.0f, 4.0f) + .withCentre (juce::Point ((float) (area.getX() + 10), (float) area.getCentreY())); + g.setColour (tealAccent); + g.fillEllipse (dot); + } +} + +void HorizontLookAndFeel::drawTooltip (juce::Graphics& g, const juce::String& text, int width, int height) +{ + g.setColour (juce::Colour (0xF0122A2B)); + g.fillRoundedRectangle (0.0f, 0.0f, (float) width, (float) height, 4.0f); + g.setColour (tealAccent); + g.drawRoundedRectangle (0.5f, 0.5f, (float) width - 1.0f, (float) height - 1.0f, 4.0f, 1.0f); + g.setColour (textMain); + g.setFont (juce::Font (juce::FontOptions (12.0f))); + g.drawText (text, juce::Rectangle (6, 0, width - 12, height), juce::Justification::centred, true); +} diff --git a/Source/HorizontLookAndFeel.h b/Source/HorizontLookAndFeel.h new file mode 100644 index 0000000..b586edf --- /dev/null +++ b/Source/HorizontLookAndFeel.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +class HorizontLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + HorizontLookAndFeel(); + + void drawRotarySlider (juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, float rotaryEndAngle, + juce::Slider&) override; + + void drawComboBox (juce::Graphics&, int width, int height, bool isButtonDown, + int buttonX, int buttonY, int buttonW, int buttonH, juce::ComboBox&) override; + + void drawPopupMenuBackground (juce::Graphics&, int width, int height) override; + + void drawPopupMenuItem (juce::Graphics&, const juce::Rectangle& area, + bool isSeparator, bool isActive, bool isHighlighted, bool isTicked, + bool hasSubMenu, const juce::String& text, const juce::String& shortcutKeyText, + const juce::Drawable* icon, const juce::Colour* textColour) override; + + void drawTooltip (juce::Graphics&, const juce::String& text, int width, int height) override; +}; diff --git a/Source/Knob.cpp b/Source/Knob.cpp new file mode 100644 index 0000000..a1d2970 --- /dev/null +++ b/Source/Knob.cpp @@ -0,0 +1,81 @@ +#include "Knob.h" + +Knob::Knob (const juce::String& title, + juce::AudioProcessorValueTreeState& apvts, + const juce::String& paramID, + float defaultValue, + std::function formatFn, + const juce::String& tooltip) + : format (std::move (formatFn)) +{ + nameLabel.setText (title, juce::dontSendNotification); + nameLabel.setJustificationType (juce::Justification::centred); + nameLabel.setFont (juce::Font (juce::FontOptions (11.0f, juce::Font::bold))); + nameLabel.setColour (juce::Label::textColourId, juce::Colour (0xFFB8CCCC)); + nameLabel.setInterceptsMouseClicks (false, false); + addAndMakeVisible (nameLabel); + + valueLabel.setJustificationType (juce::Justification::centred); + valueLabel.setFont (juce::Font (juce::FontOptions (11.0f, juce::Font::bold))); + valueLabel.setColour (juce::Label::textColourId, juce::Colour (0xFF3CE0C8)); + valueLabel.setInterceptsMouseClicks (false, false); + addAndMakeVisible (valueLabel); + + slider.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + slider.setTextBoxStyle (juce::Slider::NoTextBox, true, 0, 0); + slider.setDoubleClickReturnValue (true, defaultValue); + slider.setWantsKeyboardFocus (true); + slider.setScrollWheelEnabled (true); + slider.setTooltip (tooltip); + slider.onValueChange = [this] { updateValueLabel(); }; + addAndMakeVisible (slider); + + attachment = std::make_unique (apvts, paramID, slider); + + updateValueLabel(); +} + +void Knob::setScaleFactor (float scale) +{ + scaleFactor = scale; + resized(); +} + +void Knob::updateValueLabel() +{ + valueLabel.setText (format (slider.getValue()), juce::dontSendNotification); +} + +void Knob::paint (juce::Graphics& g) +{ + auto b = slider.getBounds().toFloat(); + auto shadow = b.translated (0.0f, b.getHeight() * 0.05f).expanded (b.getWidth() * 0.05f); + auto shadowCentre = shadow.getCentre().withY (shadow.getCentre().getY() + b.getHeight() * 0.4f); + auto grad = juce::ColourGradient (juce::Colour (0x60000000), shadowCentre, + juce::Colour (0x00000000), shadowCentre.translated (shadow.getWidth() * 0.7f, 0.0f), + true); + grad.addColour (0.55f, juce::Colour (0x20000000)); + g.setGradientFill (grad); + g.fillEllipse (shadow); +} + +void Knob::resized() +{ + auto bounds = getLocalBounds(); + const float s = scaleFactor; + + const int nameH = juce::roundToInt (18.0f * s); + const int valueH = juce::roundToInt (16.0f * s); + const int maxKnobSize = juce::roundToInt (84.0f * s); + + auto nameArea = bounds.removeFromTop (nameH); + auto valueArea = bounds.removeFromBottom (valueH); + nameLabel.setBounds (nameArea); + valueLabel.setBounds (valueArea); + + nameLabel.setFont (juce::Font (juce::FontOptions (juce::roundToInt (11.0f * s), juce::Font::bold))); + valueLabel.setFont (juce::Font (juce::FontOptions (juce::roundToInt (11.0f * s), juce::Font::bold))); + + const int knobSize = jmin (bounds.getWidth(), bounds.getHeight(), maxKnobSize); + slider.setBounds (bounds.getCentreX() - knobSize / 2, bounds.getCentreY() - knobSize / 2, knobSize, knobSize); +} diff --git a/Source/Knob.h b/Source/Knob.h new file mode 100644 index 0000000..a49e213 --- /dev/null +++ b/Source/Knob.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +class Knob : public juce::Component +{ +public: + Knob (const juce::String& title, + juce::AudioProcessorValueTreeState& apvts, + const juce::String& paramID, + float defaultValue, + std::function format, + const juce::String& tooltip); + + void paint (juce::Graphics&) override; + void resized() override; + void setScaleFactor (float scale); + +private: + void updateValueLabel(); + + std::function format; + juce::Label nameLabel; + juce::Label valueLabel; + juce::Slider slider; + std::unique_ptr attachment; + + float scaleFactor = 1.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Knob) +}; diff --git a/Source/PitchShifter.cpp b/Source/PitchShifter.cpp new file mode 100644 index 0000000..6ac897d --- /dev/null +++ b/Source/PitchShifter.cpp @@ -0,0 +1,80 @@ +#include "PitchShifter.h" + +#include + +void PitchShifter::prepare (double sampleRate) +{ + window = (float) std::max (256.0, sampleRate * 0.035); + baseDelay = window; + bufferSize = (int) (baseDelay + window + 16.0f); + buffer.assign ((size_t) bufferSize, 0.0f); + writePos = 0; + o = 0.0f; +} + +void PitchShifter::reset() +{ + std::fill (buffer.begin(), buffer.end(), 0.0f); + writePos = 0; + o = 0.0f; +} + +float PitchShifter::at (int index) const +{ + index %= bufferSize; + if (index < 0) + index += bufferSize; + + return buffer[(size_t) index]; +} + +float PitchShifter::readCubic (float pos) const +{ + const int i = (int) std::floor (pos); + const float frac = pos - (float) i; + + const float x0 = at (i - 1); + const float x1 = at (i); + const float x2 = at (i + 1); + const float x3 = at (i + 2); + + const float a0 = -0.5f * x0 + 1.5f * x1 - 1.5f * x2 + 0.5f * x3; + const float a1 = x0 - 2.5f * x1 + 2.0f * x2 - 0.5f * x3; + const float a2 = -0.5f * x0 + 0.5f * x2; + const float a3 = x1; + + return ((a0 * frac + a1) * frac + a2) * frac + a3; +} + +float PitchShifter::process (float input, float ratio) +{ + buffer[(size_t) writePos] = input; + + if (std::fabs (ratio - 1.0f) < 1e-4f) + return input; + + const float theta = juce::MathConstants::pi * o / window; + const float g1 = std::sin (theta) * std::sin (theta); + const float g2 = std::cos (theta) * std::cos (theta); + + float o2 = o + window * 0.5f; + if (o2 >= window) + o2 -= window; + + const float pos1 = (float) writePos - (baseDelay - o); + const float pos2 = (float) writePos - (baseDelay - o2); + + const float result = g1 * readCubic (pos1) + g2 * readCubic (pos2); + + o += (ratio - 1.0f); + if (o >= window) + o -= window; + else if (o < 0.0f) + o += window; + + ++writePos; + if (writePos >= bufferSize) + writePos = 0; + + return result; +} diff --git a/Source/PitchShifter.h b/Source/PitchShifter.h new file mode 100644 index 0000000..394ba8e --- /dev/null +++ b/Source/PitchShifter.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +class PitchShifter +{ +public: + void prepare (double sampleRate); + void reset(); + + float process (float input, float ratio); + +private: + float at (int index) const; + float readCubic (float pos) const; + + std::vector buffer; + int bufferSize = 0; + int writePos = 0; + float o = 0.0f; + float baseDelay = 0.0f; + float window = 0.0f; +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..7fc7371 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,349 @@ +#include "PluginEditor.h" + +#include +#include + +namespace +{ + juce::String formatHz (float v) + { + return juce::String (juce::roundToInt (v)) + " Hz"; + } + + juce::String formatSec (float v) + { + return juce::String (v, 1) + " s"; + } + + juce::String formatSize (float v) + { + return juce::String (v, 2) + "x"; + } + + juce::String formatPct (float v) + { + return juce::String (juce::roundToInt (v * 100.0f)) + " %"; + } + + juce::String formatSemis (float v) + { + const int semis = juce::roundToInt (v); + if (semis > 0) + return "+" + juce::String (semis) + " st"; + if (semis < 0) + return juce::String (semis) + " st"; + return "0 st"; + } + + juce::StringArray presetNames() + { + juce::StringArray names; + for (const auto& p : getPresets()) + names.add (p.name); + return names; + } + + constexpr float scalePresets[] = { 0.75f, 0.9f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }; + constexpr int numScalePresets = (int) (sizeof (scalePresets) / sizeof (scalePresets[0])); + + juce::StringArray scaleNames() + { + juce::StringArray names; + for (float v : scalePresets) + names.add (juce::String (juce::roundToInt (v * 100.0f)) + " %"); + return names; + } + + juce::String buildFooterText() + { +#ifndef HORIZONT_VERSION +#define HORIZONT_VERSION "0.0.0" +#endif +#ifndef HORIZONT_GIT_REVISION +#define HORIZONT_GIT_REVISION "unknown" +#endif +#ifndef HORIZONT_BUILD_TIMESTAMP +#define HORIZONT_BUILD_TIMESTAMP "unknown" +#endif + + juce::String text ("HORIZONT v" + juce::String (HORIZONT_VERSION)); + text << " · git " << HORIZONT_GIT_REVISION; + text << " · build " << HORIZONT_BUILD_TIMESTAMP; + return text; + } +} + +HorizontAudioProcessorEditor::HorizontAudioProcessorEditor (HorizontAudioProcessor& p, + juce::AudioProcessorValueTreeState& s) + : AudioProcessorEditor (&p), + processor (p), + apvts (s), + display (p) +{ + lookAndFeel = std::make_unique(); + setLookAndFeel (lookAndFeel.get()); + + titleLabel.setText ("HORIZONT", juce::dontSendNotification); + titleLabel.setFont (juce::Font (juce::FontOptions (22.0f, juce::Font::bold))); + titleLabel.setColour (juce::Label::textColourId, juce::Colour (0xFFE8F6F4)); + addAndMakeVisible (titleLabel); + + subtitleLabel.setText ("Algorithmic Spatial Processor", juce::dontSendNotification); + subtitleLabel.setFont (juce::Font (juce::FontOptions (11.0f))); + subtitleLabel.setColour (juce::Label::textColourId, juce::Colour (0xFF5E8688)); + addAndMakeVisible (subtitleLabel); + + presetBox.addItemList (presetNames(), 1); + presetBox.setSelectedItemIndex (0); + presetBox.addListener (this); + presetBox.setTooltip ("Recall a factory preset"); + addAndMakeVisible (presetBox); + + scaleBox.addItemList (scaleNames(), 1); + scaleBox.setSelectedItemIndex (2); + scaleBox.addListener (this); + scaleBox.setTooltip ("Set the UI scale"); + addAndMakeVisible (scaleBox); + + footerLabel.setText (buildFooterText(), juce::dontSendNotification); + footerLabel.setJustificationType (juce::Justification::centred); + footerLabel.setInterceptsMouseClicks (false, false); + addAndMakeVisible (footerLabel); + + addAndMakeVisible (display); + + auto makeKnob = [this] (const juce::String& title, const juce::String& id, + const juce::String& tip, std::function fmt, + float defVal) -> std::unique_ptr + { + return std::make_unique (title, apvts, id, defVal, fmt, tip); + }; + + knobs.push_back (makeKnob ("LOW CUT", "lowCut", "Input high-pass filter (20 Hz - 500 Hz)", formatHz, 40.0f)); + knobs.push_back (makeKnob ("HIGH CUT", "highCut", "Input low-pass filter (800 Hz - 20 kHz)", formatHz, 14000.0f)); + knobs.push_back (makeKnob ("DECAY", "decay", "Length of the reverb tail (RT60)", formatSec, 3.5f)); + knobs.push_back (makeKnob ("SIZE", "size", "Room size scaling", formatSize, 1.0f)); + knobs.push_back (makeKnob ("DIFFUSION", "diffusion", "Echo diffusion and smearing", formatPct, 0.6f)); + knobs.push_back (makeKnob ("BRIGHTNESS", "brightness", "High-frequency damping of the tail", formatPct, 0.75f)); + knobs.push_back (makeKnob ("FEEDBACK", "feedback", "Recirculation through the pitch shifter", formatPct, 0.6f)); + knobs.push_back (makeKnob ("PITCH", "pitch", "Pitch shift applied per feedback pass", formatSemis, 0.0f)); + knobs.push_back (makeKnob ("DRY / WET", "dryWet", "Dry / wet mix", formatPct, 0.35f)); + + for (auto& k : knobs) + addAndMakeVisible (k.get()); + + setSize (juce::roundToInt (baseWidth), juce::roundToInt (baseHeight)); + setResizeLimits (juce::roundToInt (baseWidth * 0.75f), juce::roundToInt (baseHeight * 0.75f), + juce::roundToInt (baseWidth * 2.0f), juce::roundToInt (baseHeight * 2.0f)); +} + +HorizontAudioProcessorEditor::~HorizontAudioProcessorEditor() +{ +} + +void HorizontAudioProcessorEditor::setScaleFactor (float newScale) +{ + newScale = juce::jlimit (0.6f, 2.0f, newScale); + + setSize (juce::roundToInt (baseWidth * newScale), + juce::roundToInt (baseHeight * newScale)); +} + +void HorizontAudioProcessorEditor::updateScaleFactor() +{ + const float newScale = juce::jmin ((float) getWidth() / baseWidth, + (float) getHeight() / baseHeight); + + if (juce::approximatelyEqual (scaleFactor, newScale)) + return; + + scaleFactor = newScale; + + for (auto& k : knobs) + k->setScaleFactor (scaleFactor); + + display.setScaleFactor (scaleFactor); +} + +void HorizontAudioProcessorEditor::comboBoxChanged (juce::ComboBox* box) +{ + if (box == &presetBox) + { + const int idx = presetBox.getSelectedItemIndex(); + const auto& presets = processor.getPresets(); + + if (idx >= 0 && idx < (int) presets.size()) + processor.applyPreset (presets[(size_t) idx]); + } + else if (box == &scaleBox) + { + const int idx = scaleBox.getSelectedItemIndex(); + + if (idx >= 0 && idx < numScalePresets) + setScaleFactor (scalePresets[idx]); + } +} + +void HorizontAudioProcessorEditor::updateScaleSelection() +{ + int best = 0; + float bestDiff = std::numeric_limits::max(); + + for (int i = 0; i < numScalePresets; ++i) + { + const float diff = std::abs (scaleFactor - scalePresets[i]); + if (diff < bestDiff) + { + bestDiff = diff; + best = i; + } + } + + scaleBox.setSelectedItemIndex (best, juce::dontSendNotification); +} + +void HorizontAudioProcessorEditor::layoutKnobRow (juce::Rectangle area, int startIndex, int count) +{ + juce::FlexBox fb; + fb.flexDirection = juce::FlexBox::Direction::row; + fb.justifyContent = juce::FlexBox::JustifyContent::center; + fb.alignItems = juce::FlexBox::AlignItems::stretch; + + const float minW = 80.0f * scaleFactor; + const float minH = 96.0f * scaleFactor; + + for (int i = startIndex; i < startIndex + count; ++i) + fb.items.add (juce::FlexItem (*knobs[(size_t) i]).withMinWidth (minW).withMinHeight (minH).withFlex (1.0f)); + + fb.performLayout (area); +} + +void HorizontAudioProcessorEditor::mouseWheelMove (const juce::MouseEvent&, const juce::MouseWheelDetails& wheel) +{ + if (wheel.deltaY != 0.0f) + { + const float zoomStep = 0.1f; + setScaleFactor (scaleFactor - wheel.deltaY * zoomStep); + } +} + +void HorizontAudioProcessorEditor::resized() +{ + updateScaleFactor(); + updateScaleSelection(); + + auto area = getLocalBounds(); + + const float s = scaleFactor; + const int headerH = juce::roundToInt (64.0f * s); + const int footerH = juce::roundToInt (26.0f * s); + const int presetBoxW = juce::roundToInt (180.0f * s); + const int scaleBoxW = juce::roundToInt (72.0f * s); + const int boxGap = juce::roundToInt (10.0f * s); + const int presetBoxMargin = juce::roundToInt (20.0f * s); + const int titleH = juce::roundToInt (32.0f * s); + const int titleMarginH = juce::roundToInt (18.0f * s); + const int titleMarginV = juce::roundToInt (4.0f * s); + const int subtitleMarginH = juce::roundToInt (18.0f * s); + const int subtitleMarginV = juce::roundToInt (2.0f * s); + const int rowsMarginH = juce::roundToInt (16.0f * s); + const int rowsMarginV = juce::roundToInt (4.0f * s); + const int gap = juce::roundToInt (10.0f * s); + + footerArea = area.removeFromBottom (footerH); + footerLabel.setBounds (footerArea.reduced (rowsMarginH, 0)); + footerLabel.setFont (juce::Font (juce::FontOptions (juce::roundToInt (9.0f * s)))); + footerLabel.setColour (juce::Label::textColourId, juce::Colour (0xFF5E8688)); + + auto header = area.removeFromTop (headerH); + + const int comboW = presetBoxW + boxGap + scaleBoxW; + auto comboArea = header.removeFromRight (comboW).reduced (0, presetBoxMargin); + auto scaleArea = comboArea.removeFromLeft (scaleBoxW); + scaleBox.setBounds (scaleArea); + presetBox.setBounds (comboArea); + + auto titleArea = header.removeFromTop (titleH).reduced (titleMarginH, titleMarginV); + titleLabel.setBounds (titleArea); + subtitleLabel.setBounds (header.reduced (subtitleMarginH, subtitleMarginV)); + + titleLabel.setFont (juce::Font (juce::FontOptions (juce::roundToInt (22.0f * s), juce::Font::bold))); + subtitleLabel.setFont (juce::Font (juce::FontOptions (juce::roundToInt (11.0f * s)))); + + auto modules = area.reduced (rowsMarginH, rowsMarginV); + const int panelH = (modules.getHeight() - 2 * gap) / 3; + + auto displayPanel = modules.removeFromTop (panelH); + display.setBounds (displayPanel); + + knobRow1Area = modules.removeFromTop (panelH + gap); + knobRow2Area = modules.removeFromTop (panelH + gap); + + layoutKnobRow (knobRow1Area, 0, 5); + layoutKnobRow (knobRow2Area, 5, 4); +} + +void HorizontAudioProcessorEditor::paint (juce::Graphics& g) +{ + const auto bounds = getLocalBounds(); + + g.setGradientFill (juce::ColourGradient (juce::Colour (0xFF0E2A2E), { 0.0f, 0.0f }, + juce::Colour (0xFF060B0D), { 0.0f, (float) bounds.getHeight() }, + false)); + g.fillRect (bounds); + + g.setColour (juce::Colour (0x1AFFFFFF)); + g.fillRect (0, 0, bounds.getWidth(), 1); + g.setColour (juce::Colour (0x14000000)); + g.fillRect (0, bounds.getHeight() - 1, bounds.getWidth(), 1); + + if (footerArea.getHeight() > 0) + { + g.setColour (juce::Colour (0x1AFFFFFF)); + g.fillRect (0, footerArea.getY(), bounds.getWidth(), 1); + g.setColour (juce::Colour (0x14000000)); + g.fillRect (0, footerArea.getY() + 1, bounds.getWidth(), 1); + } + + auto drawModule = [&g] (juce::Rectangle moduleArea) + { + const auto r = moduleArea.toFloat(); + + auto shRect = r.expanded (4.0f, 3.0f).translated (0.0f, 3.0f); + auto shadowGrad = juce::ColourGradient (juce::Colour (0x00000000), { 0.0f, shRect.getY() }, + juce::Colour (0x00000000), { 0.0f, shRect.getBottom() }, + false); + shadowGrad.addColour (0.45f, juce::Colour (0x70000000)); + g.setGradientFill (shadowGrad); + g.fillRoundedRectangle (shRect, 13.0f); + + g.setGradientFill (juce::ColourGradient (juce::Colour (0xFF24323A), { 0.0f, r.getY() }, + juce::Colour (0xFF0B1216), { 0.0f, r.getBottom() }, + false)); + g.fillRoundedRectangle (r, 12.0f); + + g.setColour (juce::Colour (0x2A3CE0C8)); + g.drawRoundedRectangle (r.expanded (1.0f), 13.0f, 1.0f); + + g.setColour (juce::Colour (0x38FFFFFF)); + g.drawHorizontalLine ((int) r.getY() + 1, (int) r.getX() + 3, (int) r.getRight() - 3); + g.drawVerticalLine ((int) r.getX() + 1, (int) r.getY() + 3, (int) r.getBottom() - 3); + g.setColour (juce::Colour (0x50000000)); + g.drawHorizontalLine ((int) r.getBottom() - 1, (int) r.getX() + 3, (int) r.getRight() - 3); + g.drawVerticalLine ((int) r.getRight() - 1, (int) r.getY() + 3, (int) r.getBottom() - 3); + + g.saveState(); + g.reduceClipRegion (r.toNearestInt().reduced (1)); + auto refl = juce::ColourGradient (juce::Colour (0x28FFFFFF), { 0.0f, r.getY() }, + juce::Colour (0x00FFFFFF), { 0.0f, r.getY() + r.getHeight() * 0.45f }, + false); + g.setGradientFill (refl); + g.fillRect (r); + g.setColour (juce::Colour (0x45FFFFFF)); + g.fillRect (r.getX() + 2.0f, r.getY() + 1.0f, r.getWidth() - 4.0f, 1.0f); + g.restoreState(); + }; + + drawModule (knobRow1Area); + drawModule (knobRow2Area); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..93176fe --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +#include "Knob.h" +#include "PluginProcessor.h" +#include "HorizontLookAndFeel.h" +#include "SpectrumDisplay.h" + +class HorizontAudioProcessorEditor : public juce::AudioProcessorEditor, + private juce::ComboBox::Listener +{ +public: + HorizontAudioProcessorEditor (HorizontAudioProcessor&, juce::AudioProcessorValueTreeState&); + ~HorizontAudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + void mouseWheelMove (const juce::MouseEvent&, const juce::MouseWheelDetails&) override; + + float getScaleFactor() const noexcept { return scaleFactor; } + +private: + void comboBoxChanged (juce::ComboBox* boxThatHasChanged) override; + void layoutKnobRow (juce::Rectangle area, int startIndex, int count); + void setScaleFactor (float newScale); + void updateScaleFactor(); + void updateScaleSelection(); + + HorizontAudioProcessor& processor; + juce::AudioProcessorValueTreeState& apvts; + + std::unique_ptr lookAndFeel; + juce::Label titleLabel; + juce::Label subtitleLabel; + juce::ComboBox presetBox; + juce::ComboBox scaleBox; + juce::Label footerLabel; + SpectrumDisplay display; + + std::vector> knobs; + + juce::Rectangle knobRow1Area; + juce::Rectangle knobRow2Area; + juce::Rectangle footerArea; + + float scaleFactor = 1.0f; + static constexpr float baseWidth = 860.0f; + static constexpr float baseHeight = 606.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HorizontAudioProcessorEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..22c97a8 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,314 @@ +#include "PluginProcessor.h" + +#include "PluginEditor.h" + +HorizontAudioProcessor::HorizontAudioProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)), + apvts (*this, nullptr, "Parameters", createParameterLayout()), + presets (getPresets()) +{ + lowCutParam = apvts.getRawParameterValue ("lowCut"); + highCutParam = apvts.getRawParameterValue ("highCut"); + decayParam = apvts.getRawParameterValue ("decay"); + sizeParam = apvts.getRawParameterValue ("size"); + diffusionParam = apvts.getRawParameterValue ("diffusion"); + brightnessParam = apvts.getRawParameterValue ("brightness"); + feedbackParam = apvts.getRawParameterValue ("feedback"); + pitchParam = apvts.getRawParameterValue ("pitch"); + dryWetParam = apvts.getRawParameterValue ("dryWet"); +} + +HorizontAudioProcessor::~HorizontAudioProcessor() +{ +} + +juce::AudioProcessorValueTreeState::ParameterLayout HorizontAudioProcessor::createParameterLayout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout layout; + + layout.add (std::make_unique ("lowCut", "Low Cut", + juce::NormalisableRange (20.0f, 500.0f, 0.0f, 0.35f), 40.0f)); + + layout.add (std::make_unique ("highCut", "High Cut", + juce::NormalisableRange (800.0f, 20000.0f, 0.0f, 0.25f), 14000.0f)); + + layout.add (std::make_unique ("decay", "Decay", + juce::NormalisableRange (0.2f, 10.0f, 0.0f, 0.5f), 3.5f)); + + layout.add (std::make_unique ("size", "Size", + juce::NormalisableRange (0.4f, 2.0f, 0.0f, 0.5f), 1.0f)); + + layout.add (std::make_unique ("diffusion", "Diffusion", + juce::NormalisableRange (0.0f, 1.0f, 0.0f), 0.6f)); + + layout.add (std::make_unique ("brightness", "Brightness", + juce::NormalisableRange (0.0f, 1.0f, 0.0f), 0.75f)); + + layout.add (std::make_unique ("feedback", "Feedback", + juce::NormalisableRange (0.0f, 1.0f, 0.0f), 0.6f)); + + layout.add (std::make_unique ("pitch", "Pitch", + juce::NormalisableRange (-12.0f, 12.0f, 0.0f), 0.0f)); + + layout.add (std::make_unique ("dryWet", "Dry/Wet", + juce::NormalisableRange (0.0f, 1.0f, 0.0f), 0.35f)); + + return layout; +} + +void HorizontAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + wetScratch.resize ((size_t) juce::jmax (samplesPerBlock, 1)); + dryScratch.resize ((size_t) juce::jmax (samplesPerBlock, 1)); + wetFifo.reset(); + engine.prepare (sampleRate); + updateParameters(); +} + +void HorizontAudioProcessor::releaseResources() +{ + engine.reset(); +} + +bool HorizontAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainOutputChannelSet().isDisabled()) + return false; + + const int outCh = layouts.getMainOutputChannelSet().size(); + const int inCh = layouts.getMainInputChannelSet().size(); + + if (inCh == 0) + return outCh <= 2; + + return inCh <= 2 && outCh <= 2; +} + +void HorizontAudioProcessor::updateParameters() +{ + engine.setLowCut (lowCutParam->load()); + engine.setHighCut (highCutParam->load()); + engine.setDecay (decayParam->load()); + engine.setSize (sizeParam->load()); + engine.setDiffusion (diffusionParam->load()); + engine.setBrightness (brightnessParam->load()); + engine.setFeedback (feedbackParam->load()); + engine.setPitch (pitchParam->load()); + engine.setDryWet (dryWetParam->load()); +} + +void HorizontAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + + const int numSamples = buffer.getNumSamples(); + if (numSamples == 0) + return; + + const int numOut = juce::jmin (buffer.getNumChannels(), 2); + if (numOut == 0) + return; + + for (int ch = numOut; ch < buffer.getNumChannels(); ++ch) + buffer.clear (ch, 0, numSamples); + + updateParameters(); + + const int inputChannels = getChannelCountOfBus (true, 0); + const bool haveLeft = buffer.getNumChannels() > 0 && inputChannels > 0; + const bool haveRight = buffer.getNumChannels() > 1 && inputChannels > 1; + const bool canCaptureWet = wetScratch.size() >= (size_t) numSamples; + + for (int s = 0; s < numSamples; ++s) + { + const float inL = haveLeft ? buffer.getSample (0, s) : 0.0f; + const float inR = haveRight ? buffer.getSample (1, s) : inL; + + float outL = 0.0f; + float outR = 0.0f; + engine.processSample (inL, inR, outL, outR); + + buffer.setSample (0, s, outL); + if (numOut > 1) + buffer.setSample (1, s, outR); + + if (canCaptureWet) + { + wetScratch[(size_t) s] = engine.getLastWetL(); + dryScratch[(size_t) s] = inL; + } + } + + if (canCaptureWet) + { + pushWetSamples (wetScratch.data(), numSamples); + appendDrySamples (dryScratch.data(), numSamples); + } +} + +void HorizontAudioProcessor::pushWetSamples (const float* src, int numToWrite) +{ + if (numToWrite <= 0) + return; + + numToWrite = juce::jmin (numToWrite, wetFifo.getFreeSpace()); + if (numToWrite <= 0) + return; + + int i = 0; + auto handle = wetFifo.write (numToWrite); + handle.forEach ([&] (int idx) { wetRing.setSample (0, idx, src[i++]); }); +} + +int HorizontAudioProcessor::readWetSamples (float* dest, int numToRead) +{ + const int avail = wetFifo.getNumReady(); + if (avail <= 0) + return 0; + + numToRead = juce::jmin (numToRead, avail); + + int i = 0; + auto handle = wetFifo.read (numToRead); + handle.forEach ([&] (int idx) { dest[i++] = wetRing.getSample (0, idx); }); + + return numToRead; +} + +void HorizontAudioProcessor::appendDrySamples (const float* src, int numToWrite) +{ + if (numToWrite <= 0) + return; + + size_t w = dryWritePos.load (std::memory_order_relaxed); + for (int i = 0; i < numToWrite; ++i) + { + dryRingLive[w] = src[i]; + w = (w + 1) % (size_t) dryRingSize; + } + dryWritePos.store (w, std::memory_order_release); + dryTotalWritten.fetch_add ((size_t) numToWrite, std::memory_order_release); +} + +int HorizontAudioProcessor::copyRecentDrySamples (float* dest, int numToRead) +{ + numToRead = juce::jmin (numToRead, dryRingSize); + + const size_t written = dryTotalWritten.load (std::memory_order_acquire); + const size_t wp = dryWritePos.load (std::memory_order_acquire); + + if (written < (size_t) numToRead) + { + const int avail = (int) written; + const int lead = numToRead - avail; + std::fill (dest, dest + lead, 0.0f); + for (int i = 0; i < avail; ++i) + { + const size_t idx = (wp + (size_t) dryRingSize - (size_t) avail + (size_t) i) % (size_t) dryRingSize; + dest[lead + i] = dryRingLive[idx]; + } + return numToRead; + } + + for (int i = 0; i < numToRead; ++i) + { + const size_t idx = (wp + (size_t) dryRingSize - (size_t) numToRead + (size_t) i) % (size_t) dryRingSize; + dest[i] = dryRingLive[idx]; + } + + return numToRead; +} + +juce::AudioProcessorEditor* HorizontAudioProcessor::createEditor() +{ + return new HorizontAudioProcessorEditor (*this, apvts); +} + +bool HorizontAudioProcessor::hasEditor() const +{ + return true; +} + +const juce::String HorizontAudioProcessor::getName() const +{ + return "Horizont"; +} + +bool HorizontAudioProcessor::acceptsMidi() const +{ + return false; +} + +bool HorizontAudioProcessor::producesMidi() const +{ + return false; +} + +double HorizontAudioProcessor::getTailLengthSeconds() const +{ + return 10.0; +} + +int HorizontAudioProcessor::getNumPrograms() +{ + return (int) presets.size(); +} + +int HorizontAudioProcessor::getCurrentProgram() +{ + return currentProgram; +} + +void HorizontAudioProcessor::setCurrentProgram (int index) +{ + if (index >= 0 && index < (int) presets.size()) + { + currentProgram = index; + applyPreset (presets[(size_t) index]); + } +} + +const juce::String HorizontAudioProcessor::getProgramName (int index) +{ + if (index >= 0 && index < (int) presets.size()) + return presets[(size_t) index].name; + + return "Default"; +} + +void HorizontAudioProcessor::changeProgramName (int, const juce::String&) +{ +} + +void HorizontAudioProcessor::applyPreset (const Preset& preset) +{ + for (const auto& v : preset.values) + { + if (auto* param = apvts.getParameter (v.id)) + param->setValueNotifyingHost (param->convertTo0to1 (v.value)); + } +} + +void HorizontAudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void HorizontAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); + + if (xml != nullptr) + apvts.replaceState (juce::ValueTree::fromXml (*xml)); + + updateParameters(); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new HorizontAudioProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..4dbdbad --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +#include + +#include "Presets.h" +#include "HorizontEngine.h" + +class HorizontAudioProcessor : public juce::AudioProcessor +{ +public: + HorizontAudioProcessor(); + ~HorizontAudioProcessor() override; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + bool acceptsMidi() const override; + bool producesMidi() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int index) override; + const juce::String getProgramName (int index) override; + void changeProgramName (int index, const juce::String& newName) override; + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + + void applyPreset (const Preset& preset); + const std::vector& getPresets() const noexcept { return presets; } + + void pushWetSamples (const float* data, int numToWrite); + int readWetSamples (float* dest, int numToRead); + + void appendDrySamples (const float* data, int numToWrite); + int copyRecentDrySamples (float* dest, int numToRead); + + juce::AudioProcessorValueTreeState apvts; + +private: + void updateParameters(); + + HorizontEngine engine; + std::vector presets; + int currentProgram = 0; + + juce::AbstractFifo wetFifo { 2048 }; + juce::AudioBuffer wetRing { 1, 2048 }; + std::vector wetScratch; + + std::vector dryScratch; + static constexpr int dryRingSize = 4096; + std::vector dryRingLive = std::vector (dryRingSize, 0.0f); + std::atomic dryWritePos { 0 }; + std::atomic dryTotalWritten { 0 }; + + std::atomic* lowCutParam = nullptr; + std::atomic* highCutParam = nullptr; + std::atomic* decayParam = nullptr; + std::atomic* sizeParam = nullptr; + std::atomic* diffusionParam = nullptr; + std::atomic* brightnessParam = nullptr; + std::atomic* feedbackParam = nullptr; + std::atomic* pitchParam = nullptr; + std::atomic* dryWetParam = nullptr; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (HorizontAudioProcessor) +}; diff --git a/Source/Presets.h b/Source/Presets.h new file mode 100644 index 0000000..de3fda5 --- /dev/null +++ b/Source/Presets.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +struct Preset +{ + const char* name; + + struct ParamValue + { + const char* id; + float value; + }; + + std::vector values; +}; + +inline const std::vector& getPresets() +{ + static const std::vector presets = + { + { "Studio Plate", + { { "lowCut", 30.0f }, { "highCut", 16000.0f }, { "decay", 2.5f }, + { "size", 0.75f }, { "diffusion", 0.70f }, { "brightness", 0.80f }, + { "feedback", 0.25f }, { "pitch", 0.0f }, { "dryWet", 0.40f } } }, + + { "Warm Hall", + { { "lowCut", 60.0f }, { "highCut", 8000.0f }, { "decay", 4.5f }, + { "size", 1.25f }, { "diffusion", 0.60f }, { "brightness", 0.45f }, + { "feedback", 0.50f }, { "pitch", 0.0f }, { "dryWet", 0.45f } } }, + + { "Big Cathedral", + { { "lowCut", 40.0f }, { "highCut", 12000.0f }, { "decay", 8.5f }, + { "size", 1.9f }, { "diffusion", 0.75f }, { "brightness", 0.60f }, + { "feedback", 0.70f }, { "pitch", 0.0f }, { "dryWet", 0.55f } } }, + + { "Shimmer Space", + { { "lowCut", 45.0f }, { "highCut", 15000.0f }, { "decay", 6.0f }, + { "size", 1.5f }, { "diffusion", 0.65f }, { "brightness", 0.70f }, + { "feedback", 0.90f }, { "pitch", 7.0f }, { "dryWet", 0.60f } } }, + + { "Dark Pulse", + { { "lowCut", 80.0f }, { "highCut", 7000.0f }, { "decay", 3.0f }, + { "size", 0.9f }, { "diffusion", 0.50f }, { "brightness", 0.35f }, + { "feedback", 0.85f }, { "pitch", -5.0f }, { "dryWet", 0.50f } } }, + + { "Tight Room", + { { "lowCut", 90.0f }, { "highCut", 16000.0f }, { "decay", 0.6f }, + { "size", 0.5f }, { "diffusion", 0.55f }, { "brightness", 0.85f }, + { "feedback", 0.20f }, { "pitch", 0.0f }, { "dryWet", 0.30f } } } + }; + + return presets; +} diff --git a/Source/SpectrumDisplay.cpp b/Source/SpectrumDisplay.cpp new file mode 100644 index 0000000..bce5032 --- /dev/null +++ b/Source/SpectrumDisplay.cpp @@ -0,0 +1,273 @@ +#include "SpectrumDisplay.h" + +namespace +{ + constexpr float minDb = -66.0f; + constexpr float topDb = -6.0f; + constexpr float dbRange = topDb - minDb; + + const juce::Colour wetLineCol (0xDD9DF5E9); + const juce::Colour wetFillCol (0xE63CE0C8); + const juce::Colour wetPeakCol (0x883CE0C8); + const juce::Colour dryLineCol (0xCCF0A03C); + const juce::Colour dryFillCol (0x22F0A03C); + const juce::Colour dryPeakCol (0x55E89B3A); + + juce::String formatFreq (float hz) + { + if (hz >= 1000.0f) + return juce::String (juce::roundToInt (hz / 1000.0f)) + "k"; + return juce::String (juce::roundToInt (hz)); + } +} + +SpectrumDisplay::SpectrumDisplay (HorizontAudioProcessor& p) + : processor (p) +{ + window.resize ((size_t) fftSize); + + for (int i = 0; i < fftSize; ++i) + window[(size_t) i] = 0.5f * (1.0f - std::cos (2.0f * juce::MathConstants::pi * (float) i / (float) (fftSize - 1))); + + startTimerHz (30); +} + +SpectrumDisplay::~SpectrumDisplay() +{ + stopTimer(); +} + +void SpectrumDisplay::setScaleFactor (float scale) +{ + scaleFactor = juce::jlimit (0.6f, 2.0f, scale); + repaint(); +} + +void SpectrumDisplay::computeLevels (const float* samples, int got, std::array& out) +{ + for (auto& l : out) + l = -200.0f; + + if (got <= 0) + return; + + for (int i = 0; i < got; ++i) + fftData[(size_t) i] = samples[i] * window[(size_t) i]; + for (int i = got; i < fftSize; ++i) + fftData[(size_t) i] = 0.0f; + + std::fill (fftData.begin() + fftSize, fftData.end(), 0.0f); + fft.performRealOnlyForwardTransform (fftData.data(), true); + + const float logNyquist = std::log ((float) (fftSize / 2)); + const float binScale = 2.0f / (float) fftSize; + + for (int i = 0; i < numBins; ++i) + { + const int kLo = jmax (1, (int) std::floor (std::exp (logNyquist * (float) i / (float) numBins))); + const int kHi = jmin (fftSize / 2 - 1, (int) std::ceil (std::exp (logNyquist * (float) (i + 1) / (float) numBins))); + + for (int k = kLo; k <= kHi; ++k) + { + const float re = fftData[(size_t) (2 * k)]; + const float im = fftData[(size_t) (2 * k + 1)]; + const float mag = std::sqrt (re * re + im * im) * binScale; + const float db = 20.0f * std::log10 (mag + 1e-6f); + + out[(size_t) i] = jmax (out[(size_t) i], db); + } + } +} + +void SpectrumDisplay::timerCallback() +{ + juce::AudioBuffer wetBuf (1, fftSize); + juce::AudioBuffer dryBuf (1, fftSize); + const int got = processor.readWetSamples (wetBuf.getWritePointer (0), fftSize); + processor.copyRecentDrySamples (dryBuf.getWritePointer (0), fftSize); + + computeLevels (wetBuf.getReadPointer (0), got, level); + computeLevels (dryBuf.getReadPointer (0), fftSize, dryLevel); + + for (int i = 0; i < numBins; ++i) + { + const int lo = jmax (0, i - 1); + const int hi = jmin (numBins - 1, i + 1); + + const float wetSmooth = 0.25f * level[(size_t) lo] + 0.5f * level[(size_t) i] + 0.25f * level[(size_t) hi]; + const float wetNorm = (juce::jlimit (minDb, topDb, wetSmooth) - minDb) / dbRange; + float& wd = display[(size_t) i]; + wd += (wetNorm - wd) * (wetNorm > wd ? 0.45f : 0.15f); + peak[(size_t) i] = jmax (peak[(size_t) i] * 0.93f, display[(size_t) i]); + + const float drySmooth = 0.25f * dryLevel[(size_t) lo] + 0.5f * dryLevel[(size_t) i] + 0.25f * dryLevel[(size_t) hi]; + const float dryNorm = (juce::jlimit (minDb, topDb, drySmooth) - minDb) / dbRange; + float& dd = dryDisplay[(size_t) i]; + dd += (dryNorm - dd) * (dryNorm > dd ? 0.45f : 0.15f); + dryPeak[(size_t) i] = jmax (dryPeak[(size_t) i] * 0.93f, dryDisplay[(size_t) i]); + } + + repaint(); +} + +juce::Path SpectrumDisplay::makeCurve (const juce::Rectangle& graph, const std::array& data) const +{ + juce::Path p; + for (int i = 0; i < numBins; ++i) + { + const float x = graph.getX() + graph.getWidth() * (float) (i + 0.5f) / (float) numBins; + const float y = graph.getBottom() - data[(size_t) i] * graph.getHeight(); + if (i == 0) + p.startNewSubPath (x, y); + else + p.lineTo (x, y); + } + return p; +} +void SpectrumDisplay::paint (juce::Graphics& g) +{ + const auto bounds = getLocalBounds().toFloat(); + const float s = scaleFactor; + + auto shRect = bounds.expanded (6.0f * s, 4.0f * s).translated (0.0f, 5.0f * s); + auto shadowGrad = juce::ColourGradient (juce::Colour (0x00000000), { 0.0f, shRect.getY() }, + juce::Colour (0x00000000), { 0.0f, shRect.getBottom() }, + false); + shadowGrad.addColour (0.45f, juce::Colour (0x70000000)); + g.setGradientFill (shadowGrad); + g.fillRoundedRectangle (shRect, 14.0f * s); + + auto bodyGrad = juce::ColourGradient (juce::Colour (0xFF24323A), { 0.0f, bounds.getY() }, + juce::Colour (0xFF0B1216), { 0.0f, bounds.getBottom() }, + false); + g.setGradientFill (bodyGrad); + g.fillRoundedRectangle (bounds, 12.0f * s); + + g.setColour (juce::Colour (0x3A3CE0C8)); + g.drawRoundedRectangle (bounds.expanded (1.0f * s), 13.0f * s, 1.0f * s); + + g.setColour (juce::Colour (0x38FFFFFF)); + g.drawHorizontalLine (juce::roundToInt (bounds.getY() + 1.0f * s), juce::roundToInt (bounds.getX() + 3.0f * s), juce::roundToInt (bounds.getRight() - 3.0f * s)); + g.drawVerticalLine (juce::roundToInt (bounds.getX() + 1.0f * s), juce::roundToInt (bounds.getY() + 3.0f * s), juce::roundToInt (bounds.getBottom() - 3.0f * s)); + g.setColour (juce::Colour (0x50000000)); + g.drawHorizontalLine (juce::roundToInt (bounds.getBottom() - 1.0f * s), juce::roundToInt (bounds.getX() + 3.0f * s), juce::roundToInt (bounds.getRight() - 3.0f * s)); + g.drawVerticalLine (juce::roundToInt (bounds.getRight() - 1.0f * s), juce::roundToInt (bounds.getY() + 3.0f * s), juce::roundToInt (bounds.getBottom() - 3.0f * s)); + + g.saveState(); + g.reduceClipRegion (bounds.toNearestInt().reduced (juce::roundToInt (1.0f * s))); + auto refl = juce::ColourGradient (juce::Colour (0x28FFFFFF), { 0.0f, bounds.getY() }, + juce::Colour (0x00FFFFFF), { 0.0f, bounds.getY() + bounds.getHeight() * 0.45f }, + false); + g.setGradientFill (refl); + g.fillRect (bounds); + g.setColour (juce::Colour (0x45FFFFFF)); + g.fillRect (bounds.getX() + 2.0f * s, bounds.getY() + 1.0f * s, bounds.getWidth() - 4.0f * s, 1.0f * s); + g.restoreState(); + + const auto area = bounds.reduced (14.0f * s, 8.0f * s); + + const float dbAxisW = 30.0f * s; + const float graphAreaBottom = area.getBottom() - 16.0f * s; + const float graphH = graphAreaBottom - area.getY(); + const auto graph = area.withHeight (graphH).withLeft (area.getX() + dbAxisW); + + g.setFont (juce::Font (juce::FontOptions (9.0f * s))); + + for (int i = 0; i < 4; ++i) + { + const float db = -6.0f - (float) i * 15.0f; + const float y = graph.getBottom() - ((db - minDb) / dbRange) * graph.getHeight(); + g.setColour (juce::Colour (0x223A4A4D)); + g.drawHorizontalLine (juce::roundToInt (y), juce::roundToInt (graph.getX()), juce::roundToInt (graph.getRight())); + g.setColour (juce::Colour (0x55FFFFFF)); + g.drawText (juce::String (juce::roundToInt (db)), juce::Rectangle (graph.getX() - dbAxisW, y - 6.0f * s, dbAxisW - 6.0f * s, 12.0f * s), + juce::Justification::right, false); + } + + const float sr = juce::jmax (1000.0f, (float) processor.getSampleRate()); + const float logNyquist = std::log ((float) (fftSize / 2)); + + const float freqs[] = { 50.0f, 100.0f, 200.0f, 500.0f, 1000.0f, 2000.0f, 5000.0f, 10000.0f, 20000.0f }; + for (float f : freqs) + { + const float k = f * (float) fftSize / sr; + if (k < 1.0f || k > (float) (fftSize / 2)) + continue; + + const float x = graph.getX() + (std::log (k) / logNyquist) * graph.getWidth(); + g.setColour (juce::Colour (0x223A4A4D)); + g.drawVerticalLine (juce::roundToInt (x), juce::roundToInt (graph.getY()), juce::roundToInt (graph.getBottom())); + g.setColour (juce::Colour (0x55FFFFFF)); + g.drawText (formatFreq (f), juce::Rectangle (x - 14.0f * s, graph.getBottom() + 3.0f * s, 28.0f * s, 12.0f * s), + juce::Justification::centred, false); + } + + g.setColour (juce::Colour (0x33FFFFFF)); + g.drawRect (graph, 0.6f * s); + + if (graph.getWidth() > 2.0f * s && graph.getHeight() > 2.0f * s) + { + auto curve = makeCurve (graph, display); + auto dry = makeCurve (graph, dryDisplay); + + auto wetFill = juce::Path (curve); + wetFill.lineTo (graph.getRight(), graph.getBottom()); + wetFill.lineTo (graph.getX(), graph.getBottom()); + wetFill.closeSubPath(); + + auto dryFill = juce::Path (dry); + dryFill.lineTo (graph.getRight(), graph.getBottom()); + dryFill.lineTo (graph.getX(), graph.getBottom()); + dryFill.closeSubPath(); + + auto wetGrad = juce::ColourGradient (wetFillCol, { 0.0f, graph.getY() }, + juce::Colour (0x063CE0C8), { 0.0f, graph.getBottom() }, + false); + g.setGradientFill (wetGrad); + g.fillPath (wetFill); + + auto dryGrad = juce::ColourGradient (dryFillCol, { 0.0f, graph.getY() }, + juce::Colour (0x00FFFFFF), { 0.0f, graph.getBottom() }, + false); + g.setGradientFill (dryGrad); + g.fillPath (dryFill); + + g.setColour (wetPeakCol); + g.strokePath (makeCurve (graph, peak), juce::PathStrokeType (1.0f * s)); + + g.setColour (dryLineCol); + g.strokePath (dry, juce::PathStrokeType (1.2f * s, + juce::PathStrokeType::JointStyle::curved, + juce::PathStrokeType::EndCapStyle::rounded)); + + g.setColour (dryPeakCol); + g.strokePath (makeCurve (graph, dryPeak), juce::PathStrokeType (0.8f * s)); + + g.setColour (wetLineCol); + g.strokePath (curve, juce::PathStrokeType (1.4f * s, + juce::PathStrokeType::JointStyle::curved, + juce::PathStrokeType::EndCapStyle::rounded)); + } + + g.setColour (juce::Colour (0x66FFFFFF)); + g.setFont (juce::Font (juce::FontOptions (10.0f * s, juce::Font::bold))); + g.drawText ("REVERB SPECTRUM", graph.withTop (graph.getY() - 2.0f * s).withBottom (graph.getY() + 14.0f * s), + juce::Justification::bottomLeft, false); + + const float legendY = graph.getY() + 4.0f * s; + const float legendX = graph.getRight() - 2.0f * s; + const auto legendRow = juce::Rectangle (legendX - 84.0f * s, legendY - 9.0f * s, 84.0f * s, 12.0f * s); + + g.setFont (juce::Font (juce::FontOptions (9.0f * s))); + g.setColour (wetPeakCol); + g.drawHorizontalLine (juce::roundToInt (legendRow.getY() + 8.0f * s), juce::roundToInt (legendRow.getX()), juce::roundToInt (legendRow.getX() + 11.0f * s)); + g.setColour (juce::Colour (0x88FFFFFF)); + g.drawText ("WET", legendRow.withX (legendRow.getX() + 14.0f * s).withWidth (28.0f * s), + juce::Justification::left, false); + + g.setColour (dryPeakCol); + g.drawHorizontalLine (juce::roundToInt (legendRow.getY() + 8.0f * s), juce::roundToInt (legendRow.getX() + 42.0f * s), juce::roundToInt (legendRow.getX() + 53.0f * s)); + g.setColour (juce::Colour (0x88FFFFFF)); + g.drawText ("DRY", legendRow.withX (legendRow.getX() + 56.0f * s).withWidth (28.0f * s), + juce::Justification::left, false); +} diff --git a/Source/SpectrumDisplay.h b/Source/SpectrumDisplay.h new file mode 100644 index 0000000..2d74182 --- /dev/null +++ b/Source/SpectrumDisplay.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include "PluginProcessor.h" + +class SpectrumDisplay : public juce::Component, private juce::Timer +{ +public: + explicit SpectrumDisplay (HorizontAudioProcessor&); + ~SpectrumDisplay() override; + + void paint (juce::Graphics&) override; + void setScaleFactor (float scale); + +private: + void timerCallback() override; + + HorizontAudioProcessor& processor; + + static constexpr int fftOrder = 10; + static constexpr int fftSize = 1 << fftOrder; + static constexpr int numBins = 48; + + void computeLevels (const float* samples, int got, std::array& out); + juce::Path makeCurve (const juce::Rectangle& graph, const std::array& data) const; + + juce::dsp::FFT fft { fftOrder }; + std::vector fftData = std::vector (2 * fftSize, 0.0f); + std::vector window; + std::array level {}; + std::array display {}; + std::array peak {}; + std::array dryLevel {}; + std::array dryDisplay {}; + std::array dryPeak {}; + + float scaleFactor = 1.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SpectrumDisplay) +};