This commit is contained in:
Armin 2026-08-14 21:14:18 +02:00
commit 78e52e612f
9 changed files with 1553 additions and 0 deletions

30
.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Build directories
build/
build-*/
cmake-build-*/
# CMake generated files
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
CMakeUserPresets.json
CMakeLists.txt.user
# IDE / editor
.idea/
.vscode/
*.xcworkspace/
*.xcodeproj/
xcuserdata/
DerivedData/
# macOS
.DS_Store
*~
# Plugin build products
*.vst3/
*.component/
*.app/
*.aaxplugin/
*.lv2/

124
CMakeLists.txt Normal file
View file

@ -0,0 +1,124 @@
cmake_minimum_required(VERSION 3.22)
project(Mindball VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_OSX_DEPLOYMENT_TARGET 12.0 CACHE STRING "Minimum macOS deployment target")
if(CMAKE_BUILD_TYPE STREQUAL "" AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release)
endif()
set(MINDBALL_FORMATS "AU VST3 Standalone" CACHE STRING "Plugin formats to build (space separated)")
separate_arguments(_formats UNIX_COMMAND "${MINDBALL_FORMATS}")
set(JUCE_ROOT "" CACHE PATH "Path to a local JUCE source tree (default: fetched from GitHub)")
if(JUCE_ROOT)
add_subdirectory(${JUCE_ROOT} juce)
else()
include(FetchContent)
FetchContent_Declare(juce
GIT_REPOSITORY https://github.com/juce-framework/JUCE.git
GIT_TAG 8.0.9
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(juce)
endif()
# A JUCE GUI plugin needs the core system headers to compile on Linux. Detect
# them up front so a missing package produces a clear error instead of a
# confusing compile failure. GTK and libcurl are never required.
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(_mindball_headers "X11/Xlib.h" "freetype2/freetype/freetype.h"
"fontconfig/fontconfig.h" "alsa/asoundlib.h")
set(_mindball_packages "libx11-dev" "libfreetype-dev"
"libfontconfig-dev" "libasound2-dev")
set(_mindball_missing "")
foreach(_i RANGE 0 3)
list(GET _mindball_headers ${_i} _header)
list(GET _mindball_packages ${_i} _package)
find_path(_mindball_hdr_${_i} NAMES "${_header}" QUIET)
if(NOT _mindball_hdr_${_i})
string(APPEND _mindball_missing " ${_header} (package: ${_package})\n")
endif()
endforeach()
if(_mindball_missing)
message(FATAL_ERROR
"Missing required system headers for the Linux build:\n${_mindball_missing}"
"Install them with:\n"
" sudo apt-get install pkg-config libx11-dev libfreetype-dev libfontconfig-dev libasound2-dev\n")
endif()
endif()
juce_add_plugin(Mindball
PRODUCT_NAME "Mindball"
VENDOR_NAME "Mindball"
VERSION "1.0.0"
DESCRIPTION "Mindball - a tiny, minimal delay plugin"
FORMATS ${_formats}
PLUGIN_MANUFACTURER_CODE Mind
PLUGIN_CODE Mbll
COPY_PLUGIN_AFTER_BUILD TRUE
COMPANY_COPYRIGHT "2026 Mindball")
target_sources(Mindball PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp)
target_include_directories(Mindball PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/buildinfo)
target_link_libraries(Mindball PRIVATE
juce::juce_audio_processors
juce::juce_audio_utils
juce::juce_audio_devices
juce::juce_gui_basics)
# New VST3-only plugin: no VST2 has ever been released, so the parameter-ID
# migration warning is irrelevant.
target_compile_definitions(Mindball PRIVATE JUCE_IGNORE_VST3_MISMATCHED_PARAMETER_ID_WARNING)
# juce_core enables libcurl by default on Linux; without NEEDS_CURL the JUCE
# helper targets don't link -lcurl, so that would fail at link time with
# undefined references to curl_easy_setopt. We use no network features, so
# disable it. (GTK/webkit2gtk is never required because juce_gui_extra is not
# linked.)
target_compile_definitions(Mindball PRIVATE JUCE_USE_CURL=0)
# ---- git / build info -----------------------------------------------------
find_package(Git QUIET)
set(MINDBALL_GIT_HASH "n/a")
set(MINDBALL_GIT_BRANCH "n/a")
set(MINDBALL_GIT_DIRTY false)
if(Git_FOUND)
execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse --short=8 HEAD
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
RESULT_VARIABLE _git_hash_res
OUTPUT_VARIABLE _git_hash
ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)
if(_git_hash_res EQUAL 0 AND _git_hash)
set(MINDBALL_GIT_HASH "${_git_hash}")
execute_process(COMMAND ${GIT_EXECUTABLE} branch --show-current
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
RESULT_VARIABLE _git_branch_res
OUTPUT_VARIABLE _git_branch
ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)
if(_git_branch_res EQUAL 0 AND _git_branch)
set(MINDBALL_GIT_BRANCH "${_git_branch}")
endif()
execute_process(COMMAND ${GIT_EXECUTABLE} status --porcelain
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
RESULT_VARIABLE _git_status_res
OUTPUT_VARIABLE _git_status
ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)
string(LENGTH "${_git_status}" _status_len)
if(_status_len GREATER 0)
set(MINDBALL_GIT_DIRTY true)
endif()
endif()
endif()
string(TIMESTAMP MINDBALL_BUILD_TIME "%Y-%m-%d %H:%M:%S UTC")
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/Source/BuildInfo.h.in
${CMAKE_CURRENT_BINARY_DIR}/buildinfo/BuildInfo.h @ONLY)
juce_generate_juce_header(Mindball)

