diff --git a/.gitignore b/.gitignore index 880ac59..5c1e770 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,58 @@ -build/ +# Build output (all dependencies live exclusively here) +/build/ + +# macOS .DS_Store +.DS_Store? +.AppleDouble +.LSOverride +._* +.AppleDB + +# Vim swap files +*.sw[a-p] +*.swo +*.swn +*~ +.*.sw[a-p] + +# VS Code temp files +.vscode/ +*.code-workspace +*.sublime-project +*.sublime-workspace +*.vscode* + +# Editor / IDE backups & settings +.idea/ +*.iml +*.orig +*.rej + +# Compiled / object files +*.o +*.obj +*.a +*.so +*.dylib +*.dll +*.exe +*.lib +*.out +*.class + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS generated files +Thumbs.db +Desktop.ini +ehthumbs.db + +# CMake internal clutter (kept out of the tree) +cmake-build-*/ +CMakeCache.txt +CMakeFiles/ diff --git a/Source/EqualizerDSP.cpp b/Source/EqualizerDSP.cpp index 4873755..1f04f5c 100644 --- a/Source/EqualizerDSP.cpp +++ b/Source/EqualizerDSP.cpp @@ -55,6 +55,8 @@ void computeEQResponse(const FilterBand::Type* types, void FilterBand::prepare(double sampleRate, int blockSize) { + this->sampleRate = sampleRate; + juce::dsp::ProcessSpec spec; spec.sampleRate = sampleRate; spec.maximumBlockSize = (juce::uint32) blockSize; @@ -63,19 +65,42 @@ void FilterBand::prepare(double sampleRate, int blockSize) left.prepare(spec); right.prepare(spec); + // Start from the current (target) parameters with no ramp. + targetGain = gainDb; + targetFreq = freq; + targetQ = q; + curGain = gainDb; + curFreq = freq; + curQ = q; + + updateCoeffs(); +} + +void FilterBand::setTargets(float gain, float freq, float q) +{ + gainDb = gain; + this->freq = freq; + this->q = q; + targetGain = gain; + targetFreq = freq; + targetQ = q; +} + +void FilterBand::updateCoeffs() +{ + const float db = juce::jlimit(-24.0f, 24.0f, curGain); Coeffs::Ptr c; - const float db = juce::jlimit(-24.0f, 24.0f, gainDb); switch (type) { case Type::lowShelf: - c = Coeffs::makeLowShelf(sampleRate, freq, q, juce::Decibels::decibelsToGain(db)); + c = Coeffs::makeLowShelf(sampleRate, curFreq, curQ, juce::Decibels::decibelsToGain(db)); break; case Type::highShelf: - c = Coeffs::makeHighShelf(sampleRate, freq, q, juce::Decibels::decibelsToGain(db)); + c = Coeffs::makeHighShelf(sampleRate, curFreq, curQ, juce::Decibels::decibelsToGain(db)); break; case Type::peak: default: - c = Coeffs::makePeakFilter(sampleRate, freq, q, juce::Decibels::decibelsToGain(db)); + c = Coeffs::makePeakFilter(sampleRate, curFreq, curQ, juce::Decibels::decibelsToGain(db)); break; } @@ -91,14 +116,52 @@ void FilterBand::reset() void FilterBand::process(juce::dsp::AudioBlock& block) { - if (block.getNumChannels() < 2) + const int numSamples = (int) block.getNumSamples(); + const auto numChannels = block.getNumChannels(); + + // One-pole (exponential) smoothing evaluated at block rate, using the real + // number of samples in this block so the ramp is independent of host buffer + // size. ~50 ms time constant: starts responding immediately and settles + // within ~200 ms, giving a gentle glide without the multi-second lag the old + // per-sample SmoothedValue ramp produced when only advanced once per block. + if (numSamples > 0 && numChannels > 0) + { + const float alpha = 1.0f - std::exp(-(float) numSamples / (sampleRate * 0.05f)); + + const float g = curGain + (targetGain - curGain) * alpha; + const float f = curFreq + (targetFreq - curFreq) * alpha; + const float qv = curQ + (targetQ - curQ) * alpha; + + if (std::abs(g - curGain) > 0.001f || + std::abs(f - curFreq) > 0.01f || + std::abs(qv - curQ) > 0.001f) + { + curGain = g; + curFreq = f; + curQ = qv; + updateCoeffs(); + } + } + + if (numChannels < 1) return; - // Smooth the coefficients to avoid zipper noise, then process. + // JUCE's IIR::Filter only processes mono (one channel pointer), so each + // instance must be run on a single channel's own block. Previously the whole + // (stereo) block was passed in, which made both filters operate on channel 0 + // only: the left went through twice and the right not at all. Splitting the + // channels fixes the dead right channel and the doubled (rumble) low end. left.snapToZero(); right.snapToZero(); - left.process(juce::dsp::ProcessContextReplacing(block)); - right.process(juce::dsp::ProcessContextReplacing(block)); + + auto leftBlock = block.getSingleChannelBlock(0); + left.process(juce::dsp::ProcessContextReplacing(leftBlock)); + + if (numChannels > 1) + { + auto rightBlock = block.getSingleChannelBlock(1); + right.process(juce::dsp::ProcessContextReplacing(rightBlock)); + } } // --------------------------------------------------------------------------- @@ -182,13 +245,17 @@ void SpectrumAnalyser::runAnalysis() fft.performFrequencyOnlyForwardTransform(fftBuffer.data()); // Magnitude values (non-negative frequencies) are in the first size/2+1 entries. + // Normalise so a full-scale sine reads as ~0 dB: JUCE's frequency-only FFT gives + // a magnitude of ~N/2 for a unit sine at its bin, and the Hann window halves that + // to N/4. Dividing by that reference keeps the -72..+12 dB display range meaningful. + const float norm = 0.25f * (float) fftSize; const int numBins = juce::jmin((int) fftBuffer.size(), fftSize / 2 + 1); juce::Array mags; mags.ensureStorageAllocated(numBins); for (int i = 0; i < numBins; ++i) { float mag = fftBuffer[(size_t) i]; - float db = 20.0f * std::log10(mag + 1.0e-9f); + float db = 20.0f * std::log10(mag / norm + 1.0e-9f); mags.add(db); } diff --git a/Source/EqualizerDSP.h b/Source/EqualizerDSP.h index 077257a..983bf9f 100644 --- a/Source/EqualizerDSP.h +++ b/Source/EqualizerDSP.h @@ -22,12 +22,22 @@ struct FilterBand void prepare(double sampleRate, int blockSize); void reset(); + // Sets the desired (target) values; the coefficients are then smoothed in + // `process` to avoid zipper noise when a slider is dragged. + void setTargets(float gain, float freq, float q); void process(juce::dsp::AudioBlock& block); using Filter = juce::dsp::IIR::Filter; using Coeffs = juce::dsp::IIR::Coefficients; Filter left, right; + +private: + void updateCoeffs(); + + double sampleRate = 44100.0; + float targetGain = 0.0f, targetFreq = 1000.0f, targetQ = 1.0f; + float curGain = 0.0f, curFreq = 1000.0f, curQ = 1.0f; }; // Computes the total summed magnitude response (in dB) of a series filter chain diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index ea21e19..83b1532 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -3,9 +3,11 @@ namespace gelyk { +static constexpr float fontScale = 1.4f; + static juce::Font monoFont(float height) { - return juce::Font(juce::FontOptions(juce::Font::getDefaultMonospacedFontName(), height, + return juce::Font(juce::FontOptions(juce::Font::getDefaultMonospacedFontName(), height * fontScale, juce::Font::plain)); } @@ -15,13 +17,18 @@ static juce::Font monoFont(float height) NeonLookAndFeel::NeonLookAndFeel() { - setColour(juce::Slider::textBoxTextColourId, cyan); + refreshPalette(); +} + +void NeonLookAndFeel::refreshPalette() +{ + 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); + 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, @@ -37,7 +44,7 @@ void NeonLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int widt const float tw = 2.0f; juce::Path track; track.addRoundedRectangle(cx - tw * 0.5f, (float) y, tw, (float) height, 1.0f); - g.setColour(line); + g.setColour(line()); g.fillPath(track); // Filled region from centre (0 dB) down to the handle, glowing. @@ -46,13 +53,13 @@ void NeonLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int widt 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.setColour(cyan()); g.fillPath(fill); // Handle. - g.setColour(slider.isMouseOverOrDragging(true) ? violet : cyan); + g.setColour(slider.isMouseOverOrDragging(true) ? violet() : cyan()); g.fillEllipse(cx - 5.0f, sliderPos - 5.0f, 10.0f, 10.0f); - g.setColour(bg); + g.setColour(bg()); g.fillEllipse(cx - 2.0f, sliderPos - 2.0f, 4.0f, 4.0f); } else @@ -60,9 +67,9 @@ void NeonLookAndFeel::drawLinearSlider(juce::Graphics& g, int x, int y, int widt const float th = 2.0f; juce::Path track; track.addRoundedRectangle((float) x, y + height * 0.5f - th * 0.5f, (float) width, th, 1.0f); - g.setColour(line); + g.setColour(line()); g.fillPath(track); - g.setColour(cyan); + g.setColour(cyan()); g.drawHorizontalLine((int) (y + height * 0.5f), sliderPos - 5.0f, sliderPos + 5.0f); } } @@ -78,17 +85,18 @@ void NeonLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int widt const float arcRadius = radius - lineW * 0.5f; // Background disc. - g.setColour(panel); + g.setColour(panel()); g.fillEllipse(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f); - g.setColour(line); + g.setColour(line()); g.drawEllipse(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f, 1.0f); // Filled arc (progress). juce::Path filledArc; filledArc.addCentredArc(cx, cy, arcRadius, arcRadius, 0.0f, - startAngle, toAngle, true); + startAngle + juce::MathConstants::halfPi, + toAngle + juce::MathConstants::halfPi, true); juce::PathStrokeType stroke(lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded); - g.setColour(slider.isMouseOverOrDragging(true) ? magenta : cyan); + g.setColour(slider.isMouseOverOrDragging(true) ? magenta() : cyan()); g.strokePath(filledArc, stroke); // Pointer line. @@ -97,13 +105,13 @@ void NeonLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int widt pointer.startNewSubPath(cx, cy); pointer.lineTo(cx + pointerLength * std::cos(toAngle), cy + pointerLength * std::sin(toAngle)); - g.setColour(slider.isMouseOverOrDragging(true) ? violet : bg); + g.setColour(slider.isMouseOverOrDragging(true) ? violet() : cyan()); g.strokePath(pointer, juce::PathStrokeType(juce::jmax(1.5f, radius * 0.07f), juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); // Centre hub. - g.setColour(slider.isMouseOverOrDragging(true) ? cyan : line); + g.setColour(slider.isMouseOverOrDragging(true) ? cyan() : line()); g.fillEllipse(cx - 2.0f, cy - 2.0f, 4.0f, 4.0f); } @@ -112,9 +120,9 @@ void NeonLookAndFeel::drawComboBox(juce::Graphics& g, int width, int height, boo { auto corner = 4.0f; auto bounds = box.getLocalBounds().toFloat(); - g.setColour(panel); + g.setColour(panel()); g.fillRoundedRectangle(bounds, corner); - g.setColour(cyan); + g.setColour(cyan()); g.drawRoundedRectangle(bounds, corner, 1.0f); juce::Rectangle arrowZone(width - 20, 0, 20, height); @@ -122,14 +130,136 @@ void NeonLookAndFeel::drawComboBox(juce::Graphics& g, int width, int height, boo 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.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(monoFont(13.0f)); + label.setFont(monoFont(13.0f * uiScale)); +} + +// --------------------------------------------------------------------------- +// BandFader +// --------------------------------------------------------------------------- + +void BandFader::paint(juce::Graphics& g) +{ + const float cx = getWidth() * 0.5f; + const float y = 0.0f; + const float height = (float) getHeight(); + + const float value = getGain(); + const auto start = (float) gainP->getNormalisableRange().start; + const auto end = (float) gainP->getNormalisableRange().end; + const float sliderPos = y + (end - value) / (end - start) * height; + + // Track. + const float tw = 2.0f; + g.setColour(lf.line()); + g.fillRect(cx - tw * 0.5f, y, tw, height); + + // Filled region from centre (0 dB) down to the handle. + const float midY = y + height * 0.5f; + const float top = juce::jmin(midY, sliderPos); + const float bottom = juce::jmax(midY, sliderPos); + g.setColour(lf.cyan()); + g.fillRect(cx - 3.0f, top, 6.0f, juce::jmax(1.0f, bottom - top)); + + // Handle. + g.setColour(hovered || dragging ? lf.violet() : lf.cyan()); + g.fillEllipse(cx - 5.0f, sliderPos - 5.0f, 10.0f, 10.0f); + g.setColour(lf.bg()); + g.fillEllipse(cx - 2.0f, sliderPos - 2.0f, 4.0f, 4.0f); +} + +void BandFader::mouseDown(const juce::MouseEvent& e) +{ + if (gainP == nullptr || freqP == nullptr) + return; + + if (e.getNumberOfClicks() > 1) + { + *gainP = 0.0f; + repaint(); + return; + } + + dragging = true; + startGain = (float) gainP->get(); + startFreq = (float) freqP->get(); + startX = e.getScreenX(); + startY = e.getScreenY(); + repaint(); +} + +void BandFader::mouseDrag(const juce::MouseEvent& e) +{ + if (!dragging || gainP == nullptr || freqP == nullptr) + return; + + const float h = (float) juce::jmax(1, getHeight()); + const float dy = (float) (startY - e.getScreenY()); + const float span = (float) gainP->getNormalisableRange().end - (float) gainP->getNormalisableRange().start; + const float newGain = juce::jlimit((float) gainP->getNormalisableRange().start, + (float) gainP->getNormalisableRange().end, + startGain + dy / h * span); + if (gainP->get() != newGain) + *gainP = newGain; + + const float w = dragWidth > 0.0f ? dragWidth : (float) juce::jmax(1, getWidth()); + const float dx = (float) (e.getScreenX() - startX); + // Track 1:1 with the cursor over the full visualizer width: the fader's screen + // position moves by exactly the same number of pixels as the mouse. + const float startXpos = bandFreqToX(startFreq, dragX0, w); + float newFreq = bandXToFreq(startXpos + dx, dragX0, w); + newFreq = juce::jlimit(20.0f, 20000.0f, newFreq); + + // Keep the band between its neighbours so they stay ordered left-to-right. + if (onGetFreqBounds != nullptr) + { + const auto [lo, hi] = onGetFreqBounds(); + newFreq = juce::jlimit(lo, hi, newFreq); + } + + if (std::abs(newFreq - (float) freqP->get()) > 0.5f) + { + *freqP = newFreq; + if (onFreqChange != nullptr) + onFreqChange(); + } + + repaint(); +} + +void BandFader::mouseUp(const juce::MouseEvent&) +{ + dragging = false; + repaint(); +} + +void BandFader::mouseEnter(const juce::MouseEvent&) +{ + hovered = true; + repaint(); +} + +void BandFader::mouseExit(const juce::MouseEvent&) +{ + hovered = false; + repaint(); +} + +void BandFader::mouseWheelMove(const juce::MouseEvent&, const juce::MouseWheelDetails& wheel) +{ + if (qP == nullptr) + return; + + const float delta = (float) (wheel.deltaY * 0.3); + const float lo = (float) qP->getNormalisableRange().start; + const float hi = (float) qP->getNormalisableRange().end; + *qP = juce::jlimit(lo, hi, (float) qP->get() + delta); } // --------------------------------------------------------------------------- @@ -143,9 +273,9 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& setLookAndFeel(&lookAndFeel); setOpaque(true); - titleLabel = std::make_unique("title", "GELYKEQ"); + titleLabel = std::make_unique("title", "GELYK"); titleLabel->setFont(monoFont(26.0f)); - titleLabel->setColour(juce::Label::textColourId, lookAndFeel.cyan); + titleLabel->setColour(juce::Label::textColourId, lookAndFeel.cyan()); titleLabel->setJustificationType(juce::Justification::centredLeft); addAndMakeVisible(*titleLabel); @@ -160,10 +290,69 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& scaleBox->onChange = [this] { scaleUI((float) scaleBox->getSelectedId()); }; addAndMakeVisible(*scaleBox); + const struct ColorControl + { + std::unique_ptr* label; + std::unique_ptr* slider; + const char* text; + double min, max, def; + const char* tip; + } colorControls[] = { + { &waveSensLabel, &waveSensSlider, "SENS", -12.0, 12.0, 0.0, "Live waveform sensitivity (dB)" }, + { &colorHueLabel, &colorHueSlider, "HUE", -0.5, 0.5, 0.0, "Master colour hue shift" }, + { &colorSatLabel, &colorSatSlider, "SAT", 0.0, 1.0, 1.0, "Master colour saturation" }, + { &colorBriLabel, &colorBriSlider, "BRI", 0.5, 1.5, 1.0, "Master colour brightness" }, + { &waveLabel, &waveSlider, "WAVE", 0.0, 1.0, 0.2, "Live waveform opacity" }, + }; + + for (const auto& cc : colorControls) + { + *cc.label = std::make_unique("ccap", cc.text); + (*cc.label)->setFont(monoFont(8.0f)); + (*cc.label)->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + (*cc.label)->setJustificationType(juce::Justification::centred); + addAndMakeVisible(**cc.label); + + *cc.slider = std::make_unique(juce::Slider::LinearHorizontal, juce::Slider::NoTextBox); + (*cc.slider)->setLookAndFeel(&lookAndFeel); + (*cc.slider)->setSliderStyle(juce::Slider::LinearHorizontal); + (*cc.slider)->setRange(cc.min, cc.max, 0.01); + (*cc.slider)->setDoubleClickReturnValue(true, cc.def); + (*cc.slider)->setValue(cc.def, juce::dontSendNotification); + (*cc.slider)->setTooltip(cc.tip); + addAndMakeVisible(**cc.slider); + } + + colorHueSlider->onValueChange = [this] + { + lookAndFeel.hueShift = (float) colorHueSlider->getValue(); + refreshColors(); + }; + colorSatSlider->onValueChange = [this] + { + lookAndFeel.saturation = (float) colorSatSlider->getValue(); + refreshColors(); + }; + colorBriSlider->onValueChange = [this] + { + lookAndFeel.brightness = (float) colorBriSlider->getValue(); + refreshColors(); + }; + waveSlider->onValueChange = [this] + { + lookAndFeel.waveformOpacity = (float) waveSlider->getValue(); + repaint(); + }; + waveSensSlider->onValueChange = [this] + { + lookAndFeel.waveSensitivity = (float) waveSensSlider->getValue(); + repaint(); + }; + // Master gain (vertical fader, far right). masterLabel = std::make_unique("mlab", "MASTER"); masterLabel->setFont(monoFont(11.0f)); - masterLabel->setColour(juce::Label::textColourId, lookAndFeel.magenta); + masterLabel->setColour(juce::Label::textColourId, lookAndFeel.magenta()); masterLabel->setJustificationType(juce::Justification::centred); master = std::make_unique(juce::Slider::LinearVertical, juce::Slider::TextBoxBelow); master->setLookAndFeel(&lookAndFeel); @@ -173,15 +362,23 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& master->setDoubleClickReturnValue(true, 0.0); addAndMakeVisible(*masterLabel); addAndMakeVisible(*master); + + masterPercent = std::make_unique("mper", "100%"); + masterPercent->setFont(monoFont(10.0f)); + masterPercent->setColour(juce::Label::textColourId, lookAndFeel.cyan()); + masterPercent->setJustificationType(juce::Justification::centred); + addAndMakeVisible(*masterPercent); + updateMasterPercent(); masterAttachment = std::make_unique( processor.getAPVTS(), "master", *master); + master->onValueChange = [this] { updateMasterPercent(); }; // Band faders. const char* const names[numBands] = { "LOW SHELF", "LOW", "LOW-MID", "MID", "HIGH-MID", "HIGH", "HIGH SHELF" }; const char* const freqs[numBands] = { "60", "160", "400", "800", "1.6K", "4K", "10K" }; for (int i = 0; i < numBands; ++i) { auto& w = bands[i]; - const juce::Colour accent = (i % 2 == 0) ? lookAndFeel.cyan : lookAndFeel.magenta; + const juce::Colour accent = (i % 2 == 0) ? lookAndFeel.cyan() : lookAndFeel.magenta(); w.freqLabel = std::make_unique("freq", freqs[i]); w.freqLabel->setFont(monoFont(14.0f).boldened()); @@ -190,7 +387,7 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& w.gainCaption = std::make_unique("gname", names[i]); w.gainCaption->setFont(monoFont(10.0f)); - w.gainCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim); + w.gainCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); w.gainCaption->setJustificationType(juce::Justification::centred); w.gainValue = std::make_unique("gval", "0.0"); @@ -200,23 +397,59 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& w.qCaption = std::make_unique("qcap", "Q"); w.qCaption->setFont(monoFont(10.0f)); - w.qCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim); + w.qCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); w.qCaption->setJustificationType(juce::Justification::centred); w.qValue = std::make_unique("qval", "1.00"); w.qValue->setFont(monoFont(10.0f)); - w.qValue->setColour(juce::Label::textColourId, lookAndFeel.violet); + w.qValue->setColour(juce::Label::textColourId, lookAndFeel.violet()); w.qValue->setJustificationType(juce::Justification::centred); - w.gain = std::make_unique(juce::Slider::LinearVertical, juce::Slider::NoTextBox); - w.gain->setLookAndFeel(&lookAndFeel); - w.gain->setSliderStyle(juce::Slider::LinearVertical); - w.gain->setRange(-24.0, 24.0, 0.01); - w.gain->setDoubleClickReturnValue(true, 0.0); - w.gain->setValue(0.0, juce::dontSendNotification); - w.gain->onValueChange = [this, i] + w.gain = std::make_unique(processor.getAPVTS(), + "gain" + juce::String(i), + "freq" + juce::String(i), + "q" + juce::String(i), + lookAndFeel); + w.gain->onFreqChange = [this] { - bands[i].gainValue->setText(juce::String(bands[i].gain->getValue(), 1), juce::dontSendNotification); + resized(); + }; + + // Keep bands in left-to-right frequency order: each band can only go up + // to its right-hand neighbour and down to its left-hand neighbour. + w.gain->onGetFreqBounds = [this, i]() -> std::pair + { + auto& apvts = processor.getAPVTS(); + float lo = 20.0f; + float hi = 20000.0f; + if (i > 0) + lo = apvts.getRawParameterValue("freq" + juce::String(i - 1))->load() * 1.01f; + if (i < numBands - 1) + hi = apvts.getRawParameterValue("freq" + juce::String(i + 1))->load() * 0.99f; + return { juce::jmax(20.0f, lo), juce::jmin(20000.0f, hi) }; + }; + + w.levelValue = std::make_unique("lval", "0.0"); + w.levelValue->setFont(monoFont(10.0f)); + w.levelValue->setColour(juce::Label::textColourId, accent); + w.levelValue->setJustificationType(juce::Justification::centred); + + w.levelCaption = std::make_unique("lcap", "LEVEL"); + w.levelCaption->setFont(monoFont(10.0f)); + w.levelCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + w.levelCaption->setJustificationType(juce::Justification::centred); + + w.level = std::make_unique(juce::Slider::RotaryVerticalDrag, juce::Slider::NoTextBox); + w.level->setLookAndFeel(&lookAndFeel); + w.level->setSliderStyle(juce::Slider::RotaryVerticalDrag); + w.level->setRotaryParameters(2.4f, 6.9f, true); // ~270° sweep + w.level->setRange(-24.0, 24.0, 0.01); + w.level->setDoubleClickReturnValue(true, 0.0); + w.level->setValue(0.0, juce::dontSendNotification); + w.level->setTooltip("Gain (level), also adjustable via the fader"); + w.level->onValueChange = [this, i] + { + updateGainValue(i); }; w.q = std::make_unique(juce::Slider::RotaryVerticalDrag, juce::Slider::NoTextBox); @@ -233,12 +466,12 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& w.freqCaption = std::make_unique("fcap", "FREQ"); w.freqCaption->setFont(monoFont(10.0f)); - w.freqCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim); + 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->setColour(juce::Label::textColourId, lookAndFeel.cyan()); w.freqValue->setJustificationType(juce::Justification::centred); w.freq = std::make_unique(juce::Slider::RotaryVerticalDrag, juce::Slider::NoTextBox); @@ -258,12 +491,16 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& else txt = juce::String((int) f); bands[i].freqValue->setText(txt, juce::dontSendNotification); + bands[i].freqLabel->setText(txt, juce::dontSendNotification); }; addAndMakeVisible(*w.freqLabel); addAndMakeVisible(*w.gainCaption); addAndMakeVisible(*w.gainValue); addAndMakeVisible(*w.gain); + addAndMakeVisible(*w.levelValue); + addAndMakeVisible(*w.levelCaption); + addAndMakeVisible(*w.level); addAndMakeVisible(*w.freqCaption); addAndMakeVisible(*w.freqValue); addAndMakeVisible(*w.freq); @@ -271,8 +508,8 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& addAndMakeVisible(*w.q); addAndMakeVisible(*w.qValue); - gainAttachments[i] = std::make_unique( - processor.getAPVTS(), "gain" + juce::String(i), *w.gain); + levelAttachments[i] = std::make_unique( + processor.getAPVTS(), "gain" + juce::String(i), *w.level); qAttachments[i] = std::make_unique( processor.getAPVTS(), "q" + juce::String(i), *w.q); freqAttachments[i] = std::make_unique( @@ -282,13 +519,13 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& // Status bar. statusGit = std::make_unique("git", processor.getGitInfo()); statusGit->setFont(monoFont(11.0f)); - statusGit->setColour(juce::Label::textColourId, lookAndFeel.textDim); + statusGit->setColour(juce::Label::textColourId, lookAndFeel.textDim()); statusGit->setJustificationType(juce::Justification::centredRight); statusBar = std::make_unique("status", - "GELYKEQ v" + juce::String(ProjectInfo::versionString) + " | ANALYSER"); + "GELYK v" + juce::String(ProjectInfo::versionString) + ""); statusBar->setFont(monoFont(11.0f)); - statusBar->setColour(juce::Label::textColourId, lookAndFeel.cyan.withAlpha(0.8f)); + statusBar->setColour(juce::Label::textColourId, lookAndFeel.cyan().withAlpha(0.8f)); statusBar->setJustificationType(juce::Justification::centredLeft); addAndMakeVisible(*statusBar); addAndMakeVisible(*statusGit); @@ -298,6 +535,7 @@ GelykEQAudioProcessorEditor::GelykEQAudioProcessorEditor(GelykEQAudioProcessor& eqCurve.resize(128); startTimerHz(30); + refreshColors(); scaleUI(100.0f); } @@ -332,12 +570,95 @@ void GelykEQAudioProcessorEditor::rebuildFaders() { } +void GelykEQAudioProcessorEditor::updateGainValue(int i) +{ + const juce::String txt(juce::String(bands[i].level->getValue(), 1)); + bands[i].gainValue->setText(txt, juce::dontSendNotification); + bands[i].levelValue->setText(txt, juce::dontSendNotification); +} + +void GelykEQAudioProcessorEditor::updateMasterPercent() +{ + const float db = (float) master->getValue(); + const float percent = std::pow(10.0f, db / 20.0f) * 100.0f; + masterPercent->setText(juce::String(percent, 0) + "%", juce::dontSendNotification); +} + +void GelykEQAudioProcessorEditor::refreshColors() +{ + lookAndFeel.refreshPalette(); + + titleLabel->setColour(juce::Label::textColourId, lookAndFeel.cyan()); + masterLabel->setColour(juce::Label::textColourId, lookAndFeel.magenta()); + masterPercent->setColour(juce::Label::textColourId, lookAndFeel.cyan()); + // The scale dropdown and the master value readout read their text colour from + // the LookAndFeel at construction, so they must be re-applied explicitly here + // for the saturation (and hue/brightness) faders to affect them. + scaleBox->setColour(juce::ComboBox::textColourId, lookAndFeel.cyan()); + master->setColour(juce::Slider::textBoxTextColourId, lookAndFeel.cyan()); + master->setColour(juce::Slider::textBoxBackgroundColourId, lookAndFeel.panel()); + statusBar->setColour(juce::Label::textColourId, lookAndFeel.cyan().withAlpha(0.8f)); + statusGit->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + colorHueLabel->setColour(juce::Label::textColourId, lookAndFeel.cyan()); + colorSatLabel->setColour(juce::Label::textColourId, lookAndFeel.magenta()); + colorBriLabel->setColour(juce::Label::textColourId, lookAndFeel.violet()); + waveLabel->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + waveSensLabel->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + for (int i = 0; i < numBands; ++i) + { + auto& w = bands[i]; + const juce::Colour accent = (i % 2 == 0) ? lookAndFeel.cyan() : lookAndFeel.magenta(); + w.freqLabel->setColour(juce::Label::textColourId, accent); + w.gainCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + w.gainValue->setColour(juce::Label::textColourId, accent); + w.levelCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + w.levelValue->setColour(juce::Label::textColourId, accent); + w.qCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + w.qValue->setColour(juce::Label::textColourId, lookAndFeel.violet()); + w.freqCaption->setColour(juce::Label::textColourId, lookAndFeel.textDim()); + w.freqValue->setColour(juce::Label::textColourId, lookAndFeel.cyan()); + } + + repaint(); +} + void GelykEQAudioProcessorEditor::scaleUI(float percent) { const float s = percent / 100.0f; scaling.store(s); + lookAndFeel.uiScale = s; setSize((int) (1180 * s), (int) (640 * s)); resized(); + refreshFonts(s); +} + +void GelykEQAudioProcessorEditor::refreshFonts(float s) +{ + titleLabel->setFont(monoFont(26.0f * s)); + masterLabel->setFont(monoFont(11.0f * s)); + statusGit->setFont(monoFont(11.0f * s)); + statusBar->setFont(monoFont(11.0f * s)); + colorHueLabel->setFont(monoFont(8.0f * s)); + colorSatLabel->setFont(monoFont(8.0f * s)); + colorBriLabel->setFont(monoFont(8.0f * s)); + waveLabel->setFont(monoFont(8.0f * s)); + waveSensLabel->setFont(monoFont(8.0f * s)); + + for (int i = 0; i < numBands; ++i) + { + auto& w = bands[i]; + w.freqLabel->setFont(monoFont(14.0f * s).boldened()); + w.gainCaption->setFont(monoFont(10.0f * s)); + w.gainValue->setFont(monoFont(11.0f * s)); + w.levelCaption->setFont(monoFont(10.0f * s)); + w.levelValue->setFont(monoFont(10.0f * s)); + w.qCaption->setFont(monoFont(10.0f * s)); + w.qValue->setFont(monoFont(10.0f * s)); + w.freqCaption->setFont(monoFont(10.0f * s)); + w.freqValue->setFont(monoFont(10.0f * s)); + } + + repaint(); } void GelykEQAudioProcessorEditor::resized() @@ -354,6 +675,18 @@ void GelykEQAudioProcessorEditor::resized() titleLabel->setBounds(header.removeFromLeft((int) (180 * s)).reduced(pad, 0)); scaleBox->setBounds(header.removeFromRight((int) (90 * s)).reduced((int)(6*s), (int)(9*s))); + // Colour controls, just left of the scaling dropdown. + juce::Rectangle colorArea = header.removeFromRight((int) (360 * s)).reduced((int)(2*s), (int)(9*s)); + std::unique_ptr* colorSliders[] = { &waveSensSlider, &colorHueSlider, &colorSatSlider, &colorBriSlider, &waveSlider }; + std::unique_ptr* colorLabels[] = { &waveSensLabel, &colorHueLabel, &colorSatLabel, &colorBriLabel, &waveLabel }; + int cellW = colorArea.getWidth() / 5; + for (int i = 0; i < 5; ++i) + { + juce::Rectangle cell = colorArea.removeFromLeft(cellW); + (*colorLabels[i])->setBounds(cell.removeFromTop((int)(13*s))); + (*colorSliders[i])->setBounds(cell); + } + // Footer. juce::Rectangle footer = area.removeFromBottom(footerH); footer.reduce(pad, 0); @@ -375,48 +708,66 @@ void GelykEQAudioProcessorEditor::resized() for (int i = 0; i < numBands; ++i) { auto& w = bands[i]; + const int captionH = (int) (20 * s); + const int valueH = (int) (17 * s); + const int freqH = (int) (23 * s); + const int knobStackH = (int) (16 * s) + (int)(48 * s) + (int)(12 * s); // value + knob + caption + + // Fixed, evenly spaced column for all labels and the knob row. juce::Rectangle col(x0 + i * (colW + gap), y0, colW, height); - - const int captionH = (int) (18 * s); - const int valueH = (int) (15 * s); - const int freqH = (int) (20 * 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 - knobStackH - (int)(6*s))); + // Only the fader slides horizontally to the band's frequency on the shared + // log axis; everything below stays anchored to the fixed column. + juce::Rectangle body = col; + const int faderH = body.getHeight() - freqH - knobStackH - (int)(6 * s); - w.freqLabel->setBounds(col.removeFromTop(freqH)); + const float f = processor.getAPVTS().getRawParameterValue("freq" + juce::String(i))->load(); + const int cx = (int) bandFreqToX(f, (float) x0, (float) totalW); + const int faderX = juce::jlimit(x0, x0 + totalW - colW, cx - colW / 2); + w.gain->setBounds(faderX, body.getY(), colW, faderH); + w.gain->setDragSpace((float) x0, (float) totalW); - // Bottom control row: FREQ and Q knobs side by side. - juce::Rectangle knobRow = col; + juce::Rectangle rest = col; + rest.removeFromTop(faderH + (int)(6 * s)); + w.freqLabel->setBounds(rest.removeFromTop(freqH)); + + // Bottom control row: LEVEL, FREQ and Q knobs side by side. + juce::Rectangle knobRow = rest; + bandSections[i] = knobRow.toFloat(); int rowW = knobRow.getWidth(); - int halfW = rowW / 2; + int thirdW = rowW / 3; - // 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)); + // LEVEL (left third). + juce::Rectangle levelCol = knobRow.removeFromLeft(thirdW); + w.levelValue->setBounds(levelCol.removeFromTop((int)(16*s))); + w.level->setBounds(levelCol.removeFromTop((int)(48*s)).reduced((int)(colW*0.08f), 0)); + w.levelCaption->setBounds(levelCol.removeFromTop((int)(12*s))); + + // FREQ (middle third). + juce::Rectangle freqCol = knobRow.removeFromLeft(thirdW); + w.freqValue->setBounds(freqCol.removeFromTop((int)(16*s))); + w.freq->setBounds(freqCol.removeFromTop((int)(48*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)); + // Q (right third). + w.qValue->setBounds(knobRow.removeFromTop((int)(16*s))); + w.q->setBounds(knobRow.removeFromTop((int)(48*s)).reduced((int)(colW*0.08f), 0)); w.qCaption->setBounds(knobRow.removeFromTop((int)(12*s))); } // Master column. juce::Rectangle msub = masterArea.reduced((int)(14*s), (int)(6*s)); - masterLabel->setBounds(msub.removeFromTop((int) (20 * s))); + masterLabel->setBounds(msub.removeFromTop((int) (22 * s))); + masterPercent->setBounds(msub.removeFromTop((int) (16 * s))); master->setBounds(msub); } void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) { const float s = scaling.load(); - g.fillAll(lookAndFeel.bg); + g.fillAll(lookAndFeel.bg()); juce::Rectangle area = getLocalBounds(); const int headerH = (int) (46 * s); @@ -426,13 +777,18 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) mainArea.removeFromBottom(footerH); mainArea.removeFromRight((int) (64 * s)); - const int topPad = (int) (20 * s); - const int botPad = (int) (28 * s); - const float topY = (float) (mainArea.getY() + topPad); - const float botY = (float) (mainArea.getY() + mainArea.getHeight() - botPad); + // The visualizer is the gain-fader field, bounded above by the header controls + // and below by the LEVEL/FREQ/Q knob row. Keep the waveform/spectrum and grid + // strictly inside it so they don't spill over the knobs. + const float captionH = 20.0f * s; + const float valueH = 17.0f * s; + const float freqH = 23.0f * s; + const float knobStackH = (16.0f + 48.0f + 12.0f) * s; + const float topY = (float) mainArea.getY() + captionH + valueH; + const float botY = (float) mainArea.getBottom() - freqH - knobStackH - 6.0f * s; // Neon grid lines. - g.setColour(lookAndFeel.line); + g.setColour(lookAndFeel.line()); for (int i = 1; i <= 4; ++i) { if (i == 3) continue; // skip the 0 dB centre (drawn separately) @@ -442,21 +798,27 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) // 0 dB centre line (glowing cyan). const float centreLineY = topY + (botY - topY) * 0.5f; - g.setColour(lookAndFeel.cyan.withAlpha(0.55f)); + g.setColour(lookAndFeel.cyan().withAlpha(0.55f)); g.drawHorizontalLine((int) centreLineY, (float) mainArea.getX(), (float) mainArea.getRight()); // Spectrum overlay. if (spectrum.size() > 2) { + const float waveAlpha = lookAndFeel.waveformOpacity; + const float sensFactor = std::pow(10.0f, lookAndFeel.waveSensitivity / 20.0f); juce::Path path; const float x0 = (float) mainArea.getX(); const float width = (float) mainArea.getWidth(); for (int i = 0; i < spectrum.size(); ++i) { - float db = juce::jlimit(-72.0f, 12.0f, spectrum[i]); + float db = juce::Decibels::gainToDecibels( + juce::Decibels::decibelsToGain(spectrum[i]) * sensFactor); + db = juce::jlimit(-72.0f, 12.0f, db); float norm = (db + 72.0f) / 84.0f; // 0..1 up - float xx = x0 + (float) i / (float) (spectrum.size() - 1) * width; + float xx = (i < freqBins.size()) + ? bandFreqToX(freqBins[i], x0, width) + : 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); @@ -467,12 +829,12 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) filled.lineTo((float) mainArea.getX(), botY); filled.closeSubPath(); - g.setGradientFill(juce::ColourGradient(lookAndFeel.magenta.withAlpha(0.30f), + g.setGradientFill(juce::ColourGradient(lookAndFeel.magenta().withAlpha(0.30f * waveAlpha), (float) mainArea.getX(), topY, - lookAndFeel.magenta.withAlpha(0.05f), + lookAndFeel.magenta().withAlpha(0.05f * waveAlpha), (float) mainArea.getX(), botY, false)); g.fillPath(filled); - g.setColour(lookAndFeel.cyan.withAlpha(0.9f)); + g.setColour(lookAndFeel.cyan().withAlpha(0.9f * waveAlpha)); g.strokePath(path, juce::PathStrokeType(1.6f * s)); } @@ -489,18 +851,31 @@ void GelykEQAudioProcessorEditor::paint(juce::Graphics& g) { 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; + // eqCurve bins are log-spaced from 20 Hz to 20 kHz; use the same + // log-frequency mapping as the columns so peaks line up with bands. + const float f = 20.0f * std::pow(1000.0f, (float) i / (float) (eqCurve.size() - 1)); + float xx = bandFreqToX(f, x0, width); if (i == 0) curve.startNewSubPath(xx, yy); else curve.lineTo(xx, yy); } - g.setColour(lookAndFeel.violet.withAlpha(0.20f)); + 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.setColour(lookAndFeel.violet()); g.strokePath(curve, juce::PathStrokeType(1.8f * s, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); } + + // Very light rounded outline around each band's knob group (LEVEL/FREQ/Q). + g.setColour(lookAndFeel.line()); + const float corner = 6.0f * s; + const float inset = 2.0f * s; + for (int i = 0; i < numBands; ++i) + { + juce::Rectangle r = bandSections[i].reduced(inset); + g.drawRoundedRectangle(r, corner, 1.0f * s); + } } } // namespace gelyk diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index ee5c2d7..c155915 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -21,13 +21,115 @@ public: 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 }; + juce::Colour cyan() const { return transform(baseCyan); } + juce::Colour magenta() const { return transform(baseMagenta); } + juce::Colour violet() const { return transform(baseViolet); } + juce::Colour bg() const { return transform(baseBg); } + juce::Colour panel() const { return transform(basePanel); } + juce::Colour line() const { return transform(baseLine); } + juce::Colour textDim() const { return transform(baseTextDim); } + + void refreshPalette(); + + float hueShift = 0.0f; + float saturation = 1.0f; + float brightness = 1.0f; + float waveformOpacity = 0.2f; + float waveSensitivity = 0.0f; // in dB, added to the spectrum display level + float uiScale = 1.0f; + +private: + juce::Colour transform(juce::Colour c) const + { + return c.withRotatedHue(hueShift) + .withMultipliedSaturation(saturation) + .withMultipliedBrightness(brightness); + } + + const juce::Colour baseCyan { 0xff00e5ff }; + const juce::Colour baseMagenta{ 0xffff2fd6 }; + const juce::Colour baseViolet { 0xff8a2be2 }; + const juce::Colour baseBg { 0xff0b0f16 }; + const juce::Colour basePanel { 0xff121a24 }; + const juce::Colour baseLine { 0xff1f2a38 }; + const juce::Colour baseTextDim{ 0xff7a8aa0 }; +}; + +// --------------------------------------------------------------------------- + +// Log-frequency horizontal mapping shared by the band columns and the EQ curve, +// so a band's position in the visualizer always lines up with the curve peak at +// the same frequency. 20 Hz sits at the left edge, 20 kHz at the right. +inline float bandLogNorm(float freq) +{ + return (std::log10(freq) - std::log10(20.0f)) / (std::log10(20000.0f) - std::log10(20.0f)); +} +inline float bandFreqToX(float freq, float x0, float width) +{ + return x0 + bandLogNorm(freq) * width; +} +inline float bandXToFreq(float x, float x0, float width) +{ + const float norm = (width > 0.0f) ? (x - x0) / width : 0.0f; + return std::pow(10.0f, std::log10(20.0f) + norm * (std::log10(20000.0f) - std::log10(20.0f))); +} + +// The large fader of each band. Dragging vertically changes the level, dragging +// horizontally changes the band's frequency, and scrolling the mouse wheel +// nudges the band's Q. All three write straight to the plugin's parameters. +class BandFader final : public juce::Component +{ +public: + BandFader(juce::AudioProcessorValueTreeState& apvts, + const juce::String& gainId, const juce::String& freqId, + const juce::String& qId, NeonLookAndFeel& lnf) + : gainP(dynamic_cast(apvts.getParameter(gainId))), + freqP(dynamic_cast(apvts.getParameter(freqId))), + qP(dynamic_cast(apvts.getParameter(qId))), + lf(lnf) + { + setRepaintsOnMouseActivity(false); + } + + void paint(juce::Graphics&) override; + void mouseDown(const juce::MouseEvent&) override; + void mouseDrag(const juce::MouseEvent&) override; + void mouseUp(const juce::MouseEvent&) override; + void mouseEnter(const juce::MouseEvent&) override; + void mouseExit(const juce::MouseEvent&) override; + void mouseWheelMove(const juce::MouseEvent&, const juce::MouseWheelDetails&) override; + + float getGain() const { return gainP != nullptr ? (float) gainP->get() : 0.0f; } + + // The x-origin and width of the full visualizer area, used so a horizontal + // drag moves the fader 1:1 with the mouse cursor. + void setDragSpace(float x0, float width) + { + dragX0 = x0; + dragWidth = width; + } + + // Ask the containing editor to redispose the columns after a frequency drag. + std::function onFreqChange; + + // Returns the ordering bounds [minFreq, maxFreq] this band's frequency is + // allowed to take, so bands keep their left-to-right order. + std::function()> onGetFreqBounds; + +private: + juce::AudioParameterFloat* gainP; + juce::AudioParameterFloat* freqP; + juce::AudioParameterFloat* qP; + NeonLookAndFeel& lf; + + bool dragging = false; + bool hovered = false; + float startGain = 0.0f; + float startFreq = 1000.0f; + int startX = 0; + int startY = 0; + float dragX0 = 0.0f; + float dragWidth = 1.0f; }; // --------------------------------------------------------------------------- @@ -46,6 +148,10 @@ public: private: void rebuildFaders(); void scaleUI(float percent); + void refreshColors(); + void refreshFonts(float s); + void updateGainValue(int i); + void updateMasterPercent(); GelykEQAudioProcessor& processor; @@ -54,12 +160,15 @@ private: static constexpr int numBands = 7; struct BandWidget { - std::unique_ptr gain; + std::unique_ptr gain; + std::unique_ptr level; std::unique_ptr q; std::unique_ptr freq; std::unique_ptr freqLabel; std::unique_ptr gainCaption; std::unique_ptr gainValue; + std::unique_ptr levelCaption; + std::unique_ptr levelValue; std::unique_ptr qCaption; std::unique_ptr qValue; std::unique_ptr freqCaption; @@ -68,6 +177,7 @@ private: BandWidget bands[numBands]; std::unique_ptr masterLabel; + std::unique_ptr masterPercent; std::unique_ptr master; std::unique_ptr scaleBox; @@ -76,7 +186,19 @@ private: std::unique_ptr statusBar; std::unique_ptr statusGit; - std::unique_ptr gainAttachments[numBands]; + std::unique_ptr colorHueLabel; + std::unique_ptr colorSatLabel; + std::unique_ptr colorBriLabel; + std::unique_ptr waveLabel; + std::unique_ptr waveSensLabel; + + std::unique_ptr colorHueSlider; + std::unique_ptr colorSatSlider; + std::unique_ptr colorBriSlider; + std::unique_ptr waveSlider; + std::unique_ptr waveSensSlider; + + std::unique_ptr levelAttachments[numBands]; std::unique_ptr qAttachments[numBands]; std::unique_ptr freqAttachments[numBands]; std::unique_ptr masterAttachment; @@ -84,6 +206,7 @@ private: juce::Array spectrum; juce::Array freqBins; juce::Array eqCurve; + juce::Rectangle bandSections[numBands]; // knob-group outlines 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 7aae3ed..4d3aecd 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -135,21 +135,13 @@ void GelykEQAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce: analyser.push(chans, numChannels > 1 ? 2 : 1, numSamples); } - // Refresh band coefficients (cheap) from smoothed params each block. + // Refresh band coefficient targets (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(); 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); - } + bands[i].setTargets(gain, freq, q); } juce::dsp::AudioBlock block(buffer);