GrainDelay - A sub-sample accurate granular feedback Delay

hey, i have worked on a grain delay in c++. You can get the latest release here. If you want to test it you can use that code snippet here which creates a quick gui (make sure you have NodeProxyGui2).I hope the cross platform builds have been successful.
Thanks @Sam_Pluta for the inspiration from the VariableRamp class, i have been following 1.) output 2.) increment 3.) wrap with my subsampleEvent struct. This really gave me some headaches but its working great for modulating the trigger frequency without distorting the phases now, while getting slopes, triggers and subsample offsets right (This gave me the idea to release a GrainScheduler Ugen which does only that, already created another project haha).

EDIT: The current version 1.3.0 does have 32 fixed grain channels with dynamic channel distribution, so we dont get a problem with overlapping grains with different lengths.
The circular buffer is initialized with a max delay time of 5 sec.
The params all seem to work fine. In the feedback path is a OnePole lowpass filter with a dampen factor between 0 and 1 and a Highpass filter at a fixed frequency of 3 hz as a DC blocker. You can additionally freeze the writing and mix between dry and wet via linear interpolation. If you have any additional ideas let me know :slight_smile:

(
SynthDef(\grainDelay, {

	var reset, tFreqMD, tFreqMod, tFreq;
	var overlapMD, overlapMod, overlap;
	var rateMD, rateMod, rate;
	var delayMD, delayMod, delay;
	var inSig, sig;

	reset = Trig1.ar(\reset.tr(0), SampleDur.ir);

	// trigger rate modulation
	tFreqMD = \tFreqMD.kr(0, spec: ControlSpec(0, 2));
	tFreqMod = LFDNoise3.ar(\tFreqMF.kr(1, spec: ControlSpec(0.1, 1)));
	tFreq = \tFreq.kr(1, spec: ControlSpec(1, 500, \exp));
	tFreq = tFreq * (2 ** (tFreqMod * tFreqMD));

	// overlap modulation
	overlapMD = \overlapMD.kr(0, spec: ControlSpec(0, 1));
	overlapMod = LFDNoise3.ar(\overlapMF.kr(1, spec: ControlSpec(0.01, 1)));
	overlap = \overlap.kr(1, spec: ControlSpec(0.1, 32));
	overlap = overlap * (2 ** (overlapMod * overlapMD));

	// rate modulation
	rateMD = \rateMD.kr(0, spec: ControlSpec(0, 2));
	rateMod = LFDNoise3.ar(\rateMF.kr(1, spec: ControlSpec(0.01, 1)));
	rate = \rate.kr(1, spec: ControlSpec(0.125, 2));
	rate = rate * (2 ** (rateMod * rateMD));

	// delay modulation
	delayMD = \delayMD.kr(0, spec: ControlSpec(0, 1));
	delayMod = LFDNoise3.ar(\delayMF.kr(1, spec: ControlSpec(0.01, 1)));
	delay = \delay.kr(0.3, spec: ControlSpec(0.01, 5));
	delay = delay * (1 + (delayMod * delayMD));

	inSig = Saw.ar(220!2);
	//inSig = In.ar(\in.kr(0), 2);
	//inSig = { PlayBuf.ar(1, \sndBuf.kr(0), loop: 1) } ! 2;

	sig = GrainDelay.ar(
		input: inSig,
		triggerRate: tFreq,
		overlap: overlap,
		delayTime: delay,
		grainRate: rate,
		mix: \mix.kr(0, spec: ControlSpec(0, 1)),
		feedback: \feedback.kr(0.3, spec: ControlSpec(0, 0.95)),
		damping: \damping.kr(0, spec: ControlSpec(0, 1)),
		freeze: \freeze.kr(0, spec: ControlSpec(0, 1, \lin, 1)),
		reset: reset
	);

	sig = sig * \amp.kr(-25, spec: ControlSpec(-35, -5)).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;
)

(
~nodeProxyGui2 = { |synthDefName, initArgs=#[], excludeParams=#[], ignoreParams=#[]|

	var synthDef, nodeProxy;

	synthDef = try{ SynthDescLib.global[synthDefName].def } ?? {
		Error("SynthDef '%' not found".format(synthDefName)).throw
	};

	nodeProxy = NodeProxy.audio(s, 2);
	nodeProxy.prime(synthDef).set(*initArgs);

	NodeProxyGui2(nodeProxy, show: true)
	.excludeParams_(excludeParams)
	.ignoreParams_(ignoreParams)
};
)

(
g = ~nodeProxyGui2.(\grainDelay,
	[
		\tFreq, 10,
		\tFreqMF, 0.1,
		\tFreqMD, 0,

		\overlap, 0.5,
		\overlapMF, 0.1,
		\overlapMD, 0,

		\rate, 1.0,
		\rateMF, 0.1,
		\rateMD, 0,

		\delay, 0.2,
		\delayMF, 0.1,
		\delayMD, 0,

		\feedback, 0.3,
		\damping, 0.7,
		\mix, 1.0,
		\freeze, 0,

		\amp, -25
	],
	[\reset],
	[\amp, \feedback, \freeze]
);
)

g.randomize;
g.vary;
13 Likes

implemented a busy check for dynamic channel distribution, which replaces the static round-robin method and increased the number of channels to 16. Should be usable now without constraining the overlap with min(overlap, 2 ** tFreqMD.neg * numChannels);

Have tested via some plots (the grain scheduler just outputs all the channels phases from the SubsampleEventSystem struct):


(
{
	var tFreqMD, tFreq;
	var overlapMD, overlap, maxOverlap;
	var sig;

	tFreqMD = \tFreqMD.kr(0);
	tFreq = \tFreq.kr(400) * (2 ** (SinOsc.ar(50) * tFreqMD));

	overlapMD = \overlapMD.kr(0);
	overlap = \overlap.kr(1) * (2 ** (SinOsc.ar(50) * overlapMD));

	sig = GrainScheduler.ar(rate: tFreq, overlap: overlap, reset: DC.ar(0));

}.plot(0.021);
)

overlap = 1 and tFreqMD = 0


overlap = 2 and tFreqMD = 0

overlap = 2 and tFreqMD = 1

overlap = 2 and tFreqMD = 2

etc…

1 Like

nice release! i really like the provided example, it sounds great, thanks!

1 Like

added input parameter clipping and overlap amplitude compensation

If we increase the number of fixed maximun polyphony from 16 to lets say 32, so max overlap would be 32 we are having the possibility to lean more into reverb territory, if you then have a considearable high trigger rate 300-400 hz and increase the overlap to its maximum. Trying to find the sweet spot right now for our delay.
For really lush ambient textures we would need more polyphony, but then we should also switch our event scheduling approach to the “granola” (wholegrain overlap-add), otherwise thats to heavy on CPU. This would need a completely refactor of the scheduling mechanism.
Whats really cool right now is that the CPU cycles increase when we have higher overlap, meaning more grains at the same time, seems our channel activation thing is working correctly :slight_smile:

EDIT: will probably update to 32 channels. Have been watching this again after 4 years for the first time by @alikthename https://www.youtube.com/watch?v=c5wM-Pgxf70&t=1471s and they got about 37% CPU with GrainBuf but with an even higher overlap amount around 100, while our 32 channel version hits about 6-7% CPU with maxed out overlap.

2 Likes

I found that you need at least 20 voices poliphony to make it more or less universal thing.
But even better make it like other grain ugens, with the maximum voices parameter. Why not?

Also, I think the GitHub page needs a bit more explanation into what it does. This whole granular instrumentary still doesn’t have enough acknowledgment because it is a bit difficult for musicians to grab

hey thanks for your reply :slight_smile:
Our scheduling mechanism with busy check and dynamic distribution differs from how other grain ugens implement smart polyphony. The model i have chosen is normally good for an average size of polyphony (i think 16-32 is a good choice here) for very dense grains (like 200-500 and above) the model should be switched.

But the reason i have chosen what we have right now, has some history to it:
For the last years i have been trying to modularize granulation, so the user has acces to all of its parts and can set it up themselves on the server.
The initial reason for that was, that i wanted to have FM/PM per grain which is not possible via the existing Grain Ugens. I have additionally learned about phasor based scheduling for sub-sample accurate granulation to prevent aliasing caused by high trigger rates, which is also not part of the existing grain ugens and many more things along the way.

Phasor based scheduling uses continuous, linear ramps between 0 and 1, from which you can derive all sorts of useful information like slopes, triggers, durations and sub-sample offsets. These ramps can be subdivided by non-integer ratios and can be modulated without truncation or distortion of your grain windows, if you make sure your phases stay linear and between 0 and 1 while beeing modulated (e.g trigger rate modulation).
If you setup your basic building blocks like that, you are also free to use an arbitrary carrier oscillator (as soon as it has a phase reset, granulation starts from hard sync) of your liking for example the recent OscOS or my DualOscOS implementation with dynamic mipmapping, sinc interpolation and possibility for oversampling.

In my NOTAM talk which i have held this spring i have presented phasor based scheduling and granulation based on two libraries i have created with some useful tools to manually setup granulation on the server:

Additionally I have created two extended guides for that talk, one for sub-sample accurate phasor based event scheduling and the other how to use these techniques for granulation.

sub-sample accurate phasor based scheduling
phasor based sub-sample accurate granulation

The main challenge when you manually set up granulation on the server is polyphony. The only model available without single-sample feedback or sample based for loops is the round-robin method, which increments a counter by 1 for each trigger it receives and distributes the next grain to be scheduled to the next channel (for example by using PulseDivider). This method works if all your grains have the same durations, but if we want to modulate the trigger rate (e.g. grain of unequal durations) this method does not work because the maximum possible overlap differs per channel. The round-robin method doesnt know about the state of its channels (e.g. busy or not) it just distributes the grain to the next channel and it can be the case, that this channel is still active scheduling a grain. The first extension beyond the round-robin method is to implement a busy check and distribute the next grain to a free channel (thats what we have here).

With all what im doing im trying make this whole thing as modular as possible with all the documentation necessary to get past that granular synthesis mystery for everybody.
Im currently trying to build all the necessary bits and pieces in C++ to make it even more easy for the user to setup granulation themselves (e.g. GrainScheduler Ugen from above) and have all the freedom for extensive modulation. But have just started learning c++.

I hope this does make sense. So i think for this specific model chosen for event distribution we should limit the possible number of channels to 32.
I will write a helpfle for the ugen. Currently i have no idea where to place my guides to be accessable and not be lost in the depth of a thread.

2 Likes

Really excited to try this! Thanks a lot

1 Like

updated to 32 channels and changed some other stuff and added a help file:

on my computer CPU is about 2-3% on average with 32 channels, gets a bit higher when increasing overlap.I think this is solid.

1 Like

updated to v.1.4.0

1 Like

After this initial “beta phase”, testing and refining i have reset the repository. The latest release is here: Releases · dietcv/GrainDelay · GitHub

5 Likes

@dietcv thanks enourmously for sharing this!

I am diving deep into granular synthesis now and I am still a little bit confused about examples showing the difference between sub-sample x not and how both sound like. Could you provide one?

Could you provide an example on how you have achieved the modulation of the sound example that you have provided? Or if it is too complex, some simple version to scratch the surface of it?

Are there recording of these meeting available somewhere?

This is a first sketch of the first part for the helpfile of the grain utils. You can find an A / B example in section 4c. The second part will be implemented as well.

TITLE::Event Scheduling
summary::sub-sample accurate event scheduling
categories::UGens>Triggers, Libraries>Timing, Streams-Patterns-Events>Timing
related::Classes/Phasor, Classes/Impulse, Classes/GrainBuf

DESCRIPTION::

Sub-sample accurate phasor-based scheduling provides precise timing control for audio events by using continuous, linear ramps instead of discrete triggers. 
This approach offers significant advantages over trigger-based scheduling by providing continuous time information and sub-sample timing accuracy.

Unlike trigger-based systems that only provide timing information at discrete moments, phasor-based scheduling gives you continuous access to:
list::
## Elapsed time since the last event
## Remaining time until the next event  
## Fractional sample positions for precise timing
::

This technique is particularly valuable for any application requiring high-timing accuracy beyond the sample rate resolution, for example granular synthesis.

SECTION::1) Continuous Linear Ramps as a Source of Time

subsection::1a) The Scheduling Phasor