12
Source/BuildInfo.h.in Normal file
View file

@ -0,0 +1,12 @@
#pragma once
namespace mindball
{
namespace BuildInfo
{
inline constexpr const char* gitHash = "@MINDBALL_GIT_HASH@";
inline constexpr const char* gitBranch = "@MINDBALL_GIT_BRANCH@";
inline constexpr bool gitDirty = @MINDBALL_GIT_DIRTY@;
inline constexpr const char* buildTime = "@MINDBALL_BUILD_TIME@";
}
}

282
Source/DelayEngine.h Normal file
View file

@ -0,0 +1,282 @@
#pragma once
#include "DelayLine.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdlib>
namespace mindball
{
class DelayEngine
{
public:
enum Mode
{
normal = 0,
invert,
pingpong,
superpong
};
struct Params
{
float feedback = 0.35f;
float loCutHz = 100.0f;
float hiCutHz = 12000.0f;
float dryWet = 0.4f;
float timeMs = 300.0f;
int timeSyncIndex = 0;
int mode = normal;
bool sync = false;
bool doubleTap = false;
bool center = false;
bool autoPan = false;
float panDepth = 1.0f;
double bpm = 120.0;
};
void prepare (double sampleRate, double maxDelaySeconds)
{
sr = static_cast<float> (sampleRate);
dl.prepare (static_cast<float> (maxDelaySeconds * sampleRate));
dr.prepare (static_cast<float> (maxDelaySeconds * sampleRate));
clear();
sFeedback = 0.0f;
sLoCut = 20.0f;
sHiCut = 18000.0f;
sDryWet = 0.0f;
head1 = 1.0f;
head2 = 1.0f;
blend = 0.0f;
lfoPhase = 0.0f;
}
void clear()
{
dl.clear();
dr.clear();
lpL.reset(); hpL.reset();
lpR.reset(); hpR.reset();
lfoPhase = 0.0f;
}
void process (const float* inL, const float* inR,
float* outL, float* outR,
int numSamples, const Params& p,
float* wetLOut = nullptr, float* wetROut = nullptr)
{
constexpr float pi = 3.14159265358979f;
const float kPar = 1.0f - std::exp (-1.0f / (0.02f * sr));
const float fadeInc = 1.0f / (0.012f * sr);
float targetTimeSamples;
if (p.sync)
{
const int idx = std::clamp (p.timeSyncIndex, 0, numDivisions - 1);
const double secondsPerBeat = 60.0 / std::max (1.0, p.bpm);
targetTimeSamples = static_cast<float> (divisionBeats[static_cast<std::size_t> (idx)]
* secondsPerBeat * sr);
}
else
{
targetTimeSamples = p.timeMs * 0.001f * sr;
}
targetTimeSamples = std::max (2.0f, targetTimeSamples);
const float targetFeedback = p.feedback;
const float targetLoCut = p.loCutHz;
const float targetHiCut = p.hiCutHz;
const float targetDryWet = p.dryWet;
const bool dbl = p.doubleTap;
const bool ctr = p.center;
const bool needB = dbl || (ctr && (p.mode == pingpong || p.mode == superpong));
const float lfoRate = p.autoPan ? (p.sync ? static_cast<float> (p.bpm / 240.0) : 0.25f) : 0.0f;
for (int i = 0; i < numSamples; ++i)
{
sFeedback += (targetFeedback - sFeedback) * kPar;
sLoCut += (targetLoCut - sLoCut) * kPar;
sHiCut += (targetHiCut - sHiCut) * kPar;
sDryWet += (targetDryWet - sDryWet) * kPar;
// Delay time changes are done with two stationary read heads and a
// short crossfade, never by gliding a read position. This avoids the
// pitch-bend "sped up / slowed down" artifacts that come from a
// moving read head. When the target moves, snap head2 to it and fade
// between the two fixed heads; once blended, head2 takes over.
if (blend == 0.0f && std::fabs (targetTimeSamples - head1) > 0.5f)
head2 = targetTimeSamples;
if (blend < 1.0f)
{
blend += fadeInc;
if (blend >= 1.0f)
{
head1 = head2;
blend = 0.0f;
}
}
lpL.setFc (sHiCut, sr); hpL.setFc (sLoCut, sr);
lpR.setFc (sHiCut, sr); hpR.setFc (sLoCut, sr);
const float h1L = dl.read (head1);
const float h2L = dl.read (head2);
const float h1R = dr.read (head1);
const float h2R = dr.read (head2);
const float aL = h1L + (h2L - h1L) * blend;
const float aR = h1R + (h2R - h1R) * blend;
float bL = 0.0f, bR = 0.0f;
if (needB)
{
const float two1 = 2.0f * head1;
const float two2 = 2.0f * head2;
bL = dl.read (two1) + (dl.read (two2) - dl.read (two1)) * blend;
bR = dr.read (two1) + (dr.read (two2) - dr.read (two1)) * blend;
}
float fbL = 0.0f, fbR = 0.0f;
float wetL = 0.0f, wetR = 0.0f;
switch (p.mode)
{
case normal:
fbL = aL;
fbR = aR;
wetL = aL + bL;
wetR = aR + bR;
break;
case invert:
fbL = aL;
fbR = -aR;
wetL = aL + bL;
wetR = -(aR + bR);
break;
case pingpong:
fbL = aR;
fbR = aL;
wetL = aL + bL;
wetR = aR + bR;
break;
case superpong:
{
const float sumA = aL + aR;
const float sumAB = sumA + (needB ? bL + bR : 0.0f);
fbL = 0.5f * sumA;
fbR = 0.5f * sumA;
wetL = sumAB * 0.5f;
wetR = sumAB * 0.5f;
break;
}
}
if (ctr && (p.mode == pingpong || p.mode == superpong))
{
const float c = 0.5f * (aL + aR);
wetL = c + (needB ? bL : 0.0f);
wetR = c + (needB ? bR : 0.0f);
}
if (p.autoPan)
{
const float pan = std::sin (2.0f * pi * lfoPhase);
lfoPhase += lfoRate / sr;
if (lfoPhase >= 1.0f)
lfoPhase -= std::floor (lfoPhase);
const float s = std::clamp (p.panDepth, 0.0f, 1.0f);
const float angle = (1.0f - s) * pi * 0.25f + s * (pan + 1.0f) * pi * 0.25f;
const float gL = std::cos (angle);
const float gR = std::sin (angle);
wetL *= gL;
wetR *= gR;
}
const float g = sFeedback * (1.0f + 0.002f * std::pow (sFeedback, 100.0f));
dl.write (inL[i] + softClip (lpL.processLP (hpL.processHP (fbL * g))));
dr.write (inR[i] + softClip (lpR.processLP (hpR.processHP (fbR * g))));
const float w = sDryWet;
float dryGain, wetGain;
if (w <= 0.5f)
{
dryGain = 1.0f;
wetGain = 2.0f * w;
}
else
{
dryGain = 2.0f * (1.0f - w);
wetGain = 1.0f;
}
outL[i] = inL[i] * dryGain + wetL * wetGain;
outR[i] = inR[i] * dryGain + wetR * wetGain;
if (wetLOut != nullptr)
wetLOut[i] = wetL * wetGain;
if (wetROut != nullptr)
wetROut[i] = wetR * wetGain;
}
}
private:
static float softClip (float x)
{
const float ax = std::fabs (x);
if (ax < 0.8f)
return x;
return std::copysign (0.8f + 0.2f * std::tanh ((ax - 0.8f) / 0.2f), x);
}
struct OnePole
{
float alpha = 0.0f;
float state = 0.0f;
float xPrev = 0.0f;
void reset() { state = 0.0f; xPrev = 0.0f; }
void setFc (float fc, float sr)
{
constexpr float pi = 3.14159265358979f;
alpha = 1.0f - std::exp (-2.0f * pi * fc / sr);
}
float processLP (float x) { state += alpha * (x - state); return state; }
float processHP (float x) { state = x - xPrev + (1.0f - alpha) * state; xPrev = x; return state; }
};
static constexpr int numDivisions = 12;
static constexpr std::array<float, numDivisions> divisionBeats = {
1.0f / 16.0f, 1.0f / 8.0f, 1.0f / 4.0f, 1.0f / 3.0f, 1.0f / 2.0f, 3.0f / 4.0f,
1.0f, 3.0f / 2.0f, 2.0f, 3.0f, 4.0f, 8.0f
};
DelayLine dl, dr;
OnePole lpL, hpL, lpR, hpR;
float sr = 48000.0f;
float lfoPhase = 0.0f;
float sFeedback = 0.0f;
float sLoCut = 0.0f;
float sHiCut = 0.0f;
float sDryWet = 0.0f;
float head1 = 1.0f;
float head2 = 1.0f;
float blend = 0.0f;
};
}

