Crossfade buffered envelopes for pulsar synthesis

hey, would it be possible to crossfade between different buffered envelopes inside the SynthDef using Playbuf or Phasor / BufRd and multiply it with SinOsc for pulsar synthesis? instead of sequencing them with patterns.
Overlapping grains inside the SynthDef would be another thing then using Pulsedivider or other options…
thanks.

(
// custom granular envelopes
~getCustomEnvs = {
	var tri = Env([0, 1, 0], [1, 1], \lin);
	var perc = Env.perc(0.01, 1, 1, -4);
	var customEnvs = [tri, perc];
	customEnvs.collect{|env| Buffer.sendCollection(s, env.discretize(2048), 1) };
};
~customEnvs = ~getCustomEnvs.();
)

(
~customEnvs[0].plot;
~customEnvs[1].plot;
)
(
SynthDef(\grain, { |envBuf|

	var trig = \trig.tr(1);
	var sig, phasor, env;

	var numPartials = 64;
	var n = (1..numPartials);

	// harmonic tension
	var tension = (1 + (n * n * \inharmonic.kr(0.005))).sqrt;

	// frequency spectrum
	var freqs = \freq.kr(68) * n * tension;

	// 3db/octave spectral tilt
	var tilt = (log2(n) * \tilt.kr(-3)).dbamp;

	var combDensity = { LFDNoise3.ar(0.3).linlin(-1, 1, 1, 2) } ! 2;
	var comb = ((1 - (freqs.log2 * combDensity).sin.abs) ** \peak.kr(2));

	sig = SinOsc.ar(freqs, { Rand(0, 2pi) } ! numPartials);

	sig = sig * tilt * comb;

	sig = sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum);
	sig = sig * -10.dbamp;

	env = PlayBuf.ar(1, envBuf, ( \granDur.kr(0.1) / BufDur.ir(envBuf) ).reciprocal, trig, doneAction: 0);

	//phase = EnvGen.ar(Env([0, 1], [\granDur.kr(0.1)]), trig, doneAction:2);
	//env = BufRd.ar(1, envBuf, phase * BufFrames.kr(envBuf), 1, 4);

	sig = sig * env * \amp.kr(0.25);

	OffsetOut.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\grain,
	Pmono(\grain,

		\bufIndex, Pdup(4, Pseq([0, 1], inf)),
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },

		\freq, 68,

		\tFreq, Pseg([12, 5], [5], \exp, inf),
		\legato, Pseg([0.35, 0.75], [10], \exp, inf),
		\dur, 1 / Pkey(\tFreq),

		\granDur, Pkey(\dur) * Pkey(\legato),

		\amp, 0.25,
		\out, 0,
	)
).play;
)

Of course… just about anything is possible, if you take it apart and implement it step by step.

But… do you really mean to crossfade between envelopes?

Your example specifies a minimum trigger frequency = 5 Hz and maximum legato = 0.75, translating to a maximum grain duration of 200 ms * 0.75 = 150 ms. I doubt that mid-grain changes in envelope shape would be perceptually meaningful.

If I want total control over the grain processing and a grain cloud within one SynthDef, I usually start by splitting up an Impulse train into /n/ channels (where /n/ is the maximum overlap I need).

var n = 12;  // max overlap 12 grains

var trig = Impulse.ar(tFreq);
var trigs = PulseDivider.ar(trig, n, (0 .. n-1));

If that’s hard to visualize, plot it:

// plot example, using Decay to highlight the pulses better
{
	var n = 4;
	var trig = Impulse.ar(440);
	var trigs = PulseDivider.ar(trig, n, (0 .. n-1));
	Decay.ar(trigs, 0.005)
}.plot(0.02);

Then you could TRand or use demand-rate UGens to choose a different envelope buffer for each grain.

hjh

1 Like

when i was trying to conceptualize it, I thought the first envelope should start when it receives a trigger and all the envelopes should be scaled to share the same length, but not sure about crossfading and ending.

