Sub-sample accurate events from phasor ramps

hey,

i have succesfully built the ramp2slope and ramp2trig abstractions from the ~gen book with some help from @Sam_Pluta:

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

TL:TR How do i count the number of samples correctly in the following example:

In the gen~ book is a chapter about creating sub-sample accurate events from phasor ramps (i wont get into the details here why getting triggers from ramps could be better then creating ramps froms triggers). It is stated that:

The polyphonic patches we have created so far trigger grains with sample-accurate timing, in the sense that the trigger aligns each grain to the precise sample frame during which the phasor ramp wraps. This is already much more precise than typical control rates. But the smaller the grains get, and the faster they are spawned, the more the sound can transform into a pitched oscillator. At these rates, the fractional subsample locations of events become very important, and we need to locate each event precisely within a sample frame with sub-sample accurancy

Lets zoom right in to where the scheduler phasor wraps and take a look at the grain envelope it causes.
In this graph, the horizontal axis represents the passing sample frames in real-time, and the solid line the continuous high-frequency ramp that our phasor creates.
The gray shaded region is a specific sample frame in which the phasor has wrapped, causing a trigger, and the dashed line shows a grain envelope caused by this trigger. But for a pure tone, the grain window should be aligned to the ideal ramp, as marked by the dotted line:

The book suggests calculating the subsample offset of the ramp during the trigger to properly align the grain window.

To calculate the subsample offset you can divide the phasor by its own slope, to get a ramp with a slope of 1.0. Such a slope counts by one for each passing sample frame of time. Then, in the exact sample frame that the phasor wraps, the first value will always be some number between zero and one, giving us the fractional number of samples since it wrapped:

in SC code this would look like this:

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

{
	var rate = 1000;
	var phase = (Phasor.ar(0, rate * SampleDur.ir) - SampleDur.ir).wrap(0, 1); 
	var slope = rampToSlope.(phase);
	var sampleCount = phase / slope;
	var trig = sampleCount < 1;
	[phase, trig];
}.plot(0.005);
)

grafik

There are a few subtleties to deal with, though. First, we have to ensure we are getting a steady rate of change of the ramp even through the transition, which we handled using the ramp2slope abstraction.
Unfortunately this patch doesnt work for a phasor running downwards, as it will focus on the values near the top of the ramp rather then near zero. We can fix that simply by subtracting one from the phasor to recenter it, but only if the slope is negative:

in SC code this would look like this:

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

{
	var rate = 1000;
	// handles downward ramps
	var phase = (Phasor.ar(0, rate * SampleDur.ir) - SampleDur.ir).wrap(0, 1).linlin(0, 1, 1, 0); 
	var slope = rampToSlope.(phase);
	var sampleCount = phase - (slope < 0) / slope;
	var trig = sampleCount < 1;
	[phase, trig];
}.plot(0.005);
)

grafik

Up to now, this algorithm assumes that the phasors slope isnt changing. That actually doesnt matter for the first sample after the phasor wrap, but beyond that, simply dividing the accumulated phase by the current slope wont accurately tell you the samples since the wrap. We can fix this easily enough by grabbing the fractional count at the wrap as we have above and storing it with a latch operator, and also restarting a sample counter using an accum operator at the same time. Adding both the latch and accum outputs together will give an accurate fractional measure of the number of samples since the last phasor wrap.

There is also a second condition added which handles triggers when the phasors frequency is set to zero. But i think i wont need that, so we can skip that second condition (would also not know how to implement the conditial and):

My attempt to translate this code has been the following, but im not sure about the counter / accumulator:

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

{
	var rate = 1000;
	var phase = (Phasor.ar(0, rate * SampleDur.ir) - SampleDur.ir).wrap(0, 1); 
	var slope = rampToSlope.(phase);
	var sampleCount = phase - (slope < 0) / slope;
	var trig = sampleCount < 1;
	var latch = Latch.ar(sampleCount, trig);
	// TODO: count the number of samples correctly
	var accum = Demand.ar(trig, 0, Dseries(0, 1, inf)) * SampleDur.ir;
	var subsample = latch + accum;
	[phase, trig];
}.plot(0.005);
)