The source of time (our clock) is a Phasor creating continuous, linear ramps between 0 and 1. 
The rate parameter of the Phasor determines the density of events - how frequently the phasor completes each cycle and wraps back around, 
with each wrap representing a timing event.

list::
## strong::Continuous:: continuously wrapping between 0 and 1 (no phase reset) - This preserves the fractional sample position where the wrap occurs, which contains the sub-sample timing information
## strong::Linear:: Constant rate of change per sample for every cycle, providing predictable timing relationships
## strong::Normalized:: Normalized range between 0 and 1 that simplifies calculations
::

code::
(
{
    var rate = 1000; // 1000 events per second
    Phasor.ar(DC.ar(0), rate * SampleDur.ir);
}.plot(0.0021).plotMode_(\plines); // Observe the continuous, linear ramps between 0 and 1
)
::

subsection::1b) Deriving Triggers from a Scheduling Phasor

To convert the continuous ramp into discrete timing events, we need to derive a trigger from our scheduling phasor at the moment it wraps around.

subsubsection::Magnitude Delta Method

The simplest method of deriving a trigger from a phasor's wrap, is to calculate its slope (rate of change per sample) 
and compare that with a threshold. When the phasor wraps from 1 to 0, the absolute delta becomes large (approximately 1) and we output a trigger.

