Duty Sequence for multichannel phase

hey, ive spent already some time with this and not really able to figure it out.
Im creating a multiChannelPhase for numChannels by offsetting several Phasors and would like to use a sequence which should adjust the length each ramp takes to go from 0 to 1 per channel.

(
var sequencer = { |rate, array|
	var durs = Dseq(array, inf) / rate;
	var demand = Ddup(2, durs);
	1 / Duty.ar(demand, DC.ar(0), demand);
};

var multiChannelPhase = { |numChannels, rate|
	var localRate = rate / numChannels;
	numChannels.collect{ |i|
		var localPhase, localTrig, hasTriggered;
		localPhase = (Phasor.ar(DC.ar(0), localRate * SampleDur.ir) + (1 - (i / numChannels)) - SampleDur.ir).wrap(0, 1);
		localTrig = HPZ1.ar(localPhase) < 0;
		hasTriggered = PulseCount.ar(localTrig) >= 1;
		localPhase * hasTriggered;
	}.reverse;
};

{
	var numChannels = 4;
	var overlap = min(\overlap.kr(1), numChannels);
	var rate = \rate.kr(100);
	var seq = sequencer.(rate, [5, 2, 3]);
	var phases = multiChannelPhase.(numChannels, seq) * (numChannels / overlap);
	IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), phases);
}.plot(0.3);
)

when i use overlap values == 1 its working as expected, each window is stretched in relation to the sequence and exactly ends where the new one begins (legato):

when i use overlap values < 1 its also working as expected, each window is stretched by the sequence but also “globally” compressed by the overlap value:

But when i use overlap values > 1 which is the whole point of creating this multichannel phase, to be able to overlap the windows inside the Synthdef, the phase gets distorted.

I was trying to concenptualise in different steps what should happen, but not really able to do so.
One thought was, that the Duty sequence should only advance per channel so Duty should probably also be multichannel expanded.
Does anybody could help me out with that? thanks alot.

1 Like

different approach with pulsedivider but same thing here for overlap values > 1.

(
var multiChannelTrigger = { |numChannels, trig|
	var rate = if(trig.rate == \audio, \ar, \kr);
	numChannels.collect{ |chan|
		PulseDivider.perform(rate, trig, numChannels, chan);
	};
};

var multiChannelPhase = { |triggers, windowRate|
	var rate = if(triggers.rate == \audio, \ar, \kr);
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.perform(rate, localTrig) > 0;
		Sweep.perform(rate, localTrig, windowRate * hasTriggered);
	};
};

{
	var tFreq, trig, binary, legato, overlap, windowRate, windowPhase, window, triggers, dseq;

	var numChannels = 3;

	tFreq = \tFreq.kr(100);
	trig = Impulse.ar(tFreq);

	binary = Demand.ar(trig, 0, Dseq([1, 0, 0, 0, 0, 1, 0, 1, 0, 0], inf));

	// multiply triggers from Impulse by sequence of binary values to create a rhythmic sequence
	trig = trig * binary;

	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by binary values
	legato = Demand.ar(trig, 0, Dseq([5, 2, 3], inf));

	// multiply overlap by sequence of corresponding durations to adjust window length
	overlap = \overlap.kr(1) * legato;

	windowRate = tFreq / overlap;
	windowPhase = multiChannelPhase.(triggers, windowRate);
	window = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), windowPhase);
	//windowPhase

}.plot(0.2);
)

I was also trying to calculate the maximum overlap for this sequencing use case. Normally it would be min(overlap, numChannels) but when you mask the Impulses and additionaly multiply by the legato values or choose the Phasor approach from the initial post its behaving differently.

Lets assume you have 3 channels and a sequence of [5, 2, 3].
Then the maximum overlap for the first channel is the sum of the second and the third item of the sequence.
The maximum overlap for the second channel is the sum of the third and first item of the sequence etc.
And the overall maximum overlap would be the maximum out of all these combinations, when im not mistaken. Not sure right now, how this would translate into code.

1 Like

[edit]
this gives you kind of what you want:

(
{
	var freqs = [12,20,17,10];
	var overlap = 2;
	var oVal = 1/overlap;
	var initDelay = 0;
	var s1 = Sweep.ar(LocalIn.ar(1)>=oVal, freqs[0]);
	var s2 = Sweep.ar((s1>=oVal), freqs[1]*Env([0,0,1],[1/freqs[0]*oVal,0]).kr);
	var s3 = Sweep.ar((s2>=oVal), freqs[2]*Env([0,0,1],[(1/freqs[0]*oVal)+(1/freqs[1]*oVal),0]).kr);
	var s4 = Sweep.ar(s3>=oVal, freqs[3]*Env([0,0,1],[(1/freqs[0]*oVal)+(1/freqs[1]*oVal)+(1/freqs[2]*oVal),0]).kr);
	LocalOut.ar(s4);
	1-SinOsc.ar(0, [s1,s2,s3,s4].clip(0,1)*2pi+(pi/2))
}.plot(1)
)
1 Like

Thank you very much for taking the time :slight_smile: One first observation by studying your code is that the length of the window is not adjusted when you change the overlap parameter, which is the desired behaviour. The overlap parameter just changes the relative distance from one window to the other here, like adding an offset to a phasor and wrapping it.

I don’t know exactly what you want, but you can make freq a function of overlap:

(
	{
		var overlap = 2;
		var oVal = 1/overlap;
		var freqs = [12,20,17,10]*(oVal.clip(0.5,1));
		var initDelay = 0;
		var s1 = Sweep.ar(LocalIn.ar(1)>=oVal, freqs[0]);
		var s2 = Sweep.ar((s1>=oVal), freqs[1]*Env([0,0,1],[1/freqs[0]*oVal,0]).kr);
		var s3 = Sweep.ar((s2>=oVal), freqs[2]*Env([0,0,1],[(1/freqs[0]*oVal)+(1/freqs[1]*oVal),0]).kr);
		var s4 = Sweep.ar(s3>=oVal, freqs[3]*Env([0,0,1],[(1/freqs[0]*oVal)+(1/freqs[1]*oVal)+(1/freqs[2]*oVal),0]).kr);
		LocalOut.ar(s4);
		1-SinOsc.ar(0, [s1,s2,s3,s4].clip(0,1)*2pi+(pi/2))
	}.plot(1)
)
1 Like

Without any fancy sequencing and either using Phasors or Impulse, Pulsedivider and Sweeps,
you are stretching the window for overlap values > 1 up to a maximum of numChannels, but they trigger at exactly the same moments in time for every value you choose for overlap. Without the sequencing part every window has the same length:

// Phasor approach

(
var multiChannelPhase = { |numChannels, rate|
	var localRate = rate / numChannels;
	numChannels.collect{ |i|
		var localPhase, localTrig, hasTriggered;
		localPhase = (Phasor.ar(DC.ar(0), localRate * SampleDur.ir) + (1 - (i / numChannels)) - SampleDur.ir).wrap(0, 1);
		localTrig = HPZ1.ar(localPhase) < 0;
		hasTriggered = PulseCount.ar(localTrig) >= 1;
		localPhase * hasTriggered;
	}.reverse;
};

{
	var numChannels = 4;
	var overlap = min(\overlap.kr(1), numChannels);
	var rate = \rate.kr(400);
	var phases = multiChannelPhase.(numChannels, rate) * (numChannels / overlap);
	IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), phases);
}.plot(0.02);
)

// Impulse / Pulsedivider / Sweep approach

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		Sweep.ar(localTrig, windowRate * hasTriggered);
	};
};

{
	var numChannels = 4;
	var tFreq = \tFreq.kr(400);
	var trig = Impulse.ar(tFreq);
	var triggers = multiChannelTrigger.(numChannels, trig);
	var windowRate = tFreq / min(\overlap.kr(1), numChannels);
	var windowPhase = multiChannelPhase.(triggers, windowRate);
	IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), windowPhase);
}.plot(0.05);
)