This sub-sample accurate triggers should then be used to trigger a grain window. As far as i understand it, the phase which drives the grain window is calculated by using the sum of the latched subsample offset and a trigger accumulator multiplied by the slope.

How do i count the number of samples correctly? thanks

I think the right thing to do, is to run Duty at sample rate and reset it via the trigger.
Duty.ar(SampleDur.ir, trig, Dseries(0, 1);

the window looks a bit weird on the plot, but the result is crazy:

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

{
	var rate = 1000;
	var phase = (Phasor.ar(DC.ar(0), rate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);

	var slope = rampToSlope.(phase);
	var sampleCount = phase - (slope < 0) / slope;
	var trig = sampleCount < 1;

	var subsampleOffset = Latch.ar(sampleCount, trig);
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	
	var windowSlope =  slope / \overlap.kr(1);
	var windowPhase = Latch.ar(windowSlope, trig) * (accum + subsampleOffset);
	var window = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), windowPhase);

	var sig = SinOsc.ar(slope * SampleRate.ir * \grainRate.kr(2)) * window;
	
	sig!2 * 0.1;
}.play;
)

s.freqscope;

with subsampleOffset:
grafik

and without subsampleOffset:
grafik

hi @dietcv,

im trying to understand for what use cases this approach of creating triggers from ramps w/ subsamble accurate make special sense? is it generally advisable for fine, high pitched granular synthesis, due to possibly cleaner sound?
thanks,
jan

I would say, in general yes. You see it via the freqscope that its preventing all the aliasing for an unmodulated trigger signal. But i havent figured out if its also true for heavy modulation of the trigger frequency of your grain scheduler or if it needs any further adjustments then. In general im trying to sync my grain synthesis attempts with trigger modulation and sequencing of events to a main clock derived from a phasors ramp, which also drives other instruments instead of having a granular instrument which just can do texture independent from everything else. Instead im trying to be free to do audio rate trigger modulation and sequencing synced to a main clock.

a simple example of syncing is this (feel free to adjust the curve and the stepsPerMeasure value):

(
var rampFromBPM = { |bpm, beatsPerMeasure|
	var beatsPerSec = bpm / 60;
	var measureRate = beatsPerSec / beatsPerMeasure;
	(Phasor.ar(DC.ar(0), measureRate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
};

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

SynthDef(\test, {
	var measurePhase = rampFromBPM.(120, 4).lincurve(0, 1, 0, 1, \curve.kr(2));
	var measureTrigger = rampToTrig.(measurePhase);
	var stepsPerMeasure = \stepsPerMeasure.kr(20);
	var stepPhase = (measurePhase * stepsPerMeasure).wrap(0, 1);
	var stepTrigger = rampToTrig.(stepPhase);
	OffsetOut.ar(0, stepTrigger * 0.5);
	OffsetOut.ar(1, measureTrigger * 0.5);
}).add;
)

x = Synth(\test, [\stepsPerMeasure, 20, \curve, 2]);
x.free;

x = Synth(\test, [\stepsPerMeasure, 100, \curve, 4]);
x.free;

i see, it’s an implementation that’s supposed to work for several timescales, your example shows this well.
imo, if the trade off for high rate rate audio triggering is so good audiowise, certainly this approach should be delved into much more, certainly a good case for a pseudo-ugen…
if there’s any more sources elaborating on this ramp2trig implementation it’d be interesting to read!

its in the ~gen book https://www.amazon.de/Generating-Sound-Organizing-Time-Thinking/dp/1732590311
couldnt recommend this enough! This is one of the best hands on synthesis books available imo (the miller puckette stuff is also great, but could be more up to date). But you are pretty fast running into limitations using SC.
And i have also made a thread about it: Ramp to trig abstraction

1 Like

looks interesting, but unfortunately for sc users so focused on max’s structure;)
you mean limitations because of this?

~gen is working one sample at a time. there are some feedback loops which are necessary for the patches which you cant do in SC.

one A / B example of having a SinOsc modulating the phase of a pulsaret and one subsample accurate sin Osc modulating the phase of a pulsaret:

1 Like

this is an impressive difference, and might be the solution to some questions I had raised before when dealing with trigger based fm!
is the example a sinosc.ar being phase driven by a subsample accurate phasor?

in this example i have used sin(modPhase * 2pi) but the same is true for using SinOsc.ar(0, modPhase);

Just to get this straight, as i haven’t fully wrapped my head around it yet: the modPhase being a Phasor with ramp2trig as trig Input and ramp2slope as rate Input to be sub-sample accurate?

This is work in progress and i guess i have made a subtle mistake. The A / B example was made using a multichannel signal with either a single SinOsc as a modulator or a multichannel phase driving a multichannel sin modulator. I think thats probably the difference which caused the aliasing and not the fact that its sub sample accurate. I will make some further tests. But either way it is important to create phase aligned multichannel modulators to modulate multichannel signals.

For setting up the sub-sample accurancy I create a Phasor ramp and creating triggers from it by rampToTrig and get its slope by rampToSlope. I then run an accumulator with accumulatorSubSample at the same slope the main Phasor is running. Everytime the Phasor wraps between 0 and 1 a trigger from rampToTrig resets the accumulator and its not starting from 0 but from the calculated sub sample offset. Just one of the beauties with this approach is, that you can use the same accumulator and multiply it by different slopes, here done for getting the windowPhase, the grainPhase and the modPhase. If you create a multichannel Accumulator by first using a Pulsedivider to distribute the rampToTrig triggers across the channels and calculate the subsample offset for the multichannel triggers this approach really shines. Because you dont have to create a seperate multichannel Phase for grainPhase, windowPhase and modPhase but can just use the same multichannel accumulator and multiply it by the different slopes. Here is the basic one channel approach i have been coming up with. I have figured out two important points: When running the main Phasor at a very low rate the rampToSlope function creates glitches, I think because the difference in phase between the last sample and the current sample is very small. When swapping Phasor for LFSaw you can go lower in rate until the glitches occur and using Slope.ar does seem to do a better job then using HPZ1 or calculating the delta by phase - Delay1.ar(phase). But i cant reset the LFSaw which is something to think of.

(
var rampToSlope = { |phase|
	var delta = Slope.ar(phase) * SampleDur.ir;
	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, 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 hanningWindow = { |phase|
	(1 - (phase * 2pi).cos) / 2 * (phase < 1);
};

{

	var reset, eventRate, eventPhase, eventSlope, eventTrigger, subSampleOffset, accumulator;
	var grainFreq, grainSlope, overlap, maxOverlap, windowSlope, windowPhase;
	var grainWindow, modSlope, pmIndex, modPhase, pmod;
	var grainPhase, grain;

	reset = \reset.tr(0);

	eventRate = \tFreq.kr(200);

	eventPhase = (Phasor.ar(DC.ar(0), eventRate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventTrigger);

	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	grainFreq = \freq.kr(200);
	grainSlope = grainFreq * SampleDur.ir;

	overlap = \overlap.kr(0.25);

	maxOverlap = min(overlap, (grainSlope / Latch.ar(eventSlope, eventTrigger)));

	windowSlope = grainSlope / max(0.001, maxOverlap);
	windowPhase = (windowSlope * accumulator).clip(0, 1);

	grainWindow = hanningWindow.(windowPhase);

	modSlope = grainSlope * \pmRatio.kr(3);

	modPhase = (modSlope * accumulator).wrap(0, 1);
	pmod = sin(modPhase * 2pi) * \pmIndex.kr(3);
	pmod = OnePole.ar(pmod, exp(-2pi * modSlope));

	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	grain = sin(grainPhase * 2pi + pmod);

	grain = grain * grainWindow;

	grain!2 * 0.1;

}.play;
)
1 Like

i see, thanks for clarifying! always troubleshooting in sc :upside_down_face:
thank you also for the clear & detailed example, looking forward to study it more in depth!
all in all really fascinating, these sub sample approaches!

1 Like

SC has also single sample feedback support as a Pseudo UGen via the excellent misSCallneous_lib - see Fb1 | SuperCollider 3.13.0 Help

I wonder how gen~ works internally? It seems separated within the graph as it gets compiled? Maybe this could be integrated natively into scsynth/sclang by integrating Faust in some way?

I also bought the book and the book demonstrates what you can do when working on single sample and sub-sample basis. I mainly use the book to learn Max, but it is also possible to adapt most of it into SuperCollider thinking.

I have used it at some point but dont find it a proper solution which could compete with gen~.

I have worked through most of the chapters and left out the one on filter design mainly because of the single sample feedback loops envolved. But making my first steps in gen~ and would like to try out RNBO to make some gen~ based things available in SC.

1 Like

would you know how to adjust my code to implement the bandlimited hard sync osc on page 352? The tricky part seems to be to get the phase before and after the sync, in gen~ done with a combination of wrap, history and switch.
I thought that the accumulator of wrap and history could be set up with Duty running at sample rate. But then i dont know how to get the phase before and after the sync.

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

var rampRotate = { |phase, offset|
	(phase - offset).wrap(0, 1);
};

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

var rampSubSample = { |slope, trig, subSampleOffset|
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	(slope * (accum + subSampleOffset)).wrap(0, 1);
};

{
	var rate = 110;
	var slope = rate * SampleDur.ir;

	var syncPhasor = Phasor.ar(0, 50 * SampleDur.ir);
	var syncTrig = rampToTrig.(syncPhasor);
	var syncSubSampleOffset = getSubSampleOffset.(syncPhasor, syncTrig);

	var corePhasor = rampSubSample.(slope, syncTrig, syncSubSampleOffset);

	var loopSubSample = corePhasor.wrap(-0.5, 0.5) + (slope * 0.5) / slope;
	var mix = Select.ar(syncTrig, [syncSubSampleOffset, loopSubSample]);

	var beforeTrans = rampRotate.(corePhasor, mix * slope);
	var afterTrans = rampRotate.(corePhasor, mix - 1 * slope);

	var trig = (mix >= 0) * (mix < 1);

	// the second inlet should not be the core phasor but the phase before or after the sync
	var replaceBefore = Select.ar(trig, [beforeTrans, corePhasor]);
	var replaceAfter = Select.ar(trig, [afterTrans, corePhasor]);

	var sig = XFade2.ar(replaceBefore, replaceAfter, mix.clip(0, 1));

	sig = LeakDC.ar(sig);

	sig !2 * 0.1;

}.play;
)
1 Like

hi @dietcv,

while exploring your implementation, it’s not quite the same issue, but if i try to add a (not subsample accurate) hardsync reset to all running grains and grainwindow there seems to be some crash which turns it silent due to getSubSampleOffset.
Trying to reset all the drift due to detuned array:

(
var rampToSlope = { |phase|
	var delta = Slope.ar(phase) * SampleDur.ir;
	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, 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 hanningWindow = { |phase|
	(1 - (phase * 2pi).cos) / 2 * (phase < 1);
};

{

	var reset, eventRate, eventPhase, eventSlope, eventTrigger, subSampleOffset, accumulator;
	var grainFreq, grainSlope, overlap, maxOverlap, windowSlope, windowPhase;
	var grainWindow, modSlope, pmIndex, modPhase, pmod;
	var grainPhase, grain;

	reset = Impulse.ar(1);

	eventRate = \tFreq.kr(400)*Array.geom(8,1,1.001);
	
	

	eventPhase = (Phasor.ar(DC.ar(0)+reset, eventRate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = PulseDivider.ar(rampToTrig.(eventPhase),8,(1..8)-1); 
	//	eventTrigger = PulseDivider.ar(rampToTrig.(eventPhase),8,(1..8)-1)+reset; //if reset is added here, system seems to turn silent (eventhough output is still running): something with getSubSampleOffset seems to crash the output

	subSampleOffset = getSubSampleOffset.(eventPhase, eventTrigger);

	accumulator =  Array.fill(8,{|i| Duty.ar(SampleDur.ir, eventTrigger.at(i), Dseries(0, 1))}) + subSampleOffset;

	grainFreq = \freq.kr(400);
	grainSlope = grainFreq * SampleDur.ir;

	overlap = \overlap.kr(0.25);

	maxOverlap = min(overlap, (grainSlope / Latch.ar(eventSlope, eventTrigger)));

	windowSlope = grainSlope / max(0.001, maxOverlap);
	windowPhase = (windowSlope * accumulator).clip(0, 1);

	grainWindow = hanningWindow.(windowPhase);

	modSlope = grainSlope * \pmRatio.kr(3);

	modPhase = (modSlope * accumulator).wrap(0, 1);
	pmod = sin(modPhase * 2pi) * \pmIndex.kr(3);
	pmod = OnePole.ar(pmod, exp(-2pi * modSlope));

	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	grain = sin(grainPhase * 2pi + pmod);

	grain = grain * grainWindow;

	grain.sum!2 * 0.1;

}.play;
)

Would you know a workaround to this? Maybe yo encountered an alternative during your hardsync solving?

Thanks!

the hard sync example from above is independent from granulation. the subsample offset is used to calculate the phase before and after the sync with sub sample accurate precision. This is a cheap but effective method of anti aliasing when hard syncing a phasor.

It really helps me to write different functions to know whats going on, here i have created a multichannel trigger with pulsedivider and a multiChannelAccumulator function. this is the basic setup i would use for granulation or in this case pulsar synthesis due to the windowSlope calculation:

(
var rampToSlope = { |phase|
	var delta = Slope.ar(phase) * SampleDur.ir;
	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 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 hanningWindow = { |phase|
	(1 - (phase * 2pi).cos) / 2 * (phase < 1);
};

{
	var numChannels = 8;

	var reset, eventRate, eventPhase, eventSlope, eventTrigger, triggers, subSampleOffsets, accumulator;
	var grainFreq, grainSlope, overlap, maxOverlap, windowSlope, windowPhase;
	var grainWindow, modSlope, pmIndex, modPhase, pmod;
	var grainPhase, grain;

	reset = \reset.tr(0);//Impulse.ar(4);

	eventRate = \tFreq.kr(50);
	
	eventPhase = (Phasor.ar(reset, eventRate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase); 
	
	// distribute triggers round robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator =  multiChannelAccumulator.(triggers, subSampleOffsets);

	grainFreq = \freq.kr(400);
	grainSlope = grainFreq * SampleDur.ir;

	overlap = \overlap.kr(0.25);

	maxOverlap = min(overlap, (grainSlope / eventSlope));

	windowSlope = grainSlope / max(0.001, maxOverlap);
	windowPhase = (windowSlope * accumulator).clip(0, 1);

	grainWindow = hanningWindow.(windowPhase);

	modSlope = grainSlope * \pmRatio.kr(3);

	modPhase = (modSlope * accumulator).wrap(0, 1);
	pmod = sin(modPhase * 2pi) * \pmIndex.kr(3);
	pmod = OnePole.ar(pmod, exp(-2pi * modSlope));

	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	grain = sin(grainPhase * 2pi + pmod);

	grain = grain * grainWindow;

	grain.sum!2 * 0.1;

}.play;
)
1 Like