The code follows the same general principles as the LFO, in that it divides into two sections.
The main code loop polls each of the A/D channels in turn (one channel per time round the loop) and then does something with the value that was read. The values are not used directly, but instead are used as an index into a 24-bit control lookup table. I’ll come back to the table and the updating of values based on CVs after describing the interrupt routine.
The interrupt routine is triggered by TMR2, generated every time a PWM cycle ends. This is handy, since this is when we need to generate another sample, which is essentially what the Timer2ISR code does.
The first thing the routine does is debounce the GATE and TRIGGER inputs. This is done using the vertical counters technique that I first learned frrom Scott Datalo’s site (now dead, unfortunately). The two inputs have one function each. The TRIGGER input starts the envelope. When the TRIGGER input goes high, the envelope jumps to the ATTACK stage. The GATE input ends the envelope. When the GATE goes low, the envelope goes to the RELEASE stage.
Both of these events reset the phase accumulator (coming to that too) and store the envelope output level when the change occured ( as CURRENT_LEVEL) which we’ll need later.
What’s a DDS Oscillator got to do with it?
Although the envelope generator is not an oscillator, I realised that I could use the same principles used in a Direct Digital Synthesis (DDS) oscillator to generate exponential curves of various lengths. Do a web search for “Direct Digital Synthesis” if you haven’t come across it. The gist of it is that you use a counter (called the “phase accumulator”) as an index into a wavetable. Altering the increment that the counter uses to count will alter how fast the wavetable samples play back. For example, an increment of 2 (eg skipping every other sample) with produce a waveform one octave below an increment of 4 (skipping three samples, then outputting one). Being a binary counter, when the phase accumulator reaches its maximum value, it just wraps round back to the beginning.
So how do we make a envelope generator with this? Well, the wavetable is an exponential curve. I have two, one going up for attack, and another coming down for decay and release. The difference between my env gen and a DDS osc is that the accumulator is never allowed to wraparound – instead this represents the end of one stage and the beginning of another.
Since I have various stages (ATTACK, DECAY, RELEASE) that all have different lengths, I need to use different phase accumulator increments for each. The phase accumulator is a 24-bit value, as are the increments, although only the lowest 20-bits are used for the increments.
The IncrementPhaseBranch code works out which stage we’re at and which increment we should add to the phase accumulator, and then the IncrementPhase code adds it on. I was pleased with this bit as it’s a neat use of the INDF indirect addressing technique, although it does make the code a bit obscure. As part of the IncrementPhase code, we check to see if the phase accumulator has overflowed (eg if this stage has ended), and if it has, we run the NextStage code (which moves us from ATTACK to DECAY, or from DECAY to SUSTAIN, or from RELEASE to WAIT). If not, we skip it.
Finally, we need to use our recently-updated phase accumulator value to work out an output value. This again depends on which stage we’re at (ATTACK, DECAY, SUSTAIN, Etc) so the SelectStage and SelectStageBranch code sends us to separate routines for each stage.
Calculating output values
Let’s look at each stage in turn:
WAIT is a simple stage, as it doesn’t do anything, so we just output a zero.
ATTACK is a bit more complicated. The attack curve might not start from zero- for example, if the gate triggers again whilst a slow release is still occurring. Hence, the attack needs to go from whatever the release level was when the gate changed to the maximum value. Luckily, we stored the value when the GATE changed as CURRENT_LEVEL, so we can scale the attack accordingly. The scaling value is worked out first, and then stored in MULT_IN for later.
If EXPO_OR_LIN is set to linear, we can simply use the phase accumulator high byte as our output value. For an exponential value, we go to the ExponentialAttack code and use the top phase accumulator byte to look up both the current attack curve value and the one after. The middle phase accumulator byte is then used to interpolate between these two values. This interpolation gives most of the effect of a 16-bit lookup table, whilst only storing 256 values. In fact, it’s a 255-section linear approximation to an exponential curve, but with our 8-bit output resolution, no-one is going to see the difference!
Once we’ve got our output value (either the direct linear one or the interpolated exponential one) we run the AttackScaling code and perform the scaling using the MULT_IN value we worked out earlier. This is done with the Multiply8x8 subroutine which returns 16-bit result, which (once it’s had the CURRENT_LEVEL added) finishes up in OUTPUT_HI and OUTPUT_LO.
The DECAY and RELEASE stages work very similarly, although they have to be scaled differently. The DECAY must be scaled to go from maximum to the SUSTAIN level, whatever that is, whereas RELEASE must be scaled to go from SUSTAIN down to zero. MULT_IN is worked out at the beginning of each of these routines appropriately.
Overall Envelope Level
Finally, the output value (OUTPUT_HI, OUTPUT_LO) is multiplied by the LEVEL_CV (MultiplyByLevelCV) to give the final output value (FINAL_HI, FINAL_LO) which is passed to the PWM module, and the interrupt exits. I toyed with the idea of trying to improve the accuracy of some of these multiplications (maybe by rounding rather than truncating, or by using some extra bits) but in the end I just threw away the 8 low bits and only used OUTPUT_HI in the MultiplyByLevelCV routine. This routine spits out a 16-bit result of which only ten bits (truncated, again) are used by the PWM. It seems to work well enough nonetheless!
Control Look-up table
This table converts from CV values to phase accumulator increments. The smaller the CV, the shorter the required time (eg 1mS is obtained with a CV of 0V, whereas 5V gives you 10 Secs). Since shorter times are obtained by skipping more samples in our wavetable, the phase increment will be larger. Larger increment=quicker envelope. The table does this conversion. It also changes the linear response of the AD convertor into a logarithmic response. The time increases in even decades (eg 1-10mS, 10-100mS, 100-1000mS, 1-10Secs) with 64 steps in each range. In order to get this range of times (1:10000), I found it was necessary to use a 24-bit accumulator and 20-bit increments.
Updating the CV values
Finally, let’s look briefly at what happens in the main code when a new CV is received from the DoADConversion routine, for example when we update the ATTACK CV.
The AD conversion generates an 8-bit value, but this value can be altered by the value of the TIME CV input. The TIME_CV value is also 8-bit, but is reduced to 7-bits to keep its range under control. The final value is found by taking the TIME_CV value off the ATTACK_CV, so increasing TIME_CV will shorten the envelope time. In order to prevent errors when TIME_CV is larger than ATTACK_CV, the borrow flag is tested and zero is used if the result of the subtraction was negative.
Example: ATTACK CV
Once we’ve got our final ATTACK CV value, and dealt with the effect of the TIME CV and limited that effect to the legal range, we can use the value to look up a new phase accumulator increment value from the table. This happens in the AttackInvert code, which is called that since the first thing it needs to do is invert the value to correct a previous inversion when messing about with the TIME CV.
Obviously you can’t get a 20 bit increment value in one go, so when code uses the CV value as an index into the table, the index is used three times to retrieve each byte of the 20-bit value separately (GetPhaseIncHi, GetPhaseIncMid, GetPhaseIncLo). This value is then stored as the three bytes of ATTACK_INC. Notice that the raw ATTACK CV value isn’t actually stored at all.
The DECAY CV and RELEASE CV are similar. The SUSTAIN CV is dead easy, as is the LEVEL CV, since they are just levels from 0-255. The TIME CV is bitshifted to reduce it to 7-bits once it is stored, but is also an easy one.
And that’s it! Phew! If you made it to here, bloody well done!