Husserl tutorial series (7). Repeating ADSR Envelope in gen~

Ernest's icon

Envelopes are a prime candidate for a codebox implementation because otherwise one ends up calculating some part of other stages of an envelope every cycle. This tutorial keeps things simple by providing the download example up front and explaining the gotchas, as I've already covered the major code design issues for floating-point operations. All tutorials in this series:

  1. Designing a good LFO in gen~ Codebox: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-one

  2. Resampling: when Average is Better: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-2

  3. Wavetables and Wavesets: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-3

  4. Anti-Aliasing Oscillators: https://cycling74.com/forums/husserl-tutorial-series-part-4-anti-aliasing-oscillators

  5. Implementing Multiphony in Max: https://cycling74.com/forums/implementing-multiphony-in-max

  6. Envelope Followers, Limiters, and Compressors: https://cycling74.com/forums/husserl-tutorial-series-part-6-envelope-followers-limiting-and-compression

  7. Repeating ADSR Envelope in gen~: https://cycling74.com/forums/husserl-tutorials-part-7-repeating-adsr-envelope-in-gen~

  8. JavaScript: the Oddest Programming Language: https://cycling74.com/forums/husserl-tutorial-series-javascript-part-one

  9. JavaScript for the UI, and JSUI:<a href="https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui"> https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui

  10. Programming pattrstorage with JavaScript: https://cycling74.com/forums/husserl-tutorial-series-programming-pattrstorage-with-javascript

  11. Applying gen to MIDI and real-world cases. https://cycling74.com/forums/husserl-tutorial-series-11-applying-gen-to-midi-and-real-world-cases

  12. Custom Voice Allocation. https://cycling74.com/forums/husserl-tutorial-series-12-custom-voice-allocation

Self-Repeating ADSR Envelope in gen~

People have been very eager just to get this incorporated into a design, although Max 9.2 added a news ADSR~ object with identical functionality to that in Husserl so the pressure may be off, lol. Anyway here is an updated demo for codebox and download.

Max Patch
Copy patch and select New From Clipboard in Max.

And here is the gen~ code. It's a little more complicated than the 7-staged envelope example included with Cycling74 in the gen~ examples folder because it fixes some issues with it for normal usage, but you'll probably want to look at Cycling74's gen~ example first. Then this may appear like a lot of code, but that's just because each stage needs a couple of different things to happen, so it's mostly the same kind of thing being repeated slightly differently.


adsrRpt(gate, preslope, att, dec, sus, rel, repeat){
    History stage, lvl, lvl2;
    trigger = change(gate);
    stage   = selector(stage, 
            stage+preslope, 
            stage+att, 
            stage+dec, 
            4, 
            stage+rel, 
            6
        );
    if(trigger >0){            // gate on
        if (stage > 0){ 
            stage = 1;
            lvl2 = lvl; 
        } else 
            stage = 2;
    } else if(trigger < 0){        // gate off
        stage = 5; 
        lvl2  = lvl; 
    }
    if(stage <1)
            lvl= 0;
    } else if(stage < 2){        // quick fade to 0 if note already on
        lvl = 1 - fract(stage);
         lvl = lvl * lvl * lvl * lvl2; 
    } else if(stage < 3){        // attack
        lvl = fract(stage);
        lvl = lvl * lvl * lvl; 
    } else if(stage < 4){        // decay
        lvl = 1 - fract(stage);
         lvl = lvl * lvl * lvl *(1 -sus) + sus;
    } else if(stage < 5){        //sustain
        lvl = sus; 
        if (repeat > 0){
            lvl2  = lvl; 
            stage = 5;
        }else if (sus == 0){
            lvl2  = 0;
            stage = 0;
        }
    } else if(stage < 6){        // release
        lvl    = 1 - fract(stage);
        lvl    = lvl * lvl * lvl * lvl2; 
    } else if((repeat ==0) || (gate ==0) ) {
        lvl   = 0;
        stage = 0;
    } else {
        stage = 2; 
        lvl2 = sus;
    }
    return lvl, stage;
}
//----------------- MAIN ROUTINE -----------------------------------
Data adrTime(128);
Param gate(1), att(64), dec(64), sus(.5), rel(64), rpt;
History preslope, dinit(1);
if(dinit ==1){        
    for (i = 1; i < 129; i +=1 ){
    adrTime.poke(1 / (samplerate * pow(1.12202, i * .7874 -70)), i);
    }
    preslope = 1/(samplerate * .005);
    dinit = 0;
}
a    = adrTime.peek(att);
d    = adrTime.peek(dec);
r    = adrTime.peek(rel);
lvl, stage = adsrRpt(gate, preslope, a, d, sus, r, rpt);
out1 = lvl;
out2 = stage;

