Pulsar synthesis and lfos

managed to upload a small little snippet :slight_smile:

2 Likes

Yay (to the video and the ramps!)

It would then work like this:
you trigger a fixed amount of linear sub ramps from the language (Tasks, Routines or Pmono) and can distribute them over time of a triggered event by using the shape / curve param and get slopes and single sample triggers from these linear sub ramps to run your accumulator and calculate the slopes with additional multichannel expansion for overlaping the grains up to numChannels (working on a gen~ device which is more clever then the round robin method for distributing the phases for polyphony).

In principle this is already working but the curving of the main ramp with this naive approach gives you curved and not linear sub ramps which distorts the phase of your signals (this is covered by the gen~ device). I will additionally work on different curving functions, right now its only accel up / down with this sigmoid function, but you can pair it with additional trigger masking functions (for example burst, probability etc. got some more) for more rhythmic complexity.
Just for demonstration i have randomized some params here (windows are static in this example):

(
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 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 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) };
	panPositions = Dseq(panPositions ++ Dser([0], centerMask), inf);
	Demand.ar(triggers, 0, panPositions);
};

var multiChannelDemand = { |triggers, paramRange|
	var demand = Dwhite(0, 1);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, demand)
	}.linexp(0, 1, paramRange[0], paramRange[1]);
};

var burstMask = { |trig, burst, rest|
	trig * Demand.ar(trig, 0, Dseq([Dser([1], burst), Dser([0], rest)], inf));
};

var probabilityMask = { |trig, prob|
	trig * CoinGate.ar(prob, trig);
};

var sigmoid = { |phase, curve|
	(phase - (phase * curve)) / (curve - (2 * phase * curve) + 1);
};

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

SynthDef(\test, {

	var numChannels = 5;

	var trigger, measurePhase, stepPhase, stepSlope, stepIndex, stepTrigger;
	var windowSlopes, maxOverlap, triggers, subSampleOffsets, accumulator, chanMask;
	var windowPhases, grainWindows, grainFreqs, grainSlopes, grainPhases, grains, sig;
	var modSlopes, modPhases, pmods, pmIndex, indexWindows, freqWindows, skew;

	trigger = \trig.tr(0);

	// measure phase
	measurePhase = (EnvGen.ar(Env(#[0, 0, 1], [0, \duration.kr(1)], \lin), trigger)).wrap(0, 1);
	measurePhase = sigmoid.(measurePhase, \curve.kr(0));

	// stepPhase, slope & trigger
	stepPhase = (measurePhase * \stepsPerMeasure.kr(16)).wrap(0, 1);
	stepSlope = rampToSlope.(stepPhase);
	stepTrigger = rampToTrig.(stepPhase);

	stepTrigger = burstMask.(stepTrigger, \burst.kr(16), \rest.kr(0));
	stepTrigger = probabilityMask.(stepTrigger, \prob.kr(1));

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

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

	// accumulate subsample accurate multichannel phase
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

	grainFreqs = multiChannelDemand.(triggers, \freqRange.kr([440, 440]));
	grainSlopes = grainFreqs * SampleDur.ir;

	maxOverlap = numChannels * (grainSlopes / Latch.ar(stepSlope, triggers));
	maxOverlap = min(\overlap.kr(1), maxOverlap);

	windowSlopes = grainSlopes / max(0.001, maxOverlap);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);

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

	grainWindows = IEnvGen.ar(Env([0, 1, 0], [0.03, 0.97], [4.0, -4.0]), windowPhases);

	indexWindows = IEnvGen.ar(Env([0, 1, 1, 0], [0.20, 0.60, 0.20], [4.0, 0, -4.0]), windowPhases);

	skew = \skew.kr(0.125);
	freqWindows = cos(transferFunc.(windowPhases, skew) * 2pi).neg * 0.5 + 0.5;

	pmIndex = indexWindows * \pmIndex.kr(2);

	modSlopes = grainSlopes * \pmRatio.kr(1);
	modPhases = (modSlopes * accumulator).wrap(0, 1);
	pmods = sin(modPhases * 2pi) * pmIndex;
	pmods = OnePole.ar(pmods, exp(-2pi * modSlopes));

	grainPhases = (grainSlopes * accumulator).wrap(0, 1);

	grainPhases = grainPhases + (freqWindows * (0.5 - skew) * \glissIndex.kr(2) * maxOverlap);
	grainPhases = grainPhases + (pmods / 2pi);

	grains = sin(grainPhases * 2pi);

	grains = grains * grainWindows;

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

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

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

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

(
Routine({

	s.bind {

		x = Synth(\test, [

			\trig, 1,
			\stepsPerMeasure, 16,
			\duration, 1,

			\overlap, 8,
			\freqRange, [400, 2000],

			// amp & outs
			\amp, -5.dbamp,
			\out, 0,

		]);

	};

	loop {

		rrand(3, 8).wait;

		x.set(
			\trig, 1,
			\stepsPerMeasure, rrand(8, 200),
			\overlap, rrand(0.5, 20),
			\skew, rrand(0.125, 0.5),
			\glissIndex, rrand(0, 3),
			\curve, rrand(-0.75, 0.75),
			\duration, rrand(0.5, 8),
			\panMax, rrand(0, 1),
			\prob, rrand(0.6, 1.0),
			\pmRatio, rrand(0.25, 3),
			\pmIndex, rrand(0, 3),
		);

	};

}).play;
)
1 Like

This sounds exciting. Multichannel expansion will be great! Would it be possible to modulate the curve for measure phase?

i actually dont know. will test that :slight_smile:

1 Like

1 Like

Looks like it’s working now!

My former example where i have been using a triggered EnvGen for the “one-shot measure ramp” was lacking some accurancy. I have now changed the rampToTrig function for this use case and swapped EnvGen for Sweep with clip(0, 1). This gives me a nice initial trigger for the sub ramps and they are ligned up with the measure ramp perfectly. Additionally you dont get a trigger for the “one-shot measure ramps” wrap from 1 to 0 in the end, which results in one more event then you want to have, when defining the number of sub ramps per event.

grafik

The following example is using Pmono and gives a quite nice steady clock with a mixture of language and server side sequencing:

(
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;
	var trig = delta > 0;
	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 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) };
	panPositions = Dseq(panPositions ++ Dser([0], centerMask), inf);
	Demand.ar(triggers, 0, panPositions);
};

var multiChannelDemand = { |triggers, paramRange|
	var demand = Dwhite(0, 1);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, demand)
	}.linexp(0, 1, paramRange[0], paramRange[1]);
};

