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>
306 sor
9.0 KiB
C++
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;
|
|
}
|