code::
(
var rampToTrig = { |phase|
    var history = Delay1.ar(phase);  // Previous sample value
    var delta = phase - history;     // Rate of change per sample
    delta.abs > 0.5;                 // Trigger when absolute delta exceeds threshold
};

{
    var phase, trig;
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    trig = rampToTrig.(phase);
    [phase, trig];
}.plot(0.0021).plotMode_(\plines);
)
::

subsubsection::Proportional Change Method (Recommended)

You may have noticed on the prior plot that we don't get an initial trigger from the magnitude delta method. 
We additionally don't get a trigger if we would manually reset our scheduling phasor in the first half of its duty cycle. 
But we can handle both of these limitations with the proportional change method.

Instead of comparing the raw delta with a threshold, we calculate the proportional change by dividing the delta by the sum of the current and previous sample values, 
and then compare this absolute proportional change with our threshold to detect a significant change.
To make sure we only get triggers on false-to-true transitions (extreme inputs do not cause double triggers), we wrap our trigger into link::Classes/Trig1::.

code::
(
var rampToTrig = { |phase|
    var history = Delay1.ar(phase);
    var delta = phase - history;         // Rate of change
    var sum = phase + history;           // Signal magnitude reference
    var trig = (delta / sum).abs > 0.5;  // Proportional change detection
    Trig1.ar(trig, SampleDur.ir);        // Ensure triggers on false-to-true transitions only
};

{
    var phase, trig;
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    trig = rampToTrig.(phase);
    [phase, trig];
}.plot(0.0021).plotMode_(\plines);
)
::

