commit 0a3fe6d8533f6e717d7d6704305d5508ac46d6c9 Author: opencode Date: Thu Aug 13 23:15:38 2026 +0200 Initial BEAMGRID VST3/AU spectrum analyzer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6a011e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +*.o +*.obj +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a20c2bf --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.22) +project(BEAMGRID VERSION 0.1.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) +FetchContent_Declare( + juce + GIT_REPOSITORY https://github.com/juce-framework/JUCE.git + GIT_TAG 8.0.6 +) +FetchContent_MakeAvailable(juce) + +juce_add_plugin(BEAMGRID + NAME "BEAMGRID" + COMPANY_NAME "Beamgrid" + PLUGIN_MANUFACTURER_CODE Bmgr + PLUGIN_CODE Bmgr + FORMATS VST3 AU + PRODUCT_NAME "BEAMGRID" + IS_SYNTH FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + EDITOR_WANTS_KEYBOARD_FOCUS FALSE + COPY_PLUGIN_AFTER_BUILD TRUE +) + +target_sources(BEAMGRID PRIVATE + Source/JuceHeader.h + Source/Analyser.h + Source/Analyser.cpp + Source/BeamgridLookAndFeel.h + Source/BeamgridLookAndFeel.cpp + Source/AnalyserComponent.h + Source/AnalyserComponent.cpp + Source/PluginProcessor.h + Source/PluginProcessor.cpp + Source/PluginEditor.h + Source/PluginEditor.cpp +) + +target_compile_definitions(BEAMGRID PUBLIC + JUCE_VST3_CAN_REPLACE_VST2=0 + JUCE_USE_CAMERA_PERMISSIONS=0 +) + +target_link_libraries(BEAMGRID PRIVATE + juce::juce_audio_basics + juce::juce_audio_devices + juce::juce_audio_processors + juce::juce_audio_utils + juce::juce_core + juce::juce_data_structures + juce::juce_dsp + juce::juce_events + juce::juce_graphics + juce::juce_gui_basics + juce::juce_gui_extra +) + +if(APPLE) + set_target_properties(BEAMGRID PROPERTIES + CMAKE_OSX_DEPLOYMENT_TARGET "10.15" + ) +endif() + +# Generate a version header with the current Git commit, dirty state and a +# fresh build timestamp. Runs on every build via a custom target. +set(GENERATED_DIR ${CMAKE_BINARY_DIR}/generated) +file(MAKE_DIRECTORY ${GENERATED_DIR}) +set(GIT_VERSION_HEADER ${GENERATED_DIR}/GitVersion.h) + +add_custom_target(generate_version ALL + COMMAND ${CMAKE_COMMAND} + -D SRC_DIR=${CMAKE_SOURCE_DIR} + -D TEMPLATE=${CMAKE_SOURCE_DIR}/Source/GitVersion.h.in + -D OUTPUT=${GIT_VERSION_HEADER} + -P ${CMAKE_SOURCE_DIR}/cmake/generate_version.cmake + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Generating BEAMGRID version header" +) + +target_include_directories(BEAMGRID PRIVATE ${GENERATED_DIR}) +add_dependencies(BEAMGRID generate_version) diff --git a/Source/Analyser.cpp b/Source/Analyser.cpp new file mode 100644 index 0000000..581f235 --- /dev/null +++ b/Source/Analyser.cpp @@ -0,0 +1,154 @@ +#include "Analyser.h" + +#include + +Analyser::Analyser() = default; + +void Analyser::prepare (double newSampleRate, int /*blockSize*/) +{ + sampleRate = newSampleRate > 0.0 ? newSampleRate : 44100.0; + + minFreq = 20.0; + maxFreq = sampleRate * 0.5; + + computeBandEdges(); +} + +void Analyser::computeBandEdges() +{ + const int n = numBands; + + // Log-spaced band edges mapped into FFT bins. + for (int b = 0; b <= n; ++b) + { + const double freq = minFreq * std::pow (maxFreq / minFreq, + static_cast (b) / n); + int bin = static_cast (std::round (freq * fftSize / sampleRate)); + bin = juce::jlimit (1, static_cast (fftBins), bin); + bandBinEdges[b] = bin; + } + + // Guarantee strictly increasing edges. + for (int b = 1; b <= n; ++b) + if (bandBinEdges[b] <= bandBinEdges[b - 1]) + bandBinEdges[b] = juce::jmin (static_cast (fftBins), bandBinEdges[b - 1] + 1); + + bandBinEdges[n] = fftBins; +} + +void Analyser::setNumBands (int n) noexcept +{ + numBands = juce::jlimit (1, maxBands, n); + computeBandEdges(); +} + +void Analyser::push (const float* channelData, int numSamples) +{ + if (channelData == nullptr) + return; + + for (int i = 0; i < numSamples; ++i) + pushSample (channelData[i]); +} + +void Analyser::pushSample (float sample) +{ + ring[writePos] = sample; + writePos = (writePos + 1) % fftSize; + ++totalSamples; + + // Only start transforming once the window has filled at least once, + // then recompute overlapped every `hopSize` samples. + if (totalSamples >= fftSize && ++samplesSinceFFT >= hopSize) + { + samplesSinceFFT = 0; + computeFFT(); + } +} + +void Analyser::computeFFT() +{ + // Gather the most recent fftSize samples in order (oldest -> newest). + const int start = writePos; + for (int i = 0; i < fftSize; ++i) + fftData[i] = ring[(start + i) % fftSize]; + + window.multiplyWithWindowingTable (fftData.data(), static_cast (fftSize)); + juce::FloatVectorOperations::clear (fftData.data() + fftSize, fftSize); + + fft.performFrequencyOnlyForwardTransform (fftData.data()); + + for (int b = 0; b < numBands; ++b) + { + const int lo = bandBinEdges[b]; + const int hi = bandBinEdges[b + 1]; + + double sum = 0.0; + int count = 0; + for (int k = lo; k < hi && k < fftBins; ++k) + { + sum += fftData[k]; + ++count; + } + + const double avg = count > 0 ? sum / count : 0.0; + + // Normalize the bin magnitude by the FFT size so "0 dB" corresponds + // to ~full scale, otherwise the raw magnitudes sit ~66 dB too hot. + const double normalized = avg / static_cast (fftSize); + const double db = 20.0 * std::log10 (normalized + 1e-9); + double norm = (db - minDb) / (maxDb - minDb); + norm = juce::jlimit (0.0, 1.0, norm); + + targets[b] = static_cast (norm); + } +} + +void Analyser::update (double dt) +{ + // Live bar level eases toward the latest FFT target with a fast attack + // and a knob-controlled release (bar falloff). + const double attackCoef = 1.0 - std::exp (-dt / 0.005); + const double releaseCoef = 1.0 - std::exp (-dt / juce::jmax (0.001, barReleaseTau)); + + for (int b = 0; b < numBands; ++b) + { + const float target = targets[b]; + + if (target >= levels[b]) + levels[b] += (target - levels[b]) * static_cast (attackCoef); + else + levels[b] += (target - levels[b]) * static_cast (releaseCoef); + + if (levels[b] >= peaks[b]) + { + peaks[b] = levels[b]; + peakTimers[b] = 0.0f; + } + else + { + peakTimers[b] += static_cast (dt); + + if (peakTimers[b] >= grace) + { + peaks[b] -= static_cast (falloffRate * dt); + + if (peaks[b] < levels[b]) + peaks[b] = levels[b]; + if (peaks[b] < 0.0f) + peaks[b] = 0.0f; + } + } + } +} + +float Analyser::freqToFraction (double freq) const noexcept +{ + const double f = juce::jlimit (minFreq, maxFreq, freq); + return static_cast (std::log (f / minFreq) / std::log (maxFreq / minFreq)); +} + +float Analyser::dbToFraction (double db) const noexcept +{ + return static_cast (juce::jlimit (0.0, 1.0, (db - minDb) / (maxDb - minDb))); +} diff --git a/Source/Analyser.h b/Source/Analyser.h new file mode 100644 index 0000000..7ffd933 --- /dev/null +++ b/Source/Analyser.h @@ -0,0 +1,74 @@ +#pragma once + +#include "JuceHeader.h" + +// Beamgrid analyser: log-spaced spectrum bands with peak-hold markers. +// Each band tracks a current level and a peak that holds for a definable +// "grace" time before falling off at a definable rate. +class Analyser +{ +public: + static constexpr int maxBands = 256; + enum { fftOrder = 11, fftSize = 1 << fftOrder, fftBins = fftSize / 2 }; + + Analyser(); + + void prepare (double sampleRate, int blockSize); + void push (const float* channelData, int numSamples); + void update (double dt); + + int getNumBands() const noexcept { return numBands; } + float getLevel (int band) const noexcept { return band < numBands ? juce::jmin (1.0f, levels[band]) : 0.0f; } + float getPeak (int band) const noexcept { return band < numBands ? juce::jmin (1.0f, peaks[band]) : 0.0f; } + + // Helpers for drawing axis legends. + double getMinFreq() const noexcept { return minFreq; } + double getMaxFreq() const noexcept { return maxFreq; } + float freqToFraction (double freq) const noexcept; + float dbToFraction (double db) const noexcept; + + void setGraceSeconds (double seconds) noexcept { grace = seconds; } + void setFalloffRate (double rate) noexcept { falloffRate = rate; } + void setBarReleaseTau (double seconds) noexcept { barReleaseTau = seconds; } + void setNumBands (int n) noexcept; + double getGraceSeconds() const noexcept { return grace; } + double getFalloffRate() const noexcept { return falloffRate; } + +private: + void pushSample (float sample); + void computeFFT(); + void computeBandEdges(); + + juce::dsp::FFT fft { fftOrder }; + juce::dsp::WindowingFunction window { fftSize, juce::dsp::WindowingFunction::hann, true }; + + // Sliding time-domain window so the FFT is recomputed every `hopSize` + // samples (overlapped) instead of only once per full window. This keeps + // the visualization temporally smooth. + static constexpr int hopSize = 256; + + std::array ring {}; + std::array fftData {}; + int writePos = 0; + int samplesSinceFFT = 0; + int totalSamples = 0; + + int numBands = 64; + std::array levels {}; + std::array targets {}; + std::array peaks {}; + std::array peakTimers {}; + + // First/last FFT bin index covered by each band (log spaced). + std::array bandBinEdges {}; + + double minFreq = 20.0; + double maxFreq = 22050.0; + + double sampleRate = 44100.0; + double grace = 0.8; // seconds the peak is held before falling + double falloffRate = 0.8; // normalized units per second while falling + double barReleaseTau = 0.2; // seconds for the live bar to fall (release) + double minDb = -48.0; // dBFS-equivalent floor (bands below this -> 0) + double maxDb = -6.0; // dBFS-equivalent ceiling (bands at/above -> 1) +}; diff --git a/Source/AnalyserComponent.cpp b/Source/AnalyserComponent.cpp new file mode 100644 index 0000000..692537c --- /dev/null +++ b/Source/AnalyserComponent.cpp @@ -0,0 +1,3 @@ +#include "AnalyserComponent.h" + +// (Implementation is entirely inline in the header for this small component.) diff --git a/Source/AnalyserComponent.h b/Source/AnalyserComponent.h new file mode 100644 index 0000000..764158d --- /dev/null +++ b/Source/AnalyserComponent.h @@ -0,0 +1,129 @@ +#pragma once + +#include "JuceHeader.h" +#include "Analyser.h" +#include "BeamgridLookAndFeel.h" + +// Black spectrum visualizer: teal analyzer bands and peak-colored markers. +class AnalyserComponent : public juce::Component, + public juce::Timer +{ +public: + AnalyserComponent (Analyser& analyserToUse, + juce::AudioProcessorValueTreeState& paramsToUse, + BeamgridLookAndFeel& lookAndFeelToUse) + : analyser (analyserToUse), params (paramsToUse), lf (lookAndFeelToUse) + { + startTimerHz (60); + } + + ~AnalyserComponent() override + { + stopTimer(); + } + + void timerCallback() override + { + const double graceMs = *params.getRawParameterValue ("grace"); + const double falloff = *params.getRawParameterValue ("falloff"); + const double barfall = *params.getRawParameterValue ("barfalloff"); + const int bars = juce::roundToInt (params.getRawParameterValue ("bars")->load()); + const double hue = *params.getRawParameterValue ("hue"); + + analyser.setGraceSeconds (graceMs / 1000.0); + analyser.setFalloffRate (0.1 + falloff * 5.0); + // Bar falloff: higher knob -> faster bar descent (smaller release tau). + analyser.setBarReleaseTau (2.0 - barfall * 1.98); + analyser.setNumBands (bars); + + // HUE knob: 12 o'clock (0.5) = no rotation; left/right rotate +/- 180 deg. + lf.setHueTurns (static_cast (hue - 0.5)); + + analyser.update (1.0 / 60.0); + repaint(); + } + + void paint (juce::Graphics& g) override + { + const auto area = getLocalBounds().toFloat().reduced (8.0f); + const int n = analyser.getNumBands(); + + // Reserve margins for the axis legends. + juce::Rectangle plot = area; + plot.setLeft (area.getX() + 40.0f); + plot.setBottom (area.getBottom() - 20.0f); + + const float gap = 2.0f; + const float bandWidth = (plot.getWidth() - gap * (n + 1)) / static_cast (n); + const float baseY = plot.getBottom(); + + const juce::Colour gridColour = lf.transform (juce::Colours::grey.brighter (0.2f)).withAlpha (0.32f); + const juce::Colour labelColour = lf.transform (juce::Colours::grey); + + // Frequency gridlines + labels (log spaced). + const double freqTicks[] = { 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000 }; + g.setFont (juce::FontOptions (10.0f)); + for (double f : freqTicks) + { + if (f < analyser.getMinFreq() || f > analyser.getMaxFreq()) + continue; + + const float frac = analyser.freqToFraction (f); + const float x = plot.getX() + gap + frac * (plot.getWidth() - 2.0f * gap); + + g.setColour (gridColour); + g.drawVerticalLine (static_cast (x), plot.getY(), plot.getBottom()); + + g.setColour (labelColour); + g.drawText (formatFreq (f), x - 18.0f, plot.getBottom() + 3.0f, 36.0f, 14.0f, + juce::Justification::centredTop, false); + } + + // Level gridlines + labels (dB). + const double dbTicks[] = { 0.0, -6.0, -12.0, -24.0, -36.0, -48.0 }; + for (double db : dbTicks) + { + const float frac = analyser.dbToFraction (db); + const float y = baseY - frac * plot.getHeight(); + + g.setColour (gridColour); + g.drawHorizontalLine (static_cast (y), plot.getX(), plot.getRight()); + + g.setColour (labelColour); + g.drawText (juce::String (db, 0), area.getX(), y - 7.0f, 36.0f, 14.0f, + juce::Justification::centredRight, false); + } + + // Analyzer bands (teal) and peak markers (peak color). + for (int i = 0; i < n; ++i) + { + const float level = analyser.getLevel (i); + const float peak = analyser.getPeak (i); + + const float x = plot.getX() + gap + i * (bandWidth + gap); + const float h = level * plot.getHeight(); + const float y = baseY - h; + + g.setColour (lf.getTeal()); + g.fillRect (x, y, bandWidth, h); + + const float peakY = baseY - peak * plot.getHeight(); + g.setColour (lf.getPeak()); + g.fillRect (x, peakY - 2.0f, bandWidth, 3.0f); + } + } + + static juce::String formatFreq (double f) + { + if (f >= 1000.0) + return juce::String (f / 1000.0, 1, false) + "k"; + return juce::String (static_cast (f)); + } + +private: + Analyser& analyser; + juce::AudioProcessorValueTreeState& params; + BeamgridLookAndFeel& lf; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AnalyserComponent) +}; diff --git a/Source/BeamgridLookAndFeel.cpp b/Source/BeamgridLookAndFeel.cpp new file mode 100644 index 0000000..5145605 --- /dev/null +++ b/Source/BeamgridLookAndFeel.cpp @@ -0,0 +1,4 @@ +#include "BeamgridLookAndFeel.h" + +const juce::Colour BeamgridLookAndFeel::tealBase { 0xff558899 }; +const juce::Colour BeamgridLookAndFeel::peakBase { 0xff996655 }; diff --git a/Source/BeamgridLookAndFeel.h b/Source/BeamgridLookAndFeel.h new file mode 100644 index 0000000..a2b93d2 --- /dev/null +++ b/Source/BeamgridLookAndFeel.h @@ -0,0 +1,69 @@ +#pragma once + +#include "JuceHeader.h" + +// Visual styling for BEAMGRID: black UI with teal accents. A single HUE knob +// rotates the hue of every saturated colour on the UI via a shared LookAndFeel. +class BeamgridLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + BeamgridLookAndFeel() + { + setColour (juce::Slider::rotarySliderFillColourId, tealBase); + setColour (juce::Slider::rotarySliderOutlineColourId, juce::Colours::black.brighter (0.18f)); + setColour (juce::Slider::textBoxTextColourId, juce::Colours::lightgrey); + setColour (juce::Slider::textBoxOutlineColourId, juce::Colours::transparentBlack); + setColour (juce::Label::textColourId, juce::Colours::lightgrey); + } + + // Base palette (used at 12 o'clock / zero hue rotation). + static const juce::Colour tealBase; + static const juce::Colour peakBase; + + // Hue rotation in turns (-0.5 .. +0.5); 0 = original colours. + float hueTurns = 0.0f; + + void setHueTurns (float turns) noexcept { hueTurns = turns; } + + juce::Colour transform (juce::Colour c) const noexcept + { + return c.withRotatedHue (hueTurns); + } + + juce::Colour getTeal() const noexcept { return transform (tealBase); } + juce::Colour getPeak() const noexcept { return transform (peakBase); } + + void drawRotarySlider (juce::Graphics& g, int x, int y, int w, int h, + float pos, float startAngle, float endAngle, + juce::Slider& slider) override + { + const auto bounds = juce::Rectangle (x, y, w, h).toFloat().reduced (6.0f); + const auto centre = bounds.getCentre(); + const auto radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f; + const float thickness = 5.0f; + + // Background track arc. + juce::Path track; + track.addArc (centre.x - radius, centre.y - radius, radius * 2.0f, radius * 2.0f, + startAngle, endAngle, thickness); + g.setColour (slider.findColour (juce::Slider::rotarySliderOutlineColourId)); + g.strokePath (track, juce::PathStrokeType (thickness)); + + // Filled value arc (teal, hue-rotated). + const float angle = startAngle + pos * (endAngle - startAngle); + juce::Path value; + value.addArc (centre.x - radius, centre.y - radius, radius * 2.0f, radius * 2.0f, + startAngle, angle, thickness); + g.setColour (getTeal()); + g.strokePath (value, juce::PathStrokeType (thickness)); + + // Pointer line. + juce::Path pointer; + const float pointerLen = radius * 0.78f; + pointer.addLineSegment (juce::Line (centre.x, centre.y, + centre.x + std::sin (angle) * pointerLen, + centre.y - std::cos (angle) * pointerLen), 2.0f); + g.setColour (juce::Colours::white); + g.strokePath (pointer, juce::PathStrokeType (2.5f)); + } +}; diff --git a/Source/GitVersion.h.in b/Source/GitVersion.h.in new file mode 100644 index 0000000..daf858d --- /dev/null +++ b/Source/GitVersion.h.in @@ -0,0 +1,5 @@ +#pragma once + +#define BEAMGRID_GIT_COMMIT "@GIT_COMMIT@" +#define BEAMGRID_GIT_DIRTY @GIT_DIRTY@ +#define BEAMGRID_BUILD_DATE "@BUILD_DATE@" diff --git a/Source/JuceHeader.h b/Source/JuceHeader.h new file mode 100644 index 0000000..de3288f --- /dev/null +++ b/Source/JuceHeader.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..77bc4ca --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,134 @@ +#include "PluginEditor.h" +#include "GitVersion.h" + +BeamgridAudioProcessorEditor::BeamgridAudioProcessorEditor (BeamgridAudioProcessor& p) + : AudioProcessorEditor (&p), + processorRef (p), + analyserComponent (p.getAnalyser(), p.getParametersState(), lookAndFeel) +{ + setLookAndFeel (&lookAndFeel); + setSize (820, 460); + + // Make the editor freely resizable (with a corner resizer). The plugin + // reports IPlugView::canResize() == true to the host, which is what FL + // Studio (and other hosts) use to enable their native maximize button. + setResizable (true, true); + setResizeLimits (420, 260, 4000, 4000); + + analyserComponent.setLookAndFeel (&lookAndFeel); + addAndMakeVisible (analyserComponent); + + auto setupKnob = [&] (juce::Slider& s, const juce::String& name) + { + s.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + s.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 80, 18); + s.setColour (juce::Slider::textBoxTextColourId, juce::Colours::lightgrey); + s.setTextValueSuffix (" " + name); + s.setLookAndFeel (&lookAndFeel); + addAndMakeVisible (s); + }; + + setupKnob (graceSlider, "ms"); + setupKnob (falloffSlider, ""); + setupKnob (barfalloffSlider, ""); + setupKnob (barsSlider, ""); + setupKnob (hueSlider, ""); + barsSlider.setNumDecimalPlacesToDisplay (0); + + graceLabel.setText ("PEAK GRACE", juce::dontSendNotification); + graceLabel.setJustificationType (juce::Justification::centred); + graceLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (graceLabel); + + falloffLabel.setText ("PEAK FALLOFF", juce::dontSendNotification); + falloffLabel.setJustificationType (juce::Justification::centred); + falloffLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (falloffLabel); + + barfalloffLabel.setText ("BAR FALLOFF", juce::dontSendNotification); + barfalloffLabel.setJustificationType (juce::Justification::centred); + barfalloffLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (barfalloffLabel); + + barsLabel.setText ("BARS", juce::dontSendNotification); + barsLabel.setJustificationType (juce::Justification::centred); + barsLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (barsLabel); + + hueLabel.setText ("HUE", juce::dontSendNotification); + hueLabel.setJustificationType (juce::Justification::centred); + hueLabel.setColour (juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible (hueLabel); + + titleLabel.setText ("BEAMGRID", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredRight); + titleLabel.setFont (juce::FontOptions (20.0f, juce::Font::bold)); + titleLabel.setColour (juce::Label::textColourId, lookAndFeel.getTeal()); + addAndMakeVisible (titleLabel); + + statusLabel.setFont (juce::FontOptions (11.0f)); + statusLabel.setColour (juce::Label::textColourId, juce::Colours::grey); + statusLabel.setJustificationType (juce::Justification::centredLeft); + + juce::String status; + status << "git " << juce::String (BEAMGRID_GIT_COMMIT).substring (0, 7); + status << " [" << (BEAMGRID_GIT_DIRTY ? "dirty" : "clean") << "]"; + status << " build " << BEAMGRID_BUILD_DATE; + statusLabel.setText (status, juce::dontSendNotification); + addAndMakeVisible (statusLabel); + + graceAttachment = std::make_unique( + processorRef.getParametersState(), "grace", graceSlider); + falloffAttachment = std::make_unique( + processorRef.getParametersState(), "falloff", falloffSlider); + barfalloffAttachment = std::make_unique( + processorRef.getParametersState(), "barfalloff", barfalloffSlider); + barsAttachment = std::make_unique( + processorRef.getParametersState(), "bars", barsSlider); + hueAttachment = std::make_unique( + processorRef.getParametersState(), "hue", hueSlider); +} + +BeamgridAudioProcessorEditor::~BeamgridAudioProcessorEditor() +{ + setLookAndFeel (nullptr); +} + +void BeamgridAudioProcessorEditor::paint (juce::Graphics& g) +{ + g.fillAll (juce::Colours::black); + + // Keep the title colour in sync with the HUE knob. + titleLabel.setColour (juce::Label::textColourId, lookAndFeel.getTeal()); +} + +void BeamgridAudioProcessorEditor::resized() +{ + const int panelH = 114; + const int knob = 64; + const int y = getHeight() - panelH + 14; + + analyserComponent.setBounds (0, 0, getWidth(), getHeight() - panelH); + + const int margin = 32; + const int spacing = knob + 24; + + barsSlider.setBounds (margin, y, knob, knob); + barsLabel.setBounds (margin, y + knob + 2, knob, 18); + + graceSlider.setBounds (margin + spacing, y, knob, knob); + graceLabel.setBounds (margin + spacing, y + knob + 2, knob, 18); + + barfalloffSlider.setBounds (margin + spacing * 2, y, knob, knob); + barfalloffLabel.setBounds (margin + spacing * 2, y + knob + 2, knob, 18); + + falloffSlider.setBounds (margin + spacing * 3, y, knob, knob); + falloffLabel.setBounds (margin + spacing * 3, y + knob + 2, knob, 18); + + hueSlider.setBounds (margin + spacing * 4, y, knob, knob); + hueLabel.setBounds (margin + spacing * 4, y + knob + 2, knob, 18); + + titleLabel.setBounds (getWidth() - 262, y - 6, 210, knob + 24); + + statusLabel.setBounds (8, getHeight() - 16, getWidth() - 16, 14); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..6ca3522 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,44 @@ +#pragma once + +#include "JuceHeader.h" +#include "PluginProcessor.h" +#include "AnalyserComponent.h" +#include "BeamgridLookAndFeel.h" + +class BeamgridAudioProcessorEditor : public juce::AudioProcessorEditor +{ +public: + explicit BeamgridAudioProcessorEditor (BeamgridAudioProcessor&); + ~BeamgridAudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + +private: + BeamgridAudioProcessor& processorRef; + BeamgridLookAndFeel lookAndFeel; + + AnalyserComponent analyserComponent; + + juce::Slider graceSlider; + juce::Slider falloffSlider; + juce::Slider barfalloffSlider; + juce::Slider barsSlider; + juce::Slider hueSlider; + + juce::Label graceLabel; + juce::Label falloffLabel; + juce::Label barfalloffLabel; + juce::Label barsLabel; + juce::Label hueLabel; + juce::Label titleLabel; + juce::Label statusLabel; + + std::unique_ptr graceAttachment; + std::unique_ptr falloffAttachment; + std::unique_ptr barfalloffAttachment; + std::unique_ptr barsAttachment; + std::unique_ptr hueAttachment; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BeamgridAudioProcessorEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..12bb616 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,116 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +BeamgridAudioProcessor::BeamgridAudioProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)), + params (*this, nullptr, "Parameters", createParameterLayout()) +{ +} + +BeamgridAudioProcessor::~BeamgridAudioProcessor() = default; + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new BeamgridAudioProcessor(); +} + +juce::AudioProcessorValueTreeState::ParameterLayout +BeamgridAudioProcessor::createParameterLayout() +{ + std::vector> params; + + params.push_back (std::make_unique( + "grace", "Peak Grace (ms)", + juce::NormalisableRange (0.0f, 2000.0f, 1.0f), 800.0f, + juce::AudioParameterFloatAttributes().withLabel ("ms"))); + + params.push_back (std::make_unique( + "falloff", "Peak Falloff", + juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.4f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + params.push_back (std::make_unique( + "barfalloff", "Bar Falloff", + juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.5f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + params.push_back (std::make_unique( + "hue", "Hue", + juce::NormalisableRange (0.0f, 1.0f, 0.001f), 0.5f, + juce::AudioParameterFloatAttributes().withLabel (""))); + + juce::NormalisableRange barsRange (8.0f, 128.0f, + [] (float, float, float v) { return v; }, // convertFrom0To1 + [] (float, float, float v) { return v; }, // convertTo0To1 + [] (float, float, float v) // snapToLegalValue + { + const float steps[] = { 8.0f, 16.0f, 32.0f, 64.0f, 96.0f, 128.0f }; + float best = steps[0]; + float bestDist = std::abs (v - best); + for (float s : steps) + { + const float dist = std::abs (v - s); + if (dist < bestDist) + { + bestDist = dist; + best = s; + } + } + return best; + }); + + params.push_back (std::make_unique( + "bars", "Bars", barsRange, 64.0f)); + + return { params.begin(), params.end() }; +} + +void BeamgridAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + analyser.prepare (sampleRate, samplesPerBlock); +} + +void BeamgridAudioProcessor::releaseResources() {} + +bool BeamgridAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainInputChannels() == 0 || + layouts.getMainOutputChannels() == 0) + return false; + + if (layouts.getMainInputChannels() != layouts.getMainOutputChannels()) + return false; + + return true; +} + +void BeamgridAudioProcessor::processBlock (juce::AudioBuffer& buffer, + juce::MidiBuffer& /*midi*/) +{ + juce::ScopedNoDenormals noDenormals; + + // Analyse the first channel; pass the audio through unchanged. + if (buffer.getNumChannels() > 0) + analyser.push (buffer.getReadPointer (0), buffer.getNumSamples()); +} + +juce::AudioProcessorEditor* BeamgridAudioProcessor::createEditor() +{ + return new BeamgridAudioProcessorEditor (*this); +} + +void BeamgridAudioProcessor::getStateInformation (juce::MemoryBlock& dest) +{ + auto state = params.copyState(); + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, dest); +} + +void BeamgridAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); + if (xml && xml->hasTagName (params.state.getType())) + params.replaceState (juce::ValueTree::fromXml (*xml)); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..11deeef --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,45 @@ +#pragma once + +#include "JuceHeader.h" +#include "Analyser.h" + +class BeamgridAudioProcessor : public juce::AudioProcessor +{ +public: + BeamgridAudioProcessor(); + ~BeamgridAudioProcessor() override; + + 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 { return true; } + + const juce::String getName() const override { return JucePlugin_Name; } + bool acceptsMidi() const override { return false; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const juce::String getProgramName (int) override { return {}; } + void changeProgramName (int, const juce::String&) override {} + + void getStateInformation (juce::MemoryBlock&) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + Analyser& getAnalyser() { return analyser; } + juce::AudioProcessorValueTreeState& getParametersState() { return params; } + +private: + juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + + juce::AudioProcessorValueTreeState params; + Analyser analyser; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BeamgridAudioProcessor) +}; diff --git a/cmake/generate_version.cmake b/cmake/generate_version.cmake new file mode 100644 index 0000000..70aaaea --- /dev/null +++ b/cmake/generate_version.cmake @@ -0,0 +1,28 @@ +# Generates Source/GitVersion.h from Source/GitVersion.h.in using the current +# Git state and a fresh build timestamp. Invoked via `cmake -P` at build time. + +execute_process(COMMAND git rev-parse HEAD + WORKING_DIRECTORY ${SRC_DIR} + OUTPUT_VARIABLE GIT_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + +if(NOT GIT_COMMIT) + set(GIT_COMMIT "unknown") +endif() + +execute_process(COMMAND git status --porcelain + WORKING_DIRECTORY ${SRC_DIR} + OUTPUT_VARIABLE GIT_STATUS + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + +if(GIT_STATUS STREQUAL "") + set(GIT_DIRTY 0) +else() + set(GIT_DIRTY 1) +endif() + +string(TIMESTAMP BUILD_DATE "%Y-%m-%d__%H-%M-%S") + +configure_file(${TEMPLATE} ${OUTPUT} @ONLY)