diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa88d8e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bcb6603 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/Source/BuildInfo.h.in b/Source/BuildInfo.h.in new file mode 100644 index 0000000..dce040b --- /dev/null +++ b/Source/BuildInfo.h.in @@ -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@"; +} +} diff --git a/Source/DelayEngine.h b/Source/DelayEngine.h new file mode 100644 index 0000000..e77443d --- /dev/null +++ b/Source/DelayEngine.h @@ -0,0 +1,282 @@ +#pragma once + +#include "DelayLine.h" + +#include +#include +#include +#include + +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 (sampleRate); + dl.prepare (static_cast (maxDelaySeconds * sampleRate)); + dr.prepare (static_cast (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 (divisionBeats[static_cast (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 (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 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; +}; + +} diff --git a/Source/DelayLine.h b/Source/DelayLine.h new file mode 100644 index 0000000..a57b467 --- /dev/null +++ b/Source/DelayLine.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +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 (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 (writePos)] = sample; + writePos = (writePos + 1) % static_cast (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 (buffer.size()); + const float readPos = static_cast (writePos) - d; + const float floorPos = std::floor (readPos); + + int i0 = static_cast (floorPos) % size; + if (i0 < 0) + i0 += size; + + const int i1 = (i0 + 1) % size; + const float frac = readPos - floorPos; + return buffer[static_cast (i0)] + + (buffer[static_cast (i1)] - buffer[static_cast (i0)]) * frac; + } + +private: + std::vector buffer; + float maxDelay = 1.0f; + int writePos = 0; +}; + +} diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..ea693a5 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,320 @@ +#include "PluginEditor.h" +#include "BuildInfo.h" + +#include + +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 (&feedbackKnob), static_cast (&loCutKnob), + static_cast (&hiCutKnob), static_cast (&timeKnob), + static_cast (&mixKnob), static_cast (&panKnob), + static_cast (&syncToggle), static_cast (&modeToggle), + static_cast (&doubleToggle), static_cast (¢erToggle), + static_cast (&autoPanToggle), static_cast (&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 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 (getHeight()) - 46.0f, + static_cast (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 (&feedbackKnob), static_cast (&loCutKnob), + static_cast (&hiCutKnob), static_cast (&timeKnob), + static_cast (&panKnob), static_cast (&mixKnob) }) + { + knob->setBounds (x, 14, knobW, knobH); + x += knobW + gap; + } + + x = margin; + for (auto* toggle : { static_cast (&onToggle), static_cast (&modeToggle), + static_cast (&doubleToggle), static_cast (&syncToggle), + static_cast (&autoPanToggle), static_cast (¢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 (proc.apvts, ParameterIDs::timeSync, timeKnob); + else + timeAtt = std::make_unique (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 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 (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 (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)); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..5ec3f0b --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,455 @@ +#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) + { + 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 (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 (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 (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 (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) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..3995975 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -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 ( + ParameterIDs::feedback, "Feedback", + juce::NormalisableRange (0.0f, 1.0f, 0.0001f, 1.0f), 0.35f)); + + layout.add (std::make_unique ( + ParameterIDs::locut, "Lo-Cut", + juce::NormalisableRange (20.0f, 4000.0f, 1.0f, 0.3f), 100.0f)); + + layout.add (std::make_unique ( + ParameterIDs::hicut, "Hi-Cut", + juce::NormalisableRange (500.0f, 18000.0f, 1.0f, 0.3f), 12000.0f)); + + layout.add (std::make_unique ( + ParameterIDs::time, "Time", + juce::NormalisableRange (10.0f, 3000.0f, 1.0f, 0.4f), 300.0f)); + + layout.add (std::make_unique ( + 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 ( + ParameterIDs::drywet, "Dry/Wet", + juce::NormalisableRange (0.0f, 1.0f, 0.001f, 1.0f), 0.4f)); + + layout.add (std::make_unique (ParameterIDs::sync, "Time Sync", false)); + layout.add (std::make_unique ( + ParameterIDs::mode, "Mode", + juce::StringArray { "Normal", "Invert", "Ping-Pong", "SuperPong" }, 0)); + layout.add (std::make_unique (ParameterIDs::doubleTap, "Double", false)); + layout.add (std::make_unique (ParameterIDs::center, "Center", false)); + layout.add (std::make_unique (ParameterIDs::autopan, "Auto-Pan", false)); + layout.add (std::make_unique ( + ParameterIDs::panDepth, "Pan Depth", + juce::NormalisableRange (0.0f, 1.0f, 0.001f, 1.0f), 1.0f)); + layout.add (std::make_unique (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 (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& 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 (*apvts.getRawParameterValue (ParameterIDs::timeSync)); + p.dryWet = *apvts.getRawParameterValue (ParameterIDs::drywet); + p.mode = static_cast (*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 xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void MindballAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); + if (xml != nullptr) + apvts.replaceState (juce::ValueTree::fromXml (*xml)); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new MindballAudioProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..0981a8b --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,75 @@ +#pragma once + +#include "DelayEngine.h" + +#include + +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&, 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 wetScratch; + float meterDryL = 0.0f, meterDryR = 0.0f; + float meterWetL = 0.0f, meterWetR = 0.0f; + float meterDecay = 0.0f; + std::atomic dryMeterL { 0.0f }, dryMeterR { 0.0f }; + std::atomic wetMeterL { 0.0f }, wetMeterR { 0.0f }; +};