Add per-band frequency control and static EQ curve overlay

This commit is contained in:
Gelyk 2026-08-29 15:42:21 +02:00
commit 8e9eb733cb
5 changed files with 172 additions and 11 deletions

View file

@ -3,6 +3,52 @@
namespace gelyk 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 // FilterBand
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -30,6 +30,14 @@ struct FilterBand
Filter left, right; 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 // Simple background "realtime" analyser: it keeps a circular ring buffer
// of the most recent input, computes an FFT magnitude spectrum periodically // of the most recent input, computes an FFT magnitude spectrum periodically
// in a background thread, and exposes the result thread-safely for the UI. // in a background thread, and exposes the result thread-safely for the UI.

View file

@ -231,10 +231,42 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor&
bands[i].qValue->setText(juce::String(bands[i].q->getValue(), 2), juce::dontSendNotification); bands[i].qValue->setText(juce::String(bands[i].q->getValue(), 2), juce::dontSendNotification);
}; };
w.freqCaption = std::make_unique<juce::Label>("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<juce::Label>("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>(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<double>(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.freqLabel);
addAndMakeVisible(*w.gainCaption); addAndMakeVisible(*w.gainCaption);
addAndMakeVisible(*w.gainValue); addAndMakeVisible(*w.gainValue);
addAndMakeVisible(*w.gain); addAndMakeVisible(*w.gain);
addAndMakeVisible(*w.freqCaption);
addAndMakeVisible(*w.freqValue);
addAndMakeVisible(*w.freq);
addAndMakeVisible(*w.qCaption); addAndMakeVisible(*w.qCaption);
addAndMakeVisible(*w.q); addAndMakeVisible(*w.q);
addAndMakeVisible(*w.qValue); addAndMakeVisible(*w.qValue);
@ -243,6 +275,8 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor&
processor.getAPVTS(), "gain" + juce::String(i), *w.gain); processor.getAPVTS(), "gain" + juce::String(i), *w.gain);
qAttachments[i] = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>( qAttachments[i] = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processor.getAPVTS(), "q" + juce::String(i), *w.q); processor.getAPVTS(), "q" + juce::String(i), *w.q);
freqAttachments[i] = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
processor.getAPVTS(), "freq" + juce::String(i), *w.freq);
} }
// Status bar. // Status bar.
@ -261,6 +295,7 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor&
spectrum.ensureStorageAllocated(128); spectrum.ensureStorageAllocated(128);
freqBins.ensureStorageAllocated(128); freqBins.ensureStorageAllocated(128);
eqCurve.resize(128);
startTimerHz(30); startTimerHz(30);
scaleUI(100.0f); scaleUI(100.0f);
@ -275,6 +310,21 @@ GelykEQAudioProcessorEditor::~GelykEQAudioProcessorEditor()
void GelykEQAudioProcessorEditor::timerCallback() void GelykEQAudioProcessorEditor::timerCallback()
{ {
processor.getAnalyser().copySpectrum(spectrum, freqBins, 128); 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(); repaint();
} }
@ -330,24 +380,31 @@ void GelykEQAudioProcessorEditor::resized()
const int captionH = (int) (18 * s); const int captionH = (int) (18 * s);
const int valueH = (int) (15 * s); const int valueH = (int) (15 * s);
const int freqH = (int) (20 * s); const int freqH = (int) (20 * s);
const int qValueH = (int) (14 * s); const int knobStackH = (int) (14 * s) + (int)(58 * s) + (int)(12 * s); // value + knob + caption
const int qKnobH = (int) (58 * s);
const int qCapH = (int) (14 * s);
w.gainCaption->setBounds(col.removeFromTop(captionH)); w.gainCaption->setBounds(col.removeFromTop(captionH));
w.gainValue->setBounds(col.removeFromTop(valueH)); w.gainValue->setBounds(col.removeFromTop(valueH));
// The gain fader fills the large analyser area (spectrum drawn behind it). // The gain fader fills the large analyser area (spectrum drawn behind it).
w.gain->setBounds(col.removeFromTop(col.getHeight() w.gain->setBounds(col.removeFromTop(col.getHeight() - freqH - knobStackH - (int)(6*s)));
- freqH - qValueH - qKnobH - qCapH - (int)(6*s)));
w.freqLabel->setBounds(col.removeFromTop(freqH)); w.freqLabel->setBounds(col.removeFromTop(freqH));
// Q rotary control: value label, knob, caption. // Bottom control row: FREQ and Q knobs side by side.
w.qValue->setBounds(col.removeFromTop(qValueH)); juce::Rectangle<int> knobRow = col;
juce::Rectangle<int> knobArea = col.removeFromTop(qKnobH); int rowW = knobRow.getWidth();
w.q->setBounds(knobArea.reduced((int)(colW * 0.22f), 0)); int halfW = rowW / 2;
w.qCaption->setBounds(col.removeFromTop(qCapH));
// FREQ (left half).
juce::Rectangle<int> 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. // Master column.
@ -418,6 +475,32 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g)
g.setColour(lookAndFeel.cyan.withAlpha(0.9f)); g.setColour(lookAndFeel.cyan.withAlpha(0.9f));
g.strokePath(path, juce::PathStrokeType(1.6f * s)); 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 } // namespace gelyk

