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>
253 sor
10 KiB
C++
253 sor
10 KiB
C++
#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;
|
|
}
|
|
}
|
|
}
|