This is a valid point. The reason for thinking about crossfading envelopes was the idea of taking simple waveforms and complex envelopes instead of complex waveforms and simple envelopes. (tri and perc are probably not the best examples for complex envelopes). thanks for the PulseDivider example i will try that :slight_smile:

This hybrid approach of Pmono based triggers instead of Impulse.ar for SynthDef overlapping grains is also still a headscratcher for me.

ive tried to implement the Pulsedivider approach but get a louder initial hit and discontinuities.
whats wrong here?

EDIT:
Do I get the discontinuities because when using Pmono \granDur cannot be longer then \dur?
And the louder initial hit is because the phases have to get an offset?

Im studying the code of this post right now:
https://listarc.cal.bham.ac.uk/lists/sc-users/msg67591.html

(
SynthDef(\grain, { |envBuf|

	var sig, trig, env;
	var maxOverlap = 4;

	sig = SinOsc.ar(\freq.kr(440) * (1..4));

	trig = PulseDivider.kr(\trig.tr(1), maxOverlap, (0 .. maxOverlap-1));

	env = PlayBuf.ar(1, envBuf, ( \granDur.kr(0.1) / BufDur.ir(envBuf) ).reciprocal, trig);

	sig = (sig * env).sum;

	sig = Pan2.ar(sig, \pan.kr(0), \amp.kr(0.25));
	OffsetOut.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\grain,
	Pmono(\grain,

		\bufIndex, Pdup(4, Pseq([0,1], inf)),
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },

		\freq, 440,

		\overlap, 3,
		\tFreq, 5, //Pseg([12, 5], [5], \exp, inf),
		\dur, 1 / Pkey(\tFreq),

		\granDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.25,
		\out, 0,
	)
).play;
)

I think it’s because the envBuf value is shared between all 4 PlayBufs. The next event’s envBuf changes all of them at once. If overlap > 1, then this means that a currently sounding envelope is instantaneously changing to a different shape.

I think sample-and-hold will help you here.

env = PlayBuf.ar(1,
    Latch.kr(envBuf, trig),
    // also simplifying this formula
    // (b/a).reciprocal == a/b
    // so .reciprocal is just wasting CPU time
    Latch.kr(BufDur.ir(envBuf) / \granDur.kr(0.1), trig),
    trig
);

hjh

1 Like

ive implemented the Sample and Hold and it works great.
the louder initial hit also went away, thanks :slight_smile:

is there any advantage in using now Demand Rate Ugens instead of Pmono for the sequencing different envBufs and is my Demand Ugen approach correct?

compare these two examples:

1.) sequencing envBufs with Demand Rate Ugens (BufDur is now .kr instead of .ir)

