Developing a Signal-Dependent Envelope Follower

Jatin Chowdhury
6 min readNov 23, 2023

--

In the proceedings of this year’s DAFx conference, there was a wonderful paper by Judy Najnudel et. al. about applying physical modelling to the “vactrol”, a type of “envelope follower” circuit built around a light-dependent resistor. Vactrols were commonly used in optical compressors, low-pass gates, and other audio circuits.

Vactrols are easy enough to build yourself, if you feel so inclined!

While the physics behind the vactrol is fascinating, I was perhaps more interested in its “function”. One property of the vactrol when used in compressor circuits is that the time characteristics of the compression change depending on the input signal. For example, the famed LA-2A optical compressor lists its release time as “0.5 to 5 seconds” depending on the amount of gain reduction being applied by the compressor.

So what if we could “de-couple” the signal-dependent characteristics of the vactrol from the physical processes that create that phenomenon in the real world? What if we could define a generic signal-dependence relationship? Could that allow new or unique behaviours to emerge?

Model Development

A basic envelope follower is often implemented using a 1-pole filter to estimate the level of the signal, e.g.:

where c[n] is some non-negative value derived from the input signal (often just the absolute value of the input). The filter coefficients of the envelope follower are described by:

where T is the sampling period, and t0 is the “time constant” of the follower in seconds. Often, the follower will have two time constants, one for the “attack” phase of the signal (when the signal is increasing), and another for the “release” phase.

Plugging the coefficient definition into our first equation and doing some algebraic manipulation gives us the expression:

Adding Generic Signal-Dependence

Now let’s say that we want the time constant of the follower to be dependent on the signal level. Since the signal level is being estimated by envelope follower, let’s say that the time constant is a function of the envelope follower’s level estimate. Then we can plug this definition into our earlier equation:

Unfortunately, this is a transcendental equation, so we can’t solve for λ[n] directly. We could make this function solvable by replacing f(λ[n]) with f(λ[n-1]), however this would introduce a 1-sample delay into our system, which may or may not be acceptable depending on the circumstances. Instead, we can solve the transcendental equation using an iterative solver, such as the Newton-Raphson method. To use the Newton-Raphson method here, we need to find some equation that equals 0 (usually called F). Then we can take the derivative of F and then define an update expression using F and its derivative.

Solving our implicit equation with a Newton-Raphson iterative solver.

N.B.: For some specific functions f(λ[n]), it may be possible to resolve the transcendental equation without needing an iterative solver, for example, by using the Lambert W function.

Example #1: Exponential Signal-Dependence

Let’s say that we want our follower to have a “slower” response for larger signals than it does for smaller signals. We could do this by using the following signal-dependence relationship (and it’s derivative), where A is some number greater than 0.

The way to think about this is that when the signal is small (i.e. x is near zero), then the time constant f(x) is approximately equal to G. Then when x is larger, the time constant grows larger accordingly. We can visualize this behaviour by running the envelope follower with a step function as input, with two different amplitudes.

As we can see, the envelope follower displays significantly different characteristics between the larger and smaller amplitude signals, in both the “attack” and “release” phases of the envelope. More specifically, the follower appears to react much more slowly to changes in the higher-amplitude signal, which matches our expectation.

Example #2: Inverse Exponential Signal-Dependence

Now if we use the same signal dependence relationship, but choose the parameter A to be some number less than 0, when can see the inverse behaviour, where the follower responds more quickly to the larger signal.

You might notice that the inverse exponential dependence is less obvious in the release characteristic. That’s because as the release characteristic of the larger signal plays out, the signal estimate becomes smaller, and the time constants for the larger and smaller signals converge to approximately the same thing.

We can see this more clearly by plotting the effective time constant being used by the envelope follower as the signal passes through it.

Conclusion

These are the kinds of things about signal processing that always get me excited. I’ve been starting to design some audio effects around the two example envelope followers that I’ve discussed here, but I feel l’m just scratching the surface as far as what is possible. Having a generic and simply-defined signal-dependence characteristic for an envelope follower should lend itself to all sorts of unique and interesting behaviour, so I’m excited to see what folks do with it!

Appendix: Source Code

All plots in this article were generated using the following source code.

# %%
import numpy as np
import matplotlib.pyplot as plt

# %%
FS = 48000
T = 1.0 / FS
N = FS*2 # Make the signal 2-seconds long
x = np.ones(N)
x[:1000] *= 0 # add some silence before the step function
x[-60000:] *= 0 # add some silence after, so we can see the release

# plot the input step function
plt.figure()
plt.plot(x)
plt.ylabel('Amplitude')
plt.xlabel('Time [samples]')
plt.title('Input Step')
plt.grid()
plt.savefig('plots/input.png')

# %%
def sim(x, A, G_exp):
def f_t(x, attack):
G = G_exp(attack) # choose a different "base" time constant for the attack vs. release phase
# Compute f(λ) and f'(λ), this can be optimized a lot :)
f = G * np.exp(A * x)
fp = G * A * np.exp(A * x)
return f, fp

y = np.abs(x)
fl_store = np.zeros_like(y) # store the effective time constant for plotting later

z = 0
for n, c in enumerate(y):
l = z
attack = c > z
delta = 100
# run the N-R solver:
while np.abs(delta) > 1.0e-3:
fl, flp = f_t(l, attack)
F = c + np.exp(-T / fl) * (z - c) - l
F_prime = flp * (T / (fl * fl)) * np.exp(-T / fl) * (z - c) - 1
delta = F / F_prime
l -= delta
y[n] = l
z = l
fl_store[n] = fl
return y, fl_store

def plot_signals(name, A, G_exp, file, plot_envelopes: True):
y1, fl1 = sim(x, A, G_exp)
y2, fl2 = sim(x * 2, A, G_exp)

if (plot_envelopes):
plt.plot(y1)
plt.plot(y2)
else:
plt.plot(fl1)
plt.plot(fl2)

plt.ylabel('Amplitude')
plt.xlabel('Time [samples]')
plt.title(f'{name} (A = {A})')
plt.grid()
plt.legend(['x', '2x'])
plt.savefig(file)

# %%
plt.figure()
plot_signals('Exponential Dependence', 1.5, lambda att : 0.01 if att else 0.1, 'plots/exp_dep.png')

plt.figure()
plot_signals('Inverse Exponential Dependence', -1, lambda att : 0.1 if att else 1, 'plots/inv_exp_dep.png')


# %%
plt.figure()
plot_signals('Exponential Dependence Time Constant', 1.5, lambda att : 0.01 if att else 0.1, 'plots/exp_dep_t0.png', False)

plt.figure()
plot_signals('Inverse Exponential Dependence Time Constant', -1, lambda att : 0.1 if att else 1, 'plots/inv_exp_dep_t0.png', False)

--

--