Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
feedforward_compressor.hpp File Reference

The transparent, modern compressor: a log-domain feedforward design with a soft-knee gain computer and a decoupled detector. More...

#include <pulp/signal/denormal.hpp>
#include <pulp/signal/dynamics_core.hpp>
#include <pulp/signal/dynamics_contract.hpp>
#include <pulp/signal/units.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <vector>

Go to the source code of this file.

Classes

class  pulp::signal::FeedforwardCompressorT< SampleType >
 Log-domain feedforward compressor with a soft-knee gain computer. More...

Namespaces

namespace  pulp
namespace  pulp::signal

Typedefs

using pulp::signal::FeedforwardCompressor = FeedforwardCompressorT<float>
using pulp::signal::FeedforwardCompressor64 = FeedforwardCompressorT<double>

Enumerations

enum class  pulp::signal::CompressorDetector : std::uint8_t { pulp::signal::peak , pulp::signal::rms }
 What the sidechain measures. More...

Detailed Description

The transparent, modern compressor: a log-domain feedforward design with a soft-knee gain computer and a decoupled detector.

"Feedforward" is the whole topology in one word — the sidechain reads the INPUT, never the compressor's own output. There is no loop, so there is no stability story to tell and no small-signal gain to compensate; the gain trajectory is a pure function of the input.

Every equation in the signal path comes from Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor Design — A Tutorial and Analysis", JAES 60(6), pp. 399–408, 2012. Anything not from that paper is tagged [design parameter] with a default and a range.

Why the detector is two stages

The obvious detector is one pole whose coefficient switches between attack and release depending on whether the level is rising. That design has a discontinuous derivative exactly at the crossover, and material sitting near the threshold crosses it constantly — the artefact is audible as distortion on precisely the signals a compressor is most often asked to handle. The paper's fix, used here from the outset, is to decouple the two:

y1[n] = max( r[n], α_R·y1[n−1] + (1 − α_R)·r[n] ) // release branch
r_s[n] = α_A·r_s[n−1] + (1 − α_A)·y1[n] // attack smoothing

Stage 1 already picks the faster of "follow immediately" and "release toward it", so stage 2 always has a continuous input and there is no branch for the signal to chatter across.

Sign convention, stated because it is easy to get backwards. r[n] above is the gain reduction as a POSITIVE magnitude in dB — the paper's convention. The gain computer naturally produces g_c = y_L − x_L ≤ 0, so this class negates once, at the boundary, and the detector works in positives throughout. Running the same max() on the negative quantity would make the detector snap instantly toward LESS reduction and ease into more, i.e. swap attack and release. That version still compresses, still sounds like a compressor, and fails a step-response test immediately — which is why there is one.

What the options are for

  • RMS vs peak. Peak reads transients literally. RMS integrates them into one program-loudness figure, which is what bus material wants: a peak detector chases the kick, the snare and every guitar transient separately and the result reads as pumping.
  • Program-dependent release. A release that is fast right after a transient and slower once compression has been sustained, avoiding both the thump of recovering under a still-loud passage and the pumping of holding gain down after a transient has passed. The effect is documented hardware behaviour; the constants here are original engineering, because no hardware unit's actual time constants are published.
  • Lookahead. Delays the audio, not the sidechain, so gain reduction has already ramped by the time the peak reaches the multiplier. This is what makes a near-zero attack time safe.
  • Stereo link. Both channels' detectors fed by the louder one, so a hard-panned hit does not pull the stereo image toward centre every time it lands. The 0..1 blend crosses the two DETECTOR INPUTS, not two finished gain-reduction values — crossfading after the detector would reintroduce exactly the image shift the link exists to prevent.

Use-case starting points

  • Vocal levelling — −20 dB, 3:1, knee 12 dB, attack 15 ms, release 150 ms, program-dependent, peak. Wide knee and moderate ratio read as levelling rather than squashing.
  • Bus glue — −12 dB, 2:1, knee 18 dB, attack 30 ms, release 400 ms, RMS at 20 ms.
  • Peak-safe limiting — ratio 100, knee 0 (engage at the ceiling, not early), attack at the 0.05 ms minimum, release 50 ms, lookahead 5 ms, program-dependent OFF (limiting wants one predictable release).
  • Parallel compression — −30 dB, 10:1, attack 3 ms, release 80 ms, auto-makeup OFF and makeup set deliberately hot: the output is blended under the dry signal, so makeup is a mix control, not a restore control.

Out of scope, and why

Feedback topology is a different design with a different static-curve mapping, not a mode switch. Multiband is N instances plus the existing crossovers. An external sidechain key is a graph-layer routing concern. Analog saturation is explicitly excluded by the clean/modern brief. Oversampling does not apply (series law 4, stated rather than silently omitted): there is no waveshaping nonlinearity in the audio path — the only nonlinearity is a smoothed multiplicative gain trajectory, band-limited by the detector's own smoothing at any musically sensible attack time. True-peak (inter-sample) detection would need an oversampled estimator; peak mode here is sample-peak only, flagged rather than folded in silently.

This ships ALONGSIDE compressor.hpp rather than replacing it: the two do not share a parameter layout, so repointing existing callers is a migration with its own compatibility audit, not a drop-in swap.

RT contract: prepare() may allocate — it sizes the lookahead ring buffer to the worst-case max_lookahead_ms at the prepared sample rate, so no later set_lookahead_ms() can allocate. set_*, process*, and reset() never allocate, never lock, never throw. process() is a pure function of (state, input) with no hidden globals. The stereo entry points read both channels before writing either, which the link path requires.