Modulate an LPF Freq cutoff using an envelope or LFO

Hi,
So I’m wondering how I could modulate a LPF cutoff with an envelope?
Preferably using the same envelope that’s triggered when a synthdef plays a note.
I have a synthdef with all of it’s oscillators assigned to pass through an adsr envelope when a note
is triggered. I want to pass that through a Low Pass Filter whose frequency cutoff value changes
from let’s say 200 hz to 700 hz as a note is triggered.

Is there some way I could write something like this:

LPF.ar(output, gen, 1.0, 0.0);

where output is a variable that contains all of my oscillators outputs after they’ve passed through an asdr envelope and gen is the variable for the asdr envelope being generated by the envgen unit generator?

I want the freq cutoff to change from say 200 hz to 700 hz. I guess I’d almost want the asdr envelope shape to act as an LFO that triggers when a note triggers.

It would be very helpful to know how to change a value inside a unit generator that way because then theoretically I could modulate all kinds of parameters. I saw this inside the example code:

LPF.ar(Saw.ar(200,0.1), SinOsc.kr(XLine.kr(0.7,300,20),0,3600,4000));
I tried using that and changing the input from a saw wave to the output from my oscillators+envelope
and that works but I want it to trigger and release each time a note is played. The example code has a sinewave as the LFO shape that’s shifting the cutoff and I’d want my own custom shape, something like an asdr envelope instead.

Is that even possible in SC or should I settle for a sinewave osc like in the example code and focus on getting it to just shift the cutoff between two different values as the note is played?

You can definitely use the same envelope for controlling different things in the same Synth.

The adsr envelope will range between 0 and 1, which is good for amplitude, but not specifically for controlling the cutoff of a filter. You’ll need to scale the value of the envelope when setting it to control the LPF’s cutoff.

There are several ways to do this:

You can either straight up multiply and/or add values to it to get it into the range you’re after. If you want to control from 200 Hz to 700 Hz, you can multiply the envelope by 500 and add 200. This way when the envelope is at 0, the cutoff is at 200, and when the envelope peaks at 1, the cutoff will be at 700.

LPF.ar(Saw.ar(200, 0.1), env * 500 + 200);

Or you can using a scaling method like linlin, or linexp and have it do the math for you, providing the bottom and top range.

LPF.ar(Saw.ar(200, 0.1), env.linlin(0, 1, 200, 700));

I personally find both of these a bit inflexible because because if you were to break them out into arguments in your SynthDef all of a sudden you need to provide two values, which need to sync with a third- freq. Also, you’ll always need to use the envelope when calling the Synth.

The way I typically do it mimics a typical filter envelope control on any subtractive synth, I have a cutoff parameter, and then I can also apply the envelope if I want to:

LFP.ar(Saw.ar(200, 0.1), cutoff + env.linlin(0, 1, 0, envAmount));

This way you can set envAmount to be 0 and just get a regular control for the cutoff.

For envAmount you can have scaling math elsewhere so that a 0-1 value will allow you to sweep frequency, but personally I just put in the amount in Hertz. And envAmount can be negative to sweep downward.

(
SynthDef(\synth, { |out, a = 0.01, d = 0.3, s = 0.5, r = 4, gate = 1, freq = 440, cutoff = 3000, envAmount = 200, amp = 0.2|
	var env, sig;
	env = Env.adsr(a, d, s, r).kr(Done.freeSelf, gate);
	sig = Saw.ar(freq) ! 2;
	sig = LPF.ar(sig, cutoff + env.linlin(0, 1, 0, envAmount));
	sig = sig * env * amp;
	Out.ar(out, sig);
}).add
)

a = Synth(\synth, [a: 4, d: 1.2, s: 0.3, r: 4, freq: 200, cutoff: 200, envAmount: 500])  // env will sweep from cutoff to (cutoff + envAmount)
a.set(\gate, 0) // release envelope and synth

// no envelope used on filter:
a = Synth(\synth, [a: 4, d: 1.2, s: 0.3, r: 4, freq: 200 cutoff: 200, envAmount: 0])
a.set(\cutoff, 600)
a.set(\gate, 0)

You can apply the same scaling strategy to use an oscillator UGen as an LFO in place of the envelope too. You just need to know the default range it oscillates between. It’s typically in the help files, but I also stick the poll message onto things in SynthDefs to see how they behave over time. For instance in that SynthDef, sig = LPF.ar(sig, cutoff + env.linlin(0, 1, 0, envAmount).poll); will show me the envelope’s contribution. Or wrap cutoff + env in parenthesis to see how the actual cutoff frequency for the LPF.

