Sub-sample accurate granulation with random periods

hey, i would like to share this granulator with you :slight_smile:

pros instead of using GrainBuf:

  • the grains are sub-sample accurate (no aliasing for high trigger rates)
  • no static buffered windows, the shape of the stateless window functions can be modulated
  • possibility of frequency trajectories per grain (PM/FM)

cons instead of using GrainBuf:

  • multichannel expansion is causing higher CPU

features:

  • If you raise the randomness param (think Dust but with the awareness of time), the rampToRandom function creates linear ramps between 0 and 1 with random periods. This comes in handy to interrupt the periodicity of the metallic, comb like response when setting higher overlap values
  • The overlapRange param is based on a multichannel triggered Dwhite, which let you set min and max values of random overlap values per grain
  • The channelMask function distributes each grain to its own audio channel, the panMax param gives you pan control over the stereo field.
  • The grain window is a stateless gaussian window with modulatable control over skew and index.
  • The modulation is pretty basic, you can modulate the position rate, which is the rate of scanning through the buffered waveform and you can modulate the grain rate, which is the playback rate of each grain, a snapshot taken from the current position of the buffered waveform. But you could easily add FM/PM per grain for example.

These are just basic concepts, which you can adjust to your liking :slight_smile:
May the slope calculation of 32-bit floats be with you.

note: The round-robin method for distributing grains to separate audio channels, which is used here is not the optimal solution. But without the ability of single-sample feedback the only possible implementation for overlapping grains inside a SynthDef. Same is true for the rampToRandom function, which would be better implemented with modulation of the rate of the grain scheduling ramp which samples and holds its own modulation via a derived trigger.
One problem when distributing overlapping grains with different durations round-robin across the channels is to prevent phase-distortion (this issue is inherent to the round-robin method). Therefore the maximum overlap possible is limited to maxOverlaps = min(overlaps, 2 ** randomness.neg * numChannels); This means for higher randomness values the amount of possible overlap is lower and vice versa. I think this is an acceptable tradeoff.

(
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 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 multiChannelDemand = { |triggers, demandUgen, paramRange|
	var demand = demandUgen;
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, demand)
	}.linexp(0, 1, paramRange[0], paramRange[1]);
};

var channelMask = { |triggers, numChannels, channelMask, centerMask|
	var panChannels = Array.series(numChannels, -1 / numChannels, 2 / numChannels).wrap(-1.0, 1.0);
	var panPositions = panChannels.collect { |pos| Dser([pos], channelMask) };
	Demand.ar(triggers, 0, Dseq(panPositions ++ Dser([0], centerMask), inf));
};

var transferFunc = { |phase, skew|
	phase = phase.linlin(0, 1, skew.neg, 1 - skew);
	phase.bilin(0, skew.neg, 1 - skew, 1, 0, 0);
};

var unitGaussian = { |phase, index|
	var cosine = cos(phase * 0.5pi) * index;
	exp(cosine.neg * cosine);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) / 2;
};

var gaussianWindow = { |phase, skew, index|
	var warpedPhase = transferFunc.(phase, skew);
	var gaussian = unitGaussian.(warpedPhase, index);
	var hanning = unitHanning.(warpedPhase);
	gaussian * hanning;
};

var getRandomPeriods = { |rate, randomness|
	var randomPeriod = Ddup(2, (2 ** (Dwhite(-1.0, 1.0) * randomness))) / rate;
	Duty.ar(randomPeriod, DC.ar(0), 1 / randomPeriod);
};

var rampToRandom = { |rate, randomness|
	var randomPeriod = getRandomPeriods.(rate, randomness);
	var trig = Changed.ar(randomPeriod) > 0;
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	var phase = randomPeriod * SampleDur.ir * accum;
	(phase - SampleDur.ir).wrap(0, 1);
};

