Files
InstaGrain/Source/GrainEngine.cpp
hariel1985 55b5f89ac5 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>
2026-03-26 17:26:06 +01:00

306 sor
9.0 KiB
C++

#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;
}