Skip to content
audiojsPublic

About

Compress, limit, expand algorithms

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

@audio/dynamics test npm license

Try it in the browser: Loudness meter and normalizer. Runs on this package, nothing is uploaded.

Dynamics processing — compressor, limiter, gate, expander, de-limiter, de-esser, ducker, softclip, compand, multiband. The family includes envelope-driven gain control, lookahead limiting, waveshaping and whole-buffer level correction. Part of audiojs.

Kind Gain function Typical use
compressor envelope soft-knee above threshold leveling vocals, mix glue
limiter lookahead brickwall at ceiling master bus, peak control
gate envelope hard cut below threshold silence between phrases
expander envelope gentle below-threshold reduction soft gating, noise bed shaping
unlimit dual envelope transient-gated upward expansion de-limiting, crest restoration
deesser sidechain sibilance band over voice body, cut within a range harsh 's' in voice
ducker ext. sidechain compressor keyed by side signal music-under-voice, podcast
softclip waveshaper static transfer curve gentle peak limiting + coloration
compand envelope piecewise-linear transfer SoX-style multi-segment
multiband envelope × N bands LR split + per-band up/down compression mastering glue, OTT-style upward+downward

Usage

npm install @audio/dynamics
import { compressor, limiter } from '@audio/dynamics'

const sampleRate = 48000
const samples = Float32Array.from({ length: 4800 }, (_, i) =>
  0.8 * Math.sin(2 * Math.PI * 220 * i / sampleRate))
const opts = { sampleRate, threshold: -18, ratio: 4, attack: 5, release: 100 }
const compressed = compressor(samples, opts)
const limited = limiter(compressed, { sampleRate, ceiling: -1, lookahead: 5 })
console.log(limited.length) // 4800; samples and compressed are preserved

// Each writer owns its history. Keep it for successive chunks, then flush once.
const write = compressor(opts)
const blocks = [write(samples.subarray(0, 256)), write(samples.subarray(256)), write()]
console.log(blocks.reduce((n, block) => n + block.length, 0)) // 4800

For one processor: npm install @audio/dynamics-compressor, then import compressor from '@audio/dynamics-compressor'. The umbrella re-exports the same functions and types. Existing CompressorOpts, GateOpts and other legacy option names remain available alongside the leaf names (CompressorOptions, GateOptions, etc.).

Processing expectations

Functions Buffer ownership / streaming Sample-rate option
compressor, limiter, gate, expander, unlimit, deesser, ducker, compand, opto, fet, vca, varimu Batch calls return a new buffer. Calling with options returns a writer; keep it across chunks and call with no arguments to flush. sampleRate
softclip Returns a new buffer. Its writer buffers until flush when oversample > 1. fs
transientShaper Mutates and returns the input. Reuse the same options object across chunks; a fresh object resets the envelopes. fs
multiband, leveler Mutate and return the input. Whole-buffer processing; no writer form. fs
envelope Returns a stateful sample → level function. Create another follower to reset. sampleRate

PCM is mono Float32Array; process channels with separate state/writers. Sample rate defaults to 44100 Hz. Gains/thresholds are dB and attack/release/lookahead are milliseconds unless stated otherwise: softclip.drive and transient-shaper gains are linear, while leveler.frame is seconds. Do not substitute fs for sampleRate indiscriminately; only some processors accept that alias.

Batch compression and a fresh writer fed the same signal produce the same samples. Lookahead writers can return fewer samples until flush; concatenate every returned block, including the final flush. An empty chunk is a zero-length write, not a flush. Treat flush as the end of a signal and create a new writer to reset. Options are construction settings, not a live automation interface.

The leaf /audio exports are host processor factories with their own parameter metadata. Some controls restart their processor, and some factories require a whole render (streaming: false); check those limits before using an AudioWorklet adapter.

envelope

The common detector for the envelope-driven processors is a branching one-pole follower with separate attack/release time constants, peak or RMS detection.

import { envelope } from '@audio/dynamics'

let follow = envelope({ attack: 5, release: 100, detector: 'peak' })
let level = []
for (let x of samples) level.push(follow(x))
Param Default
sampleRate 44100 —
attack 5 ms
release 50 ms
detector 'peak' 'peak' or 'rms'
rmsWindow 256 samples, for RMS detector