var sigmoid = { |phase, curve|
	(phase - (phase * curve)) / (curve - (2 * phase * curve) + 1);
};

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

SynthDef(\test, {

	var numChannels = 5;

	var measurePhase, stepPhase, stepSlope, stepIndex, stepTrigger;
	var windowSlopes, maxOverlap, triggers, subSampleOffsets, accumulator, chanMask;
	var windowPhases, grainWindows, grainFreqs, grainSlopes, grainPhases, grains, sig;
	var modSlopes, modPhases, pmods, pmIndex, indexWindows, freqWindows, skew;

	// measure phase
	measurePhase = Sweep.ar(\trig.tr(0), 1 / \sustain.kr(1)).clip(0, 1);

	// stepPhase, slope & trigger
	stepPhase = (measurePhase * \stepsPerMeasure.kr(16)).wrap(0, 1);
	stepSlope = rampToSlope.(stepPhase);
	stepTrigger = rampToTrig.(stepPhase);

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

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

	// accumulate subsample accurate multichannel phase
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

	grainFreqs = multiChannelDemand.(triggers, \freqRange.kr([440, 440]));
	grainSlopes = grainFreqs * SampleDur.ir;

	maxOverlap = numChannels * (grainSlopes / Latch.ar(stepSlope, triggers));
	maxOverlap = min(\overlap.kr(1), maxOverlap);

	windowSlopes = grainSlopes / max(0.001, maxOverlap);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);

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

	grainWindows = IEnvGen.ar(Env(
		[0, 1, 0],
		[0.03, 0.97],
		[4.0, -4.0]
	), windowPhases);

	indexWindows = IEnvGen.ar(Env(
		[0, 1, 1, 0],
		[0.20, 0.60, 0.20],
		[4.0, 0, -4.0]
	), windowPhases);

	skew = \skew.kr(0.125);
	freqWindows = cos(transferFunc.(windowPhases, skew) * 2pi).neg * 0.5 + 0.5;

	pmIndex = indexWindows * \pmIndex.kr(2);

	modSlopes = grainSlopes * \pmRatio.kr(1);
	modPhases = (modSlopes * accumulator).wrap(0, 1);
	pmods = sin(modPhases * 2pi) * pmIndex;
	pmods = OnePole.ar(pmods, exp(-2pi * modSlopes));

	grainPhases = (grainSlopes * accumulator).wrap(0, 1);

	grainPhases = grainPhases + (freqWindows * (0.5 - skew) * \glissIndex.kr(2) * maxOverlap);
	grains = sin(grainPhases * 2pi + pmods);

	grains = grains * grainWindows;

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

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

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

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

(
Pdef(\test,
	Pmono(\test,

		\trig, 1,
		\dur, 4,
		\legato, 1,

		\stepsPerMeasure, 8,//Pwhite(8, 16, inf).trace,

		\overlap, 8,
		\freqRange, [[1000, 1000]],

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		\amp, -15,
		\out, 0,
	),
).play;
)
1 Like

This looks great. What’s your thinking behind the Pmono?

i dont know how expressive this can be compared to LFO based trigger freq modulation, but the idea was to sequence events with (Tdef, Routines, Pbind/Pmono) and additionally curve the main ramp to distribute a fixed amount of sub ramps per main ramp (still thinking of more advanced functions) and apply some trigger masking functions for more micro rhythm complexity. The github issue i have raised about the RNBO export was fixed, but there are still some problems left with the export which have to be fixed before i can export the gen~device to SC, will keep you in the loop. In the meanwhile share your thoughts on this attempt :slight_smile: My aim is to be more flexible composing formal structures of microsound and to be able to pair it with additional instruments on the same clock.

This is just for demonstration purposes:

(
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 trig = delta > 0;
	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 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) };
	panPositions = Dseq(panPositions ++ Dser([0], centerMask), inf);
	Demand.ar(triggers, 0, panPositions);
};