SynthDef(\grains, { |sndBuf|

	var numChannels = 10;

	var randomness, stepPhase, stepTrigger, stepSlope, subSampleOffsets;
	var triggers, accumulator, overlaps, maxOverlaps, chanMask;
	var windowSlopes, windowPhases, grainWindows;
	var posRate, posRateMod, pos;
	var rate, rateMod, grainPhases;
	var sigs, sig;
	
	randomness = \randomness.kr(0.5);

	stepPhase = rampToRandom.(\tFreq.kr(8), randomness);
	stepTrigger = rampToTrig.(stepPhase);
	stepSlope = rampToSlope.(stepPhase);

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

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

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

	overlaps = multiChannelDemand.(triggers, Dwhite(0, 1), \overlapRange.kr([1, 1]));
	maxOverlaps = min(overlaps, 2 ** randomness.neg * numChannels);

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

	chanMask = channelMask.(triggers, numChannels - 1, \channelMask.kr(1), \centerMask.kr(1));

	grainWindows = gaussianWindow.(windowPhases, \skew.kr(0.5), \index.kr(0));

	posRateMod = SinOsc.ar(\posRateMF.kr(1));
	posRate = \posRate.kr(1) + (posRateMod * \posRateMD.kr(0));

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

	rateMod = SinOsc.ar(\rateMF.kr(1));
	rate = \rate.kr(1) + (rateMod * \rateMD.kr(0));

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

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

	sigs = sigs * grainWindows;

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

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

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

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

~buffer = ... load your sample here ...

(
Synth(\grains, [

	\tFreq, 10,
	\randomness, 0,
	\overlapRange, [1, 1],

	\skew, 0.5,
	\index, 0,

	\rate, 1.0,
	\rateMF, 1,
	\rateMD, 0,

	\posRate, 1.0,
	\posRateMF, 1,
	\posRateMD, 0,

	\posLo, 0,
	\posHi, 1,

	\sndBuf, ~buffer,
	
	\panMax, 0.8,
	\channelMask, 1,
	\centerMask, 1,

	\amp, -20,
	\out, 0,

]);
)
13 Likes

That’s a clever hack

1 Like

Hi @dietcv,
Thanks for sharing this!:slight_smile: Just wanted to ask, also in regard previous threads on the issue, if this in fact now would allow for overlap without phase interruption?
I just tested by replacing a sine function as window, and the randomness still seems to interfere with current window phases:

(
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 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 multiChannelDemand = { |triggers, demandUgen, paramRange|
	var demand = demandUgen;
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, demand)
	}.linexp(0, 1, paramRange[0], paramRange[1]);
};

var channelMask = { |triggers, numChannels, channelMask, centerMask|
	var panChannels = Array.series(numChannels, -1 / numChannels, 2 / numChannels).wrap(-1.0, 1.0);
	var panPositions = panChannels.collect { |pos| Dser([pos], channelMask) };
	Demand.ar(triggers, 0, Dseq(panPositions ++ Dser([0], centerMask), inf));
};

var transferFunc = { |phase, skew|
	phase = phase.linlin(0, 1, skew.neg, 1 - skew);
	phase.bilin(0, skew.neg, 1 - skew, 1, 0, 0);
};

var unitGaussian = { |phase, index|
	var cosine = cos(phase * 0.5pi) * index;
	exp(cosine.neg * cosine);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) / 2;
};

var gaussianWindow = { |phase, skew, index|
	var warpedPhase = transferFunc.(phase, skew);
	var gaussian = unitGaussian.(warpedPhase, index);
	var hanning = unitHanning.(warpedPhase);
	gaussian * hanning;
};

var randomPeriods = { |rate, randomness|
	var events = 2 ** (Dwhite(-1.0, 1.0) * randomness);
	var rates = Ddup(2, events) / rate;
	Duty.ar(rates, DC.ar(0), 1 / rates);
};