compressor

Feed-forward soft-knee downward compressor, as Giannoulis, Massberg & Reiss recommend: level in dB → quadratic soft-knee gain curve → gain reduction smoothed in the log domain by the smooth decoupled peak detector → linear gain applied to input. attack and release are the time constants of the gain reduction itself (1 − 1/e), at any depth of compression; the first sample over the threshold is already reduced.

Downward compression (above threshold, reduces gain) is one half of the canonical four-quadrant dynamics taxonomy — downward/upward compression, downward/upward expansion (Giannoulis, Massberg & Reiss 2012; Izhaki, Mixing Audio). Setting upThreshold engages the other compression half: upward compression lifts quiet passages toward the threshold instead of squashing loud ones — the "OTT up" half popularized by Xfer OTT. Both curves read the same envelope and sum in the dB domain, so a single compressor call can glue loud material down and lift quiet material up at once.

import { compressor } from '@audio/dynamics'

compressor(data, { threshold: -18, ratio: 4 })
compressor(data, { threshold: -24, ratio: 2, knee: 12, attack: 10, release: 200, makeup: 6 })

// upward + downward together (OTT-style): lift quiet passages, squash loud ones
compressor(data, { threshold: -18, ratio: 4, upThreshold: -40, upRatio: 2, upRange: 12 })
Param Default
threshold -20 dB
ratio 4 —
knee 6 dB (soft-knee width)
attack 5 ms
release 100 ms
makeup 0 dB
depth 1 scales the summed up+down gain before makeup (OTT "Depth" macro; 0 = identity)
upThreshold null dB; null disables upward compression
upRatio 2 — (1 is a mathematical no-op)
upKnee 6 dB
upRange 12 dB, max upward lift — without a ceiling, silence would take unbounded gain

Use when: vocals, bass, drum bus, mix glue; add upThreshold for OTT-style up+down "aggressive" glue.
Not for: peak control at the master bus — use limiter. Transparent loudness shaping — use compand with gentle slope.

limiter

Lookahead brickwall limiter. The gain each sample needs, its sliding minimum over the lookahead span and that minimum's moving average: the gain ramps down across lookahead ms into a peak, never stepping, and still covers every sample in transit; exponential release after it passes.

import { limiter } from '@audio/dynamics'

limiter(data, { ceiling: -0.3 })
limiter(data, { ceiling: -1, lookahead: 10, release: 100 })
Param Default
ceiling -0.3 dB (brickwall)
lookahead 5 ms (introduces delay)
release 50 ms

Use when: sample-peak control at the master bus. This limiter does not measure or constrain reconstructed inter-sample peaks.
Not for: musical dynamics shaping — use compressor. Low-latency paths — use softclip.

gate

Noise gate with hysteresis, hold-then-close logic and look-ahead. Opens above threshold, closes only below closeThreshold (hysteresis prevents chatter around a single threshold); below it, signal is attenuated by range dB. A hold timer keeps the gate open after a drop-out; attack/release smooth the gain transitions. lookahead runs detection ahead of emission so the gate is already opening when a transient reaches the output — batch calls stay sample-aligned (no silence prefix, no dropped tail); block hosts get the delay declared as atom latency.

import { gate } from '@audio/dynamics'

gate(data, { threshold: -40 })
gate(data, { threshold: -35, range: -80, hold: 20, attack: 1, release: 150, lookahead: 5 })
Param Default
threshold -40 dB, open above
closeThreshold threshold − 6 dB, close below (hysteresis)
range -60 dB attenuation when closed
hold 10 ms
attack 0.1 ms (opening)
release 100 ms (closing)
lookahead 0 ms, detection leads emission

Use when: drum mics, voice dialogue with ambient noise, removing hiss between phrases.
Not for: subtle low-level reduction — use expander.

expander

Downward expander (mode: 'downward', default) — a softer gate. Below threshold, gain is reduced by (threshold − level) × (ratio − 1) dB, clamped at range.