var multiChannelDemand = { |triggers, paramRange|
	var demand = Dwhite(0, 1);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, 0, demand)
	}.linexp(0, 1, paramRange[0], paramRange[1]);
};

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

var probabilityMask = { |trig, prob|
	trig * CoinGate.ar(prob, trig);
};

var triggerMask = { |trig, triggerMaskOn, sequence, seqSize|
	var triggerMask = Demand.ar(trig, 0, Dseq([Dser(sequence, seqSize)], inf));
	trig * Select.ar(triggerMaskOn, [K2A.ar(1), triggerMask]);
};

SynthDef(\test, {

	var numChannels = 5;

	var measurePhase, stepPhase, stepSlope, stepIndex, stepTrigger;
	var windowSlopes, maxOverlap, triggers, subSampleOffsets, accumulator, chanMask;
	var windowPhases, grainWindows, grainFreqs, grainSlopes, grainPhases, grains, sig;
	var modSlopes, modPhases, pmods, pmIndex, indexWindows, freqWindows, skew;

	// measure phase
	measurePhase = Sweep.ar(\trig.tr(0), 1 / \sustain.kr(1)).clip(0, 1);

	// stepPhase, slope & trigger
	stepPhase = (measurePhase * \stepsPerMeasure.kr(16)).wrap(0, 1);
	stepSlope = rampToSlope.(stepPhase);
	stepTrigger = rampToTrig.(stepPhase);

	stepTrigger = probabilityMask.(stepTrigger, \prob.kr(1));

	stepTrigger = triggerMask.(
		stepTrigger,
		\stepSeqOn.kr(1),
		\arrayOfSteps.kr(Array.fill(16, 1)),
		\stepSeqSize.kr(7)
	);

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

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

	// accumulate subsample accurate multichannel phase
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

	grainFreqs = multiChannelDemand.(triggers, \freqRange.kr([440, 440]));
	grainSlopes = grainFreqs * SampleDur.ir;

	maxOverlap = numChannels * (grainSlopes / Latch.ar(stepSlope, triggers));
	maxOverlap = min(\overlap.kr(1), maxOverlap);

	windowSlopes = grainSlopes / max(0.001, maxOverlap);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);

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

	grainWindows = IEnvGen.ar(Env(
		[0, 1, 0],
		[0.03, 0.97],
		[4.0, -4.0]
	), windowPhases);

	indexWindows = IEnvGen.ar(Env(
		[0, 1, 1, 0],
		[0.20, 0.60, 0.20],
		[4.0, 0, -4.0]
	), windowPhases);

	skew = \skew.kr(0.125);
	freqWindows = cos(transferFunc.(windowPhases, skew) * 2pi).neg * 0.5 + 0.5;

	pmIndex = indexWindows * \pmIndex.kr(2);

	modSlopes = grainSlopes * \pmRatio.kr(1);
	modPhases = (modSlopes * accumulator).wrap(0, 1);
	pmods = sin(modPhases * 2pi) * pmIndex;
	pmods = OnePole.ar(pmods, exp(-2pi * modSlopes));

	grainPhases = (grainSlopes * accumulator).wrap(0, 1);

	grainPhases = grainPhases + (freqWindows * (0.5 - skew) * \glissIndex.kr(2) * maxOverlap);
	grains = sin(grainPhases * 2pi + pmods);

	grains = grains * grainWindows;

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

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

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

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

(
var rhythm = [1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1];

Pdef(\test,
	Pmono(\test,

		\trig, 1,
		\dur, Pseq([1, 3, 2], inf),
		\legato, 1.0,

		\stepsPerMeasure, Pseq([11, 23, 31], inf),

		\stepSeqOn, 1,
		\stepSeqSize, rhythm.size,
		\arrayOfSteps, [[rhythm]],

		\glissIndex, 0,

		\pmRatio, 3,
		\pmIndex, 2,

		\overlap, 20,
		\freqRange, [[1000, 2000]],

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		// amp & outs
		\amp, -15,
		\out, 0,

	),
).play;
)

I like this idea of combining language- and server-side sequencing here. I could see this being useful for a more structured approach to developing phrases and variations