commit 13863f4522308db46924b8f6202fb7ee4260fa10 Author: Gelyk Date: Sat Aug 29 13:17:11 2026 +0200 Initial Gelyk EQ plugin diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..cca049b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,121 @@ +cmake_minimum_required(VERSION 3.15) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +project(GelykEQ VERSION 1.0.0 LANGUAGES CXX) + +if(NOT DEFINED JUCE_ROOT) + include(FetchContent) + set(FETCHCONTENT_QUIET OFF) + FetchContent_Declare( + JUCE + GIT_REPOSITORY https://github.com/juce-framework/JUCE.git + GIT_TAG 8.0.7 + ) + FetchContent_MakeAvailable(JUCE) +else() + add_subdirectory(${JUCE_ROOT} JUCE) +endif() + +juce_add_plugin(GelykEQ + VENDOR_NAME "Gelyk Audio" + VENDOR_WEBSITE "https://gelyk.example" + VENDOR_EMAIL "dev@gelyk.example" + VENDOR_VERSION_CODE 1 + PLUGIN_MANUFACTURER_CODE Gely + PLUGIN_CODE GkEQ + FORMATS AU VST3 + PRODUCT_NAME "Gelyk EQ" + IS_SYNTH FALSE + IS_MIDI_EFFECT FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE + COPY_PLUGIN_AFTER_BUILD TRUE + INSTALL_AU TRUE + INSTALL_VST3 TRUE + BUNDLE_ID com.gelyk.eq + JucePlugin_Name "Gelyk EQ" + JucePlugin_Desc "A minimalist cyber/neon multiband equalizer" +) + +juce_generate_juce_header(GelykEQ) + +# --- Generate Git build info header ------------------------------------- +find_package(Git QUIET) +set(GELYK_GIT_BRANCH "unknown") +set(GELYK_GIT_SHA "unknown") +set(GELYK_GIT_DESCRIBE "no-git") +set(GELYK_GIT_DIRTY "") +if(Git_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GELYK_GIT_BRANCH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + execute_process( + COMMAND ${GIT_EXECUTABLE} describe --always --tags --dirty + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GELYK_GIT_DESCRIBE + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GELYK_GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + execute_process( + COMMAND ${GIT_EXECUTABLE} status --porcelain + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GELYK_GIT_DIRTY + ERROR_QUIET) + if(NOT GELYK_GIT_DIRTY STREQUAL "") + set(GELYK_GIT_DIRTY "dirty") + else() + set(GELYK_GIT_DIRTY "") + endif() +endif() + +string(TIMESTAMP GELYK_BUILD_DATE "%Y-%m-%d %H:%M") + +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/Source/GitInfo.h.in + ${CMAKE_CURRENT_BINARY_DIR}/Source/GitInfo.h + @ONLY +) + +target_sources(GelykEQ + PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/EqualizerDSP.cpp +) + +target_include_directories(GelykEQ + PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/Source +) + +target_compile_definitions(GelykEQ PRIVATE + JucePlugin_Build_VST=1 + JucePlugin_Build_AU=1 + JucePlugin_Build_AAX=0 + JucePlugin_Build_Standalone=0 +) + +target_compile_options(GelykEQ PRIVATE + $<$:-Wall -Wextra> +) + +option(GELYK_BUILD_STANDALONE "Build a standalone audio app target too" OFF) +if(GELYK_BUILD_STANDALONE) + target_sources(GelykEQ PRIVATE Source/Standalone.cpp) + add_executable(GelykEQStandalone ${CMAKE_CURRENT_BINARY_DIR}/JuceLibraryCode/Standalone.cpp) + target_link_libraries(GelykEQStandalone PRIVATE GelykEQ_impl juce_recommended_config_flags) +endif() + +set_target_properties(GelykEQ PROPERTIES + OUTPUT_NAME GelykEQ +) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..285abd3 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +BUILD_DIR := build + +all: build + +build: cmake-configure + cmake --build $(BUILD_DIR) --config Release -j$(shell sysctl -n hw.ncpu) + +cmake-configure: + cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release + +install: build + cmake --install $(BUILD_DIR) + +# Install directly to the standard macOS user plugin folders (AU + VST3). +install-local: build + cmake --install $(BUILD_DIR) --prefix "$(HOME)" + +clean: + rm -rf $(BUILD_DIR) + +.PHONY: all build cmake-configure install install-local clean diff --git a/Source/EqualizerDSP.cpp b/Source/EqualizerDSP.cpp new file mode 100644 index 0000000..e2e6e1e --- /dev/null +++ b/Source/EqualizerDSP.cpp @@ -0,0 +1,203 @@ +#include "EqualizerDSP.h" + +namespace gelyk +{ + +// --------------------------------------------------------------------------- +// FilterBand +// --------------------------------------------------------------------------- + +void FilterBand::prepare(double sampleRate, int blockSize) +{ + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = (juce::uint32) blockSize; + spec.numChannels = 2; + + left.prepare(spec); + right.prepare(spec); + + Coeffs::Ptr c; + const float db = juce::jlimit(-24.0f, 24.0f, gainDb); + switch (type) + { + case Type::lowShelf: + c = new Coeffs(Coeffs::makeLowShelf(sampleRate, freq, q, juce::Decibels::decibelsToGain(db))); + break; + case Type::highShelf: + c = new Coeffs(Coeffs::makeHighShelf(sampleRate, freq, q, juce::Decibels::decibelsToGain(db))); + break; + case Type::peak: + default: + c = new Coeffs(Coeffs::makePeakFilter(sampleRate, freq, q, juce::Decibels::decibelsToGain(db))); + break; + } + + left.coefficients = c; + right.coefficients = c; +} + +void FilterBand::reset() +{ + left.reset(); + right.reset(); +} + +void FilterBand::process(juce::dsp::AudioBlock& block) +{ + if (block.getNumChannels() < 2) + return; + + // Smooth the coefficients to avoid zipper noise, then process. + left.snapToZero(); + right.snapToZero(); + left.process(juce::dsp::ProcessContextReplacing(block)); + right.process(juce::dsp::ProcessContextReplacing(block)); +} + +// --------------------------------------------------------------------------- +// SpectrumAnalyser +// --------------------------------------------------------------------------- + +SpectrumAnalyser::SpectrumAnalyser() +{ + // The timer drives periodic background analysis. + startTimerHz(30); +} + +void SpectrumAnalyser::prepare(double sr, int maxBlockSize) +{ + const int size = 4096; + sampleRate = sr; + ring.setSize(2, size); + ring.clear(); + ringFill = 0; + ringChannels = 2; +} + +void SpectrumAnalyser::push(const float* const* channels, int numChannels, int numSamples) +{ + if (!enabled.load() || ring.getNumSamples() == 0) + return; + + const int n = (int) ring.getNumSamples(); + const int c = juce::jmin(ringChannels, numChannels); + + for (int i = 0; i < numSamples; ++i) + { + const int dst = (ringFill + i) % n; + for (int ch = 0; ch < c; ++ch) + ring.setSample(ch, dst, channels[ch][i]); + } + ringFill = (ringFill + numSamples) % n; +} + +void SpectrumAnalyser::startBackground() +{ + startTimerHz(30); +} + +void SpectrumAnalyser::stopBackground() +{ + stopTimer(); +} + +void SpectrumAnalyser::timerCallback() +{ + if (!enabled.load()) + return; + runAnalysis(); +} + +void SpectrumAnalyser::runAnalysis() +{ + const int n = (int) ring.getNumSamples(); + if (n == 0) + return; + + juce::AudioBuffer block(1, n); + // Mesa of the two channels and window with a Hann. + block.copyFrom(0, 0, ring, 0, 0, n); + if (ringChannels > 1) + block.addFrom(0, 0, ring, 1, 0, n); + + juce::dsp::FFT fft(12); // 4096 point + juce::HeapBlock> fftData; + fftData.calloc((size_t) n); + for (int i = 0; i < n; ++i) + { + float v = block.getSample(0, i) * 0.5f; + const float win = 0.5f * (1.0f - std::cos(2.0f * juce::MathConstants::pi * i / (n - 1))); + fftData[i] = std::complex(v * win, 0.0f); + } + fft.perform((juce::dsp::Complex*) fftData.getData(), false); + + const int numBins = n / 2; + juce::Array mags; + mags.ensureStorageAllocated(numBins); + for (int i = 1; i < numBins; ++i) + { + float re = fftData[i].real(); + float im = fftData[i].imag(); + float mag = std::sqrt(re * re + im * im); + float db = 20.0f * std::log10(mag + 1.0e-9f); + mags.add(db); + } + + { + juce::ScopedLock sl(spectrumLock); + readySpectrum = mags; + } +} + +int SpectrumAnalyser::copySpectrum(juce::Array& dest, juce::Array& freqBins, + int maxBins) const +{ + juce::Array snapshot; + { + juce::ScopedLock sl(spectrumLock); + snapshot = readySpectrum; + } + + if (snapshot.size() == 0) + { + dest.clear(); + freqBins.clear(); + return 0; + } + + // Log-spaced aggregation from ~20 Hz to 20 kHz. + const float fMin = 20.0f; + const float fMax = 20000.0f; + const double nfft = (double)(snapshot.size() * 2); + const float binFreq = (float)(sampleRate / nfft); + + dest.clear(); + freqBins.clear(); + + const int bands = juce::jmin(maxBins, snapshot.size()); + for (int b = 0; b < bands; ++b) + { + const float f = fMin * std::pow(fMax / fMin, (float) b / (float) (bands - 1)); + freqBins.add(f); + const int startBin = juce::jmax(1, (int) std::floor(f / binFreq)); + const int endBin = juce::jmin(snapshot.size() - 1, (int) std::ceil((f * 1.5f) / binFreq)); + float total = 0.0f; + int count = 0; + for (int i = startBin; i <= endBin; ++i) + { + total += snapshot[i]; + ++count; + } + dest.add(count > 0 ? total / count : -96.0f); + } + + return dest.size(); +} + +void SpectrumAnalyser::setEnabled(bool e) +{ + enabled.store(e); +} + +} // namespace gelyk diff --git a/Source/EqualizerDSP.h b/Source/EqualizerDSP.h new file mode 100644 index 0000000..317cae8 --- /dev/null +++ b/Source/EqualizerDSP.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +namespace gelyk +{ + +// A single peaking/shelf filter band with smoothed coefficients. +struct FilterBand +{ + enum class Type + { + lowShelf, + peak, + highShelf + }; + + Type type = Type::peak; + float gainDb = 0.0f; // target gain in dB + float freq = 1000.0f; // centre/corner frequency + float q = 1.0f; + + void prepare(double sampleRate, int blockSize); + void reset(); + void process(juce::dsp::AudioBlock& block); + + using Filter = juce::dsp::IIR::Filter; + using Coeffs = juce::dsp::IIR::Coefficients; + + Filter left, right; +}; + +// Simple background "realtime" analyser: it keeps a circular ring buffer +// of the most recent input, computes an FFT magnitude spectrum periodically +// in a background thread, and exposes the result thread-safely for the UI. +class SpectrumAnalyser : public juce::Timer +{ +public: + SpectrumAnalyser(); + + void prepare(double sampleRate, int maxBlockSize); + void push(const float* const* channels, int numChannels, int numSamples); + + void startBackground(); + void stopBackground(); + + // Copies the latest spectrum into `dest` (size destSize) with equal + // log-spaced bands, returning the number of bins written. + int copySpectrum(juce::Array& dest, juce::Array& freqBins, + int maxBins) const; + + void setEnabled(bool enabled); + +private: + void timerCallback() override; + + void runAnalysis(); + + mutable juce::CriticalSection spectrumLock; + juce::Array readySpectrum; // dB magnitudes per bin + + juce::AudioBuffer ring; + int ringFill = 0; + int ringChannels = 0; + double sampleRate = 44100.0; + + std::atomic enabled { true }; +}; + +} // namespace gelyk diff --git a/Source/GitInfo.h.in b/Source/GitInfo.h.in new file mode 100644 index 0000000..6ada4ce --- /dev/null +++ b/Source/GitInfo.h.in @@ -0,0 +1,9 @@ +#pragma once + +// This file is generated by CMake at configure time from GitInfo.h.in + +#define GELYK_GIT_BRANCH "@GELYK_GIT_BRANCH@" +#define GELYK_GIT_SHA "@GELYK_GIT_SHA@" +#define GELYK_GIT_VERSION "@GELYK_GIT_DESCRIBE@" +#define GELYK_GIT_DIRTY_STR "@GELYK_GIT_DIRTY@" +#define GELYK_BUILD_DATE_STR "@GELYK_BUILD_DATE@" diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..2bce8a7 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,348 @@ +#include "PluginEditor.h" + +namespace gelyk +{ + +// --------------------------------------------------------------------------- +// NeonLookAndFeel +// --------------------------------------------------------------------------- + +NeonLookAndFeel::NeonLookAndFeel() +{ + setColour(juce::Slider::textBoxTextColourId, cyan); + setColour(juce::Slider::textBoxOutlineColourId, juce::Colours::transparentBlack); + setColour(juce::Slider::textBoxBackgroundColourId, panel); + setColour(juce::ComboBox::backgroundColourId, panel); + setColour(juce::ComboBox::outlineColourId, line); + setColour(juce::ComboBox::textColourId, cyan); + setColour(juce::ComboBox::arrowColourId, cyan); +} + +void NeonLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float, float, + const juce::Slider::SliderStyle style, juce::Slider& slider) +{ + const bool vertical = (style == juce::Slider::LinearVertical); + const float cx = x + width * 0.5f; + + // Track + juce::Path track; + if (vertical) + { + const float tw = 2.0f; + track.addRoundedRectangle(cx - tw * 0.5f, (float) y, tw, (float) height, 1.0f); + } + else + { + const float th = 2.0f; + track.addRoundedRectangle((float) x, y + height * 0.5f - th * 0.5f, (float) width, th, 1.0f); + } + g.setColour(line); + g.fillPath(track); + + // Filled region from centre (0 dB) down to the handle, glowing. + if (vertical) + { + const float midY = y + height * 0.5f; + const float top = juce::jmin(midY, sliderPos); + const float bottom = juce::jmax(midY, sliderPos); + juce::Path fill; + fill.addRoundedRectangle(cx - 3.0f, top, 6.0f, juce::jmax(1.0f, bottom - top), 3.0f); + g.setColour(cyan); + g.fillPath(fill); + + // Handle. + g.setColour(slider.isEnabled() && isMouseOverOrDragging(true) ? violet : cyan); + g.fillEllipse(cx - 5.0f, sliderPos - 5.0f, 10.0f, 10.0f); + g.setColour(bg); + g.fillEllipse(cx - 2.0f, sliderPos - 2.0f, 4.0f, 4.0f); + } + else + { + g.setColour(cyan); + g.fillRect(x, y + height * 0.5f - 3.0f, (float) width, 6.0f); + } +} + +void NeonLookAndFeel::drawComboBox(juce::Graphics& g, int width, int height, bool, + int, int, int, int, juce::ComboBox& box) +{ + auto corner = 4.0f; + auto bounds = box.getLocalBounds().toFloat(); + g.setColour(panel); + g.fillRoundedRectangle(bounds, corner); + g.setColour(cyan); + g.drawRoundedRectangle(bounds, corner, 1.0f); + + juce::Rectangle arrowZone(width - 20, 0, 20, height); + juce::Path p; + p.startNewSubPath((float) arrowZone.getX() + 4.0f, (float) arrowZone.getCentreY() - 3.0f); + p.lineTo((float) arrowZone.getCentreX(), (float) arrowZone.getCentreY() + 3.0f); + p.lineTo((float) arrowZone.getX() + 12.0f, (float) arrowZone.getCentreY() - 3.0f); + g.setColour(cyan); + g.strokePath(p, juce::PathStrokeType(1.5f)); +} + +void NeonLookAndFeel::positionComboBoxText(juce::ComboBox& box, juce::Label& label) +{ + label.setBounds(2, 1, box.getWidth() - 22, box.getHeight() - 2); + label.setFont(juce::FontOptions(13.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); +} + +// --------------------------------------------------------------------------- +// GelykEQAudioProcessorEditor +// --------------------------------------------------------------------------- + +GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& p) + : AudioProcessorEditor(&p), + processor(p), + analyserAttachment(processor.getAPVTS().parameterState, + "analyserEnabled", + [this](float v) { processor.getAnalyser().setEnabled(v > 0.5f); }) +{ + setLookAndFeel(&lookAndFeel); + setOpaque(true); + + titleLabel = std::make_unique("title", "GELYKEQ"); + titleLabel->setFont(juce::FontOptions(26.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + titleLabel->setColour(juce::Label::textColourId, cyan); + titleLabel->setJustificationType(juce::Justification::centredLeft); + addAndMakeVisible(*titleLabel); + + scaleBox = std::make_unique("scale"); + scaleBox->addItem("100%", 100); + scaleBox->addItem("125%", 125); + scaleBox->addItem("150%", 150); + scaleBox->addItem("175%", 175); + scaleBox->addItem("200%", 200); + scaleBox->setSelectedId(100, juce::dontSendNotification); + scaleBox->onChange = [this] { scaleUI((float) scaleBox->getSelectedId()); }; + addAndMakeVisible(*scaleBox); + + // Master gain (vertical fader, far right). + masterLabel = std::make_unique("mlab", "MASTER"); + masterLabel->setFont(juce::FontOptions(11.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + masterLabel->setColour(juce::Label::textColourId, magenta); + masterLabel->setJustificationType(juce::Justification::centred); + master = std::make_unique(juce::Slider::LinearVertical, + juce::Slider::TextBoxBelow); + master->setLookAndFeel(&lookAndFeel); + master->setSliderStyle(juce::Slider::LinearVertical); + master->setTextBoxStyle(juce::Slider::TextBoxBelow, false, 40, 14); + master->setRange(-24.0, 24.0, 0.01); + addAndMakeVisible(*masterLabel); + addAndMakeVisible(*master); + masterAttachment = std::make_unique( + processor.getAPVTS(), "master", *master); + + // Band faders. + const char* const names[numBands] = { "LOW", "LOW-MID", "MID", "HIGH-MID", "HIGH" }; + const char* const freqs[numBands] = { "60", "200", "1K", "4K", "10K" }; + for (int i = 0; i < numBands; ++i) + { + auto& w = bands[i]; + + w.freqLabel = std::make_unique("freq", freqs[i]); + w.freqLabel->setFont(juce::FontOptions(13.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + w.freqLabel->setColour(juce::Label::textColourId, textDim); + w.freqLabel->setJustificationType(juce::Justification::centred); + + w.gainLabel = std::make_unique("gname", names[i]); + w.gainLabel->setFont(juce::FontOptions(11.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + w.gainLabel->setColour(juce::Label::textColourId, i % 2 == 0 ? cyan : magenta); + w.gainLabel->setJustificationType(juce::Justification::centred); + + w.gain = std::make_unique(juce::Slider::LinearVertical, juce::Slider::TextBoxBelow); + w.gain->setLookAndFeel(&lookAndFeel); + w.gain->setSliderStyle(juce::Slider::LinearVertical); + w.gain->setTextBoxStyle(juce::Slider::TextBoxBelow, false, 40, 14); + w.gain->setRange(-24.0, 24.0, 0.01); + w.gain->setDoubleClickReturnValue(true, 0.0); + + w.q = std::make_unique(juce::Slider::LinearHorizontal, juce::Slider::NoTextBox); + w.q->setLookAndFeel(&lookAndFeel); + w.q->setRange(0.1, 6.0, 0.01); + w.q->setDoubleClickReturnValue(true, 1.0); + + addAndMakeVisible(*w.freqLabel); + addAndMakeVisible(*w.gainLabel); + addAndMakeVisible(*w.gain); + addAndMakeVisible(*w.q); + + gainAttachments[i] = std::make_unique( + processor.getAPVTS(), "gain" + juce::String(i), *w.gain); + qAttachments[i] = std::make_unique( + processor.getAPVTS(), "q" + juce::String(i), *w.q); + } + + // Status bar. + statusGit = std::make_unique("git", processor.getGitInfo()); + statusGit->setFont(juce::FontOptions(11.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + statusGit->setColour(juce::Label::textColourId, textDim); + statusGit->setJustificationType(juce::Justification::centredRight); + + statusBar = std::make_unique("status", + "GELYKEQ v" + juce::String(ProjectInfo::versionString) + " | AU / VST3 "); + statusBar->setFont(juce::FontOptions(11.0f).setTypefaceName(juce::Font::getDefaultMonospacedFontName())); + statusBar->setColour(juce::Label::textColourId, textDim); + statusBar->setJustificationType(juce::Justification::centredLeft); + addAndMakeVisible(*statusBar); + addAndMakeVisible(*statusGit); + + spectrum.ensureStorageAllocated(128); + freqBins.ensureStorageAllocated(128); + + startTimerHz(30); + rebuildFaders(); + scaleUI(100.0f); +} + +GelykEQAudioProcessorEditor::~GelykEQAudioProcessorEditor() +{ + stopTimer(); + setLookAndFeel(nullptr); +} + +void GelykEQAudioProcessorEditor::timerCallback() +{ + processor.getAnalyser().copySpectrum(spectrum, freqBins, 128); + repaint(); +} + +void GelykEQAudioProcessorEditor::rebuildFaders() +{ + // Nothing needed at construction; sliders are attachments. +} + +void GelykEQAudioProcessorEditor::scaleUI(float percent) +{ + const float s = percent / 100.0f; + scaling.store(s); + setSize((int) (920 * s), (int) (540 * s)); + resized(); +} + +void GelykEQAudioProcessorEditor::resized() +{ + juce::Rectangle area = getLocalBounds(); + const float s = scaling.load(); + + const int headerH = (int) (46 * s); + const int footerH = (int) (26 * s); + const int pad = (int) (12 * s); + + // Header. + juce::Rectangle header = area.removeFromTop(headerH); + titleLabel->setBounds(header.removeFromLeft((int) (160 * s)).reduced(pad, 0)); + scaleBox->setBounds(header.removeFromRight((int) (90 * s)).reduced((int)(6*s), (int)(9*s))); + + // Footer. + juce::Rectangle footer = area.removeFromBottom(footerH); + footer.reduce(pad, 0); + statusBar->setBounds(footer.removeFromLeft(footer.getWidth() / 2)); + statusGit->setBounds(footer); + + // Main grid. + const int masterW = (int) (64 * s); + juce::Rectangle mainArea = area; + juce::Rectangle masterArea = mainArea.removeFromRight(masterW); + + const int x0 = mainArea.getX(); + const int y0 = mainArea.getY(); + const int totalW = mainArea.getWidth(); + + const int gap = (int) (10 * s); + const int colW = (totalW - gap * (numBands - 1)) / numBands; + + for (int i = 0; i < numBands; ++i) + { + auto& w = bands[i]; + juce::Rectangle col(x0 + i * (colW + gap), y0, colW, mainArea.getHeight()); + + const int topLabelH = (int) (18 * s); + const int freqH = (int) (16 * s); + const int gainH = (int) (14 * s); + + juce::Rectangle sub = col.reduced((int)(8*s), 0); + w.gainLabel->setBounds(sub.removeFromTop(topLabelH)); + + juce::Rectangle analyserArea = sub.removeFromTop(sub.getHeight() - freqH - gainH - (int)(18*s) - (int)(10*s)); + + w.freqLabel->setBounds(sub.removeFromTop(freqH)); + w.gain->setBounds(sub.removeFromTop(gainH + (int)(34*s))); + + w.q->setBounds(col.removeFromBottom((int)(18*s)).reduced((int)(8*s), 0)); + } + + // Master column. + juce::Rectangle msub = masterArea.reduced((int)(14*s), 0); + masterLabel->setBounds(msub.removeFromTop((int) (18 * s))); + master->setBounds(msub); +} + +void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) +{ + const float s = scaling.load(); + g.fillAll(bg); + + // Analyser canvas area matches fader columns. + juce::Rectangle area = getLocalBounds(); + const int headerH = (int) (46 * s); + const int footerH = (int) (26 * s); + juce::Rectangle mainArea = area; + mainArea.removeFromTop(headerH); + mainArea.removeFromBottom(footerH); + mainArea.removeFromRight((int) (64 * s)); + + // Neon grid lines behind faders. + g.setColour(line); + for (int i = 0; i < 4; ++i) + { + float f = (i + 1) / 5.0f; + // 0 dB line. + if (i == 2) + continue; + float yy = mainArea.getY() + 18.0f * s + (mainArea.getHeight() - 18.0f * s - 60.0f * s) * f; + g.drawHorizontalLine((int) yy + (int)(18*s), (float) mainArea.getX(), (float) mainArea.getRight()); + } + + // The 0 dB centre line (glowing). + const float centreLineY = mainArea.getY() + (int)(18*s) + + (mainArea.getHeight() - (int)(18*s) - (int)(60*s)) * 0.5f; + g.setColour(cyan.withAlpha(0.55f)); + g.drawHorizontalLine((int) centreLineY, (float) mainArea.getX(), (float) mainArea.getRight()); + + // Spectrum overlay. + if (spectrum.size() > 2) + { + juce::Path path; + const int x0 = mainArea.getX(); + const int width = mainArea.getWidth(); + const float topY = mainArea.getY() + 18.0f * s; + const float botY = mainArea.getY() + (int)(8*s) + (mainArea.getHeight() - (int)(18*s) - (int)(60*s)) * 0.92f; + + for (int i = 0; i < spectrum.size(); ++i) + { + float db = juce::jlimit(-72.0f, 12.0f, spectrum[i]); + float norm = (db + 72.0f) / 84.0f; // 0..1 up + float xx = (float) x0 + (float) i / (float) (spectrum.size() - 1) * width; + float yy = botY - norm * (botY - topY); + if (i == 0) path.startNewSubPath(xx, yy); + else path.lineTo(xx, yy); + } + + juce::Path filled = path; + filled.lineTo((float) mainArea.getRight(), botY); + filled.lineTo((float) mainArea.getX(), botY); + filled.closeSubPath(); + + g.setGradientFill(juce::ColourGradient(magenta.withAlpha(0.35f), (float) mainArea.getX(), topY, + magenta.withAlpha(0.05f), (float) mainArea.getX(), botY, + false)); + g.fillPath(filled); + g.setColour(cyan.withAlpha(0.9f)); + g.strokePath(path, juce::PathStrokeType(1.6f * s)); + } +} + +} // namespace gelyk diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..bc12783 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include "PluginProcessor.h" + +namespace gelyk +{ + +class NeonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + NeonLookAndFeel(); + void drawLinearSlider(juce::Graphics&, int x, int y, int width, int height, + float sliderPos, float minSliderPos, float maxSliderPos, + const juce::Slider::SliderStyle, 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 positionComboBoxText(juce::ComboBox&, juce::Label&) override; + + juce::Colour cyan { 0xff00e5ff }; + juce::Colour magenta{ 0xffff2fd6 }; + juce::Colour violet { 0xff8a2be2 }; + juce::Colour bg { 0xff0b0f16 }; + juce::Colour panel { 0xff121a24 }; + juce::Colour line { 0xff1f2a38 }; + juce::Colour textDim{ 0xff7a8aa0 }; +}; + +// --------------------------------------------------------------------------- + +class GelykEQAudioProcessorEditor final : public juce::AudioProcessorEditor, + public juce::Timer +{ +public: + explicit GelykEQAudioProcessorEditor(GelykEQAudioProcessor&); + ~GelykEQAudioProcessorEditor() override; + + void paint(juce::Graphics&) override; + void resized() override; + void timerCallback() override; + +private: + void rebuildFaders(); + void scaleUI(float percent); + + GelykEQAudioProcessor& processor; + + NeonLookAndFeel lookAndFeel; + + static constexpr int numBands = 5; + struct BandWidget + { + std::unique_ptr gain; + std::unique_ptr q; + std::unique_ptr freqLabel; + std::unique_ptr gainLabel; + }; + BandWidget bands[numBands]; + + std::unique_ptr masterLabel; + std::unique_ptr master; + + std::unique_ptr scaleBox; + std::unique_ptr titleLabel; + + std::unique_ptr statusBar; + std::unique_ptr statusGit; + + juce::AudioProcessorValueTreeState::SliderAttachment gainAttachments[numBands]; + juce::AudioProcessorValueTreeState::SliderAttachment qAttachments[numBands]; + std::unique_ptr masterAttachment; + + juce::AudioProcessorValueTreeState::ParameterAttachment analyserAttachment; + + juce::Array spectrum; + juce::Array freqBins; + std::atomic scaling { 1.0f }; // for resized() + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GelykEQAudioProcessorEditor) +}; + +} // namespace gelyk diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..b4cd6d7 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,166 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include "GitInfo.h" + +namespace gelyk +{ + +namespace +{ +juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout layout; + + auto addGain = [&](juce::String id, juce::String name, float def) + { + layout.add(std::make_unique( + id, name, juce::NormalisableRange(-24.0f, 24.0f, 0.01f), def)); + }; + + auto addQ = [&](juce::String id, juce::String name, float def) + { + layout.add(std::make_unique( + id, name, juce::NormalisableRange(0.1f, 6.0f, 0.01f), def)); + }; + + addGain("gain0", "Low Shelf Gain", 0.0f); + addGain("gain1", "Low-Mid Gain", 0.0f); + addGain("gain2", "Mid Gain", 0.0f); + addGain("gain3", "High-Mid Gain", 0.0f); + addGain("gain4", "High Shelf Gain", 0.0f); + + addQ("q0", "Low Shelf Q", 0.71f); + addQ("q1", "Low-Mid Q", 1.0f); + addQ("q2", "Mid Q", 1.0f); + addQ("q3", "High-Mid Q", 1.0f); + addQ("q4", "High Shelf Q", 0.71f); + + layout.add(std::make_unique( + "master", "Master", juce::NormalisableRange(-24.0f, 24.0f, 0.01f), 0.0f)); + + layout.add(std::make_unique( + "analyserEnabled", "Analyser", true)); + + return layout; +} + +const char* const bandTypes[5] = { "lowShelf", "peak", "peak", "peak", "highShelf" }; +const float bandFreqs[5] = { 60.0f, 200.0f, 1000.0f, 4000.0f, 10000.0f }; +} // namespace + +GelykEQAudioProcessor::GelykEQAudioProcessor() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + apvts(*this, nullptr, "Parameters", createParameterLayout()) +{ + buildVersion = ProjectInfo::versionString; + + gitInfo = juce::String(GELYK_GIT_BRANCH) + " @ " + juce::String(GELYK_GIT_SHA) + + (juce::String(GELYK_GIT_DIRTY_STR).isNotEmpty() ? " (dirty)" : "") + + " | " + juce::String(GELYK_BUILD_DATE_STR); +} + +void GelykEQAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + currentSampleRate = sampleRate; + + for (int i = 0; i < numBands; ++i) + { + bands[i].type = (FilterBand::Type) + (juce::String(bandTypes[i]) == "lowShelf" ? 0 : + (juce::String(bandTypes[i]) == "highShelf" ? 2 : 1)); + bands[i].freq = bandFreqs[i]; + bands[i].prepare(sampleRate, samplesPerBlock); + bands[i].reset(); + } + + analyser.prepare(sampleRate, samplesPerBlock); + analyser.startBackground(); + + rebuildCoefficients(); +} + +void GelykEQAudioProcessor::releaseResources() +{ + analyser.stopBackground(); +} + +void GelykEQAudioProcessor::rebuildCoefficients() +{ + for (int i = 0; i < numBands; ++i) + { + float gain = apvts.getRawParameterValue("gain" + juce::String(i))->load(); + float q = apvts.getRawParameterValue("q" + juce::String(i))->load(); + bands[i].gainDb = gain; + bands[i].q = q; + bands[i].prepare(currentSampleRate, 512); + } +} + +void GelykEQAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + + const int numChannels = buffer.getNumChannels(); + const int numSamples = buffer.getNumSamples(); + + if (numChannels < 1) + return; + + // Feed the analyser with the input. + if (apvts.getRawParameterValue("analyserEnabled")->load() > 0.5f) + { + const float* chans[2] = { buffer.getReadPointer(0), + numChannels > 1 ? buffer.getReadPointer(1) : buffer.getReadPointer(0) }; + analyser.push(chans, numChannels > 1 ? 2 : 1, numSamples); + } + + // Refresh band coefficients (cheap) from smoothed params each block. + for (int i = 0; i < numBands; ++i) + { + float gain = apvts.getRawParameterValue("gain" + juce::String(i))->load(); + float q = apvts.getRawParameterValue("q" + juce::String(i))->load(); + if (std::abs(gain - bands[i].gainDb) > 0.001f || std::abs(q - bands[i].q) > 0.001f) + { + bands[i].gainDb = gain; + bands[i].q = q; + bands[i].prepare(currentSampleRate, numSamples); + } + } + + juce::dsp::AudioBlock block(buffer); + + for (int i = 0; i < numBands; ++i) + bands[i].process(block); + + float master = apvts.getRawParameterValue("master")->load(); + if (std::abs(master) > 0.001f) + buffer.applyGain(juce::Decibels::decibelsToGain(master)); +} + +void GelykEQAudioProcessor::getStateInformation(juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + std::unique_ptr xml(state.createXml()); + copyXmlToBinary(*xml, destData); +} + +void GelykEQAudioProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + std::unique_ptr xml(getXmlFromBinary(data, sizeInBytes)); + if (xml != nullptr && xml->hasTagName(apvts.state.getType())) + apvts.replaceState(juce::ValueTree::fromXml(*xml)); +} + +juce::AudioProcessorEditor* GelykEQAudioProcessor::createEditor() +{ + return new GelykEQAudioProcessorEditor(*this); +} + +} // namespace gelyk + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new gelyk::GelykEQAudioProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..7bd9256 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include "EqualizerDSP.h" + +namespace gelyk +{ + +class GelykEQAudioProcessor final : public juce::AudioProcessor +{ +public: + GelykEQAudioProcessor(); + ~GelykEQAudioProcessor() override = default; + + 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 "Gelyk EQ"; } + bool acceptsMidi() const override { return false; } + bool producesMidi() 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&) override; + void setStateInformation(const void*, int) override; + + juce::AudioProcessorValueTreeState& getAPVTS() { return apvts; } + + // Exposed for the editor / analyser. + SpectrumAnalyser& getAnalyser() { return analyser; } + + // Returns human-readable Git build info. + const juce::String& getGitInfo() const { return gitInfo; } + +private: + juce::AudioProcessorValueTreeState apvts; + + static constexpr int numBands = 5; + FilterBand bands[numBands]; + double currentSampleRate = 44100.0; + + SpectrumAnalyser analyser; + + juce::String buildVersion; + juce::String gitInfo; + + void rebuildCoefficients(); +}; + +} // namespace gelyk