subsection::1c) Deriving Slopes from a Scheduling Phasor

Deriving the slope of the phasor gives us its rate of change per sample (normalized frequency).
At the phasor's wrap we get a discontinuity in slope, which we used earlier for our trigger detection.
Our derived slope should be a continuous value for each phasor's cycle without any discontinuities. 
To achieve that we can wrap the derived slope between -0.5 and 0.5 (nyquist frequency). 
The slope multiplied by the sample rate gives us frequency in Hz.

code::
(
var rampToSlope = { |phase|
    var history = Delay1.ar(phase);
    var delta = phase - history;
    delta.wrap(-0.5, 0.5); // Handle discontinuity at the phasor's wrap
};

{
    var rate, phase, slope;
    rate = 1000;
    phase = Phasor.ar(DC.ar(0), rate * SampleDur.ir);
    slope = rampToSlope.(phase);
    [phase, slope * SampleRate.ir / 1000]; // Scale slope for visualization purposes
}.plot(0.0021).plotMode_(\plines);
)
::

SECTION::2) Accumulating vs Integrating Ramps from a Scheduling Phasor

The crucial distinction between these two approaches is their handling of frequency modulation:
list::
## strong::Accumulation:: counts samples and scales running total with slope. The slope has to be sampled and held for each cycle - no frequency modulation possible
## strong::Integration:: adds up slope values for every sample - supports frequency modulation
::

