Grain envelopes: Gotcha

Taking a minute to describe a gotcha that tripped me up today.

TL;DR – Do not trust audio-rate triggering of EnvGen if the EnvGen’s parameters are being latched to the trigger.

TL;DR – If you’re rolling your own granular synthesis, find @dietcv 's threads and read his windowing functions carefully – using them instead of EnvGen may save you time in the long run.

So – I was working on a synth with FMGrain – OK, but (as expected) aliasing at high frequencies.

A-ha, there’s PMOscOS – an oversampling phase-modulation oscillator! That should reduce the aliasing. I just need to run my own grain envelopes, which I’ve done before, no problem.

I’ll plot just the envelopes, to show the issue.

(
{ |tfreq = 440, overlap = 2|
	var n = 4;
	var trig = Impulse.ar(tfreq);
	var t = PulseDivider.ar(trig, n, (0..n-1));
	var dur = overlap / tfreq;
	EnvGen.ar(
		Env.sine(1),  // Hann window
		t,
		// tfreq and overlap may modulate,
		// so this grain's duration needs to be grabbed and frozen
		timeScale: Latch.ar(K2A.ar(dur), t)
	)
}.plot;
)

The problem (as it took me way too much time to figure out) is that EnvGen reads its inputs at control rate, even when it’s being triggered at audio rate.

In the fourth channel here, the trigger coincides with the first control block boundary, so Latch provides the dur value on time, and this envelope is fine.

In the others, the trigger is in the middle of a control block. Latch passes the value through in the middle of the control block, but EnvGen has already sampled the old timeScale (== 0.0), and renders a single sample blip.

There’s no workaround for EnvGen. So I ended up running a linear ramp after each trigger, and shaping that into a Hann window.

(
{ |tfreq = 440, overlap = 2|
	var n = 4;
	var trig = Impulse.ar(tfreq);
	var t = PulseDivider.ar(trig, n, (0..n-1));

	// Sweep runs even when not triggered;
	// freeze it at 0 until a trigger has occurred
	var channelHadTrig = PulseCount.ar(t) > 0;

	// now we're using a rate, not a dur, so invert the fraction
	var rates = Latch.ar(K2A.ar(tfreq / overlap), t);

	// and... K2A means the new value isn't immediately available
	// so, push the grain envelope trigger back by one sample
	var sweeps = Sweep.ar(Delay1.ar(t), channelHadTrig * rates);

	// alt: IEnvGen.ar(Env.sine(1), sweeps)
	sin(sweeps.clip(0, 1) * pi).squared
}.plot(duration: 0.01);
)

If we had a non-interpolating K2A, then the Delay1 would not be needed – but we don’t, so, add another workaround to the pile.

Whew.

hjh

Can that be changed?

Naively, for all ugens I’d expect that if one of the arguments is audio then everything should be processed at audio rate, control rate should only be used if they are all control rate.

3 Likes

That’s not exactly the issue with EnvGen. In EnvGen, you can specify all audio-rate inputs, but only the gate is actually read at audio rate. The envelope parameters are treated as kr, no matter what the actual input is. (Most of the time, envelope parameters don’t change, so it’s not as bad as it sounds, apart from edge cases such as the one I’m complaining about here.)

It becomes a combinatorics problem: an ADSR envelope has 3 segments = 12 inputs, plus the 3 scaling and offset inputs. These could in principle be any combination of audio or control rate = 2^15 = 32768 combinations, where audio rate needs to step through a wire buffer, but if you tried to step through a wire buffer for a kr input, you might crash. So I can understand why one might just avoid the problem by declaring envelope inputs to be consumed at kr only.

But that conflicts with audio-rate triggering/gating.

I’m sure it’s fixable; I don’t have the C chops for it though.

hjh

This might be Important for you or you can just ignore it:

In general its important for this pulsarish synthesis to reset your carrier waveform with your scheduling trigger. Basically granulation starts from hard sync and you apply a window function on phase reset to smooth out the hard edge.
If your carrier doesnt have a phase reset (e.g. PMOscOS) the result is very different and probably not what you want. I always use OscOs for these purposes and manually add phase modulation. Otherwise you dont get a clean tone from audio rate scheduling.

see the plot and have listen / look at the freqscope for this A/B example with carrier = SinOsc.ar(DC.ar(0), grainPhase * 2pi); vs. //carrier = SinOsc.ar(grainFreq); from my NOTAM guide:

// - the carrier waveform needs a phase reset
// - the carrier gets reset by the trigger derived from the scheduling phasor (hard sync)
// - to smooth out the phase reset a window function is applied to the carrier (windowed sync)

(
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 getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase / slope;
	Latch.ar(sampleCount, trig);
};

var accumSubSample = { |trig, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
	accum + subSampleOffset;
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

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

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, grainPhase;
	var carrier, grain;

	eventPhase = Phasor.ar(DC.ar(0), \tFreq.kr(100) * SampleDur.ir);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = hanningWindow.(windowPhase, \skew.kr(0.5));

	grainFreq = \freq.kr(440);
	grainSlope = grainFreq * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	carrier = sin(grainPhase * 2pi);

	grain = carrier * grainWindow;

	[windowPhase.wrap(0, 1), eventTrigger, grainPhase, carrier, grain];
}.plot(0.021);
)

// 1.2.2.) compare hard sync with no hard sync

(
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 getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase / slope;
	Latch.ar(sampleCount, trig);
};

var accumSubSample = { |trig, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
	accum + subSampleOffset;
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

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

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, grainPhase;
	var carrier, grain;

	eventPhase = Phasor.ar(DC.ar(0), \tFreq.kr(100) * SampleDur.ir);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = hanningWindow.(windowPhase, \skew.kr(0.5));

	grainFreq = \freq.kr(440);
	grainSlope = grainFreq * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	carrier = SinOsc.ar(DC.ar(0), grainPhase * 2pi);
	//carrier = SinOsc.ar(grainFreq);

	grain = carrier * grainWindow;

	grain = LeakDC.ar(grain);
	grain!2 * 0.1;

}.play;
)

s.freqscope;
1 Like

True, but I don’t want a clean tone – I want a tremolo-style alternation between two different PM modulator ratios. Overlap == 2 or 4 would be smooth; I’m finding overlap == 1.4 is just about the sound I want.

hjh

Yeah, thats cool. Just wanted to point that out :slight_smile: Thats just another thing beside using envelopes vs. stateless functions which one should be aware of when doing things on the server, especially for audio rate scheduling. These two things are implicitly done for the user when they are using event based scheduling with Pbinds, but for setting up polyphony on the server these might be important.

Actually just found a way: Demand.ar(arTrig, 0, krSignal) can sample-and-hold mid-block without the linear interpolation that K2A does:

(
{
	var kr = Line.kr(0, 1, 0.01);
	var dust = Dust.ar(300) > 0;
	var shN = Demand.ar(dust, 0, kr);
	var shL = Latch.ar(K2A.ar(kr), dust);
	// promote an entire kr signal to ar without interpolation
	var krWorkaround = Duty.ar(ControlDur.ir, 0, kr);
	[krWorkaround, shN, shL, dust]
}.plot
)

Note that, for the first and third triggers, the third channel undershoots the desired value because K2A is making a ramp within the control block. See also Add an argument to K2A to switch linear interpolation on or off · Issue #5803 · supercollider/supercollider · GitHub, which points out that K2A makes a wrong assumption that interpolation is always desirable. I’d say, for sample-hold use, interpolation is not appropriate. At least there’s a demand-rate workaround.

hjh