59
Source/DelayLine.h Normal file
View file

@ -0,0 +1,59 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <vector>
namespace mindball
{
// Simple fractional-delay ring buffer with linear interpolation.
class DelayLine
{
public:
void prepare (float maxDelaySamples)
{
maxDelay = std::max (1.0f, maxDelaySamples);
buffer.assign (static_cast<std::size_t> (maxDelay) + 2, 0.0f);
writePos = 0;
}
void clear()
{
std::fill (buffer.begin(), buffer.end(), 0.0f);
writePos = 0;
}
void write (float sample)
{
buffer[static_cast<std::size_t> (writePos)] = sample;
writePos = (writePos + 1) % static_cast<int> (buffer.size());
}
float read (float delaySamples) const
{
if (buffer.empty())
return 0.0f;
const float d = std::max (1.0f, std::min (delaySamples, maxDelay - 1.0f));
const int size = static_cast<int> (buffer.size());
const float readPos = static_cast<float> (writePos) - d;
const float floorPos = std::floor (readPos);
int i0 = static_cast<int> (floorPos) % size;
if (i0 < 0)
i0 += size;
const int i1 = (i0 + 1) % size;
const float frac = readPos - floorPos;
return buffer[static_cast<std::size_t> (i0)]
+ (buffer[static_cast<std::size_t> (i1)] - buffer[static_cast<std::size_t> (i0)]) * frac;
}
private:
std::vector<float> buffer;
float maxDelay = 1.0f;
int writePos = 0;
};
}