SynthDef(\grains, { |sndBuf|

	var numChannels = 10;

	var tFreq, stepTrigger, stepPhase, stepSlope, subSampleOffsets;
	var triggers, accumulator, overlaps, maxOverlaps, chanMask;
	var windowSlopes, windowPhases, grainWindows;
	var posRate, posRateMod, pos;
	var rate, rateMod, grainPhases;
	var sigs, sig;

	// create random periods of linear ramps
	tFreq = randomPeriods.(MouseY.kr(1,1000,1), 1); //maximum randomness

	stepPhase = (Phasor.ar(DC.ar(0), tFreq * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	stepTrigger = rampToTrig.(stepPhase);
	stepSlope = rampToSlope.(stepPhase);

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

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

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

	overlaps = multiChannelDemand.(triggers, Dwhite(0, 1), \overlapRange.kr([1, 1]));
	maxOverlaps = min(overlaps, numChannels);

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

	chanMask = channelMask.(triggers, numChannels - 1, \channelMask.kr(1), \centerMask.kr(1));

	grainWindows = sin(windowPhases*1pi).scope;

	sigs =  grainWindows;

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

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

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

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

~buffer =  Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");

(
Synth(\grains, [

	\tFreq, 29,
	\randomness, 0.1,
	\overlapRange, [10, 10],

	\skew, 0.5,
	\index, 0,

	\rate, 1.0,
	\rateMF, 1,
	\rateMD, 0,

	\posRate, 0.25,
	\posRateMF, 1,
	\posRateMD, 0,

	\posLo, 0,
	\posHi, 1,

	\sndBuf, ~buffer,
	
	\panMax, 0.8,
	\channelMask, 1,
	\centerMask, 1,

	\amp, -20,
	\out, 0,

]);
)

Maybe that’s what you meant with stating that some implementation is not optimal due to inherent sc-limitations, just wanted to make sure!

Greetings,

Jan

hey,

the desired output of your grain scheduling phasor are linear ramps between 0 and 1. Therefore you cannot modulate the frequency of your grain scheduling phasor without implementing a sample and hold triggered by a trigger derived from the grain scheduling phasor itself inside a single-sample feedback loop.

This would look like this in gen~:

Thats the reason for the randomPeriods function. Its output are always linear ramps between 0 and 1. The implementation of 2 ** (bipolar signal * modDepth) is the formula beeing used for exponential FM, for modDepths of 0 it collapses neatly to 1 and therefore outputs the linear ramps unchanged. The limit of the modDepth (randomness param) isnt 1, you can set it even higher and the ramps will still be linear and between 0 and 1.

(
var randomPeriods = { |rate, randomness|
	var events = 2 ** (Dwhite(-1, 1) * randomness) / rate;
	var duration = Ddup(2, events);
	Duty.ar(duration, DC.ar(0), 1 / duration);
};

{
	var rate = \rate.kr(100);
	var seq = randomPeriods.(rate, \randomness.kr(2));
	Phasor.ar(DC.ar(0), seq * SampleDur.ir);
}.plot(0.1);
)

The only issue, and we have discussed this before, is the round-robin method combined with random durations and overlap.
If you set the randomness param to “higher values” and additionally raise the overlap param to its maximum value, determined by the number of channels you use, you can see that some of the phases wont reach 1.
The windows beeing driven by that phase wont reach their end but will abruptly end in the middle of their duration. This is undesirable but unanvoidable with the combination of these two techniques.
The phases are still linear thats good, but for extreme settings wont reach 1. Thats still better then additionally having non-linear phases from modulation without the implementation of a sample and hold in the single sample feedback loop.

The only option you have which works with the round-robin method and does not create any of the issues above is, instead of creating fully random triggers derived from your grain scheduling phasor, to mask your triggers with probablity using CoinGate to get quasi-random triggers.

The slope derived from your ramps to accumulate your window phases are sampled and hold by the multichannel trigger. This also ensures the phases to be linear for modulation of the trigger frequency, but if you modulate your trigger frequency, you instantly get accumulated window phases which are not between 0 and 1. With the randomPeriods function you only run into this issue when you use extreme settings for randomness and overlap. If you dont touch overlap no problem.

Better then using the round-robin method is to test, if the current window is still active with the condition window > 0 and if true schedule the next window on a different channel inside a single sample feedback loop.

Yes, i see all these considerations for such an elusive synthesis challenge! :sweat_smile:
Regarding the avoidance of retriggering an open window i found a compromise as workaround, which is to extend the trigger length to a frequency cycle:

tFreq = randomPeriods.(300, MouseY.kr(0,1)); //increase randomness by Mouse
stepPhase = (Phasor.ar(DC.ar(0), tFreq * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
stepTrigger = Trig1.ar(rampToTrig.(stepPhase),(MouseX.kr(0,1).round(1)*1)/tFreq); //extend trigger length to frequency cycle

(...)

grainWindows = sin(windowPhases*1pi).scope;

That though, introduces frequency division by skipping the next cycle, and limits the random deviation also.

the issue is not “retriggering an open window”, its scheduling a new window on a channel which is still in use by another window.

Indeed, it is! (Just in practice & especially for tonal material this does seem to help quite a bit) But surely that test to choose a free available channel would be really amazing to have…

Hm, I wouldn’t have expected that. I’d say the technique is “subsample aware” but – BufRd here approximates the inter-sample values and thus doesn’t exactly accurately give the true bandlimited waveform.

I have a demo of this but not enough time to explain it at the moment – later.

hjh

I think the question was about the problem of random periods, overlap and the round-robin method as elaborated above. Sub-sample accurate granulation with random periods - #4 by dietcv

There are two interconnected issues here, where the randomPeriod function just solves one.

1.) You want to modulate the trigger frequency of your grain scheduling phasor, but it should always output linear ramps between 0 and 1.
2.) You cant know in advance, when creating random periods per trigger if the current channel is still busy when its time to schedule a new window when using the round-robin method and overlap and therefore create the phase distortion.

The calculation of the sub-sample offset does work quite nicely to prevent aliasing for high triggers rates with BufRd, compare it with GrainBuf and watch the freqscope here:

(
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, 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 sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1,0], 0!2));

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

	tFreq = 1000;

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

	subSampleOffset = getSubSampleOffset.(eventPhase, eventTrigger);

	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger);
	windowPhase = (windowSlope * accumulator).clip(0, 1);

	grainWindow = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), windowPhase);

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

	sig = BufRd.ar(1, sndBuf, grainPhase * BufFrames.kr(sndBuf), 1, 4);
	sig = sig * grainWindow;

	sig = LeakDC.ar(sig);

	sig!2 * 0.1;
}.play;
)

s.freqscope;

(
var sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1,0], 0!2));

{
	var tFreq = 1000;
	var trig = Impulse.ar(tFreq);
	var grainRate = 2000;

	var sig = GrainBuf.ar(
		numChannels: 1,
		trigger: trig,
		dur: 1 / tFreq,
		sndbuf: sndBuf,
		rate: grainRate * SampleDur.ir * BufFrames.kr(sndBuf),
		interp: 4
	);

	sig = LeakDC.ar(sig);

	sig!2 * 0.1;

}.play;
)

I see, so, not so much about the actual audio result, and more about the driving phase.

I guess I was opining/inquiring about what’s the limit of subsample accuracy. The timing of a band limited sawtooth, or square, or impulse can be subsample accurate by encoding the timing offset in the Gibbs effect wiggles (magnificent demo at xiph.org), but a subsample time-shifted Dirac impulse doesn’t have any zero samples! So it couldn’t be used as a trigger: it would be constantly triggering, every other sample. An SC trigger must be on a sample boundary.

You can calculate the error (when the phasor would have occurred crossed back past 0.5 under an infinite sample rate), but the trigger itself can’t be subsample-ly positioned.

But… if it sounds good, then I’m just splitting hairs (although AFAICS your two examples produced just about the same spectrum).

hjh

hey,

TL;TR triggers can only happen on a sample frame thats correct, its not about changing the position of the trigger. Its about figuring out the sub-sample offset of the ideal phasors wrap and when you reset your accumulator, then dont reset it to 0 but to the sub-sample offset. I can cleary hear and see a difference on the freqscope. I dont making this up.

sub-sample accurate accumulator used with BufRD:

naive implementation with Impulse and GrainBuf:

For scheduling events with continous waveforms you need a carrier phase which should reset to zero whenever your scheduling phase completes a cycle. An implementation of this naive hard sync causes a jump in the phase of the carrier whenever the scheduler completes a cycle. This jump in phase leads to a hard edge in the carrier waveform and therefore causes harsh aliasing noise. The first step of getting rid of this hard edge whenever the scheduler forces the carrier to wrap around is to fade out the carrier waveform at the moment the sync happens by applying a window function. This is called windowed sync and is the basic concept of granulation.

In the context of granulation you have a grain scheduler, a phasor going from 0 to 1 and you derive triggers from it to schedule your grain events by calculating the delta of your phasors slope to figure out when the absolute delta is above a certain threshold (the moment the grain scheduler wraps) to reset your carrier phase and applying a window function to fade out the carrier waveform at exactly that moment to smooth out the hard edge the reset causes.

