Envelope generators—ADSR code

First, a brief example of how to use the ADSR code:

// create ADSR env
ADSR *env = new ADSR();

// initialize settings
env->setAttackRate(.1 * sampleRate);  // .1 second
env->setDecayRate(.3 * sampleRate);
env->setReleaseRate(5 * sampleRate);
env->setSustainLevel(.8);
…
// at some point, by MIDI perhaps, the envelope is gated "on"
env->gate(true);
…
// and some time later, it's gated "off"
env->gate(false);

In your “real-time” thread, where you fill audio buffers for output, you’d use the process command to generate and return the next ADSR output:

// env->process() to generate and return the ADSR output...
outBuf[idx] = filter->process(osc->getOutput()) * env->process();

Functions

Create and destroy:

ADSR(void);
~ADSR(void);

Call the process function at the control rate (which might be once per sample period, or a lower rate if you’ve implemented a lower control sampling rate); it returns the current envelope output. The getOutput function returns the current output only, for convenience:

float process(void);
float getOutput(void);

Set the gate state on (true) or off (false):

void gate(int on);

Set the Attack, Decay, and Release rates (in samples—you can multiply time in seconds by the sample rate), and the sustain level (0.0-1.0); call these when settings change, not at the sample rate:

void setAttackRate(float rate);
void setDecayRate(float rate);
void setReleaseRate(float rate);
void setSustainLevel(float level);

Adjust the curves of the Attack, or Decay and Release segments, from the initial default values (small number such as 0.0001 to 0.01 for mostly-exponential, large numbers like 100 for virtually linear):

void setTargetRatioA(float targetRatio);
void setTargetRatioDR(float targetRatio);

Reset the envelope generator:

void reset(void);

It may be useful to know what state an envelope generator is in, for sophisticated control applications (typically, though, you won’t need this); a state enum is defined for convenience (for example, ADSR::env_sustain is equivalent to the value 3, and indicates that the current state is Sustain):

int getState(void);
    env_idle = 0, env_attack, env_decay, env_sustain, env_release

Code

Calculations that are done in the “real time” thread are written as inline code in the header file. Calculations that are done asynchronously, at “setup time” (which include patch recall and “knob twiddling”), are in the cpp file functions; this includes sub-calculations of the real-time math that are dependent only on setup-time parameters.

Download ADSR source code

This entry was posted in Envelope Generators, Source Code, Synthesizers. Bookmark the permalink.