2 Likes

ive been trying to find way which gives me flexible envelope control for a while. this is perfect. thanks a lot.

2 Likes

So I’ve pasted the code I got to run below. I’m basically trying to recreate Serum patches as synthdefs in SC so I can control them through Tidal Cycles.

//The steps in variables a-f create a wavetable from a wav file to load into Oscillator A of our synth
// variable 'a' to open the sound file to prepare for reading contents, for Windows you need to make double back slashes in between each file directory
a = SoundFile.openRead("C:\\Users\\44774\\Documents\\Xfer\\Serum Presets\\Tables\\User\\wubsquare8.wav".standardizePath);
// variable 'b' to create a float array that is the same size as the sound file
b = FloatArray.newClear(a.numFrames);
// variable 'c' will read the sample data of the sound file 'a' into the array provided as variable 'b'
c = a.readData(b);
// variable 'd' will convert the array named variable 'b' from the previous step into a signal
d = b.as(Signal);
// variable 'e' will convert the signal produced from the step in variable 'd' into a wavetable format
e = d.asWavetable;
// variable 'f' will load the wavetable in variable 'e' created from the previous step into a buffer on the server s
f = Buffer.loadCollection(s, e);


f.bufnum; // display the buffer number of the wavetable to assign in the osc bufnum parameter
e.plot; // display a plot of the wavetable
a.close; // close the original wav file

// repeat the steps from var a-f again to load another wav as a wavetable for Oscillator B. I've renamed the variables in ascending alphabetical order so as not to confuse the server (or myself)
g = SoundFile.openRead("C:\\Users\\44774\\Documents\\Xfer\\Serum Presets\\Tables\\Analog\\Basic Mini.wav".standardizePath);
h = FloatArray.newClear(g.numFrames);
i = g.readData(h);
j = h.as(Signal);
k = j.asWavetable;
l = Buffer.loadCollection(s, k);


l.bufnum; // display the buffer number of the wavetable to assign in the osc bufnum parameter
k.plot; // display a plot of the wavetable
g.close; // close the original wav file

SynthDef(\autobot, {
		| out, sustain=1, freq=440, speed=1, begin=0, end=1, pan, accelerate, offset, volume|
		var env = Env.adsr(0.0017, 0.015, 0.82, 0.0005, 0.7, 1.0, 0.0); // an ASDR envelope for the synth
	    var gen = EnvGen.ar(env, 1.0, 1.0, 0.0, 1.0, doneAction: Done.freeSelf); // pass the env through to envgen
		// our variable sig is going to be our Oscillator A with the first wavetable 
	    var sig = [Osc.ar(2111, freq, 0.0, 1.0, 0.0)];
	    // Variable sigb will be Oscilator B with the second wavetable 
	    var sigb = [Osc.ar(2112, freq, 0.0, 1.0, 0.0)];
	    // Variable sub is a square wave sub oscilator 
	    var sub = [Pulse.ar(freq/2, 0.5, 1.0, 1.0)];
	    // pass both oscillator A and B and the Sub Oscillator through the envelope
        var output= sub+sig+sigb*gen;
    //This is where the output from the envelope passes through a low pass filter whose filter cutoff is modulated by the envelope
	var chain1 = LPF.ar(output, LinLin.ar(gen, 0, 1, 700, 1600), 1.0, 0.0);
	// We'll send all the final oputput to Tidal Cycles
	OffsetOut.ar(out,DirtPan.ar(chain1, ~dirt.numChannels)); 
}).add;



Env.adsr(0.0017, 0.015, 0.82, 0.0005, 0.7, 1.0, 0.0).plot; // plot the adsr envelope, the visual plot helps to get the right shape

So that works as a filter controlled by the envelope, I’ve been experimenting with changing the cutoff values. I can hear popping noises when it plays notes. I think the signal gets turned off and on too rapidly by the envelope. Either that or I need to use the LeakDC Ugen and/or detect-silence Ugen to help with the rapid changes in the signal. I found this page on LeakDC and detect-silence but it might be outdated, I’m not sure:

https://defaultxr.github.io/cl-collider-tutorial/07-effects.html

When I remove the LPF the popping disappears.

Maybe it needs some sort of crossfade envelope going on in between notes?

How are you turning off the notes? The gate in the EnvGen is set to 1, so it is never executing the "r"elease part of the envelope. Try adding a gate argument that you set to 0 to free the synth:

(
a = SynthDef(\autobot, {
		| out=0, sustain=1, freq=440, speed=1, begin=0, end=1, pan, accelerate, offset, volume, gate = 1|
		var env = Env.adsr(0.0017, 0.015, 0.82, 0.0005, 0.7, 1.0, 0.0); // an ASDR envelope for the synth
	    var gen = EnvGen.ar(env, gate, 1.0, 0.0, 1.0, doneAction: Done.freeSelf); // pass the env through to envgen
		// our variable sig is going to be our Oscillator A with the first wavetable 
	    var sig = [Osc.ar(2111, freq, 0.0, 1.0, 0.0)];
	    // Variable sigb will be Oscilator B with the second wavetable 
	    var sigb = [Osc.ar(2112, freq, 0.0, 1.0, 0.0)];
	    // Variable sub is a square wave sub oscilator 
	    var sub = [Pulse.ar(freq/2, 0.5, 1.0, 1.0)];
	    // pass both oscillator A and B and the Sub Oscillator through the envelope
        var output= sub+sig+sigb*gen;
    //This is where the output from the envelope passes through a low pass filter whose filter cutoff is modulated by the envelope
	//var chain1 = LPF.ar(output, LinLin.ar(gen, 0, 1, 700, 1600), 1.0, 0.0);
	// We'll send all the final oputput to Tidal Cycles
	Out.ar(out,output); 
}).play;)


a.set(\gate, 0)

I’ve not used Tidal myself, but use SuperClean, which was forked from Tidal’s SuperCollider aspect, SuperDirt. In SuperClean none of the envelopes are of the sustaining types, so AR or ASR (where the “S” is just sustain time, not level).

So to piggy back on Sam’s post, perhaps try swapping the envelope out for env.linen or similar? I know SuperClean does not cause a gate argument to 0 at the end of each event, could be the same for Tidal and SuperDirt.

I think I understand what you’re saying. I tried running the code but I got a NAME error. IDK why. I found this in the help files in SC:

Sustained Envelope Creation Methods
The following methods create some frequently used envelope shapes which have a sustain segment. They are typically used in SynthDefs in situations where at the time of starting the synth it is not known when it will end. Typical cases are external interfaces, midi input, or quickly varying TempoClock.
(
SynthDef(\env_help, { |out, gate = 1, amp = 0.1, release = 0.1|
    var env = Env.adsr(0.02, release, amp);
    var gen = EnvGen.kr(env, gate, doneAction: Done.freeSelf);
    Out.ar(out, PinkNoise.ar(1 ! 2) * gen)
}).add
);
​
a = Synth(\env_help);
b = Synth(\env_help, [\release, 2]);
a.set(\gate, 0); // alternatively, you can write a.release;
b.set(\gate, 0);

So I tried to implement that code and went with this:

SynthDef(\autobot, {
		| out = 0, sustain=1, freq=440, gate = 1, attack = 0.1 , decay = 0.2, sustainl = 0.3, release = 0.5, amp = 0.3, speed=1, begin=0, end=1, pan, accelerate, offset, volume|
	var env = Env.adsr(attack, decay, sustainl, release).test(0.6); // an ASDR envelope for the synth
	    var gen = EnvGen.ar(env, gate, 1, 0.0, 0.6, doneAction: Done.freeSelf); // pass the env through to envgen
		// our variable sig is going to be our Oscillator A with the first wavetable
	    var sig = [Osc.ar(2111, freq, 0.0, 1.0, 0.0)];
	    // Variable sigb will be Oscilator B with the second wavetable
	    var sigb = [Osc.ar(2112, freq, 0.0, 1.0, 0.0)];
	    // Variable sub is a square wave sub oscilator
	    var sub = [Pulse.ar(freq/2, 0.5, 1.0, 1.0)];
	    // pass both oscillator A and B and the Sub Oscillator through the envelope
        var output= sub+sig+sigb*gen;
    //This is where the output from the envelope passes through a low pass filter whose filter cutoff is modulated by the envelope
	var chain1 = LPF.ar(output, LinLin.ar(gen, 0, 1, 700, 1600), 1.0, 0.0);
	// We'll send all the final oputput to Tidal Cycles
	OffsetOut.ar(out,DirtPan.ar(chain1, ~dirt.numChannels));
}).add;

a = Synth(\autobot);
b = Synth(\autobot, [\release, 2]);
a.set(\gate, 0); // alternatively, you can write a.release;
b.set(\gate, 0);