mode: 'upward' switches to upward expansion — the de-compression complement, raising gain above threshold instead of cutting it below. Classical substrate for de-limiting: transient-aware upward expansion restores crest factor a brickwall limiter (or an over-eager mix bus compressor) flattened. Same four-quadrant taxonomy as compressor's upward mode (Giannoulis/Reiss; Izhaki, Mixing Audio).

import { expander } from '@audio/dynamics'

expander(data, { threshold: -30, ratio: 2 })

// de-limiting: expand transients back out above threshold
expander(data, { mode: 'upward', threshold: -20, ratio: 1.5, range: 20 })
Param Default
mode 'downward' 'downward' | 'upward'
threshold -30 dB
ratio 2 —
knee 6 dB
range -40 (downward) / 20 (upward) dB, max reduction (downward, negative) or max lift (upward, positive)
attack 5 ms
release 50 ms

Use when: gentle noise-floor suppression without the abruptness of a gate (downward); restoring dynamics to over-compressed or over-limited material (upward).
Not for: hard removal of sound between phrases — use gate.

unlimit

De-limiter. iZotope Ozone 12's "Unlimiter" (Sept 2025) created the de-limiting category with a trained ML model; this atom is the classical counterpart — transient-synchronous upward expansion, restoring the crest factor a brickwall limiter (or an over-eager bus compressor) flattened. Program-adaptive upward expansion gated to transients, not level — one more cell in the four-quadrant dynamics taxonomy (Giannoulis, Massberg & Reiss 2012, JAES 60(6); Izhaki, Mixing Audio), built on expander's upwardExpanderGain curve.

A fast envelope (fastAttack/fastRelease, near-instant) and a slow envelope (slowAttack/slowRelease, sluggish) both track the input; their gap in dB — transientness — rises sharply on attacks and sits near zero on sustained material. Gain lift follows transientness, not absolute level: an absolute-level upward expander would pump sustains; gating on the fast/slow gap instead is what makes this a de-limiter rather than a leveler.

import { unlimit } from '@audio/dynamics'

unlimit(data, { amount: 9, drive: 2 })                  // deliberate restoration
unlimit(data, { amount: 9, drive: 2, ceiling: -1 })      // guard restored peaks at -1 dBFS
Param Default
amount 6 dB, max crest restoration (range 0–18)
drive 1 scales the deficit-driven restoration (1 = restore attacks to crestTarget); in adaptive: false mode, dB of lift per dB of transientness
adaptive true deficit mode (see below); false = raw proportional transient-following (a transient exaggerator, for manual sound design)
crestTarget 10 dB of transientness a healthy attack is expected to show; flattened attacks get lifted by what they're missing
ceiling null dBFS; post guard so restored peaks don't exceed it. null (default): peaks may exceed 0 dBFS by design (float domain)
fastAttack 0.5 ms
fastRelease 20 ms
slowAttack 20 ms
slowRelease 200 ms

The default mode lifts by transient deficit, not transient presence — the inverse-limiter insight: a brickwall limiter's fingerprint is attacks that are too small (3–8 dB of fast-over-slow transientness where healthy program shows 12–25 dB), so each onset gets back crestTarget − measured dB, and a naturally healthy attack gets structurally zero lift. Safety on dynamic material is a property of the curve, not a timid default: measured on the test fixture, defaults change never-limited program by ≤ 0.2 dB RMS while amount: 9 recovers ~4.5 dB of crest from a 9 dB-crushed brickwall (see tests). Three gates make that separation robust — peak-hold (deficit is judged against an attack's peak transientness, not its rise samples), a 10 ms attack window (a decaying tail keeps the fast envelope above the slow one for its whole length; a decay is not an onset), and a ~3 ms confirmation ramp (a healthy attack outruns crestTarget in ~1.5 ms, collapsing its own deficit before lift confirms; a limiter-flattened plateau is still standing).

Honest scope: this restores dynamics/crest — it cannot recover information a clipper already destroyed (pair with @audio/denoise-declip for that), and it does not un-mix limiter pumping artifacts baked into the waveform's history. Over-driving amount/drive invents transients that were never there. v1 is zero-latency with no lookahead — it reacts to a transient already underway, it cannot anticipate one; lookahead attack-anticipation is a future option.