Issue One: triggering

The first issue with making an envelope in gen~ is that it needs a unique positive value for an attack trigger. That is, If you have two gate-on events that are 1 both in sequence, without an intervening gate-off event, gen~ can't tell that a new event has happened. In the audio world, the value of the gate didn't change, it's still 1. So gen~ can't spot the trigger. Hence these days what I do is simply put a counter on the positive gate signal. As Max has 32-bit resolution on integer values, it would be difficult to overflow the counter, so there's no need to loop the counter or worry about overflow in any way.

I've seen people rely on velocity values being different and using those as gate-on triggers, but that obviously means you are going to miss notes occasionally.

  • Regarding velocity, if you want to scale the envelope with a velocity, then you have to do that AFTER the envelope function, or the level slopes won't calculate properly. So there's not really any point in passing the velocity into the ADSR function as an argument.

For gate-off events, when a gate is off, the envelope is in the release phase or over. Two gate-off events can happen in sequence and it doesn't make any difference to what the envelope does. So gate-off zeroes can just be passed straight into gen~.

The above demo patch has the simplest possible implementation of a counter on the toggle box for the gate param. I've tried adding fracitonal values on the input and stripping them off, but it takes more to do .

You may prefer to have an audio trigger for greater timing accuracy. If there's is more than one audio clock between MIDI triggers, then it's pretty easy to force the signal to zero between them, even inside the ADSR codebox. But in the MIDI polyphonic domain, two gate-on events can EASILY happen within one audio cycle, and if you're low on voices the same voice instance can get them. Depending on your design, you might want to ignore the second trigger, but ehast if two triggers arrive on consecutive audio cycles? if you're a perfectionist, you might end up adding various fractional values to audio triggers to indicate if there is more than one envelope starting, and decoding them in gen~.

Issue Two: attack preslope

If an envelope receives a gate-on event while it is already running, even during the release stage, then there will be a very noticeable click if you don't have a preslope stage that decays the envelope level to zero before starting the attack stage. I calculate a preslope of 5 milliseconds, which is about the shortest period you can drop to zero without a click (although people do sometimes want a click of varying strengths between ADSR phases, so envelopes usually support stages of less than 5msecs).

Some people just start the attack phase at whatever level it happens to be when the gate-on trigger happens, but purists object to that.

Issue Three: ADR durations

