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