About envelope curves using 1-pole filter, and the exponential curve setting in Env

Hi, I’ve seen around that analog gear uses a 1-pole filter for envelope generation. I see that Env has many curve options, but no 1-pole filter, what do you think?

I also see that Env uses a curve parameter and I wonder where the formula comes from and if we have sources say and confirm that this is a common setting in music software applications. In my tests, a 1-pole filter is somewhat similar to using a “-7” curve value.

cheers

Wouldn’t the way to implement this be a curve: \step envelope → OnePole?

(
{
	var step = EnvGen.kr(Env([0, 1, 1, 0], [0.05, 0.2, 0.05], \step));
	[step, OnePole.kr(step, 0.9)]
}.plot(duration: 0.5);
)

hjh

Having a look at the Env.sc file, you can see that the curve algorithm comes from SimpleNumber.sc :

lincurve { |inMin = 0, inMax = 1, outMin = 0, outMax = 1, curve = -4, clip = \minmax|
		var grow, a, b, scaled;
		switch(clip,
			\minmax, {
				if(this <= inMin) { ^outMin };
				if(this >= inMax) { ^outMax };
			},
			\min, {
				if(this <= inMin) { ^outMin };
			},
			\max, {
				if(this >= inMax) { ^outMax };
			}
		);
		if(abs(curve) < 0.001) {
			// If the value should be clipped, it has already been clipped (above).
			// If we got this far, then linlin does not need to do any clipping.
			// Inlining the formula here makes it even faster.
			^(this - inMin) / (inMax - inMin) * (outMax - outMin) + outMin
		};

		grow = exp(curve);
		a = outMax - outMin / (1.0 - grow);
		b = outMin + a;
		scaled = (this - inMin) / (inMax - inMin);

		^b - (a * pow(grow, scaled))
	}

This only works if every segment has the same length, though! Also, it’s rather awkward that you have to manually specify the one pole coefficient, which depends on the segment length! I personally use 5 time constants, which reaches 99,3% of the target value, see also RC time constant - Wikipedia.

Anyway, I think a \onePole option for Env could be a cool feature.

yeah :slight_smile:

me too :wink:

I think I found something slightly different that I tested and used, but the question remains about where does this come from in the literature for using it in envelope generators. In the meantime I checked the code for VCV’s ADSR envelope and it seems it uses a one pole filter as well.

We have many many precedents for pseudo-UGens that automate otherwise awkward calculations. Now, as you noted, that’s not possible here because the segment duration isn’t known – just saying that I don’t find it objectionable to create an abstraction for something that would be awkward without the abstraction (since this is a large proportion of what programming is).

A more significant counterargument is that EnvGen → OnePole would require every segment to be processed by OnePole, making it impossible to have a linear segment followed by a OnePole segment (unless the coefficient were zeroed, based on segment details that are again unknown).

So the proposal is to add a new envelope segment warp and use the curve-number value to specify… I guess proportion of the segment duration to use as the convergence time? I’d be ok with this.

hjh

For me it would be fine to just fix the coefficient at 5 time constants. Then we would just need to add a new curve type (e.g. \rc or \onePole).

If we want to allow users to specify the number of time constants, that would be pretty tricky to implement because Env uses a single argument (curve) to specify either a curve (Float) or any other shape (Symbol). The EnvGen UGen, on the other hand, already uses two different inputs for the shape type and the curvature number, so that part would be easy.

I think this has been kind of answered above - RC circuits, which handle the envelopes in analog synths and which are (in this case) effectively low pass filters (they smoothe out the steps in some input voltage), the simplest case of which is a onepole, which also happens to be easy to digitize.
For literature, I looked at Will Pirkle’s “Synth plugins in cpp” (because I have it and it’s recent), and one of the approaches he cites is this website: Envelope generators—ADSR Part 2 | EarLevel Engineering
I suspect this is mostly too self-evident for most dsp-concerned folks to generate much “literature”; what one would probably have to look for is emulation of a particular envelope generator, e.g. the CEM3310 chip that Pirkle mentions.

I meant the curves used in SuperCollider, which is not a one pole filter. I know where they come from and what I don’t know is where SuperCollider got its exponential formula from :wink:

looks like a cool source, will check out later, thanks

See Some remarks on linexp and lincurve for a bit of background on the math

As I understand it, the curve algorithm is meant to approximate parts of an exponential curve but more flexible, e.g. can cross 0

Ah right, the programming interface would be tricky for that.

I’m going to admit to being a bit of an idiot here – “5 time constants” has no intuitive meaning for me. I suspect I’m not alone in having some experience with audio engineering and with SC programming, but no experience with analog circuit design. I looked up the term and I have an idea of it now – I guess, just a suggestion that documentation of a \rc or \onePole feature shouldn’t rely too strongly on this specific wording.

hjh

Folks, I think that a so called \rc or \oneple should just be the same as the “Lag” Ugen, with the line segment length being the “lagTime” (60 dB lag time).

Right, that’s more or less it!

Fwiw yesterday I ended up sketching some code that converts a linear envelope to a curve envelope emulating the CEM3310 chip (separate caps for charge and discharge; 1.5 time constants for charge, 4.95 for discharge; also charges only to 0.77 of the charge cap’s total, which makes for a steeper slope/ faster attack). But I think just multiplying the curve value with the duration of the segment gets you fairly close as well.

As an aside, Redmon*/Pirkle mention an issue with possible float underflow / denormaled numbers as decrements become very small in exponential decay, and do some offsetting to counteract it. I’m sure sc has some way of handling this, but I wouldn’t know from looking at the OnePole implementation. Maybe zapgremlins?

*(In my mind I just have to say that name with a Jamaican pronunciation, I apologize).

