The Dynamics Compressor
A feed-forward digital compressor built from first principles: log-domain detection, attack and release ballistics, and a soft-knee gain computer — with the real DSP running live in your browser.
A feed-forward digital compressor built from first principles: log-domain detection, attack and release ballistics, and a soft-knee gain computer — with the real DSP running live in your browser.
A compressor is a control system for loudness. It measures how loud a signal is, decides — moment by moment — whether that exceeds a chosen threshold, and if so it turns the signal down by a controlled amount. Everything else is refinement of those three ideas: how we measure, how we decide, and how quickly we act.
The dynamic range of a passage is the distance in decibels between its quietest and loudest moments. A close-miked vocal might swing forty decibels between a whispered consonant and a belted note; a snare drum's transient can sit twenty decibels above the ring that follows it. Such range is musically expressive, but it is awkward: the quiet parts vanish beneath a mix, the loud parts overshoot a broadcast limit or a cheap loudspeaker. Compression narrows that range so the signal sits more predictably — louder on average, steadier under the hand of a mix, and often simply more present.
The decibel is the natural currency here because loudness perception is roughly logarithmic. A signal of amplitude $a$ has a level in decibels relative to full scale of
Doing the arithmetic of compression in this log (dB) domain is not merely convenient — it is what makes a compressor's controls behave the way an engineer expects. A "ratio" is a straight line in dB-in against dB-out; an "attack time" is a time constant applied to a level expressed in decibels. Keep that in mind: almost all of the mathematics below lives in decibels, and only the very last step converts back to a linear multiplier that scales the samples.
Four numbers define the static behaviour, and two more define the dynamics:
We will build each of these as a concrete equation, then wire them into a working plug-in whose on-screen code is exactly the code producing the sound.
Every hardware topology solved the same problem with a different variable element, and each left a sonic fingerprint that digital designs still emulate. The lineage runs from valve to optical to FET to VCA, and finally into software.
Two architectural choices shape everything: whether the detector looks at the input or the output (feed-forward versus feed-back), and whether the level is measured before or after conversion to decibels (linear versus log-domain detection).
In a feed-back compressor — the natural topology for most vintage hardware — the detector watches the output and steers the gain element to keep that output in check. It is a closed loop. The advantage is a smooth, self-correcting, programme-dependent response; the disadvantage is that the effective ratio and timing depend on the loop gain in ways that are hard to specify exactly, and a genuinely infinite ratio is impossible (the loop would need infinite gain).
In a feed-forward compressor the detector watches the input and computes the gain directly, applying it downstream. The path is open-loop, so the static characteristic is exactly whatever the gain computer says it is — a clean, prescribed curve, an exactly infinite ratio if desired, and a response that is straightforward to analyse. Modern digital compressors, including the one here, are almost universally feed-forward.
Detector reads the input x[n]. Gain computed from a static curve and applied to a (possibly delayed) copy of the input. Exact ratios, predictable, allows look-ahead. Our design.
Detector reads the output y[n]. Self-correcting and smooth, but ratio and timing emerge from loop dynamics. Characterful; harder to specify. Most classic hardware.
The second choice is where to place the logarithm. In a log-domain design we convert the detector signal to decibels first, then run the gain computer and the attack/release smoothing entirely in dB. This is the dbx-160 lineage, and it is what makes the ballistics musically sensible: a time constant applied to a decibel value produces exponential recovery in dB, which is what our ears track. It also means the gain computer is a set of straight lines and a parabola, trivial to implement exactly.
The complete feed-forward, log-domain signal path we will build is:
Read left to right: rectify or average the input to get an amplitude, express it in decibels, pass it through the static gain computer to find the reduction the curve demands, smooth that reduction in time with the attack and release coefficients, add make-up gain, convert back to a linear multiplier, and scale the (dry) input by it. The sections that follow are simply these boxes, one at a time.
The detector answers "how loud is the signal right now?" It has two parts: a rectifier that turns the bipolar waveform into a positive amplitude, and a smoother that gives that amplitude a controlled attack and release — the compressor's ballistics.
A peak detector tracks the instantaneous magnitude $|x[n]|$. It responds to the sharpest transient sample-by-sample, which is exactly what you want when protecting against overs or catching a snare crack. An RMS detector instead tracks a short-time root-mean-square,
which follows the signal's energy over a small window and correlates far better with perceived loudness. RMS detection ignores brief spikes and reacts to sustained level — gentler, and more like the ear. Vintage VCA units such as the dbx 160 are RMS-sensing; fast FET units such as the 1176 are essentially peak-sensing. Our plug-in offers both, and you can hear the difference on the drum loop: peak clamps the transients hard, RMS lets them through and leans on the body.
Both the RMS averager above and the attack/release stage are the same first-order low-pass filter, the workhorse of dynamics processing:
Its single coefficient $\alpha \in [0,1)$ sets the time constant. To make a smoother whose step response reaches $1 - 1/e \approx 63\%$ of the way to a new value in $\tau$ seconds, at sample rate $f_s$, choose
Small $\tau$ (fast) gives $\alpha$ near zero and near-instant tracking; large $\tau$ (slow) pushes $\alpha$ towards one and a long, lazy glide. A compressor needs two such constants — a short one for the attack, so gain clamps down quickly as the signal rises, and a longer one for the release, so it eases back gently as the signal falls. Using separate coefficients depending on the direction of travel is what gives dynamics their feel.
There is a subtlety of ordering that separates a good compressor from a lumpy one. Rather than smoothing the level and then computing gain, we compute the gain the static curve demands at every sample and smooth that — the gain-reduction control signal, in decibels. This is the branching (decoupled) peak detector of the Giannoulis tutorial: it keeps the attack and release acting on the quantity we actually care about, the number of decibels of reduction, and avoids the artefacts of smoothing a level that is about to be pushed through a non-linear curve. The next section produces that gain; the branch that chooses attack or release lives at the very end of the loop.
The gain computer is the static heart of the compressor: given an input level in decibels, it returns the output level the compressor's characteristic demands. Its graph is the transfer curve you see in every compressor's display — a straight line below threshold, a straight line of shallower slope above, joined by a smooth parabolic knee.
With no knee, the static curve mapping input level $x$ to target output level $x_{sc}$ (both in dB) is two line segments:
Below threshold the compressor is transparent — output equals input, unity slope. Above threshold the slope flattens to $1/R$: a 4 dB overshoot at $R=4$ becomes 1 dB, so the output only climbs a quarter as fast as the input. As $R \to \infty$ the slope goes to zero and the compressor becomes a limiter, pinning everything above $T$ to the threshold.
A hard corner at $T$ is audible as an abrupt onset of compression. A soft knee of width $W$ decibels replaces the corner with a parabola that is tangent to the unity line at $x = T - W/2$ and tangent to the compressed line at $x = T + W/2$, so the transition is continuous in both value and slope. The full piecewise gain computer is:
Below the knee the response is unity; inside the knee it is the quadratic interpolation above; beyond the knee it is the compressed line. A wide knee (say 12–24 dB) gives a gentle, "over-easy" compression that starts working well below the nominal threshold — flattering on vocals and mix bus. A knee of zero recovers the hard corner, useful for surgical limiting.
The static curve tells us the target output level. The gain the compressor must apply, in decibels, is simply the difference between what came in and what the curve says should come out:
This is the raw gain reduction — zero below threshold, increasingly negative above. Now the ballistics from §4 smooth it, choosing the attack coefficient when the reduction is deepening and the release coefficient when it is easing:
Finally we add make-up gain and convert the smoothed decibel gain to a linear multiplier, which scales the input sample:
For parallel (New York) compression, we blend a fraction of the untouched dry signal back in — a mix control from fully wet (the equation above) to fully dry (bypass). Blending a heavily compressed copy under the original keeps transients intact while raising the quiet detail, and it is a single linear crossfade at the very end. That is the entire algorithm; the demo below runs precisely these equations, sample by sample.
The processor below is a hand-written feed-forward compressor running inside an AudioWorklet: the detector, gain computer and ballistics are the equations of §4 and §5, sample by sample, on the audio thread. Start the built-in drum loop — a punchy kick, a snappy snare and a sustained bass — then lean on the threshold and watch the gain-reduction meter bite on every transient.
Knobs: drag vertically to adjust, double-click to reset. The left display is the static transfer curve with the live operating point; the red bar is gain reduction; the lower pane is a scrolling waveform with the gain-reduction envelope drawn over it. "A/B" bypasses the processor for an instant dry comparison at matched routing.
The demo carries the three canvas visualisations the brief calls for, each a direct window onto the maths:
Because the curve and the audio share one implementation of the gain computer, the dot always sits on the line — there is no separate "visualiser" that could drift from the sound.
Two listings follow: the JavaScript AudioWorklet that is actually running above, and an idiomatic JUCE processBlock that implements the identical algorithm in C++. They are line-for-line the same maths — detector, gain computer, branching ballistics — so that reading either teaches the whole thing.
This is the core of the worklet whose output you are hearing. Levels are in decibels throughout; only the final line returns to a linear multiplier.
class CompProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name:'threshold', defaultValue:-18, automationRate:'k-rate' },
{ name:'ratio', defaultValue:4 , automationRate:'k-rate' },
{ name:'attack', defaultValue:0.010, automationRate:'k-rate' }, // seconds
{ name:'release', defaultValue:0.150, automationRate:'k-rate' },
{ name:'knee', defaultValue:6 , automationRate:'k-rate' }, // dB
{ name:'makeup', defaultValue:0 , automationRate:'k-rate' }, // dB
{ name:'mix', defaultValue:1 , automationRate:'k-rate' }, // 0..1
{ name:'bypass', defaultValue:0 , automationRate:'k-rate' },
{ name:'detector', defaultValue:0 , automationRate:'k-rate' }, // 0 peak, 1 RMS
{ name:'schp', defaultValue:0 , automationRate:'k-rate' } // sidechain HPF Hz
];
}
constructor() {
super();
this.gs = 0; // smoothed gain reduction, dB (<= 0)
this.msq = 1e-8; // running mean-square for RMS detection
this.rms = 0.010; // RMS window, seconds
this.hpX = 0; this.hpY = 0; // sidechain high-pass state
this.frame = 0;
}
process(inputs, outputs, p) {
const inp = inputs[0], out = outputs[0];
if (!inp || inp.length === 0) return true;
const T=p.threshold[0], R=p.ratio[0], W=p.knee[0], M=p.makeup[0];
const mix=p.mix[0], bypass=p.bypass[0], det=p.detector[0], scHz=p.schp[0];
const atk=Math.max(p.attack[0],1e-5), rel=Math.max(p.release[0],1e-4);
const aA = Math.exp(-1/(atk*sampleRate)); // attack coefficient
const aR = Math.exp(-1/(rel*sampleRate)); // release coefficient
const aM = Math.exp(-1/(this.rms*sampleRate)); // RMS averaging
const hpR = scHz>0 ? Math.exp(-2*Math.PI*scHz/sampleRate) : 0;
const nCh = inp.length, N = inp[0].length;
let grPeak = 0, lastX = -120, lastY = -120;
for (let i=0; i<N; i++) {
// --- detector signal: mono sum of the channels ---
let s = 0; for (let c=0;c<nCh;c++) s += inp[c][i]; s /= nCh;
// --- optional first-order sidechain high-pass (detection only) ---
if (scHz>0){ const y = hpR*(this.hpY + s - this.hpX); this.hpX=s; this.hpY=y; s=y; }
else { this.hpX=s; this.hpY=0; }
// --- rectify -> level in dB ---
let xdb;
if (det > 0.5){ this.msq = aM*this.msq + (1-aM)*(s*s); xdb = 10*Math.log10(this.msq+1e-12); }
else { xdb = 20*Math.log10(Math.abs(s)+1e-12); }
// --- gain computer (soft knee), all in dB ---
const over = xdb - T; let xsc;
if (2*over < -W) xsc = xdb; // below knee
else if (2*Math.abs(over) <= W) xsc = xdb + (1/R-1)*Math.pow(over+W/2,2)/(2*W); // in knee
else xsc = T + over/R; // above knee
const gc = xsc - xdb; // raw gain reduction, dB (<= 0)
// --- branching ballistics on the gain signal ---
if (gc <= this.gs) this.gs = aA*this.gs + (1-aA)*gc; // deepening -> attack
else this.gs = aR*this.gs + (1-aR)*gc; // easing -> release
if (this.gs < grPeak) grPeak = this.gs;
// --- make-up, to linear, apply with wet/dry blend ---
const g = Math.pow(10, (this.gs + M)/20);
for (let c=0;c<nCh;c++){
const dry = inp[c][i];
let z = bypass>0.5 ? dry : (mix*(dry*g) + (1-mix)*dry);
const L=0.98; // transparent safety soft-clip
if (z> L) z = L+(1-L)*Math.tanh((z-L)/(1-L));
else if (z<-L) z = -L+(1-L)*Math.tanh((z+L)/(1-L));
out[c][i] = z;
}
lastX = xdb; lastY = xdb + this.gs;
}
if ((this.frame=(this.frame+1)&3)===0)
this.port.postMessage({ gr:grPeak, x:lastX, y:lastY });
return true;
}
}
registerProcessor('comp-processor', CompProcessor);
The exact processor above is compiled to a Blob URL and loaded with audioContext.audioWorklet.addModule(...). Nothing else touches the samples.
A hand-rolled processBlock with an AudioProcessorValueTreeState for parameters and SmoothedValue to de-zip make-up gain. This is more instructive than reaching for juce::dsp::Compressor<float> — though that class exists and wraps essentially this algorithm behind setAttack/setRelease/setRatio/setThreshold if you would rather not hand-write it.
// ---- dB <-> linear helpers ----
static inline float dB (float g) { return 20.0f * std::log10 (std::max (g, 1.0e-6f)); }
static inline float lin (float dB){ return std::pow (10.0f, dB * 0.05f); }
struct CompDSP
{
void prepare (double fs)
{
sampleRate = fs;
gs = 0.0f; msq = 1.0e-8f;
makeupSmoothed.reset (fs, 0.02);
}
// one-pole coefficient for a time constant tau (seconds)
inline float coeff (float tau) const
{ return std::exp (-1.0f / (std::max (tau, 1.0e-5f) * (float) sampleRate)); }
void processBlock (juce::AudioBuffer& buffer, const Params& pr)
{
const int nCh = buffer.getNumChannels();
const int nSm = buffer.getNumSamples();
const float aA = coeff (pr.attack); // attack coefficient
const float aR = coeff (pr.release); // release coefficient
const float aM = coeff (rmsWindow); // RMS averaging
const float hpR = pr.scHz > 0.0f ? std::exp (-juce::MathConstants::twoPi
* pr.scHz / (float) sampleRate) : 0.0f;
makeupSmoothed.setTargetValue (pr.makeup);
for (int n = 0; n < nSm; ++n)
{
// detector: mono sum of channels
float s = 0.0f;
for (int c = 0; c < nCh; ++c) s += buffer.getSample (c, n);
s /= (float) nCh;
if (pr.scHz > 0.0f) { const float y = hpR * (hpY + s - hpX); hpX = s; hpY = y; s = y; }
float xdb;
if (pr.rmsDetect) { msq = aM * msq + (1.0f - aM) * (s * s);
xdb = 10.0f * std::log10 (msq + 1.0e-12f); }
else xdb = dB (std::abs (s));
// gain computer with soft knee (all dB)
const float T = pr.threshold, R = pr.ratio, W = pr.knee;
const float over = xdb - T; float xsc;
if (2.0f * over < -W) xsc = xdb;
else if (2.0f * std::abs (over) <= W) xsc = xdb + (1.0f/R - 1.0f)
* (over + W*0.5f) * (over + W*0.5f) / (2.0f*W);
else xsc = T + over / R;
const float gc = xsc - xdb; // raw reduction, dB
gs = (gc <= gs) ? aA*gs + (1.0f-aA)*gc // attack
: aR*gs + (1.0f-aR)*gc; // release
const float g = lin (gs + makeupSmoothed.getNextValue());
for (int c = 0; c < nCh; ++c)
{
const float dry = buffer.getSample (c, n);
float z = pr.mix * (dry * g) + (1.0f - pr.mix) * dry;
buffer.setSample (c, n, z);
}
lastReductionDb = gs; // publish to the editor's GR meter
}
}
double sampleRate = 44100.0;
float gs = 0.0f, msq = 1.0e-8f, rmsWindow = 0.010f;
float hpX = 0.0f, hpY = 0.0f;
std::atomic lastReductionDb { 0.0f };
juce::SmoothedValue makeupSmoothed;
};
Parameters would be created in an AudioProcessorValueTreeState::ParameterLayout (AudioParameterFloat for threshold, ratio, attack, release, knee, make-up and mix) and copied into Params once per block from the atomic parameter pointers.
SmoothedValue ramps make-up gain over ~20 ms so parameter moves do not zip, and the whole gain path lives in the log domain until the single lin() at the end. The gain-reduction value is published through a std::atomic<float> so the GUI can read the meter without a lock.The equations are fixed; the taste is in the settings. A handful of comparisons capture most of the decisions an engineer makes when reaching for a compressor.
| Choice | Fast / hard | Slow / soft |
|---|---|---|
| Detector | Peak — catches every transient, protects against overs, can sound clamped | RMS — follows energy, tracks perceived loudness, lets transients breathe |
| Attack | Sub-millisecond — tames the initial spike, can dull the "snap" | 10–50 ms — lets the transient through, then grabs the body (punch) |
| Release | Fast — energetic, can pump or distort low end | Slow — smooth, invisible, can miss quick level changes |
| Knee | Hard (0 dB) — surgical, obvious onset, for limiting | Soft (12–24 dB) — gentle, musical, "over-easy" mix-bus glue |
| Ratio | High (8:1–∞) — limiting, peak control | Low (2:1–4:1) — levelling, gentle density |
Constant, deep gain reduction with no movement means the threshold is too low — the compressor is a volume knob, not a dynamics tool. Rhythmic surging of the noise floor (pumping) usually means the release is fighting the tempo; lengthen it or switch to RMS detection. Distortion on bass with a fast release is the envelope tracking individual cycles of the waveform — slow the release past the lowest period. The gain-reduction meter, not the ear alone, is where these problems first show.