As part of a series on the Gakken SX-150 mk II, we're looking at the envelope. Much like the rest of the synth, it's very spartan. Our only controls are attack and decay, making it an AD envelope. We could add MIDI control and call it a day, but wouldn't it be nice to have a full ADSR?
| The stock SX-150 mk II envelope |
This is a pretty standard design. An incoming gate signal gets filtered into a trigger that sets a latch, starting the attack phase. The latch charges a cap until it passes the threshold of a comparator, and the comparator resets the latch, starting the decay stage. The capacitor discharges through the latch until another gate signal is encountered.
All the World's a Stage
The latch state defines the stage, and it only has two states, so how do we add a stage? We need to fold in another signal to help define it. We have the input trigger, but that's too fleeting. What about the incoming gate? The rising edge of it lines up with the start of the attack, so that's not adding any information. The falling edge can extend past the end of the attack stage though; That's something new to work from.
We can use the gate signal to "lift up" the decay stage, so that it decays to a sustain level instead of 0V. We'll sever the connection between the latch and the decay pot, then connect it - via potentiometer - to the gate signal. The potentiometer lets us set the sustain level.
| Envelope response with sustain adjustments |
Well, it sort of works, but the attack stage gets rounded off and lowered as we lower the sustain. If the sustain gets too low, the attack stage is unable to progress to the decay stage. Previously, the latch kept the left side of the decay potentiometer high (~5V) during the attack stage. This combined with the diode prevented it from discharging the capacitor. Now we're setting that side to the sustain level, and fighting against the attack stage. The resistance of the sustain pot is also making the decay longer, and limiting the minimum decay time. Let's fix it.
Self Sustaining
| Sustain envelope V2 |
That's more like it. The attack stage isn't impacted, nor is the decay rate. This is because we mixed the attack signal into the sustain level (seen in red), temporarily raising the sustain level and preventing it from pulling down the attack stage. We also added a buffer to prevent the sustain pot's resistance from changing the decay.
We even gained a more elegant way to turn off the sustain: a single-throw switch instead of a double. We can also get the same effect by turning the sustain level down to 0V.
Release Yourself
Now we have an elegant way to add sustain, but notice that the decay and release rates are the same. That's because they're defined by the same pot. How can we separate out the release rate?
We need to repeat the trick we just learned, and prevent the decay stage from pulling down the release stage. If we know when the release stage starts, we can force the sustain level high during it. Well, that's pretty easy; The release stage starts as soon as the gate goes low. The circuit already has an inverted version of the gate that we can tap into, and diode-or with the sustain level, bringing it high.
Now that the decay stage is out of the way, we need to do the same for the release stage. We can pipe the non-inverted gate signal into it, keeping it held high and inactive until the start of the release stage. Once that goes low, the release stage can start, draining the capacitor through a new, separate release pot.
| Full ADSR |
There we have it, a full ADSR that only requires six extra components.
MIDI Control
We have our improved envelope, how can we control it with our Arduino R4? Digipots are a natural choice since they're drop in replacements for the original potentiometers. There are drawbacks though. Their resolution is finite, usually only 128-256 steps, and they can't pass high current. This envelope uses an especially high value capacitor at 100uf, and this takes a large current to charge/discharge quickly. Let's lower the capacitor value so we can reduce the current demand. 10uf is a little more reasonable.
Lowering the capacitor shortened the time of the entire envelope by a factor of 10. We can compensate by increasing the potentiometer values by 10. There's a problem though; Digipots only come in a few values, usually topping out at 100k.
Finger on the Pulse
Another option that we've looked at before is PWM and analog switches. PWM has the potential for many more steps (65536 with full 16-bit PWM). The switches pass more current, and they have a large range of effective resistances. The main drawback is that they don't pass high frequencies all that well. This isn't a problem for our envelope though.
There's a native library that makes it easy to setup PWM on the R4. We want to select a pin that doesn't interfere with things like SPI, and we want to avoid clobbering any timer that we're already using (like GPT0 used in our prior autotune code).
Finding the relationship between Arduino pin, RA4M1 pin, and GPT channel requires cross referencing multiple documents (and the Nano R4 User Manual is currently incorrect!), so here's a table:
Pin 5 fits the bill, corresponding to GPT2, so we can call the library like this:
#include "pwm.h" PwmOut pwmPin5(5); //GPT2 pwmPin5.begin(10000.0f, 0.0f); //10kHz PWM pwmPin5.pulse_perc(10.0f); //set duty percent
That gives us 10kHz PWM with a 10% duty cycle. Varying the duty cycle changes the effective resistance, and sets the attack/decay time. The relationship between duty cycle and decay rate is not what you might expect though, and the majority of the adjustment happens in the range of 0-10%.
We can analyze the effective resistance of the analog switch under PWM, and the response of the RC circuit, or we could just tweak some curves until it feels right. Given that all we care about is the feel, let's do that.
Getting to the Bottom of Things
We can normalize our MIDI CC value so that it spans 0-1 by just dividing it by the max value of 127. We'll let `x` represent this value and that'll make all the math simpler.
Since our range is all bunched up at the lower values, let's try making our CC exponential. An exponential curve rises slowly, and that serves to space out the lower values, while bunching up the higher values instead.
We can pick a base number: b, and take it to the power of our CC value, `x`: `b^x`
| `10^x` |
Here's what `10^x` looks like. The curve is ok, but there are problems. Most of it is defined by negative values of `x`, it never hits 0, and the y-intercept of 1 isn't very convenient.
Made to Fit
The y intercept of 1 holds true for any base value, because of the definition: `b^0 = 1` (when `b != 0`). We can force any formula to pass through (0, 0) by just subtracting the y-intercept:
| `10^x - 1` |
We're anchored at (0,0) now, but that's about it. If we can force it to pass through (1,1), then we'd have a very handy normalized function (one where `0<y<1` for `0<x<1` ). We can plug in an `x` value of 1, and evaluate for `y`: `y = b^1 - 1 = b - 1`. This is another special case for exponents where `b^1 = b`.
We know that anything divided by itself gives us 1, so we can take the `y` value we just found for `x=1`, divide the whole formula by it, and that will force it to output 1 for `x=1`:
`(b^x - 1)/(b-1)`
| `(10^x - 1)/(10-1)` |
There we go; The curve passes through both points (0,0) and (1,1). Now we can adjust `b` and only change the severity of the curve, not the range.
| `(10000^x - 1)/(10000-1)` |
Finally we can rearrange the math to make it a little more friendly to the R4. Division is slower than multiplication, so we want to help the compiler avoid it. If we pick a fixed value for `b`, we can precompute the reciprocal of the denominator, turning the division into multiplication. Sometimes the compiler is able to do this itself, but we can spell it out without impacting the legibility of our code.
While we're at it, we can scale from our 0-1 range, up to 0-100 for the duty cycle percentage by multiplying our precomputed value.
constexpr float b = 50000.0; //curve severity
constexpr float r = 100 / (b - 1.0); //precalc reciprocal
float expoVal = (pow(b, normalizedCCval) - 1) * r;
pwmPin5.pulse_perc(expoVal); //set duty percentSustained Fire
We can apply this scaled-PWM-analog-switch solution to the attack, decay, and release times. The sustain control is a different animal though. It's set up as a voltage divider instead of a single resistance, it doesn't have to pass a high current, and it doesn't need to be adjusted exponentially. This is actually a great use-case for a Digipot... but PWM channels come in pairs on the R4, and we've used three. Let's rework the sustain level to use this free PWM channel.
If we lowpass the PWM signal, we get an equally "free" analog output. The voltage we get is proportional to the duty cycle. Filtered PWM can be a little sluggish to change, but this isn't a problem for a rarely-changing sustain level.
Remember that we switched out the decay pot for an analog switch. It can do double duty, connecting and disconnecting our sustain CV as needed. A little more diode logic will do just that. We add a simple filter before the op-amp buffer, and we have a PWM-driven sustain stage.
| Filtered PWM sustain |
Again, there are multiple ways to solve this problem, and the best option is going to depend on what parts/IO we have to spare. If we have a leftover CV channel, that's better than filtered PWM. If we have a spare digipot, why not use that? We'll reevaluate our options as the rest of the MIDI-fied Gakken project progresses.