320
Source/PluginEditor.cpp Normal file
View file

@ -0,0 +1,320 @@
#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*> (&centerToggle),
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);
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 = "-";
buildInfo.setText ("Mindball v1.0.0\n"
+ 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
{
const float decay = std::exp (-1.0f / (0.9f * 30.0f));
dryMeter.update (proc.getDryMeterL(), proc.getDryMeterR(), decay);
wetMeter.update (proc.getWetMeterL(), proc.getWetMeterR(), decay);
}
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*> (&centerToggle) })
{
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;
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));
}

455
Source/PluginEditor.h Normal file
View file

@ -0,0 +1,455 @@
#pragma once
#include "PluginProcessor.h"
#include <JuceHeader.h>
#include <array>
#include <functional>
#include <memory>
#include <cmath>
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<int> (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<juce::String (double)> 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<float> (getMaximum()) - static_cast<float> (getMinimum());
const float norm = juce::jlimit (0.0f, 1.0f,
range > 0.0f
? (static_cast<float> (getValue()) - static_cast<float> (getMinimum())) / range
: 0.0f);
const float sweep = juce::MathConstants<float>::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<juce::String (double)> valueTextFn;
};
// --------------------------------------------------------------------------------
class LevelMeter final : public juce::Component
{
public:
explicit LevelMeter (const juce::String& meterName)
: name (meterName)
{
}
void update (float linL, float linR, float decay)
{
levels[0] = linL;
levels[1] = linR;
peaks[0] = std::max (peaks[0] * decay, linL);
peaks[1] = std::max (peaks[1] * decay, linR);
repaint();
}
void paint (juce::Graphics& g) override
{
constexpr int steps = 15;
constexpr float dbMin = -30.0f;
constexpr float dbPerStep = 2.0f;
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<float> (steps);
for (int ch = 0; ch < 2; ++ch)
{
auto row = b.removeFromTop (rowH);
const float lin = std::max (0.0f, levels[ch]);
const float db = 20.0f * std::log10 (lin > 0.0f ? lin : 1e-9f);
const int lit = juce::jlimit (0, steps, juce::roundToInt ((db - dbMin) / dbPerStep));
const int pk = juce::jlimit (0, steps, juce::roundToInt ((20.0f * std::log10 (std::max (1e-9f, peaks[ch])) - dbMin) / dbPerStep));
for (int s = 0; s < steps; ++s)
{
const auto seg = juce::Rectangle<float> (row.getX() + s * (segW + segGap),
row.getY() + 2.0f, segW, rowH - 4.0f);
g.setColour (s < lit ? MindballColors::meterGreen : MindballColors::arcBg);
g.fillRoundedRectangle (seg, 1.0f);
}
if (pk > lit)
{
const auto seg = juce::Rectangle<float> (row.getX() + pk * (segW + segGap),
row.getY() + 2.0f, segW, rowH - 4.0f);
g.setColour (MindballColors::meterRed);
g.fillRoundedRectangle (seg, 1.0f);
}
}
}
private:
juce::String name;
float levels[2] = { 0.0f, 0.0f };
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<juce::AudioParameterChoice*> (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<void (float)> 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<void (float)> pickCallback;
float scale = 1.5f;
};
// --------------------------------------------------------------------------------
class PresetButton final : public juce::Button
{
public:
explicit PresetButton (std::function<void (int)> 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<int()> 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<void (int)> pickCallback;
std::function<int()> 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> ui;
ScaleButton scaleButton;
PresetButton presetButton;
float uiScale = 1.5f;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MindballAudioProcessorEditor)
};

196
Source/PluginProcessor.cpp Normal file
View file

@ -0,0 +1,196 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
MindballAudioProcessor::MindballAudioProcessor()
: AudioProcessor (BusesProperties()
.withInput ("Input", juce::AudioChannelSet::stereo(), true)
.withOutput ("Output", juce::AudioChannelSet::stereo(), true)),
apvts (*this, nullptr, "Parameters", createParameterLayout())
{
}
juce::AudioProcessorValueTreeState::ParameterLayout MindballAudioProcessor::createParameterLayout()
{
juce::AudioProcessorValueTreeState::ParameterLayout layout;
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::feedback, "Feedback",
juce::NormalisableRange<float> (0.0f, 1.0f, 0.0001f, 1.0f), 0.35f));
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::locut, "Lo-Cut",
juce::NormalisableRange<float> (20.0f, 4000.0f, 1.0f, 0.3f), 100.0f));
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::hicut, "Hi-Cut",
juce::NormalisableRange<float> (500.0f, 18000.0f, 1.0f, 0.3f), 12000.0f));
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::time, "Time",
juce::NormalisableRange<float> (10.0f, 3000.0f, 1.0f, 0.4f), 300.0f));
layout.add (std::make_unique<juce::AudioParameterChoice> (
ParameterIDs::timeSync, "Time (Sync)",
juce::StringArray { "1/16", "1/8", "1/4", "1/3", "1/2", "3/4", "1", "3/2", "2", "3", "4", "8" }, 4));
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::drywet, "Dry/Wet",
juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f), 0.4f));
layout.add (std::make_unique<juce::AudioParameterBool> (ParameterIDs::sync, "Time Sync", false));
layout.add (std::make_unique<juce::AudioParameterChoice> (
ParameterIDs::mode, "Mode",
juce::StringArray { "Normal", "Invert", "Ping-Pong", "SuperPong" }, 0));
layout.add (std::make_unique<juce::AudioParameterBool> (ParameterIDs::doubleTap, "Double", false));
layout.add (std::make_unique<juce::AudioParameterBool> (ParameterIDs::center, "Center", false));
layout.add (std::make_unique<juce::AudioParameterBool> (ParameterIDs::autopan, "Auto-Pan", false));
layout.add (std::make_unique<juce::AudioParameterFloat> (
ParameterIDs::panDepth, "Pan Depth",
juce::NormalisableRange<float> (0.0f, 1.0f, 0.001f, 1.0f), 1.0f));
layout.add (std::make_unique<juce::AudioParameterBool> (ParameterIDs::bypass, "Bypass", false));
return layout;
}
const juce::String MindballAudioProcessor::getName() const { return JucePlugin_Name; }
bool MindballAudioProcessor::acceptsMidi() const { return false; }
bool MindballAudioProcessor::producesMidi() const { return false; }
double MindballAudioProcessor::getTailLengthSeconds() const { return 10.0; }
int MindballAudioProcessor::getNumPrograms() { return 1; }
int MindballAudioProcessor::getCurrentProgram() { return 0; }
void MindballAudioProcessor::setCurrentProgram (int) {}
const juce::String MindballAudioProcessor::getProgramName (int) { return {}; }
void MindballAudioProcessor::changeProgramName (int, const juce::String&) {}
void MindballAudioProcessor::prepareToPlay (double sampleRate, int /*samplesPerBlock*/)
{
engine.prepare (sampleRate, maxDelaySeconds);
meterDecay = static_cast<float> (std::exp (-1.0 / (0.4 * sampleRate)));
}
void MindballAudioProcessor::releaseResources()
{
engine.clear();
}
bool MindballAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const
{
const auto mainIn = layouts.getMainInputChannelSet();
const auto mainOut = layouts.getMainOutputChannelSet();
return mainIn == mainOut
&& (mainIn == juce::AudioChannelSet::mono() || mainIn == juce::AudioChannelSet::stereo());
}
void MindballAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const int numSamples = buffer.getNumSamples();
const int numChannels = buffer.getNumChannels();
if (numSamples == 0 || numChannels == 0)
return;
const bool bypass = *apvts.getRawParameterValue (ParameterIDs::bypass) > 0.5f;
if (bypass)
{
if (!wasBypassed)
{
engine.clear();
wasBypassed = true;
}
meterDryL = meterDryR = meterWetL = meterWetR = 0.0f;
dryMeterL.store (0.0f); dryMeterR.store (0.0f);
wetMeterL.store (0.0f); wetMeterR.store (0.0f);
return;
}
wasBypassed = false;
double bpm = 120.0;
if (auto* playHead = getPlayHead())
{
juce::AudioPlayHead::CurrentPositionInfo pos;
if (playHead->getCurrentPosition (pos))
bpm = pos.bpm > 0 ? pos.bpm : 120.0;
}
mindball::DelayEngine::Params p;
p.feedback = *apvts.getRawParameterValue (ParameterIDs::feedback);
p.loCutHz = *apvts.getRawParameterValue (ParameterIDs::locut);
p.hiCutHz = *apvts.getRawParameterValue (ParameterIDs::hicut);
p.timeMs = *apvts.getRawParameterValue (ParameterIDs::time);
p.timeSyncIndex = static_cast<int> (*apvts.getRawParameterValue (ParameterIDs::timeSync));
p.dryWet = *apvts.getRawParameterValue (ParameterIDs::drywet);
p.mode = static_cast<int> (*apvts.getRawParameterValue (ParameterIDs::mode));
p.sync = *apvts.getRawParameterValue (ParameterIDs::sync) > 0.5f;
p.doubleTap = *apvts.getRawParameterValue (ParameterIDs::doubleTap) > 0.5f;
p.center = *apvts.getRawParameterValue (ParameterIDs::center) > 0.5f;
p.autoPan = *apvts.getRawParameterValue (ParameterIDs::autopan) > 0.5f;
p.panDepth = *apvts.getRawParameterValue (ParameterIDs::panDepth);
p.bpm = bpm;
wetScratch.setSize (2, numSamples);
if (numChannels >= 2)
{
engine.process (buffer.getReadPointer (0), buffer.getReadPointer (1),
buffer.getWritePointer (0), buffer.getWritePointer (1),
numSamples, p,
wetScratch.getWritePointer (0), wetScratch.getWritePointer (1));
}
else
{
engine.process (buffer.getReadPointer (0), buffer.getReadPointer (0),
buffer.getWritePointer (0), buffer.getWritePointer (0),
numSamples, p,
wetScratch.getWritePointer (0), wetScratch.getWritePointer (1));
}
const float* outL = buffer.getReadPointer (0);
const float* outR = numChannels >= 2 ? buffer.getReadPointer (1) : outL;
const float* wetL = wetScratch.getReadPointer (0);
const float* wetR = wetScratch.getReadPointer (1);
for (int i = 0; i < numSamples; ++i)
{
const float dryL = outL[i] - wetL[i];
const float dryR = outR[i] - wetR[i];
meterDryL = std::max (std::abs (dryL), meterDryL * meterDecay);
meterDryR = std::max (std::abs (dryR), meterDryR * meterDecay);
meterWetL = std::max (std::abs (wetL[i]), meterWetL * meterDecay);
meterWetR = std::max (std::abs (wetR[i]), meterWetR * meterDecay);
}
dryMeterL.store (meterDryL); dryMeterR.store (meterDryR);
wetMeterL.store (meterWetL); wetMeterR.store (meterWetR);
}
juce::AudioProcessorEditor* MindballAudioProcessor::createEditor()
{
return new MindballAudioProcessorEditor (*this);
}
bool MindballAudioProcessor::hasEditor() const { return true; }
void MindballAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
{
auto state = apvts.copyState();
std::unique_ptr<juce::XmlElement> xml (state.createXml());
copyXmlToBinary (*xml, destData);
}
void MindballAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, sizeInBytes));
if (xml != nullptr)
apvts.replaceState (juce::ValueTree::fromXml (*xml));
}
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new MindballAudioProcessor();
}

