The FET compressor: a FEEDBACK-topology design in the documented 1176 lineage, with a FET operated as a voltage-controlled resistor inside the loop, microsecond attack times, discrete ratio buttons including the documented "all buttons in" state, no threshold control, and an output transformer's low-frequency tilt as a colour stage. More...
#include <pulp/signal/biquad.hpp>#include <pulp/signal/denormal.hpp>#include <pulp/signal/dynamics_core.hpp>#include <pulp/signal/dynamics_contract.hpp>#include <pulp/signal/units.hpp>#include <pulp/signal/windowed_sinc_design.hpp>#include <algorithm>#include <array>#include <cmath>#include <cstddef>#include <cstdint>Go to the source code of this file.
Classes | |
| class | pulp::signal::FetCompressorT< SampleType > |
| Feedback-topology FET compressor. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::FetCompressor = FetCompressorT<float> |
| using | pulp::signal::FetCompressor64 = FetCompressorT<double> |
Enumerations | |
| enum class | pulp::signal::FetRatio : std::uint8_t { pulp::signal::r4_1 , pulp::signal::r8_1 , pulp::signal::r12_1 , pulp::signal::r20_1 , pulp::signal::all_buttons_in } |
| The ratio buttons. More... | |
The FET compressor: a FEEDBACK-topology design in the documented 1176 lineage, with a FET operated as a voltage-controlled resistor inside the loop, microsecond attack times, discrete ratio buttons including the documented "all buttons in" state, no threshold control, and an output transformer's low-frequency tilt as a colour stage.
"Feedback" is the whole topology in one word, and it is the only thing that really separates this from feedforward_compressor.hpp: the level detector senses the compressor's OWN OUTPUT, not its input. Everything downstream of that one wire — the stability story, the static curve a user actually measures, the knee they actually hear, and how fast the thing really attacks — changes, and each of those changes is quantified below and asserted by the suite rather than described.
The gain computer, soft knee and branching attack/release detector come from Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor Design — A Tutorial and Analysis", JAES 60(6), pp. 399–408, 2012. The divider law is the Shichman–Hodges triode approximation (Shichman & Hodges, IEEE JSSC SC-3(3), pp. 285–289, 1968): a FET in its ohmic region has drain CONDUCTANCE approximately linear in gate overdrive, so conductance — not resistance — is what interpolates linearly with the control voltage. The "all buttons in" state is documented qualitatively by Moore, A., "All Buttons In: An Investigation Into the Use of the 1176 FET Compressor in Popular Music Production", Journal on the Art of Record Production, Issue 6, 2012. Everything not from those sources is tagged [design parameter] with a default and a range.
gain_computer_db() returns gain reduction as a POSITIVE magnitude in dB — the paper's convention, and the same one feedforward_compressor.hpp uses. The detector runs in positives throughout, so the attack branch is taken when MORE reduction is wanted. Running the branch on the negative gain-computer output instead would make the detector snap toward less reduction and ease into more, i.e. swap attack and release — a version that still sounds like a working compressor and fails a step-response test immediately, which is why there is one.
The detector reads y[n−1], the previous OVERSAMPLED divider output, so the loop is closed explicitly with a one-sample delay. There is no implicit solve anywhere in process(): the divider law is evaluated in closed form and its inverse is closed form too. The iteration count is zero, at every sample, unconditionally — which is the only version of "bounded iteration count" that is actually RT-safe.
Linearising the loop in the dB domain around any operating point:
A is where the naive design fails. The spec this module is built from normalises the control voltage as c = y_L / GR_max_db, i.e. it assumes the divider's dB attenuation is linear in c. It is not: Γ(c) = 20·log10(1 + R_s·g_fet(c)) − 20·log10(1 + R_s·g_min) with g_fet linear in c, which at the shipped constants is Γ(c) ≈ 20·log10(1 + 99·c). That maps c = 0.5 onto 34 dB of attenuation when the detector asked for 20, and gives A = dΓ/dD ≈ 21.5 near c = 0. At the documented 20 µs attack (α = 0.770 at 192 kHz) and R = 8 that puts the closed-loop pole at α − (1−α)·A·B = 0.770 − 0.230·21.5·0.875 = −3.55 — an unstable loop, arrived at by arithmetic, not by taste.
So the control voltage is instead the CLOSED-FORM INVERSE of the divider law (control_for_reduction_db()), which makes A ≡ 1 by construction — the loop is unity-compensated, exactly as series law 1 asks. That is also the same calibration intent the divider already carries: R_s is solved at prepare() so the endpoints land on 0 dB and −kGainReductionMaxDb exactly, and inverting the law simply extends that calibration to every point between them rather than only the two ends.
With A = 1 the pole is p = α − (1−α)·B with α ∈ [0,1) and B ∈ [0, 1 − 1/R], so
unconditionally, at every attack time, ratio, knee and sample rate. That is the bound the suite asserts — loop_slope() returns B, and a test drives the fastest attack against the hardest ratio and checks the settling is monotone rather than ringing.
A feedback detector is not a flavour of a feedforward one; it re-maps the static curve, and this module exposes the re-mapping instead of describing it. With m = 1 − 1/R the closed-loop fixed point ŷ = x̂ − D(ŷ) solves in closed form (measured_static_curve_db()), and the consequences are:
g_fet_inst is a memoryless nonlinearity evaluated inside the loop, so it generates harmonics and aliases. The stage runs at 4× on a linear-phase Kaiser (β = 8) 65-tap polyphase pair. 4× is chosen rather than merely defaulted: it is also what keeps the one-sample loop delay at 1/(4·fs) = 5.2 µs at 48 kHz, comfortably under the fastest documented 20 µs attack. At 1× the same delay would be 20.8 µs — the same order as the fastest time constant the circuit is documented to have, which is why 1× is rejected.
The source spec calls this a "half-band pair", which it is not, and the two readings do not cost the same. A genuine half-band pair is two cascaded 2× stages: 65 taps at 2× contributes 16 base-rate samples and 65 taps at 4× contributes 8, so a two-stage pair would report 48 samples of latency, not
The 65-tap length is a hard constraint from the latency contract below, and it is short for the job. Measured on the shipped taps at kResamplerCutoffFraction: the round trip is flat to 15 kHz at 48 kHz, −0.44 dB at 18 kHz, −1.34 dB at 19.2 kHz, and rejects 30 kHz by 57.6 dB. The honest limitation, stated rather than buried: a fundamental near fs/4, whose second harmonic lands in the pair's transition band around fs/2, is the worst case for aliasing — a longer filter would fix it and would change latency_samples().
Exactly kLatencySamples = 16 samples at the base rate, independent of every parameter. The linear-phase prototype's group delay is (65−1)/2 = 32 samples at the oversampled rate for each of the interpolation and decimation filters; 32 is divisible by the 4× factor, so each contributes exactly 8 base-rate samples and the pair contributes 16 — an exact integer, computed from the shipped constants, with no fractional residue to round away. The one-sample feedback delay is internal to the oversampled loop and delays the control path, not the audio path, so it adds nothing to the reported figure. The dry path of set_mix() is delayed by the same 16 samples, or the parallel blend would be a comb filter.
The divider is a series-resistor / shunt-FET attenuator. Its supremum gain is exactly 1, attained only at rest (c = 0), and the coloration term cannot lift it above 1: raising c raises the shunt conductance far faster than the coloration term can lower it (Δg·(1 − α·v_clamp) ≫ α·v_clamp·g_min by a factor of ~5×10⁴ at the shipped constants). worst_case_gain() therefore returns a closed-form ℓ∞ bound on the whole block:
where the middle 1s are the divider and the cut-only transformer shelf, and resampler_peak_gain_bound() is ‖h_up‖₁·‖h_down‖₁ computed from the shipped taps (≈ 3.06 — a true worst-case-input bound; the measured overshoot on a full-scale step is 1.045). The suite asserts realised gain never exceeds it, asserts the divider supremum is exactly 1 over the whole (c, v) grid, and separately asserts the spec's own conductance-multiplier bound 1 + α_max·v_clamp.
A note on that last one, because it is stated backwards in the source spec. The spec's registry entry cites worst_case_gain ≤ 1 + α_max_ABI · v_clamp = 1.1344. That quantity is the bound on the CONDUCTANCE multiplier 1 + α(c)·v, and gain is 1/(1 + R_s·g) — so a higher conductance means a LOWER gain. The 1.1344 figure bounds the divider's gain from below, not above, and cannot serve as a worst_case_gain. coloration_multiplier_bound() returns it under its correct name; worst_case_gain() returns the bound that actually bounds the gain.
There is no set_threshold_db(). kThresholdDbfs is a fixed internal reference and input_gain_db is the only lever into gain reduction, which is the documented hardware's arrangement: the circuit has no threshold knob, and the input control is what drives program material into the fixed reference.
Power-supply sag is a second-order effect with no citable public analysis for this lineage. Meter ballistics belong with a UI component. Stereo link composes as two instances driven from a caller-computed shared detector signal; a convenience wrapper is future work. A SPICE-accurate device model would add cost with no citable audible target to validate against.
RT contract: prepare() designs the resampler taps and the transformer shelf and is the only call permitted to be expensive; in this implementation every buffer is a fixed-size std::array, so prepare() performs no heap allocation at all — strictly stronger than the contract requires. set_*(), process() and reset() never allocate, never lock, never throw, and never perform I/O. process() is a pure function of (state, input) with no hidden globals; all recursive state is denormal-snapped. Control changes are consumed immediately by the setters (a clamp, a store, and at most one exp/pow for a coefficient) rather than through a SmoothedValue stage — matching feedforward_compressor.hpp, because the catalog node drives every knob per sample and a smoother there would be a second, redundant smoothing stage with its own state to keep deterministic.