Art of Softsynth Development: The Stateless Envelope
ADSR envelopes are a universal tool in softsynths. They’re almost always used to control the amplitude of individual voices, but you can also use them for more general modulation. Here’s an illustration of how the basic ADSR envelope looks:
attack, decay and release are all time parameters, while sustain represents an amplitude. This is the basic envelope. There are many variants of the ADSR envelope, primarily with regards to curve shape, but some synths are more flexible, having e.g. hold, fade and delay parameters.
The stateful ADSR envelope
Most people make a mistake when they implement envelopes: they make it stateful rather than stateless. The basic idea in the stateful implementation is that the envelope is a simple state machine. So you define a set of possible states for the envelope:
class ADSREnvelope
{
public:
enum State
{
kStateInvalid = 0,
kStateAttack,
kStateDecay,
kStateSustain,
kStateRelease,
kStateFinished,
}
// State
State curState;
double curValue;
...
};
States must then be set externally on note on/off:
void ADSREnvelope::NoteOn()
{
curState = kStateAttack;
curValue = 0.0;
}
void ADSREnvelope::NoteOff()
{
curState = kStateRelease;
}
When processing, you update the current value of the envelope, and then check if it necessary to move to the next state. I am assuming here the simplest possible envelope, using linear ramps (don’t use those in practice, I am merely illustrating a point).
double ADSREnvelope::ProcessSample()
{
switch (curState)
{
case kStateAttack:
curValue += deltaValueAttack;
if (curValue >= 1.0)
state = kStateDecay;
break;
case kStateDecay:
curValue -= deltaValueDecay;
if (curValue <= sustain)
state = kStateSustain;
break;
case kStateRelease:
curValue -= deltaValueRelease;
if (curValue <= 0.0)
state = kStateFinished;
break;
case default:
curValue = 0.0;
break;
}
return curValue;
}
Problem?
This approach is intuitive, which is why most ADSR envelopes are implemented this way. It’s relatively simple and fast, but that is about all it has going for it.
The first problem is brittleness: it’s not numerically robust, and it will have surprising corner cases that will trip you up. Don’t do it this way.
The second problem is that what I’ve shown here is the absolutely simplest version of it, and it doesn’t scale! As soon as you start adding features, you’re going to make problem #1 way worse.
The third problem is flexibility. The only thing this envelope can do is update and move to the next sample. What if I wanted to know the value three milliseconds ago? In 2 seconds? What if the user starts changing parameters while the envelope is running? What if I want to use this for modulation, which only updates every 256 samples? What if the modulation rate is adaptive, changing all the time?
Statelessness
Let me outline the stateless ADSR envelope implementation for you (still linear ramps, which you still don’t want in practice):
double ADSREnvelope(double timeSinceNoteOn, double timeSinceNoteOff, double attack, double decay, double sustain, double release)
{
if (timeSinceNoteOff < 0.0) // we didn't note off yet
{
if (timeSinceNoteOn < attack)
{
// attack
return timeSinceNoteOn / attack;
}
else if (timeSinceNoteOn < attack+decay)
{
// decay
return 1.0 - (1.0 - sustain)*(timeSinceNoteOn-attack)/decay
}
else
{
// sustain
return sustain;
}
}
else
{
if (timeSinceNoteOff < release)
{
// release
return sustain*(1.0-timeSinceNoteOff/release);
}
else
{
// finished
return 0.0;
}
}
}
There! Much simpler, and much more flexible too! Subtle bugs - sure, the math might be wrong, but subtle? Not exactly.
It will also answer all of your questions about what happened at different times, it’ll respond robustly to parameter changes, and using it for modulation at adaptive rates is no problem.
Additionally - it scales with more features! It might not be pretty (neither would it have been with statefulness), but more features will be manageable without riddling it with subtle bugs.
But – this’ll be slow as molasses!
I challenge you to profile your synth with this envelope and truthfully tell me that your envelope calculations use up more than 1% of your CPU time. This is not going to make or break you. Considering the simplicity, flexibility and reusability - we will accept the performance hit.
But – I have to pass in all of these parameters?
Seriously, that’s your objection?
Such is the nature of statelessness - or functional programming, in another word. The function can only know what you pass to it. I agree that the syntax isn’t pretty, so spend 5 minutes thinking about simplifying it.
In conclusion
Softsynths already have state all over the place. There is no reason to introduce more state for minuscule performance wins, so try to write your code in a functional manner. I promise that you’ll be happy you did.