2026-08-14 21:14:18 +02:00
|
|
|
#include "PluginEditor.h"
|
|
|
|
|
#include "BuildInfo.h"
|
|
|
|
|
|
|
|
|
|
#include <cmath>
|
|
|
|
|
|
|
|
|
|
class UI final : public juce::Component, public juce::Timer, private juce::AudioProcessorValueTreeState::Listener
|
|
|
|
|
{
|
|
|
|
|
public:
|
|
|
|
|
explicit UI (MindballAudioProcessor& p)
|
|
|
|
|
: proc (p),
|
|
|
|
|
feedbackKnob ("FEEDBACK", 0.35),
|
|
|
|
|
loCutKnob ("LO-CUT", 100.0),
|
|
|
|
|
hiCutKnob ("HI-CUT", 12000.0),
|
|
|
|
|
timeKnob ("TIME", 300.0),
|
|
|
|
|
mixKnob ("DRY/WET", 0.4),
|
|
|
|
|
panKnob ("PAN", 1.0),
|
|
|
|
|
syncToggle ("SYNC"),
|
|
|
|
|
doubleToggle ("DOUBLE"),
|
|
|
|
|
centerToggle ("CENTER"),
|
|
|
|
|
autoPanToggle ("AUTO-PAN"),
|
|
|
|
|
onToggle ("ON"),
|
|
|
|
|
modeToggle (p.apvts),
|
|
|
|
|
dryMeter ("DRY"),
|
|
|
|
|
wetMeter ("WET"),
|
|
|
|
|
feedbackAtt (p.apvts, ParameterIDs::feedback, feedbackKnob),
|
|
|
|
|
loCutAtt (p.apvts, ParameterIDs::locut, loCutKnob),
|
|
|
|
|
hiCutAtt (p.apvts, ParameterIDs::hicut, hiCutKnob),
|
|
|
|
|
mixAtt (p.apvts, ParameterIDs::drywet, mixKnob),
|
|
|
|
|
panAtt (p.apvts, ParameterIDs::panDepth, panKnob),
|
|
|
|
|
syncAtt (p.apvts, ParameterIDs::sync, syncToggle),
|
|
|
|
|
doubleAtt (p.apvts, ParameterIDs::doubleTap, doubleToggle),
|
|
|
|
|
centerAtt (p.apvts, ParameterIDs::center, centerToggle),
|
|
|
|
|
autoPanAtt (p.apvts, ParameterIDs::autopan, autoPanToggle),
|
|
|
|
|
onAtt (p.apvts, ParameterIDs::bypass, onToggle)
|
|
|
|
|
{
|
|
|
|
|
for (auto* c : { static_cast<juce::Component*> (&feedbackKnob), static_cast<juce::Component*> (&loCutKnob),
|
|
|
|
|
static_cast<juce::Component*> (&hiCutKnob), static_cast<juce::Component*> (&timeKnob),
|
|
|
|
|
static_cast<juce::Component*> (&mixKnob), static_cast<juce::Component*> (&panKnob),
|
|
|
|
|
static_cast<juce::Component*> (&syncToggle), static_cast<juce::Component*> (&modeToggle),
|
|
|
|
|
static_cast<juce::Component*> (&doubleToggle), static_cast<juce::Component*> (¢erToggle),
|
|
|
|
|
static_cast<juce::Component*> (&autoPanToggle), static_cast<juce::Component*> (&onToggle) })
|
|
|
|
|
addAndMakeVisible (c);
|
|
|
|
|
|
|
|
|
|
addAndMakeVisible (dryMeter);
|
|
|
|
|
addAndMakeVisible (wetMeter);
|
|
|
|
|
|
|
|
|
|
feedbackKnob.setValueTextFunction ([] (double v) { return juce::String (juce::roundToInt (v * 100.0)) + "%"; });
|
|
|
|
|
loCutKnob.setValueTextFunction ([] (double v) { return formatHz (v); });
|
|
|
|
|
hiCutKnob.setValueTextFunction ([] (double v) { return formatHz (v); });
|
|
|
|
|
timeKnob.setValueTextFunction ([this] (double v)
|
|
|
|
|
{
|
|
|
|
|
if (proc.apvts.getRawParameterValue (ParameterIDs::sync)->load() > 0.5f)
|
|
|
|
|
return syncDivisionNames [juce::jlimit (0, syncDivisionNames.size() - 1, juce::roundToInt (v))];
|
|
|
|
|
return juce::String (juce::roundToInt (v)) + " ms";
|
|
|
|
|
});
|
|
|
|
|
mixKnob.setValueTextFunction ([] (double v) { return juce::String (juce::roundToInt (v * 100.0)) + "%"; });
|
|
|
|
|
panKnob.setValueTextFunction ([] (double v) { return juce::String (juce::roundToInt (v * 100.0)) + "%"; });
|
|
|
|
|
onToggle.setInverted (true);
|
|
|
|
|
|
2026-08-15 13:54:42 +02:00
|
|
|
syncToggle.onClick = [this]
|
|
|
|
|
{
|
|
|
|
|
if (syncToggle.getToggleState())
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
const int index = juce::roundToInt (proc.apvts.getRawParameterValue (ParameterIDs::timeSync)->load());
|
|
|
|
|
const float ms = mindball::DelayEngine::syncTimeMs (index, proc.getCurrentBpm());
|
|
|
|
|
|
|
|
|
|
if (auto* timeParam = proc.apvts.getParameter (ParameterIDs::time))
|
|
|
|
|
{
|
|
|
|
|
timeParam->beginChangeGesture();
|
|
|
|
|
timeParam->setValueNotifyingHost (timeParam->convertTo0to1 (juce::roundToInt (ms)));
|
|
|
|
|
timeParam->endChangeGesture();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-14 21:14:18 +02:00
|
|
|
buildInfo.setJustificationType (juce::Justification::centredRight);
|
|
|
|
|
buildInfo.setFont (juce::Font (juce::Font::getDefaultMonospacedFontName(), 9.0f, 0));
|
|
|
|
|
|
|
|
|
|
juce::String dirty = mindball::BuildInfo::gitDirty ? " +dirty" : "";
|
|
|
|
|
juce::String hash = juce::String (mindball::BuildInfo::gitHash);
|
|
|
|
|
juce::String branch = juce::String (mindball::BuildInfo::gitBranch);
|
|
|
|
|
if (hash.isEmpty() || hash == "n/a")
|
|
|
|
|
hash = "no-commit";
|
|
|
|
|
if (branch.isEmpty() || branch == "n/a")
|
|
|
|
|
branch = "-";
|
|
|
|
|
|
2026-08-14 21:48:05 +02:00
|
|
|
buildInfo.setText ("Mindball v0.0.5\n"
|
2026-08-14 21:14:18 +02:00
|
|
|
+ hash + " " + branch + dirty + "\n"
|
|
|
|
|
+ juce::String (mindball::BuildInfo::buildTime),
|
|
|
|
|
juce::dontSendNotification);
|
|
|
|
|
|
|
|
|
|
addAndMakeVisible (buildInfo);
|
|
|
|
|
|
|
|
|
|
proc.apvts.addParameterListener (ParameterIDs::sync, this);
|
|
|
|
|
updateTimeAttachment();
|
|
|
|
|
startTimerHz (30);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
~UI() override
|
|
|
|
|
{
|
|
|
|
|
stopTimer();
|
|
|
|
|
proc.apvts.removeParameterListener (ParameterIDs::sync, this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void timerCallback() override
|
|
|
|
|
{
|
2026-08-14 21:48:05 +02:00
|
|
|
dryMeter.update (proc.getDryMeterL(), proc.getDryMeterR(), meterDecay);
|
|
|
|
|
wetMeter.update (proc.getWetMeterL(), proc.getWetMeterR(), meterDecay);
|
2026-08-14 21:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void parameterChanged (const juce::String& paramID, float) override
|
|
|
|
|
{
|
|
|
|
|
if (paramID == ParameterIDs::sync)
|
|
|
|
|
{
|
|
|
|
|
const juce::Component::SafePointer<UI> safeThis (this);
|
|
|
|
|
juce::MessageManager::callAsync ([safeThis]
|
|
|
|
|
{
|
|
|
|
|
if (safeThis != nullptr)
|
|
|
|
|
safeThis->updateTimeAttachment();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void paint (juce::Graphics& g) override
|
|
|
|
|
{
|
|
|
|
|
g.fillAll (MindballColors::bg);
|
|
|
|
|
|
|
|
|
|
g.setColour (MindballColors::arcBg);
|
|
|
|
|
g.fillRect (0.0f, static_cast<float> (getHeight()) - 46.0f,
|
|
|
|
|
static_cast<float> (getWidth()), 1.0f);
|
|
|
|
|
|
|
|
|
|
g.setColour (MindballColors::textBright);
|
|
|
|
|
g.setFont (juce::Font (juce::FontOptions (11.0f)));
|
|
|
|
|
g.drawText ("MINDBALL", 30, getHeight() - 40, 200, 15, juce::Justification::left);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void resized() override
|
|
|
|
|
{
|
|
|
|
|
const int width = getWidth();
|
|
|
|
|
const int margin = 30;
|
|
|
|
|
const int knobW = 96;
|
|
|
|
|
const int knobH = 118;
|
|
|
|
|
const int gap = (width - 2 * margin - 6 * knobW) / 5;
|
|
|
|
|
|
|
|
|
|
int x = margin;
|
|
|
|
|
for (auto* knob : { static_cast<juce::Slider*> (&feedbackKnob), static_cast<juce::Slider*> (&loCutKnob),
|
|
|
|
|
static_cast<juce::Slider*> (&hiCutKnob), static_cast<juce::Slider*> (&timeKnob),
|
|
|
|
|
static_cast<juce::Slider*> (&panKnob), static_cast<juce::Slider*> (&mixKnob) })
|
|
|
|
|
{
|
|
|
|
|
knob->setBounds (x, 14, knobW, knobH);
|
|
|
|
|
x += knobW + gap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
x = margin;
|
|
|
|
|
for (auto* toggle : { static_cast<juce::Button*> (&onToggle), static_cast<juce::Button*> (&modeToggle),
|
|
|
|
|
static_cast<juce::Button*> (&doubleToggle), static_cast<juce::Button*> (&syncToggle),
|
|
|
|
|
static_cast<juce::Button*> (&autoPanToggle), static_cast<juce::Button*> (¢erToggle) })
|
|
|
|
|
{
|
|
|
|
|
toggle->setBounds (x, 146, knobW, 30);
|
|
|
|
|
x += knobW + gap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const int meterW = (width - 2 * margin - 20) / 2;
|
|
|
|
|
dryMeter.setBounds (margin, 206, meterW, 41);
|
|
|
|
|
wetMeter.setBounds (margin + meterW + 20, 206, meterW, 41);
|
|
|
|
|
|
|
|
|
|
buildInfo.setBounds (width - 270, getHeight() - 44, 240, 38);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
static juce::String formatHz (double v)
|
|
|
|
|
{
|
|
|
|
|
if (v >= 1000.0)
|
|
|
|
|
return juce::String (v / 1000.0, 1) + " kHz";
|
|
|
|
|
return juce::String (juce::roundToInt (v)) + " Hz";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void updateTimeAttachment()
|
|
|
|
|
{
|
|
|
|
|
timeAtt.reset();
|
|
|
|
|
timeSyncAtt.reset();
|
|
|
|
|
|
|
|
|
|
if (proc.apvts.getRawParameterValue (ParameterIDs::sync)->load() > 0.5f)
|
|
|
|
|
timeSyncAtt = std::make_unique<SliderAttachment> (proc.apvts, ParameterIDs::timeSync, timeKnob);
|
|
|
|
|
else
|
|
|
|
|
timeAtt = std::make_unique<SliderAttachment> (proc.apvts, ParameterIDs::time, timeKnob);
|
|
|
|
|
|
|
|
|
|
timeKnob.repaint();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using SliderAttachment = juce::AudioProcessorValueTreeState::SliderAttachment;
|
|
|
|
|
using ButtonAttachment = juce::AudioProcessorValueTreeState::ButtonAttachment;
|
|
|
|
|
|
2026-08-14 21:48:05 +02:00
|
|
|
static constexpr float meterDecay = 0.9636077f; // exp (-1.0f / (0.9f * 30.0f))
|
|
|
|
|
|
2026-08-14 21:14:18 +02:00
|
|
|
MindballAudioProcessor& proc;
|
|
|
|
|
|
|
|
|
|
static const juce::StringArray syncDivisionNames;
|
|
|
|
|
|
|
|
|
|
Knob feedbackKnob, loCutKnob, hiCutKnob, timeKnob, mixKnob, panKnob;
|
|
|
|
|
PillToggle syncToggle, doubleToggle, centerToggle, autoPanToggle, onToggle;
|
|
|
|
|
ModeToggle modeToggle;
|
|
|
|
|
LevelMeter dryMeter, wetMeter;
|
|
|
|
|
|
|
|
|
|
SliderAttachment feedbackAtt, loCutAtt, hiCutAtt, mixAtt, panAtt;
|
|
|
|
|
ButtonAttachment syncAtt, doubleAtt, centerAtt, autoPanAtt, onAtt;
|
|
|
|
|
std::unique_ptr<SliderAttachment> timeAtt, timeSyncAtt;
|
|
|
|
|
|
|
|
|
|
juce::Label buildInfo;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const juce::StringArray UI::syncDivisionNames = { "1/16", "1/8", "1/4", "1/3", "1/2", "3/4", "1", "3/2", "2", "3", "4", "8" };
|
|
|
|
|
|
|
|
|
|
// --------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
MindballAudioProcessorEditor::MindballAudioProcessorEditor (MindballAudioProcessor& p)
|
|
|
|
|
: juce::AudioProcessorEditor (p),
|
|
|
|
|
proc (p),
|
|
|
|
|
ui (std::make_unique<UI> (p)),
|
|
|
|
|
scaleButton ([this] (float s) { setUiScale (s); }),
|
|
|
|
|
presetButton ([this] (int index) { applyPreset (index); })
|
|
|
|
|
{
|
|
|
|
|
setSize (juce::roundToInt (baseW * uiScale), juce::roundToInt (baseH * uiScale));
|
|
|
|
|
|
|
|
|
|
addAndMakeVisible (ui.get());
|
|
|
|
|
addAndMakeVisible (scaleButton);
|
|
|
|
|
addAndMakeVisible (presetButton);
|
|
|
|
|
|
|
|
|
|
presetButton.setCurrentProvider ([this] { return findMatchingPreset(); });
|
|
|
|
|
presetButton.setCurrent (findMatchingPreset());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MindballAudioProcessorEditor::~MindballAudioProcessorEditor() = default;
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::paint (juce::Graphics& g)
|
|
|
|
|
{
|
|
|
|
|
g.fillAll (MindballColors::bg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::resized()
|
|
|
|
|
{
|
|
|
|
|
ui->setBounds (0, 0, baseW, baseH);
|
|
|
|
|
ui->setTransform (juce::AffineTransform::scale (uiScale));
|
|
|
|
|
|
|
|
|
|
const int btnH = 24;
|
|
|
|
|
const int btnY = 8;
|
|
|
|
|
scaleButton.setBounds (getWidth() - 52, btnY, 42, btnH);
|
|
|
|
|
presetButton.setBounds (getWidth() - 218, btnY, 160, btnH);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::setParam (const char* paramID, float value)
|
|
|
|
|
{
|
|
|
|
|
if (auto* param = proc.apvts.getParameter (paramID))
|
|
|
|
|
{
|
|
|
|
|
param->beginChangeGesture();
|
|
|
|
|
param->setValueNotifyingHost (param->convertTo0to1 (value));
|
|
|
|
|
param->endChangeGesture();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::setChoiceParam (const char* paramID, int index)
|
|
|
|
|
{
|
|
|
|
|
if (auto* param = static_cast<juce::AudioParameterChoice*> (proc.apvts.getParameter (paramID)))
|
|
|
|
|
{
|
|
|
|
|
param->beginChangeGesture();
|
|
|
|
|
param->setValueNotifyingHost (param->convertTo0to1 (index));
|
|
|
|
|
param->endChangeGesture();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::applyPreset (int index)
|
|
|
|
|
{
|
|
|
|
|
if (index < 0 || index >= Presets::count)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
const auto& p = Presets::all[index];
|
|
|
|
|
setParam (ParameterIDs::feedback, p.feedback);
|
|
|
|
|
setParam (ParameterIDs::locut, p.loCut);
|
|
|
|
|
setParam (ParameterIDs::hicut, p.hiCut);
|
|
|
|
|
setParam (ParameterIDs::time, p.timeMs);
|
|
|
|
|
setChoiceParam (ParameterIDs::timeSync, p.timeSyncIndex);
|
|
|
|
|
setParam (ParameterIDs::drywet, p.dryWet);
|
|
|
|
|
setParam (ParameterIDs::sync, p.sync ? 1.0f : 0.0f);
|
|
|
|
|
setChoiceParam (ParameterIDs::mode, p.mode);
|
|
|
|
|
setParam (ParameterIDs::doubleTap, p.doubleTap ? 1.0f : 0.0f);
|
|
|
|
|
setParam (ParameterIDs::center, p.center ? 1.0f : 0.0f);
|
|
|
|
|
setParam (ParameterIDs::autopan, p.autoPan ? 1.0f : 0.0f);
|
|
|
|
|
setParam (ParameterIDs::panDepth, p.panDepth);
|
|
|
|
|
|
|
|
|
|
presetButton.setCurrent (index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MindballAudioProcessorEditor::presetMatches (const Presets::Definition& p) const
|
|
|
|
|
{
|
|
|
|
|
auto val = [this] (const char* paramID) { return proc.apvts.getRawParameterValue (paramID)->load(); };
|
|
|
|
|
auto near = [] (float a, float b) { return std::fabs (a - b) < 0.02f; };
|
|
|
|
|
|
|
|
|
|
if (! near (val (ParameterIDs::feedback), p.feedback)) return false;
|
|
|
|
|
if (! near (val (ParameterIDs::locut), p.loCut)) return false;
|
|
|
|
|
if (! near (val (ParameterIDs::hicut), p.hiCut)) return false;
|
|
|
|
|
if (! near (val (ParameterIDs::time), p.timeMs)) return false;
|
|
|
|
|
if (! near (val (ParameterIDs::drywet), p.dryWet)) return false;
|
|
|
|
|
if (! near (val (ParameterIDs::panDepth), p.panDepth)) return false;
|
|
|
|
|
|
|
|
|
|
if (std::round (val (ParameterIDs::timeSync)) != p.timeSyncIndex) return false;
|
|
|
|
|
if (std::round (val (ParameterIDs::mode)) != p.mode) return false;
|
|
|
|
|
|
|
|
|
|
auto flag = [&val] (const char* paramID, bool expected)
|
|
|
|
|
{
|
|
|
|
|
return (val (paramID) > 0.5f) == expected;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (! flag (ParameterIDs::sync, p.sync)) return false;
|
|
|
|
|
if (! flag (ParameterIDs::doubleTap, p.doubleTap)) return false;
|
|
|
|
|
if (! flag (ParameterIDs::center, p.center)) return false;
|
|
|
|
|
if (! flag (ParameterIDs::autopan, p.autoPan)) return false;
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int MindballAudioProcessorEditor::findMatchingPreset() const
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < Presets::count; ++i)
|
|
|
|
|
{
|
|
|
|
|
if (presetMatches (Presets::all[i]))
|
|
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MindballAudioProcessorEditor::setUiScale (float s)
|
|
|
|
|
{
|
|
|
|
|
uiScale = s;
|
|
|
|
|
scaleButton.setScale (s);
|
|
|
|
|
ui->setTransform (juce::AffineTransform::scale (s));
|
|
|
|
|
setSize (juce::roundToInt (baseW * s), juce::roundToInt (baseH * s));
|
|
|
|
|
}
|