From 8e9eb733cb96e95186f07ffe5be2c4371e7ee716 Mon Sep 17 00:00:00 2001 From: Gelyk Date: Sat, 29 Aug 2026 15:42:21 +0200 Subject: [PATCH] Add per-band frequency control and static EQ curve overlay --- Source/EqualizerDSP.cpp | 46 +++++++++++++++++ Source/EqualizerDSP.h | 8 +++ Source/PluginEditor.cpp | 103 +++++++++++++++++++++++++++++++++---- Source/PluginEditor.h | 5 ++ Source/PluginProcessor.cpp | 21 +++++++- 5 files changed, 172 insertions(+), 11 deletions(-) diff --git a/Source/EqualizerDSP.cpp b/Source/EqualizerDSP.cpp index 217c5c4..4873755 100644 --- a/Source/EqualizerDSP.cpp +++ b/Source/EqualizerDSP.cpp @@ -3,6 +3,52 @@ namespace gelyk { +void computeEQResponse(const FilterBand::Type* types, + const float* gains, const float* freqs, const float* qs, + int numBands, double sampleRate, float* outDb, int numBins) +{ + if (numBins <= 0) + return; + + for (int b = 0; b < numBins; ++b) + outDb[b] = 0.0f; + + const float fMin = 20.0f; + const float fMax = 20000.0f; + const float logMin = std::log10(fMin); + const float logMax = std::log10(fMax); + + for (int i = 0; i < numBands; ++i) + { + const float g = juce::jlimit(-24.0f, 24.0f, gains[i]); + const float gainFactor = juce::Decibels::decibelsToGain(g); + const float f = juce::jlimit(20.0f, 20000.0f, freqs[i]); + const float q = juce::jlimit(0.1f, 6.0f, qs[i]); + + FilterBand::Coeffs::Ptr c; + switch (types[i]) + { + case FilterBand::Type::lowShelf: + c = FilterBand::Coeffs::makeLowShelf(sampleRate, f, q, gainFactor); + break; + case FilterBand::Type::highShelf: + c = FilterBand::Coeffs::makeHighShelf(sampleRate, f, q, gainFactor); + break; + case FilterBand::Type::peak: + default: + c = FilterBand::Coeffs::makePeakFilter(sampleRate, f, q, gainFactor); + break; + } + + for (int b = 0; b < numBins; ++b) + { + const float ratio = (float) b / (float) (numBins - 1); + const double freq = std::pow(10.0, logMin + ratio * (logMax - logMin)); + outDb[b] += 20.0f * std::log10((float) c->getMagnitudeForFrequency(freq, sampleRate)); + } + } +} + // --------------------------------------------------------------------------- // FilterBand // --------------------------------------------------------------------------- diff --git a/Source/EqualizerDSP.h b/Source/EqualizerDSP.h index cdc764c..077257a 100644 --- a/Source/EqualizerDSP.h +++ b/Source/EqualizerDSP.h @@ -30,6 +30,14 @@ struct FilterBand Filter left, right; }; +// Computes the total summed magnitude response (in dB) of a series filter chain +// at `numBins` log-spaced frequencies from 20 Hz to 20 kHz. Uses the current +// gains, frequencies and Q values and the same coefficient maths as the audio path, +// so it exactly matches what is heard. +void computeEQResponse(const FilterBand::Type* types, + const float* gains, const float* freqs, const float* qs, + int numBands, double sampleRate, float* outDb, int numBins); + // 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. diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index ef09b78..ea21e19 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -231,10 +231,42 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& bands[i].qValue->setText(juce::String(bands[i].q->getValue(), 2), juce::dontSendNotification); }; + w.freqCaption = std::make_unique("fcap", "FREQ"); + w.freqCaption->setFont(monoFont(10.0f)); + w.freqCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim); + w.freqCaption->setJustificationType(juce::Justification::centred); + + w.freqValue = std::make_unique("fval", "160"); + w.freqValue->setFont(monoFont(10.0f)); + w.freqValue->setColour(juce::Label::textColourId, lookAndFeel.cyan); + w.freqValue->setJustificationType(juce::Justification::centred); + + w.freq = std::make_unique(juce::Slider::RotaryVerticalDrag, juce::Slider::NoTextBox); + w.freq->setLookAndFeel(&lookAndFeel); + w.freq->setSliderStyle(juce::Slider::RotaryVerticalDrag); + w.freq->setRotaryParameters(2.4f, 6.9f, true); // ~270° sweep + auto freqRange = juce::NormalisableRange(20.0, 20000.0, 0.5); + freqRange.setSkewForCentre(1000.0); + w.freq->setNormalisableRange(freqRange); + w.freq->setDoubleClickReturnValue(true, 1000.0f); + w.freq->onValueChange = [this, i] + { + float f = bands[i].freq->getValue(); + juce::String txt; + if (f >= 1000.0f) + txt = juce::String(f / 1000.0f, 1) + "K"; + else + txt = juce::String((int) f); + bands[i].freqValue->setText(txt, juce::dontSendNotification); + }; + addAndMakeVisible(*w.freqLabel); addAndMakeVisible(*w.gainCaption); addAndMakeVisible(*w.gainValue); addAndMakeVisible(*w.gain); + addAndMakeVisible(*w.freqCaption); + addAndMakeVisible(*w.freqValue); + addAndMakeVisible(*w.freq); addAndMakeVisible(*w.qCaption); addAndMakeVisible(*w.q); addAndMakeVisible(*w.qValue); @@ -243,6 +275,8 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& processor.getAPVTS(), "gain" + juce::String(i), *w.gain); qAttachments[i] = std::make_unique( processor.getAPVTS(), "q" + juce::String(i), *w.q); + freqAttachments[i] = std::make_unique( + processor.getAPVTS(), "freq" + juce::String(i), *w.freq); } // Status bar. @@ -261,6 +295,7 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& spectrum.ensureStorageAllocated(128); freqBins.ensureStorageAllocated(128); + eqCurve.resize(128); startTimerHz(30); scaleUI(100.0f); @@ -275,6 +310,21 @@ GelykEQAudioProcessorEditor::~GelykEQAudioProcessorEditor() void GelykEQAudioProcessorEditor::timerCallback() { processor.getAnalyser().copySpectrum(spectrum, freqBins, 128); + + const int n = 128; + FilterBand::Type types[numBands]; + float gains[numBands], freqs[numBands], qs[numBands]; + auto& params = processor.getAPVTS(); + for (int i = 0; i < numBands; ++i) + { + types[i] = (FilterBand::Type)(i == 0 ? 0 : (i == numBands - 1 ? 2 : 1)); + gains[i] = params.getRawParameterValue("gain" + juce::String(i))->load(); + freqs[i] = params.getRawParameterValue("freq" + juce::String(i))->load(); + qs[i] = params.getRawParameterValue("q" + juce::String(i))->load(); + } + computeEQResponse(types, gains, freqs, qs, numBands, + (double) processor.getSampleRate(), eqCurve.getRawDataPointer(), n); + repaint(); } @@ -330,24 +380,31 @@ void GelykEQAudioProcessorEditor::resized() const int captionH = (int) (18 * s); const int valueH = (int) (15 * s); const int freqH = (int) (20 * s); - const int qValueH = (int) (14 * s); - const int qKnobH = (int) (58 * s); - const int qCapH = (int) (14 * s); + const int knobStackH = (int) (14 * s) + (int)(58 * s) + (int)(12 * s); // value + knob + caption w.gainCaption->setBounds(col.removeFromTop(captionH)); w.gainValue->setBounds(col.removeFromTop(valueH)); // The gain fader fills the large analyser area (spectrum drawn behind it). - w.gain->setBounds(col.removeFromTop(col.getHeight() - - freqH - qValueH - qKnobH - qCapH - (int)(6*s))); + w.gain->setBounds(col.removeFromTop(col.getHeight() - freqH - knobStackH - (int)(6*s))); w.freqLabel->setBounds(col.removeFromTop(freqH)); - // Q rotary control: value label, knob, caption. - w.qValue->setBounds(col.removeFromTop(qValueH)); - juce::Rectangle knobArea = col.removeFromTop(qKnobH); - w.q->setBounds(knobArea.reduced((int)(colW * 0.22f), 0)); - w.qCaption->setBounds(col.removeFromTop(qCapH)); + // Bottom control row: FREQ and Q knobs side by side. + juce::Rectangle knobRow = col; + int rowW = knobRow.getWidth(); + int halfW = rowW / 2; + + // FREQ (left half). + juce::Rectangle freqCol = knobRow.removeFromLeft(halfW); + w.freqValue->setBounds(freqCol.removeFromTop((int)(14*s))); + w.freq->setBounds(freqCol.removeFromTop((int)(58*s)).reduced((int)(colW*0.08f), 0)); + w.freqCaption->setBounds(freqCol.removeFromTop((int)(12*s))); + + // Q (right half). + w.qValue->setBounds(knobRow.removeFromTop((int)(14*s))); + w.q->setBounds(knobRow.removeFromTop((int)(58*s)).reduced((int)(colW*0.08f), 0)); + w.qCaption->setBounds(knobRow.removeFromTop((int)(12*s))); } // Master column. @@ -418,6 +475,32 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) g.setColour(lookAndFeel.cyan.withAlpha(0.9f)); g.strokePath(path, juce::PathStrokeType(1.6f * s)); } + + // Static EQ curve overlay (the actual summed filter response). + if (eqCurve.size() > 2) + { + const float x0 = (float) mainArea.getX(); + const float width = (float) mainArea.getWidth(); + const float centreY = topY + (botY - topY) * 0.5f; + const float halfH = (botY - topY) * 0.42f; // ±24 dB maps here + + juce::Path curve; + for (int i = 0; i < eqCurve.size(); ++i) + { + float db = juce::jlimit(-24.0f, 24.0f, eqCurve[i]); + float yy = centreY - (db / 24.0f) * halfH; + float xx = x0 + (float) i / (float) (eqCurve.size() - 1) * width; + if (i == 0) curve.startNewSubPath(xx, yy); + else curve.lineTo(xx, yy); + } + + g.setColour(lookAndFeel.violet.withAlpha(0.20f)); + g.strokePath(curve, juce::PathStrokeType(7.0f * s, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + g.setColour(lookAndFeel.violet); + g.strokePath(curve, juce::PathStrokeType(1.8f * s, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } } } // namespace gelyk diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index f7b5550..ee5c2d7 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -56,11 +56,14 @@ private: { std::unique_ptr gain; std::unique_ptr q; + std::unique_ptr freq; std::unique_ptr freqLabel; std::unique_ptr gainCaption; std::unique_ptr gainValue; std::unique_ptr qCaption; std::unique_ptr qValue; + std::unique_ptr freqCaption; + std::unique_ptr freqValue; }; BandWidget bands[numBands]; @@ -75,10 +78,12 @@ private: std::unique_ptr gainAttachments[numBands]; std::unique_ptr qAttachments[numBands]; + std::unique_ptr freqAttachments[numBands]; std::unique_ptr masterAttachment; juce::Array spectrum; juce::Array freqBins; + juce::Array eqCurve; std::atomic scaling { 1.0f }; // for resized() JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GelykEQAudioProcessorEditor) diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 69080fa..7aae3ed 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -23,6 +23,13 @@ juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() id, name, juce::NormalisableRange(0.1f, 6.0f, 0.01f), def)); }; + auto addFreq = [&](juce::String id, juce::String name, float def) + { + auto range = juce::NormalisableRange(20.0f, 20000.0f, 0.5f); + range.setSkewForCentre(1000.0f); + layout.add(std::make_unique(id, name, range, def)); + }; + addGain("gain0", "Low Shelf Gain", 0.0f); addGain("gain1", "Low Gain", 0.0f); addGain("gain2", "Low-Mid Gain", 0.0f); @@ -39,6 +46,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() addQ("q5", "High Q", 1.0f); addQ("q6", "High Shelf Q", 0.71f); + addFreq("freq0", "Low Shelf Freq", 60.0f); + addFreq("freq1", "Low Freq", 160.0f); + addFreq("freq2", "Low-Mid Freq", 400.0f); + addFreq("freq3", "Mid Freq", 800.0f); + addFreq("freq4", "High-Mid Freq", 1600.0f); + addFreq("freq5", "High Freq", 4000.0f); + addFreq("freq6", "High Shelf Freq", 10000.0f); + layout.add(std::make_unique( "master", "Master", juce::NormalisableRange(-24.0f, 24.0f, 0.01f), 0.0f)); @@ -125,10 +140,14 @@ void GelykEQAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce: { 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) + float freq = apvts.getRawParameterValue("freq" + juce::String(i))->load(); + if (std::abs(gain - bands[i].gainDb) > 0.001f || + std::abs(q - bands[i].q) > 0.001f || + std::abs(freq - bands[i].freq) > 0.5f) { bands[i].gainDb = gain; bands[i].q = q; + bands[i].freq = freq; bands[i].prepare(currentSampleRate, numSamples); } }