Use when: restoring life to over-limited masters, streaming-loudness-flattened stems, squashed dialogue or game audio.
Not for: recovering clipped/distorted peaks — use a declipper. Undoing audible limiter pumping — remix from an earlier, unlimited stage if one exists.

deesser

Sibilance reduction. An 's' is told by its shape, not its level: the sibilance band (a bandpass at fc, Q) is measured against the voice body (a low-pass an octave under fc), in dB, as the dbx 902 compares its high band with the full band. The same 's' is caught on a quiet recording and a loud one, and a cymbal in a mix, which sits over the mix's body, is left alone. How far the band rises over threshold sets the cut, held within range, the Range of the dbx 902, FabFilter Pro-DS and Waves Sibilance: past about 6 dB an 's' turns into a lisp. A pause is measured against the voice's running level, so hiss between words is not taken for an 's'. mode applies the cut: broadband (default) turns the whole sound down while the 's' lasts; band is a peaking EQ at fc, so only the sibilance band moves.

import { deesser } from '@audio/dynamics'

deesser(data)                                    // at any recording level
deesser(data, { mode: 'band', fc: 7500, Q: 1 })  // only the sibilance band moves
deesser(data, { threshold: -3, range: -8 })      // softer esses too, deeper
Param Default
mode 'broadband' 'broadband' | 'band'
fc 6500 Hz, sibilance center: the band watched and, in band mode, cut; the body is under fc / 2 (freq still accepted)
Q 2 width of that band; 1.4 in band mode (q still accepted)
threshold 0 dB of the sibilance band over the voice body where the cut starts; not a level
ratio 4 —
range -6 dB, the deepest cut
knee 6 dB
attack 1 ms
release 15 ms

node scripts/deesser.js runs it over speech, singing and music and reads, per 10 ms frame, how far the 4–10 kHz band and the 0.3–4 kHz voice body moved: on 's' frames (active, half their energy above 3.5 kHz) the cut, elsewhere the share of frames moved by more than 1 dB. The defaults were chosen on 504 VoiceBank training utterances of 28 speakers and 10 narrations (SET=train); below, the VoiceBank test set (824 utterances, 2 other speakers) measured once, 10 other narrations (spoken Wikipedia, 60 s each), VocalSet:

's' frames, 4–10 kHz cut: median · 90th pct · most voice frames moved > 1 dB pauses moved > 1 dB
VoiceBank test, broadband 0.6 · 4.4 · 6.0 dB 1.1% 0.2%
narrations, broadband 2.1 · 6.0 · 6.0 dB 1.2% 0.4%
sung, broadband 3.6 · 6.0 · 6.0 dB 0.4% 0.7%
VoiceBank test, band 0.6 · 3.3 · 5.7 dB 0.9% 0.1%
narrations, band 1.7 · 3.9 · 5.7 dB 1.1% 0.3%
sung, band 2.6 · 4.2 · 5.1 dB 0.4% 0.6%

Normalized to −16 LUFS first, as the editor's recipes do, every figure is the same. Music ("Vibe Ace", Brahms, the Nutcracker, a trumpet): no frame moved by 1 dB in either mode. A ride cymbal 18, 12 and 6 dB under "Vibe Ace", the mix at −16 LUFS: its 4–10 kHz band cut by a median 0 dB (90th percentile 0, 0 and 0.5 dB).