(
SynthDef(\grain, {

	var sig, trig, env, envBuf;
	var maxOverlap = 4;
	
	sig = SinOsc.ar(\freq.kr(110) * (1..4));

	trig = PulseDivider.kr(\trig.tr(1), maxOverlap, (0 .. maxOverlap-1));
	
	envBuf = Demand.kr(trig, 0, Dseq([Dser(\envBufs.kr(#[0,0,0,0]), \envBufMod.kr(4))], inf));
	env = PlayBuf.ar(1, Latch.kr(envBuf, trig), Latch.kr(BufDur.kr(envBuf) / \granDur.kr(0.1), trig), trig);

	sig = (sig * env).sum;

	sig = Pan2.ar(sig, \pan.kr(0), \amp.kr(0.25));
	OffsetOut.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\grain,
	Pmono(\grain,
		
		\envSequence, [0, 1, 0, 1],
		\envBufs, Pfunc { |ev| ev[\envSequence].collect{ |env| ~customEnvs[env] } }.collect(`_), 
		\envBufMod, 4,
			
		\freq, 440,

		\overlap, 2.0,
		\tFreq, 5,
		\dur, 1 / Pkey(\tFreq),

		\granDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.25,
		\out, 0,
	)
).play;
)

2.) sequencing envBuf via Pmono

(
SynthDef(\grain, { |envBuf|

	var sig, trig, env;
	var maxOverlap = 4;
	
	sig = SinOsc.ar(\freq.kr(110) * (1..4));

	trig = PulseDivider.kr(\trig.tr(1), maxOverlap, (0 .. maxOverlap-1));
	
	env = PlayBuf.ar(1, Latch.kr(envBuf, trig), Latch.kr(BufDur.ir(envBuf) / \granDur.kr(0.1), trig), trig);

	sig = (sig * env).sum;

	sig = Pan2.ar(sig, \pan.kr(0), \amp.kr(0.25));
	OffsetOut.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\grain,
	Pmono(\grain,

		\bufIndex, Pseq([0,1], inf),
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },
			
		\freq, 440,

		\overlap, 2,
		\tFreq, 5,
		\dur, 1 / Pkey(\tFreq),

		\granDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.25,
		\out, 0,
	)
).play;
)

3.) and what is the advantage vs. just using GrainSin?

(
SynthDef(\grainSin, { |envBuf|

	var trig = \trig.tr(1);
	var sig;

	sig = GrainSin.ar(
		numChannels: 1,
		trigger: trig,
		dur: \grainDur.kr(1),
		freq: \freq.kr(440),
		pan: 0,
		envbufnum: envBuf,
		maxGrains: 2048
	);

	sig = Pan2.ar(sig, \pan.kr(0), \amp.kr(0.25));
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\grainSin,
	Pmono(\grainSin,

		\bufIndex, Pseq([0, 1], inf),
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },

		\freq, 440 * Pseq((4..1), inf),

		\overlap, 2.0,
		\tFreq, 5,
		\dur, 1 / Pkey(\tFreq),

		\grainDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.25,
		\out, 0,
	)
).play;
)

ive tried to replace GrainBufJ with the pulsedivider / playbuf combination but its not sounding good:

why are these two examples not sounding the same or how can i get them sounding the same.
and why does the PulseDivider / Playbuf combination sound distorted?

1. ) sounds nice with GrainBufJ:

(
// custom granular envelopes
~getCustomEnvs = {
	var tri = Env([0, 1, 0], [1, 1], \lin);
	var perc = Env.perc(0.01, 1, 1, -4);
	var customEnvs = [tri, perc];
	customEnvs.collect{|env| Buffer.sendCollection(s, env.discretize(2048), 1) };
};
~customEnvs = ~getCustomEnvs.();
)

(
SynthDef(\pulsar, { |envBuf|

	var trig = \trig.tr(1);
	var sig, pan;
	
	var spectrum = [ 1.0, 2.2954172674277, 3.5984846739581, 4.9032805732124, 6.2087321305725, 7.514500962484, 8.8204471056119, 10.126502295694, 11.432629299891, 12.738806093605 ];

	var tension = (1 + (spectrum * spectrum * \inharmonic.kr(0.001))).sqrt;
	var freqs = \freq.kr(68, 0.3) * spectrum * tension;
	var tilt = (log2(spectrum) * \tilt.kr(-3)).dbamp;

	pan = Demand.kr(trig, 0, Dseq([-1, 1], inf) * \panMax.kr(0.8));

	sig = GrainSinJ.ar(
		numChannels: 2,
		trigger: trig,
		dur: \granDur.kr(1),
		freq: freqs,
		grainAmp: tilt,
		pan: pan,
		envbufnum: envBuf,
		maxGrains: 2048
	);

	sig = sig.sum;
	
	sig = sig * -25.dbamp;

	sig = sig * \amp.kr(0.25);

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

(
Pdef(\pulsar,
	Pmono(\pulsar,
		
		\freq, 51.913,

		\bufIndex, 1,
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },

		\tFreq, Pseg([51.913, 5.913], [5], \exp, inf),
		\dur, 1 / Pkey(\tFreq),
		\overlap, 1,

		\granDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.5,
		\out, 0,
	)
).play;
)

2. ) sounds distorted with Pulsedivider / Playbuf:

(
SynthDef(\pulsar, { |envBuf|

	var trig = \trig.tr(1);
	var sig, pan, env;
	var maxOverlap = 4;
	
	var spectrum = [ 1.0, 2.2954172674277, 3.5984846739581, 4.9032805732124, 6.2087321305725, 7.514500962484, 8.8204471056119, 10.126502295694, 11.432629299891, 12.738806093605 ];

	var tension = (1 + (spectrum * spectrum * \inharmonic.kr(0.001))).sqrt;
	var freqs = \freq.kr(68, 0.3) * spectrum * tension;
	var tilt = (log2(spectrum) * \tilt.kr(-3)).dbamp;
	
	trig = PulseDivider.kr(trig, maxOverlap, (0 .. maxOverlap-1));
	env = PlayBuf.ar(1, Latch.kr(envBuf, trig), Latch.kr(BufDur.kr(envBuf) / \granDur.kr(0.1), trig), trig);

	pan = Demand.kr(trig, 0, Dseq([-1, 1], inf) * \panMax.kr(0.8));
	
	sig = SinOsc.ar(freqs);	

	sig = (sig * tilt).sum;
	
	sig = sig * -25.dbamp;
	
	sig = Pan2.ar(sig, pan, \amp.kr(0.25));
	
	sig = (sig * env).sum;

	OffsetOut.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\pulsar,
	Pmono(\pulsar,
		
		\freq, 51.913,

		\bufIndex, 1,
		\envBuf, Pfunc{ |ev| ~customEnvs[ev[\bufIndex]] },

		\tFreq, Pseg([51.913, 5.913], [5], \exp, inf),
		\dur, 1 / Pkey(\tFreq),
		\overlap, 1,

		\granDur, Pkey(\dur) * Pkey(\overlap),

		\amp, 0.5,
		\out, 0,
	)
).play;
)

hey, beside the Playbuf implementation which is distorting the sound, i dont understand why the initial hit is maxOverlap times louder. i think i just stick with GrainBuf, GrainSin etc. im not able to make this work. thanks.

(
// custom granular envelopes
~getCustomEnvs = {
	var tri = Env([0, 1, 0], [1, 1], \lin);
	var perc = Env.perc(0.01, 1, 1, -4);
	var customEnvs = [tri, perc];
	customEnvs.collect{|env| Buffer.sendCollection(s, env.discretize(2048), 1) };
};
~customEnvs = ~getCustomEnvs.();
)

~envBuf = ~customEnvs[1];

(
SynthDef(\grains, {
	
	var maxOverlap = 4;
	var trigRate = \trigRate.kr(10);
	var sig, trig, gainEnv;
			
	gainEnv = EnvGen.kr(Env.asr(0.01, 1, 0.01), \gate.kr(1), doneAction: 2);
	
	trig = Impulse.ar(trigRate);
	
	sig = Array.fill(maxOverlap, { |i|
		
		var localTrig, startPhase, grainDur, phase, grainEnv;
		
		localTrig = PulseDivider.ar(trig, maxOverlap, i);
		startPhase = 0;
		grainDur = \overlap.kr(1) / trigRate;
		
		//phase = Sweep.ar(localTrig, 1);	
		//grainEnv = IEnvGen.ar(Env([0, 1, 0], [0.001, 0.5], \lin), phase / grainDur);
		//sig = BufRd.ar(1, bufnum, phase * BufSampleRate.kr(bufnum) + startPhase, loop: 0);
		
		grainEnv = PlayBuf.ar(1, ~envBuf, BufDur.ir(~envBuf) / \granDur.kr(0.1), localTrig);	
		sig = SinOscFB.ar(\freq.kr(440), 0.6);
		
		sig = sig * grainEnv;
		
		sig = Pan2.ar(sig, i.linlin(0, maxOverlap - 1, -1, 1));
	}).sum;
	
	sig = sig * gainEnv * \amp.kr(0.1);
	
	Out.ar(\out.kr(0), sig);
}).add;
)

Synth(\grains, [\trigRate, 1, \freq, 440, \overlap, 0.25]);

EDIT: its seems that the louder initial hits is somehow related to grainDur:

(
{
	
	var maxOverlap = 8;
	var tFreq = \tFreq.kr(5);
	var trig = Impulse.ar(tFreq);
	var sig;
	
	sig = Array.fill(maxOverlap, { |i|
		
		var localTrig, grainDur, phase, grainEnv, freqEnv;
		var grain;
		
		localTrig = PulseDivider.ar(trig, maxOverlap, i);
		grainDur = \overlap.kr(0.5) / tFreq;

		//phase = Sweep.ar(localTrig, 1);
		//grainEnv = IEnvGen.ar(Env.perc(0.01, 0.97), phase / grainDur);
		
		grainEnv = EnvGen.ar(Env.perc(0.01, 0.97), localTrig);
		
		//grainEnv = PlayBuf.ar(1, ~envBuf, BufDur.kr(~envBuf) / grainDur, localTrig);
		
		grain = SinOsc.ar(440);
		
		grain = grain * grainEnv;
	});
	
	sig = Mix(sig);
	
	sig = sig * \amp.kr(0.50);

}.play;
)

Oops… partial post… fixed here:

A good approach is to take the synthdef apart and check components individually.

If the initial grain is louder, it would suggest that all of the channels’ envelopes are opening. (One clue here would be panning.) But we also know PulseDivider is fine (from my earlier plotexample).

So perhaps check the behavior of PlayBuf with different trigger values.

(
var dur = 0.01;
{
	var trig = DC.kr(1);  // nonzero trigger
	PlayBuf.ar(1, b, BufDur.ir(b) / dur, trig)
}.plot;
)

(
var dur = 0.01;
{
	var trig = DC.kr(0);  // guaranteed zero trigger
	PlayBuf.ar(1, b, BufDur.ir(b) / dur, trig)
}.plot;
)

In both cases, the plot is the same.

So, unlike EnvGen, a zero trigger does not prevent PlayBuf from starting. (I forgot about this until now…)

One solution/workaround might be to hold the playback rate at zero until at least one trigger has been received.

(
// custom granular envelopes
~getCustomEnvs = {
	var tri = Env([0, 1, 0], [1, 1], \lin);
	var perc = Env.perc(0.01, 1, 1, -4);
	var customEnvs = [tri, perc];
	customEnvs.collect{|env| Buffer.sendCollection(s, env.discretize(2048), 1) };
};
~customEnvs = ~getCustomEnvs.();
)

(
SynthDef(\grains, { |envBuf|
	
	var maxOverlap = 4;
	var trigRate = \trigRate.kr(10);
	var sig, trig, grainDur, gainEnv;
			
	gainEnv = EnvGen.kr(Env.asr(0.01, 1, 0.01), \gate.kr(1), doneAction: 2);
	
	trig = Impulse.ar(trigRate);
	
	// btw it is less efficient to put this inside the loop
	// because it is always the same!
	// Do you want the server to do the same calculation 4 times,
	// or only once?
	grainDur = \overlap.kr(1) / trigRate;
	
	sig = Array.fill(maxOverlap, { |i|
		
		var localTrig, phase, grainEnv;
		var hasTriggered;
		
		localTrig = PulseDivider.ar(trig, maxOverlap, i);
		hasTriggered = PulseCount.ar(localTrig) > 0;
		// startPhase = 0;  // you're not using this, delete
		
		grainEnv = PlayBuf.ar(1, envBuf,
			hasTriggered * BufDur.ir(envBuf) / grainDur,
			localTrig
		);
		sig = SinOscFB.ar(\freq.kr(440), 0.6);
		
		sig = sig * grainEnv;
		
		sig = Pan2.ar(sig, i.linlin(0, maxOverlap - 1, -1, 1));
	}).sum;
	
	sig = sig * gainEnv * \amp.kr(0.1);
	
	Out.ar(\out.kr(0), sig);
}).add;
)

Synth(\grains, [\trigRate, 1, \freq, 440, \overlap, 0.25, envBuf: ~customEnvs[0]]);

hjh

1 Like

thank you very much, that is fixing the initial louder hit :slight_smile:
do you have suggestions for handling higher trigRates at around 50-100 more smoothly?

Synth(\grains, [\trigRate, 55, \freq, 440, \overlap, 0.25, envBuf: ~customEnvs[1]]);

ive seen this other thread whichs seems kind of related to this topic:

ive tried out to swap Playbuf for PlaybufCF but thats not as smooth as i was expecting compared to silky smooth with the same perc envelope and GrainSin.
Ive also looked at Osc1 which is not working together with Pmono i guess.

Grain length = overlap / trigrate = 0.25 / 55 = about 4.5 ms per grain. I’m fairly sure it will be incredibly difficult to get a smooth sound with this.

The smoothest sound would be obtained with a Hann window (not triangular, and definitely not percussive) and an exactly even overlap. (Edit: A triangular window with even overlap may also sound smooth. Hann window is probably a bit better because its slope is continuous, which is not true of a triangular window. I didn’t test that.)

Also, panning each grain channel differently will result in amplitude modulation artifacts in each channel. The smoothness of granular synthesis depends on grains that are fading in to cancel out other grains’ fade-out. Splaying the channels is good for testing, but it isn’t a good idea if you want a nice, clean, smooth sound.

(
SynthDef(\grains, { |envBuf|
	
	var maxOverlap = 4;
	var trigRate = \trigRate.kr(10);
	var sig, trig, grainDur, gainEnv;
			
	gainEnv = EnvGen.kr(Env.asr(0.01, 1, 0.01), \gate.kr(1), doneAction: 2);
	
	trig = Impulse.ar(trigRate);
	
	// btw it is less efficient to put this inside the loop
	// because it is always the same!
	// Do you want the server to do the same calculation 4 times,
	// or only once?
	grainDur = \overlap.kr(1) / trigRate;
	
	sig = Array.fill(maxOverlap, { |i|
		
		var localTrig, phase, grainEnv;
		var hasTriggered;
		
		localTrig = PulseDivider.ar(trig, maxOverlap, i);
		hasTriggered = PulseCount.ar(localTrig) > 0;
		// startPhase = 0;  // you're not using this, delete
		
		grainEnv = PlayBuf.ar(1, envBuf,
			hasTriggered * BufDur.ir(envBuf) / grainDur,
			localTrig
		);
		sig = SinOscFB.ar(\freq.kr(440), 0.6);
		
		sig = sig * grainEnv;
		
		// sig = Pan2.ar(sig, i.linlin(0, maxOverlap - 1, -1, 1));
	}).sum;
	
	sig = sig * gainEnv * \amp.kr(0.1);
	
	Out.ar(\out.kr(0), sig.dup);
}).add;
)

~hann = Buffer.sendCollection(s, Signal.hannWindow(2048));

Synth(\grains, [\trigRate, 55, \freq, 440, \overlap, 4, envBuf: ~hann]);

After deleting the panning and using a Hann window with overlap 4 (or 2, both work just as well), all of the amplitude modulation artifacts completely disappear.

This tells you that the grain logic is working transparently – then, you could try other envelopes to introduce artifacts creatively (understanding that any change away from the ideal case posted here will be less clean and less transparent – as a creative choice).

hjh

1 Like

thank you very much for the detailed explanation :slight_smile:
i think ive now a more or less working replication of GrainSin with Pulsedivider and custom Envelopes with further possibilties of adjustment.
Actually the initial louder hit was also found in the example you have once shared with Sweep and IEnvGen from this thread: Re: [sc-users] pulse overlap within synthdef?