Files
serumalt/Source/PluginEditor.cpp
T
biggy a50666355c feat(gui): add knob value popup, tooltips, tab cycling, and layout system
Add a floating value label near the cursor while dragging a knob, giving
immediate feedback without altering slider behaviour. Inherit
SettableTooltipClient on ToggleButton so setTooltip works consistently
with knobs and combos.

Introduce a shared layout constants namespace in PluginEditor so every
panel, row and control position derives from one spacing system. Add
tooltips to all knobs, combos, toggles, and buttons. Add Tab/Shift+Tab
tab cycling via keyPressed, with a TooltipWindow for hover hints.
2026-09-09 13:24:40 +02:00

883 lines
44 KiB
C++

#include "PluginEditor.h"
namespace serum
{
namespace
{
// -----------------------------------------------------------------------
// Consistent spacing system. Every panel, row and control position below is
// derived from these constants so the tabs share identical margins, padding
// and row pitches.
// -----------------------------------------------------------------------
namespace layout
{
constexpr int margin = 10; // panels inset from the tab view edge
constexpr int padding = 8; // controls inset inside panels + row rhythm
constexpr int gap = 6; // horizontal spacing between standard knobs
constexpr int knobSize = 76; // standard knob footprint (incl. label)
constexpr int knobSmall = 44; // small knob width (env / LFO rows)
constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows
constexpr int panelSpacing = 10; // gap between adjacent panels
constexpr int labelHeight = 16; // label strip height
// Derived from the visual style (panel title bar and control sizes) so the
// content grid stays aligned across every tab.
constexpr int titleHeight = 22; // panel title bar height
constexpr int comboHeight = 24; // combo box height
constexpr int buttonHeight = 26; // text button height
constexpr int toggleWidth = 40; // "On"/"Sync" toggle width
constexpr int toggleHeight = 36; // toggle height (LED + visible label)
constexpr int headerHeight = toggleHeight; // top control row height
constexpr int displayHeight = 66; // waveform / filter display height
constexpr int envDisplayHeight = 56; // envelope display height
constexpr int lfoDisplayHeight = 64; // LFO display height
constexpr int smallKnobHeight = 60; // height of 44px-wide small knobs
constexpr int smallGap = 3; // horizontal spacing between small knobs
constexpr int arrowWidth = 26; // FX up/down arrow button width
constexpr int macroKnobSize = 120; // macro knob footprint
}
// Horizontal position of a grid column (0-based).
inline int gridX (int col, int cellW, int cellGap)
{
return layout::margin + col * (cellW + cellGap);
}
// Y position of a combo box centred on a panel's header row.
inline int comboRowY()
{
return layout::titleHeight + (layout::headerHeight - layout::comboHeight) / 2;
}
// Y position of the first content row below a panel's header row.
inline int bodyTop()
{
return layout::titleHeight + layout::headerHeight + layout::padding;
}
juce::StringArray wavetableItems()
{
juce::StringArray a;
for (auto n : kWavetableNames) a.add (n);
return a;
}
juce::StringArray modSourceItems()
{
juce::StringArray a;
for (int i = 0; i < kNumModSources; ++i)
a.add (modSourceName ((ModSource) i));
return a;
}
juce::StringArray modTargetItems (std::vector<ModTarget>& enums)
{
juce::StringArray a;
for (int i = 0; i < kNumModTargets; ++i)
{
const auto t = (ModTarget) i;
if (t == ModTarget::None) continue;
a.add (modTargetName (t));
enums.push_back (t);
}
return a;
}
juce::String fmtUnison (float v) { return juce::String (1 + (int) std::llround (v * 15.0f)); }
juce::String fmtSlope (float v) { const int s = (int) std::llround (v * 2.0f); return juce::String (s == 0 ? 6 : (s == 1 ? 12 : 24)) + " dB"; }
juce::String fmtDepth (float v) { return juce::String (v, 2); }
void placeKnobs (std::vector<Knob*>& knobs, int x, int y, int w, int h, int gap = layout::gap)
{
for (int i = 0; i < (int) knobs.size(); ++i)
knobs[(size_t) i]->setBounds (x + i * (w + gap), y, w, h);
}
const char* kFxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
const char* kFxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
const char* kFxP1[8] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
const char* kFxP2[8] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 };
const char* kFxP3[8] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3,
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 };
const char* kFxP4[8] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4,
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 };
}
// ---------------------------------------------------------------------------
PluginEditor::PluginEditor (SerumAltAudioProcessor& p)
: AudioProcessorEditor (p), processor (p),
tooltipWindow (this, 500),
masterKnob ("Master", formatPercent)
{
// Tab / Shift+Tab cycling is handled by keyPressed(); this lets the editor
// receive keyboard focus when the user clicks anywhere on it.
setWantsKeyboardFocus (true);
root.setBounds (0, 0, kBaseW, kBaseH);
addAndMakeVisible (root);
logo = createLogoDrawable();
buildTopBar();
buildOscTab();
buildFilterTab();
buildModTab();
buildFxTab();
buildMacroTab();
setTab (0);
applyUiScale();
startTimerHz (30);
}
PluginEditor::~PluginEditor()
{
stopTimer();
}
// ---------------------------------------------------------------------------
void PluginEditor::paint (juce::Graphics& g)
{
g.fillAll (theme::bg);
g.setGradientFill (juce::ColourGradient (theme::panel, 0.0f, 0.0f,
theme::bg, 0.0f, (float) getHeight(), false));
g.fillAll();
if (logo != nullptr)
logo->drawWithin (g, juce::Rectangle<float> (10.0f, 8.0f, 180.0f, 44.0f),
juce::RectanglePlacement::centred, 1.0f);
}
void PluginEditor::resized()
{
root.setBounds (0, 0, kBaseW, kBaseH);
}
// ---------------------------------------------------------------------------
Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
const juce::String& paramId, std::function<juce::String (float)> fmt,
const juce::String& tooltip)
{
auto* k = new Knob (name, std::move (fmt));
parent->addAndMakeVisible (k);
if (paramId.isNotEmpty())
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, paramId, *k));
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
return k;
}
juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::String& paramId,
const juce::StringArray& items, const juce::String& tooltip)
{
auto* c = new juce::ComboBox();
c->addItemList (items, 1);
c->setSelectedItemIndex (0, juce::dontSendNotification);
parent->addAndMakeVisible (c);
if (paramId.isNotEmpty())
comboAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (
processor.parameters, paramId, *c));
if (tooltip.isNotEmpty())
c->setTooltip (tooltip);
return c;
}
ToggleButton* PluginEditor::makeToggle (juce::Component* parent, const juce::String& label,
const juce::String& paramId, const juce::String& tooltip)
{
auto* t = new ToggleButton (label);
parent->addAndMakeVisible (t);
if (paramId.isNotEmpty())
t->attach (processor.parameters, paramId);
t->setTooltip (tooltip.isNotEmpty() ? tooltip : label);
return t;
}
// ---------------------------------------------------------------------------
void PluginEditor::buildTopBar()
{
// Preset selector.
juce::StringArray presetNames;
for (int i = 0; i < processor.getNumPrograms(); ++i)
presetNames.add (processor.getProgramName (i));
presetCombo.addItemList (presetNames, 1);
presetCombo.setSelectedItemIndex (processor.getCurrentProgram(), juce::dontSendNotification);
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (presetCombo);
presetCombo.setBounds (200, 16, 220, 24);
presetCombo.setTooltip ("Select a factory preset");
// UI scale.
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
scaleCombo.setSelectedItemIndex (processor.getUiScaleIndex(), juce::dontSendNotification);
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (scaleCombo);
scaleCombo.setBounds (980, 16, 70, 24);
scaleCombo.setTooltip ("Interface scale (75% - 200%)");
// RAVE.
raveButton.setLabel ("RAVE");
raveButton.setOnColour (theme::raveGlow);
raveButton.setBounds (1062, 2, 50, 56);
root.addAndMakeVisible (raveButton);
raveButton.setTooltip ("RAVE one-click boost (unison, width, drive, OTT, reverb)");
// RAVE uses a callback rather than a direct parameter attachment.
raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); });
// Tab bar.
juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro };
const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" };
const char* tabTips[5] = { "Oscillators (Tab cycles sections)",
"Filters (Tab cycles sections)",
"Modulation: envelopes, LFOs and matrix (Tab cycles sections)",
"Effects rack (Tab cycles sections)",
"Macros (Tab cycles sections)" };
for (int i = 0; i < 5; ++i)
{
tabs[i]->setButtonText (tabNames[i]);
tabs[i]->setClickingTogglesState (true);
tabs[i]->setRadioGroupId (1001);
tabs[i]->setBounds (10 + i * 92, 36, 84, 22);
tabs[i]->onClick = [this, i] { setTab (i); };
tabs[i]->setTooltip (tabTips[i]);
root.addAndMakeVisible (tabs[i]);
}
// Master.
masterKnob.setBounds (906, 0, 64, 60);
masterKnob.setTooltip ("Master output volume (0-100%)");
root.addAndMakeVisible (masterKnob);
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, ids::master, masterKnob));
masterDisplay.setTitle ("MASTER");
root.addAndMakeVisible (masterDisplay);
masterDisplay.setBounds (846, 8, 56, 44);
}
void PluginEditor::buildOscTab()
{
oscView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (oscView);
constexpr int colW = 545; // two equal columns filling kBaseW
constexpr int oscH = 308;
constexpr int subH = 150;
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH);
subPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
noisePanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
for (auto* panel : { &oscAPanel, &oscBPanel, &subPanel, &noisePanel })
oscView.addAndMakeVisible (panel);
const int comboY = comboRowY();
const int displayY = bodyTop(); // below header row
const int row1Y = displayY + layout::displayHeight + layout::padding;
const int row2Y = row1Y + layout::rowHeight;
// --- Oscillator A ---
makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator A warp mode")
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscAWave.setWavetables (&processor.engine.getWavetables());
oscAPanel.addAndMakeVisible (oscAWave);
oscAWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> rowA1 = {
makeKnob (&oscAPanel, "Wavetable Position", ids::oscAWtPos, formatPercent, "Wavetable position (0-100%)"),
makeKnob (&oscAPanel, "Warp Amount", ids::oscAWarpAmt, formatPercent, "Warp amount (0-100%)"),
makeKnob (&oscAPanel, "Coarse", ids::oscACoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
makeKnob (&oscAPanel, "Fine", ids::oscAFine, formatCents, "Pitch fine (-100 to +100 cents)"),
makeKnob (&oscAPanel, "Level", ids::oscALevel, formatPercent, "Oscillator A level (0-100%)"),
makeKnob (&oscAPanel, "Pan", ids::oscAPan, formatPan, "Oscillator A pan (L100-R100)")
};
placeKnobs (rowA1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
std::vector<Knob*> rowA2 = {
makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"),
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"),
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"),
makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)")
};
placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
// --- Oscillator B ---
makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator B warp mode")
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscBWave.setWavetables (&processor.engine.getWavetables());
oscBPanel.addAndMakeVisible (oscBWave);
oscBWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> rowB1 = {
makeKnob (&oscBPanel, "Wavetable Position", ids::oscBWtPos, formatPercent, "Wavetable position (0-100%)"),
makeKnob (&oscBPanel, "Warp Amount", ids::oscBWarpAmt, formatPercent, "Warp amount (0-100%)"),
makeKnob (&oscBPanel, "Coarse", ids::oscBCoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
makeKnob (&oscBPanel, "Fine", ids::oscBFine, formatCents, "Pitch fine (-100 to +100 cents)"),
makeKnob (&oscBPanel, "Level", ids::oscBLevel, formatPercent, "Oscillator B level (0-100%)"),
makeKnob (&oscBPanel, "Pan", ids::oscBPan, formatPan, "Oscillator B pan (L100-R100)")
};
placeKnobs (rowB1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
std::vector<Knob*> rowB2 = {
makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"),
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"),
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"),
makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)")
};
placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
// --- Sub ---
makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight);
makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
// --- Noise ---
makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
}
void PluginEditor::buildFilterTab()
{
filterView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (filterView);
constexpr int colW = 360;
constexpr int panelH = 300; // retains the original panel height (content top-aligned)
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH);
routingPanel.setBounds (gridX (2, colW, layout::panelSpacing), layout::margin, colW, panelH);
for (auto* panel : { &filter1Panel, &filter2Panel, &routingPanel })
filterView.addAndMakeVisible (panel);
const juce::StringArray filterTypes = { "Ladder LP", "Ladder HP", "Ladder BP", "Diode", "Comb", "Formant", "Screamer" };
const juce::StringArray slopes = { "6 dB", "12 dB", "24 dB" };
const int comboY = comboRowY();
const int displayY = bodyTop();
const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1
// Filter 1
makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
filter1Panel.addAndMakeVisible (filter1Display);
filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f1 = {
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"),
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"),
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-100%)"),
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 keytrack (0-100%)")
};
placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Filter 2
makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
filter2Panel.addAndMakeVisible (filter2Display);
filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f2 = {
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"),
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"),
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-100%)"),
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 keytrack (0-100%)")
};
placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Routing
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
makeKnob (&routingPanel, "Output", ids::fOut, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
}
void PluginEditor::buildModTab()
{
modView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (modView);
const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" };
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
constexpr int colGap = 8;
constexpr int envH = 154;
constexpr int lfoH = 206;
// Envelopes (4 across).
for (int i = 0; i < kNumEnvelopes; ++i)
{
envPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, envH);
modView.addAndMakeVisible (envPanels[(size_t) i]);
envPanels[(size_t) i].addAndMakeVisible (envDisplays[(size_t) i]);
envDisplays[(size_t) i].setBounds (layout::padding, layout::titleHeight,
colW - 2 * layout::padding, layout::envDisplayHeight);
const char* idsA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
const char* idsD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
const char* idsS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
const char* idsR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
const char* idsC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
std::vector<Knob*> knobs = {
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level (0-100%)"),
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Envelope curve (0-100%)")
};
placeKnobs (knobs, layout::padding,
layout::titleHeight + layout::envDisplayHeight + layout::padding,
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
}
// LFOs (4 across).
const int lfoTop = layout::margin + envH + layout::panelSpacing;
for (int i = 0; i < kNumLfos; ++i)
{
lfoPanels[(size_t) i].setBounds (gridX (i, colW, colGap), lfoTop, colW, lfoH);
modView.addAndMakeVisible (lfoPanels[(size_t) i]);
const char* rate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
const char* sync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
const char* beat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
const char* shape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
const char* phase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
const char* fade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
const char* delay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]);
lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight);
std::vector<Knob*> knobs = {
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], formatPercent, "LFO rate (0.02 Hz - 30 Hz)"),
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"),
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], formatPercent, "Phase (0-100%)"),
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"),
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], formatPercent, "Start delay (0-100%)")
};
placeKnobs (knobs, layout::padding,
bodyTop() + layout::lfoDisplayHeight + layout::padding,
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
lfoDisplays[(size_t) i].setOnShapeEdited ([this, i] (const std::vector<float>& data, int steps)
{
processor.engine.setLfoShapeData (i, data, steps);
});
}
// Modulation matrix.
const int matrixTop = lfoTop + lfoH + layout::panelSpacing;
const int matrixH = kBaseH - 60 - matrixTop - layout::margin;
matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH);
modView.addAndMakeVisible (matrixPanel);
std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (targetEnums);
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
modSourceCombo.addItemList (modSourceItems(), 1);
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
modSourceCombo.setTooltip ("Modulation source");
matrixPanel.addAndMakeVisible (modSourceCombo);
modTargetCombo.addItemList (targetItems, 1);
modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight);
modTargetCombo.setTooltip ("Modulation destination");
matrixPanel.addAndMakeVisible (modTargetCombo);
const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap;
modDepthKnob.setRange (-1.0, 1.0);
modDepthKnob.setValue (0.5, juce::dontSendNotification);
modDepthKnob.setFormatter (fmtDepth);
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
modDepthKnob.setTooltip ("Modulation depth (-1 to +1)");
matrixPanel.addAndMakeVisible (modDepthKnob);
modBipolarToggle.setLabel ("Bipolar");
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight);
modBipolarToggle.setTooltip ("Bipolar modulation depth");
matrixPanel.addAndMakeVisible (modBipolarToggle);
modBipolarToggle.setToggleState (true);
const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap;
modAddButton.setButtonText ("Add");
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
modAddButton.setTooltip ("Add modulation connection");
modAddButton.onClick = [this, targetEnums]
{
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
const int tid = modTargetCombo.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size())
{
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
(float) modDepthKnob.getValue(),
modBipolarToggle.getToggleState());
updateModList();
}
};
matrixPanel.addAndMakeVisible (modAddButton);
modRemoveButton.setButtonText ("Remove Last");
modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight);
modRemoveButton.setTooltip ("Remove last modulation connection");
modRemoveButton.onClick = [this]
{
processor.engine.getMatrix().removeConnection (processor.engine.getMatrix().size() - 1);
updateModList();
};
matrixPanel.addAndMakeVisible (modRemoveButton);
modClearButton.setButtonText ("Clear");
modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
modClearButton.setTooltip ("Clear all modulation connections");
modClearButton.onClick = [this]
{
processor.engine.getMatrix().clear();
updateModList();
};
matrixPanel.addAndMakeVisible (modClearButton);
const int listTop = layout::titleHeight + 60 + layout::padding;
modList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding,
matrixH - listTop - layout::padding);
modList.setReadOnly (true);
modList.setMultiLine (true, false);
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
modList.setColour (juce::TextEditor::textColourId, theme::text);
modList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
matrixPanel.addAndMakeVisible (modList);
}
void PluginEditor::buildFxTab()
{
fxView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (fxView);
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser",
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
constexpr int colW = 545;
constexpr int slotH = 150;
for (int i = 0; i < kNumFxSlots; ++i)
{
const int col = i % 2;
const int row = i / 2;
const int x = gridX (col, colW, layout::panelSpacing);
const int y = layout::margin + row * (slotH + layout::panelSpacing);
fxPanels[(size_t) i].setBounds (x, y, colW, slotH);
fxView.addAndMakeVisible (fxPanels[(size_t) i]);
fxTypeCombos[(size_t) i] = makeCombo (&fxPanels[(size_t) i], kFxType[i], fxTypes,
"FX slot " + juce::String (i + 1) + " effect type");
fxTypeCombos[(size_t) i]->setBounds (layout::padding, comboRowY(), 160, layout::comboHeight);
const int upX = layout::padding + 160 + layout::gap;
fxUp[(size_t) i] = new juce::TextButton ("\xe2\x86\x91");
fxUp[(size_t) i]->setBounds (upX, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxUp[(size_t) i]->setTooltip ("Move effect up");
fxPanels[(size_t) i].addAndMakeVisible (fxUp[(size_t) i]);
fxDown[(size_t) i] = new juce::TextButton ("\xe2\x86\x93");
fxDown[(size_t) i]->setBounds (upX + layout::arrowWidth + layout::gap, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxDown[(size_t) i]->setTooltip ("Move effect down");
fxPanels[(size_t) i].addAndMakeVisible (fxDown[(size_t) i]);
fxUp[(size_t) i]->onClick = [this, i] { if (i > 0) swapFxSlots (i, i - 1); };
fxDown[(size_t) i]->onClick = [this, i] { if (i < kNumFxSlots - 1) swapFxSlots (i, i + 1); };
std::vector<Knob*> knobs = {
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4 (0-100%)")
};
placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
fxKnobs[(size_t) i] = knobs;
}
}
void PluginEditor::buildMacroTab()
{
macroView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (macroView);
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
constexpr int colGap = 8;
constexpr int macroH = 158;
for (int i = 0; i < kNumMacros; ++i)
{
macroPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, macroH);
macroView.addAndMakeVisible (macroPanels[(size_t) i]);
const char* ids[4] = { ids::macro1, ids::macro2, ids::macro3, ids::macro4 };
macroKnobs[(size_t) i] = makeKnob (&macroPanels[(size_t) i], MacroControls::macroName (i), ids[i], formatPercent,
MacroControls::macroName (i) + " macro (0-100%)");
macroKnobs[(size_t) i]->setBounds ((colW - layout::macroKnobSize) / 2,
layout::titleHeight + layout::padding,
layout::macroKnobSize, layout::macroKnobSize);
}
// Macro assignment editor.
const int assignTop = layout::margin + macroH + layout::panelSpacing;
const int assignH = kBaseH - 60 - assignTop - layout::margin;
macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH);
macroView.addAndMakeVisible (macroAssignPanel);
std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (targetEnums);
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1);
macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight);
macroAssignIndex.setTooltip ("Macro to assign");
macroAssignPanel.addAndMakeVisible (macroAssignIndex);
macroAssignTarget.addItemList (targetItems, 1);
macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight);
macroAssignTarget.setTooltip ("Destination parameter");
macroAssignPanel.addAndMakeVisible (macroAssignTarget);
const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap;
macroDepthKnob.setFormatter (fmtDepth);
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)");
macroAssignPanel.addAndMakeVisible (macroDepthKnob);
const int assignBtnX = depthX + layout::knobSize + layout::gap;
macroAssignButton.setButtonText ("Assign");
macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
macroAssignButton.setTooltip ("Assign destination to the selected macro");
macroAssignButton.onClick = [this, targetEnums]
{
const int macro = macroAssignIndex.getSelectedItemIndex();
const int tid = macroAssignTarget.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size())
{
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
(float) macroDepthKnob.getValue());
updateMacroList();
}
};
macroAssignPanel.addAndMakeVisible (macroAssignButton);
macroClearButton.setButtonText ("Clear All");
macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
macroClearButton.setTooltip ("Clear all macro assignments");
macroClearButton.onClick = [this]
{
processor.engine.getMacros().clear();
updateMacroList();
};
macroAssignPanel.addAndMakeVisible (macroClearButton);
const int listTop = layout::titleHeight + 60 + layout::padding;
macroList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding,
assignH - listTop - layout::padding);
macroList.setReadOnly (true);
macroList.setMultiLine (true, false);
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
macroList.setColour (juce::TextEditor::textColourId, theme::text);
macroList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
macroAssignPanel.addAndMakeVisible (macroList);
}
void PluginEditor::swapFxSlots (int a, int b)
{
auto swapParam = [this] (const char* pa, const char* pb)
{
auto* p1 = processor.parameters.getParameter (pa);
auto* p2 = processor.parameters.getParameter (pb);
const float v1 = p1->getValue();
const float v2 = p2->getValue();
p1->setValueNotifyingHost (v2);
p2->setValueNotifyingHost (v1);
};
swapParam (kFxType[a], kFxType[b]);
swapParam (kFxMix[a], kFxMix[b]);
swapParam (kFxP1[a], kFxP1[b]);
swapParam (kFxP2[a], kFxP2[b]);
swapParam (kFxP3[a], kFxP3[b]);
swapParam (kFxP4[a], kFxP4[b]);
}
// ---------------------------------------------------------------------------
void PluginEditor::setTab (int index)
{
currentTab = juce::jlimit (0, 4, index);
oscView.setVisible (currentTab == 0);
filterView.setVisible(currentTab == 1);
modView.setVisible (currentTab == 2);
fxView.setVisible (currentTab == 3);
macroView.setVisible (currentTab == 4);
tabOsc.setToggleState (currentTab == 0, juce::dontSendNotification);
tabFilter.setToggleState(currentTab == 1, juce::dontSendNotification);
tabMod.setToggleState (currentTab == 2, juce::dontSendNotification);
tabFx.setToggleState (currentTab == 3, juce::dontSendNotification);
tabMacro.setToggleState (currentTab == 4, juce::dontSendNotification);
}
void PluginEditor::cycleTab (int delta)
{
setTab ((currentTab + delta + 5) % 5);
}
bool PluginEditor::keyPressed (const juce::KeyPress& key)
{
// Only handle plain Tab / Shift+Tab (never Ctrl/Cmd/Alt+Tab).
if (key.getKeyCode() == juce::KeyPress::tabKey
&& ! key.getModifiers().isCtrlDown()
&& ! key.getModifiers().isAltDown()
&& ! key.getModifiers().isCommandDown())
{
// Leave Tab alone while a text editor or combo box is being edited so we
// don't break typing / focus traversal.
for (auto* c = juce::Component::getCurrentlyFocusedComponent(); c != nullptr; c = c->getParentComponent())
{
if (dynamic_cast<juce::TextEditor*> (c) != nullptr
|| dynamic_cast<juce::ComboBox*> (c) != nullptr)
return false;
}
cycleTab (key.getModifiers().isShiftDown() ? -1 : +1);
return true;
}
return false;
}
void PluginEditor::applyUiScale()
{
const float scale = processor.getUiScale();
currentScaleIndex = processor.getUiScaleIndex();
root.setTransform (juce::AffineTransform::scale (scale));
setSize ((int) (kBaseW * scale), (int) (kBaseH * scale));
}
// ---------------------------------------------------------------------------
void PluginEditor::updateVisuals()
{
const auto& apvts = processor.parameters;
auto gv = [&] (const char* id) { return apvts.getRawParameterValue (id)->load(); };
masterDisplay.setValue (formatPercent (gv (ids::master)));
// Waveforms.
const int waveA = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscAWave) * (kNumWavetables - 1)));
const int waveB = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscBWave) * (kNumWavetables - 1)));
oscAWave.setWaveIndex (waveA);
oscAWave.setFramePosition (gv (ids::oscAWtPos));
oscAWave.setEnabled (gv (ids::oscAOn) > 0.5f);
oscBWave.setWaveIndex (waveB);
oscBWave.setFramePosition (gv (ids::oscBWtPos));
oscBWave.setEnabled (gv (ids::oscBOn) > 0.5f);
oscAWave.repaint();
oscBWave.repaint();
// Filters.
filter1Display.setParams ((int) std::llround (gv (ids::f1Type) * 6.0f), gv (ids::f1Cutoff), gv (ids::f1Res),
gv (ids::f1Drive), (int) std::llround (gv (ids::f1Slope) * 2.0f));
filter1Display.setEnabled (gv (ids::f1On) > 0.5f);
filter2Display.setParams ((int) std::llround (gv (ids::f2Type) * 6.0f), gv (ids::f2Cutoff), gv (ids::f2Res),
gv (ids::f2Drive), (int) std::llround (gv (ids::f2Slope) * 2.0f));
filter2Display.setEnabled (gv (ids::f2On) > 0.5f);
filter1Display.repaint();
filter2Display.repaint();
// Envelopes.
const char* a[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
const char* d[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
const char* s[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
const char* r[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
const char* c[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
for (int i = 0; i < kNumEnvelopes; ++i)
{
envDisplays[(size_t) i].setParams (gv (a[i]), gv (d[i]), gv (s[i]), gv (r[i]), gv (c[i]));
envDisplays[(size_t) i].repaint();
}
// LFOs.
const char* lshape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
for (int i = 0; i < kNumLfos; ++i)
{
lfoDisplays[(size_t) i].setShape ((int) std::llround (gv (lshape[i]) * 6.0f));
lfoDisplays[(size_t) i].setShapeData (processor.engine.getLfos()[(size_t) i].getShapeData(),
processor.engine.getLfos()[(size_t) i].getShapeSteps());
lfoDisplays[(size_t) i].repaint();
}
// RAVE state.
raveButton.setToggleState (processor.isRaveEnabled());
// Scale change.
if (processor.getUiScaleIndex() != currentScaleIndex)
applyUiScale();
}
void PluginEditor::updateModList()
{
juce::String text;
const auto& cons = processor.engine.getMatrix().connections;
for (const auto& c : cons)
text += modSourceName (c.source) + " -> " + modTargetName (c.target)
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
modList.setText (text, false);
}
void PluginEditor::updateMacroList()
{
juce::String text;
for (int m = 0; m < kNumMacros; ++m)
{
text += MacroControls::macroName (m) + ":\n";
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
if (assigns.empty())
text += " (none)\n";
for (const auto& a : assigns)
text += " " + modTargetName (a.target) + " [" + juce::String (a.depth, 2) + "]\n";
}
macroList.setText (text, false);
}
void PluginEditor::timerCallback()
{
updateVisuals();
updateModList();
updateMacroList();
}
} // namespace serum