The tone of the notes sounds way better but I keep hearing this annoying pop sound at the end of each note. I’m arpeggiating chords at a fast tempo with the synth using Tidal Cycles and I keep hearing it. It’s not as noticeable when it’s buried in multiple synths.

If I play a slower sequence of notes I can hear it more clearly and it sounds more like it’s making a weird pop or “ptt” sound when the note reaches the peak of the sustain level.

If I change the attack time to a much slower attack, like 1 second then I hear the pop sound happening right when the note begins.

Maybe if the gate value could change from 0 to 1 more subtly. IDK how to assign an LFO to raise the gate from 0 to 1 gradually though?

The options for shaping tone with Env.Linen seem limited compared to the adsr, which is why I’m kinda stuck trying to tweak it.

I think it’s clipping. If you turn the volume down or use a Limiter (preferably on master) the artifact should go away. EDIT: Unless SuperDirt already does that, idk.

Additionally, the settings for Pulse.ar set the “add” parameter to 1.0. This adds dc bias to the signal and exacerbates the clipping.

1 Like

yeah. notice the amp argument isn’t being used.

sam

Can you provide a link to the wavetables (wubsquare8.wav and Basic Mini.wav) so we can reproduce your issue?

Okay, I adjusted the “add” parameter and sent the output through a limiter before sending it out to Tidal. The gain is at a much more comfortable level. I’m mixing with headphones atm and had the headphones level turned down quite a bit before adding the limiter. It was a pretty hot signal, but I had assumed it was because Tidal doesn’t normalize audio at all.

SynthDef(\autobot, {
		| out = 0, sustain=1, freq=440, gate = 1, attack = 0.05 , decay = 1, sustainl = 1.0, release = 0.01, amp = 0.3, speed=1, begin=0, end=1, pan, accelerate, offset, volume|
	var env = Env.adsr(attack, decay, sustainl, release, 1.0, 1.0, 0.0).test(0.6); // an ASDR envelope for the synth
	    var gen = EnvGen.ar(env, gate, 1, 0.0, 0.6, doneAction: Done.freeSelf); // pass the env through to envgen
		// our variable sig is going to be our Oscillator A with the first wavetable
	    var sig = [Osc.ar(2111, freq, 0.0, 1.0, 0.0)];
	    // Variable sigb will be Oscilator B with the second wavetable
	    var sigb = [Osc.ar(2112, freq, 0.0, 1.0, 0.0)];
	    // Variable sub is a square wave sub oscilator
	    var sub = [Pulse.ar(freq/2, 0.5, 1.0, 0.0)];
	    // pass both oscillator A and B and the Sub Oscillator through the envelope
        var output= sub+sig+sigb*gen;
    //This is where the output from the envelope passes through a low pass filter whose filter cutoff is modulated by the envelope
	var chain1 = LPF.ar(output, LinLin.ar(gen, 0, 1, 700, 1600), 1.0, 0.0);
	// add a limiter
	var limitChain1 = Limiter.ar(chain1, 0.8, 0.01);
	// We'll send all the final oputput to Tidal Cycles
	OffsetOut.ar(out,DirtPan.ar(limitChain1, ~dirt.numChannels));
}).add;

a = Synth(\autobot);
b = Synth(\autobot, [\release, 2]);
a.set(\gate, 0); // alternatively, you can write a.release;
b.set(\gate, 0);

It still clicks when a note is turned on. I’m pretty sure it’s the envelope gate. When I play it without the envelope gate there’s no clicking but the sound has no real tone shape to it at all :frowning:

Yeah, here’s the wav files. They should just be a single cycle waveform each.
To recreate turning them into wavetables you’d need to change the file path in step a. Once it gets loaded into a buffer the bufnum might be different for you as well, mine usually gets assigned 2111 and upwards from there 2112, 2113 … etc


1 Like

It’s the amplitude for sure, just add * amp to var output= sub+sig+sigb*gen; Plus then you’ll also get volume control, since you currently don’t have it wired up.