75
Source/PluginProcessor.h Normal file
View file

@ -0,0 +1,75 @@
#pragma once
#include "DelayEngine.h"
#include <JuceHeader.h>
namespace ParameterIDs
{
inline constexpr const char* feedback = "feedback";
inline constexpr const char* locut = "locut";
inline constexpr const char* hicut = "hicut";
inline constexpr const char* time = "time";
inline constexpr const char* timeSync = "timeSync";
inline constexpr const char* drywet = "drywet";
inline constexpr const char* sync = "sync";
inline constexpr const char* mode = "mode";
inline constexpr const char* doubleTap = "double";
inline constexpr const char* center = "center";
inline constexpr const char* autopan = "autopan";
inline constexpr const char* panDepth = "pandepth";
inline constexpr const char* bypass = "bypass";
}
class MindballAudioProcessor : public juce::AudioProcessor
{
public:
MindballAudioProcessor();
~MindballAudioProcessor() override = default;
juce::AudioProcessorValueTreeState apvts;
void prepareToPlay (double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
bool isBusesLayoutSupported (const BusesLayout& layouts) const override;
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override;
const juce::String getName() const override;
bool acceptsMidi() const override;
bool producesMidi() const override;
double getTailLengthSeconds() const override;
int getNumPrograms() override;
int getCurrentProgram() override;
void setCurrentProgram (int index) override;
const juce::String getProgramName (int index) override;
void changeProgramName (int index, const juce::String& newName) override;
void getStateInformation (juce::MemoryBlock& destData) override;
void setStateInformation (const void* data, int sizeInBytes) override;
float getDryMeterL() const { return dryMeterL.load (std::memory_order_relaxed); }
float getDryMeterR() const { return dryMeterR.load (std::memory_order_relaxed); }
float getWetMeterL() const { return wetMeterL.load (std::memory_order_relaxed); }
float getWetMeterR() const { return wetMeterR.load (std::memory_order_relaxed); }
private:
static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
static constexpr double maxDelaySeconds = 16.0;
mindball::DelayEngine engine;
bool wasBypassed = false;
juce::AudioBuffer<float> wetScratch;
float meterDryL = 0.0f, meterDryR = 0.0f;
float meterWetL = 0.0f, meterWetR = 0.0f;
float meterDecay = 0.0f;
std::atomic<float> dryMeterL { 0.0f }, dryMeterR { 0.0f };
std::atomic<float> wetMeterL { 0.0f }, wetMeterR { 0.0f };
};