commit 55b5f89ac52a48af7fe90657e72b99c035f33dff Author: hariel1985 Date: Thu Mar 26 17:26:06 2026 +0100 Initial release — InstaGrain granular synthesizer v1.0 8-voice polyphonic granular synth (VST3/AU/LV2) with: - 128 grain pool per voice, Hann windowing, linear interpolation - Root note selector, sample rate correction, sustain pedal (CC64) - Scatter controls, direction modes (Fwd/Rev/PingPong), freeze - ADSR envelope, global filter (LP/HP/BP), reverb - Waveform display with grain visualization - Drag & drop sample loading, full state save/restore - CI/CD for Windows/macOS/Linux Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e9b2fbe --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,130 @@ +name: Build InstaGrain + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake + run: cmake -B build -G "Visual Studio 17 2022" -A x64 + + - name: Build Release + run: cmake --build build --config Release + + - name: Package VST3 + run: Compress-Archive -Path "build/InstaGrain_artefacts/Release/VST3/InstaGrain.vst3" -DestinationPath "InstaGrain-VST3-Win64.zip" + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaGrain-VST3-Win64 + path: InstaGrain-VST3-Win64.zip + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake (Universal Binary) + run: cmake -B build -G Xcode -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 + + - name: Build Release + run: cmake --build build --config Release + + - name: Package VST3 + working-directory: build/InstaGrain_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaGrain-VST3-macOS.zip VST3/InstaGrain.vst3 + + - name: Package AU + working-directory: build/InstaGrain_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaGrain-AU-macOS.zip AU/InstaGrain.component + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaGrain-VST3-macOS + path: InstaGrain-VST3-macOS.zip + + - name: Upload AU + uses: actions/upload-artifact@v4 + with: + name: InstaGrain-AU-macOS + path: InstaGrain-AU-macOS.zip + + build-linux: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake git libasound2-dev \ + libfreetype6-dev libx11-dev libxrandr-dev libxcursor-dev \ + libxinerama-dev libwebkit2gtk-4.1-dev libcurl4-openssl-dev + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake + run: cmake -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build Release + run: cmake --build build --config Release --parallel $(nproc) + + - name: Package VST3 + working-directory: build/InstaGrain_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaGrain-VST3-Linux-x64.zip VST3/InstaGrain.vst3 + + - name: Package LV2 + working-directory: build/InstaGrain_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaGrain-LV2-Linux-x64.zip LV2/InstaGrain.lv2 + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaGrain-VST3-Linux-x64 + path: InstaGrain-VST3-Linux-x64.zip + + - name: Upload LV2 + uses: actions/upload-artifact@v4 + with: + name: InstaGrain-LV2-Linux-x64 + path: InstaGrain-LV2-Linux-x64.zip + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/InstaGrain-VST3-Win64/InstaGrain-VST3-Win64.zip + artifacts/InstaGrain-VST3-macOS/InstaGrain-VST3-macOS.zip + artifacts/InstaGrain-AU-macOS/InstaGrain-AU-macOS.zip + artifacts/InstaGrain-VST3-Linux-x64/InstaGrain-VST3-Linux-x64.zip + artifacts/InstaGrain-LV2-Linux-x64/InstaGrain-LV2-Linux-x64.zip + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..494880d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.22) +project(InstaGrain VERSION 1.0.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../JUCE ${CMAKE_CURRENT_BINARY_DIR}/JUCE) + +juce_add_plugin(InstaGrain + COMPANY_NAME "InstaGrain" + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT FALSE + PLUGIN_MANUFACTURER_CODE Inst + PLUGIN_CODE Igrn + FORMATS VST3 AU LV2 + LV2URI "https://github.com/hariel1985/InstaGrain" + PRODUCT_NAME "InstaGrain" + COPY_PLUGIN_AFTER_BUILD FALSE +) + +juce_generate_juce_header(InstaGrain) + +juce_add_binary_data(InstaGrainData SOURCES + Resources/Rajdhani-Regular.ttf + Resources/Rajdhani-Medium.ttf + Resources/Rajdhani-Bold.ttf +) + +target_sources(InstaGrain + PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/LookAndFeel.cpp + Source/GrainCloud.cpp + Source/GrainVoice.cpp + Source/GrainEngine.cpp + Source/WaveformDisplay.cpp + Source/GrainControlPanel.cpp + Source/ScatterPanel.cpp + Source/EnvelopePanel.cpp + Source/EffectsPanel.cpp + Source/MasterPanel.cpp +) + +target_compile_definitions(InstaGrain + PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 +) + +target_link_libraries(InstaGrain + PRIVATE + InstaGrainData + juce::juce_audio_basics + juce::juce_audio_devices + juce::juce_audio_formats + juce::juce_audio_processors + juce::juce_audio_utils + juce::juce_dsp + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0f63941 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + For the full license text, see diff --git a/README.md b/README.md new file mode 100644 index 0000000..74468d7 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# InstaGrain + +Granular synthesizer plugin (VST3/AU/LV2) built with JUCE. + +Loads audio samples and splits them into tiny grains, manipulating position, size, density, pitch, and pan to create evolving textures and entirely new sounds. MIDI-controlled, polyphonic (8 voices), with drag & drop sample loading. + +## Features + +- **Granular Engine** — 128 grain pool per voice, Hann windowing, linear interpolation +- **8-voice polyphony** — MIDI note maps to pitch offset relative to configurable root note +- **Root Note selector** — set which MIDI note the loaded sample represents +- **Scatter controls** — randomize position, size, pitch, and pan per grain +- **Direction modes** — Forward, Reverse, PingPong +- **Freeze** — lock grain position for continuous texture generation +- **ADSR envelope** — per-voice amplitude shaping +- **Global effects** — State Variable filter (LP/HP/BP) + Reverb +- **Sustain pedal** — full MIDI CC64 support with proper voice management +- **Sample rate correction** — automatic pitch compensation for sample rate mismatches +- **Waveform display** — real-time grain visualization with position indicator and scatter range +- **Drag & drop** — load WAV, AIFF, MP3, FLAC, OGG files +- **State save/restore** — sample path and all parameters persist with DAW session + +## Build + +Requires [JUCE](https://github.com/juce-framework/JUCE) cloned at `../JUCE` relative to this project. + +```bash +cmake -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Release +``` + +## License + +GPL-3.0 diff --git a/Resources/Rajdhani-Bold.ttf b/Resources/Rajdhani-Bold.ttf new file mode 100644 index 0000000..47af157 Binary files /dev/null and b/Resources/Rajdhani-Bold.ttf differ diff --git a/Resources/Rajdhani-Medium.ttf b/Resources/Rajdhani-Medium.ttf new file mode 100644 index 0000000..a6960c6 Binary files /dev/null and b/Resources/Rajdhani-Medium.ttf differ diff --git a/Resources/Rajdhani-Regular.ttf b/Resources/Rajdhani-Regular.ttf new file mode 100644 index 0000000..d25bd37 Binary files /dev/null and b/Resources/Rajdhani-Regular.ttf differ diff --git a/Source/EffectsPanel.cpp b/Source/EffectsPanel.cpp new file mode 100644 index 0000000..2304ec1 --- /dev/null +++ b/Source/EffectsPanel.cpp @@ -0,0 +1,93 @@ +#include "EffectsPanel.h" + +EffectsPanel::EffectsPanel() +{ + titleLabel.setText ("FILTER / REVERB", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + filterTypeBox.addItem ("LP", 1); + filterTypeBox.addItem ("HP", 2); + filterTypeBox.addItem ("BP", 3); + filterTypeBox.setSelectedId (1); + addAndMakeVisible (filterTypeBox); + + filterLabel.setText ("Type", juce::dontSendNotification); + filterLabel.setJustificationType (juce::Justification::centred); + filterLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (filterLabel); + + auto setupKnob = [this] (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& type, + const juce::String& suffix = "") + { + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 55, 14); + knob.setRange (min, max); + knob.setValue (def); + knob.setTextValueSuffix (suffix); + knob.getProperties().set (InstaGrainLookAndFeel::knobTypeProperty, type); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (label); + }; + + setupKnob (cutoffKnob, cutoffLabel, "Cutoff", 20.0, 20000.0, 20000.0, "orange", " Hz"); + cutoffKnob.setSkewFactorFromMidPoint (1000.0); + + setupKnob (resoKnob, resoLabel, "Reso", 0.1, 10.0, 0.707, "dark"); + resoKnob.setSkewFactorFromMidPoint (1.5); + + setupKnob (reverbSizeKnob, revSizeLabel, "Rev Size", 0.0, 1.0, 0.0, "dark"); + setupKnob (reverbDecayKnob, revDecayLabel, "Rev Decay", 0.0, 1.0, 0.0, "dark"); +} + +void EffectsPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + int colW = bounds.getWidth() / 5; + + // Filter type combo + auto typeCol = bounds.removeFromLeft (colW); + filterLabel.setBounds (typeCol.removeFromTop (14)); + filterTypeBox.setBounds (typeCol.reduced (4, 2).removeFromTop (24)); + + // Cutoff + auto cutCol = bounds.removeFromLeft (colW); + auto cutKnobArea = cutCol.withTrimmedBottom (16); + cutoffKnob.setBounds (cutKnobArea.reduced (2)); + cutoffLabel.setBounds (cutCol.getX(), cutKnobArea.getBottom() - 2, cutCol.getWidth(), 16); + + // Reso + auto resCol = bounds.removeFromLeft (colW); + auto resKnobArea = resCol.withTrimmedBottom (16); + resoKnob.setBounds (resKnobArea.reduced (2)); + resoLabel.setBounds (resCol.getX(), resKnobArea.getBottom() - 2, resCol.getWidth(), 16); + + // Reverb Size + auto rsCol = bounds.removeFromLeft (colW); + auto rsKnobArea = rsCol.withTrimmedBottom (16); + reverbSizeKnob.setBounds (rsKnobArea.reduced (2)); + revSizeLabel.setBounds (rsCol.getX(), rsKnobArea.getBottom() - 2, rsCol.getWidth(), 16); + + // Reverb Decay + auto rdCol = bounds; + auto rdKnobArea = rdCol.withTrimmedBottom (16); + reverbDecayKnob.setBounds (rdKnobArea.reduced (2)); + revDecayLabel.setBounds (rdCol.getX(), rdKnobArea.getBottom() - 2, rdCol.getWidth(), 16); +} + +void EffectsPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/EffectsPanel.h b/Source/EffectsPanel.h new file mode 100644 index 0000000..6e5b35a --- /dev/null +++ b/Source/EffectsPanel.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class EffectsPanel : public juce::Component +{ +public: + EffectsPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::ComboBox filterTypeBox; + juce::Slider cutoffKnob, resoKnob, reverbSizeKnob, reverbDecayKnob; + +private: + juce::Label filterLabel, cutoffLabel, resoLabel, revSizeLabel, revDecayLabel; + juce::Label titleLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (EffectsPanel) +}; diff --git a/Source/EnvelopePanel.cpp b/Source/EnvelopePanel.cpp new file mode 100644 index 0000000..a9ebb5c --- /dev/null +++ b/Source/EnvelopePanel.cpp @@ -0,0 +1,63 @@ +#include "EnvelopePanel.h" + +EnvelopePanel::EnvelopePanel() +{ + titleLabel.setText ("ENVELOPE", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + setupKnob (attackKnob, aLabel, "A", 0.001, 2.0, 0.01, " s"); + setupKnob (decayKnob, dLabel, "D", 0.001, 2.0, 0.1, " s"); + setupKnob (sustainKnob, sLabel, "S", 0.0, 1.0, 1.0); + setupKnob (releaseKnob, rLabel, "R", 0.01, 5.0, 0.3, " s"); + + attackKnob.setSkewFactorFromMidPoint (0.2); + decayKnob.setSkewFactorFromMidPoint (0.3); + releaseKnob.setSkewFactorFromMidPoint (0.5); +} + +void EnvelopePanel::setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& suffix) +{ + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 50, 14); + knob.setRange (min, max); + knob.setValue (def); + knob.setTextValueSuffix (suffix); + knob.getProperties().set (InstaGrainLookAndFeel::knobTypeProperty, "orange"); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (label); +} + +void EnvelopePanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + int knobW = bounds.getWidth() / 4; + + juce::Slider* knobs[] = { &attackKnob, &decayKnob, &sustainKnob, &releaseKnob }; + juce::Label* labels[] = { &aLabel, &dLabel, &sLabel, &rLabel }; + + for (int i = 0; i < 4; ++i) + { + auto col = bounds.removeFromLeft (knobW); + auto knobArea = col.withTrimmedBottom (16); + knobs[i]->setBounds (knobArea.reduced (2)); + labels[i]->setBounds (col.getX(), knobArea.getBottom() - 2, col.getWidth(), 16); + } +} + +void EnvelopePanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/EnvelopePanel.h b/Source/EnvelopePanel.h new file mode 100644 index 0000000..2c22352 --- /dev/null +++ b/Source/EnvelopePanel.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class EnvelopePanel : public juce::Component +{ +public: + EnvelopePanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider attackKnob, decayKnob, sustainKnob, releaseKnob; + +private: + juce::Label aLabel, dLabel, sLabel, rLabel; + juce::Label titleLabel; + + void setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& suffix = ""); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (EnvelopePanel) +}; diff --git a/Source/Grain.h b/Source/Grain.h new file mode 100644 index 0000000..da539d9 --- /dev/null +++ b/Source/Grain.h @@ -0,0 +1,45 @@ +#pragma once +#include + +// ============================================================ +// Hann window lookup table (1024 points, computed once) +// ============================================================ +struct GrainWindow +{ + static constexpr int tableSize = 1024; + float table[tableSize]; + + GrainWindow() + { + for (int i = 0; i < tableSize; ++i) + { + float phase = (float) i / (float) (tableSize - 1); + table[i] = 0.5f * (1.0f - std::cos (juce::MathConstants::twoPi * phase)); + } + } + + float getValue (float phase) const + { + float index = juce::jlimit (0.0f, 1.0f, phase) * (float) (tableSize - 1); + int i0 = (int) index; + int i1 = std::min (i0 + 1, tableSize - 1); + float frac = index - (float) i0; + return table[i0] + frac * (table[i1] - table[i0]); + } +}; + +// ============================================================ +// Single grain +// ============================================================ +struct Grain +{ + int startSample = 0; // where in source buffer + int lengthSamples = 0; // grain duration in samples + float pitchRatio = 1.0f; // playback speed + double readPosition = 0.0; // current fractional read position + int samplesElapsed = 0; // output samples generated + float panPosition = 0.0f; // -1 left, +1 right + float volume = 1.0f; + bool reverse = false; + bool active = false; +}; diff --git a/Source/GrainCloud.cpp b/Source/GrainCloud.cpp new file mode 100644 index 0000000..2bc88e0 --- /dev/null +++ b/Source/GrainCloud.cpp @@ -0,0 +1,182 @@ +#include "GrainCloud.h" + +GrainCloud::GrainCloud() {} + +void GrainCloud::prepare (double sampleRate) +{ + currentSampleRate = sampleRate; + reset(); +} + +void GrainCloud::reset() +{ + for (auto& g : grains) + g.active = false; + samplesUntilNextGrain = 0; +} + +void GrainCloud::spawnGrain (const juce::AudioBuffer& sourceBuffer) +{ + const int numSourceSamples = sourceBuffer.getNumSamples(); + if (numSourceSamples == 0) return; + + // Find free slot + Grain* slot = nullptr; + for (auto& g : grains) + { + if (! g.active) + { + slot = &g; + break; + } + } + if (slot == nullptr) return; // all slots busy + + // Position with scatter + float pos = position.load(); + float posS = posScatter.load(); + float scatteredPos = pos + (rng.nextFloat() * 2.0f - 1.0f) * posS; + scatteredPos = juce::jlimit (0.0f, 1.0f, scatteredPos); + + // Size with scatter + float sizeMs = grainSizeMs.load(); + float sizeS = sizeScatter.load(); + float scatteredSize = sizeMs * (1.0f + (rng.nextFloat() * 2.0f - 1.0f) * sizeS); + scatteredSize = juce::jlimit (10.0f, 500.0f, scatteredSize); + int lengthSamp = (int) (scatteredSize * 0.001f * currentSampleRate); + lengthSamp = std::max (1, lengthSamp); + + // Pitch with scatter + MIDI offset + float pitchST = pitchSemitones.load() + midiPitchOffset; + float pitchS = pitchScatter.load(); + float scatteredPitch = pitchST + (rng.nextFloat() * 2.0f - 1.0f) * pitchS * 12.0f; + float pitchRatio = std::pow (2.0f, scatteredPitch / 12.0f) * sampleRateRatio; + + // Pan with scatter + float p = pan.load(); + float panS = panScatter.load(); + float scatteredPan = p + (rng.nextFloat() * 2.0f - 1.0f) * panS; + scatteredPan = juce::jlimit (-1.0f, 1.0f, scatteredPan); + + // Direction + int dir = direction.load(); + bool rev = false; + if (dir == 1) rev = true; + else if (dir == 2) rev = (rng.nextFloat() > 0.5f); + + // Setup grain + slot->startSample = (int) (scatteredPos * (float) (numSourceSamples - 1)); + slot->lengthSamples = lengthSamp; + slot->pitchRatio = pitchRatio; + slot->readPosition = rev ? (double) (lengthSamp - 1) : 0.0; + slot->samplesElapsed = 0; + slot->panPosition = scatteredPan; + slot->volume = 1.0f; + slot->reverse = rev; + slot->active = true; +} + +float GrainCloud::readSampleInterpolated (const juce::AudioBuffer& buffer, double pos) const +{ + const int numSamples = buffer.getNumSamples(); + if (numSamples == 0) return 0.0f; + + int i0 = (int) pos; + int i1 = i0 + 1; + float frac = (float) (pos - (double) i0); + + // Wrap to valid range + i0 = juce::jlimit (0, numSamples - 1, i0); + i1 = juce::jlimit (0, numSamples - 1, i1); + + const float* data = buffer.getReadPointer (0); + return data[i0] + frac * (data[i1] - data[i0]); +} + +void GrainCloud::processBlock (juce::AudioBuffer& output, int numSamples, + const juce::AudioBuffer& sourceBuffer) +{ + if (sourceBuffer.getNumSamples() == 0) return; + + float* outL = output.getWritePointer (0); + float* outR = output.getNumChannels() > 1 ? output.getWritePointer (1) : outL; + + for (int samp = 0; samp < numSamples; ++samp) + { + // Spawn new grains + --samplesUntilNextGrain; + if (samplesUntilNextGrain <= 0) + { + spawnGrain (sourceBuffer); + float d = density.load(); + float interval = (float) currentSampleRate / std::max (1.0f, d); + samplesUntilNextGrain = std::max (1, (int) interval); + } + + // Render active grains + float mixL = 0.0f, mixR = 0.0f; + + for (auto& grain : grains) + { + if (! grain.active) continue; + + // Window amplitude + float phase = (float) grain.samplesElapsed / (float) grain.lengthSamples; + float amp = window.getValue (phase) * grain.volume; + + // Read from source + double srcPos = (double) grain.startSample + grain.readPosition; + float sample = readSampleInterpolated (sourceBuffer, srcPos) * amp; + + // Pan + float leftGain = std::cos ((grain.panPosition + 1.0f) * 0.25f * juce::MathConstants::pi); + float rightGain = std::sin ((grain.panPosition + 1.0f) * 0.25f * juce::MathConstants::pi); + mixL += sample * leftGain; + mixR += sample * rightGain; + + // Advance read position + if (grain.reverse) + grain.readPosition -= (double) grain.pitchRatio; + else + grain.readPosition += (double) grain.pitchRatio; + + grain.samplesElapsed++; + + // Deactivate if done + if (grain.samplesElapsed >= grain.lengthSamples) + grain.active = false; + } + + outL[samp] += mixL; + outR[samp] += mixR; + } +} + +std::array GrainCloud::getActiveGrainInfo() const +{ + std::array info; + for (int i = 0; i < maxGrains; ++i) + { + if (grains[i].active) + { + info[i].startSample = grains[i].startSample; + info[i].lengthSamples = grains[i].lengthSamples; + info[i].progress = (float) grains[i].samplesElapsed / (float) std::max (1, grains[i].lengthSamples); + } + else + { + info[i].startSample = -1; + info[i].lengthSamples = 0; + info[i].progress = 0.0f; + } + } + return info; +} + +int GrainCloud::getActiveGrainCount() const +{ + int count = 0; + for (auto& g : grains) + if (g.active) ++count; + return count; +} diff --git a/Source/GrainCloud.h b/Source/GrainCloud.h new file mode 100644 index 0000000..e326f76 --- /dev/null +++ b/Source/GrainCloud.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include "Grain.h" + +class GrainCloud +{ +public: + static constexpr int maxGrains = 128; + + enum class Direction { Forward, Reverse, PingPong }; + + GrainCloud(); + + void prepare (double sampleRate); + void processBlock (juce::AudioBuffer& output, int numSamples, + const juce::AudioBuffer& sourceBuffer); + void reset(); + + // Parameters (atomic — GUI writes, audio reads) + std::atomic position { 0.5f }; // 0-1 + std::atomic grainSizeMs { 100.0f }; // 10-500 + std::atomic density { 10.0f }; // 1-100 grains/sec + std::atomic pitchSemitones { 0.0f }; // -24..+24 + std::atomic pan { 0.0f }; // -1..+1 + std::atomic posScatter { 0.0f }; // 0-1 + std::atomic sizeScatter { 0.0f }; // 0-1 + std::atomic pitchScatter { 0.0f }; // 0-1 + std::atomic panScatter { 0.0f }; // 0-1 + std::atomic direction { 0 }; // 0=Fwd, 1=Rev, 2=PingPong + std::atomic freeze { false }; + + // Extra pitch offset from MIDI note + float midiPitchOffset = 0.0f; + + // Sample rate correction: sourceSampleRate / dawSampleRate + float sampleRateRatio = 1.0f; + + // For visualization — snapshot of active grains + struct GrainInfo { int startSample; int lengthSamples; float progress; }; + std::array getActiveGrainInfo() const; + int getActiveGrainCount() const; + +private: + double currentSampleRate = 44100.0; + std::array grains; + GrainWindow window; + int samplesUntilNextGrain = 0; + juce::Random rng; + + void spawnGrain (const juce::AudioBuffer& sourceBuffer); + float readSampleInterpolated (const juce::AudioBuffer& buffer, double position) const; +}; diff --git a/Source/GrainControlPanel.cpp b/Source/GrainControlPanel.cpp new file mode 100644 index 0000000..5c14ab8 --- /dev/null +++ b/Source/GrainControlPanel.cpp @@ -0,0 +1,83 @@ +#include "GrainControlPanel.h" + +GrainControlPanel::GrainControlPanel() +{ + titleLabel.setText ("GRAIN", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + setupKnob (positionKnob, posLabel, "Position", 0.0, 1.0, 0.5); + setupKnob (sizeKnob, sizeLabel, "Size", 10.0, 500.0, 100.0, " ms"); + setupKnob (densityKnob, densityLabel, "Density", 1.0, 100.0, 10.0, " g/s"); + setupKnob (pitchKnob, pitchLabel, "Pitch", -24.0, 24.0, 0.0, " st"); + setupKnob (panKnob, panLabel, "Pan", -1.0, 1.0, 0.0); + + // Root Note selector (MIDI 0-127) + const char* noteNames[] = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; + for (int i = 0; i <= 127; ++i) + { + int octave = (i / 12) - 1; + juce::String name = juce::String (noteNames[i % 12]) + juce::String (octave); + rootNoteBox.addItem (name, i + 1); // ComboBox IDs start at 1 + } + rootNoteBox.setSelectedId (60 + 1); // Default: C4 + addAndMakeVisible (rootNoteBox); + + rootNoteLabel.setText ("Root", juce::dontSendNotification); + rootNoteLabel.setJustificationType (juce::Justification::centred); + rootNoteLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (rootNoteLabel); +} + +void GrainControlPanel::setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& suffix) +{ + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 60, 14); + knob.setRange (min, max); + knob.setValue (def); + knob.setTextValueSuffix (suffix); + knob.getProperties().set (InstaGrainLookAndFeel::knobTypeProperty, "orange"); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (label); +} + +void GrainControlPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + int knobW = bounds.getWidth() / 6; + int knobH = bounds.getHeight() - 16; + + auto row = bounds.removeFromTop (knobH); + + juce::Slider* knobs[] = { &positionKnob, &sizeKnob, &densityKnob, &pitchKnob, &panKnob }; + juce::Label* labels[] = { &posLabel, &sizeLabel, &densityLabel, &pitchLabel, &panLabel }; + + for (int i = 0; i < 5; ++i) + { + auto col = row.removeFromLeft (knobW); + knobs[i]->setBounds (col.reduced (2)); + labels[i]->setBounds (col.getX(), col.getBottom() - 2, col.getWidth(), 16); + } + + // Root Note combo in the remaining space + auto rootCol = row; + rootNoteLabel.setBounds (rootCol.removeFromTop (14)); + rootNoteBox.setBounds (rootCol.reduced (4, 2).removeFromTop (24)); +} + +void GrainControlPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/GrainControlPanel.h b/Source/GrainControlPanel.h new file mode 100644 index 0000000..ea055f4 --- /dev/null +++ b/Source/GrainControlPanel.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class GrainControlPanel : public juce::Component +{ +public: + GrainControlPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider positionKnob, sizeKnob, densityKnob, pitchKnob, panKnob; + juce::ComboBox rootNoteBox; + +private: + juce::Label posLabel, sizeLabel, densityLabel, pitchLabel, panLabel; + juce::Label rootNoteLabel; + juce::Label titleLabel; + + void setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& suffix = ""); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (GrainControlPanel) +}; diff --git a/Source/GrainEngine.cpp b/Source/GrainEngine.cpp new file mode 100644 index 0000000..005ae46 --- /dev/null +++ b/Source/GrainEngine.cpp @@ -0,0 +1,305 @@ +#include "GrainEngine.h" + +GrainEngine::GrainEngine() +{ + formatManager.registerBasicFormats(); +} + +void GrainEngine::prepare (double sampleRate, int samplesPerBlock) +{ + currentSampleRate = sampleRate; + currentBlockSize = samplesPerBlock; + + for (auto& voice : voices) + voice.prepare (sampleRate); + + // Filter + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = (juce::uint32) samplesPerBlock; + spec.numChannels = 2; + filter.prepare (spec); + filter.setType (juce::dsp::StateVariableTPTFilterType::lowpass); + filter.setCutoffFrequency (20000.0f); + filter.setResonance (0.707f); + + // Reverb + reverb.setSampleRate (sampleRate); + reverbParams.roomSize = 0.0f; + reverbParams.damping = 0.5f; + reverbParams.wetLevel = 0.0f; + reverbParams.dryLevel = 1.0f; + reverbParams.width = 1.0f; + reverb.setParameters (reverbParams); +} + +void GrainEngine::loadSample (const juce::File& file) +{ + auto* reader = formatManager.createReaderFor (file); + if (reader == nullptr) return; + + juce::AudioBuffer tempBuffer ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&tempBuffer, 0, (int) reader->lengthInSamples, 0, true, true); + + // Convert to mono if stereo + if (tempBuffer.getNumChannels() > 1) + { + juce::AudioBuffer monoBuffer (1, tempBuffer.getNumSamples()); + monoBuffer.clear(); + for (int ch = 0; ch < tempBuffer.getNumChannels(); ++ch) + monoBuffer.addFrom (0, 0, tempBuffer, ch, 0, tempBuffer.getNumSamples(), + 1.0f / (float) tempBuffer.getNumChannels()); + sampleBuffer = std::move (monoBuffer); + } + else + { + sampleBuffer = std::move (tempBuffer); + } + + loadedSamplePath = file.getFullPathName(); + sourceSampleRate = reader->sampleRate; + + // Reset all voices + for (auto& voice : voices) + voice.getCloud().reset(); + + delete reader; +} + +void GrainEngine::loadSample (const void* data, size_t dataSize, const juce::String& /*formatName*/) +{ + auto stream = std::make_unique (data, dataSize, false); + auto* reader = formatManager.createReaderFor (std::move (stream)); + if (reader == nullptr) return; + + juce::AudioBuffer tempBuffer ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&tempBuffer, 0, (int) reader->lengthInSamples, 0, true, true); + + if (tempBuffer.getNumChannels() > 1) + { + juce::AudioBuffer monoBuffer (1, tempBuffer.getNumSamples()); + monoBuffer.clear(); + for (int ch = 0; ch < tempBuffer.getNumChannels(); ++ch) + monoBuffer.addFrom (0, 0, tempBuffer, ch, 0, tempBuffer.getNumSamples(), + 1.0f / (float) tempBuffer.getNumChannels()); + sampleBuffer = std::move (monoBuffer); + } + else + { + sampleBuffer = std::move (tempBuffer); + } + + loadedSamplePath = ""; + sourceSampleRate = reader->sampleRate; + + for (auto& voice : voices) + voice.getCloud().reset(); + + delete reader; +} + +void GrainEngine::syncVoiceParameters() +{ + for (auto& voice : voices) + { + auto& cloud = voice.getCloud(); + cloud.position.store (position.load()); + cloud.grainSizeMs.store (grainSizeMs.load()); + cloud.density.store (density.load()); + cloud.pitchSemitones.store (pitchSemitones.load()); + cloud.pan.store (pan.load()); + cloud.posScatter.store (posScatter.load()); + cloud.sizeScatter.store (sizeScatter.load()); + cloud.pitchScatter.store (pitchScatter.load()); + cloud.panScatter.store (panScatter.load()); + cloud.direction.store (direction.load()); + cloud.freeze.store (freeze.load()); + cloud.sampleRateRatio = (float) (sourceSampleRate / currentSampleRate); + + voice.rootNote.store (rootNote.load()); + voice.attackTime.store (attackTime.load()); + voice.decayTime.store (decayTime.load()); + voice.sustainLevel.store (sustainLevel.load()); + voice.releaseTime.store (releaseTime.load()); + } +} + +void GrainEngine::handleNoteOff (int note) +{ + // Release ALL voices playing this note (not just the first) + // Prevents stuck notes when the same key is pressed multiple times with sustain pedal + for (int i = 0; i < maxVoices; ++i) + { + if (voices[i].isActive() && voices[i].getCurrentNote() == note) + { + if (sustainPedalDown) + sustainedVoices[i] = true; + else + voices[i].noteOff(); + } + } +} + +void GrainEngine::handleMidiEvent (const juce::MidiMessage& msg) +{ + if (msg.isNoteOn()) + { + // Velocity 0 = note-off (standard MIDI convention) + if (msg.getFloatVelocity() == 0.0f) + { + handleNoteOff (msg.getNoteNumber()); + return; + } + + int note = msg.getNoteNumber(); + float vel = msg.getFloatVelocity(); + + // Find free voice or steal oldest + int targetIdx = -1; + for (int i = 0; i < maxVoices; ++i) + { + if (! voices[i].isActive()) + { + targetIdx = i; + break; + } + } + // If no free voice, steal first + if (targetIdx < 0) + targetIdx = 0; + + // Clear sustained flag when stealing/reusing a voice slot + sustainedVoices[targetIdx] = false; + voices[targetIdx].noteOn (note, vel); + } + else if (msg.isNoteOff()) + { + handleNoteOff (msg.getNoteNumber()); + } + else if (msg.isSustainPedalOn()) + { + sustainPedalDown = true; + } + else if (msg.isSustainPedalOff()) + { + sustainPedalDown = false; + for (int i = 0; i < maxVoices; ++i) + { + if (sustainedVoices[i]) + { + voices[i].noteOff(); + sustainedVoices[i] = false; + } + } + } + else if (msg.isAllNotesOff()) + { + sustainPedalDown = false; + for (int i = 0; i < maxVoices; ++i) + { + sustainedVoices[i] = false; + if (voices[i].isActive()) + voices[i].noteOff(); + } + } + else if (msg.isAllSoundOff()) + { + // Immediate kill — no release tail + sustainPedalDown = false; + for (int i = 0; i < maxVoices; ++i) + { + sustainedVoices[i] = false; + voices[i].forceStop(); + } + } +} + +void GrainEngine::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + const int numSamples = buffer.getNumSamples(); + + // Sync parameters + syncVoiceParameters(); + + // Handle MIDI + for (const auto metadata : midiMessages) + handleMidiEvent (metadata.getMessage()); + + // Clear output + buffer.clear(); + + // Process all voices + for (auto& voice : voices) + { + if (voice.isActive()) + voice.processBlock (buffer, numSamples, sampleBuffer); + } + + // Global filter + { + float cutoff = filterCutoff.load(); + float reso = filterReso.load(); + int fType = filterType.load(); + + filter.setCutoffFrequency (cutoff); + filter.setResonance (reso); + + switch (fType) + { + case 0: filter.setType (juce::dsp::StateVariableTPTFilterType::lowpass); break; + case 1: filter.setType (juce::dsp::StateVariableTPTFilterType::highpass); break; + case 2: filter.setType (juce::dsp::StateVariableTPTFilterType::bandpass); break; + } + + // Only apply if cutoff < 19999 (otherwise skip for efficiency) + if (cutoff < 19999.0f || fType != 0) + { + juce::dsp::AudioBlock block (buffer); + juce::dsp::ProcessContextReplacing context (block); + filter.process (context); + } + } + + // Global reverb + { + float size = reverbSize.load(); + float decay = reverbDecay.load(); + + if (size > 0.001f || decay > 0.001f) + { + reverbParams.roomSize = size; + reverbParams.damping = 1.0f - decay; + reverbParams.wetLevel = std::max (size, decay) * 0.5f; + reverbParams.dryLevel = 1.0f - reverbParams.wetLevel * 0.3f; + reverb.setParameters (reverbParams); + reverb.processStereo (buffer.getWritePointer (0), buffer.getWritePointer (1), numSamples); + } + } + + // Master volume + float vol = masterVolume.load(); + buffer.applyGain (vol); + + // VU meter + vuLevelL.store (buffer.getMagnitude (0, 0, numSamples)); + if (buffer.getNumChannels() > 1) + vuLevelR.store (buffer.getMagnitude (1, 0, numSamples)); + else + vuLevelR.store (vuLevelL.load()); +} + +std::vector GrainEngine::getActiveGrainInfo() const +{ + std::vector result; + for (const auto& voice : voices) + { + if (! voice.isActive()) continue; + auto cloudInfo = voice.getCloud().getActiveGrainInfo(); + for (const auto& gi : cloudInfo) + { + if (gi.startSample >= 0) + result.push_back ({ gi.startSample, gi.lengthSamples, gi.progress }); + } + } + return result; +} diff --git a/Source/GrainEngine.h b/Source/GrainEngine.h new file mode 100644 index 0000000..2d6ce23 --- /dev/null +++ b/Source/GrainEngine.h @@ -0,0 +1,86 @@ +#pragma once +#include +#include "GrainVoice.h" + +class GrainEngine +{ +public: + static constexpr int maxVoices = 8; + + GrainEngine(); + + void prepare (double sampleRate, int samplesPerBlock); + void processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages); + void loadSample (const juce::File& file); + void loadSample (const void* data, size_t dataSize, const juce::String& formatName); + + const juce::AudioBuffer& getSampleBuffer() const { return sampleBuffer; } + double getSampleRate() const { return currentSampleRate; } + bool hasSample() const { return sampleBuffer.getNumSamples() > 0; } + juce::String getSamplePath() const { return loadedSamplePath; } + + // Root note — which MIDI note the sample represents (default C4 = 60) + std::atomic rootNote { 60 }; + + // Grain parameters (GUI → audio) + std::atomic position { 0.5f }; + std::atomic grainSizeMs { 100.0f }; + std::atomic density { 10.0f }; + std::atomic pitchSemitones { 0.0f }; + std::atomic pan { 0.0f }; + std::atomic posScatter { 0.0f }; + std::atomic sizeScatter { 0.0f }; + std::atomic pitchScatter { 0.0f }; + std::atomic panScatter { 0.0f }; + std::atomic direction { 0 }; + std::atomic freeze { false }; + + // ADSR + std::atomic attackTime { 0.01f }; + std::atomic decayTime { 0.1f }; + std::atomic sustainLevel { 1.0f }; + std::atomic releaseTime { 0.3f }; + + // Filter + std::atomic filterType { 0 }; // 0=LP, 1=HP, 2=BP + std::atomic filterCutoff { 20000.0f }; + std::atomic filterReso { 0.707f }; + + // Reverb + std::atomic reverbSize { 0.0f }; + std::atomic reverbDecay { 0.0f }; + + // Master + std::atomic masterVolume { 1.0f }; + + // VU meter levels (audio → GUI) + std::atomic vuLevelL { 0.0f }; + std::atomic vuLevelR { 0.0f }; + + // Grain visualization + struct ActiveGrainInfo { int startSample; int lengthSamples; float progress; }; + std::vector getActiveGrainInfo() const; + +private: + std::array voices; + juce::AudioBuffer sampleBuffer; + juce::String loadedSamplePath; + double currentSampleRate = 44100.0; + double sourceSampleRate = 44100.0; + int currentBlockSize = 512; + + juce::AudioFormatManager formatManager; + + // Global effects + juce::dsp::StateVariableTPTFilter filter; + juce::Reverb reverb; + juce::Reverb::Parameters reverbParams; + + void handleMidiEvent (const juce::MidiMessage& msg); + void handleNoteOff (int note); + void syncVoiceParameters(); + + // Sustain pedal + bool sustainPedalDown = false; + std::array sustainedVoices {}; // voices held by pedal +}; diff --git a/Source/GrainVoice.cpp b/Source/GrainVoice.cpp new file mode 100644 index 0000000..6a7a6fc --- /dev/null +++ b/Source/GrainVoice.cpp @@ -0,0 +1,78 @@ +#include "GrainVoice.h" + +GrainVoice::GrainVoice() {} + +void GrainVoice::prepare (double sampleRate) +{ + currentSampleRate = sampleRate; + cloud.prepare (sampleRate); + adsr.setSampleRate (sampleRate); +} + +void GrainVoice::noteOn (int midiNote, float velocity) +{ + currentNote = midiNote; + velocityGain = velocity; + voiceActive = true; + + // Pitch offset relative to sample's root note + cloud.midiPitchOffset = (float) (midiNote - rootNote.load()); + + // Update ADSR + adsrParams.attack = attackTime.load(); + adsrParams.decay = decayTime.load(); + adsrParams.sustain = sustainLevel.load(); + adsrParams.release = releaseTime.load(); + adsr.setParameters (adsrParams); + adsr.noteOn(); + + cloud.reset(); +} + +void GrainVoice::noteOff() +{ + adsr.noteOff(); +} + +void GrainVoice::forceStop() +{ + adsr.reset(); + voiceActive = false; + currentNote = -1; + cloud.reset(); +} + +void GrainVoice::processBlock (juce::AudioBuffer& output, int numSamples, + const juce::AudioBuffer& sourceBuffer) +{ + if (! voiceActive) return; + + // Render cloud into temp buffer + juce::AudioBuffer voiceBuffer (output.getNumChannels(), numSamples); + voiceBuffer.clear(); + + cloud.processBlock (voiceBuffer, numSamples, sourceBuffer); + + // Compute ADSR envelope once per sample (not per channel!) + std::vector envBuffer ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + envBuffer[(size_t) i] = adsr.getNextSample() * velocityGain; + + // Apply envelope and mix into output + for (int ch = 0; ch < output.getNumChannels(); ++ch) + { + const float* voiceData = voiceBuffer.getReadPointer (ch); + float* outData = output.getWritePointer (ch); + + for (int i = 0; i < numSamples; ++i) + outData[i] += voiceData[i] * envBuffer[(size_t) i]; + } + + // Check if ADSR has finished + if (! adsr.isActive()) + { + voiceActive = false; + currentNote = -1; + cloud.reset(); + } +} diff --git a/Source/GrainVoice.h b/Source/GrainVoice.h new file mode 100644 index 0000000..bed79fc --- /dev/null +++ b/Source/GrainVoice.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include "GrainCloud.h" + +class GrainVoice +{ +public: + GrainVoice(); + + void prepare (double sampleRate); + void noteOn (int midiNote, float velocity); + void noteOff(); + void forceStop(); + void processBlock (juce::AudioBuffer& output, int numSamples, + const juce::AudioBuffer& sourceBuffer); + bool isActive() const { return voiceActive; } + int getCurrentNote() const { return currentNote; } + + GrainCloud& getCloud() { return cloud; } + const GrainCloud& getCloud() const { return cloud; } + + // ADSR parameters (set from processor) + // Root note reference (set from engine) + std::atomic rootNote { 60 }; + + // ADSR parameters (set from processor) + std::atomic attackTime { 0.01f }; + std::atomic decayTime { 0.1f }; + std::atomic sustainLevel { 1.0f }; + std::atomic releaseTime { 0.3f }; + +private: + GrainCloud cloud; + juce::ADSR adsr; + juce::ADSR::Parameters adsrParams; + float velocityGain = 1.0f; + int currentNote = -1; + bool voiceActive = false; + double currentSampleRate = 44100.0; +}; diff --git a/Source/LookAndFeel.cpp b/Source/LookAndFeel.cpp new file mode 100644 index 0000000..bc7ddc3 --- /dev/null +++ b/Source/LookAndFeel.cpp @@ -0,0 +1,372 @@ +#include "LookAndFeel.h" +#include "BinaryData.h" + +InstaGrainLookAndFeel::InstaGrainLookAndFeel() +{ + typefaceRegular = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniRegular_ttf, BinaryData::RajdhaniRegular_ttfSize); + typefaceMedium = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniMedium_ttf, BinaryData::RajdhaniMedium_ttfSize); + typefaceBold = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniBold_ttf, BinaryData::RajdhaniBold_ttfSize); + + setColour (juce::ResizableWindow::backgroundColourId, bgDark); + setColour (juce::Label::textColourId, textPrimary); + setColour (juce::TextButton::buttonColourId, bgMedium); + setColour (juce::TextButton::textColourOffId, textPrimary); + setColour (juce::ComboBox::backgroundColourId, bgMedium); + setColour (juce::ComboBox::textColourId, textPrimary); + setColour (juce::ComboBox::outlineColourId, bgLight); + setColour (juce::PopupMenu::backgroundColourId, bgMedium); + setColour (juce::PopupMenu::textColourId, textPrimary); + setColour (juce::PopupMenu::highlightedBackgroundColourId, bgLight); + + generateNoiseTexture(); +} + +juce::Typeface::Ptr InstaGrainLookAndFeel::getTypefaceForFont (const juce::Font& font) +{ + if (font.isBold()) + return typefaceBold; + return typefaceRegular; +} + +juce::Font InstaGrainLookAndFeel::getRegularFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceRegular).withHeight (height)); +} + +juce::Font InstaGrainLookAndFeel::getMediumFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceMedium).withHeight (height)); +} + +juce::Font InstaGrainLookAndFeel::getBoldFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceBold).withHeight (height)); +} + +void InstaGrainLookAndFeel::generateNoiseTexture() +{ + const int texW = 256, texH = 256; + noiseTexture = juce::Image (juce::Image::ARGB, texW, texH, true); + + juce::Random rng (42); + + for (int y = 0; y < texH; ++y) + { + for (int x = 0; x < texW; ++x) + { + float noise = rng.nextFloat() * 0.06f; + bool crossA = ((x + y) % 4 == 0); + bool crossB = ((x - y + 256) % 4 == 0); + float pattern = (crossA || crossB) ? 0.03f : 0.0f; + float alpha = noise + pattern; + noiseTexture.setPixelAt (x, y, juce::Colour::fromFloatRGBA (1.0f, 1.0f, 1.0f, alpha)); + } + } +} + +void InstaGrainLookAndFeel::drawBackgroundTexture (juce::Graphics& g, juce::Rectangle area) +{ + for (int y = area.getY(); y < area.getBottom(); y += noiseTexture.getHeight()) + for (int x = area.getX(); x < area.getRight(); x += noiseTexture.getWidth()) + g.drawImageAt (noiseTexture, x, y); +} + +// ============================================================ +// Rotary slider (3D metal knob) +// ============================================================ + +void InstaGrainLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) +{ + float knobSize = std::min ((float) width, (float) height); + float s = knobSize / 60.0f; + float margin = std::max (4.0f, 6.0f * s); + + auto bounds = juce::Rectangle (x, y, width, height).toFloat().reduced (margin); + auto radius = std::min (bounds.getWidth(), bounds.getHeight()) / 2.0f; + auto cx = bounds.getCentreX(); + auto cy = bounds.getCentreY(); + auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + + auto knobType = slider.getProperties() [knobTypeProperty].toString(); + bool isDark = (knobType == "dark"); + + juce::Colour arcColour = isDark ? juce::Colour (0xff4488ff) : juce::Colour (0xffff8833); + juce::Colour arcBgColour = isDark ? juce::Colour (0xff1a2a44) : juce::Colour (0xff2a1a0a); + juce::Colour bodyTop = isDark ? juce::Colour (0xff3a3a4a) : juce::Colour (0xff5a4a3a); + juce::Colour bodyBottom = isDark ? juce::Colour (0xff1a1a2a) : juce::Colour (0xff2a1a0a); + juce::Colour rimColour = isDark ? juce::Colour (0xff555566) : juce::Colour (0xff886644); + juce::Colour highlightCol = isDark ? juce::Colour (0x33aabbff) : juce::Colour (0x44ffcc88); + juce::Colour pointerColour = isDark ? juce::Colour (0xff66aaff) : juce::Colour (0xffffaa44); + + float arcW = std::max (1.5f, 2.5f * s); + float glowW1 = std::max (3.0f, 10.0f * s); + float hotW = std::max (0.8f, 1.2f * s); + float ptrW = std::max (1.2f, 2.0f * s); + float bodyRadius = radius * 0.72f; + + // 1. Drop shadow + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.fillEllipse (cx - bodyRadius + 1, cy - bodyRadius + 2, bodyRadius * 2, bodyRadius * 2); + + // 2. Outer arc track + { + juce::Path arcBg; + arcBg.addCentredArc (cx, cy, radius - 1, radius - 1, 0.0f, + rotaryStartAngle, rotaryEndAngle, true); + g.setColour (arcBgColour); + g.strokePath (arcBg, juce::PathStrokeType (arcW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + // 3. Outer arc value with glow + if (sliderPos > 0.01f) + { + juce::Path arcVal; + arcVal.addCentredArc (cx, cy, radius - 1, radius - 1, 0.0f, + rotaryStartAngle, angle, true); + + const int numGlowLayers = 8; + for (int i = 0; i < numGlowLayers; ++i) + { + float t = (float) i / (float) (numGlowLayers - 1); + float layerWidth = glowW1 * (1.0f - t * 0.7f); + float layerAlpha = 0.03f + t * t * 0.35f; + g.setColour (arcColour.withAlpha (layerAlpha)); + g.strokePath (arcVal, juce::PathStrokeType (layerWidth, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + g.setColour (arcColour); + g.strokePath (arcVal, juce::PathStrokeType (arcW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + + g.setColour (arcColour.brighter (0.6f).withAlpha (0.5f)); + g.strokePath (arcVal, juce::PathStrokeType (hotW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + // 4. Knob body + { + juce::ColourGradient bodyGrad (bodyTop, cx, cy - bodyRadius * 0.5f, + bodyBottom, cx, cy + bodyRadius, true); + g.setGradientFill (bodyGrad); + g.fillEllipse (cx - bodyRadius, cy - bodyRadius, bodyRadius * 2, bodyRadius * 2); + } + + // 5. Rim + g.setColour (rimColour.withAlpha (0.6f)); + g.drawEllipse (cx - bodyRadius, cy - bodyRadius, bodyRadius * 2, bodyRadius * 2, std::max (0.8f, 1.2f * s)); + + // 6. Inner shadow + { + float innerR = bodyRadius * 0.85f; + juce::ColourGradient innerGrad (juce::Colours::black.withAlpha (0.15f), cx, cy - innerR * 0.3f, + juce::Colours::transparentBlack, cx, cy + innerR, true); + g.setGradientFill (innerGrad); + g.fillEllipse (cx - innerR, cy - innerR, innerR * 2, innerR * 2); + } + + // 7. Top highlight + { + float hlRadius = bodyRadius * 0.55f; + float hlY = cy - bodyRadius * 0.35f; + juce::ColourGradient hlGrad (highlightCol, cx, hlY - hlRadius * 0.5f, + juce::Colours::transparentBlack, cx, hlY + hlRadius, true); + g.setGradientFill (hlGrad); + g.fillEllipse (cx - hlRadius, hlY - hlRadius * 0.6f, hlRadius * 2, hlRadius * 1.2f); + } + + // 8. Pointer with glow + { + float pointerLen = bodyRadius * 0.75f; + + for (int i = 0; i < 4; ++i) + { + float t = (float) i / 3.0f; + float gw = ptrW * (2.0f - t * 1.5f); + float alpha = 0.02f + t * t * 0.15f; + + juce::Path glowLayer; + glowLayer.addRoundedRectangle (-gw, -pointerLen, gw * 2, pointerLen * 0.55f, gw * 0.5f); + glowLayer.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour.withAlpha (alpha)); + g.fillPath (glowLayer); + } + + { + juce::Path pointer; + pointer.addRoundedRectangle (-ptrW * 0.5f, -pointerLen, ptrW, pointerLen * 0.55f, ptrW * 0.5f); + pointer.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour); + g.fillPath (pointer); + } + + { + juce::Path hotCenter; + float hw = ptrW * 0.3f; + hotCenter.addRoundedRectangle (-hw, -pointerLen, hw * 2, pointerLen * 0.5f, hw); + hotCenter.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour.brighter (0.7f).withAlpha (0.6f)); + g.fillPath (hotCenter); + } + } + + // 9. Center cap + { + float capR = bodyRadius * 0.18f; + juce::ColourGradient capGrad (rimColour.brighter (0.3f), cx, cy - capR, + bodyBottom, cx, cy + capR, false); + g.setGradientFill (capGrad); + g.fillEllipse (cx - capR, cy - capR, capR * 2, capR * 2); + } +} + +// ============================================================ +// Button style +// ============================================================ + +void InstaGrainLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button, + const juce::Colour& backgroundColour, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) +{ + auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); + + auto baseColour = backgroundColour; + if (shouldDrawButtonAsDown) + baseColour = baseColour.brighter (0.2f); + else if (shouldDrawButtonAsHighlighted) + baseColour = baseColour.brighter (0.1f); + + juce::ColourGradient grad (baseColour.brighter (0.05f), 0, bounds.getY(), + baseColour.darker (0.1f), 0, bounds.getBottom(), false); + g.setGradientFill (grad); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (bgLight.withAlpha (shouldDrawButtonAsHighlighted ? 0.8f : 0.5f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} + +// ============================================================ +// Toggle button — glowing switch +// ============================================================ + +void InstaGrainLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, + bool /*shouldDrawButtonAsDown*/) +{ + auto bounds = button.getLocalBounds().toFloat(); + float h = std::min (bounds.getHeight() * 0.6f, 14.0f); + float w = h * 1.8f; + float trackR = h * 0.5f; + + float sx = bounds.getX() + (bounds.getWidth() - w) * 0.5f; + float sy = bounds.getCentreY() - h * 0.5f; + auto trackBounds = juce::Rectangle (sx, sy, w, h); + + bool isOn = button.getToggleState(); + auto onColour = accent; + auto offColour = bgLight; + + float targetPos = isOn ? 1.0f : 0.0f; + float animPos = (float) button.getProperties().getWithDefault ("animPos", targetPos); + animPos += (targetPos - animPos) * 0.25f; + if (std::abs (animPos - targetPos) < 0.01f) animPos = targetPos; + button.getProperties().set ("animPos", animPos); + + if (std::abs (animPos - targetPos) > 0.005f) + button.repaint(); + + float thumbR = h * 0.4f; + float thumbX = sx + trackR + animPos * (w - trackR * 2); + float thumbY = sy + h * 0.5f; + float glowIntensity = animPos; + + if (glowIntensity > 0.01f) + { + for (int i = 0; i < 3; ++i) + { + float t = (float) i / 2.0f; + float expand = (1.0f - t) * 1.5f; + float alpha = (0.04f + t * t * 0.1f) * glowIntensity; + g.setColour (onColour.withAlpha (alpha)); + g.fillRoundedRectangle (trackBounds.expanded (expand), trackR + expand); + } + } + + { + juce::Colour offCol = offColour.withAlpha (0.3f); + juce::Colour onCol = onColour.withAlpha (0.35f); + juce::Colour trackCol = offCol.interpolatedWith (onCol, glowIntensity); + if (shouldDrawButtonAsHighlighted) + trackCol = trackCol.brighter (0.15f); + g.setColour (trackCol); + g.fillRoundedRectangle (trackBounds, trackR); + + g.setColour (offColour.withAlpha (0.4f).interpolatedWith (onColour.withAlpha (0.5f), glowIntensity)); + g.drawRoundedRectangle (trackBounds, trackR, 0.8f); + } + + if (glowIntensity > 0.01f) + { + for (int i = 0; i < 3; ++i) + { + float t = (float) i / 2.0f; + float r = thumbR * (1.5f - t * 0.5f); + float alpha = (0.05f + t * t * 0.12f) * glowIntensity; + g.setColour (onColour.withAlpha (alpha)); + g.fillEllipse (thumbX - r, thumbY - r, r * 2, r * 2); + } + } + + { + juce::Colour thumbTopOff (0xff555566), thumbBotOff (0xff333344); + juce::Colour thumbTopOn = onColour.brighter (0.3f), thumbBotOn = onColour.darker (0.2f); + juce::ColourGradient thumbGrad ( + thumbTopOff.interpolatedWith (thumbTopOn, glowIntensity), thumbX, thumbY - thumbR, + thumbBotOff.interpolatedWith (thumbBotOn, glowIntensity), thumbX, thumbY + thumbR, false); + g.setGradientFill (thumbGrad); + g.fillEllipse (thumbX - thumbR, thumbY - thumbR, thumbR * 2, thumbR * 2); + + g.setColour (juce::Colour (0xff666677).withAlpha (0.5f).interpolatedWith (onColour.withAlpha (0.6f), glowIntensity)); + g.drawEllipse (thumbX - thumbR, thumbY - thumbR, thumbR * 2, thumbR * 2, 0.8f); + + float hlR = thumbR * 0.4f; + g.setColour (juce::Colours::white.withAlpha (0.1f + 0.15f * glowIntensity)); + g.fillEllipse (thumbX - hlR, thumbY - thumbR * 0.6f - hlR * 0.3f, hlR * 2, hlR * 1.2f); + } +} + +// ============================================================ +// ComboBox style +// ============================================================ + +void InstaGrainLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, bool /*isButtonDown*/, + int /*buttonX*/, int /*buttonY*/, int /*buttonW*/, int /*buttonH*/, + juce::ComboBox& box) +{ + auto bounds = juce::Rectangle (0, 0, (float) width, (float) height); + + g.setColour (bgMedium); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (bgLight.withAlpha (0.5f)); + g.drawRoundedRectangle (bounds.reduced (0.5f), 4.0f, 1.0f); + + // Arrow + float arrowSize = height * 0.3f; + float arrowX = (float) width - height * 0.6f; + float arrowY = ((float) height - arrowSize) * 0.5f; + + juce::Path arrow; + arrow.addTriangle (arrowX, arrowY, + arrowX + arrowSize, arrowY, + arrowX + arrowSize * 0.5f, arrowY + arrowSize); + g.setColour (textSecondary); + g.fillPath (arrow); +} diff --git a/Source/LookAndFeel.h b/Source/LookAndFeel.h new file mode 100644 index 0000000..9c85fd7 --- /dev/null +++ b/Source/LookAndFeel.h @@ -0,0 +1,54 @@ +#pragma once +#include + +class InstaGrainLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + // Colour palette + static inline const juce::Colour bgDark { 0xff1a1a2e }; + static inline const juce::Colour bgMedium { 0xff16213e }; + static inline const juce::Colour bgLight { 0xff0f3460 }; + static inline const juce::Colour textPrimary { 0xffe0e0e0 }; + static inline const juce::Colour textSecondary { 0xff888899 }; + static inline const juce::Colour accent { 0xff00ff88 }; + + // Knob type property key + static constexpr const char* knobTypeProperty = "knobType"; + + InstaGrainLookAndFeel(); + + void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPosProportional, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) override; + + void drawButtonBackground (juce::Graphics& g, juce::Button& button, + const juce::Colour& backgroundColour, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) override; + + void drawToggleButton (juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) override; + + void drawComboBox (juce::Graphics& g, int width, int height, bool isButtonDown, + int buttonX, int buttonY, int buttonW, int buttonH, + juce::ComboBox& box) override; + + // Custom fonts + juce::Font getRegularFont (float height) const; + juce::Font getMediumFont (float height) const; + juce::Font getBoldFont (float height) const; + + // Background texture + void drawBackgroundTexture (juce::Graphics& g, juce::Rectangle area); + + juce::Typeface::Ptr getTypefaceForFont (const juce::Font& font) override; + +private: + juce::Typeface::Ptr typefaceRegular; + juce::Typeface::Ptr typefaceMedium; + juce::Typeface::Ptr typefaceBold; + + juce::Image noiseTexture; + void generateNoiseTexture(); +}; diff --git a/Source/MasterPanel.cpp b/Source/MasterPanel.cpp new file mode 100644 index 0000000..d969236 --- /dev/null +++ b/Source/MasterPanel.cpp @@ -0,0 +1,49 @@ +#include "MasterPanel.h" + +MasterPanel::MasterPanel() +{ + titleLabel.setText ("MASTER", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + volumeKnob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + volumeKnob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 55, 14); + volumeKnob.setRange (0.0, 2.0); + volumeKnob.setValue (1.0); + volumeKnob.getProperties().set (InstaGrainLookAndFeel::knobTypeProperty, "orange"); + addAndMakeVisible (volumeKnob); + + volLabel.setText ("Volume", juce::dontSendNotification); + volLabel.setJustificationType (juce::Justification::centred); + volLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (volLabel); + + addAndMakeVisible (vuMeter); +} + +void MasterPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + int halfW = bounds.getWidth() / 2; + + // Volume knob + auto knobArea = bounds.removeFromLeft (halfW); + auto knobRect = knobArea.withTrimmedBottom (16); + volumeKnob.setBounds (knobRect.reduced (2)); + volLabel.setBounds (knobArea.getX(), knobRect.getBottom() - 2, knobArea.getWidth(), 16); + + // VU meter + vuMeter.setBounds (bounds.reduced (8, 4)); +} + +void MasterPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/MasterPanel.h b/Source/MasterPanel.h new file mode 100644 index 0000000..7e50e36 --- /dev/null +++ b/Source/MasterPanel.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include "LookAndFeel.h" +#include "VuMeter.h" + +class MasterPanel : public juce::Component +{ +public: + MasterPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider volumeKnob; + VuMeter vuMeter; + +private: + juce::Label volLabel; + juce::Label titleLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MasterPanel) +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..a9eeae9 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,252 @@ +#include "PluginEditor.h" + +InstaGrainEditor::InstaGrainEditor (InstaGrainProcessor& p) + : AudioProcessorEditor (&p), processor (p) +{ + setLookAndFeel (&lookAndFeel); + setSize (900, 650); + setResizable (true, true); + setResizeLimits (700, 500, 1400, 1000); + + // Title + titleLabel.setText ("INSTAGRAIN", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::accent); + addAndMakeVisible (titleLabel); + + // Version + versionLabel.setText (kInstaGrainVersion, juce::dontSendNotification); + versionLabel.setJustificationType (juce::Justification::centredLeft); + versionLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (versionLabel); + + // Load button + loadButton.onClick = [this] + { + fileChooser = std::make_unique ( + "Load Sample", juce::File(), "*.wav;*.aif;*.aiff;*.mp3;*.flac;*.ogg"); + fileChooser->launchAsync (juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this] (const juce::FileChooser& fc) + { + auto file = fc.getResult(); + if (file.existsAsFile()) + loadSampleFile (file); + }); + }; + addAndMakeVisible (loadButton); + + // Bypass + bypassButton.setToggleState (processor.bypass.load(), juce::dontSendNotification); + addAndMakeVisible (bypassButton); + bypassLabel.setText ("BYPASS", juce::dontSendNotification); + bypassLabel.setJustificationType (juce::Justification::centredLeft); + bypassLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (bypassLabel); + + // Panels + addAndMakeVisible (waveformDisplay); + addAndMakeVisible (grainPanel); + addAndMakeVisible (scatterPanel); + addAndMakeVisible (envelopePanel); + addAndMakeVisible (effectsPanel); + addAndMakeVisible (masterPanel); + + // Waveform position callback + waveformDisplay.onPositionChanged = [this] (float pos) + { + processor.getEngine().position.store (pos); + grainPanel.positionKnob.setValue (pos, juce::dontSendNotification); + }; + + // Initialize waveform if sample already loaded + if (processor.getEngine().hasSample()) + waveformDisplay.setBuffer (&processor.getEngine().getSampleBuffer()); + + // Sync knobs from engine state + syncKnobsToEngine(); + + // Start timer (30 Hz) + startTimerHz (30); +} + +InstaGrainEditor::~InstaGrainEditor() +{ + setLookAndFeel (nullptr); + stopTimer(); +} + +void InstaGrainEditor::loadSampleFile (const juce::File& file) +{ + processor.getEngine().loadSample (file); + waveformDisplay.setBuffer (&processor.getEngine().getSampleBuffer()); +} + +void InstaGrainEditor::syncKnobsToEngine() +{ + auto& eng = processor.getEngine(); + grainPanel.positionKnob.setValue (eng.position.load(), juce::dontSendNotification); + grainPanel.sizeKnob.setValue (eng.grainSizeMs.load(), juce::dontSendNotification); + grainPanel.densityKnob.setValue (eng.density.load(), juce::dontSendNotification); + grainPanel.pitchKnob.setValue (eng.pitchSemitones.load(), juce::dontSendNotification); + grainPanel.panKnob.setValue (eng.pan.load(), juce::dontSendNotification); + grainPanel.rootNoteBox.setSelectedId (eng.rootNote.load() + 1, juce::dontSendNotification); + + scatterPanel.posScatterKnob.setValue (eng.posScatter.load(), juce::dontSendNotification); + scatterPanel.sizeScatterKnob.setValue (eng.sizeScatter.load(), juce::dontSendNotification); + scatterPanel.pitchScatterKnob.setValue (eng.pitchScatter.load(), juce::dontSendNotification); + scatterPanel.panScatterKnob.setValue (eng.panScatter.load(), juce::dontSendNotification); + scatterPanel.directionBox.setSelectedId (eng.direction.load() + 1, juce::dontSendNotification); + scatterPanel.freezeButton.setToggleState (eng.freeze.load(), juce::dontSendNotification); + + envelopePanel.attackKnob.setValue (eng.attackTime.load(), juce::dontSendNotification); + envelopePanel.decayKnob.setValue (eng.decayTime.load(), juce::dontSendNotification); + envelopePanel.sustainKnob.setValue (eng.sustainLevel.load(), juce::dontSendNotification); + envelopePanel.releaseKnob.setValue (eng.releaseTime.load(), juce::dontSendNotification); + + effectsPanel.filterTypeBox.setSelectedId (eng.filterType.load() + 1, juce::dontSendNotification); + effectsPanel.cutoffKnob.setValue (eng.filterCutoff.load(), juce::dontSendNotification); + effectsPanel.resoKnob.setValue (eng.filterReso.load(), juce::dontSendNotification); + effectsPanel.reverbSizeKnob.setValue (eng.reverbSize.load(), juce::dontSendNotification); + effectsPanel.reverbDecayKnob.setValue (eng.reverbDecay.load(), juce::dontSendNotification); + + masterPanel.volumeKnob.setValue (eng.masterVolume.load(), juce::dontSendNotification); + bypassButton.setToggleState (processor.bypass.load(), juce::dontSendNotification); +} + +void InstaGrainEditor::syncEngineFromKnobs() +{ + auto& eng = processor.getEngine(); + eng.position.store ((float) grainPanel.positionKnob.getValue()); + eng.grainSizeMs.store ((float) grainPanel.sizeKnob.getValue()); + eng.density.store ((float) grainPanel.densityKnob.getValue()); + eng.pitchSemitones.store ((float) grainPanel.pitchKnob.getValue()); + eng.pan.store ((float) grainPanel.panKnob.getValue()); + eng.rootNote.store (grainPanel.rootNoteBox.getSelectedId() - 1); + + eng.posScatter.store ((float) scatterPanel.posScatterKnob.getValue()); + eng.sizeScatter.store ((float) scatterPanel.sizeScatterKnob.getValue()); + eng.pitchScatter.store ((float) scatterPanel.pitchScatterKnob.getValue()); + eng.panScatter.store ((float) scatterPanel.panScatterKnob.getValue()); + eng.direction.store (scatterPanel.directionBox.getSelectedId() - 1); + eng.freeze.store (scatterPanel.freezeButton.getToggleState()); + + eng.attackTime.store ((float) envelopePanel.attackKnob.getValue()); + eng.decayTime.store ((float) envelopePanel.decayKnob.getValue()); + eng.sustainLevel.store ((float) envelopePanel.sustainKnob.getValue()); + eng.releaseTime.store ((float) envelopePanel.releaseKnob.getValue()); + + eng.filterType.store (effectsPanel.filterTypeBox.getSelectedId() - 1); + eng.filterCutoff.store ((float) effectsPanel.cutoffKnob.getValue()); + eng.filterReso.store ((float) effectsPanel.resoKnob.getValue()); + eng.reverbSize.store ((float) effectsPanel.reverbSizeKnob.getValue()); + eng.reverbDecay.store ((float) effectsPanel.reverbDecayKnob.getValue()); + + eng.masterVolume.store ((float) masterPanel.volumeKnob.getValue()); + processor.bypass.store (bypassButton.getToggleState()); +} + +void InstaGrainEditor::timerCallback() +{ + // GUI → Engine + syncEngineFromKnobs(); + + // Update waveform visualization + auto& eng = processor.getEngine(); + waveformDisplay.setGrainPosition (eng.position.load()); + waveformDisplay.setScatterRange (eng.posScatter.load()); + waveformDisplay.setActiveGrains (eng.getActiveGrainInfo()); + + // Update VU meter + masterPanel.vuMeter.setLevel (eng.vuLevelL.load(), eng.vuLevelR.load()); +} + +void InstaGrainEditor::paint (juce::Graphics& g) +{ + // Background + g.fillAll (InstaGrainLookAndFeel::bgDark); + lookAndFeel.drawBackgroundTexture (g, getLocalBounds()); + + // Top bar background + float scale = (float) getHeight() / 650.0f; + int topBarH = (int) (36.0f * scale); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.7f)); + g.fillRect (0, 0, getWidth(), topBarH); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawHorizontalLine (topBarH, 0, (float) getWidth()); +} + +void InstaGrainEditor::resized() +{ + auto bounds = getLocalBounds(); + float scale = (float) getHeight() / 650.0f; + + int topBarH = (int) (36.0f * scale); + int padding = (int) (6.0f * scale); + + // Top bar + auto topBar = bounds.removeFromTop (topBarH).reduced (padding, 0); + titleLabel.setFont (lookAndFeel.getBoldFont (20.0f * scale)); + titleLabel.setBounds (topBar.removeFromLeft ((int) (130 * scale))); + versionLabel.setFont (lookAndFeel.getRegularFont (13.0f * scale)); + versionLabel.setBounds (topBar.removeFromLeft ((int) (50 * scale))); + + // Right side: bypass + load + auto bypassArea = topBar.removeFromRight ((int) (30 * scale)); + bypassButton.setBounds (bypassArea); + bypassLabel.setFont (lookAndFeel.getRegularFont (11.0f * scale)); + bypassLabel.setBounds (topBar.removeFromRight ((int) (50 * scale))); + topBar.removeFromRight (padding); + loadButton.setBounds (topBar.removeFromRight ((int) (120 * scale)).reduced (0, 2)); + + auto content = bounds.reduced (padding); + + // Waveform display (~40%) + int waveH = (int) (content.getHeight() * 0.40f); + waveformDisplay.setBounds (content.removeFromTop (waveH)); + content.removeFromTop (padding); + + // Middle row: Grain + Scatter (~25%) + int midH = (int) (content.getHeight() * 0.45f); + auto midRow = content.removeFromTop (midH); + int halfW = midRow.getWidth() / 2; + grainPanel.setBounds (midRow.removeFromLeft (halfW - padding / 2)); + midRow.removeFromLeft (padding); + scatterPanel.setBounds (midRow); + content.removeFromTop (padding); + + // Bottom row: Envelope + Effects + Master + auto botRow = content; + int thirdW = botRow.getWidth() / 3; + envelopePanel.setBounds (botRow.removeFromLeft (thirdW - padding / 2).reduced (0, 0)); + botRow.removeFromLeft (padding); + effectsPanel.setBounds (botRow.removeFromLeft (thirdW - padding / 2).reduced (0, 0)); + botRow.removeFromLeft (padding); + masterPanel.setBounds (botRow); +} + +bool InstaGrainEditor::isInterestedInFileDrag (const juce::StringArray& files) +{ + for (auto& f : files) + { + auto ext = juce::File (f).getFileExtension().toLowerCase(); + if (ext == ".wav" || ext == ".aif" || ext == ".aiff" || ext == ".mp3" + || ext == ".flac" || ext == ".ogg") + return true; + } + return false; +} + +void InstaGrainEditor::filesDropped (const juce::StringArray& files, int /*x*/, int /*y*/) +{ + for (auto& f : files) + { + juce::File file (f); + auto ext = file.getFileExtension().toLowerCase(); + if (ext == ".wav" || ext == ".aif" || ext == ".aiff" || ext == ".mp3" + || ext == ".flac" || ext == ".ogg") + { + loadSampleFile (file); + break; + } + } +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..4ce5cfb --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include "PluginProcessor.h" +#include "LookAndFeel.h" +#include "WaveformDisplay.h" +#include "GrainControlPanel.h" +#include "ScatterPanel.h" +#include "EnvelopePanel.h" +#include "EffectsPanel.h" +#include "MasterPanel.h" + +static constexpr const char* kInstaGrainVersion = "v1.0"; + +class InstaGrainEditor : public juce::AudioProcessorEditor, + public juce::FileDragAndDropTarget, + public juce::Timer +{ +public: + explicit InstaGrainEditor (InstaGrainProcessor&); + ~InstaGrainEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + + // FileDragAndDropTarget + bool isInterestedInFileDrag (const juce::StringArray& files) override; + void filesDropped (const juce::StringArray& files, int x, int y) override; + + // Timer + void timerCallback() override; + +private: + InstaGrainProcessor& processor; + InstaGrainLookAndFeel lookAndFeel; + + // Top bar + juce::Label titleLabel; + juce::Label versionLabel; + juce::TextButton loadButton { "LOAD SAMPLE" }; + juce::ToggleButton bypassButton; + juce::Label bypassLabel; + + // Panels + WaveformDisplay waveformDisplay; + GrainControlPanel grainPanel; + ScatterPanel scatterPanel; + EnvelopePanel envelopePanel; + EffectsPanel effectsPanel; + MasterPanel masterPanel; + + std::unique_ptr fileChooser; + + void loadSampleFile (const juce::File& file); + void syncKnobsToEngine(); + void syncEngineFromKnobs(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaGrainEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..a0a8181 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,119 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +InstaGrainProcessor::InstaGrainProcessor() + : AudioProcessor (BusesProperties() + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) +{ +} + +InstaGrainProcessor::~InstaGrainProcessor() {} + +void InstaGrainProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + engine.prepare (sampleRate, samplesPerBlock); +} + +void InstaGrainProcessor::releaseResources() {} + +bool InstaGrainProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + return true; +} + +void InstaGrainProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + + if (bypass.load()) + { + buffer.clear(); + return; + } + + engine.processBlock (buffer, midiMessages); +} + +juce::AudioProcessorEditor* InstaGrainProcessor::createEditor() +{ + return new InstaGrainEditor (*this); +} + +void InstaGrainProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto xml = std::make_unique ("InstaGrainState"); + + xml->setAttribute ("samplePath", engine.getSamplePath()); + xml->setAttribute ("rootNote", engine.rootNote.load()); + xml->setAttribute ("position", (double) engine.position.load()); + xml->setAttribute ("grainSize", (double) engine.grainSizeMs.load()); + xml->setAttribute ("density", (double) engine.density.load()); + xml->setAttribute ("pitch", (double) engine.pitchSemitones.load()); + xml->setAttribute ("pan", (double) engine.pan.load()); + xml->setAttribute ("posScatter", (double) engine.posScatter.load()); + xml->setAttribute ("sizeScatter", (double) engine.sizeScatter.load()); + xml->setAttribute ("pitchScatter", (double) engine.pitchScatter.load()); + xml->setAttribute ("panScatter", (double) engine.panScatter.load()); + xml->setAttribute ("direction", engine.direction.load()); + xml->setAttribute ("freeze", engine.freeze.load()); + xml->setAttribute ("attack", (double) engine.attackTime.load()); + xml->setAttribute ("decay", (double) engine.decayTime.load()); + xml->setAttribute ("sustain", (double) engine.sustainLevel.load()); + xml->setAttribute ("release", (double) engine.releaseTime.load()); + xml->setAttribute ("filterType", engine.filterType.load()); + xml->setAttribute ("filterCutoff", (double) engine.filterCutoff.load()); + xml->setAttribute ("filterReso", (double) engine.filterReso.load()); + xml->setAttribute ("reverbSize", (double) engine.reverbSize.load()); + xml->setAttribute ("reverbDecay", (double) engine.reverbDecay.load()); + xml->setAttribute ("masterVolume", (double) engine.masterVolume.load()); + xml->setAttribute ("bypass", bypass.load()); + + copyXmlToBinary (*xml, destData); +} + +void InstaGrainProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + auto xml = getXmlFromBinary (data, sizeInBytes); + if (xml == nullptr || ! xml->hasTagName ("InstaGrainState")) + return; + + // Load sample + juce::String samplePath = xml->getStringAttribute ("samplePath", ""); + if (samplePath.isNotEmpty()) + { + juce::File f (samplePath); + if (f.existsAsFile()) + engine.loadSample (f); + } + + engine.rootNote.store (xml->getIntAttribute ("rootNote", 60)); + engine.position.store ((float) xml->getDoubleAttribute ("position", 0.5)); + engine.grainSizeMs.store ((float) xml->getDoubleAttribute ("grainSize", 100.0)); + engine.density.store ((float) xml->getDoubleAttribute ("density", 10.0)); + engine.pitchSemitones.store ((float) xml->getDoubleAttribute ("pitch", 0.0)); + engine.pan.store ((float) xml->getDoubleAttribute ("pan", 0.0)); + engine.posScatter.store ((float) xml->getDoubleAttribute ("posScatter", 0.0)); + engine.sizeScatter.store ((float) xml->getDoubleAttribute ("sizeScatter", 0.0)); + engine.pitchScatter.store ((float) xml->getDoubleAttribute ("pitchScatter", 0.0)); + engine.panScatter.store ((float) xml->getDoubleAttribute ("panScatter", 0.0)); + engine.direction.store (xml->getIntAttribute ("direction", 0)); + engine.freeze.store (xml->getBoolAttribute ("freeze", false)); + engine.attackTime.store ((float) xml->getDoubleAttribute ("attack", 0.01)); + engine.decayTime.store ((float) xml->getDoubleAttribute ("decay", 0.1)); + engine.sustainLevel.store ((float) xml->getDoubleAttribute ("sustain", 1.0)); + engine.releaseTime.store ((float) xml->getDoubleAttribute ("release", 0.3)); + engine.filterType.store (xml->getIntAttribute ("filterType", 0)); + engine.filterCutoff.store ((float) xml->getDoubleAttribute ("filterCutoff", 20000.0)); + engine.filterReso.store ((float) xml->getDoubleAttribute ("filterReso", 0.707)); + engine.reverbSize.store ((float) xml->getDoubleAttribute ("reverbSize", 0.0)); + engine.reverbDecay.store ((float) xml->getDoubleAttribute ("reverbDecay", 0.0)); + engine.masterVolume.store ((float) xml->getDoubleAttribute ("masterVolume", 1.0)); + bypass.store (xml->getBoolAttribute ("bypass", false)); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new InstaGrainProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..c6ddac2 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include "GrainEngine.h" + +class InstaGrainProcessor : public juce::AudioProcessor +{ +public: + InstaGrainProcessor(); + ~InstaGrainProcessor() 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 true; } + 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& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + GrainEngine& getEngine() { return engine; } + + std::atomic bypass { false }; + +private: + GrainEngine engine; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaGrainProcessor) +}; diff --git a/Source/ScatterPanel.cpp b/Source/ScatterPanel.cpp new file mode 100644 index 0000000..3a17dc1 --- /dev/null +++ b/Source/ScatterPanel.cpp @@ -0,0 +1,90 @@ +#include "ScatterPanel.h" + +ScatterPanel::ScatterPanel() +{ + titleLabel.setText ("SCATTER", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + setupKnob (posScatterKnob, posScatLabel, "Pos"); + setupKnob (sizeScatterKnob, sizeScatLabel, "Size"); + setupKnob (pitchScatterKnob, pitchScatLabel, "Pitch"); + setupKnob (panScatterKnob, panScatLabel, "Pan"); + + directionBox.addItem ("Forward", 1); + directionBox.addItem ("Reverse", 2); + directionBox.addItem ("PingPong", 3); + directionBox.setSelectedId (1); + addAndMakeVisible (directionBox); + + dirLabel.setText ("Direction", juce::dontSendNotification); + dirLabel.setJustificationType (juce::Justification::centred); + dirLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (dirLabel); + + freezeButton.setButtonText ("Freeze"); + addAndMakeVisible (freezeButton); + + freezeLabel.setText ("Freeze", juce::dontSendNotification); + freezeLabel.setJustificationType (juce::Justification::centred); + freezeLabel.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (freezeLabel); +} + +void ScatterPanel::setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name) +{ + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 50, 14); + knob.setRange (0.0, 1.0); + knob.setValue (0.0); + knob.getProperties().set (InstaGrainLookAndFeel::knobTypeProperty, "dark"); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaGrainLookAndFeel::textSecondary); + addAndMakeVisible (label); +} + +void ScatterPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + // Top row: 4 knobs + int knobW = bounds.getWidth() / 4; + int knobH = (bounds.getHeight() - 30) * 2 / 3; + auto topRow = bounds.removeFromTop (knobH); + + juce::Slider* knobs[] = { &posScatterKnob, &sizeScatterKnob, &pitchScatterKnob, &panScatterKnob }; + juce::Label* labels[] = { &posScatLabel, &sizeScatLabel, &pitchScatLabel, &panScatLabel }; + + for (int i = 0; i < 4; ++i) + { + auto col = topRow.removeFromLeft (knobW); + knobs[i]->setBounds (col.reduced (2)); + labels[i]->setBounds (col.getX(), col.getBottom() - 2, col.getWidth(), 16); + } + + // Bottom row: Direction combo + Freeze toggle + auto botRow = bounds.reduced (2); + int halfW = botRow.getWidth() / 2; + + auto dirArea = botRow.removeFromLeft (halfW); + dirLabel.setBounds (dirArea.removeFromTop (14)); + directionBox.setBounds (dirArea.reduced (4, 2)); + + auto freezeArea = botRow; + freezeLabel.setBounds (freezeArea.removeFromTop (14)); + freezeButton.setBounds (freezeArea.reduced (4, 2)); +} + +void ScatterPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaGrainLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/ScatterPanel.h b/Source/ScatterPanel.h new file mode 100644 index 0000000..7a0e1e3 --- /dev/null +++ b/Source/ScatterPanel.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class ScatterPanel : public juce::Component +{ +public: + ScatterPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider posScatterKnob, sizeScatterKnob, pitchScatterKnob, panScatterKnob; + juce::ComboBox directionBox; + juce::ToggleButton freezeButton; + +private: + juce::Label posScatLabel, sizeScatLabel, pitchScatLabel, panScatLabel; + juce::Label dirLabel, freezeLabel; + juce::Label titleLabel; + + void setupKnob (juce::Slider& knob, juce::Label& label, const juce::String& name); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ScatterPanel) +}; diff --git a/Source/VuMeter.h b/Source/VuMeter.h new file mode 100644 index 0000000..ba2503e --- /dev/null +++ b/Source/VuMeter.h @@ -0,0 +1,62 @@ +#pragma once +#include + +class VuMeter : public juce::Component +{ +public: + void setLevel (float left, float right) + { + if (left > peakL) peakL = left; + else peakL *= 0.995f; + if (right > peakR) peakR = right; + else peakR *= 0.995f; + + levelL = std::max (left, levelL * 0.85f); + levelR = std::max (right, levelR * 0.85f); + repaint(); + } + + void paint (juce::Graphics& g) override + { + auto bounds = getLocalBounds().toFloat().reduced (1); + float barGap = 2.0f; + float halfW = (bounds.getWidth() - barGap) / 2.0f; + auto leftBar = bounds.removeFromLeft (halfW); + bounds.removeFromLeft (barGap); + auto rightBar = bounds; + + drawBar (g, leftBar, levelL, peakL); + drawBar (g, rightBar, levelR, peakR); + } + +private: + float levelL = 0.0f, levelR = 0.0f; + float peakL = 0.0f, peakR = 0.0f; + + void drawBar (juce::Graphics& g, juce::Rectangle bar, float level, float peak) + { + g.setColour (juce::Colour (0xff111122)); + g.fillRoundedRectangle (bar, 2.0f); + + float displayLevel = std::pow (juce::jlimit (0.0f, 1.0f, level), 0.5f); + float h = bar.getHeight() * displayLevel; + auto filled = bar.withTop (bar.getBottom() - h); + + if (displayLevel < 0.6f) + g.setColour (juce::Colour (0xff00cc44)); + else if (displayLevel < 0.85f) + g.setColour (juce::Colour (0xffcccc00)); + else + g.setColour (juce::Colour (0xffff3333)); + + g.fillRoundedRectangle (filled, 2.0f); + + float displayPeak = std::pow (juce::jlimit (0.0f, 1.0f, peak), 0.5f); + if (displayPeak > 0.01f) + { + float peakY = bar.getBottom() - bar.getHeight() * displayPeak; + g.setColour (juce::Colours::white.withAlpha (0.8f)); + g.fillRect (bar.getX(), peakY, bar.getWidth(), 1.5f); + } + } +}; diff --git a/Source/WaveformDisplay.cpp b/Source/WaveformDisplay.cpp new file mode 100644 index 0000000..f84eece --- /dev/null +++ b/Source/WaveformDisplay.cpp @@ -0,0 +1,201 @@ +#include "WaveformDisplay.h" +#include "LookAndFeel.h" + +WaveformDisplay::WaveformDisplay() {} + +void WaveformDisplay::setBuffer (const juce::AudioBuffer* buffer) +{ + audioBuffer = buffer; + if (buffer != nullptr) + { + totalSourceSamples = buffer->getNumSamples(); + lastBufferSize = totalSourceSamples; + } + else + { + totalSourceSamples = 0; + lastBufferSize = 0; + } + pathDirty = true; + repaint(); +} + +void WaveformDisplay::setActiveGrains (const std::vector& grains) +{ + activeGrains = grains; + repaint(); +} + +void WaveformDisplay::resized() +{ + pathDirty = true; +} + +void WaveformDisplay::rebuildWaveformPath (juce::Rectangle bounds) +{ + cachedWaveformPath.clear(); + + if (audioBuffer == nullptr || audioBuffer->getNumSamples() == 0) + return; + + const int numSamples = audioBuffer->getNumSamples(); + const float width = bounds.getWidth(); + const float height = bounds.getHeight(); + const float midY = bounds.getCentreY(); + const float* data = audioBuffer->getReadPointer (0); + + // Build filled waveform path + int blockSize = std::max (1, numSamples / (int) width); + + // Top line (max values) + cachedWaveformPath.startNewSubPath (bounds.getX(), midY); + for (int x = 0; x < (int) width; ++x) + { + int sampleIndex = (int) ((float) x / width * (float) numSamples); + sampleIndex = juce::jlimit (0, numSamples - 1, sampleIndex); + + float maxVal = -1.0f; + for (int j = 0; j < blockSize && (sampleIndex + j) < numSamples; ++j) + maxVal = std::max (maxVal, data[sampleIndex + j]); + + float topY = midY - maxVal * (height * 0.45f); + cachedWaveformPath.lineTo ((float) x + bounds.getX(), topY); + } + + // Bottom line (min values, reversed) + for (int x = (int) width - 1; x >= 0; --x) + { + int sampleIndex = (int) ((float) x / width * (float) numSamples); + sampleIndex = juce::jlimit (0, numSamples - 1, sampleIndex); + + float minVal = 1.0f; + for (int j = 0; j < blockSize && (sampleIndex + j) < numSamples; ++j) + minVal = std::min (minVal, data[sampleIndex + j]); + + float botY = midY - minVal * (height * 0.45f); + cachedWaveformPath.lineTo ((float) x + bounds.getX(), botY); + } + + cachedWaveformPath.closeSubPath(); + lastWidth = getWidth(); + lastHeight = getHeight(); + pathDirty = false; +} + +void WaveformDisplay::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + + // Background gradient + { + juce::ColourGradient bgGrad (InstaGrainLookAndFeel::bgDark.darker (0.4f), 0, bounds.getY(), + InstaGrainLookAndFeel::bgDark.darker (0.2f), 0, bounds.getBottom(), false); + g.setGradientFill (bgGrad); + g.fillRoundedRectangle (bounds, 4.0f); + } + + // Border + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); + + // Grid lines + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.12f)); + for (int i = 1; i < 8; ++i) + { + float xLine = bounds.getX() + bounds.getWidth() * (float) i / 8.0f; + g.drawVerticalLine ((int) xLine, bounds.getY(), bounds.getBottom()); + } + + // Center line + g.setColour (InstaGrainLookAndFeel::bgLight.withAlpha (0.2f)); + float midY = bounds.getCentreY(); + g.drawHorizontalLine ((int) midY, bounds.getX(), bounds.getRight()); + + if (audioBuffer == nullptr || audioBuffer->getNumSamples() == 0) + { + // "Drop sample here" text + g.setColour (InstaGrainLookAndFeel::textSecondary); + g.setFont (16.0f); + g.drawText ("Drop a sample here or click Load Sample", bounds, juce::Justification::centred); + return; + } + + // Rebuild path if needed + if (pathDirty || lastWidth != getWidth() || lastHeight != getHeight() + || lastBufferSize != audioBuffer->getNumSamples()) + { + lastBufferSize = audioBuffer->getNumSamples(); + rebuildWaveformPath (bounds); + } + + // Scatter range highlight + if (scatterRange > 0.001f) + { + float posX = bounds.getX() + grainPosition * bounds.getWidth(); + float rangeW = scatterRange * bounds.getWidth(); + float left = std::max (bounds.getX(), posX - rangeW); + float right = std::min (bounds.getRight(), posX + rangeW); + + g.setColour (juce::Colour (0x1800ff88)); + g.fillRect (left, bounds.getY(), right - left, bounds.getHeight()); + } + + // Waveform fill + stroke + juce::Colour waveColour (0xffff8844); + g.setColour (waveColour.withAlpha (0.5f)); + g.fillPath (cachedWaveformPath); + g.setColour (waveColour.withAlpha (0.9f)); + g.strokePath (cachedWaveformPath, juce::PathStrokeType (1.0f)); + + // Active grain rectangles + if (totalSourceSamples > 0) + { + for (const auto& grain : activeGrains) + { + if (grain.startSample < 0) continue; + float gx = bounds.getX() + ((float) grain.startSample / (float) totalSourceSamples) * bounds.getWidth(); + float gw = ((float) grain.lengthSamples / (float) totalSourceSamples) * bounds.getWidth(); + gw = std::max (2.0f, gw); + + float alpha = 0.6f * (1.0f - grain.progress); // fade as grain progresses + g.setColour (InstaGrainLookAndFeel::accent.withAlpha (alpha)); + g.fillRect (gx, bounds.getY() + 2, gw, bounds.getHeight() - 4); + } + } + + // Position indicator line + { + float posX = bounds.getX() + grainPosition * bounds.getWidth(); + g.setColour (InstaGrainLookAndFeel::accent); + g.drawVerticalLine ((int) posX, bounds.getY(), bounds.getBottom()); + + // Glow + g.setColour (InstaGrainLookAndFeel::accent.withAlpha (0.3f)); + g.fillRect (posX - 2.0f, bounds.getY(), 5.0f, bounds.getHeight()); + } +} + +void WaveformDisplay::updatePositionFromMouse (const juce::MouseEvent& e) +{ + auto bounds = getLocalBounds().toFloat(); + float pos = (float) (e.x - bounds.getX()) / bounds.getWidth(); + pos = juce::jlimit (0.0f, 1.0f, pos); + grainPosition = pos; + + if (onPositionChanged) + onPositionChanged (pos); + + repaint(); +} + +void WaveformDisplay::mouseDown (const juce::MouseEvent& e) +{ + if (audioBuffer != nullptr && audioBuffer->getNumSamples() > 0) + updatePositionFromMouse (e); +} + +void WaveformDisplay::mouseDrag (const juce::MouseEvent& e) +{ + if (audioBuffer != nullptr && audioBuffer->getNumSamples() > 0) + updatePositionFromMouse (e); +} diff --git a/Source/WaveformDisplay.h b/Source/WaveformDisplay.h new file mode 100644 index 0000000..86c406c --- /dev/null +++ b/Source/WaveformDisplay.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include "GrainEngine.h" + +class WaveformDisplay : public juce::Component +{ +public: + WaveformDisplay(); + + void setBuffer (const juce::AudioBuffer* buffer); + void setGrainPosition (float pos) { grainPosition = pos; repaint(); } + void setScatterRange (float range) { scatterRange = range; repaint(); } + void setActiveGrains (const std::vector& grains); + + std::function onPositionChanged; + + void paint (juce::Graphics& g) override; + void resized() override; + void mouseDown (const juce::MouseEvent& e) override; + void mouseDrag (const juce::MouseEvent& e) override; + +private: + const juce::AudioBuffer* audioBuffer = nullptr; + juce::Path cachedWaveformPath; + bool pathDirty = true; + int lastWidth = 0, lastHeight = 0; + int lastBufferSize = 0; + + float grainPosition = 0.5f; + float scatterRange = 0.0f; + std::vector activeGrains; + int totalSourceSamples = 0; + + void rebuildWaveformPath (juce::Rectangle bounds); + void updatePositionFromMouse (const juce::MouseEvent& e); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WaveformDisplay) +};