I rewrote your SynthDef to implement the changes suggested by @thresholdpeople and @nathan. I also removed a bunch of unnecessary/redundant/unused code, for example all of the UGen arguments which were just set to their default value (like mul and add, mostly). It now works fine for me in a Pbind without clicking. Note also the reassignment of variables like sig (which is standard nomenclature in SynthDefs), which IMO makes parsing the signal flow much easier. Just replace the last line of the SynthDef (OffsetOut.ar(out, ...) with OffsetOut.ar(out, DirtPan.ar(sig, ~dirt.numChannels)); to get it to work in Tidal.

(
var wtPaths, waveTable, waveFile, envirVarName;

wtPaths = [
	/* 
    insert comma-delimited file path strings here, i.e.:
    "C:/Users/Path/To/SingleCycleWTs/Basic Mini.wav",
    "C:/Users/Path/To/SingleCycleWTs/wubsquare8.wav",
    (etc.)
    */
];

"Wavetable Buffers loaded:".postln;

wtPaths.do { |file|
	waveFile = SoundFile.openRead(file);
	waveTable = FloatArray.newClear(waveFile.numFrames);
	waveFile.readData(waveTable);
	waveTable = waveTable.as(Signal).asWavetable;
	// {waveTable.plot}.defer; // uncomment if you want to plot each wavetable

	envirVarName = ("wt_" ++ PathName(file.standardizePath).fileNameWithoutExtension.replace(" ","_")).asSymbol;
    ("~" ++ envirVarName).postln;
	envirVarName.envirPut(Buffer.loadCollection(s, waveTable));
    waveFile.close;
};
)

~wt_Basic_Mini.bufnum; // should post some number

(
SynthDef(\autobot, {
	arg wtBufA, wtBufB,
	out = 0, gate = 1, amp = 0.3, freq = 440,
	attack = 0.1 , decay = 0.2, sustain = 0.3, release = 0.5;
	
	var sig, osca, oscb, sub, env, mix;
	
	env = Env.adsr(attack, decay, sustain, release); 
	env = EnvGen.ar(env, gate, timeScale: 0.6, doneAction: 2); 
	
	osca = Osc.ar(wtBufA, freq);
	oscb = Osc.ar(wtBufB, freq);
	sub = Pulse.ar(freq/2);
	
	sig = osca + oscb + sub;	
	sig = sig * env * amp;
	
	sig = LPF.ar(sig, LinLin.ar(env, 0, 1, 700, 1600));
	sig = Limiter.ar(sig, 0.8);

	OffsetOut.ar(out, Pan2.ar(sig));
}).add;
)

(
Pbind(
	\instrument, \autobot,
	\dur, Pseq([1, 1, 2, 1]/10, inf),
	#[freq, sustain], Ptuple([
		Pseq((1..16) * 50, 4),
		Pseq([1/10, 1/5, 1/7, 2/30], inf)
	]),
	\wtBufA, ~wt_Basic_Mini.bufnum,
	\wtBufB, ~wt_wubsquare8.bufnum
).play;
)

Edit: Added simplified code for loading multiple wavetable files into Buffers and fixed some bugs in my code. Also, I’ve turned the Buffer indices (2111/2112 in your code) into SynthDef arguments instead of having them hardcoded in the graph function like before, which makes it easy to switch wavetables on the fly (just pass two bufnums to the wtBufA and wtBufB arguments when creating a Synth, or have a look at my Pbind code).

Oh cool, I didn’t know you could just reassign variables, I thought you had to keep passing them through to a new variable.

I added the amp argument and it got rid of the clicks. It still sounds a little rough at the beginning of the notes but now it’s more of a harmonic sounding thud than a sharp click. I’ll try and adjust the amp value more and see how much smoother or rough it gets with smaller values.

Edit: nm it’s clicking again :’( I’m going to try and mess with it some more tomorrow. I might need to increase the buffer size or something

I reloaded SC and the clipping disappeared after re-running the code. Sometimes I can hear it in my headphones while I’m mixing/coding but oddly enough it’s not apparent in any recordings with the code.
Either way I think I made a ton of progress with yours and everyone’s help! Thank you!

1 Like

whats the right way to use .linexp instead of .linlin for this approach? i get CheckBadValues: NaN found for this: (i know i can also use linExp to bend the curves with maths and then use .linlin for mapping)

lfo = Maths2.ar(\rise.kr(1), \fall.kr(0.5), \linExp.kr(0.5))[0];
fltRange = 300 + lfo.linexp(0, 1, 0.001, \fltRangeAmount.kr(900)).lag(0.01);
sig = RLPF.ar(sig, fltRange, 0.1);

instead using fltRange = 300 * lfo.linexp(0, 1, 1, 4).lag(0.01); works correctly.

I’m not getting that error. Is it possible you are setting fltRangeAmount to 0 or negative?

okay i think there was another issue. sorry for the noise. but both examples give different results cause the base frequency is not 300 when 0 mapped to 0.001. so better adjust the curves in maths and map with .linlin i guess. thanks a lot for the fast reply.

yes. and the curve in the Maths is slightly different than exponential and to my ear more pleasing, whatever that means (or at least more like the euro rack module)…