subsection::2a) Using Duty for Accumulation

We first derive the slope and triggers from our scheduling phasor. 
Then we count samples with Duty and scale the running total of the accumulator by the derived slope and reset it by the derived trigger.

code::
(
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 accum = { |trig|
    var hasTriggered = PulseCount.ar(trig) > 0;
    Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
};

{
    var phase, trig, slope, accumulator, accumulatedRamp;
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    trig = rampToTrig.(phase);
    slope = rampToSlope.(phase);
    
    accumulator = accum.(trig);
    accumulatedRamp = Latch.ar(slope, trig) * accumulator; // Constant slope per cycle
    
    [phase, trig, accumulatedRamp];
}.plot(0.0021).plotMode_(\plines);
)
::

subsection::2b) Using Sweep for Integration

We first derive the slope and triggers from our scheduling phasor. 
Then we integrate the derived slope values with Sweep and reset the integrator by the derived trigger.

code::
(
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 ramp = { |trig, slope|
    var hasTriggered = PulseCount.ar(trig) > 0;
    Sweep.ar(trig, slope * SampleRate.ir) * hasTriggered;
};

{
    var phase, trig, slope, integratedRamp;
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    trig = rampToTrig.(phase);
    slope = rampToSlope.(phase);
    
    integratedRamp = ramp.(trig, slope); // Continuous integration
    
    [phase, trig, integratedRamp];
}.plot(0.0021).plotMode_(\plines);
)
::

SECTION::3) Ramp Division

In the prior section we have recreated our initial scheduling phasor by accumulation or integration. 
Instead of just recreating our scheduling phasor, we can accumulate or integrate ramps which are subdivisions of our scheduling phasor.
This is similiar to a Clock Divider. However the advantage of ramp division vs clock division is that ramp division is possible
with non-integer ratios and the derived events can be sub-sample accurate (we will look at this more closely in section 4).

subsection::3a) Multiply and Wrap

The simplest way of ramp division is to multiply the phasor by a ratio and wrap the result between 0 and 1.

code::
(
{
    var phase, subdividedRamp;
    phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
    subdividedRamp = (phase * \ratio.kr(4)).wrap(0, 1);
    [phase, subdividedRamp];
}.plot(0.021);
)
::

subsection::3b) Accumulation with Triggered Reset

With the simple multiply and wrap approach, we cant ensure that our subvidided ramps stay perfectly in sync with our scheduling phasor.
To make sure our subdivided ramps stay perfectly in sync, we can derive the slope and triggers from our scheduling phasor, 
run an accumulator and multiply it by the slope and a ratio, reset it by the derived triggers and wrap it between 0 and 1.
The triggered reset is the only way of keeping the subdivided ramps perfectly in sync with our scheduling phasor. Thats what we are going to use for granulation.

code::
(
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 phase, slope, trig, accumulator, subdividedRamp;
    
    phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
    slope = rampToSlope.(phase);
    trig = rampToTrig.(phase);
    
    accumulator = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
    subdividedRamp = (slope * \ratio.kr(4) * accumulator).wrap(0, 1);
    
    [phase, subdividedRamp];
}.plot(0.021);
)
::

subsection::3c) Accumulation without Triggered Reset

The only way we can accumulate ramps which are slower than our scheduling phasor, 
is either to use multichannel expansion which we are going to use for granulation or to simply run an accumulator without a triggered reset. 
To make sure our accumulated ramps can be slower, we can derive the slope from our scheduling phasor, run an accumulator and multiply it by the slope 
and a ratio and wrap it between 0 and 1. But there is no guarantee that these accumulated ramps will stay in sync with the scheduling phasor.

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

