Sub-sample accurate events from phasor ramps

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;
)
2 Likes

this does it perfectly, thanks a lot for the thorough example!!