0.2.7 (a threshold on the band's own level, its cut unbounded) did next to nothing at its defaults on these files at their own level (the narrations sit between −41 and −11 LUFS), and too much once they were loud: at −16 LUFS with the editor's "De-ess a voice" settings ({ mode: 'band', fc: 7500, Q: 1, threshold: -35 }) it cut the narrations' 's' frames by a median 5.7 dB, 10.9 at the 90th percentile and up to 15.9, moved 23 % of their voice frames and 22 % of Brahms' and the trumpet's, and cut the ride 12 dB under "Vibe Ace" by a median 5.4 dB. { mode: 'band', fc: 7500, Q: 1 } now: 1.5 · 4.6 · 5.6 dB, 1.0 %, none, 0 dB.

Use when: harsh 's' / 'sh' in a voice, bright vocal takes; mode: 'band' when the voice sits with program that must not pump.
Not for: broadband brightness — use an EQ. Generic compression — use compressor.

ducker

External-sidechain compressor. Main signal's gain tracks the level of a separate side signal.

import { ducker } from '@audio/dynamics'

// batch
let podcast = ducker(music, voice, { threshold: -30, range: -12 })

// streaming — callable takes (main, side); call with no args to flush
let duck = ducker({ threshold: -30, range: -15 })
let out1 = duck(musicBlock1, voiceBlock1)
let out2 = duck(musicBlock2, voiceBlock2)
let tail = duck()
Param Default
threshold -30 dB (on side level)
ratio 4 —
knee 6 dB
range -24 dB, max reduction
attack 20 ms
release 300 ms

Use when: music-under-voice podcasts, dialogue ducking, sidechain-pumped mixes.
Not for: sidechain from the same signal — use compressor.

softclip

Static waveshaping — no time state, no pumping. Maps input through a fixed transfer curve; peaks saturate smoothly, introducing controlled harmonic content.

Hard/high-drive clipping generates harmonics above Nyquist that fold back as audible aliasing. oversample (1/2/4/8, default 1) runs the transfer at N× rate and decimates back down through a windowed-sinc anti-alias filter, same technique as @audio/saturate's oversampled shapers — oversample: 1 is the exact non-oversampled path (no resampling, zero cost).

import { softclip } from '@audio/dynamics'

softclip(data, { curve: 'tanh', drive: 1.5 })
softclip(data, { curve: 'cubic', drive: 2, ceiling: 0.9 })
softclip(data, { curve: 'hard', drive: 4, oversample: 4, fs: 44100 })   // clean high-drive clip
Param Default
curve 'tanh' 'tanh', 'atan', 'cubic', 'sin', 'hard'
drive 1 input pre-gain
ceiling 1 output asymptote
oversample 1 1, 2, 4, 8 — anti-aliased oversampling
fs 44100 Hz, sample rate (only used when oversample > 1)

Use when: gentle peak control with musical saturation, avoiding pumping artifacts of a limiter, lo-fi character; oversample for hard/high-drive clipping that must stay alias-free.
Not for: transparent true-peak safety — use limiter. Clean gain reduction — use compressor.

compand

SoX-style multi-segment compander. Arbitrary piecewise-linear transfer in dB unifies compression, expansion, and gating under one curve — points below the identity line compress; above, they expand.

import { compand } from '@audio/dynamics'

// Default: compress above -20 dB
compand(data)

// Broadcast leveler: lift -40..-20 dB, compress above -10 dB
compand(data, {
  points: [[-90, -90], [-40, -30], [-20, -18], [-10, -10], [0, -4]],
  attack: 20, release: 500
})
Param Default
points [[-90,-90],[-60,-60],[-20,-20],[0,-8]] [[inDb, outDb], ...]
attack 5 ms
release 200 ms

Use when: broadcast leveling, speech normalization, any time a single compressor's fixed ratio is too rigid.
Not for: simple threshold compression — use compressor.

multiband

Multiband compressor — Linkwitz-Riley crossover split, an independent compressor per band (upward half included), flat sum by construction (SoX mcompand class). The manifest (multiband/audio) is a 3-band "one-knob" mastering stage — one shared setting across low/mid/high, split at low/high. The kernel (multiband(data, opts)) takes N-1 crossover points and per-band settings directly, for full control; every bands entry is spread straight into compressor(), so upward compression and depth are already there per band.

import { multiband } from '@audio/dynamics'

// one-knob: shared setting across 3 bands split at 200/2000 Hz
multiband(data, { freqs: [200, 2000], bands: { threshold: -24, ratio: 3 } })

// per-band settings, N bands (mutates data in place)
multiband(data, {
  freqs: [400, 4000],
  bands: [
    { threshold: -24, ratio: 3 },               // low
    { threshold: -20, ratio: 4, makeup: 2 },     // mid
    null,                                        // high: pass through uncompressed
  ],
})

OTT-class upward+downward multiband — Xfer OTT's "upward + downward compression on 3 bands" recipe, reproduced with this atom's upThreshold/upRatio/depth:

let depth = 1   // OTT's "Depth" macro — 0 is a transparent pass, 1 is full effect, up to 2 overshoots it
multiband(data, {
  freqs: [88.3, 2500],   // OTT's own crossover points
  bands: [
    { threshold: -24, ratio: 4, upThreshold: -30, upRatio: 2, attack: 2, release: 35, depth },  // low
    { threshold: -24, ratio: 4, upThreshold: -30, upRatio: 2, attack: 5, release: 60, depth },  // mid
    { threshold: -24, ratio: 4, upThreshold: -30, upRatio: 2, attack: 2, release: 35, depth },  // high
  ],
})
Param Default
freqs [200, 2000] Hz, N-1 crossover points for N bands
bands — per-band {threshold, ratio, knee, attack, release, makeup, upThreshold, upRatio, upKnee, upRange, depth}, or one object shared by all bands; null passes a band through uncompressed
order 4 Linkwitz-Riley crossover order (2, 4, 8)
fs 44100 Hz

Manifest params (3-band one-knob form): low, high, threshold, ratio, upThreshold, upRatio, depth, attack, release, makeup.

Use when: mastering-stage glue across the spectrum; OTT-style "upward + downward everywhere" aggressive multiband; taming one band without touching others.
Not for: single-band dynamics — use compressor directly.

Additional processors

Export Behavior and detailed options
transientShaper Attack/sustain gain shaping; state continues on the reused options object.
opto Optical-style compression, RMS detection and program-dependent release.
fet FET-style compression, fast peak detection.
vca VCA-style compression, feed-forward peak detection and a firm knee.
varimu Variable-mu-style compression, level-dependent ratio.
leveler Dialogue gain riding, whole-buffer analysis and smoothing.

These compressor models describe gain-control behavior; they do not model a hardware unit's full circuit or coloration.

The umbrella also exposes pure dB gain-curve helpers: compressorGain(levelDb, threshold, ratio, kneeDb), upwardGain(levelDb, threshold, ratio, kneeDb, rangeDb?), upwardExpanderGain(levelDb, threshold, ratio, kneeDb, rangeDb), and unlimitGain(fastDb, slowDb, amount, drive). They return gain in dB and do not modify audio. See the corresponding leaf declarations for parameter details.

See also

  • denoise — umbrella for everything noise; its gate/deesser are seconds-unit adapters over this package (2026-07 near-dupe merge)
  • filter — biquads for deesser sidechain
  • effect — modulation effects
  • stretch — sibling package

References

  • Giannoulis, D., Massberg, M. & Reiss, J.D. (2012). "Digital dynamic range compressor design — a tutorial and analysis." JAES, 60(6).
  • Izhaki, R. Mixing Audio: Concepts, Practices and Tools. Focal Press / Routledge. Four-quadrant dynamics taxonomy — downward/upward compression, downward/upward expansion.
  • Zölzer, U. (ed., 2011). DAFX — Digital Audio Effects (2nd ed.), chapter on dynamics processing.
  • Reiss, J.D. & McPherson, A. (2014). Audio Effects — Theory, Implementation and Application, Ch. 6.
  • Bristow-Johnson, R. (2005). "Audio EQ Cookbook." (RBJ biquad formulae, used in deesser sidechain.)
  • dbx Professional Products (1996). 902 de-Esser owner's manual (18-2015-B): sibilance detected in dB of the high band against the full band, "regardless of variations in signal levels"; Range 0–20 dB, past its normal region an 's' is "swallowed".
  • Sonnox. Oxford SuprEsser user guide, §3.3.2: auto threshold that follows the signal outside the band, with a 24 dB window.
  • FabFilter. Pro-DS help: Threshold, Range ("scales the detected gain reduction so that it stays within a desired range"), Wide Band / Split Band. Waves. Sibilance user guide: Range 0 to −48 dB.
  • SoX manual — compand (piecewise-linear compander semantics).

About

Compress, limit, expand algorithms

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages