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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user