{
    var phase, slope, accumulator, subdividedRamp;
    
    phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
    slope = rampToSlope.(phase);
    
    accumulator = Duty.ar(SampleDur.ir, DC.ar(0), Dseries(0, 1));
    subdividedRamp = (slope * \ratio.kr(0.5) * accumulator).wrap(0, 1);
    
    [phase, subdividedRamp];
}.plot(0.021);
)
::

SECTION::4) Sub-Sample Offset Calculation

If we run our scheduling phasor at high trigger rates which are non-integer divisions of our sample rate we get aliasing.
Our scheduling phasor has a fractional value of non-zero at the moment it wraps around (sub-sample offset). 
To make sure our accumulated or integrated ramps are sub-sample accurate, we want to calculate the sub-sample offset of our scheduling phasor and add it to 
our accumulated or integrated ramps on each triggered phase reset.

For each sample frame where our scheduling phasor wraps around, we can calculate the sub-sample offset with a fractional sample counter 
(scheduling phasor divided by its own slope), sample and hold the fractional sample count with the derived trigger 
and add the fractional sample count (sub-sample offset) to our accumulated or integrated ramps on each triggered phase reset.

subsection::4a) Sub-Sample Offset Calculation (accumulator with Duty)

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

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

{
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var accumulatedRamp;

	eventPhase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumSubSample.(eventTrigger, subSampleOffset);
	accumulatedRamp = eventSlope * accumulator;

	[eventPhase, eventTrigger, accumulatedRamp];
}.plot(0.0011).plotMode_(\plines);
)
::

subsection::4b) Sub-Sample Offset Calculation (integrator with Sweep)

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

var integSubSample = { |trig, slope, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Sweep.ar(trig, slope * SampleRate.ir) * hasTriggered;
	accum + (slope * subSampleOffset);
};

{
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, integratedRamp;

	eventPhase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	integratedRamp = integSubSample.(eventTrigger, eventSlope, subSampleOffset);

	[eventPhase, eventTrigger, integratedRamp];
}.plot(0.0011).plotMode_(\plines);
)
::

subsection::4c) sub-sample accurate scheduling vs trigger-based scheduling

You can compare our sub-sample accurate phasor-based scheduling with ordinary trigger-based scheduling with the following examples.
The first example uses our sub-sample accurate phasor-based scheduling, where the other uses trigger-based scheduling. 
Running both examples, you will hear and see the difference on the freqscope.

code::

// sub-sample accurate phasor-based scheduling

(
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;
	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;
};

~sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1]));

{
	var triggerFreq, eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainSlope, grainPhase, carrier, sig;

	triggerFreq = \triggerFreq.kr(1043);
	//triggerFreq = s.sampleRate / 40;

	eventPhase = Phasor.ar(DC.ar(0), triggerFreq * SampleDur.ir);
	eventTrigger = rampToTrig.(eventPhase);
	eventSlope = rampToSlope.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

	grainSlope = \grainFreq.kr(2000) * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	carrier = BufRd.ar(1, ~sndBuf, grainPhase * BufFrames.kr(~sndBuf), 1, 4);

	sig = carrier * grainWindow;

	sig = LeakDC.ar(sig);
	sig!2 * 0.1;

}.play;
)

// trigger-based scheduling

(
~sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1]));

{
	var triggerFreq, trig, sig;

	triggerFreq = \triggerFreq.kr(1043);
	//triggerFreq = s.sampleRate / 40;

	trig = Impulse.ar(triggerFreq);

	sig = GrainBuf.ar(
		numChannels: 1,
		trigger: trig,
		dur: 1 / triggerFreq,
		sndbuf: ~sndBuf,
		rate: \grainFreq.kr(2000) * SampleDur.ir * BufFrames.kr(~sndBuf),
		interp: 4
	);

	sig = LeakDC.ar(sig);
	sig!2 * 0.1;

}.play;
)

