hey, i would like to share this granulator with you
pros instead of using GrainBuf
:
- the grains are sub-sample accurate (no aliasing for high trigger rates)
- no static buffered windows, the shape of the stateless window functions can be modulated
- possibility of frequency trajectories per grain (PM/FM)
cons instead of using GrainBuf
:
- multichannel expansion is causing higher CPU
features:
- If you raise the
randomness
param (thinkDust
but with the awareness of time), therampToRandom
function creates linear ramps between 0 and 1 with random periods. This comes in handy to interrupt the periodicity of the metallic, comb like response when setting higher overlap values - The
overlapRange
param is based on a multichannel triggeredDwhite
, which let you set min and max values of random overlap values per grain - The
channelMask
function distributes each grain to its own audio channel, thepanMax
param gives you pan control over the stereo field. - The grain window is a stateless gaussian window with modulatable control over
skew
andindex
. - The modulation is pretty basic, you can modulate the position rate, which is the rate of scanning through the buffered waveform and you can modulate the grain rate, which is the playback rate of each grain, a snapshot taken from the current position of the buffered waveform. But you could easily add FM/PM per grain for example.
These are just basic concepts, which you can adjust to your liking
May the slope calculation of 32-bit floats be with you.
note: The round-robin method for distributing grains to separate audio channels, which is used here is not the optimal solution. But without the ability of single-sample feedback the only possible implementation for overlapping grains inside a SynthDef. Same is true for the rampToRandom
function, which would be better implemented with modulation of the rate of the grain scheduling ramp which samples and holds its own modulation via a derived trigger.
One problem when distributing overlapping grains with different durations round-robin across the channels is to prevent phase-distortion (this issue is inherent to the round-robin method). Therefore the maximum overlap possible is limited to maxOverlaps = min(overlaps, 2 ** randomness.neg * numChannels);
This means for higher randomness
values the amount of possible overlap is lower and vice versa. I think this is an acceptable tradeoff.
(
var rampToTrig = { |phase|
var history = Delay1.ar(phase);
var delta = (phase - history);
var sum = (phase + history);
var trig = (delta / sum).abs > 0.5;
Trig1.ar(trig, SampleDur.ir);
};
var rampToSlope = { |phase|
var history = Delay1.ar(phase);
var delta = (phase - history);
delta.wrap(-0.5, 0.5);
};
var multiChannelTrigger = { |numChannels, trig|
numChannels.collect{ |chan|
PulseDivider.ar(trig, numChannels, numChannels - 1 - chan);
};
};
var getSubSampleOffset = { |phase, trig|
var slope = rampToSlope.(phase);
var sampleCount = phase - (slope < 0) / slope;
Latch.ar(sampleCount, trig);
};
var accumulatorSubSample = { |trig, subSampleOffset|
var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
accum + subSampleOffset;
};
var multiChannelAccumulator = { |triggers, subSampleOffsets|
triggers.collect{ |localTrig, i|
var hasTriggered = PulseCount.ar(localTrig) > 0;
var localAccum = accumulatorSubSample.(localTrig, subSampleOffsets[i]);
localAccum * hasTriggered;
};
};
var multiChannelDemand = { |triggers, demandUgen, paramRange|
var demand = demandUgen;
triggers.collect{ |localTrig|
Demand.ar(localTrig, 0, demand)
}.linexp(0, 1, paramRange[0], paramRange[1]);
};
var channelMask = { |triggers, numChannels, channelMask, centerMask|
var panChannels = Array.series(numChannels, -1 / numChannels, 2 / numChannels).wrap(-1.0, 1.0);
var panPositions = panChannels.collect { |pos| Dser([pos], channelMask) };
Demand.ar(triggers, 0, Dseq(panPositions ++ Dser([0], centerMask), inf));
};
var transferFunc = { |phase, skew|
phase = phase.linlin(0, 1, skew.neg, 1 - skew);
phase.bilin(0, skew.neg, 1 - skew, 1, 0, 0);
};
var unitGaussian = { |phase, index|
var cosine = cos(phase * 0.5pi) * index;
exp(cosine.neg * cosine);
};
var unitHanning = { |phase|
1 - cos(phase * pi) / 2;
};
var gaussianWindow = { |phase, skew, index|
var warpedPhase = transferFunc.(phase, skew);
var gaussian = unitGaussian.(warpedPhase, index);
var hanning = unitHanning.(warpedPhase);
gaussian * hanning;
};
var getRandomPeriods = { |rate, randomness|
var randomPeriod = Ddup(2, (2 ** (Dwhite(-1.0, 1.0) * randomness))) / rate;
Duty.ar(randomPeriod, DC.ar(0), 1 / randomPeriod);
};
var rampToRandom = { |rate, randomness|
var randomPeriod = getRandomPeriods.(rate, randomness);
var trig = Changed.ar(randomPeriod) > 0;
var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
var phase = randomPeriod * SampleDur.ir * accum;
(phase - SampleDur.ir).wrap(0, 1);
};
SynthDef(\grains, { |sndBuf|
var numChannels = 10;
var randomness, stepPhase, stepTrigger, stepSlope, subSampleOffsets;
var triggers, accumulator, overlaps, maxOverlaps, chanMask;
var windowSlopes, windowPhases, grainWindows;
var posRate, posRateMod, pos;
var rate, rateMod, grainPhases;
var sigs, sig;
randomness = \randomness.kr(0.5);
stepPhase = rampToRandom.(\tFreq.kr(8), randomness);
stepTrigger = rampToTrig.(stepPhase);
stepSlope = rampToSlope.(stepPhase);
// distribute triggers round-robin across the channels
triggers = multiChannelTrigger.(numChannels, stepTrigger);
// calculate sub-sample offset per multichannel trigger
subSampleOffsets = getSubSampleOffset.(stepPhase, triggers);
// create a multichannel accumulator with sub-sample accuracy
accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);
overlaps = multiChannelDemand.(triggers, Dwhite(0, 1), \overlapRange.kr([1, 1]));
maxOverlaps = min(overlaps, 2 ** randomness.neg * numChannels);
windowSlopes = Latch.ar(stepSlope, triggers) / max(0.001, Latch.ar(maxOverlaps, triggers));
windowPhases = (windowSlopes * accumulator).clip(0, 1);
chanMask = channelMask.(triggers, numChannels - 1, \channelMask.kr(1), \centerMask.kr(1));
grainWindows = gaussianWindow.(windowPhases, \skew.kr(0.5), \index.kr(0));
posRateMod = SinOsc.ar(\posRateMF.kr(1));
posRate = \posRate.kr(1) + (posRateMod * \posRateMD.kr(0));
pos = Phasor.ar(
trig: DC.ar(0),
rate: posRate * BufRateScale.kr(sndBuf) * SampleDur.ir / BufDur.kr(sndBuf),
start: \posLo.kr(0),
end: \posHi.kr(1)
);
rateMod = SinOsc.ar(\rateMF.kr(1));
rate = \rate.kr(1) + (rateMod * \rateMD.kr(0));
grainPhases = (Latch.ar(rate, triggers) * accumulator) + Latch.ar(pos * BufFrames.kr(sndBuf), triggers);
sigs = BufRd.ar(
numChannels: 1,
bufnum: sndBuf,
phase: grainPhases,
loop: 1,
interpolation: 4
);
sigs = sigs * grainWindows;
sigs = PanAz.ar(2, sigs, chanMask * \panMax.kr(0.8));
sig = sigs.sum;
sig = sig * \amp.kr(-20).dbamp;
sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));
sig = LeakDC.ar(sig);
Out.ar(\out.kr(0), sig);
}).add;
)
~buffer = ... load your sample here ...
(
Synth(\grains, [
\tFreq, 10,
\randomness, 0,
\overlapRange, [1, 1],
\skew, 0.5,
\index, 0,
\rate, 1.0,
\rateMF, 1,
\rateMD, 0,
\posRate, 1.0,
\posRateMF, 1,
\posRateMD, 0,
\posLo, 0,
\posHi, 1,
\sndBuf, ~buffer,
\panMax, 0.8,
\channelMask, 1,
\centerMask, 1,
\amp, -20,
\out, 0,
]);
)