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.

Audio Plugins · DSP & MusicBrendan Lynskey
Begin ↓

01Dynamic range, and why we tame it

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

$$ L = 20\,\log_{10}\!\left(\frac{|a|}{a_{\text{ref}}}\right)\ \text{dBFS}, \qquad a_{\text{ref}} = 1 .$$

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.

02A short history of turning things down

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.

1930s–40s · Broadcast necessity
Radio and disc-cutting demanded automatic level control to protect transmitters and styluses. The earliest limiters used valve gain stages controlled by a rectified sidechain — the ancestor of every design that follows.
1959 · Fairchild 670 — Vari-Mu
A variable-mu (remote cut-off) valve compressor: the control voltage changes the tube's gain directly. Programme-dependent, gentle, and famously heavy. The Vari-Mu character comes from soft, level-dependent ratios rather than a fixed one.
1965 · Teletronix LA-2A — optical
A luminescent panel and a photoresistor form the gain element. The cell's sluggish, frequency-dependent recovery gives a slow, self-levelling response that flatters voices and bass. Attack and release are essentially fixed by physics, not knobs.
1967 · Urei 1176 — FET
A field-effect transistor as a voltage-controlled resistor gives attack times measured in microseconds. Fast enough to catch transients that optical cells miss; its "all-buttons-in" ratio and aggressive character defined a generation of drum and vocal sounds.
1971 · dbx 160 — VCA
David Blackmer's voltage-controlled amplifier and true-RMS log-domain detector made ratio, threshold and timing into clean, repeatable electrical parameters. The VCA topology is the direct conceptual parent of the digital compressor.
1976 · SSL Bus Compressor — VCA on the mix
Built into the centre section of the SSL 4000 console, its VCA bus compressor glued whole mixes together. It made "strap a compressor across the two-bus" standard practice.
1980s–90s · Feed-forward, digital
Digital designs moved decisively to feed-forward detection — measuring the input rather than the output — because it is stable, predictable, and trivial to reason about in code. Look-ahead delay, exact log arithmetic and arbitrary knee shapes became free.
2000s– · The plug-in era
Waves, FabFilter (Pro-C), and countless others put every classic topology, plus displays impossible in hardware, inside the DAW. The 2012 Giannoulis–Massberg–Reiss tutorial gave the community a clean, canonical reference design — the one this page follows.

03Topology: where do we measure, and in what domain?

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).

Feed-forward versus feed-back

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.

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.

Feed-back

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.

Log-domain detection

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:

$$ x[n] \;\to\; \underbrace{|x|\ \text{or}\ \sqrt{\overline{x^2}}}_{\text{detector}} \;\to\; \underbrace{20\log_{10}(\cdot)}_{\text{to dB}} \;\to\; \underbrace{\text{gain computer}}_{x_{sc}} \;\to\; \underbrace{\text{ballistics}}_{\text{attack/release}} \;\to\; \underbrace{+\,M,\ 10^{(\cdot)/20}}_{\text{make-up, to linear}} \;\to\; \times\, x[n] $$

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.

04The detector: envelope followers and ballistics

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.

Peak versus RMS rectification

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,

$$ x_{\text{rms}}[n] = \sqrt{\,\overline{x^2}[n]\,}, \qquad \overline{x^2}[n] = \alpha_r\,\overline{x^2}[n-1] + (1-\alpha_r)\,x^2[n], $$

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.

One-pole smoothing and the time-constant coefficient

Both the RMS averager above and the attack/release stage are the same first-order low-pass filter, the workhorse of dynamics processing:

$$ y[n] = \alpha\,y[n-1] + (1-\alpha)\,x[n]. $$

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

$$ \alpha = \exp\!\left(-\frac{1}{\tau\, f_s}\right). $$

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.

Where the ballistics are applied

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.

05The gain computer and the soft knee

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.

The hard-knee characteristic

