#pragma once #include "PluginProcessor.h" #include #include #include #include #include namespace MindballColors { inline const juce::Colour bg { 0xff171718 }; inline const juce::Colour arcBg { 0xff292a2b }; inline const juce::Colour accent { 0xff6fc1ff }; inline const juce::Colour textDim { 0xff757575 }; inline const juce::Colour textBright { 0xffe6e6e6 }; inline const juce::Colour meterGreen { 0xff45a9f9 }; inline const juce::Colour meterRed { 0xffff2c6d }; } // -------------------------------------------------------------------------------- namespace Presets { struct Definition { const char* name; float feedback; float loCut; float hiCut; float timeMs; int timeSyncIndex; float dryWet; bool sync; int mode; bool doubleTap; bool center; bool autoPan; float panDepth; }; inline constexpr Definition all[] = { { "Default", 0.35f, 100.0f, 12000.0f, 300.0f, 4, 0.40f, false, 0, false, false, false, 1.00f }, { "Slapback", 0.20f, 80.0f, 15000.0f, 120.0f, 4, 0.30f, false, 0, false, false, false, 1.00f }, { "Analog Echo", 0.45f, 120.0f, 5000.0f, 350.0f, 4, 0.35f, false, 0, false, false, false, 1.00f }, { "Tape Echo", 0.55f, 100.0f, 3000.0f, 450.0f, 4, 0.40f, false, 0, false, false, false, 1.00f }, { "Dub Delay", 0.70f, 150.0f, 2000.0f, 600.0f, 4, 0.50f, false, 0, false, false, false, 1.00f }, { "Ping-Pong", 0.45f, 100.0f, 10000.0f, 400.0f, 4, 0.40f, false, 2, false, false, false, 1.00f }, { "Wide Ping-Pong", 0.40f, 100.0f, 10000.0f, 500.0f, 4, 0.45f, false, 2, true, false, false, 1.00f }, { "SuperPong", 0.50f, 100.0f, 8000.0f, 350.0f, 4, 0.45f, false, 3, false, false, false, 1.00f }, { "Sync 1/16", 0.35f, 100.0f, 12000.0f, 250.0f, 0, 0.35f, true, 0, false, false, false, 1.00f }, { "Sync 1/8", 0.40f, 100.0f, 12000.0f, 250.0f, 1, 0.40f, true, 0, false, false, false, 1.00f }, { "Sync 1/4", 0.45f, 100.0f, 12000.0f, 300.0f, 2, 0.45f, true, 0, false, false, false, 1.00f }, { "Sync 1/2", 0.50f, 100.0f, 12000.0f, 300.0f, 4, 0.45f, true, 0, false, false, false, 1.00f }, { "Sync 3/4", 0.55f, 100.0f, 12000.0f, 300.0f, 5, 0.50f, true, 0, false, false, false, 1.00f }, { "Invert", 0.40f, 100.0f, 12000.0f, 300.0f, 4, 0.40f, false, 1, false, false, false, 1.00f }, { "Hall Echo", 0.25f, 200.0f, 8000.0f, 1200.0f, 4, 0.30f, false, 0, false, false, false, 1.00f }, { "Whale Song", 0.15f, 300.0f, 6000.0f, 3000.0f, 4, 0.50f, false, 0, false, false, false, 1.00f }, { "Filtered Space", 0.55f, 300.0f, 4000.0f, 900.0f, 4, 0.45f, false, 0, false, false, false, 1.00f }, { "Auto-Pan Delay", 0.35f, 100.0f, 12000.0f, 250.0f, 4, 0.40f, false, 0, false, false, true, 1.00f }, { "Centered Pong", 0.60f, 100.0f, 8000.0f, 500.0f, 4, 0.45f, false, 2, false, true, false, 1.00f }, { "Clean Echo", 0.15f, 60.0f, 16000.0f, 280.0f, 4, 0.25f, false, 0, false, false, false, 1.00f }, }; inline constexpr int count = static_cast (std::size (all)); } // -------------------------------------------------------------------------------- class Knob final : public juce::Slider { public: Knob (const juce::String& labelText, double defaultValue) : label (labelText) { setSliderStyle (juce::Slider::SliderStyle::RotaryVerticalDrag); setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); setDoubleClickReturnValue (true, defaultValue); setColour (juce::Slider::ColourIds::thumbColourId, MindballColors::accent); } void setValueTextFunction (std::function fn) { valueTextFn = std::move (fn); } void paint (juce::Graphics& g) override { const auto area = getLocalBounds().toFloat(); const float cx = area.getCentreX(); const float cy = area.getCentreY() - 8.0f; const float radius = juce::jmin (area.getWidth(), area.getHeight()) * 0.30f; const float range = static_cast (getMaximum()) - static_cast (getMinimum()); const float norm = juce::jlimit (0.0f, 1.0f, range > 0.0f ? (static_cast (getValue()) - static_cast (getMinimum())) / range : 0.0f); const float sweep = juce::MathConstants::twoPi * 0.75f; // JUCE's addCentredArc measures clockwise from the top, so the arc // angles are the screen angles shifted by +90 degrees. const float arcStart = juce::degreesToRadians (225.0f); // down-left const float arcEnd = arcStart + sweep; // down-right juce::Path arc; arc.addCentredArc (cx, cy, radius, radius, 0.0f, arcStart, arcEnd, true); g.setColour (MindballColors::arcBg); g.strokePath (arc, juce::PathStrokeType (3.0f, juce::PathStrokeType::JointStyle::curved, juce::PathStrokeType::EndCapStyle::rounded)); juce::Path valueArc; valueArc.addCentredArc (cx, cy, radius, radius, 0.0f, arcStart, arcStart + sweep * norm, true); g.setColour (MindballColors::accent); g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::JointStyle::curved, juce::PathStrokeType::EndCapStyle::rounded)); if (valueTextFn != nullptr) { const juce::String valueText = valueTextFn (getValue()); if (valueText.isNotEmpty()) { g.setColour (MindballColors::textBright); g.setFont (juce::Font (juce::FontOptions (9.0f))); g.drawFittedText (valueText, getLocalBounds().removeFromBottom (32).removeFromTop (16), juce::Justification::centred, 1); } } g.setColour (MindballColors::textDim); g.setFont (juce::Font (juce::FontOptions (9.0f))); g.drawFittedText (label, getLocalBounds().removeFromBottom (16), juce::Justification::centredBottom, 1); } private: juce::String label; std::function valueTextFn; }; // -------------------------------------------------------------------------------- class LevelMeter final : public juce::Component { public: explicit LevelMeter (const juce::String& meterName) : name (meterName) { } void update (float linL, float linR, float decay) { const bool changedL = updateChannel (0, linL, decay); const bool changedR = updateChannel (1, linR, decay); if (changedL || changedR) repaint(); } void paint (juce::Graphics& g) override { constexpr int steps = 15; auto b = getLocalBounds().toFloat(); g.setColour (MindballColors::textDim); g.setFont (juce::Font (juce::FontOptions (9.0f))); g.drawText (name, b.removeFromTop (13.0f), juce::Justification::centred, false); const float rowH = (b.getHeight() - 4.0f) * 0.5f; const float segGap = 1.0f; const float segW = (b.getWidth() - (steps - 1) * segGap) / static_cast (steps); for (int ch = 0; ch < 2; ++ch) { auto row = b.removeFromTop (rowH); for (int s = 0; s < steps; ++s) { const auto seg = juce::Rectangle (row.getX() + s * (segW + segGap), row.getY() + 2.0f, segW, rowH - 4.0f); g.setColour (s < lit[ch] ? MindballColors::meterGreen : MindballColors::arcBg); g.fillRect (seg); } if (pk[ch] > 0) { const int peakSeg = juce::jmin (pk[ch], steps - 1); const auto seg = juce::Rectangle (row.getX() + peakSeg * (segW + segGap), row.getY() + 2.0f, segW, rowH - 4.0f); g.setColour (MindballColors::meterRed); g.fillRect (seg); } } } private: bool updateChannel (int ch, float lin, float decay) { peaks[ch] = std::max (peaks[ch] * decay, lin); const int newLit = levelToSteps (lin); const int newPk = levelToSteps (peaks[ch]); if (newLit == lit[ch] && newPk == pk[ch]) return false; lit[ch] = newLit; pk[ch] = newPk; return true; } static int levelToSteps (float lin) { constexpr int steps = 15; constexpr float dbMin = -30.0f; constexpr float dbPerStep = 2.0f; const float db = 20.0f * std::log10 (lin > 0.0f ? lin : 1e-9f); return juce::jlimit (0, steps, juce::roundToInt ((db - dbMin) / dbPerStep)); } juce::String name; int lit[2] = { 0, 0 }; int pk[2] = { 0, 0 }; float peaks[2] = { 0.0f, 0.0f }; }; // -------------------------------------------------------------------------------- class PillToggle final : public juce::Button { public: explicit PillToggle (const juce::String& text) : juce::Button (text) { setClickingTogglesState (true); } void setInverted (bool shouldInvert) { inverted = shouldInvert; repaint(); } void paintButton (juce::Graphics& g, bool, bool) override { const auto b = getLocalBounds().toFloat().reduced (1.0f); const bool on = inverted ? ! getToggleState() : getToggleState(); if (on) { g.setColour (MindballColors::accent); g.fillRoundedRectangle (b, b.getHeight() * 0.5f); g.setColour (MindballColors::bg); } else { g.setColour (MindballColors::arcBg); g.drawRoundedRectangle (b, b.getHeight() * 0.5f, 1.0f); g.setColour (MindballColors::textDim); } g.setFont (juce::Font (juce::FontOptions (9.0f))); g.drawText (getButtonText(), getLocalBounds(), juce::Justification::centred, false); } private: bool inverted = false; }; // -------------------------------------------------------------------------------- class ModeToggle final : public juce::Button, private juce::AudioProcessorParameter::Listener { public: explicit ModeToggle (juce::AudioProcessorValueTreeState& apvts) : juce::Button ("Mode"), param (static_cast (apvts.getParameter (ParameterIDs::mode))) { if (param != nullptr) param->addListener (this); } ~ModeToggle() override { if (param != nullptr) param->removeListener (this); } void clicked() override { if (param == nullptr) return; const int next = (param->getIndex() + 1) % param->choices.size(); param->beginChangeGesture(); param->setValueNotifyingHost (param->convertTo0to1 (next)); param->endChangeGesture(); } void parameterValueChanged (int, float) override { juce::MessageManager::callAsync ([this] { repaint(); }); } void parameterGestureChanged (int, bool) override {} void paintButton (juce::Graphics& g, bool, bool) override { const auto b = getLocalBounds().toFloat().reduced (1.0f); const int mode = param != nullptr ? param->getIndex() : 0; const bool on = mode != 0; if (on) { g.setColour (MindballColors::accent); g.fillRoundedRectangle (b, b.getHeight() * 0.5f); g.setColour (MindballColors::bg); } else { g.setColour (MindballColors::arcBg); g.drawRoundedRectangle (b, b.getHeight() * 0.5f, 1.0f); g.setColour (MindballColors::textDim); } g.setFont (juce::Font (juce::FontOptions (9.0f))); g.drawText (param != nullptr ? param->getCurrentChoiceName() : "Mode", getLocalBounds(), juce::Justification::centred, false); } private: juce::AudioParameterChoice* param; }; // -------------------------------------------------------------------------------- class ScaleButton final : public juce::Button { public: explicit ScaleButton (std::function onPick) : juce::Button ("UI Scale"), pickCallback (std::move (onPick)) { setScale (1.5f); } void setScale (float s) { scale = s; setButtonText (juce::String (juce::roundToInt (scale * 100.0f)) + "%"); repaint(); } void paintButton (juce::Graphics& g, bool, bool) override { const auto b = getLocalBounds().toFloat().reduced (1.0f); g.setColour (MindballColors::arcBg); g.drawRoundedRectangle (b, b.getHeight() * 0.5f, 1.0f); g.setColour (MindballColors::textBright); g.setFont (juce::Font (juce::FontOptions (11.0f))); g.drawText (getButtonText(), getLocalBounds(), juce::Justification::centred, false); } void clicked() override { const float options[] = { 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f, 2.5f, 3.0f }; juce::PopupMenu menu; for (int i = 0; i < 8; ++i) menu.addItem (i + 1, juce::String (juce::roundToInt (options[i] * 100.0f)) + "%", true, scale == options[i]); menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (this), [this, options] (int result) { if (result >= 1 && result <= 8 && pickCallback != nullptr) pickCallback (options[result - 1]); }); } private: std::function pickCallback; float scale = 1.5f; }; // -------------------------------------------------------------------------------- class PresetButton final : public juce::Button { public: explicit PresetButton (std::function onPick) : juce::Button ("Presets"), pickCallback (std::move (onPick)) { } void setCurrent (int index) { current = index; setButtonText (juce::String (Presets::all[index].name)); repaint(); } void setCurrentProvider (std::function provider) { currentProvider = std::move (provider); } void paintButton (juce::Graphics& g, bool, bool) override { const auto b = getLocalBounds().toFloat().reduced (1.0f); g.setColour (MindballColors::arcBg); g.drawRoundedRectangle (b, b.getHeight() * 0.5f, 1.0f); g.setColour (MindballColors::textBright); g.setFont (juce::Font (juce::FontOptions (11.0f))); g.drawText (getButtonText(), b, juce::Justification::centred, false); } void clicked() override { const int checked = currentProvider != nullptr ? currentProvider() : current; juce::PopupMenu menu; for (int i = 0; i < Presets::count; ++i) menu.addItem (i + 1, juce::String (Presets::all[i].name), true, i == checked); menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (this), [this] (int result) { if (result >= 1 && result <= Presets::count && pickCallback != nullptr) pickCallback (result - 1); }); } private: std::function pickCallback; std::function currentProvider; int current = 0; }; // -------------------------------------------------------------------------------- class UI; class MindballAudioProcessorEditor final : public juce::AudioProcessorEditor { public: explicit MindballAudioProcessorEditor (MindballAudioProcessor& p); ~MindballAudioProcessorEditor() override; void paint (juce::Graphics&) override; void resized() override; private: static constexpr int baseW = 736; static constexpr int baseH = 320; void setUiScale (float s); void applyPreset (int index); int findMatchingPreset() const; bool presetMatches (const Presets::Definition& p) const; void setParam (const char* paramID, float value); void setChoiceParam (const char* paramID, int index); MindballAudioProcessor& proc; std::unique_ptr ui; ScaleButton scaleButton; PresetButton presetButton; float uiScale = 1.5f; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MindballAudioProcessorEditor) };