overlap = 1;
grafik

overlap = 2;
grafik

overlap =3;
grafik

overlap =4;
grafik

This overlap behaviour should remain the same.

With the sequencing part for example added to the Impulse / PulseDivider / Sweep approach, you multiply each trigger by a sequence of binary values (for example [1, 0, 0, 1, 0, 0, 1, 0]), this results in a rhythmic staccato sequence (in this case a tresillo rhythm). When you additionally multiply the overlap by exactly the same sequence of integer values [3, 3, 2], each window will be stretched for every trigger until the next trigger arrives. This converts the former staccato rhythm to a legato rhythm.

The sequencing with Duty for the Phasor approach has the advantage that you dont have to use parallel collections of data which have to be passed to the SynthDef, so you dont have to first mask the triggers from Impulse by a binary sequence and then addtionally multiply overlap by the same sequence of integer values.

However in both cases this type of sequencing does only work for overlap values <= 1, otherwise the phase gets distorted and i dont know how to solve that. So my intention is to be able to combine the possibilty to overlap the windows which is working without any problems and the sequencing part with adjusted legato. If i just mask the triggers by a binary sequence and dont adjust the length of the windows the overlapping is of course working.

I have been doing posts on the forum about different aspects of this kind of granulation for about two years now and have added different modules along the way to my core approach i have shared above. One of the modules which im not quite happy with is the sequencing part im currently working on. Long story short every new module has to fit into my already existing framework and I can already see problems when having to use LocalIn / LocalOut for the sequencing part. Nevertheless Im really happy to see your approach which lets me think about the problem from another angle :slight_smile:

thank you very much :slight_smile: I think thats the behaviour i was looking for, but if you dont use s.options.blockSize = 1 its not sample accurate. Maybe i can implement this to be used with one of the approaches i have shared above. Additionally the size of the sequence is binded to the channels you use. You cant for example have a a sequence of 5 values and 4 channels, probably you need iteration for this. Still not sure how to calculate the maximum overlap for this setup before the phase gets distorted.

(
{
	var rate = 100;
	var durations = (1 / [1, 1, 1, 1]);
	var rates = rate * durations;
	var overlap = 1;
	var windowRates = rates / overlap;

	var s0 = LocalIn.ar(1);

	var s1 = Sweep.ar(s0 >= (1 / overlap), windowRates[0] * EnvGen.ar(Env([0, 0, 1], [0, 0])));
	var s2 = Sweep.ar(s1 >= (1 / overlap), windowRates[1] * EnvGen.ar(Env([0, 0, 1], [(1 / rates[0]), 0])));
	var s3 = Sweep.ar(s2 >= (1 / overlap), windowRates[2] * EnvGen.ar(Env([0, 0, 1], [(1 / rates[0]) + (1 / rates[1]), 0])));
	var s4 = Sweep.ar(s3 >= (1 / overlap), windowRates[3] * EnvGen.ar(Env([0, 0, 1], [(1 / rates[0]) + (1 / rates[1]) + (1 / rates[2]), 0])));

	LocalOut.ar(s4);

	IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), [s4, s3, s2, s1].clip(0, 1));
}.plot(0.1)
)

triggering the legato values by the multichannel trigger and then passing the multichannel rates to create a multichannel phase does work! Increasing the overlap value > 1 does behave correctly now. You always have to add an initial trigger if you pass multichannel triggers to Demand Ugens, here with Impulse.ar(0), is this a bug?
Only thing which remains to solve is the calculation of the maximum overlap, here i have hardcoded the value 10 which is the sum of the legato sequence [5, 2, 3]. But if you would disable the sequencing, the maximum overlap should be min(overlap, numChannels). Additionally it behaves differently when numChannels is unequal to the size of the legato sequence. How can this be solved?

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

