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) <noreply@anthropic.com>
This commit is contained in:
130
.github/workflows/build.yml
vendored
Normal file
130
.github/workflows/build.yml
vendored
Normal file
@@ -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
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build/
|
||||
65
CMakeLists.txt
Normal file
65
CMakeLists.txt
Normal file
@@ -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
|
||||
)
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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 <https://www.gnu.org/licenses/gpl-3.0.txt>
|
||||
34
README.md
Normal file
34
README.md
Normal file
@@ -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
|
||||
BINáris
Resources/Rajdhani-Bold.ttf
Normal file
BINáris
Resources/Rajdhani-Bold.ttf
Normal file
Binary file not shown.
BINáris
Resources/Rajdhani-Medium.ttf
Normal file
BINáris
Resources/Rajdhani-Medium.ttf
Normal file
Binary file not shown.
BINáris
Resources/Rajdhani-Regular.ttf
Normal file
BINáris
Resources/Rajdhani-Regular.ttf
Normal file
Binary file not shown.
93
Source/EffectsPanel.cpp
Normal file
93
Source/EffectsPanel.cpp
Normal file
@@ -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);
|
||||
}
|
||||
20
Source/EffectsPanel.h
Normal file
20
Source/EffectsPanel.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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)
|
||||
};
|
||||
63
Source/EnvelopePanel.cpp
Normal file
63
Source/EnvelopePanel.cpp
Normal file
@@ -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);
|
||||
}
|
||||
22
Source/EnvelopePanel.h
Normal file
22
Source/EnvelopePanel.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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)
|
||||
};
|
||||
45
Source/Grain.h
Normal file
45
Source/Grain.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
|
||||
// ============================================================
|
||||
// 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<float>::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;
|
||||
};
|
||||
182
Source/GrainCloud.cpp
Normal file
182
Source/GrainCloud.cpp
Normal file
@@ -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<float>& 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<float>& 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<float>& output, int numSamples,
|
||||
const juce::AudioBuffer<float>& 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<float>::pi);
|
||||
float rightGain = std::sin ((grain.panPosition + 1.0f) * 0.25f * juce::MathConstants<float>::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::GrainInfo, GrainCloud::maxGrains> GrainCloud::getActiveGrainInfo() const
|
||||
{
|
||||
std::array<GrainInfo, maxGrains> 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;
|
||||
}
|
||||
52
Source/GrainCloud.h
Normal file
52
Source/GrainCloud.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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<float>& output, int numSamples,
|
||||
const juce::AudioBuffer<float>& sourceBuffer);
|
||||
void reset();
|
||||
|
||||
// Parameters (atomic — GUI writes, audio reads)
|
||||
std::atomic<float> position { 0.5f }; // 0-1
|
||||
std::atomic<float> grainSizeMs { 100.0f }; // 10-500
|
||||
std::atomic<float> density { 10.0f }; // 1-100 grains/sec
|
||||
std::atomic<float> pitchSemitones { 0.0f }; // -24..+24
|
||||
std::atomic<float> pan { 0.0f }; // -1..+1
|
||||
std::atomic<float> posScatter { 0.0f }; // 0-1
|
||||
std::atomic<float> sizeScatter { 0.0f }; // 0-1
|
||||
std::atomic<float> pitchScatter { 0.0f }; // 0-1
|
||||
std::atomic<float> panScatter { 0.0f }; // 0-1
|
||||
std::atomic<int> direction { 0 }; // 0=Fwd, 1=Rev, 2=PingPong
|
||||
std::atomic<bool> 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<GrainInfo, maxGrains> getActiveGrainInfo() const;
|
||||
int getActiveGrainCount() const;
|
||||
|
||||
private:
|
||||
double currentSampleRate = 44100.0;
|
||||
std::array<Grain, maxGrains> grains;
|
||||
GrainWindow window;
|
||||
int samplesUntilNextGrain = 0;
|
||||
juce::Random rng;
|
||||
|
||||
void spawnGrain (const juce::AudioBuffer<float>& sourceBuffer);
|
||||
float readSampleInterpolated (const juce::AudioBuffer<float>& buffer, double position) const;
|
||||
};
|
||||
83
Source/GrainControlPanel.cpp
Normal file
83
Source/GrainControlPanel.cpp
Normal file
@@ -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);
|
||||
}
|
||||
24
Source/GrainControlPanel.h
Normal file
24
Source/GrainControlPanel.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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)
|
||||
};
|
||||
305
Source/GrainEngine.cpp
Normal file
305
Source/GrainEngine.cpp
Normal file
@@ -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<float> 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<float> 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<juce::MemoryInputStream> (data, dataSize, false);
|
||||
auto* reader = formatManager.createReaderFor (std::move (stream));
|
||||
if (reader == nullptr) return;
|
||||
|
||||
juce::AudioBuffer<float> tempBuffer ((int) reader->numChannels, (int) reader->lengthInSamples);
|
||||
reader->read (&tempBuffer, 0, (int) reader->lengthInSamples, 0, true, true);
|
||||
|
||||
if (tempBuffer.getNumChannels() > 1)
|
||||
{
|
||||
juce::AudioBuffer<float> 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<float>& 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<float> block (buffer);
|
||||
juce::dsp::ProcessContextReplacing<float> 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::ActiveGrainInfo> GrainEngine::getActiveGrainInfo() const
|
||||
{
|
||||
std::vector<ActiveGrainInfo> 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;
|
||||
}
|
||||
86
Source/GrainEngine.h
Normal file
86
Source/GrainEngine.h
Normal file
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#include "GrainVoice.h"
|
||||
|
||||
class GrainEngine
|
||||
{
|
||||
public:
|
||||
static constexpr int maxVoices = 8;
|
||||
|
||||
GrainEngine();
|
||||
|
||||
void prepare (double sampleRate, int samplesPerBlock);
|
||||
void processBlock (juce::AudioBuffer<float>& 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<float>& 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<int> rootNote { 60 };
|
||||
|
||||
// Grain parameters (GUI → audio)
|
||||
std::atomic<float> position { 0.5f };
|
||||
std::atomic<float> grainSizeMs { 100.0f };
|
||||
std::atomic<float> density { 10.0f };
|
||||
std::atomic<float> pitchSemitones { 0.0f };
|
||||
std::atomic<float> pan { 0.0f };
|
||||
std::atomic<float> posScatter { 0.0f };
|
||||
std::atomic<float> sizeScatter { 0.0f };
|
||||
std::atomic<float> pitchScatter { 0.0f };
|
||||
std::atomic<float> panScatter { 0.0f };
|
||||
std::atomic<int> direction { 0 };
|
||||
std::atomic<bool> freeze { false };
|
||||
|
||||
// ADSR
|
||||
std::atomic<float> attackTime { 0.01f };
|
||||
std::atomic<float> decayTime { 0.1f };
|
||||
std::atomic<float> sustainLevel { 1.0f };
|
||||
std::atomic<float> releaseTime { 0.3f };
|
||||
|
||||
// Filter
|
||||
std::atomic<int> filterType { 0 }; // 0=LP, 1=HP, 2=BP
|
||||
std::atomic<float> filterCutoff { 20000.0f };
|
||||
std::atomic<float> filterReso { 0.707f };
|
||||
|
||||
// Reverb
|
||||
std::atomic<float> reverbSize { 0.0f };
|
||||
std::atomic<float> reverbDecay { 0.0f };
|
||||
|
||||
// Master
|
||||
std::atomic<float> masterVolume { 1.0f };
|
||||
|
||||
// VU meter levels (audio → GUI)
|
||||
std::atomic<float> vuLevelL { 0.0f };
|
||||
std::atomic<float> vuLevelR { 0.0f };
|
||||
|
||||
// Grain visualization
|
||||
struct ActiveGrainInfo { int startSample; int lengthSamples; float progress; };
|
||||
std::vector<ActiveGrainInfo> getActiveGrainInfo() const;
|
||||
|
||||
private:
|
||||
std::array<GrainVoice, maxVoices> voices;
|
||||
juce::AudioBuffer<float> sampleBuffer;
|
||||
juce::String loadedSamplePath;
|
||||
double currentSampleRate = 44100.0;
|
||||
double sourceSampleRate = 44100.0;
|
||||
int currentBlockSize = 512;
|
||||
|
||||
juce::AudioFormatManager formatManager;
|
||||
|
||||
// Global effects
|
||||
juce::dsp::StateVariableTPTFilter<float> 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<bool, maxVoices> sustainedVoices {}; // voices held by pedal
|
||||
};
|
||||
78
Source/GrainVoice.cpp
Normal file
78
Source/GrainVoice.cpp
Normal file
@@ -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<float>& output, int numSamples,
|
||||
const juce::AudioBuffer<float>& sourceBuffer)
|
||||
{
|
||||
if (! voiceActive) return;
|
||||
|
||||
// Render cloud into temp buffer
|
||||
juce::AudioBuffer<float> voiceBuffer (output.getNumChannels(), numSamples);
|
||||
voiceBuffer.clear();
|
||||
|
||||
cloud.processBlock (voiceBuffer, numSamples, sourceBuffer);
|
||||
|
||||
// Compute ADSR envelope once per sample (not per channel!)
|
||||
std::vector<float> 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();
|
||||
}
|
||||
}
|
||||
40
Source/GrainVoice.h
Normal file
40
Source/GrainVoice.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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<float>& output, int numSamples,
|
||||
const juce::AudioBuffer<float>& 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<int> rootNote { 60 };
|
||||
|
||||
// ADSR parameters (set from processor)
|
||||
std::atomic<float> attackTime { 0.01f };
|
||||
std::atomic<float> decayTime { 0.1f };
|
||||
std::atomic<float> sustainLevel { 1.0f };
|
||||
std::atomic<float> 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;
|
||||
};
|
||||
372
Source/LookAndFeel.cpp
Normal file
372
Source/LookAndFeel.cpp
Normal file
@@ -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<int> 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<int> (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<float> (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<float> (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);
|
||||
}
|
||||
54
Source/LookAndFeel.h
Normal file
54
Source/LookAndFeel.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
|
||||
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<int> 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();
|
||||
};
|
||||
49
Source/MasterPanel.cpp
Normal file
49
Source/MasterPanel.cpp
Normal file
@@ -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);
|
||||
}
|
||||
21
Source/MasterPanel.h
Normal file
21
Source/MasterPanel.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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)
|
||||
};
|
||||
252
Source/PluginEditor.cpp
Normal file
252
Source/PluginEditor.cpp
Normal file
@@ -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<juce::FileChooser> (
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Source/PluginEditor.h
Normal file
58
Source/PluginEditor.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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<juce::FileChooser> fileChooser;
|
||||
|
||||
void loadSampleFile (const juce::File& file);
|
||||
void syncKnobsToEngine();
|
||||
void syncEngineFromKnobs();
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaGrainEditor)
|
||||
};
|
||||
119
Source/PluginProcessor.cpp
Normal file
119
Source/PluginProcessor.cpp
Normal file
@@ -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<float>& 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<juce::XmlElement> ("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();
|
||||
}
|
||||
43
Source/PluginProcessor.h
Normal file
43
Source/PluginProcessor.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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<float>&, 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<bool> bypass { false };
|
||||
|
||||
private:
|
||||
GrainEngine engine;
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaGrainProcessor)
|
||||
};
|
||||
90
Source/ScatterPanel.cpp
Normal file
90
Source/ScatterPanel.cpp
Normal file
@@ -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);
|
||||
}
|
||||
24
Source/ScatterPanel.h
Normal file
24
Source/ScatterPanel.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#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)
|
||||
};
|
||||
62
Source/VuMeter.h
Normal file
62
Source/VuMeter.h
Normal file
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
|
||||
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<float> 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
201
Source/WaveformDisplay.cpp
Normal file
201
Source/WaveformDisplay.cpp
Normal file
@@ -0,0 +1,201 @@
|
||||
#include "WaveformDisplay.h"
|
||||
#include "LookAndFeel.h"
|
||||
|
||||
WaveformDisplay::WaveformDisplay() {}
|
||||
|
||||
void WaveformDisplay::setBuffer (const juce::AudioBuffer<float>* 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<GrainEngine::ActiveGrainInfo>& grains)
|
||||
{
|
||||
activeGrains = grains;
|
||||
repaint();
|
||||
}
|
||||
|
||||
void WaveformDisplay::resized()
|
||||
{
|
||||
pathDirty = true;
|
||||
}
|
||||
|
||||
void WaveformDisplay::rebuildWaveformPath (juce::Rectangle<float> 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);
|
||||
}
|
||||
38
Source/WaveformDisplay.h
Normal file
38
Source/WaveformDisplay.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
#include "GrainEngine.h"
|
||||
|
||||
class WaveformDisplay : public juce::Component
|
||||
{
|
||||
public:
|
||||
WaveformDisplay();
|
||||
|
||||
void setBuffer (const juce::AudioBuffer<float>* buffer);
|
||||
void setGrainPosition (float pos) { grainPosition = pos; repaint(); }
|
||||
void setScatterRange (float range) { scatterRange = range; repaint(); }
|
||||
void setActiveGrains (const std::vector<GrainEngine::ActiveGrainInfo>& grains);
|
||||
|
||||
std::function<void (float)> 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<float>* 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<GrainEngine::ActiveGrainInfo> activeGrains;
|
||||
int totalSourceSamples = 0;
|
||||
|
||||
void rebuildWaveformPath (juce::Rectangle<float> bounds);
|
||||
void updatePositionFromMouse (const juce::MouseEvent& e);
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WaveformDisplay)
|
||||
};
|
||||
Reference in New Issue
Block a user