// play both examples and have a look at their spectrum
s.freqscope;
::

SECTION::5) Modulating the rate of a Scheduling Phasor

subsection::5a) Modulating the rate of Phasor

The modulation of the scheduling phasor's rate creates discontinuities in slope, 
if we accumulate ramps with the derived slope which has to be sampled and held per derived trigger, 
the latched slope doesnt match the duration of the current cycle of the scheduling phasor, 
which leads to a truncation of our stateless window functions.

code::
(
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;
	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;
};

{
	var tFreqMod, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;

	// Modulated frequency
	tFreqMod = SinOsc.ar(10, 1.5pi);
	tFreq = \tFreq.kr(200) * (2 ** (tFreqMod * \modDepth.kr(2)));

	eventPhase = Phasor.ar(DC.ar(0), tFreq * SampleDur.ir);
	eventTrigger = rampToTrig.(eventPhase);
	eventSlope = rampToSlope.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

	[eventPhase, windowPhase, grainWindow];

}.plot(0.041);
)
::

subsection::5b) Modulating the rate of RampCycle

The scheduling phasor has to be linear even when its rate is beeing modulated.
RampCycle solves this with a sample and hold of its own rate for each of its cycles within a single-sample feedback loop.
Additionally we have to make sure our next slope is known at the moment our next ramp cycle starts,
if the slope is updated in the middle of a ramp cycle we get a discontinuity for our accumulated ramp signals.
Therefore we have to add a single-sample delay to our scheduling phasor after we have derived our slope and before we derive our trigger.

code::
(
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;
    Latch.ar(sampleCount, trig);
};

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

{
    var tFreqMod, tFreq, eventPhase, eventSlope, eventTrigger;
    var subSampleOffset, accumulator;
    var windowSlope, windowPhase, grainWindow;
    
    // Modulated frequency
    tFreqMod = SinOsc.ar(10, 1.5pi);
    tFreq = \tFreq.kr(200) * (2 ** (tFreqMod * \modDepth.kr(2)));
    
    // Use RampCycle instead of Phasor
    eventPhase = RampCycle.ar(tFreq);
    eventSlope = rampToSlope.(eventPhase);
    
    // Add single-sample delay after slope calculation for proper synchronization
    eventPhase = Delay1.ar(eventPhase);
    eventTrigger = rampToTrig.(eventPhase);
    
    subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
    accumulator = accumSubSample.(eventTrigger, subSampleOffset);
    
    windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
    windowPhase = (windowSlope * accumulator).clip(0, 1);
    grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;
    
    [eventPhase, windowPhase, grainWindow];
}.plot(0.041);
)
::

I have just used the SynthDef i have shared in the helpfile / in the initial post.

There are no recordings.

1 Like

Notam Meetup host here - we unfortunately don’t record the meetups for various reasons, but many of our presenters are very generous and share their notes/code etc. in the respective forum threads :slight_smile:

(sorry for the off-topic post, feel free to delete/move)

I currently making alot of effort to include these notam guides in my grain utils repository. The first part is already there more or less (see draft above). But i will need a bit more time to make sure the details are correct and also understandable.
Then i will include explanations for the new ugens, which do all the heavy lifting for the user. But its good to understand what happens behind the scenes of these ugens. Thats what the guides are for.

5 Likes

hey, if you grab the latest version of the grain utils, you can now search for “Event Scheduling” in the help browser and should find the first part of the guide there.

2 Likes

I think its probably a good idea to merge these repositories and also maybe the thread so everything is in one place. sorry for the confusion i just started working on all the stuff and didnt know where this would end up. But i have a quite clear vision for the second guide on granulation now, where i could then also refer to the GrainDelay as one specific way of setting up granulation with a delay line beside buffer granulation where i have already a finished example i will include and the single cycle waveform / wavetable stuff for glisson, pulsar etc. synthesis, which we have mainly been talking about.

2 Likes

The GrainDelay can now be found in the GrainUtils repository, so everything is in one place and i can better reference to the different parts in the yet to come help files.
The current GrainDelay repository will be deleted.

1 Like