A collection of functions for sub-sample accurate granulation

hey, here is a collection of functions, which are handy for sub-sample accurate granulation. You can already find a collection of unit shapers in this post A collection of unit shapers. These are meant to be used together.

To use them you need the ProtoDef class by @elgiano GitHub - elgiano/ProtoDef: Prototyping classes for SuperCollider.

You can find some examples of possible use cases below:

(
ProtoDef(\grainFunctions) {

	~init = { |self|

		self.helperFunctions = IdentityDictionary.new();
		self.multiChannel = IdentityDictionary.new();
		self.oneShotRamps = IdentityDictionary.new();
		self.masking = IdentityDictionary.new();

		self.getHelperFunctions;
		self.getMultiChannelFunctions;
		self.getOneShotRampFunctions;
		self.getMaskingFunctions;

	};

	~getHelperFunctions = { |self|

		var rampToSlope = { |phase|
			var history = Delay1.ar(phase);
			var delta = (phase - history);
			delta.wrap(-0.5, 0.5);
		};

		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 getSubSampleOffset = { |phase, slope, trig|
			var sampleCount = phase - (slope < 0) / slope;
			Latch.ar(sampleCount, trig);
		};

		var accumulatorSubSample = { |trig, subSampleOffset|
			var hasTriggered = PulseCount.ar(trig) > 0;
			var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
			accum + subSampleOffset;
		};

		self.helperFunctions.put(\rampToSlope, rampToSlope);
		self.helperFunctions.put(\rampToTrig, rampToTrig);
		self.helperFunctions.put(\subSampleOffset, getSubSampleOffset);
		self.helperFunctions.put(\accumSubSample, accumulatorSubSample);

	};

	~getMultiChannelFunctions = { |self|

		var multiChannelTrigger = { |numChannels, trig|
			numChannels.collect{ |chan|
				PulseDivider.ar(trig, numChannels, numChannels - 1 - chan);
			};
		};

		var multiChannelAccumulator = { |triggers, subSampleOffsets|
			triggers.collect{ |localTrig, i|
				self.helperFunctions[\accumSubSample].(localTrig, subSampleOffsets[i]);
			};
		};

		var multiChannelDwhite = { |triggers|
			var demand = Dwhite(-1.0, 1.0);
			triggers.collect{ |localTrig|
				Demand.ar(localTrig, DC.ar(0), demand)
			};
		};

		var multiChannelDseq = { |triggers, reset, arrayOfItems, numOfItems, repeatItem|
			var demand = Ddup(repeatItem, Dseq([Dser(arrayOfItems, numOfItems)], inf));
			triggers.collect{ |localTrig|
				Demand.ar(localTrig + reset, reset, demand)
			};
		};

		var multiChannelBufRd = { |triggers, phase, arrayOfBuffers, numOfBuffers, repeatBuffer|
			var demand = Ddup(repeatBuffer, Dseq([Dseries(0, 1, numOfBuffers)], inf));
			var bufferIndex = triggers.collect{ |localTrig|
				Demand.ar(localTrig, DC.ar(0), demand);
			};
			var playbufs = arrayOfBuffers.collect{ |buffer|
				BufRd.ar(1, buffer, phase * BufFrames.kr(buffer), loop: 1, interpolation: 4);
			};
			Select.ar(bufferIndex, playbufs);
		};

		self.multiChannel.put(\trigger, multiChannelTrigger);
		self.multiChannel.put(\accumSubSample, multiChannelAccumulator);
		self.multiChannel.put(\dwhite, multiChannelDwhite);
		self.multiChannel.put(\dseq, multiChannelDseq);
		self.multiChannel.put(\bufrd, multiChannelBufRd);

	};

	~getOneShotRampFunctions = { |self|

		var getSubDivs = { |trig, arrayOfSubDivs, numOfSubDivs, duration|
			var hasTriggered = PulseCount.ar(trig) > 0;
			var subDiv = Ddup(2, Dseq(arrayOfSubDivs, numOfSubDivs)) * duration;
			Duty.ar(subDiv, trig, subDiv) * hasTriggered;
		};

		var rampOneShot = { |trig, duration, cycles = 1|
			var hasTriggered = PulseCount.ar(trig) > 0;
			var phase = Sweep.ar(trig, 1 / duration).clip(0, cycles);
			phase * hasTriggered;
		};

		var oneShotRampToTrig = { |phase|
			var compare = phase > 0;
			var delta = HPZ1.ar(compare);
			delta > 0;
		};

		var oneShotBurstToTrig = { |phaseScaled|
			var phaseStepped = phaseScaled.ceil;
			var delta = HPZ1.ar(phaseStepped);
			delta > 0;
		};

		self.oneShotRamps.put(\subDivs, getSubDivs);
		self.oneShotRamps.put(\rampOneShot, rampOneShot);
		self.oneShotRamps.put(\rampToTrig, oneShotRampToTrig);
		self.oneShotRamps.put(\burstToTrig, oneShotBurstToTrig);

	};

	~getMaskingFunctions = { |self|

		var triggerMask = { |trig, maskOn, arrayOfBinaries, numOfBinaries|
			var demand = Dseq([Dser(arrayOfBinaries, numOfBinaries)], inf);
			var triggerMask = Demand.ar(trig, DC.ar(0), demand);
			trig * Select.ar(maskOn, [K2A.ar(1), triggerMask]);
		};

		var burstMask = { |trig, burst = 16, rest = 0|
			var demand = Dseq([Dser([1], burst), Dser([0], rest)], inf);
			trig * Demand.ar(trig, DC.ar(0), demand);
		};

		var probabilityMask = { |trig, prob = 1|
			trig * CoinGate.ar(prob, trig);
		};

		var channelMask = { |trig, channelMask, centerMask, numSpeakers = 2|
			var arrayOfPositions = Array.series(numSpeakers, -1 / numSpeakers, 2 / numSpeakers).wrap(-1.0, 1.0);
			var channelPos = Dswitch1(arrayOfPositions,
				Dser(Dseries(0, 1, numSpeakers), channelMask)
			);
			Demand.ar(trig, DC.ar(0), Dseq([channelPos, Dser([0], centerMask)], inf));
		};

		self.masking.put(\trigger, triggerMask);
		self.masking.put(\burst, burstMask);
		self.masking.put(\probability, probabilityMask);
		self.masking.put(\channel, channelMask);

	};

};
)