View file

@ -56,11 +56,14 @@ private:
{ {
std::unique_ptr<juce::Slider> gain; std::unique_ptr<juce::Slider> gain;
std::unique_ptr<juce::Slider> q; std::unique_ptr<juce::Slider> q;
std::unique_ptr<juce::Slider> freq;
std::unique_ptr<juce::Label> freqLabel; std::unique_ptr<juce::Label> freqLabel;
std::unique_ptr<juce::Label> gainCaption; std::unique_ptr<juce::Label> gainCaption;
std::unique_ptr<juce::Label> gainValue; std::unique_ptr<juce::Label> gainValue;
std::unique_ptr<juce::Label> qCaption; std::unique_ptr<juce::Label> qCaption;
std::unique_ptr<juce::Label> qValue; std::unique_ptr<juce::Label> qValue;
std::unique_ptr<juce::Label> freqCaption;
std::unique_ptr<juce::Label> freqValue;
}; };
BandWidget bands[numBands]; BandWidget bands[numBands];
@ -75,10 +78,12 @@ private:
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> gainAttachments[numBands]; std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> gainAttachments[numBands];
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> qAttachments[numBands]; std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> qAttachments[numBands];
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> freqAttachments[numBands];
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> masterAttachment; std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> masterAttachment;
juce::Array<float> spectrum; juce::Array<float> spectrum;
juce::Array<float> freqBins; juce::Array<float> freqBins;
juce::Array<float> eqCurve;
std::atomic<float> scaling { 1.0f }; // for resized() std::atomic<float> scaling { 1.0f }; // for resized()
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GelykEQAudioProcessorEditor) JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GelykEQAudioProcessorEditor)

View file

@ -23,6 +23,13 @@ juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout()
id, name, juce::NormalisableRange<float>(0.1f, 6.0f, 0.01f), def)); id, name, juce::NormalisableRange<float>(0.1f, 6.0f, 0.01f), def));
}; };
auto addFreq = [&](juce::String id, juce::String name, float def)
{
auto range = juce::NormalisableRange<float>(20.0f, 20000.0f, 0.5f);
range.setSkewForCentre(1000.0f);
layout.add(std::make_unique<juce::AudioParameterFloat>(id, name, range, def));
};
addGain("gain0", "Low Shelf Gain", 0.0f); addGain("gain0", "Low Shelf Gain", 0.0f);
addGain("gain1", "Low Gain", 0.0f); addGain("gain1", "Low Gain", 0.0f);
addGain("gain2", "Low-Mid 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("q5", "High Q", 1.0f);
addQ("q6", "High Shelf Q", 0.71f); 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<juce::AudioParameterFloat>( layout.add(std::make_unique<juce::AudioParameterFloat>(
"master", "Master", juce::NormalisableRange<float>(-24.0f, 24.0f, 0.01f), 0.0f)); "master", "Master", juce::NormalisableRange<float>(-24.0f, 24.0f, 0.01f), 0.0f));
@ -125,10 +140,14 @@ void GelykEQAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce:
{ {
float gain = apvts.getRawParameterValue("gain" + juce::String(i))->load(); float gain = apvts.getRawParameterValue("gain" + juce::String(i))->load();
float q = apvts.getRawParameterValue("q" + 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].gainDb = gain;
bands[i].q = q; bands[i].q = q;
bands[i].freq = freq;
bands[i].prepare(currentSampleRate, numSamples); bands[i].prepare(currentSampleRate, numSamples);
} }
} }