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>
183 sor
5.6 KiB
C++
183 sor
5.6 KiB
C++
#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;
|
|
}
|