saftpumpe/Source/PluginEditor.cpp

857 lines
37 KiB
C++
Raw Normal View History

2026-07-12 14:18:24 +02:00
#include "PluginEditor.h"
static juce::String formatTime(float seconds) {
if (seconds >= 1.0f) return juce::String(seconds, 2) + "s";
if (seconds >= 0.001f) return juce::String(seconds * 1000.0f, 1) + "ms";
return juce::String(seconds * 1000.0f, 1) + "ms";
}
const std::vector<SaftpumpeEditor::Preset>& SaftpumpeEditor::getPresets() {
static const std::vector<Preset> presets = {
{"-- None --", 0.0f, 1.0f, 0.030f, 0.30f, 6.0f, 0.0f, 1.0f, 1.0f},
{"Mastering 0dB", -2.0f, 2.0f, 0.020f, 0.20f, 6.0f, 0.0f, 1.0f, 1.0f},
{"Mastering -1dB", -6.0f, 2.5f, 0.015f, 0.18f, 6.0f, 1.0f, 1.0f, 1.0f},
{"Mastering -2dB", -10.0f, 3.0f, 0.010f, 0.15f, 8.0f, 1.5f, 1.0f, 1.0f},
{"Mastering -3dB", -14.0f, 4.0f, 0.010f, 0.12f, 8.0f, 2.0f, 1.0f, 1.0f},
{"Kick Punch", -18.0f, 6.0f, 0.003f, 0.10f, 10.0f, 3.0f, 0.8f, 0.0f},
};
return presets;
}
void SaftpumpeEditor::applyPreset(int index) {
auto& presets = getPresets();
if (index < 0 || index >= (int)presets.size()) return;
auto& p = presets[index];
thresholdSlider.setValue(p.threshold, juce::sendNotificationSync);
ratioSlider.setValue(p.ratio, juce::sendNotificationSync);
attackSlider.setValue(p.attack, juce::sendNotificationSync);
releaseSlider.setValue(p.release, juce::sendNotificationSync);
kneeSlider.setValue(p.knee, juce::sendNotificationSync);
makeupSlider.setValue(p.makeup, juce::sendNotificationSync);
mixSlider.setValue(p.mix, juce::sendNotificationSync);
autoReleaseSlider.setValue(p.autoRelease, juce::sendNotificationSync);
}
SaftpumpeEditor::SaftpumpeEditor(SaftpumpeProcessor& p)
: AudioProcessorEditor(&p), processorRef(p)
{
setSize(700, 560);
2026-07-12 14:18:24 +02:00
setResizable(true, this);
setResizeLimits(650, 480, 1100, 900);
2026-07-12 14:18:24 +02:00
startTimerHz(30);
auto setupKnob = [this](juce::Slider& s, juce::Label& l, const juce::String& text,
const juce::String& pid, float min, float max, float def) {
s.setLookAndFeel(&lnf);
s.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag);
s.setTextBoxStyle(juce::Slider::NoTextBox, true, 0, 0);
s.setRange(min, max, 0.001);
s.setValue(def, juce::dontSendNotification);
s.setPopupDisplayEnabled(true, true, this);
addAndMakeVisible(s);
l.setText(text, juce::dontSendNotification);
l.setJustificationType(juce::Justification::centred);
l.setColour(juce::Label::textColourId, textColour);
l.setFont(juce::Font(juce::FontOptions(11.0f)));
addAndMakeVisible(l);
};
setupKnob(thresholdSlider, thresholdLabel, "THRESHOLD", "threshold", -48.0, 0.0, -20.0);
thresholdAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "threshold", thresholdSlider);
setupKnob(ratioSlider, ratioLabel, "RATIO", "ratio", 1.0, 20.0, 4.0);
ratioAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "ratio", ratioSlider);
setupKnob(attackSlider, attackLabel, "ATTACK", "attack", 0.001, 0.1, 0.03);
attackAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "attack", attackSlider);
setupKnob(releaseSlider, releaseLabel, "RELEASE", "release", 0.05, 1.5, 0.3);
releaseAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "release", releaseSlider);
setupKnob(kneeSlider, kneeLabel, "KNEE", "knee", 0.0, 12.0, 6.0);
kneeAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "knee", kneeSlider);
setupKnob(makeupSlider, makeupLabel, "MAKEUP", "makeup", 0.0, 24.0, 0.0);
makeupAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "makeup", makeupSlider);
setupKnob(mixSlider, mixLabel, "MIX", "mix", 0.0, 1.0, 1.0);
mixAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "mix", mixSlider);
setupKnob(autoReleaseSlider, autoReleaseLabel, "AUTO REL", "autoRelease", 0.0, 1.0, 1.0);
autoReleaseAttachment = std::make_unique<SliderAttachment>(processorRef.parameters, "autoRelease", autoReleaseSlider);
// LCD displays
auto setupLCD = [this](juce::Label& lcd) {
lcd.setJustificationType(juce::Justification::centred);
lcd.setColour(juce::Label::textColourId, lcdText);
lcd.setColour(juce::Label::backgroundColourId, lcdBg);
lcd.setFont(juce::Font(juce::FontOptions(12.0f * uiScale).withStyle("Bold")));
addAndMakeVisible(lcd);
};
setupLCD(thresholdLCD);
setupLCD(ratioLCD);
setupLCD(attackLCD);
setupLCD(releaseLCD);
setupLCD(kneeLCD);
setupLCD(makeupLCD);
setupLCD(mixLCD);
setupLCD(autoReleaseLCD);
// Zoom dropdown
zoomLabel.setText("UI SCALE", juce::dontSendNotification);
2026-07-12 14:18:24 +02:00
zoomLabel.setJustificationType(juce::Justification::centredRight);
zoomLabel.setColour(juce::Label::textColourId, textDim);
zoomLabel.setFont(juce::Font(juce::FontOptions(10.0f)));
addAndMakeVisible(zoomLabel);
zoomBox.addItem("1.0x", 1);
zoomBox.addItem("1.5x", 2);
zoomBox.addItem("2.0x", 3);
zoomBox.addItem("2.5x", 4);
zoomBox.addItem("3.0x", 5);
zoomBox.setSelectedId(1, juce::dontSendNotification);
2026-07-12 14:18:24 +02:00
zoomBox.setColour(juce::ComboBox::backgroundColourId, grooveDark);
zoomBox.setColour(juce::ComboBox::textColourId, textColour);
zoomBox.setColour(juce::ComboBox::arrowColourId, textDim);
zoomBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff333338));
zoomBox.addListener(this);
addAndMakeVisible(zoomBox);
// Preset dropdown
presetLabel.setText("PRESET", juce::dontSendNotification);
presetLabel.setJustificationType(juce::Justification::centredRight);
presetLabel.setColour(juce::Label::textColourId, textDim);
presetLabel.setFont(juce::Font(juce::FontOptions(10.0f)));
addAndMakeVisible(presetLabel);
auto& presets = getPresets();
for (int i = 0; i < (int)presets.size(); ++i)
presetBox.addItem(presets[i].name, i + 1);
presetBox.setSelectedId(1, juce::dontSendNotification);
presetBox.setColour(juce::ComboBox::backgroundColourId, grooveDark);
presetBox.setColour(juce::ComboBox::textColourId, textColour);
presetBox.setColour(juce::ComboBox::arrowColourId, textDim);
presetBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff333338));
presetBox.addListener(this);
addAndMakeVisible(presetBox);
// Bypass toggle
bypassLabel.setText("BYPASS", juce::dontSendNotification);
bypassLabel.setJustificationType(juce::Justification::centredRight);
bypassLabel.setColour(juce::Label::textColourId, textDim);
bypassLabel.setFont(juce::Font(juce::FontOptions(10.0f)));
addAndMakeVisible(bypassLabel);
bypassButton.setLookAndFeel(&toggleLnf);
bypassButton.setToggleState(false, juce::dontSendNotification);
addAndMakeVisible(bypassButton);
bypassAttachment = std::make_unique<ButtonAttachment>(processorRef.parameters, "bypass", bypassButton);
2026-07-12 14:18:24 +02:00
}
SaftpumpeEditor::~SaftpumpeEditor() {
thresholdSlider.setLookAndFeel(nullptr);
ratioSlider.setLookAndFeel(nullptr);
attackSlider.setLookAndFeel(nullptr);
releaseSlider.setLookAndFeel(nullptr);
kneeSlider.setLookAndFeel(nullptr);
makeupSlider.setLookAndFeel(nullptr);
mixSlider.setLookAndFeel(nullptr);
autoReleaseSlider.setLookAndFeel(nullptr);
bypassButton.setLookAndFeel(nullptr);
2026-07-12 14:18:24 +02:00
zoomBox.removeListener(this);
presetBox.removeListener(this);
stopTimer();
}
void SaftpumpeEditor::comboBoxChanged(juce::ComboBox* box) {
if (box == &zoomBox) {
static const float scales[] = { 1.0f, 1.5f, 2.0f, 2.5f, 3.0f };
int id = box->getSelectedId();
if (id >= 1 && id <= 5) {
uiScale = scales[id - 1];
setSize(static_cast<int>(700 * uiScale), static_cast<int>(560 * uiScale));
2026-07-12 14:18:24 +02:00
}
} else if (box == &presetBox) {
int id = box->getSelectedId() - 1;
applyPreset(id);
}
}
void SaftpumpeEditor::mouseUp(const juce::MouseEvent& event) {
if (scopeArea.contains(event.position.toFloat())) {
bool wasSpectrum = processorRef.isSpectrumMode();
processorRef.setSpectrumMode(!wasSpectrum);
repaint();
}
}
2026-07-12 14:18:24 +02:00
juce::String SaftpumpeEditor::formatValue(int idx, float val) {
switch (idx) {
case 0: return juce::String(val, 1) + " dB";
case 1: return juce::String(val, 1) + ":1";
case 2: return formatTime(val);
case 3: return formatTime(val);
case 4: return juce::String(val, 1) + " dB";
case 5: return juce::String(val, 1) + " dB";
case 6: return juce::String(static_cast<int>(val * 100)) + "%";
case 7: return val > 0.5f ? "ON" : "OFF";
default: return {};
}
}
void SaftpumpeEditor::computeLayout() {
float s = uiScale;
auto bounds = getLocalBounds().toFloat();
auto faceBounds = bounds.reduced(8.0f * s);
// Content starts below accent line (accent line at faceY + 80*s)
float accentY = faceBounds.getY() + 80.0f * s;
float contentTop = accentY + 22.0f * s;
2026-07-12 14:18:24 +02:00
// Bottom controls — side-by-side preset + zoom
float ctrlW = 140.0f * s;
float ctrlH = 52.0f * s;
float bottomY = faceBounds.getBottom() - ctrlH - 10.0f * s;
// Meters — right column (stops above bottom controls)
2026-07-12 14:18:24 +02:00
float meterW = 56.0f * s;
float meterLegW = 18.0f * s;
meterArea = juce::Rectangle<float>(faceBounds.getRight() - meterW - meterLegW - 14.0f * s,
2026-07-12 14:18:24 +02:00
contentTop,
meterW,
bottomY - contentTop - 8.0f * s);
2026-07-12 14:18:24 +02:00
// Knob grid area — 2 rows x 4 cols
float gridLeft = faceBounds.getX() + 14.0f * s;
float gridW = meterArea.getX() - gridLeft - 14.0f * s;
float gridH = meterArea.getHeight();
float rowH = gridH / 2.0f;
rowBounds[0] = juce::Rectangle<float>(gridLeft, contentTop, gridW, rowH);
rowBounds[1] = juce::Rectangle<float>(gridLeft, contentTop + rowH, gridW, rowH);
// Compute knob positions within each row
int knobsPerRow = 4;
float colW = gridW / static_cast<float>(knobsPerRow);
knobRadius = juce::jmin(colW * 0.30f, rowH * 0.28f);
for (int row = 0; row < 2; ++row) {
for (int col = 0; col < knobsPerRow; ++col) {
int idx = row * knobsPerRow + col;
float cx = rowBounds[row].getX() + colW * static_cast<float>(col) + colW / 2.0f;
float cy = rowBounds[row].getY() + rowH * 0.45f;
2026-07-12 14:18:24 +02:00
knobCentre[idx] = {cx, cy};
knobBounds[idx] = juce::Rectangle<float>(cx - knobRadius, cy - knobRadius,
knobRadius * 2.0f, knobRadius * 2.0f);
}
}
// Bottom: scope + side-by-side preset & zoom
2026-07-12 14:18:24 +02:00
float bottomH = faceBounds.getBottom() - bottomY - 10.0f * s;
float rightX = faceBounds.getRight() - ctrlW * 2.0f - 14.0f * s;
2026-07-12 14:18:24 +02:00
presetArea = juce::Rectangle<float>(rightX, bottomY, ctrlW, bottomH);
zoomArea = juce::Rectangle<float>(rightX + ctrlW + 4.0f * s, bottomY, ctrlW, bottomH);
2026-07-12 14:18:24 +02:00
scopeArea = juce::Rectangle<float>(faceBounds.getX() + 14.0f * s, bottomY,
presetArea.getX() - faceBounds.getX() - 14.0f * s, bottomH);
// Bypass switch — top-left, below the screw
bypassArea = juce::Rectangle<float>(faceBounds.getX() + 20.0f * s,
faceBounds.getY() + 30.0f * s,
80.0f * s, 20.0f * s);
2026-07-12 14:18:24 +02:00
}
void SaftpumpeEditor::resized() {
computeLayout();
float s = uiScale;
float labelH = 14.0f * s;
float lcdH = 18.0f * s;
float knobDiam = knobRadius * 2.0f;
float arcMargin = 14.0f * s;
auto setKnobBounds = [&](juce::Slider& slider, juce::Label& label, juce::Label& lcd, int idx) {
// Slider gets arc area (larger than knob body for arc meter)
float arcR = knobRadius + arcMargin;
auto arcB = juce::Rectangle<float>(knobCentre[idx].getX() - arcR, knobCentre[idx].getY() - arcR,
arcR * 2.0f, arcR * 2.0f);
slider.setBounds(arcB.toNearestInt());
// Label above arc
auto lb = juce::Rectangle<int>(
static_cast<int>(knobCentre[idx].getX() - knobRadius),
static_cast<int>(knobCentre[idx].getY() - knobRadius - 10.0f * s - 4.0f * s - labelH),
static_cast<int>(knobDiam),
static_cast<int>(labelH));
label.setBounds(lb);
// LCD below knob
auto lcdB = juce::Rectangle<int>(
static_cast<int>(knobCentre[idx].getX() - knobRadius * 0.85f),
static_cast<int>(knobCentre[idx].getY() + knobRadius + 3.0f * s),
static_cast<int>(knobRadius * 1.7f),
static_cast<int>(lcdH));
lcd.setBounds(lcdB);
};
setKnobBounds(thresholdSlider, thresholdLabel, thresholdLCD, 0);
setKnobBounds(ratioSlider, ratioLabel, ratioLCD, 1);
setKnobBounds(attackSlider, attackLabel, attackLCD, 2);
setKnobBounds(releaseSlider, releaseLabel, releaseLCD, 3);
setKnobBounds(kneeSlider, kneeLabel, kneeLCD, 4);
setKnobBounds(makeupSlider, makeupLabel, makeupLCD, 5);
setKnobBounds(mixSlider, mixLabel, mixLCD, 6);
setKnobBounds(autoReleaseSlider, autoReleaseLabel, autoReleaseLCD, 7);
float ctrlLabelH = 14.0f * s;
float ctrlBoxH = 24.0f * s;
float ctrlGap = 2.0f * s;
float ctrlItemH = ctrlLabelH + ctrlGap + ctrlBoxH;
float ctrlPadY = (presetArea.getHeight() - ctrlItemH) / 2.0f;
2026-07-12 14:18:24 +02:00
presetLabel.setBounds(presetArea.getX(), presetArea.getY() + ctrlPadY,
presetArea.getWidth(), ctrlLabelH);
presetBox.setBounds(presetArea.getX(), presetArea.getY() + ctrlPadY + ctrlLabelH + ctrlGap,
presetArea.getWidth(), ctrlBoxH);
zoomLabel.setBounds(zoomArea.getX(), zoomArea.getY() + ctrlPadY,
zoomArea.getWidth(), ctrlLabelH);
zoomBox.setBounds(zoomArea.getX(), zoomArea.getY() + ctrlPadY + ctrlLabelH + ctrlGap,
zoomArea.getWidth(), ctrlBoxH);
// Bypass switch
float bypassLabelW = 48.0f * s;
float bypassToggleW = 36.0f * s;
float bypassH = 16.0f * s;
bypassLabel.setBounds(static_cast<int>(bypassArea.getX()),
static_cast<int>(bypassArea.getY() + (bypassArea.getHeight() - bypassH) / 2.0f),
static_cast<int>(bypassLabelW), static_cast<int>(bypassH));
bypassButton.setBounds(static_cast<int>(bypassArea.getX() + bypassLabelW + 4.0f * s),
static_cast<int>(bypassArea.getY() + (bypassArea.getHeight() - bypassH) / 2.0f),
static_cast<int>(bypassToggleW), static_cast<int>(bypassH));
2026-07-12 14:18:24 +02:00
}
void SaftpumpeEditor::paint(juce::Graphics& g) {
if (rowBounds[0].getWidth() <= 0) computeLayout();
auto bounds = getLocalBounds().toFloat();
float s = uiScale;
auto faceBounds = bounds.reduced(8.0f * s);
// Dark bezel
g.setColour(bezelColour);
g.fillRect(bounds);
// Faceplate
juce::ColourGradient faceGrad(faceplateTop, faceBounds.getX(), faceBounds.getY(),
faceplateBot, faceBounds.getX(), faceBounds.getBottom(), false);
g.setGradientFill(faceGrad);
g.fillRoundedRectangle(faceBounds, 6.0f * s);
// Brushed texture
g.setColour(juce::Colours::white.withAlpha(0.02f));
for (float y = faceBounds.getY(); y < faceBounds.getBottom(); y += 2.0f * s)
g.drawLine(faceBounds.getX(), y, faceBounds.getRight(), y, 0.4f);
// Edge
g.setColour(juce::Colours::white.withAlpha(0.06f));
g.drawLine(faceBounds.getX() + 4, faceBounds.getY() + 1, faceBounds.getRight() - 4, faceBounds.getY() + 1, 1.0f);
g.setColour(juce::Colours::black.withAlpha(0.4f));
g.drawLine(faceBounds.getX() + 4, faceBounds.getBottom() - 1, faceBounds.getRight() - 4, faceBounds.getBottom() - 1, 1.0f);
// Screws
float screwR = 3.5f * s;
float screwInset = 14.0f * s;
drawScrew(g, faceBounds.getX() + screwInset, faceBounds.getY() + screwInset, screwR);
drawScrew(g, faceBounds.getRight() - screwInset, faceBounds.getY() + screwInset, screwR);
drawScrew(g, faceBounds.getX() + screwInset, faceBounds.getBottom() - screwInset, screwR);
drawScrew(g, faceBounds.getRight() - screwInset, faceBounds.getBottom() - screwInset, screwR);
// Title
auto titleArea = juce::Rectangle<float>(faceBounds.getX() + 30.0f * s, faceBounds.getY() + 6.0f * s,
faceBounds.getWidth() - 60.0f * s, 28.0f * s);
g.setColour(textBright);
g.setFont(juce::Font(juce::FontOptions(22.0f * s).withStyle("Bold")));
g.drawText("SAFTPUMPE", titleArea, juce::Justification::centred);
g.setColour(textDim.withAlpha(0.2f));
g.drawText("SAFTPUMPE", titleArea.translated(0, 1.0f), juce::Justification::centred);
// Subtitle
auto subArea = titleArea.translated(0, 22.0f * s).withHeight(16.0f * s);
g.setColour(textDim);
g.setFont(juce::Font(juce::FontOptions(11.0f * s)));
g.drawText("BUS COMPRESSOR", subArea, juce::Justification::centred);
// Accent line
float lineY = subArea.getBottom() + 4.0f * s;
g.setColour(accentLine.withAlpha(0.25f));
g.drawLine(faceBounds.getX() + 20.0f * s, lineY, faceBounds.getRight() - 20.0f * s, lineY, 1.0f);
// Draw knobs
juce::Slider* sliders[] = { &thresholdSlider, &ratioSlider, &attackSlider, &releaseSlider,
&kneeSlider, &makeupSlider, &mixSlider, &autoReleaseSlider };
juce::Label* lcds[] = { &thresholdLCD, &ratioLCD, &attackLCD, &releaseLCD,
&kneeLCD, &makeupLCD, &mixLCD, &autoReleaseLCD };
const char* labels[] = { "THRESHOLD", "RATIO", "ATTACK", "RELEASE",
"KNEE", "MAKEUP", "MIX", "AUTO REL" };
const juce::StringArray marks[] = {
{"0", "-12", "-24", "-36", "-48"},
{"20:1", "10:1", "6:1", "4:1", "2:1", "1:1"},
{"100m", "30m", "10m", "3m", "1m"},
{"1.5s", "500m", "100m", "50m"},
{"12", "6", "0"},
{"24", "12", "0"},
{"100", "50", "0"},
{"OFF", "ON"}
};
for (int i = 0; i < 8; ++i) {
float norm = static_cast<float>(sliders[i]->valueToProportionOfLength(sliders[i]->getValue()));
drawKnob(g, knobBounds[i], norm, norm);
}
// Meters
float grH = meterArea.getHeight() * 0.42f;
float meterLegW = 18.0f * s;
auto grBar = juce::Rectangle<float>(meterArea.getX(), meterArea.getY(),
meterArea.getWidth() - meterLegW, grH);
2026-07-12 14:18:24 +02:00
float gr = processorRef.getGainReductionMeter();
drawMeter(g, grBar, gr, 0.0f, 24.0f, true);
drawPeakHold(g, grBar, grPeak.peak, true);
2026-07-12 14:18:24 +02:00
g.setColour(textDim);
g.setFont(juce::Font(juce::FontOptions(10.0f * s)));
g.drawText("GR", grBar.translated(0, grBar.getHeight() + 2.0f * s), juce::Justification::centredTop);
2026-07-12 14:18:24 +02:00
// GR scale legends
{
float lx = grBar.getRight() + 2.0f * s;
float lw = meterLegW - 2.0f * s;
g.setFont(juce::Font(juce::FontOptions(8.0f * s)));
g.setColour(textDim.withAlpha(0.6f));
const float grTicks[] = { 0.0f, 6.0f, 12.0f, 18.0f, 24.0f };
const char* grLabels[] = { "0", "-6", "-12", "-18", "-24" };
for (int i = 0; i < 5; ++i) {
float norm = grTicks[i] / 24.0f;
float ty = grBar.getY() + grBar.getHeight() * norm;
g.drawLine(grBar.getRight() - 2.0f * s, ty, grBar.getRight() + 2.0f * s, ty, 0.5f);
g.drawText(grLabels[i], lx, ty - 5.0f * s, lw, 10.0f * s, juce::Justification::centredLeft);
}
}
float ioY = grBar.getBottom() + 16.0f * s;
2026-07-12 14:18:24 +02:00
float ioH = meterArea.getBottom() - ioY - 14.0f * s;
float ioBarW = (meterArea.getWidth() - meterLegW - 3.0f * s) / 2.0f;
auto inBar = juce::Rectangle<float>(meterArea.getX(), ioY, ioBarW, ioH);
auto outBar = juce::Rectangle<float>(meterArea.getX() + ioBarW + 3.0f * s, ioY, ioBarW, ioH);
2026-07-12 14:18:24 +02:00
drawMeter(g, inBar, processorRef.getInputLevel(), -60.0f, 0.0f, false);
drawMeter(g, outBar, processorRef.getOutputLevel(), -60.0f, 0.0f, false);
drawPeakHold(g, inBar, inPeak.peak);
drawPeakHold(g, outBar, outPeak.peak);
g.setColour(textDim);
g.setFont(juce::Font(juce::FontOptions(9.0f * s)));
g.drawText("IN", inBar.translated(0, ioH + 2.0f * s), juce::Justification::centredTop);
g.drawText("OUT", outBar.translated(0, ioH + 2.0f * s), juce::Justification::centredTop);
// VU scale legends
{
float lx = meterArea.getRight() - meterLegW + 2.0f * s;
float lw = meterLegW - 2.0f * s;
g.setFont(juce::Font(juce::FontOptions(7.5f * s)));
g.setColour(textDim.withAlpha(0.5f));
const float vuTicks[] = { 0.0f, 0.333f, 0.667f, 1.0f };
const char* vuLabels[] = { "0", "-20", "-40", "-60" };
for (int i = 0; i < 4; ++i) {
float ty = inBar.getBottom() - inBar.getHeight() * vuTicks[i];
g.drawLine(outBar.getRight() - 1.0f * s, ty, meterArea.getRight() - 1.0f * s, ty, 0.4f);
g.drawText(vuLabels[i], lx, ty - 4.0f * s, lw, 8.0f * s, juce::Justification::centredLeft);
}
}
2026-07-12 14:18:24 +02:00
// Scope
drawScope(g, scopeArea);
// Zoom + Preset area borders
g.setColour(juce::Colour(0xff333338));
g.drawRoundedRectangle(zoomArea.reduced(2.0f * s), 2.0f * s, 0.5f);
g.drawRoundedRectangle(presetArea.reduced(2.0f * s), 2.0f * s, 0.5f);
// Bypass area border
g.drawRoundedRectangle(bypassArea.reduced(1.0f * s), 2.0f * s, 0.5f);
2026-07-12 14:18:24 +02:00
}
void SaftpumpeEditor::drawKnob(juce::Graphics& g, juce::Rectangle<float> bounds,
float normalizedValue, float arcFillFrac) {
float s = uiScale;
float cx = bounds.getCentreX();
float cy = bounds.getCentreY();
float r = bounds.getWidth() / 2.0f;
// Arc meter around the knob
float arcR = r + 10.0f * s;
float arcThickness = 3.0f * s;
auto buildArc = [&](float fromFrac, float toFrac) {
juce::Path p;
float a0 = startAngle + fromFrac * (endAngle - startAngle);
float a1 = startAngle + toFrac * (endAngle - startAngle);
int steps = juce::jmax(2, (int) std::ceil(std::abs(a1 - a0) / 0.15f));
float da = (a1 - a0) / (float) steps;
p.startNewSubPath(cx + std::cos(a0) * arcR, cy + std::sin(a0) * arcR);
for (int i = 1; i <= steps; ++i) {
float a = a0 + da * (float) i;
p.lineTo(cx + std::cos(a) * arcR, cy + std::sin(a) * arcR);
}
return p;
};
// Background arc track
g.setColour(juce::Colour(0xff3a3a42));
g.strokePath(buildArc(0.0f, 1.0f), juce::PathStrokeType(arcThickness));
// Filled arc — colored by parameter zone
if (arcFillFrac > 0.001f) {
juce::Colour arcCol;
if (arcFillFrac < 0.5f)
arcCol = greenLed.interpolatedWith(amberLed, arcFillFrac * 2.0f);
else
arcCol = amberLed.interpolatedWith(redLed, (arcFillFrac - 0.5f) * 2.0f);
g.setColour(arcCol.withAlpha(0.9f));
g.strokePath(buildArc(0.0f, arcFillFrac), juce::PathStrokeType(arcThickness));
}
// Knob shadow — soft outer + hard inner
g.setColour(juce::Colours::black.withAlpha(0.3f));
g.fillEllipse(cx - r - 3.0f * s, cy - r + 3.0f * s, r * 2.0f + 6.0f * s, r * 2.0f + 6.0f * s);
g.setColour(juce::Colours::black.withAlpha(0.6f));
g.fillEllipse(cx - r - 1.0f, cy - r + 2.0f * s, r * 2.0f + 2.0f * s, r * 2.0f + 2.0f * s);
// Outer bevel ring — catches light on top, shadow on bottom
float bevelR = r + 1.0f * s;
{
juce::ColourGradient bevelGrad(juce::Colour(0xff5a5a60), cx - bevelR, cy - bevelR,
juce::Colour(0xff2a2a2e), cx + bevelR, cy + bevelR, true);
g.setGradientFill(bevelGrad);
g.fillEllipse(cx - bevelR, cy - bevelR, bevelR * 2.0f, bevelR * 2.0f);
}
// Knob body — radial gradient for 3D dome effect
{
juce::ColourGradient bodyGrad(juce::Colour(0xff9a9aa0), cx, cy - r * 0.3f,
juce::Colour(0xff3a3a40), cx, cy + r, true);
bodyGrad.addColour(0.3, juce::Colour(0xff808086));
bodyGrad.addColour(0.7, juce::Colour(0xff505056));
g.setGradientFill(bodyGrad);
g.fillEllipse(bounds);
}
// Knurling — fine radial lines around the edge
{
float knurlR = r - 1.0f * s;
float knurlInner = r - 4.0f * s;
int numTeeth = 40;
g.setColour(juce::Colours::black.withAlpha(0.12f));
for (int i = 0; i < numTeeth; ++i) {
float a = (static_cast<float>(i) / static_cast<float>(numTeeth)) * juce::MathConstants<float>::twoPi;
float ca = std::cos(a);
float sa = std::sin(a);
g.drawLine(cx + ca * knurlInner, cy + sa * knurlInner,
cx + ca * knurlR, cy + sa * knurlR, 0.6f);
}
}
// Inner chamfer ring
g.setColour(juce::Colours::white.withAlpha(0.06f));
g.drawEllipse(bounds.reduced(3.0f * s), 0.8f);
g.setColour(juce::Colours::black.withAlpha(0.15f));
g.drawEllipse(bounds.reduced(1.5f * s), 0.8f);
// Specular highlight — top-left crescent
{
juce::ColourGradient specGrad(juce::Colours::white.withAlpha(0.18f), cx - r * 0.2f, cy - r * 0.5f,
juce::Colours::white.withAlpha(0.0f), cx + r * 0.3f, cy + r * 0.1f, true);
g.setGradientFill(specGrad);
g.fillEllipse(cx - r * 0.6f, cy - r * 0.8f, r * 1.0f, r * 0.55f);
}
// Bottom rim shadow inside the body
g.setColour(juce::Colours::black.withAlpha(0.12f));
g.fillEllipse(cx - r * 0.7f, cy + r * 0.3f, r * 1.4f, r * 0.6f);
// Pointer line — wider with subtle glow
float angle = startAngle + normalizedValue * (endAngle - startAngle);
float pIn = r * 0.22f;
float pOut = r * 0.72f;
float px1 = cx + std::cos(angle) * pIn;
float py1 = cy + std::sin(angle) * pIn;
float px2 = cx + std::cos(angle) * pOut;
float py2 = cy + std::sin(angle) * pOut;
// Glow behind pointer
g.setColour(juce::Colours::white.withAlpha(0.12f));
g.drawLine(px1, py1, px2, py2, 4.0f * s);
// Pointer itself
g.setColour(juce::Colours::white.withAlpha(0.85f));
g.drawLine(px1, py1, px2, py2, 1.8f * s);
// Dark edge for definition
g.setColour(juce::Colours::black.withAlpha(0.2f));
g.drawLine(px1, py1, px2, py2, 0.4f);
}
void SaftpumpeEditor::drawLCD(juce::Graphics& g, juce::Rectangle<float> bounds, const juce::String& text) {
float s = uiScale;
g.setColour(lcdBg);
g.fillRoundedRectangle(bounds, 2.0f * s);
g.setColour(juce::Colour(0xff1a2a1a));
g.drawRoundedRectangle(bounds.expanded(0.5f), 2.0f * s, 1.0f);
g.setColour(lcdText);
g.setFont(juce::Font(juce::FontOptions(12.0f * s).withStyle("Bold")));
g.drawText(text, bounds, juce::Justification::centred);
}
void SaftpumpeEditor::drawMeter(juce::Graphics& g, juce::Rectangle<float> bounds,
float level, float minDb, float maxDb, bool isGainReduction) {
float s = uiScale;
g.setColour(grooveDark);
g.fillRoundedRectangle(bounds, 2.0f * s);
g.setColour(juce::Colours::black.withAlpha(0.5f));
g.drawRoundedRectangle(bounds.expanded(0.5f), 2.0f * s, 1.0f);
float norm = juce::jlimit(0.0f, 1.0f, (level - minDb) / (maxDb - minDb));
if (norm > 0.005f) {
float h = bounds.getHeight() * norm;
float y = isGainReduction ? bounds.getY() : bounds.getBottom() - h;
juce::ColourGradient grad;
if (isGainReduction) {
grad = juce::ColourGradient(greenLed, 0.0f, y, redLed, 0.0f, y + h, false);
} else {
grad = juce::ColourGradient(greenLed, 0.0f, bounds.getBottom(), amberLed, 0.0f, y, false);
if (norm > 0.8f) grad.addColour(1.0, redLed);
}
g.setGradientFill(grad);
g.fillRect(bounds.getX() + 1.0f, y, bounds.getWidth() - 2.0f, h);
}
}
void SaftpumpeEditor::drawPeakHold(juce::Graphics& g, juce::Rectangle<float> bounds,
float peakNorm, bool fromTop) {
float s = uiScale;
if (peakNorm < 0.005f) return;
float y = fromTop ? bounds.getY() + bounds.getHeight() * peakNorm
: bounds.getBottom() - bounds.getHeight() * peakNorm;
g.setColour(juce::Colours::white.withAlpha(0.85f));
g.fillRect(bounds.getX() + 1.0f, y - 1.0f, bounds.getWidth() - 2.0f, 2.0f);
}
void SaftpumpeEditor::drawScope(juce::Graphics& g, juce::Rectangle<float> bounds) {
if (processorRef.isSpectrumMode()) {
drawSpectrum(g, bounds);
return;
}
2026-07-12 14:18:24 +02:00
float s = uiScale;
g.setColour(grooveDark);
g.fillRoundedRectangle(bounds, 3.0f * s);
g.setColour(juce::Colours::black.withAlpha(0.4f));
g.drawRoundedRectangle(bounds.expanded(0.5f), 3.0f * s, 1.0f);
g.setColour(accentLine.withAlpha(0.06f));
float centerY = bounds.getCentreY();
g.drawLine(bounds.getX() + 2.0f, centerY, bounds.getRight() - 2.0f, centerY, 0.5f);
for (int i = 1; i <= 4; ++i) {
float gx = bounds.getX() + bounds.getWidth() * (static_cast<float>(i) / 4.0f);
g.drawLine(gx, bounds.getY() + 2.0f, gx, bounds.getBottom() - 2.0f, 0.3f);
}
auto& inData = processorRef.inputScope;
auto& outData = processorRef.outputScope;
int numPoints = processorRef.scopeSize;
float legendY = bounds.getBottom() - 10.0f * s;
float legendX = bounds.getX() + 6.0f * s;
g.setFont(juce::Font(juce::FontOptions(8.0f * s)));
g.setColour(redLed.withAlpha(0.5f));
g.drawText("IN", legendX, legendY, 16.0f * s, 8.0f * s, juce::Justification::centredLeft);
g.setColour(greenLed.withAlpha(0.4f));
g.drawText("OUT", legendX + 18.0f * s, legendY, 22.0f * s, 8.0f * s, juce::Justification::centredLeft);
g.setColour(textDim.withAlpha(0.3f));
g.setFont(juce::Font(juce::FontOptions(7.0f * s)));
g.drawText("WAVEFORM", bounds.getRight() - 48.0f * s, legendY, 42.0f * s, 8.0f * s,
juce::Justification::centredRight);
2026-07-12 14:18:24 +02:00
if (numPoints < 2) return;
float width = bounds.getWidth() - 4.0f;
float halfH = bounds.getHeight() / 2.0f - 2.0f;
float xStep = width / static_cast<float>(numPoints - 1);
float ox = bounds.getX() + 2.0f;
{
juce::Path path;
float x = ox;
path.startNewSubPath(x, centerY - inData[0] * halfH);
for (int i = 1; i < numPoints; ++i) { x += xStep; path.lineTo(x, centerY - inData[i] * halfH); }
g.setColour(redLed.withAlpha(0.15f));
g.strokePath(path, juce::PathStrokeType(4.0f));
g.setColour(redLed.withAlpha(0.8f));
g.strokePath(path, juce::PathStrokeType(1.5f));
}
{
juce::Path path;
float x = ox;
path.startNewSubPath(x, centerY - outData[0] * halfH);
for (int i = 1; i < numPoints; ++i) { x += xStep; path.lineTo(x, centerY - outData[i] * halfH); }
g.setColour(greenLed.withAlpha(0.12f));
g.strokePath(path, juce::PathStrokeType(3.0f));
g.setColour(greenLed.withAlpha(0.65f));
g.strokePath(path, juce::PathStrokeType(1.0f));
}
}
2026-07-12 14:18:24 +02:00
void SaftpumpeEditor::drawSpectrum(juce::Graphics& g, juce::Rectangle<float> bounds) {
float s = uiScale;
g.setColour(grooveDark);
g.fillRoundedRectangle(bounds, 3.0f * s);
g.setColour(juce::Colours::black.withAlpha(0.4f));
g.drawRoundedRectangle(bounds.expanded(0.5f), 3.0f * s, 1.0f);
auto area = bounds.reduced(4.0f * s);
float w = area.getWidth();
float h = area.getHeight();
float ox = area.getX();
float oy = area.getY();
g.setColour(accentLine.withAlpha(0.05f));
for (int i = 0; i <= 4; ++i) {
float ly = oy + h * static_cast<float>(i) / 4.0f;
g.drawLine(ox, ly, ox + w, ly, 0.3f);
}
int numBins = processorRef.fftSize / 2;
if (numBins < 2) return;
float minLog = std::log10(20.0f);
float maxLog = std::log10(22050.0f);
auto freqToX = [&](float freq) -> float {
float logF = std::log10(juce::jmax(freq, 20.0f));
return ox + w * (logF - minLog) / (maxLog - minLog);
};
auto dbToY = [&](float db) -> float {
float norm = juce::jlimit(0.0f, 1.0f, (db + 80.0f) / 80.0f);
return oy + h * (1.0f - norm);
};
const float freqs[] = { 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000 };
g.setFont(juce::Font(juce::FontOptions(7.0f * s)));
g.setColour(textDim.withAlpha(0.4f));
for (float freq : freqs) {
float fx = freqToX(freq);
g.drawLine(fx, oy, fx, oy + h, 0.3f);
juce::String label = freq >= 1000 ? juce::String(freq / 1000.0f, 0) + "k"
: juce::String(static_cast<int>(freq));
g.drawText(label, fx - 10.0f * s, oy + h + 1.0f * s, 20.0f * s, 8.0f * s,
juce::Justification::centredTop);
}
const float dbTicks[] = { 0, -20, -40, -60 };
g.setColour(textDim.withAlpha(0.35f));
for (float db : dbTicks) {
float ly = dbToY(db);
g.drawLine(ox, ly, ox + w, ly, 0.3f);
g.drawText(juce::String(static_cast<int>(db)), ox - 22.0f * s, ly - 4.0f * s,
20.0f * s, 8.0f * s, juce::Justification::centredRight);
}
float binHz = 44100.0f / static_cast<float>(processorRef.fftSize);
auto drawSpectrumLine = [&](const std::array<float, SaftpumpeProcessor::fftSize / 2>& data,
juce::Colour col, float shadowAlpha, float lineAlpha, float strokeW) {
juce::Path path;
bool started = false;
for (int i = 1; i < numBins; ++i) {
float freq = binHz * static_cast<float>(i);
if (freq < 20.0f) continue;
float fx = freqToX(freq);
float db = data[i] * 80.0f - 80.0f;
float fy = dbToY(db);
if (!started) { path.startNewSubPath(fx, fy); started = true; }
else path.lineTo(fx, fy);
}
g.setColour(col.withAlpha(shadowAlpha));
g.strokePath(path, juce::PathStrokeType(strokeW * 2.5f));
g.setColour(col.withAlpha(lineAlpha));
g.strokePath(path, juce::PathStrokeType(strokeW));
};
drawSpectrumLine(processorRef.inputSpectrum, redLed, 0.12f, 0.75f, 1.2f);
drawSpectrumLine(processorRef.outputSpectrum, greenLed, 0.10f, 0.60f, 1.0f);
float legendY = bounds.getBottom() - 10.0f * s;
2026-07-12 14:18:24 +02:00
float legendX = bounds.getX() + 6.0f * s;
g.setFont(juce::Font(juce::FontOptions(8.0f * s)));
g.setColour(redLed.withAlpha(0.5f));
g.drawText("IN", legendX, legendY, 16.0f * s, 8.0f * s, juce::Justification::centredLeft);
g.setColour(greenLed.withAlpha(0.4f));
g.drawText("OUT", legendX + 18.0f * s, legendY, 22.0f * s, 8.0f * s, juce::Justification::centredLeft);
g.setColour(textDim.withAlpha(0.3f));
g.setFont(juce::Font(juce::FontOptions(7.0f * s)));
g.drawText("SPECTRUM", bounds.getRight() - 52.0f * s, legendY, 46.0f * s, 8.0f * s,
juce::Justification::centredRight);
2026-07-12 14:18:24 +02:00
}
void SaftpumpeEditor::drawScrew(juce::Graphics& g, float cx, float cy, float radius) {
juce::ColourGradient grad(juce::Colour(0xff3a3a40), cx - radius, cy - radius,
juce::Colour(0xff1a1a1e), cx + radius, cy + radius, true);
g.setGradientFill(grad);
g.fillEllipse(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f);
g.setColour(juce::Colour(0xff555558));
float cs = radius * 0.6f;
g.drawLine(cx - cs, cy, cx + cs, cy, 0.8f);
g.drawLine(cx, cy - cs, cx, cy + cs, 0.8f);
g.setColour(juce::Colours::white.withAlpha(0.15f));
g.fillEllipse(cx - radius * 0.3f, cy - radius * 0.5f, radius * 0.4f, radius * 0.25f);
}
void SaftpumpeEditor::timerCallback() {
double now = juce::Time::getMillisecondCounterHiRes();
auto updatePeak = [&](PeakHold& pk, float level) {
if (level > pk.peak || (now - pk.peakTime) > PeakHold::holdMs) {
pk.peak = level;
pk.peakTime = now;
}
};
float grNorm = juce::jlimit(0.0f, 1.0f,
processorRef.getGainReductionMeter() / 24.0f);
float inNorm = juce::jlimit(0.0f, 1.0f,
(processorRef.getInputLevel() - (-60.0f)) / (0.0f - (-60.0f)));
float outNorm = juce::jlimit(0.0f, 1.0f,
(processorRef.getOutputLevel() - (-60.0f)) / (0.0f - (-60.0f)));
updatePeak(grPeak, grNorm);
updatePeak(inPeak, inNorm);
updatePeak(outPeak, outNorm);
// Update LCD displays
auto updateLCD = [](juce::Label& lcd, const juce::String& text) {
if (lcd.getText() != text) lcd.setText(text, juce::dontSendNotification);
};
updateLCD(thresholdLCD, formatValue(0, static_cast<float>(thresholdSlider.getValue())));
updateLCD(ratioLCD, formatValue(1, static_cast<float>(ratioSlider.getValue())));
updateLCD(attackLCD, formatValue(2, static_cast<float>(attackSlider.getValue())));
updateLCD(releaseLCD, formatValue(3, static_cast<float>(releaseSlider.getValue())));
updateLCD(kneeLCD, formatValue(4, static_cast<float>(kneeSlider.getValue())));
updateLCD(makeupLCD, formatValue(5, static_cast<float>(makeupSlider.getValue())));
updateLCD(mixLCD, formatValue(6, static_cast<float>(mixSlider.getValue())));
updateLCD(autoReleaseLCD, formatValue(7, static_cast<float>(autoReleaseSlider.getValue())));
repaint();
}