Files
serumalt/Source/PluginEditor.cpp
T
biggy 8364737d1c perf(gui): skip unchanged repaints and gate updates by tab
Add change detection to Display, WaveformDisplay, FilterDisplay,
EnvelopeDisplay, and LFODisplay so repaint() is only called when
values actually change. Gate updateVisuals() by currentTab so only
the visible tab's displays are updated each timer tick. Similarly
gate updateModList/updateMacroList to their respective tabs.

Manage dynamically created controls via ownedControls
unique_ptr vector instead of raw new leaks. Null-check parameter
lookups before creating attachments. Hold engine control lock
when reading matrix/macros/LFO shape data.

Fix envelope display curve directions (attack rising from bottom,
decay falling toward sustain, release falling toward bottom).
Add tooltip to FilterDisplay noting the approximation. Replace
ToggleButton raw param pointer with ParameterAttachment for proper
gesture handling. Sync preset and scale combos to processor state.
2026-09-09 14:33:41 +02:00

969 lines
46 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);
}
constexpr auto& kFxType = paramIds::fxType;
constexpr auto& kFxMix = paramIds::fxMix;
constexpr auto& kFxP1 = paramIds::fxP1;
constexpr auto& kFxP2 = paramIds::fxP2;
constexpr auto& kFxP3 = paramIds::fxP3;
constexpr auto& kFxP4 = paramIds::fxP4;
constexpr const char* kFxParamNames[(int) FxType::Count][4] = {
{ "P1", "P2", "P3", "P4" },
{ "Intensity", "Low Amount", "High Amount", "Output" },
{ "Rate", "Depth", "Width", "Unused" },
{ "Rate", "Depth", "Feedback", "Unused" },
{ "Rate", "Depth", "Feedback", "Stages" },
{ "Drive", "Shape", "Tone", "Output" },
{ "Low Gain", "Mid Gain", "High Gain", "Mid Freq" },
{ "Threshold", "Ratio", "Attack", "Release" },
{ "Time", "Feedback", "Damping", "Ping-Pong" },
{ "Size", "Damping", "Width", "Predelay" }
};
}
// ---------------------------------------------------------------------------
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);
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 control = std::make_unique<Knob> (name, std::move (fmt));
auto* k = control.get();
ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (k);
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
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 control = std::make_unique<juce::ComboBox>();
auto* c = control.get();
ownedControls.push_back (std::move (control));
c->addItemList (items, 1);
c->setSelectedItemIndex (0, juce::dontSendNotification);
parent->addAndMakeVisible (c);
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
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 control = std::make_unique<ToggleButton> (label);
auto* t = control.get();
ownedControls.push_back (std::move (control));
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 auto& idsA = paramIds::envAttack;
const auto& idsD = paramIds::envDecay;
const auto& idsS = paramIds::envSustain;
const auto& idsR = paramIds::envRelease;
const auto& idsC = paramIds::envCurve;
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 auto& rate = paramIds::lfoRate;
const auto& sync = paramIds::lfoSync;
const auto& beat = paramIds::lfoBeat;
const auto& shape = paramIds::lfoShape;
const auto& phase = paramIds::lfoPhase;
const auto& fade = paramIds::lfoFade;
const auto& delay = paramIds::lfoDelay;
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())
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
auto& matrix = processor.engine.getMatrix();
if (matrix.size() > 0)
matrix.removeConnection (matrix.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]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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;
auto up = std::make_unique<juce::TextButton> ("\xe2\x86\x91");
fxUp[(size_t) i] = up.get();
ownedControls.push_back (std::move (up));
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]);
auto down = std::make_unique<juce::TextButton> ("\xe2\x86\x93");
fxDown[(size_t) i] = down.get();
ownedControls.push_back (std::move (down));
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 auto& macroIds = paramIds::macros;
macroKnobs[(size_t) i] = makeKnob (&macroPanels[(size_t) i], MacroControls::macroName (i), macroIds[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())
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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)
{
if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b)
return;
auto swapParam = [this] (const char* pa, const char* pb)
{
auto* p1 = processor.parameters.getParameter (pa);
auto* p2 = processor.parameters.getParameter (pb);
if (p1 == nullptr || p2 == nullptr)
return;
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);
timerCallback();
}
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)
{
if (auto* value = apvts.getRawParameterValue (id))
return value->load();
return 0.0f;
};
masterDisplay.setValue (formatPercent (gv (ids::master)));
// Waveforms.
if (currentTab == 0)
{
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);
}
// Filters.
if (currentTab == 1)
{
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);
}
// Envelopes.
if (currentTab == 2)
{
const auto& a = paramIds::envAttack;
const auto& d = paramIds::envDecay;
const auto& s = paramIds::envSustain;
const auto& r = paramIds::envRelease;
const auto& c = paramIds::envCurve;
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]));
}
// LFOs.
if (currentTab == 2)
{
const auto& lshape = paramIds::lfoShape;
const juce::ScopedLock lock (processor.engine.getControlLock());
for (int i = 0; i < kNumLfos; ++i)
{
const auto& lfo = processor.engine.getLfos()[(size_t) i];
lfoDisplays[(size_t) i].setShape ((int) std::llround (gv (lshape[i]) * 6.0f));
lfoDisplays[(size_t) i].setShapeData (lfo.getShapeData(), lfo.getShapeSteps());
}
}
if (currentTab == 3)
{
for (int i = 0; i < kNumFxSlots; ++i)
{
const int type = juce::jlimit (0, (int) FxType::Count - 1,
(int) std::llround (gv (kFxType[i]) * ((int) FxType::Count - 1)));
for (int p = 0; p < 4; ++p)
{
auto* knob = fxKnobs[(size_t) i][(size_t) p + 1];
const juce::String name = kFxParamNames[type][p];
if (knob->getName() != name)
{
knob->setName (name);
knob->setTooltip (name == "Unused" ? "Not used by this effect"
: name + " (normalised 0-100%)");
knob->repaint();
}
}
}
}
// RAVE state.
raveButton.setToggleState (processor.isRaveEnabled());
const int program = processor.getCurrentProgram();
if (presetCombo.getSelectedItemIndex() != program)
presetCombo.setSelectedItemIndex (program, juce::dontSendNotification);
// Scale change.
if (processor.getUiScaleIndex() != currentScaleIndex)
applyUiScale();
if (scaleCombo.getSelectedItemIndex() != currentScaleIndex)
scaleCombo.setSelectedItemIndex (currentScaleIndex, juce::dontSendNotification);
}
void PluginEditor::updateModList()
{
juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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";
}
if (modList.getText() != text)
modList.setText (text, false);
}
void PluginEditor::updateMacroList()
{
juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
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";
}
}
if (macroList.getText() != text)
macroList.setText (text, false);
}
void PluginEditor::timerCallback()
{
updateVisuals();
if (currentTab == 2)
updateModList();
else if (currentTab == 4)
updateMacroList();
}
} // namespace serum