Husserl needs control values in range 0-127 for MIDI (that's what conventional velocity values are in MIDI too). So there's often some need to convert MIDI value ranges into timings.

This design shapes the ADR stage durations by applying a 'dbota'-style function to the CC value range to create logarithmically varying durations with more accuracy for shorter timings. The design precalculates the log values and stores them in a data array on initialization to save CPU (see the prior tutorial for a description of [dinit]). Also, the dinit subroutine sets the preslope increment at 5 msec.

Data envslope(128);
History preslope, dinit(1);
if(dinit ==1){        
    for (i = 1; i < 129; i +=1 ){
        adrTime.poke(
            1 / (samplerate * pow(1.12202, i * .7874 -70)), i);
    }
    preslope = 1/(samplerate * .005);
    dinit = 0;
}

This means the ADR adjustment is looked up from the data() array with peek statements. But don't put the peeks inside the function call, as per Tutorial one:


a   = adrTime.peek(att);
d   = adrTime.peek(dec);
r   = adrTime.peek(rel);
lvl, stage    = adsrRpt(gate, preslope, a, d, sus, r, rpt);

// DONT DO THIS!
//lvl, stage    = adsrRpt(gate, 
//                       preslope, 
//                       adrTime.peek(att), 
//                       adrTime.peek(dec)
//                       adrTime.peek(sus),
//                       adrTime.peek(rel),
//                       rpt   
//                    ); 

You can also put the timing adjustment outside gen~ entirely, or inside the ADSR function. People have had problems with data() in functions. To share this data() array with the adsr function, you can pass its name in as an argument, but it's easier to redeclare the data array at the top of the function, in which case the array data is shared from wherever you fill it in gen~. Data arrays are not shared across poly or MC instances at all, and are not shared outside the codebox unless they are redeclared. If you want to precalculate the array outside of gen~, see the example for using buffer~ to share EPTR coefficients in Tutorial 4.

Issue four: Stopping clicks between envelope phases

This is a very difficult problem. Although it may look stupid, I find it best to have TWO SEPARATE CONDIToNALS, one to increment the envelope stage, and one to calculate the envelope level. While one can combine them, one ends up going through a lot of hoopla to stop clicks between envelope stages for various reasons. In Husserl2, which has the simplest ADSR envelope design I've yet made, it has two selectors, because selectors are better than if/then statements, as described in Tutorial One. But to make a REPEATING envelope, one would have to create additional functions to call for the repeating and non-repeating versions, and it just didn't seem worth that much to me, so I just changed the second selector to a long if..then statement in this version, which is at least easier to understand. Moreover, it's not actually slower, because the additional conditions are only evaluated during each corresponding later stage, and aren't evaluated at all in the earlier stages.

Issue Five: Shaping the envelope

I used to feel the ideal envelope curve was pow(2.5). These days I prefer a sharper click so I use pow(3), or cube it. That means I can just multiply the envelope level by itself a couple of times: (x * x * x).

If you prefer a softer curve, then you can just square it, by multiplying the level by itself
(x *x).

Using a POW function is computationally expensive and to be avoided if you can. It does allow for changing the POW factor and thus changing the envelope curve. If you do want arbitrary envelope curves, it would be best to precalculate an array each time its coefficient changes (and put in a data() array, as in the above example for the ADR duration). I'm pretty sure Cycling74 doesn't bother doing that for its example, because the above gen~ code is much more efficient than their prebuilt example. The Max object also draws an envelope curve....I will be discussing drawing curves in a later tutorial.

If you prefer to minimize code, a couple of steps are required to apply the curve after the current envelope level is calculated. Husserl2 combined the steps by looking the POW(2.5) value up in a table, so that the peek method could be included in a single level calculation phase, and as a result, the level calculation could all fit in a selector statement (but it didn't support envelope self-repeats).

Issue Six: Transitions to release stage during attack and decay stages

This problem is also ignored in cycling74's example folder. If the release phase starts before the envelope reaches the sustain stage, then you need to start the release stage from that arbitrary level, which means remembering the current level for that during the release phase so you can ramp down from it, and that means an additional history operator for it. It results in a bit of code complexity, but now knowing why it's there should pretty well explain all of it.

Beyond the ADSR

There are many variants on the standard ADSR envelope, but it transpires to be popular, because it can emulate many others. By setting sustain to zero, you have an AR envelope. By setting decay to zero and sustain to max, you have an ASR envelope. By switching the release stage, you can support a hold pedal too. Making other kinds of envelopes mostly just means changing the stages. Husserl 3 will include a reversible envelope, for those wanting a little something more.

-------------------------

I've been asked for an ADSR envelope more than for anything else besides an anti-aliased pulse. No one ever asked about how it works after getting it, so I'm assuming it really is pretty self-explanatory, except for the first two issues, which I've probably repeated on these forums several dozen times, even after people download my example, they copy the gen~ object into their patches ignoring the rest, and then say it doesn't work because it doesn't trigger. Now I can just point people to his tutorial. Hurray!

It's going to be a while before I can get back to writing tutorials, so... happy patching :)

Hannes d'Hoine's icon

thank you very much for this. I don't have any experience whatsoever in writing code. But with this example, in combination with the simplified patch you posted somewhere else, I have learned a lot.