With no knee, the static curve mapping input level $x$ to target output level $x_{sc}$ (both in dB) is two line segments:

$$ x_{sc} = \begin{cases} x, & x < T \\[4pt] T + \dfrac{x - T}{R}, & x \ge T. \end{cases} $$

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.

The soft knee

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:

$$ x_{sc} = \begin{cases} x, & 2(x-T) < -W \\[6pt] x + \left(\dfrac{1}{R}-1\right)\dfrac{\bigl(x - T + \tfrac{W}{2}\bigr)^{2}}{2W}, & \bigl|\,2(x-T)\,\bigr| \le W \\[10pt] T + \dfrac{x - T}{R}, & 2(x-T) > W. \end{cases} $$

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.

From curve to applied gain

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:

$$ g_c[n] = x_{sc}[n] - x[n] \;\le\; 0 . $$

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:

$$ g_s[n] = \begin{cases} \alpha_A\, g_s[n-1] + (1-\alpha_A)\,g_c[n], & g_c[n] \le g_s[n-1] \quad(\text{attack})\\[6pt] \alpha_R\, g_s[n-1] + (1-\alpha_R)\,g_c[n], & g_c[n] > g_s[n-1] \quad(\text{release}). \end{cases} $$

Finally we add make-up gain and convert the smoothed decibel gain to a linear multiplier, which scales the input sample:

$$ y[n] = x[n]\cdot 10^{\,(g_s[n] + M)/20}. $$

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.

06Live demo — a compressor you can hear

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.

FF·COMP — feed-forward dynamics DSP & MUSIC
Threshold–18 dB
Ratio4.0:1
Attack10 ms
Release150 ms
Knee6 dB
Make-up0 dB
Mix100%
idle

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.

Reading the three displays

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.

07The code — the same DSP, twice

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.

The AudioWorklet processor (running now)

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.

The same thing in JUCE / C++

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.

Note the two design touches that keep it click-free: 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.

08In practice — choices, and their trade-offs

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.

ChoiceFast / hardSlow / soft
DetectorPeak — catches every transient, protects against overs, can sound clampedRMS — follows energy, tracks perceived loudness, lets transients breathe
AttackSub-millisecond — tames the initial spike, can dull the "snap"10–50 ms — lets the transient through, then grabs the body (punch)
ReleaseFast — energetic, can pump or distort low endSlow — smooth, invisible, can miss quick level changes
KneeHard (0 dB) — surgical, obvious onset, for limitingSoft (12–24 dB) — gentle, musical, "over-easy" mix-bus glue
RatioHigh (8:1–∞) — limiting, peak controlLow (2:1–4:1) — levelling, gentle density

A few reliable starting points

The pitfalls the meter reveals

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.

09References & further reading

Sources

  1. D. Giannoulis, M. Massberg & J. D. Reiss, "Digital Dynamic Range Compressor Design — A Tutorial and Analysis," Journal of the Audio Engineering Society, vol. 60, no. 6, pp. 399–408, 2012. The canonical reference for the feed-forward, log-domain design used here, including the branching detector and the soft-knee gain computer.
  2. U. Zölzer (ed.), DAFX: Digital Audio Effects, 2nd ed., Wiley, 2011. Chapter on dynamics processing: envelope followers, static curves, and ballistics.
  3. J. O. Smith III, Introduction to Digital Filters & the online JOS publications (ccrma.stanford.edu/~jos), for the one-pole smoother, time-constant relations and log-domain reasoning.
  4. G. W. McNally, "Dynamic Range Control of Digital Audio Signals," Journal of the Audio Engineering Society, vol. 32, no. 5, 1984. An early and clear treatment of digital compression.
  5. R. Bristow-Johnson, "Cookbook formulae for audio EQ biquad filter coefficients," for the sidechain high-pass filter design.
  6. D. E. Blackmer, dbx patents on true-RMS log-domain detection (VCA topology, 1971 onward), the electrical ancestor of the digital detector.