monostep/Source/PluginEditor.cpp

1095 lines
41 KiB
C++

#include "PluginEditor.h"
#include "PluginProcessor.h"
#include "Theme.h"
#include "BuildInfo.h"
#include "dsp/FxProcessor.h"
using namespace monostep;
namespace
{
constexpr int oscDropW = 96; // OSC 1 / OSC 2 dropdown width
constexpr int oscLabelW = 46; // label strip to the left of each dropdown
constexpr int oscGapW = 8; // gap between the dropdown column and the knobs
juce::ColourGradient orangeGradient (const juce::Rectangle<float>& area)
{
const auto top = area.getTopLeft().translated (area.getWidth() * 0.5f, 0.0f);
const auto bottom = area.getBottomLeft().translated (area.getWidth() * 0.5f, 0.0f);
return juce::ColourGradient (colours::accent, top,
colours::accent.darker (0.35f), bottom,
false);
}
juce::ColourGradient sectionGradient (const juce::Rectangle<float>& area)
{
const auto top = area.getTopLeft().translated (area.getWidth() * 0.5f, 0.0f);
const auto bottom = area.getBottomLeft().translated (area.getWidth() * 0.5f, 0.0f);
return juce::ColourGradient (colours::section, top,
colours::section.darker (0.35f), bottom,
false);
}
} // namespace
//==============================================================================
EditorLookAndFeel::EditorLookAndFeel()
{
setColour (juce::Slider::textBoxBackgroundColourId, colours::panel);
setColour (juce::Slider::textBoxTextColourId, colours::text);
setColour (juce::Slider::textBoxOutlineColourId, colours::grid);
}
void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w, int h,
float sliderPos, float rotaryStartAngle,
float rotaryEndAngle, juce::Slider&)
{
const auto bounds = juce::Rectangle<float> ((float) x, (float) y, (float) w, (float) h);
const auto centre = bounds.getCentre();
const float radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f;
const float start = rotaryStartAngle;
const float end = rotaryEndAngle;
const float angle = start + sliderPos * (end - start);
const auto bodyRect = juce::Rectangle<float> (centre.getX() - radius, centre.getY() - radius,
radius * 2.0f, radius * 2.0f);
// Raised body: lighter at the top edge, darker at the bottom.
juce::ColourGradient bodyGrad (colours::gridLight.brighter (0.15f),
centre.getX(), bodyRect.getY(),
colours::grid.darker (0.18f),
centre.getX(), bodyRect.getBottom(), false);
g.setGradientFill (bodyGrad);
g.fillEllipse (bodyRect);
// Almost-vertical (-2°) shading overlay to give the dial a subtle 3D look.
constexpr float degToRad = juce::MathConstants<float>::pi / 180.0f;
const float tiltSin = std::sin (-2.0f * degToRad);
const float tiltCos = std::cos (-2.0f * degToRad);
const auto topPoint = centre + juce::Point<float> ( radius * tiltSin, -radius * tiltCos);
const auto bottomPoint = centre + juce::Point<float> (-radius * tiltSin, radius * tiltCos);
juce::ColourGradient shading (juce::Colours::black.withAlpha (0.10f), topPoint,
juce::Colours::black.withAlpha (0.10f), bottomPoint, false);
g.setGradientFill (shading);
g.fillEllipse (bodyRect);
// Outer rim.
g.setColour (colours::gridLight);
g.drawEllipse (bodyRect, 1.0f);
if (end - start < 6.0f)
{
juce::Path valueArc;
valueArc.addCentredArc (centre.getX(), centre.getY(),
radius * 0.85f, radius * 0.85f,
0.0f, start, angle, true);
g.setGradientFill (orangeGradient (bounds));
g.strokePath (valueArc, juce::PathStrokeType (2.5f));
}
// The pointer reaches out to the value arc so it touches the arc.
const float tickLen = radius * 0.85f + 1.0f;
g.setGradientFill (orangeGradient (bounds));
g.drawLine (centre.getX(), centre.getY(),
centre.getX() + tickLen * std::sin (angle),
centre.getY() - tickLen * std::cos (angle),
2.5f);
g.setColour (colours::accent);
g.fillEllipse (centre.getX() - 2.5f, centre.getY() - 2.5f, 5.0f, 5.0f);
}
void EditorLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& button,
bool, bool)
{
const auto bounds = button.getLocalBounds().toFloat();
if (button.getToggleState())
{
g.setGradientFill (orangeGradient (bounds));
g.fillRoundedRectangle (bounds, 4.0f);
}
else
{
// Raised, slightly-3D inactive button.
juce::ColourGradient offGrad (colours::gridLight.brighter (0.12f),
bounds.getX(), bounds.getY(),
colours::grid.darker (0.15f),
bounds.getX(), bounds.getBottom(), false);
g.setGradientFill (offGrad);
g.fillRoundedRectangle (bounds, 4.0f);
g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f));
g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1,
bounds.getX() + 3.0f, bounds.getRight() - 3.0f);
g.setColour (colours::bg.withAlpha (0.30f));
g.drawRoundedRectangle (bounds.reduced (0.5f), 4.0f, 1.0f);
}
g.setFont (font (11.0f));
g.setColour (button.getToggleState() ? colours::bg : colours::text);
g.drawText (button.getButtonText(), bounds, juce::Justification::centred);
}
void EditorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button,
const juce::Colour&, bool highlighted, bool)
{
const auto bounds = button.getLocalBounds().toFloat().reduced (0.5f);
if (button.getToggleState())
{
g.setGradientFill (orangeGradient (bounds));
g.fillRoundedRectangle (bounds, 4.0f);
}
else
{
// Raised, slightly-3D inactive button.
const juce::Colour topColour = highlighted ? colours::gridLight.brighter (0.22f)
: colours::gridLight.brighter (0.10f);
juce::ColourGradient offGrad (topColour, bounds.getX(), bounds.getY(),
colours::grid.darker (0.15f),
bounds.getX(), bounds.getBottom(), false);
g.setGradientFill (offGrad);
g.fillRoundedRectangle (bounds, 4.0f);
g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f));
g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1,
bounds.getX() + 3.0f, bounds.getRight() - 3.0f);
}
}
void EditorLookAndFeel::drawButtonText (juce::Graphics& g, juce::TextButton& button, bool, bool)
{
g.setFont (font (10.5f));
g.setColour (button.getToggleState() ? colours::bg : colours::text);
g.drawText (button.getButtonText(), button.getLocalBounds(), juce::Justification::centred);
}
void EditorLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, bool,
int, int, int, int, juce::ComboBox&)
{
const auto bounds = juce::Rectangle<float> (1.0f, 1.0f, (float) (width - 2), (float) (height - 2));
// Subtle drop shadow under the box.
g.setColour (juce::Colours::black.withAlpha (0.30f));
g.fillRoundedRectangle (bounds.translated (0.0f, 1.5f), 4.0f);
// Raised box: lighter top edge, darker bottom.
juce::ColourGradient boxGrad (colours::gridLight.brighter (0.10f),
bounds.getX(), bounds.getY(),
colours::grid.darker (0.15f),
bounds.getX(), bounds.getBottom(), false);
g.setGradientFill (boxGrad);
g.fillRoundedRectangle (bounds, 4.0f);
g.setColour (colours::gridLight);
g.drawRoundedRectangle (bounds, 4.0f, 1.0f);
g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f));
g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1,
bounds.getX() + 4.0f, bounds.getRight() - 4.0f);
const float cx = (float) (width - 15);
const float cy = (float) height * 0.5f;
juce::Path arrow;
arrow.addTriangle (cx - 5.0f, cy - 3.0f, cx + 5.0f, cy - 3.0f, cx, cy + 3.5f);
g.setColour (colours::accent);
g.fillPath (arrow);
}
juce::Font EditorLookAndFeel::getComboBoxFont (juce::ComboBox&)
{
return font (11.0f);
}
void EditorLookAndFeel::drawPopupMenuBackground (juce::Graphics& g, int width, int height)
{
// Slightly recessed panel with a gentle top-to-bottom gradient.
juce::ColourGradient bgGrad (colours::gridLight, 0.0f, 0.0f,
colours::panel.darker (0.06f), 0.0f, (float) height, false);
g.setGradientFill (bgGrad);
g.fillRect (0, 0, width, height);
g.setColour (colours::gridLight);
g.drawRect (0.0f, 0.0f, (float) width, (float) height, 1.0f);
}
void EditorLookAndFeel::drawPopupMenuItem (juce::Graphics& g, const juce::Rectangle<int>& area,
bool isSeparator, bool isActive, bool isHighlighted,
bool isTicked, bool hasSubMenu,
const juce::String& text, const juce::String& shortcutKeyText,
const juce::Drawable* icon, const juce::Colour* textColour)
{
(void) hasSubMenu;
(void) icon;
if (isSeparator)
{
g.setColour (colours::grid);
g.fillRect (area.reduced (10, 0).withY (area.getCentreY()).withHeight (1));
return;
}
g.setColour (isHighlighted ? colours::gridLight : colours::panel);
g.fillRect (area);
if (isTicked)
{
g.setColour (colours::accent);
g.fillEllipse ((float) (area.getRight() - 17), area.getCentreY() - 3.5f, 7.0f, 7.0f);
}
g.setFont (font (11.0f));
g.setColour (isActive ? (textColour != nullptr ? *textColour : colours::text)
: colours::textDim);
g.drawFittedText (text, area.withTrimmedLeft (12).withTrimmedRight (isTicked ? 28 : 10),
juce::Justification::centredLeft, 1);
if (shortcutKeyText.isNotEmpty())
{
g.setColour (colours::textDim);
g.drawFittedText (shortcutKeyText, area.withTrimmedRight (16), juce::Justification::centredRight, 1);
}
}
juce::Font EditorLookAndFeel::getPopupMenuFont()
{
return font (11.0f);
}
//==============================================================================
Knob::Knob (const juce::String& title)
{
slider.setSliderStyle (juce::Slider::RotaryVerticalDrag);
slider.setRotaryParameters (juce::MathConstants<float>::pi * 1.25f,
juce::MathConstants<float>::pi * 2.75f,
true);
slider.setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
slider.onValueChange = [this]
{
updateValueLabel();
if (customOnValueChange != nullptr)
customOnValueChange ((float) slider.getValue());
};
addAndMakeVisible (slider);
titleLabel.setText (title, juce::dontSendNotification);
titleLabel.setJustificationType (juce::Justification::centred);
titleLabel.setFont (font (10.0f));
titleLabel.setColour (juce::Label::textColourId, colours::text);
addAndMakeVisible (titleLabel);
valueLabel.setJustificationType (juce::Justification::centred);
valueLabel.setFont (font (9.0f));
valueLabel.setColour (juce::Label::textColourId, colours::accent);
addAndMakeVisible (valueLabel);
updateValueLabel();
}
void Knob::resized()
{
const int w = getWidth();
const int h = getHeight();
titleLabel.setBounds (0, 0, w, 14);
const int valueH = 12;
const int sliderH = juce::jmax (20, juce::jmin (w - 8, h - 15 - valueH));
slider.setBounds (4, 15, w - 8, sliderH);
valueLabel.setBounds (0, 15 + sliderH, w, valueH);
}
void Knob::updateValueLabel()
{
const float value = slider.getValue();
if (format)
valueLabel.setText (format (value), juce::dontSendNotification);
else
valueLabel.setText (juce::String (value, 2), juce::dontSendNotification);
}
//==============================================================================
ChoiceBar::ChoiceBar (juce::AudioProcessorValueTreeState& apvtsRef,
const juce::String& id,
const juce::StringArray& choices)
: apvts (&apvtsRef), paramId (id)
{
for (int i = 0; i < choices.size(); ++i)
{
auto* button = buttons.add (new juce::TextButton (choices[i]));
button->setClickingTogglesState (true);
button->setRadioGroupId (1);
button->onClick = [this, i] { setIndex (i); };
addAndMakeVisible (button);
}
apvts->addParameterListener (paramId, this);
refreshFromParam();
}
ChoiceBar::ChoiceBar (const juce::StringArray& choices,
std::function<void (int)> onSelected)
: manualHandler (std::move (onSelected))
{
for (int i = 0; i < choices.size(); ++i)
{
auto* button = buttons.add (new juce::TextButton (choices[i]));
button->setClickingTogglesState (true);
button->setRadioGroupId (1);
button->onClick = [this, i] { setIndex (i); };
addAndMakeVisible (button);
}
buttons.getFirst()->setToggleState (true, juce::dontSendNotification);
}
ChoiceBar::~ChoiceBar()
{
if (apvts != nullptr)
apvts->removeParameterListener (paramId, this);
}
void ChoiceBar::parameterChanged (const juce::String&, float)
{
juce::MessageManager::callAsync ([this] { refreshFromParam(); });
}
void ChoiceBar::refreshFromParam()
{
if (apvts == nullptr)
return;
if (auto* parameter = apvts->getParameter (paramId))
{
const int index = juce::roundToInt (parameter->convertFrom0to1 (parameter->getValue()));
for (int i = 0; i < buttons.size(); ++i)
buttons[i]->setToggleState (i == index, juce::dontSendNotification);
}
}
void ChoiceBar::setIndex (int index)
{
if (apvts != nullptr)
{
if (auto* parameter = apvts->getParameter (paramId))
{
parameter->beginChangeGesture();
parameter->setValueNotifyingHost (parameter->convertTo0to1 ((float) index));
parameter->endChangeGesture();
}
return;
}
if (manualHandler != nullptr)
manualHandler (index);
}
void ChoiceBar::resized()
{
const int count = buttons.size();
const float spacing = 3.0f;
const float w = ((float) getWidth() - spacing * (count - 1)) / (float) count;
for (int i = 0; i < count; ++i)
buttons[i]->setBounds (juce::roundToInt (i * (w + spacing)), 0,
juce::roundToInt (w), getHeight());
}
//==============================================================================
StepPanel::StepPanel (MonostepAudioProcessor& processorRef)
: processor (processorRef),
fineKnob ("Fine")
{
titleLabel.setText ("Step", juce::dontSendNotification);
titleLabel.setJustificationType (juce::Justification::centred);
titleLabel.setFont (font (12.0f));
titleLabel.setColour (juce::Label::textColourId, colours::text);
addAndMakeVisible (titleLabel);
noteLabel.setJustificationType (juce::Justification::centred);
noteLabel.setFont (font (20.0f));
noteLabel.setColour (juce::Label::textColourId, colours::accent);
addAndMakeVisible (noteLabel);
gateLabel.setJustificationType (juce::Justification::centred);
gateLabel.setFont (font (10.0f));
gateLabel.setColour (juce::Label::textColourId, colours::textDim);
addAndMakeVisible (gateLabel);
auto& fineSlider = fineKnob.getSlider();
fineSlider.setRange (-50.0, 50.0, 1.0);
fineSlider.setValue (0.0, juce::dontSendNotification);
fineKnob.setFormat ([] (float v) { return juce::String (v, 0) + " ct"; });
fineKnob.setOnValueChanged ([this] (float v)
{
if (! updating)
processor.setStepCents (selectedStep, v);
});
addAndMakeVisible (fineKnob);
prevButton.setButtonText ("<");
prevButton.onClick = [this] { setSelectedStep (selectedStep - 1); };
addAndMakeVisible (prevButton);
nextButton.setButtonText (">");
nextButton.onClick = [this] { setSelectedStep (selectedStep + 1); };
addAndMakeVisible (nextButton);
clearButton.setButtonText ("Clear Pattern");
clearButton.onClick = [this] { clearPattern(); };
addAndMakeVisible (clearButton);
shiftLeftButton.setButtonText ("<<");
shiftLeftButton.onClick = [this] { processor.shiftPattern (-1); };
addAndMakeVisible (shiftLeftButton);
shiftRightButton.setButtonText (">>");
shiftRightButton.onClick = [this] { processor.shiftPattern (1); };
addAndMakeVisible (shiftRightButton);
toolsCaption.setText ("TOOLS", juce::dontSendNotification);
toolsCaption.setJustificationType (juce::Justification::centredLeft);
toolsCaption.setFont (font (9.0f));
toolsCaption.setColour (juce::Label::textColourId, colours::textDim);
addAndMakeVisible (toolsCaption);
presetCombo.setTextWhenNothingSelected ("Pick a preset");
presetCombo.setColour (juce::ComboBox::textColourId, colours::text);
presetCombo.setColour (juce::ComboBox::outlineColourId, colours::gridLight);
presetCombo.setColour (juce::ComboBox::backgroundColourId, colours::grid);
presetCombo.setColour (juce::ComboBox::arrowColourId, colours::accent);
for (int i = 0; i < processor.getNumPresets(); ++i)
presetCombo.addItem (processor.getPresetName (i), i + 1);
presetCombo.onChange = [this]
{
const int id = presetCombo.getSelectedId();
if (id > 0)
processor.applyPreset (id - 1);
};
addAndMakeVisible (presetCombo);
randomizeButton.setButtonText ("Randomize");
randomizeButton.onClick = [this]
{
if (randomizeToggles[RandomPattern].getToggleState())
processor.randomizePattern();
if (randomizeToggles[RandomOsc].getToggleState())
processor.randomizeOsc();
if (randomizeToggles[RandomFilter].getToggleState())
processor.randomizeFilter();
if (randomizeToggles[RandomEnv].getToggleState())
processor.randomizeEnv();
if (randomizeToggles[RandomSeq].getToggleState())
processor.randomizeSeqSettings();
if (randomizeToggles[RandomFx].getToggleState())
processor.randomizeFx();
if (randomizeToggles[RandomAccent].getToggleState())
processor.randomizeAccent();
if (randomizeToggles[RandomSlide].getToggleState())
processor.randomizeSlide();
if (randomizeToggles[RandomFine].getToggleState())
processor.randomizeFine();
if (randomizeToggles[RandomRate].getToggleState())
processor.randomizeRate();
};
addAndMakeVisible (randomizeButton);
randomizeCaption.setText ("RANDOMIZE", juce::dontSendNotification);
randomizeCaption.setJustificationType (juce::Justification::centredLeft);
randomizeCaption.setFont (font (9.0f));
randomizeCaption.setColour (juce::Label::textColourId, colours::textDim);
addAndMakeVisible (randomizeCaption);
static const char* groupNames[] = { "Pattern", "Osc", "Filter", "Env", "Length", "FX",
"Accent", "Slide", "Fine", "Rate" };
for (int i = 0; i < RandomGroupCount; ++i)
{
randomizeToggles[i].setButtonText (groupNames[i]);
randomizeToggles[i].setToggleState (i == RandomPattern, juce::dontSendNotification);
addAndMakeVisible (randomizeToggles[i]);
}
resetFineButton.setButtonText ("Reset Fine");
resetFineButton.onClick = [this]
{
for (int i = 0; i < numSteps; ++i)
processor.setStepCents (i, 0.0f);
};
addAndMakeVisible (resetFineButton);
retrigToggle.setButtonText ("Retrig");
retrigToggle.setTooltip ("Restart the pattern from step 1 on each new note trigger");
retrigAttachment = std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment> (processor.getAPVTS(), "seqRetrig", retrigToggle);
addAndMakeVisible (retrigToggle);
setSelectedStep (0);
}
void StepPanel::setSelectedStep (int stepIdx)
{
if (stepIdx < 0 || stepIdx >= numSteps)
return;
if (stepIdx != selectedStep)
{
selectedStep = stepIdx;
if (onStepSelected)
onStepSelected (stepIdx);
}
refresh();
}
void StepPanel::refresh()
{
const auto& s = processor.getSequencer().step (selectedStep);
updating = true;
titleLabel.setText ("Step " + juce::String (selectedStep + 1), juce::dontSendNotification);
noteLabel.setText (processor.getStepNoteName (selectedStep)
+ (std::fabs (s.cents) > 0.5f ? " (" + formatCents (s.cents) + ")" : ""),
juce::dontSendNotification);
gateLabel.setText (s.gate ? "gate: on" : "gate: off", juce::dontSendNotification);
fineKnob.getSlider().setValue (s.cents, juce::dontSendNotification);
updating = false;
}
void StepPanel::clearPattern()
{
for (int i = 0; i < numSteps; ++i)
{
processor.setStepGate (i, false);
processor.setStepNote (i, 0);
processor.setStepCents (i, 0.0f);
processor.setStepSlide (i, false);
processor.setStepAccent (i, false);
}
refresh();
}
void StepPanel::resized()
{
const int w = getWidth();
titleLabel.setBounds (0, 4, w, 16);
noteLabel.setBounds (0, 22, w, 26);
gateLabel.setBounds (0, 50, w, 14);
const int knobX = (w - 74) / 2;
const int knobY = 66;
fineKnob.setBounds (knobX, knobY, 74, 84);
const int btnY = knobY + 84 + 6;
prevButton.setBounds (knobX - 32, btnY, 24, 24);
nextButton.setBounds (knobX + 74 + 8, btnY, 24, 24);
const int shiftW = 22;
shiftLeftButton.setBounds (10, btnY + 40, shiftW, 24);
clearButton.setBounds (10 + shiftW + 4, btnY + 40, w - 20 - shiftW * 2 - 8, 24);
shiftRightButton.setBounds (10 + w - 20 - shiftW, btnY + 40, shiftW, 24);
toolsCaption.setBounds (10, btnY + 70, w - 20, 12);
presetCombo.setBounds (10, btnY + 84, w - 20, 24);
randomizeButton.setBounds (10, btnY + 112, w - 20, 24);
randomizeCaption.setBounds (10, btnY + 140, w - 20, 12);
const int colW = (w - 20 - 4) / 2;
const int rowH = 18;
for (int i = 0; i < RandomGroupCount; ++i)
{
const int col = i % 2;
const int row = i / 2;
randomizeToggles[i].setBounds (10 + col * (colW + 4), btnY + 154 + row * (rowH + 2), colW, rowH);
}
const int retrigW = 100;
retrigToggle.setBounds (10, btnY + 254, retrigW, 24);
resetFineButton.setBounds (10 + retrigW + 4, btnY + 254, w - 20 - retrigW - 4, 24);
}
//==============================================================================
MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcessor& processor)
: AudioProcessorEditor (processor),
processorRef (processor),
content (*this),
lnf (std::make_unique<EditorLookAndFeel>()),
lengthKnob ("Length"),
swingKnob ("Swing"),
rootKnob ("Root"),
gateKnob ("Gate"),
masterKnob ("Master"),
coarseKnob ("Coarse"),
coarseKnob2 ("Coarse"),
detuneKnob ("Detune"),
mixKnob ("Mix"),
fineKnob1 ("Fine"),
fineKnob2 ("Fine"),
cutoffKnob ("Cutoff"),
resKnob ("Res"),
glideKnob ("Glide"),
attackKnob ("Attack"),
decayKnob ("Decay"),
sustainKnob ("Sustain"),
releaseKnob ("Release"),
fAttackKnob ("Attack"),
fDecayKnob ("Decay"),
fSustainKnob ("Sustain"),
fReleaseKnob ("Release"),
fAmountKnob ("Amt"),
driveKnob ("Drive"),
ringKnob ("Ring"),
delayTimeKnob ("Time"),
delayFeedbackKnob ("Feedback"),
delayDryWetKnob ("Dry/Wet"),
reverbRoomKnob ("Room"),
reverbStrengthKnob ("Level"),
reverbDiffusionKnob ("Diff"),
reverbDryWetKnob ("Dry/Wet"),
accentKnob ("Accent"),
rateBar (processor.getAPVTS(), "seqRate", { "1/4", "1/8", "1/8T", "1/16", "1/32" }),
octaveBar (processor.getAPVTS(), "seqOctave", { "-2", "-1", "0", "+1", "+2" }),
zoomBar ({ "100%", "125%", "150%", "175%", "200%" }, [] (int) {}),
matrix (processor),
stepPanel (processor)
{
setLookAndFeel (lnf.get());
setSize (baseWidth, baseHeight);
auto& apvts = processor.getAPVTS();
addAndMakeVisible (content);
content.addAndMakeVisible (rateBar);
content.addAndMakeVisible (octaveBar);
content.addAndMakeVisible (zoomBar);
const juce::StringArray waveformChoices =
{
"Sine", "Triangle", "Saw", "Square",
"Pulse", "Noise", "Sub", "Pluck",
"Super", "S&H", "Formant", "PWM"
};
auto setupWaveCombo = [&waveformChoices] (juce::ComboBox& combo)
{
combo.setColour (juce::ComboBox::textColourId, colours::text);
combo.setColour (juce::ComboBox::outlineColourId, colours::gridLight);
combo.setColour (juce::ComboBox::backgroundColourId, colours::grid);
combo.setColour (juce::ComboBox::arrowColourId, colours::accent);
for (int i = 0; i < waveformChoices.size(); ++i)
combo.addItem (waveformChoices[i], i + 1);
};
setupWaveCombo (wave1Box);
setupWaveCombo (wave2Box);
wave1Attachment = std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (apvts, "osc1Wave", wave1Box);
wave2Attachment = std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (apvts, "osc2Wave", wave2Box);
// filter type dropdown (below the two OSC dropdowns)
const juce::StringArray filterTypeChoices =
{
"12 LP", "12 HP", "12 BP", "12 Notch",
"24 LP", "24 HP", "24 BP", "24 Notch",
"48 LP", "48 HP", "48 BP", "48 Notch"
};
for (int i = 0; i < filterTypeChoices.size(); ++i)
filterTypeBox.addItem (filterTypeChoices[i], i + 1);
filterTypeBox.setColour (juce::ComboBox::textColourId, colours::text);
filterTypeBox.setColour (juce::ComboBox::outlineColourId, colours::gridLight);
filterTypeBox.setColour (juce::ComboBox::backgroundColourId, colours::grid);
filterTypeBox.setColour (juce::ComboBox::arrowColourId, colours::accent);
filterTypeAttachment = std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (apvts, "filterType", filterTypeBox);
content.addAndMakeVisible (wave1Box);
content.addAndMakeVisible (wave2Box);
content.addAndMakeVisible (filterTypeBox);
static constexpr float zoomValues[] = { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f };
zoomBar.setManualHandler ([this] (int idx)
{
idx = juce::jlimit (0, 4, idx);
processorRef.setZoomIndex (idx);
setZoomFactor (zoomValues[idx]);
});
auto addKnob = [&apvts, this] (Knob& knob, const juce::String& id, std::function<juce::String (float)> fmt)
{
content.addAndMakeVisible (knob);
knob.attach (apvts, id);
knob.setFormat (std::move (fmt));
};
addKnob (lengthKnob, "seqLen", [] (float v) { return juce::String ((int) v); });
addKnob (swingKnob, "seqSwing", [] (float v) { return juce::String (v, 2); });
addKnob (rootKnob, "seqRoot", [] (float v) { return juce::String ((int) v); });
addKnob (gateKnob, "seqGateLen", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (masterKnob, "master", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (coarseKnob, "osc2Coarse", [] (float v) { return juce::String (juce::roundToInt (v)) + " st"; });
addKnob (detuneKnob, "detune", [] (float v) { return juce::String (juce::roundToInt (v)) + " ct"; });
addKnob (mixKnob, "mix", [] (float v) { return juce::String (v, 2); });
addKnob (cutoffKnob, "cutoff", [] (float v) { return v >= 1000.0f ? juce::String (juce::roundToInt (v / 100.0f) * 100) + " Hz" : juce::String (juce::roundToInt (v)) + " Hz"; });
addKnob (resKnob, "res", [] (float v) { return juce::String (v, 2); });
addKnob (glideKnob, "glide", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (attackKnob, "attack", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (decayKnob, "decay", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (sustainKnob, "sustain", [] (float v) { return juce::String (v, 2); });
addKnob (releaseKnob, "release", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (fAttackKnob, "filterAttack", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (fDecayKnob, "filterDecay", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (fSustainKnob, "filterSustain", [] (float v) { return juce::String (v, 2); });
addKnob (fReleaseKnob, "filterRelease", [] (float v) { return juce::String (v, 2) + "s"; });
addKnob (fAmountKnob, "filterEnvAmt", [] (float v) { return juce::String (v, 2) + " oct"; });
addKnob (driveKnob, "drive", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (ringKnob, "ringmod", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (delayTimeKnob, "delayTime", [this] (float v)
{
const bool sync = processorRef.getAPVTS().getRawParameterValue ("delaySync")->load() >= 0.5f;
return monostep::FxProcessor::delayTimeLabel (v, sync, 120.0);
});
addKnob (delayFeedbackKnob, "delayStrength", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (delayDryWetKnob, "delayDryWet", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbRoomKnob, "reverbRoom", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbStrengthKnob, "reverbStrength", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbDiffusionKnob, "reverbDiffusion", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbDryWetKnob, "reverbDryWet", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (accentKnob, "accent", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
delaySyncToggle.setButtonText ("Sync");
delaySyncToggle.setTooltip ("Snap the delay time to the host tempo (off: free milliseconds)");
delaySyncAttachment = std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment> (apvts, "delaySync", delaySyncToggle);
content.addAndMakeVisible (delaySyncToggle);
fAmountKnob.setDragSensitivity (800);
content.addAndMakeVisible (matrix);
content.addAndMakeVisible (stepPanel);
matrix.onStepSelected = [this] (int stepIdx)
{
stepPanel.setSelectedStep (stepIdx);
};
stepPanel.onStepSelected = [this] (int stepIdx)
{
matrix.setSelectedStep (stepIdx);
};
lengthKnob.setOnValueChanged ([this] (float)
{
matrix.repaint();
});
matrix.onGateChanged = [this] (int stepIdx, bool gate)
{
processorRef.setStepGate (stepIdx, gate);
stepPanel.refresh();
};
matrix.onNoteChanged = [this] (int stepIdx, int semitone)
{
processorRef.setStepNote (stepIdx, semitone);
stepPanel.refresh();
};
matrix.onFineChanged = [this] (int stepIdx, float cents)
{
processorRef.setStepCents (stepIdx, cents);
stepPanel.refresh();
};
matrix.onSlideChanged = [this] (int stepIdx, bool slide)
{
processorRef.setStepSlide (stepIdx, slide);
stepPanel.refresh();
};
matrix.onAccentChanged = [this] (int stepIdx, bool accent)
{
processorRef.setStepAccent (stepIdx, accent);
stepPanel.refresh();
};
processorRef.onPatternChanged = [this] { refreshEditor(); };
startTimerHz (30);
refreshEditor();
const int savedZoom = processorRef.getZoomIndex();
zoomBar.setSelectedIndex (savedZoom);
setZoomFactor (zoomValues[juce::jlimit (0, 4, savedZoom)]);
}
MonostepAudioProcessorEditor::~MonostepAudioProcessorEditor()
{
stopTimer();
processorRef.onPatternChanged = nullptr;
setLookAndFeel (nullptr);
}
void MonostepAudioProcessorEditor::timerCallback()
{
const int step = processorRef.getCurrentStep();
if (step != lastPlayStep)
{
lastPlayStep = step;
matrix.setPlayPosition (step);
}
}
void MonostepAudioProcessorEditor::refreshEditor()
{
matrix.setSelectedStep (matrix.getSelectedStep());
stepPanel.refresh();
matrix.repaint();
}
void MonostepAudioProcessorEditor::paint (juce::Graphics& g)
{
g.fillAll (colours::bg);
}
void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g)
{
g.setFont (font (22.0f));
g.setColour (colours::text);
g.drawText ("Monostep", 16, 12, 130, 24, juce::Justification::centredLeft);
g.setFont (font (9.0f));
g.setColour (colours::textDim);
g.drawText ("2 OSC Step-Sequencer Synth", 16, 34, 130, 12, juce::Justification::centredLeft);
const auto drawCaption = [&] (const juce::Component& component, const juce::String& text)
{
g.drawText (text,
component.getX(), component.getY() - 13,
component.getWidth(), 12,
juce::Justification::centred);
};
drawCaption (rateBar, "RATE");
drawCaption (octaveBar, "OCTAVE");
drawCaption (zoomBar, "ZOOM");
// Small labels inside the OSC section, left of the dropdowns.
g.setFont (font (8.5f));
g.setColour (colours::textDim);
g.drawText ("OSC 1", wave1Box.getX() - oscLabelW, wave1Box.getY() + 5, oscLabelW - 6, 14, juce::Justification::centredRight);
g.drawText ("OSC 2", wave2Box.getX() - oscLabelW, wave2Box.getY() + 5, oscLabelW - 6, 14, juce::Justification::centredRight);
g.setFont (font (9.0f));
// Union of two knob bounds, padded.
const auto groupBox = [] (const juce::Component& a, const juce::Component& b, float pad)
{
const auto ra = a.getBounds().toFloat();
const auto rb = b.getBounds().toFloat();
juce::Rectangle<float> r;
r.setX (juce::jmin (ra.getX(), rb.getX()) - pad);
r.setY (juce::jmin (ra.getY(), rb.getY()) - pad);
r.setRight (juce::jmax (ra.getRight(), rb.getRight()) + pad);
r.setBottom (juce::jmax (ra.getBottom(), rb.getBottom()) + pad);
return r;
};
// Draw a knob group frame: a slight border around the knobs themselves.
// leftPad extends the frame to the left so it can enclose side labels.
auto drawFrame = [&] (const juce::Component& first, const juce::Component& last,
float pad, float leftPad = 0.0f)
{
auto box = groupBox (first, last, pad);
box.setLeft (box.getX() - leftPad);
g.setColour (colours::section.withAlpha (0.30f));
g.drawRoundedRectangle (box, 3.0f, 1.0f);
};
// A gradient legend pill spanning the given group, with the title centred.
auto drawPill = [&] (const juce::Component& first, const juce::Component& last,
const juce::String& title, float leftPad = 0.0f)
{
const int capX = first.getX() - (int) leftPad;
const int capW = last.getRight() - capX;
const int capY = first.getY() - 13;
const juce::Rectangle<float> legend (capX - 6.0f, capY - 4.0f, (float) capW + 12.0f, 12.0f);
g.setGradientFill (sectionGradient (legend));
g.fillRoundedRectangle (legend, 3.0f);
g.setFont (font (9.0f).boldened());
g.setColour (colours::bg);
g.drawText (title, legend, juce::Justification::centred);
};
drawFrame (wave1Box, mixKnob, 6.0f, (float) (oscLabelW - 21 + 9));
drawPill (wave1Box, mixKnob, "OSC", (float) (oscLabelW - 21 + 9));
drawFrame (glideKnob, accentKnob, 6.0f);
drawPill (glideKnob, accentKnob, "NOTE");
drawFrame (driveKnob, reverbDryWetKnob, 6.0f);
drawPill (driveKnob, ringKnob, "FX");
drawPill (delayTimeKnob, delaySyncToggle, "DELAY");
drawPill (reverbRoomKnob, reverbDryWetKnob, "REVERB");
drawFrame (filterTypeBox, resKnob, 6.0f, 6.0f);
drawPill (filterTypeBox, resKnob, "FILTER", 6.0f);
drawFrame (attackKnob, releaseKnob, 6.0f);
drawPill (attackKnob, releaseKnob, "AMP ENV");
drawFrame (fAttackKnob, fAmountKnob, 6.0f);
drawPill (fAttackKnob, fAmountKnob, "FILTER ENV");
const int footY = baseHeight - 20;
g.setColour (colours::grid);
g.fillRect (0, footY, baseWidth, 20);
g.setColour (colours::panel);
g.drawHorizontalLine (footY, 0, (float) baseWidth);
g.setFont (font (9.0f));
g.setColour (colours::textDim);
g.drawText (buildinfo::footerText(), 16, footY + 4, baseWidth - 32, 12, juce::Justification::centredLeft);
}
void MonostepAudioProcessorEditor::setZoomFactor (float newZoom)
{
zoomFactor = newZoom;
setSize (juce::roundToInt (baseWidth * newZoom), juce::roundToInt (baseHeight * newZoom));
}
void MonostepAudioProcessorEditor::resized()
{
const int targetW = juce::roundToInt (baseWidth * zoomFactor);
const int targetH = juce::roundToInt (baseHeight * zoomFactor);
if (getWidth() != targetW || getHeight() != targetH)
{
setSize (targetW, targetH);
return;
}
content.setBounds (0, 0, baseWidth, baseHeight);
content.setTransform (juce::AffineTransform::scale (zoomFactor));
// All layout below happens in design units; the content transform scales it.
const int w = baseWidth;
const int h = baseHeight;
const int topBarH = 78;
const int bottomBarH = 240;
const int pad = 12;
const int topBarY = (topBarH - 26) / 2;
lengthKnob.setBounds (128, 2, 54, 74);
rateBar.setBounds (192, topBarY, 210, 26);
swingKnob.setBounds (422, 2, 54, 74);
rootKnob.setBounds (482, 2, 54, 74);
octaveBar.setBounds (548, topBarY, 170, 26);
zoomBar.setBounds (w - pad - 54 - 8 - 236, topBarY, 236, 26);
masterKnob.setBounds (w - pad - 54, 2, 54, 74);
const int panelW = 200;
const int midTop = topBarH;
const int midH = h - topBarH - bottomBarH - pad;
stepPanel.setBounds (w - pad - panelW, midTop, panelW, midH);
matrix.setBounds (pad, midTop, w - pad * 2 - panelW - pad, midH);
const int bottomY = h - bottomBarH - pad;
const int knobW = 56;
const int knobH = 82;
const int knobGap = 6;
const int step = knobW + knobGap; // left-edge advance per knob (62)
const int oscColW = oscLabelW + oscDropW + oscGapW; // labels + dropdowns + gap to knobs
// Both rows are centered over the full bottom bar width.
const int blockW = oscColW + 15 * step + 152; // osc col + FX (9 widgets + sync toggle + subgroup gaps)
const int oscX = pad + ((w - 2 * pad) - blockW) / 2 + oscColW;
const int row1Y = bottomY + 24;
const int row2Y = row1Y + knobH + 34; // row 1 + a caption band
// OSC 1 / OSC 2 dropdowns stacked inside the OSC section, after the labels.
const int oscDropX = oscX - oscColW + oscLabelW;
wave1Box.setBounds (oscDropX, row1Y, oscDropW, 24);
wave2Box.setBounds (oscDropX, row1Y + 30, oscDropW, 24);
auto placeRow = [&] (int y, int startX, const std::initializer_list<Knob*> knobs)
{
int cx = startX;
for (auto* k : knobs)
{
k->setBounds (cx, y, knobW, knobH);
cx += step;
}
};
// Row 1: coarse / detune / mix / glide / accent / FX (drive / ring / delay / reverb).
placeRow (row1Y, oscX, { &coarseKnob, &detuneKnob, &mixKnob });
placeRow (row1Y, oscX + 3 * step + 24, { &glideKnob, &accentKnob });
const int fxX = oscX + 5 * step + 48;
placeRow (row1Y, fxX, { &driveKnob, &ringKnob });
placeRow (row1Y, fxX + 2 * step + 24, { &delayTimeKnob, &delayFeedbackKnob, &delayDryWetKnob });
placeRow (row1Y, fxX + 6 * step + 46, { &reverbRoomKnob, &reverbStrengthKnob, &reverbDiffusionKnob, &reverbDryWetKnob });
delaySyncToggle.setBounds (fxX + 5 * step + 24, row1Y + (knobH - 24) / 2, knobW, 24);
// Row 2: Filter (dropdown + cutoff, res) [gap] Amp Envelope [gap] Filter Env.
// The three sections are centered as one block with equal frame gaps.
const int frameGap = 20;
const int framePad = 5;
auto frameW = [&] (int n) { return n * knobW + (n - 1) * knobGap + 2 * framePad; };
const int filterDropW = oscDropW; // FILTER TYPE dropdown width (matches the OSC dropdowns)
const int filterDropGap = 8; // gap between the dropdown and the filter knobs
const int filterSecW = filterDropW + filterDropGap + 2 * knobW + knobGap + 2 * framePad;
const int row2W = filterSecW + frameGap + frameW (4) + frameGap + frameW (5);
const int row2Left = pad + ((w - 2 * pad) - row2W) / 2;
const int filterDropX = row2Left + framePad;
const int filterKnobX = filterDropX + filterDropW + filterDropGap;
const int envStart = row2Left + filterSecW + frameGap + framePad;
const int fenvStart = row2Left + filterSecW + frameGap + frameW (4) + frameGap + framePad;
filterTypeBox.setBounds (filterDropX, row2Y, filterDropW, 24);
placeRow (row2Y, filterKnobX, { &cutoffKnob, &resKnob });
placeRow (row2Y, envStart, { &attackKnob, &decayKnob, &sustainKnob, &releaseKnob });
placeRow (row2Y, fenvStart, { &fAttackKnob, &fDecayKnob, &fSustainKnob, &fReleaseKnob, &fAmountKnob });
}