Designing a straightforward limiter
Geraint LuffSignalsmith Audio Ltd.
We set out some requirements for a brick-wall limiter, have a go at solving them, and sketch out some ways it could be modified.
So, here's the challenge. We have some input signal, but it goes outside the "allowed" range we have set:
We want to modify this signal such that it stays within the specified range. We want to preserve the loudness to some degree, and we don't want to reduce the gain in quieter sections where we don't need to.
This is a job for a limiter! Let's write one.
Varying gain over time
What we need is a gain envelope which changes over time, in response to the signal. That's simple enough: for each sample, we see if it's over the limit, and reduce it as much as needed.
Example C++ code
We can see the gain levels reacting to the input signal, with the lowest values corresponding to the biggest peaks in the input. Let's have a listen:
Ah. You see, what we've written here is just a hard-clipper: any sample above the amplitude limit gets reduced to exactly the limit.
Smoothing out the gain changes
That gain value is the maximum gain we can apply to avoid the output going above our limit, but sticking exactly to that limit means the gain curve changes really quickly, and this distorts our signal. We can avoid this by creating a smoother gain curve, which stays strictly below the gain values we used above.
There's a neat way to do this, using two other processors we discussed recently:
Peak-hold
We can turn a peak-hold processor upside-down to get a moving minimum instead of a maximum. For each input sample, the next
Example C++ code
Finite-length smoothing
If we then take a (weighted) moving average no longer than our moving minimum, it will smooth out the result while still remaining below our input:
A box-filter (rectangular moving average) would work here, but we recently looked at how you can cascade box-filters to get something even better. The key point is that if the moving-average is finite, the output will be strictly below the input.
Latency
We end up with a smoothly-changing gain value that is always under the maximum gain... from
To get our new smooth gain curve to align properly, we just need to delay the original input signal by the same number of samples as our moving-minimum and smoothing:
Example C++: basic limiter
BoxStackFilter
) are existing implementations from the Signalsmith DSP Library. We've talked about the peak-hold/smoothing algorithms before, so you can implement the same thing yourself if you want.With that, here's our output:
The waveform now fits within our bounds, and sounds reasonably like the original. So, this is a limiter!
Release curve
That example was computed with a 30ms peak-hold and smoothing time - meaning the gain starts reducing 30ms before each peak (with 30ms of latency) and takes 30ms to return to settle back afterwards. Here's a graph of the output gain over time (compensated for latency):
Because of how we perceive transients and volume-changes, we often want a shorter attack/anticipation than this - but still have a longer release. This is fairly simple to do - we use a shorter hold/smoothing, and put a release into the gain-curve calculation path.
One of the simplest release curves is exponential. Every sample, the gain envelope either decreases to match the input, or increases (at most) a fixed percentage towards the input:
Example C++: exponential release
struct ExponentialRelease {
double releaseSlew;
double output = 1;
ExponentialRelease(double releaseSamples) {
// The exact value is `1 - exp(-1/releaseSamples)`
// but this is a decent approximation
releaseSlew = 1/(releaseSamples + 1);
}
double step(double input) {
// Move towards input
output += (input - output)*releaseSlew;
output = std::min(output, input);
return output;
}
};
Extra hold time
Similarly, we might want the gain curve to hold constant for a bit, before the release curve starts. If there are lots of peaks in a row, this prevents our gain envelope from fluttering.
This is also pretty simple to do: we increase the time for our moving-minimum, but not the smoothing or latency. Our processing flow looks the same as before, only the lengths have changed:
Let's look at our gain response curve for this updated flow:
Examples
Let's try this new setup on our example sound. For this, I've used a 5ms attack, 15ms extra hold time, and a 40ms release:
And here's the implementation:
Example C++: limiter with attack/hold/release
Alternative release curves
There are a ton of ways to implement release curves, some of which are also very simple. One example would be to limit the gain to increase at a fixed rate:
Constant-time release
There's another one I like, where the curve returns back to neutral in a fixed amount of time, regardless of how deep the dip is:
The algorithm here is quite simple, but figuring it out was a fun little puzzle. You start with a moving-minimum, and then your output moves with a gradient proportional to the difference between the input and that minimum:
Example C++: constant-time release
struct ConstantTimeRelease {
signalsmith::envelopes::PeakHold<double> peakHold{0};
double gradientFactor = 1;
double output = 1;
ConstantTimeRelease(int releaseSamples) {
releaseSamples = std::max(releaseSamples, 1);
// This will finish its release 0.01 samples too early
// but that avoids numerical errors
gradientFactor = 1.0/(releaseSamples - 0.01);
peakHold.resize(releaseSamples);
peakHold.reset(1); // start with gain 1
}
double step(double input) {
// We need the peak from one sample back
double prevMin = -peakHold.read();
peakHold(-input);
// Gradient is proportional to the difference
output += (input - prevMin)*gradientFactor;
output = std::min(output, input);
return output;
}
};
Cascaded releases
Both the exponential-release and the constant-time shown above can be cascaded in series to produce a smoother curve. Here's what happens when you cascade
Closed-form expression for the cascaded-exponential release curve
Above, we scaled the release lengths by
For that, it's useful to have a closed-form expression for the release curve, at least for the continuous-time equivalent. We'll normalise the release time, such that the impulse response of our smoother is the one-sided exponential:
A cascade of
This has a fairly neat closed-form solution:
This is trivially true for
Each extra stage adds another level of continuity - so one release-stage produces continuous values, two will produce continuous gradient, and so on.
You can cascade constant-time releases in the same way:
For both of these, when the cascade has more stages, the release curve has a much slower start (starting to look similar to the hold period) while the main part of the release is steeper.
... and even more choices
Even starting from this fairly simple design, we've had to make some decisions about the shape of our release curve, and the shape of the FIR smoothing for the attack.
These choices will affect the particular sound of a limiter - and here are a few more things to consider:
Inter-sample peaks
So far, we've only considered the values of individual samples. But when a signal is re-sampled, or converted to analogue (to head to the speakers!), it can produce values outside our allowed range.
These are called "inter-sample peaks", and a good limiter should make sure these are contained as well. The most straightforward approach would be to actually upsample the signal, see what values come out, and then use these in the maximum-gain calculation.
Loudness scale
Our design (and example code) above dealt directly with gain multipliers. But we could easily adapt it to dB instead:
We convert our "maximum gain" into dB (or any other scale we want!), perform all the same smoothing and release logic as before, and then convert back afterwards. A lot of compressors work on a dB scale - should our limiter use dB as well?
Latency and imperfect anticipation
Our design uses a delay and FIR smoothing to anticipate peaks. But do we need to perfectly anticipate everything?
If we were prepared to accept a bit of clipping on sudden peaks, our smoothers could be longer than the peak-hold length. We'd have to hard-clip the output, but this would let us use IIR smoothers, or could be used trade distortion for latency (by reducing the delay and peak-hold durations). In a live situation where 10ms of latency would be a dealbreaker, it might be worth it.
Adaptive release
Does the release time have to be constant? Are there things we can detect in the input to determine when we should recover quicker or slower?
Mixed-size release cascades
The release cascades we looked at were using the same length for all of their stages, but we can use any combination of sizes. Using smaller releases in combination with bigger ones can soften the corners without disrupting the overall shape - and we could even mix exponential/constant-time stages.
Multi-band processing
Multi-band compressors are pretty common, where you perform a band-split and then compress each band separately.
Similarly, having a limiter which reduces some bands more than others (or has different attack/hold/release times) is an appealing idea. However, if we distribute the gain-reduction differently between the bands, they might add up differently in the attack/hold/release stages. This could be solved by iterating, or just having a second full-band limiter afterwards, but it's less straightforward than for compressors.
Conclusion
While there are ways it could be extended and improved, we've gone through a simple design for a limiter, where we specify:
- maximum output value (limiter threshold)
- attack time
- hold time
- release time
I hope it can be a useful starting-point for anyone who hasn't considered writing one before.