[quote=“girthrub, post:14, topic:11595”]
Maybe zapgremlins?
[/quote]

Yes, the purpose of zapgremlins is to flush denormals.

1 Like

Ok, one last shot, if anyone mentioned it I just missed it, please help as this is a main concern/inquiry. Is the current usage of the very specific non linear curves used by default in Env.adsr actually a common practice in envelope design for music software?

And does it come from exactly?

cheers

here is one analog envelope emulation based on the SH-101 circuit ive picked up from the gen~ discord recently:

// Attack, decay, sustain and release are 0..1, retrig is separate for repeat key detection, aftertouch is 0 .. 1 added to sustain.
// enva — is evelope amplitude voltage to scale ONLY the ATTACK amplitude. 1 — is nominal, but can be boosted for sharper attack.

vaADSR(attack, decay, sustain, release, gate, retrig, aft, enva) {
    History vCap(0), state(0);
    
    // Gate edge detection
    gateOn     = (delta(gate) > 0) || retrig;
    gateOff = delta(gate) < 0;
    
    // Circuit parameters
    vCharge = 14.5;            // Charging voltage supply (14V)
    vDischarge = 5.0;        // Release floor voltage (5V)
        
    // Maximum voltage accumulation factor
    vMax     = 1.44;               // Basically clip
    
    // Clipping pot floor and ceiling
    sustain = clip(sustain + aft, 0, 1);
    
    // Convert ADSR parameters to circuit values
    rAttack = attack * 1e6 + 100;        // Attack potentiometer (1MΩ) + added resistances to smooth sharp jumps. Lower added resistance to make sharper attacks.
    rDecay     = decay * 1e6 + 1.1e3;        // Decay potentiometer (1MΩ)
    rRelease = release * 1e6 + 0.3e3;     // Release resistance (1MΩ)
    cCap     = 4.7e-6;                    // Timing capacitor (4.7μF)
    
    trSw = 0.64; // Cap charge coef to switch from attack to decay 
    vPeak = vCharge * trSw * enva; // Peak coltage for comparator to switch from attack to decay
    
    // Normalise sustain
    vSustain = (vDischarge * trSw + (sustain * (vCharge * trSw - vDischarge * trSw))) * 1.030; // Sustain voltage mismatch with comparator uniquie to SH-101 behaviour

    dt = 1 / samplerate;
    
    // Comparator logic
    if (gateOn) { 
        state = 1;
    }
    else if (state == 1) {
        if (vCap >= vPeak * enva) {
            state = 2; // Switch to decay
        }
    }
    else if (!gate) {
        state = 3;
    }
    
    heatNoise = noise () * 0.0003;
    
    supplyV     = (!gate) ? vDischarge : (state == 2) ? vSustain : vCharge; // Voltage supply sources
    currentR     = (!gate) ? rRelease : (state == 2) ? rDecay : rAttack; // Circuit resistance

    vDelta         = (supplyV - vCap) / currentR;
    vD             = (vDelta * dt) / cCap;
    vCap         += vD;

    // Normalize
    vO = (vCap - vDischarge) / ((vCharge * trSw) - vDischarge);
        
    return clip(vO + heatNoise, 0, vCharge * vMax);
}

Sort of bumping this question because it’s an interesting one and I’d like to know the answer too… my personal uneducated guess is that the default “curve” setting (- 4) is close enough to what people are used to from e.g. the one-poles etc., and simultaneously more flexible; just a useful generalization (you can make it linear by setting to 0, or tweak it to some other value, say -3.91 or +17, that fits the specific curve you have in mind, or just ignore it). I’m not a mathematician but [insert unbelievably naive statement here].

Iirc the visual envelope editor in Max also has a way of adjusting envelope curves in that way; not sure since when. At any rate it’s an interesting history of technology question.

I don’t know where the formula comes from, but – this GUI snippet behaves a lot like the envelope or LFO segment editor in Serum and Vital, so I’d say something like lincurve is used pretty widely in digital synths.

(
// ~curve is from https://swiki.hfbk-hamburg.de/MusicTechnology/860
// can't believe this page still exists!
// (edited to remove the `(condition).if` syntax I was using at the time)
~curve = { |minval, midval, maxval|
	var a, b, c, sqrterm, qresult, sgn = sign(maxval - minval);
	// the formula is unstable just above the average of minval and maxval
	// so mirror the midval around the average
	if(midval > ((maxval + minval) * 0.5)) {
		midval = minval + maxval - midval;
		sgn = sgn.neg;
	};
	a = midval - minval;
	b = minval - maxval;
	c = maxval - midval;
	sqrterm = sqrt(b.squared - (4 * a * c));
	qresult = (sqrterm - b) / (2 * a);
	if(qresult.abs != 1) {
		log(qresult.squared).abs * sgn
	} {
		log(((b.neg - sqrterm) / (2 * a)).squared).abs * sgn
	};
};

w = Window("curve", Rect(800, 200, 500, 400)).front;
w.layout = StackLayout(
	m = MultiSliderView()
	.drawLines_(true)
	.drawRects_(false)
	.elasticMode_(true)
	.editable_(false)
	.background_(Color.white)
	.strokeColor_(Color.black),
	View().layout_(HLayout(
		nil,
		z = Slider().fixedWidth_(20).background_(Color.clear).knobColor_(Color.blue(0.8)),
		nil
	))
).index_(1).mode_(1);

z.action_({ |view|
	var curve = ~curve.(0, view.value, 1);
	m.value = Array.fill(256, { |x|
		x.lincurve(0, 255, 0, 1, curve)
	});
});

z.valueAction_(0.5);
)

hjh

hmm, this doesn’t seem to be what the ‘curve’ paramenter in, say Env.adsr is