var multiChannelPhase = { |triggers, windowRates|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRates[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var tFreq, trig, binary, legato, overlap, windowRates, windowPhases, window, triggers, dseq;

	var numChannels = 3;

	tFreq = \tFreq.kr(100);
	trig = Impulse.ar(tFreq);

	binary = Demand.ar(trig, 0, Dseq([1, 0, 0, 0, 0, 1, 0, 1, 0, 0], inf));

	// multiply triggers from Impulse by sequence of binary values to create a rhythmic sequence
	trig = trig * binary;

	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by binary values
	legato = Demand.ar(triggers, 0, Dseq([5, 2, 3], inf)) + Impulse.ar(0);

	// multiply overlap by sequence of corresponding durations to adjust window length for each trigger
	overlap = \overlap.kr(1) * legato;

	windowRates = tFreq / min(overlap, 10);
	multiChannelPhase.(triggers, windowRates);

}.plot(0.2);
)

This would only work for fixed sequences, but at least ive figured out how you can theoretically calculate the maximum overlap, depending on numChannels, the legato values and the size of the sequence:

(
var numChannels = 5;
var sequence = [5, 2, 3];
var size = sequence.size;

size.collect{ |item|
	var index = ((0..numChannels - 1) + item).wrap(0, size - 1);
	sequence[index].sum;
};
)

EDIT: It also collapses nicely to numChannels if the legato sequence is [1, 1, 1]; which would be the case when disabling the sequencing. Im wondering if there is a way to rewrite this with Demand Ugens for variable size legato sequences.

Here is a plot for numChannels = 5, sequence = [5, 2, 3]; and the calculated maxOverlap = [17, 15, 18];
increase overlap starting with 1:

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

var multiChannelPhase = { |triggers, windowRates|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRates[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var tFreq, trig, binary, legato, overlap, maxOverlap, windowRates, windowPhases, window, triggers;

	var numChannels = 5;

	tFreq = \tFreq.kr(100);
	trig = Impulse.ar(tFreq);

	binary = Demand.ar(trig, 0, Dseq([1, 0, 0, 0, 0, 1, 0, 1, 0, 0], inf));

	// multiply triggers from Impulse by sequence of binary values to create a rhythmic sequence
	trig = trig * binary;

	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by binary values
	legato = Demand.ar(triggers, 0, Dseq([5, 2, 3], inf)) + Impulse.ar(0);

	// multiply overlap by sequence of corresponding durations to adjust window length
	overlap = \overlap.kr(1) * legato;

	// calculate maxiumum overlap
	maxOverlap = min(overlap, Demand.ar(triggers, 0, Dseq([17, 15, 18], inf))) + Impulse.ar(0);

	windowRates = tFreq / maxOverlap;
	multiChannelPhase.(triggers, windowRates);

}.plot(0.5);
)

Maybe this is nonsense but Im currently trying to transfer the maxOverlap calculation idea to Demand Ugens. If somebody has an idea how to do that, please let me know :slight_smile:

(
var numChannels = 5;
var sequence = [5, 2, 3];
var size = sequence.size;

size.collect{ |item|
	var index = ((0..numChannels - 1) + item).wrap(0, size - 1);
	sequence[index].sum;
};
)

Here is the work in progress:

(
var numChannels = 5;
var seqOfDurations = [5, 2, 3];
var seqSize = 3;

{
	var trig = Impulse.ar(2);

	// poll values per duration for binary and legato sequence
	var valuesPerDuration = Ddup(2, Dseq([Dser(seqOfDurations, seqSize)], inf));

	var countValuesPerDuration = Demand.ar(trig, 0, Dseq([Dseries(0, 1, valuesPerDuration)], inf));
	var binarySequence = HPZ1.ar(countValuesPerDuration) < 0 + Impulse.ar(0);

	var legatoSequence = Demand.ar(binarySequence, 0, valuesPerDuration);

	// TO DO: calculate maxOverlap
	//var maxOverlapSequence = Demand.ar(binarySequence, 0, Dseq([Dseries(0, 1, numChannels - 1)], inf));

	binarySequence.poll(trig, label: \binary);
	legatoSequence.poll(binarySequence, label: \legato);
	//maxOverlapSequence.poll(binarySequence, label: \maxOverlap);

	Silent.ar;
}.play;
)

Im making some progress here, this gives me the correct maxOverlap sequence of [17, 15, 18] per trigger. But im pretty sure i have overlooked something important when wanting to calculate the maxOverlap for variable size legato sequences and I think everything has to be reset properly when passing an array with different values to the SynthDef. I guess its only working when you sequence every item in the array one after the other with Dseq. Im wondering if you could also make it work for other Demand List Ugens.

(
{ 
	var numChannels = 5;
	var sequence = [5, 2, 3];
	var size = sequence.size;
	
	var trig = Impulse.ar(1);
	var maxOverlap = numChannels.collect{ |i|
		Demand.ar(trig, 0, Dswitch1(sequence, Dseries(0, 1) + i % size));
	}.sum;
	maxOverlap.poll(trig, \maxOverlap);
	Silent.ar;
}.play;
)

together with the rest:

(
{
	var numChannels = 5;
	var seqOfDurations = [5, 2, 3];
	var seqSize = 3;

	var trig = Impulse.ar(2);

	// poll values per duration for binary and legato sequence
	var valuesPerDuration = Ddup(2, Dseq([Dser(seqOfDurations, seqSize)], inf));

	var countValuesPerDuration = Demand.ar(trig, 0, Dseq([Dseries(0, 1, valuesPerDuration)], inf));
	var binary = HPZ1.ar(countValuesPerDuration) < 0 + Impulse.ar(0);

	var legato = Demand.ar(binary, 0, valuesPerDuration);

	// calculate the maximum overlap for each trigger by rotating the seqOfDurations array and summing the items
	var maxOverlap = numChannels.collect{ |i|
		Demand.ar(binary, 0, Dswitch1(seqOfDurations, Dseries(0, 1) + i % seqSize));
	}.sum;

	binary.poll(trig, label: \binary);
	legato.poll(binary, label: \legato);
	maxOverlap.poll(binary, label: \maxOverlap);

	Silent.ar;
}.play;
)

Next step would be to implement it with the multichannel expansion.

I have tried to implement the calculation of the binary, legato and the maxOverlap sequence into the multichannel expansion framework:

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRate[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var tFreq, trig, binary, maskedTrig, legato, overlap, maxOverlap, windowRates, windowPhases, window, triggers;
	var valuesPerDuration, countValuesPerDuration;

	var numChannels = 5;
	var seqOfDurations = [5, 2, 3];
	var seqSize = 3;

	tFreq = \tFreq.kr(100);
	trig = Impulse.ar(tFreq);

	// poll values per duration for binary and legato sequence
	valuesPerDuration = Ddup(2, Dseq([Dser(seqOfDurations, seqSize)], inf));
	countValuesPerDuration = Demand.ar(trig, 0, Dseq([Dseries(0, 1, valuesPerDuration)], inf));
	//binary = HPZ1.ar(countValuesPerDuration) < 0 + Impulse.ar(0);
	binary = Demand.ar(trig, 0, Dseq([1, 0, 0, 0, 0, 1, 0, 1, 0, 0], inf));

	// multiply triggers from Impulse by sequence of binary values (trigger mask)
	maskedTrig = trig * binary;

	triggers = multiChannelTrigger.(numChannels, maskedTrig);

	// trigger legato sequence by masked triggers
	legato = triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, valuesPerDuration) + Impulse.ar(0)
	};

	// multiply overlap by sequence of durations to adjust window length per trigger
	overlap = \overlap.kr(1) * legato;

	// calculate maxiumum overlap per trigger
	maxOverlap = numChannels.collect{ |i|
		Demand.ar(triggers, 0, Dswitch1(seqOfDurations, Dseries(0, 1, inf) + i % seqSize)) + Impulse.ar(0);
	}.sum;
	maxOverlap = min(overlap, maxOverlap);

	windowRates = tFreq / maxOverlap;
	multiChannelPhase.(triggers, windowRates);

}.plot(0.5);
)

1.) When using HPZ1 to create the binary sequence from the integer sequence the first phase is missing and the phases are not corretly distributed across the channels. If i use the manually calculated binary sequence its working as expected.
2.) As far as i know Demand doesnt multichannel expand, so i have been using triggers.collect{ |localTrig| ... }; which has the oddity that you have to add Impulse.ar(0); for some reason. Why is that? Additonally i would expect that i have to do the same for maxOverlap, but triggers.collect{ |localTrig| ... }; does not give me the correct maxOverlap values per trigger.
3.) I have also tried to pass in maskedTrig into Demand for legato and maxOverlap but then the values are also not correctly calculated because they are not multichannel expanded. You can see that, if you increase \overlap.kr up to 10.

If somebody has an idea how to fix these three issues, let me know.

any Demand multichannel experts here?

i have replaced the calculation of the binary values by binary = TDuty.ar(valuesPerDuration / tFreq);
This seems to work for all overlap values correctly, but the multichannel expansion of Demand with the additional Impulse.ar(0) is still a headscratcher to me. It also seems to work without using .collect but adding Impulse.ar(0)

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRate[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var tFreq, trig, binary, maskedTrig, legato, overlap, maxOverlap;
	var windowRates, windowPhases, window, triggers, valuesPerDuration;

	var numChannels = 5;
	var seqOfDurations = [5, 2, 3];
	var seqSize = 3;

	tFreq = \tFreq.kr(100);
	trig = Impulse.ar(tFreq);

	// poll values per duration for binary and legato sequence
	valuesPerDuration = Ddup(2, Dseq([Dser(seqOfDurations, seqSize)], inf));

	// calculate binary sequence
	binary = TDuty.ar(valuesPerDuration / tFreq);

	// multiply triggers from Impulse by sequence of binary values (trigger mask)
	maskedTrig = trig * binary;

	// create multiChannel trigger and distribute them round robin across the channels
	triggers = multiChannelTrigger.(numChannels, maskedTrig);

	// trigger legato sequence by multichannel trigger
	legato = Demand.ar(triggers, 0, valuesPerDuration);

	// multiply overlap by sequence of durations to adjust window length per trigger
	overlap = \overlap.kr(1) * legato;

	// calculate maxiumum overlap per trigger
	maxOverlap = numChannels.collect{ |i|
		Demand.ar(triggers, 0, Dswitch1(seqOfDurations, Dseries(0, 1, inf) + i % seqSize));
	}.sum;
	maxOverlap = min(overlap, maxOverlap);

	windowRates = tFreq / (maxOverlap + Impulse.ar(0));
	
	multiChannelPhase.(triggers, windowRates);

}.plot(0.5);
)

This also has the advantage that you are not binded to Dseq for calculating the binary values. But when wanting to use other list patterns i have to come up with another solution for the calculation of maxOverlap instead of using Dswitch1.

I think I found the issue by polling valuesPerDuration with .dpoll. The value for the Demand sequence is 0 before block 9.
This leads to a division through zero when calculating windowRates = tFreq / maxOverlap; for the first blocks and causes an empty plot window. When adding Impulse.ar(0) to maxOverlap its working. Im not sure if this is a bug.

for a Dseq sequence this is working perfectly, the phase will never distort when increasing overlap:

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRate[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var numChannels = 5;

	var tFreq, reset, trig, durations, triggers, legato, overlap, maxOverlap, windowRates, windowPhases;
	var seqOfDurations, seqSize;

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

	// durations for triggers and legato sequence
	seqOfDurations = [5, 2, 3];
	seqSize = 3;
	durations = Dunique(Dseq([Dser(seqOfDurations, seqSize)], inf));

	// create triggers from sequence of durations
	trig = TDuty.ar(durations / tFreq, reset);

	// distribute triggers round robin across the channels
	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by multichannel trigger
	legato = Demand.ar(triggers, reset, durations);

	// multiply overlap by sequence of durations to adjust phase per trigger
	overlap = \overlap.kr(2) * legato;

	// calculate maxiumum overlap per trigger
	maxOverlap = numChannels.collect{ |chan|
		Demand.ar(triggers, reset, Dswitch1(seqOfDurations, Dseries(0, 1, inf) + chan % seqSize));
	}.sum;
	maxOverlap = min(overlap, maxOverlap);

	// add Impulse.ar(0) to prevent division through zero
	windowRates = tFreq / (maxOverlap + Impulse.ar(0));
	windowPhases = multiChannelPhase.(triggers, windowRates);

	IEnvGen.ar(Env([0, 1, 0.8, 0], [0.25, 0.5, 0.25]), windowPhases);

}.plot(0.5);
)

also made an attempt with total random durations, where the maxOverlap calculation has to be rethought to look into the future. I thought one could collect numChannels times random durations and accumulate them already on the first trigger, but maybe thats nonsense:

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRate[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var numChannels = 5;

	var tFreq, reset, trig, durations, triggers, legato, overlap, maxOverlap;
	var windowRates, windowPhases, randomness;

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

	// random durations for trigger and legato sequence
	randomness = \randomness.kr(1);
	durations = Dunique(2 ** (2 * randomness * Dwhite(0, 1) - randomness));

	// create triggers from sequence of durations
	trig = TDuty.ar(durations / tFreq, reset);

	// distribute triggers round robin across the channels
	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by multichannel triggers
	legato = Demand.ar(triggers, reset, durations);

	// multiply overlap by sequence of durations to adjust phase per trigger
	overlap = \overlap.kr(2) * legato;

	// TO DO: calculate maximum overlap per trigger
	// maxOverlap = ....

	// add Impulse.ar(0) to prevent division through zero
	windowRates = tFreq / (overlap + Impulse.ar(0));
	windowPhases = multiChannelPhase.(triggers, windowRates);

	IEnvGen.ar(Env([0, 1, 0.8, 0], [0.25, 0.5, 0.25]), windowPhases);

}.plot(0.1);
)

i have also calculated the maxOverlap for Dbjorklund2 from f0plugins by redFrik:

EDIT: there now also is a Dsieve extension, you know whats coming next :wink:

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

var multiChannelPhase = { |triggers, windowRate|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localPhase = Sweep.ar(localTrig, windowRate[i] * hasTriggered);
		localPhase * (localPhase < 1);
	};
};

{
	var numChannels = 5;

	var tFreq, reset, trig, triggers, legato, overlap, maxOverlap, windowRates;
	var windowPhases, durations, numHits, numSize, offSet, sequencer;

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

	// sequencer
	numHits = \numHits.kr(3);
	numSize = \numSize.kr(8);
	offSet = \offSet.kr(0);
	sequencer = Dbjorklund2(numHits, numSize, offSet, inf);

	// bjorklund durations for triggers and legato sequence
	durations = Ddup(2, sequencer);

	// create triggers from sequence of durations
	trig = TDuty.ar(durations / tFreq, reset);

	// distribute triggers round robin across the channels
	triggers = multiChannelTrigger.(numChannels, trig);

	// trigger legato sequence by multichannel triggers
	legato = Demand.ar(triggers, reset, durations);

	// multiply overlap by sequence of durations to adjust phase per trigger
	overlap = \overlap.kr(2) * legato;

	// calculate maximum overlap per trigger
	maxOverlap = numChannels.collect{ |chan|
		Demand.ar(triggers, reset, Dbjorklund2(numHits, numSize, offSet + chan % numHits, inf));
	}.sum;

	// add Impulse.ar(0) to prevent division through zero
	windowRates = tFreq / (min(overlap, maxOverlap) + Impulse.ar(0));
	windowPhases = multiChannelPhase.(triggers, windowRates);

	IEnvGen.ar(Env([0, 1, 0.8, 0], [0.25, 0.5, 0.25]), windowPhases);

}.plot(0.5);
)

I thought there could be a universal way of calculating these, but probably there isnt one for all the different attempts. But one thing which came conceptually to my mind is first calculate the maxOverlap, then the legato and triggers from it, maybe this will lead to generalisation.