The ideal scheduling phasors wrap (green) happens somewhere between one sample frame and the next (red box). Calculating the scheduling phasors slope by taking its sample values from the last sample frame and its current sample frame (purple) and to look if this delta is above a certain threshold derives a trigger from the scheduling phasor (blue) which resets the carrier phase exactly to zero at the actual scheduling phasors wrap (marked by the black arrow) and schedules a window function starting from zero at exactly that moment. But within that sample frame, where the actual scheduling phasor wraps the sample value is slightly above zero, it deviates from zero by a small amount (yellow) and the ideal scheduling phasors wrap happens with a fractional offset from the actual scheduling phasors wrap.

The carrier and the window function should have a value of zero at the ideal scheduling phasors wrap to be perfectly aligned with the ideal phasor (green). The actual phasors wrap (marked with the black arrow) should therefore not reset the carrier and the window function to zero but to the fractional sub-sample offset (yellow).

1 Like

Maybe it’s that your example happens to use frequencies that evenly divide 48000 but which do not divide 44100. I’m running my built-in soundcard at 48000 and both were clean.

It should be possible to adjust pos in GrainBuf, except for the randomized timing.

hjh

thats a good point, if you change the trigger frequency and the grain frequency to values which are not evenly dividing your sample rate, you can clearly hear and see the difference on the freqscope.

We have been discussing this issue in several threads, maybe there could be a condition to test which is upstream from the actual windowPhases (the test windowPhases > 0, would imply a single-sample feedback loop) to redistribute the triggers by resetting the Pulsedivider. There are good odds that this is impossible, but maybe we can rethink that once again because it is desperately needed.

(
var multiChannelTrigger = { |numChannels, trig, counterLimit|
	var count = Demand.ar(trig, DC.ar(0), Dseq([Dseries(0, 1, counterLimit)], inf));
	numChannels.collect{ |chan|
		trig * BinaryOpUGen('==', (count + (numChannels - 1 - chan) + 1) % numChannels, 0);
	};
};

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 numChannels = 4;
	var rate = 400;
	var phase = (Phasor.ar(DC.ar(0), rate * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	var trig = rampToTrig.(phase);
	multiChannelTrigger.(numChannels, trig, counterLimit: 1);
}.plot(0.02);
)

hi, desperately needed indeed! while i have no current implementation ideas, one thing i’ve wondered, on a more abstract level, was whether inducing a general latency on the whole process and using that time frame to read where windows have been placed could provide a feasible workaround (maybe more for nrt than realtime)?
otherwise, but also limiting would be to hardcode a random sequence in advance, that gets intermittently updated.

it seems at least the concept could work, if you run a parallel accumulator logic upstream without a retrigger, check for overlap 1, 2, 3…But yeah i guess we are hitting a wall here once more.

(
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 multiChannelTrigger = { |numChannels, trig, reset|
	var count = Demand.ar(trig, reset, Dseries(0, 1, inf).dpoll);
	numChannels.collect{ |chan|
		trig * BinaryOpUGen('==', (count + (numChannels - 1 - chan) + 1) % numChannels, 0);
	};
};

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 randomPeriods = { |rate, randomness|
	var events = 2 ** (Dwhite(-1.0, 1.0) * randomness);
	var rates = Ddup(2, events) / rate;
	Duty.ar(rates, DC.ar(0), 1 / rates);
};

var isChannelBusy = { |stepSlope, stepTrigger, maxOverlap|
	var accum = Duty.ar(SampleDur.ir, 0, Dseries(0, 1));
	var slope = Latch.ar(stepSlope, stepTrigger) / max(0.001, maxOverlap);
	var phase = (slope * accum).wrap(0, 1);
	rampToTrig.(phase);
};

{
	var numChannels = 5;

	var tFreq, stepTrigger, stepPhase, stepSlope, subSampleOffsets;
	var triggers, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases;
	var busyCheck;

	overlap = \overlap.kr(3);
	maxOverlap = min(overlap, numChannels);

	// create random periods of linear ramps
	tFreq = randomPeriods.(500, 0);

	stepPhase = (Phasor.ar(DC.ar(0), tFreq * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	stepTrigger = rampToTrig.(stepPhase);
	stepSlope = rampToSlope.(stepPhase);

	busyCheck = isChannelBusy.(stepSlope, stepTrigger, maxOverlap);

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

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

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

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

	windowPhases.wrap(0, 1);
	
}.plot(0.021);
)

that’s quite a few walls we hit already!
where’s an omniscient AI versed in synthesis to help us solve this riddle.