~grainFunctions = Prototype(\grainFunctions);
2 Likes

Here is one example for a possible SynthDef for sub-sample accurate granulation, using both of these collections of functions.
The granulation SynthDef is also using the VariableRamp class from Oversampling Oscillators, which is important to make sure your scheduling ramps are continuous, linear ramps between 0 and 1, while beeing modulated by an LFO or something else. Beside sub-sample accuracy this setup allows for additional phase modulation or phase shaping for frequency trajectories per grain and modulation of the trigger rate with quite a high modulational index for ratchets, without truncating or distorting the phase of your grain windows. This is a universal template which can be adjusted for all types of microsound like pulsar synthesis, glisson synthesis etc…

(
SynthDef(\grains, { |sndBuf|

	var numChannels = 5;

	var tFreqMD, tFreqMod, tFreq;
	var stepTrigger, stepPhase, stepSlope;
	var subSampleOffsets, triggers, accumulator;
	var overlapMod, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var posRate, posRateMod, pos;
	var rate, rateMod, grainPhases;
	var panMF, panMod, pan;
	var sigs, sig;

	tFreqMD = \tFreqMD.kr(0, spec: ControlSpec(0, 2));
	tFreqMod = LFDNoise1.ar(\tFreqMF.kr(1, spec: ControlSpec(0.1, 5)));

	tFreq = \tFreq.kr(1, spec: ControlSpec(1, 200, \exp));
	tFreq = tFreq * (2 ** (tFreqMod * tFreqMD));

	stepPhase = VariableRamp.ar(tFreq);
	stepSlope = ~grainFunctions.helperFunctions[\rampToSlope].(stepPhase);

	stepPhase = Delay1.ar(stepPhase);
	stepTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(stepPhase);

	///////////////////////////////////////////////////////////////////////////////////

	// distribute triggers round-robin across the channels
	triggers = ~grainFunctions.multiChannel[\trigger].(numChannels, stepTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = ~grainFunctions.helperFunctions[\subSampleOffset].(stepPhase, stepSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = ~grainFunctions.multiChannel[\accumSubSample].(triggers, subSampleOffsets);

	///////////////////////////////////////////////////////////////////////////////////

	overlap = \overlap.kr(1, spec: ControlSpec(0.125, numChannels));
	overlapMod = ~grainFunctions.multiChannel[\dwhite].(triggers);
	overlap = overlap * (2 ** (overlapMod * \overlapMD.kr(0, spec: ControlSpec(0, 1))));

	maxOverlap = min(overlap, 2 ** tFreqMD.neg * numChannels);

	windowSlopes = stepSlope / max(0.001, maxOverlap);
	windowPhases = (Latch.ar(windowSlopes, triggers) * accumulator).clip(0, 1);

	///////////////////////////////////////////////////////////////////////////////////

	grainWindows = ~unitShapers.windowFunctions[\tukey].(
		windowPhases,
		\windowSkew.kr(0.5, spec: ControlSpec(0, 1)),
		\windowWidth.kr(0, spec: ControlSpec(0, 0.99)),
	);

	///////////////////////////////////////////////////////////////////////////////////

	posRateMod = SinOsc.ar(\posRateMF.kr(1, spec: ControlSpec(0.01, 1)));
	posRateMod = posRateMod * \posRateMD.kr(0, spec: ControlSpec(0, 2));
	posRate = \posRate.kr(1, spec: ControlSpec(0.125, 4)) + posRateMod;

	pos = Phasor.ar(
		trig: DC.ar(0),
		rate: posRate * BufRateScale.kr(sndBuf) * SampleDur.ir / BufDur.kr(sndBuf),
		start: \posLo.kr(0, spec: ControlSpec(0, 1)),
		end: \posHi.kr(1, spec: ControlSpec(0, 1))
	);

	///////////////////////////////////////////////////////////////////////////////////

	rateMod = SinOsc.ar(\rateMF.kr(1, spec: ControlSpec(0.01, 1)));
	rateMod = rateMod * \rateMD.kr(0, spec: ControlSpec(0, 2));
	rate = \rate.kr(1, spec: ControlSpec(0.125, 4)) + rateMod;

	///////////////////////////////////////////////////////////////////////////////////

	grainPhases = Latch.ar(rate, triggers) * accumulator;
	grainPhases = grainPhases + Latch.ar(pos * BufFrames.kr(sndBuf), triggers);

	sigs = BufRd.ar(
		numChannels: 1,
		bufnum: sndBuf,
		phase: grainPhases,
		loop: 1,
		interpolation: 4
	);

	sigs = sigs * grainWindows;

	///////////////////////////////////////////////////////////////////////////////////

	// panning
	panMF = \panMF.kr(0.3, spec: ControlSpec(0.01, 1));
	panMod = { |phase|
		SinOsc.ar(panMF, phase * pi)
	};

	pan = ~unitShapers.helperFunctions[\modScaleBipolar].(
		modulator: panMod.(0.5),
		value: \pan.kr(0.5, spec: ControlSpec(0, 1)),
		amount: \panMD.kr(0, spec: ControlSpec(0, 1)),
		direction: \center
	);

	sigs = PanAz.ar(2, sigs, pan.linlin(0, 1, -0.5, 0.5));
	sig = sigs.sum;

	sig = sig * ~unitShapers.helperFunctions[\modScaleBipolar].(
		modulator: SinOsc.ar(\ampMF.kr(5, spec: ControlSpec(1, 10))),
		value: \amp.kr(-25, spec: ControlSpec(-35, -5)).dbamp,
		amount: \ampMD.kr(0, spec: ControlSpec(0, 1)),
		direction: \floor
	);

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

	sig = LeakDC.ar(sig);
	sig = Limiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)
3 Likes

Here is a demonstration of the one-shot functions you find in the collection for a hybrid approach of server and language side sequencing, perfectly in sync.
You send a “measure trigger” from the language, either by Pmono or a Routine and prepare an array of normalized subdivisions per measure trigger. The measure phase gets subdivided by this array of normalized subdivisions (note that ramp division is possible with non-integer division ratios) to get sub-sample accurate ramps and audio triggers per event. The event phases will be continous, linear ramps between 0 and 1 with full legato (this is not possible with trigger based scheduling using Impulse, Dust etc.) and the triggers derived from these phases are sub-sample accurate audio triggers, so you could also do audio rate ratching (see last example) from the language.

(
SynthDef(\oneShot, {

	var numChannels = 5;

	var initTrigger, duration, stepsPerMeasure, measurePhase, measureTrigger;
	var numOfSubDivs, arrayOfSubDivs, seqOfSubDivs;
	var stepPhase, stepPhaseScaled, stepSlope, stepTrigger;

	var triggers, subSampleOffsets, accumulator, chanMask;
	var overlapMod, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var grainSlope, grainPhases;
	var sigs, sig;

	initTrigger = Trig1.ar(\trig.tr(0), SampleDur.ir);
	duration = \sustain.kr(1);
	arrayOfSubDivs = \arrayOfSubDivs.kr(Array.fill(100, 1));
	numOfSubDivs = \numOfSubDivs.kr(12);

	//initTrigger.poll(initTrigger, \initTrig);

	measurePhase = ~grainFunctions.oneShotRamps[\rampOneShot].(initTrigger, duration);
	measureTrigger = ~grainFunctions.oneShotRamps[\rampToTrig].(measurePhase);

	seqOfSubDivs = ~grainFunctions.oneShotRamps[\subDivs].(initTrigger, arrayOfSubDivs, numOfSubDivs, duration);
	stepPhaseScaled = ~grainFunctions.oneShotRamps[\rampOneShot].(initTrigger, seqOfSubDivs, numOfSubDivs);

	stepTrigger = ~grainFunctions.oneShotRamps[\burstToTrig].(stepPhaseScaled);

	stepPhase = stepPhaseScaled.wrap(0, 1);
	stepSlope = ~grainFunctions.helperFunctions[\rampToSlope].(stepPhase);

	///////////////////////////////////////////////////////////////////////////////////

	// distribute triggers round-robin across the channels
	triggers = ~grainFunctions.multiChannel[\trigger].(numChannels, stepTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = ~grainFunctions.helperFunctions[\subSampleOffset].(stepPhase, stepSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = ~grainFunctions.multiChannel[\accumSubSample].(triggers, subSampleOffsets);

	// add channel mask
	chanMask = ~grainFunctions.masking[\channel].(triggers, \channelMask.kr(1), \centerMask.kr(1));

	/////////////////////////////////////////////////////////////////////////////

	overlapMod = ~grainFunctions.multiChannel[\dwhite].(triggers);
	overlap = \overlap.kr(1) * (2 ** (overlapMod * \overlapMD.kr(0)));
	maxOverlap = min(overlap, numChannels);

	windowSlopes = Latch.ar(stepSlope, triggers) / max(0.001, Latch.ar(maxOverlap, triggers));
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	
	grainWindows = ~unitShapers.windowFunctions[\exponential].(
		windowPhases,
		\windowSkew.kr(0.01),
		\windowShape.kr(0.99)
	);
	
	/////////////////////////////////////////////////////////////////////////////

	grainSlope = \freq.kr(440) * SampleDur.ir;
	grainPhases = (grainSlope * accumulator).wrap(0, 1);
	sigs = sin(grainPhases * 2pi);

	sigs = sigs * grainWindows;

	sigs = PanAz.ar(2, sigs, chanMask * \panMax.kr(0.8));
	sig = sigs.sum;

	sig = sig * \amp.kr(-15).dbamp;

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

	sig = LeakDC.ar(sig);
	sig = Limiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

/////////////////////////////////////////////////////////////////////////////////////////

// pmono

(
//var arrayOfSubDivs = Array.fill(12, { 2 ** rrand(-1.0, 1.0) } ).normalizeSum;
//arrayOfSubDivs.debug(\arrayOfSubDivs);

var arrayOfSubDivs = [0.090043825988599, 0.10094508851196, 0.059173625247572, 0.078758510686157, 0.057549414766475, 0.085870514810413, 0.10684511179581, 0.082087147023147, 0.1207103511832, 0.047718306079275, 0.12704484171827, 0.043253262189117];
var numOfSubDivs = arrayOfSubDivs.size;

Pdef(\oneShot,
	Pmono(\oneShot,

		\trig, 1,
		\legato, 0.8,
		\dur, 4,

		\freq, 440,
		\overlap, 1,

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		\arrayOfSubDivs, [arrayOfSubDivs],
		\numOfSubDivs, numOfSubDivs,

		\amp, -15,
		\out, 0,

	),
).play;
)

/////////////////////////////////////////////////////////////////////////////////////////

// Routines

(
//var arrayOfSubDivs = Array.fill(12, { 2 ** rrand(-1.0, 1.0) } ).normalizeSum;
//arrayOfSubDivs.debug(\arrayOfSubDivs);

var arrayOfSubDivs = [0.090043825988599, 0.10094508851196, 0.059173625247572, 0.078758510686157, 0.057549414766475, 0.085870514810413, 0.10684511179581, 0.082087147023147, 0.1207103511832, 0.047718306079275, 0.12704484171827, 0.043253262189117];
var numOfSubDivs = arrayOfSubDivs.size;

Routine({

	s.bind {

		~synth = Synth(\oneShot, [

			\trig, 0,

			\freq, 440,
			\overlap, 1,

			\amp, -15,
			\out, 0,

		]);

	};

	s.sync;

	loop {

		s.bind {

			~synth.set(
				\trig, 1,
				\sustain, 3.2,
				\arrayOfSubDivs, arrayOfSubDivs,
				\numOfSubDivs, numOfSubDivs,
			);

		};

		4.wait;

	};

}).play;
)

/////////////////////////////////////////////////////////////////////////////////////////

// ratchets

(
var coreQuintic = { |x|
	x * x * x * x * x;
};

var outQuintic = { |x|
	1 - coreQuintic.(1 - x);
};

var inOutQuintic = { |x|
	if(x > 0.5) {
		1 - (coreQuintic.(2 * (1 - x)) / 2)
	} {
		coreQuintic.(2 * x) / 2
	};
};

var outInQuintic = { |x|
	if(x > 0.5) {
		(1 - coreQuintic.(1 - (x * 2))) / 2
	} {
		(1 + coreQuintic.((x * 2) - 1)) / 2
	};
};

var quinticOutToLinear = { |x, shape|
	var mix = shape * 2;
	var easeOut = outQuintic.(x);
	easeOut * (1 - mix) + (x * mix);
};

var linearToQuinticIn = { |x, shape|
	var mix = (shape - 0.5) * 2;
	var easeIn = coreQuintic.(x);
	x * (1 - mix) + (easeIn * mix);
};

var sigmoidToLinear = { |x, shape|
	var mix = shape * 2;
	var easeInOut = inOutQuintic.(x);
	easeInOut * (1 - mix) + (x * mix);
};

var linearToSeat = { |x, shape|
	var mix = (shape - 0.5) * 2;
	var easeOutIn = outInQuintic.(x);
	x * (1 - mix) + (easeOutIn * mix);
};

var expToLinearMorph = { |x, shape|
	if(shape > 0.5) {
		linearToQuinticIn.(x, shape)
	} {
		sigmoidToLinear.(x, shape)
	};
};

var sigmoidToLinearMorph = { |x, shape|
	if(shape > 0.5) {
		linearToSeat.(x, shape)
	} {
		sigmoidToLinear.(x, shape)
	};
};

var sigmoidToExp = { |x, shape, mix|
	var sigmoid = sigmoidToLinearMorph.(x, shape);
	var exponential = expToLinearMorph.(x, shape);
	exponential * (1 - mix) + (sigmoid * mix);
};

~phasePattern = { |numEvents, shape, blend|

	(0..numEvents - 1).collect{ |index|

		var currentEvent, currentValue;
		var nextEvent, nextValue;

		currentEvent = (numEvents - 1 - index) / numEvents;
		currentValue = sigmoidToExp.(currentEvent, shape, blend);

		nextEvent = (numEvents - 1 - index + 1) / numEvents;
		nextValue = sigmoidToExp.(nextEvent, shape, blend);

		nextValue - currentValue;
	};

};
)


(
var numOfSubDivs = 36;
var arrayOfSubDivs = ~phasePattern.(numEvents: numOfSubDivs, shape: 0.75, blend: 0);

Pdef(\oneShot,
	Pmono(\oneShot,

		\trig, 1,
		\legato, 0.8,
		\dur, 4,

		\freq, 440,
		\overlap, 1,

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		\arrayOfSubDivs, [arrayOfSubDivs],
		\numOfSubDivs, numOfSubDivs,
		
		\channelMask, 0,
		\centerMask, 1,
		\panMax, 0.8,

		\amp, -15,
		\out, 0,

	),
).play;
)
2 Likes