28 Responses to Envelope generators—ADSR code

  1. Mark Heath says:

    thanks for sharing this code, I’ve tried making my own ADSR envelope generators once before, but this one is nicer.

  2. Hi Nigel,

    this is an interesting articles series. Allow me to add a few remarks:

    While your ADSR approach looks fine on “paper” (or better: It looks fine on the screen), it can fail miserably is practice. Let me explain in a technical manner to keep it short. The problem is that your branches are not bandlimited. These corners create infinitely highly frequency content which – in a discrete environment – will simply alias (in this case: “mirror”) back to lower frequencies.

    What does it mean? It mean that the pretty lines you are “drawing” in your the app above only look so well because they have been build to look fine in your specific case. Your example does not show the true continuous waveform the ADSR module is really meant to generate. In some words, you cut half of the sampling theorem and expect useful results. This doesn’t work as expected, to say the least. 🙂

    In reality, the heavy aliasing your approach produces will become visible in the form of wild oscillations and overshoots, instead of the nice lines your example seems to produce. Upsampling helps to makes to make visible, but measuring the analogue output with an oscilloscope is even better.

    In a real world application, you’ll want to get rid of the “corners”, i.e. you’ll want to band-limit the algorithm somehow. 🙂

    • Nigel Redmon says:

      Hi Fabien,

      Thanks for your comments, but you are mistaken here. The envelopes are entirely synchronous with the sample rate, so there is no aliasing. The mistake you’re making is in thinking that sharp corners equates with aliasing. Sharp corners that fall in between sample points and move around producing aliasing. It’s the modulation that’s perceived as aliasing, not the corners.

      For example, listen to the 5-bit version of the digital waveform here. It doesn’t alias, despite toggling between positive and negative maximums, because it’s synchronous with the sample rate. (Some might argue that it really does alias, but the alias components are in exact harmonic relations and therefore are equivalent to harmonic distortion. That’s not true in this case, however. It’s a digital wave that “is what it is”.)

      Even if the envelopes were not synchronous with the sample rate, the error would be transient and not perceived as aliasing, unless you cycled the envelope at audio rate. But because it’s synchronous, even turning the envelope into an oscillator wouldn’t result in aliasing, unless you terminated the envelope early and re-triggered with a varying period (it’s not possible to trig between samples). The only reason to do that would be to attempt a crude approximation of a ADSR waveshaper (crude because it can’t be triggered at fractional sample times). This is an ADSR envelope generator, not an oscillator waveshaper. And most of the analog ADSRs it emulates can’t serve as an oscillator either.

      Nigel

  3. Michael Kraft says:

    Hi Nigel,

    just a question. I havn´t tried yet to use your ADSR, but I think it has an Attack curve that is rising inverse exponentially. That would mean, it rises first very quick and slows down its rising-rate as it approaches its upper limit. If I am not mistaken, that should be the other way round, rising first slowly and accelerate its rising speed as it progresses. That is e.g. how a fade-in has to proceed to sound naturally.

    Am I wrong?

    The decay and release curves instead are perfect as they are (descending first fast and then slower).

    • Nigel Redmon says:

      Hi Michael,

      I do address this in the articles, and also in the video (check that first, probably the easiest to understand with the graphics). Your thought process is correct if you are thinking that for an audio ramp (fade in), you’d want an exponential rise. But instruments attacks don’t work that way—nothing I can think of in nature has an attack that starts slowly and increases exponentially. A linear attack is probably the most useful overall, and hardware envelope generators approximated a linear attack cheaply with a truncated reverse exponential. If you do want a constant (linear in dB) ramp using a modular analog synth, you’d use a linear ramp generator and an exponential VCA. I considered adding adding exponentially rising attacks to the ADSR, but I didn’t want to get far from emulating typical analog hardware. And in practical use, such a curve is used rarely.

      Nigel

  4. Bart Bralski says:

    Hi there,

    First off all thanx for all the great info and the nicely documented code!

    I have converted the ADSR-code (with a bit of help from a C++ to Java converter) and reworked the output and optimized the code to work with Processing.

    Find the ADSR processing sketch here.

    I’m calling the code from a method :


    void generateADSR() {
    ADSR adsr1 = new ADSR();

    adsr1.setAttackRate(12); // attackTime in ticks... // use adsr1.setAttackRate(12 * samplerate)
    adsr1.setDecayRate(24);
    adsr1.setReleaseRate(64);
    adsr1.setSustainLevel(0.25);
    adsr1.setTargetRatioA(0.02);
    adsr1.setTargetRatioDR(0.01);

    int noteLength = 75;
    int[] attackDecaySustain = new int[noteLength];
    int[] release = new int[outputArray.length-noteLength];

    adsr1.gate(1);
    for (int i = 0; i < noteLength; ++i) {
    adsr1.process();
    attackDecaySustain[i] = int(adsr1.getOutput()*(175 + 0.5)-80); //175 is the amplitude, -80 is an offset value
    }
    adsr1.gate(0);
    for (int i = 0; i < outputArray.length-noteLength; ++i) {
    adsr1.process();
    release[i] = int(adsr1.getOutput()*(175 + 0.5)-80); //175 is the amplitude, -80 is an offset value
    }
    outputArray = concat(attackDecaySustain, release);
    }

    It’s not realtime… but I use it to render an envelope I use as a sort of lookup table.
    I hope somebody will find this helpfull.

  5. Peter Janson says:

    Very nice article. Thank you!

  6. Mark Miles says:

    Thanks for this code. I’m looking forward to testing it, but I have a doubt: when you apply the envelope to a sampled percussive sound such as a piano, a guitar or a drum, which is already sampled with its own natural decay, you don’t want to add further decay, resulting in shortening the decay in an unnatural way. For this reason an inverse exponential decay is applied. How can I achieve this with your code? It would also be interesting to add a ‘hold’ state between attack and decay, in case you have to apply envelope to a percussive sample with looped tail.

  7. Nigel Redmon says:

    I don’t understand about inverse exponential…that would make a very abrupt cutoff, and would sound very unnatural. I believe that exponential decays are applied to the exponential reverb tails in convolution reverbs to adjust the tails, for instance.

    Yes, “hold”, “delay”, and “cycle” would be useful states.

  8. Daniel Doubleday says:

    Thanks for sharing Nigel!

    I have one question: Don’t you need to re-calcCoef when changing the target ratios?
    For instance for attack:

    void ADSR::setAttackRate(float rate) {
    attackRate = rate;
    attackCoef = calcCoef(rate, targetRatioA);
    attackBase = (1.0 + targetRatioA) * (1.0 – attackCoef);
    }

    void ADSR::setTargetRatioA(float targetRatio) {
    if (targetRatio < 0.000000001)
    targetRatio = 0.000000001; // -180 dB
    targetRatioA = targetRatio;
    attackBase = (1.0 + targetRatioA) * (1.0 – attackCoef);
    }

    • Nigel Redmon says:

      Yes, Daniel, you are correct. I did a late optimization to pull some partial calculations outside of the “process” function (AttackBase, etc.). That created some new dependancies, and I didn’t catch all of them (interestingly, the javascript version, translated from the c++ code, that is used in the adsr widget accounts for them, so maybe I posted a non-final version of the c++ code inadvertently). At the moment, I’m wishing I didn’t optimized quite that much (faster process function, at the expense of tutorial clarity), but I’ll update the code when I get a chance. Thanks!

      Nigel

      PS—The old code does work correctly when the target ratios are set first. That’s the normal/correct way to use the ADSR (initialize the ADSR with the curve settings you want, or use the defaults, then control the A, D, S, R settings), so it’s unlikely people got snagged with the old code, fortunately.

      PPS—Code has been updated.

  9. Pete says:

    Hi,

    I really appreciate that you’ve made this lucid material available – explanation and code.

    I’ve been adapting this to a scenario where I need to guarantee the times for each state are sample-accurate, so iterating based on a number of samples rather than relying on the value to trip the next state. I may have found a bug! 🙂

    Where you calculate the coefficient as:

    exp(-log((1.0 + targetRatio) / targetRatio) / rate);

    I found I get better results for the decay and release states if I calculate it as

    return exp(-log((distance + targetRatio) / targetRatio) / rate);

    where “distance” is the absolute difference between the start and end values of the particular state – i.e. 1.0 – sustainLevel for decay state and simply sustainLevel for the release state.

    After this change, I find the number of steps taken for a stage’s output value to reach its terminus is much closer to the “rate” param – and also I can use arbitrary values outside the range 0.0 – 1.0 for the start and end values of states.

    I freely admit that I don’t know what I’m doing here – thus very much in need of your tutorials (hence my gratitude for that!) – and so I may be confused. Any clarification welcome. I’ll post a link to a paste of my test code in a separate comment, in case any filters don’t like it 🙂

    • Nigel Redmon says:

      Hi Pete—There’s nothing wrong with doing the rate based on distance. However, I was going for how hardware ADSRs usually work. In that case, changing the sustain level does not change the rate, as it would with your method. In hardware, the decay and release knobs are changing a resistance and therefore the bleed rate of a capacitor. So, changing the sustain level wouldn’t affect that rate. In fact, that’s why I called it rate (SetDecayRate, etc.) and not time, even though you can set them in number of samples (per full range move), which is time.

      • Pete says:

        Thanks for the explanation – that makes a lot of sense!

        As you’re probably aware, your work was used in a DSP book – “Designing Software Synthesizer Plug-Ins in C++: For RackAFX, VST3, and Audio”, which I found in google books while investigating exponential decay. But there (e.g. p.299) , the author’s functions look like they’re setting a time in ms

        “double dSamples = m_dSampleRate*(m_dReleaseTime_mSec/1000.0);”

        But then dSamples goes on to play the exact role played by your “rate” param in setting the coefficient and base (which is named “offset” in the book.) This is what misled me into thinking the rate param was intended as “time to complete a given state (in samples.)” (So I guess I should be posting on the author’s blog and not yours!)

        So anyway, if I understand you correctly, typical hardware behaviour would be for the decay and release times to change as the sustain level was adjusted? That’s very interesting in itself!

        • Nigel Redmon says:

          Well, I could have just said something totally wrong (about analog synths usually having decay rate unaffected by sustain level)—sorry, a bit overwhelmed at this time. But I can say with confidence that I chose constant rate and not time because I didn’t want to recalculate or have the rate change while the user was turning the sustain level. There’s nothing inherently wrong with either way, just not sure off the top of my head which way analog synths typically did it at the moment!

          And I just found out about Will Pirkle referencing me in his book—cool! I own it, have been too busy to read it.

          • Pete says:

            “because I didn’t want to recalculate or have the rate change while the user was turning the sustain level”

            – insert sound of lightbulb going on –

            😉

          • Will Pirkle says:

            Have a look at the MIDI Manufacturer’s Association DLS Level 1 (or better) Level 2 Specs for a software synth which is very much based off of the typical hardware synth architecture. You can get the Level 1 Spec here:

            https://www.midi.org/specifications/item/dls-level-1-specification

            It violates the license for me to re-publish the statements about the EG here, but you can read them in the spec. The Level 2 spec is sightly more detailed in its description on how the sustain level affects the *actual* decay/release times, and that the times the user sets with the controls are really full-scale-to-zero times, not the times that the state machine stays in the decay or release state. I would advise trying to track down the L2 spec if possible.

            All the best,
            Will

          • Nigel Redmon says:

            PS—Yes, I think my original assertion is correct. If I had a functioning o-scope, I’d just hook my old Aries AR-312 ADSR (basically what I was modeling) to it and give times, but a glance at the schematic looks like it’s constant rate. I did a real crude and quick check with Diva, which is supposed to be a faithful Minimoog envelope, and it’s pretty clear that the knob is controlling rate. For instance, recording noise through the VCA with a moderate Decay, and looking at the final release with Sustain full and again at one-third, the release decay from full is clearly substantially longer than the release from the lower sustain level.

  10. Pete says:

    Some test code related to the above post is at a .com site where bins can be pasted, raw/HkPcny6x

  11. Sean says:

    Thank you Nigel for the clear ADSR implementation overview.

    I’m a little late to the party but nonetheless I have one question about the code:

    In the .cpp, in the constructor, the attack, decay and release rates are all set to 0 (lines 27 – 29). Wouldn’t this cause a division by 0 in the subsequent calculations of the coefficients (line 57)? Doesn’t this cause issues at runtime?

    Thanks again for this blog, it’s a very useful resource.

    • Nigel Redmon says:

      Sean, you may be late to the party, but you brought something! Yes…the code works if the C++ implementation follows the IEEE standard, but C++ is not bound to. That is, -log(…)/0 results in -inf, and exp(-inf) = 0—the IEE standard guarantees it, but C++ doesn’t (works fine in Xcode/LLVM, for instance, but who knows what it won’t work in). I’ll rev the code to make sure it works in all C++. Thanks!

      Nigel

  12. JD says:

    Hi Nigel,
    Thanks for making this available. I am implementing your logic/math in HDL for an FPGA.

    I have a dumb question:
    In the decay and release states, how exactly is the output value supposed to decrease?
    In the decay case, I see:

    output = decayBase + output * decayCoef;

    Are these not all positive operands? How can this decay the envelope? Same question for the release case.

    • Nigel Redmon says:

      Yes, thinking about the math can be confounding at times. Don’t think of RC decay as subtracting. Think of it as being a percentage—always less than 100%—of what it was the last instant. So, output = .9 * output. Except in this case we’re not heading to zero but a higher level for sustain, so we need to add than back in.

      • JD says:

        Thank you. I was able to get my implementation working.
        Wonder if there is a name for this type of math where the output depends on previous outputs.

  13. Makabongwe says:

    Hi there Nigel. I’m working on a synthesizer and I found your code to be very clear and understandable. I’m using a microcontroller to test the code, I have a function that generates a sound wave and I’m not sure how to include your idea in my code so that I get ADSR behavior. How do I test your idea with a sound I have generated?

    • Nigel Redmon says:

      Just create a new envelope, set it, then multiply your signal samples by the “process” output; somewhere, you are triggering the ADSR, of course:

      // initialization
          // create
          ADSR *env = new ADSR();
      
          // set
          env->setAttackRate(.1 * sampleRate);
          env->setDecayRate(.3 * sampleRate);
          env->setReleaseRate(5 * sampleRate);
          env->setSustainLevel(.8);
      ...
      // control: start/end envelope, change envelope params...
          // start envelope
          env->gate(true);
      ...
      // audio processing thread
          // for each sample
          out = in * env->process());
      

Leave a Reply

Your email address will not be published. Required fields are marked *