Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
stages.hpp
Go to the documentation of this file.
1#pragma once
2
3// The stages that bracket the tank: the transient ducker on the way in, and
4// the width / ensemble / limiter chain on the way out.
5//
6// Both live INSIDE the tank-rate domain (see the engine's block diagram). That
7// is deliberate for the output chain: at a 20 kHz tank the ensemble's own
8// bandwidth is part of the low-rate texture, and moving it to the host rate
9// would sand off exactly the character the tank rate exists to provide.
10//
11// RT contract: prepare() allocates; process/reset allocate nothing.
12
15#include <pulp/signal/detail/simd_lanes.hpp>
20
21#include <algorithm>
22#include <array>
23#include <cmath>
24
25namespace pulp::signal::fdn {
26
27namespace lanes = signal::detail::lanes;
28
29// The loop saturator's curve. See signal::lipschitz_tanh for why it is this
30// one — 1-Lipschitz so it can never raise the loop gain, and bounded so a
31// freeze-regime tail settles rather than merely decaying slower than it grows.
32// The character delay uses the same curve for its dub feedback; fast_math.hpp
33// holds the one definition.
35
36// Transient ducker on the INPUT. Attacks excite the reverb less, sustains
37// excite it fully, which is what keeps a groove feeling dry under a big tail.
38// The ratio is normalized by the slow envelope, so the duck responds to the
39// SHAPE of the input, not its level.
41public:
42 void configure(double sample_rate) {
43 fast_coeff_ = coeff_for(kDuckFastMs, sample_rate);
44 slow_coeff_ = coeff_for(kDuckSlowMs, sample_rate);
45 }
46
47 void reset() {
48 fast_ = 0.0;
49 slow_ = 0.0;
50 }
51
52 // Returns the input gain to apply, in (0, 1].
53 double process(double magnitude) {
54 magnitude = finite_or_zero(magnitude);
55 fast_ = snap_to_zero(fast_ + (magnitude - fast_) * fast_coeff_);
56 slow_ = snap_to_zero(slow_ + (magnitude - slow_) * slow_coeff_);
57 fast_ = finite_or_zero(fast_);
58 slow_ = finite_or_zero(slow_);
59 return gain_for(fast_, slow_);
60 }
61
62 // In place over a stereo block: each sample's gain from the mean of the
63 // two rails' magnitudes, applied to both. Returns the last gain. The
64 // block's input is finite (the engine sanitizes at its entry) and the
65 // envelopes follow non-negative magnitudes, so the recursion runs bare;
66 // the owner calls heal() on a fixed sample grid.
67 template <typename SampleType> double process_block(SampleType* l, SampleType* r, int n) {
68 double fast = fast_;
69 double slow = slow_;
70 double gain = 1.0;
71 for (int k = 0; k < n; ++k) {
72 const double magnitude =
73 0.5 * (std::abs(static_cast<double>(l[k])) + std::abs(static_cast<double>(r[k])));
74 fast += (magnitude - fast) * fast_coeff_;
75 slow += (magnitude - slow) * slow_coeff_;
76 gain = gain_for(fast, slow);
77 l[k] = static_cast<SampleType>(static_cast<double>(l[k]) * gain);
78 r[k] = static_cast<SampleType>(static_cast<double>(r[k]) * gain);
79 }
80 fast_ = fast;
81 slow_ = slow;
82 return gain;
83 }
84
85 // Snap and sanitize the envelopes, as process() does every sample.
86 void heal() {
87 fast_ = finite_or_zero(snap_to_zero(fast_));
88 slow_ = finite_or_zero(snap_to_zero(slow_));
89 }
90
91 double fast_envelope() const { return fast_; }
92 double slow_envelope() const { return slow_; }
93
94 private:
95 static double gain_for(double fast, double slow) {
96 const double ratio = (fast - slow) / std::max(slow, 1e-9);
97 const double t = std::clamp(ratio / kDuckKnee, 0.0, 1.0);
98 const double smooth = t * t * (3.0 - 2.0 * t);
99 return 1.0 - kDuckAmount * smooth;
100 }
101
102 static double coeff_for(double ms, double sample_rate) {
103 const double n = std::max(1.0, ms * 0.001 * sample_rate);
104 return 1.0 - std::exp(-1.0 / n);
105 }
106
107 double fast_coeff_ = 0.0;
108 double slow_coeff_ = 0.0;
109 double fast_ = 0.0;
110 double slow_ = 0.0;
111};
112
113// Six-LFO stereo ensemble. The L and R taps of each LFO sit 90 degrees apart,
114// which is what turns six modulated delays into a wide, soft gloss instead of
115// six audible chorus voices.
116//
117// The LFOs are a lockstep quadrature bank: the right rail's cosine tap is the
118// same oscillator's quadrature output, so a sample costs six rotations rather
119// than six sines and six cosines. process_block() pushes a whole span first
120// and then reads it back, which is exact because the shortest tap delay
121// (base minus depth) is longer than kMaxBlockSamples at every tank rate.
122template <typename SampleType = float>
124public:
125 void prepare(double max_sample_rate) {
126 const int cap =
127 static_cast<int>((kEnsembleBaseMs + kEnsembleDepthMs * 2.0) * 0.001 * max_sample_rate) +
129 left_.prepare(cap);
130 right_.prepare(cap);
131 }
132
133 void configure(double sample_rate) {
134 base_samples_ = kEnsembleBaseMs * 0.001 * sample_rate;
135 depth_samples_ = kEnsembleDepthMs * 0.001 * sample_rate;
136 for (int i = 0; i < kNumEnsembleLfos; ++i) {
137 const double t = static_cast<double>(i) /
138 static_cast<double>(kNumEnsembleLfos - 1);
139 const double hz =
141 lfo_.set_rate(i, hz, sample_rate);
142 }
143 }
144
145 void reset() {
146 left_.reset();
147 right_.reset();
148 lfo_.restart();
149 for (int i = 0; i < kNumEnsembleLfos; ++i)
150 lfo_.reset(i, static_cast<double>(i) / static_cast<double>(kNumEnsembleLfos));
151 }
152
153 void process(SampleType& l, SampleType& r) {
154 process_block(&l, &r, 1);
155 }
156
157 // In place, n <= kMaxBlockSamples. The span is pushed first; then each
158 // sample's twelve taps (six per rail) are read back against that sample's
159 // own post-push position, all twelve per pass: one 16-byte tap load each
160 // (FracDelayT::taps_at) and the interpolation three taps-groups per
161 // vector. Same delays, fractions, taps, curve, and summation order as
162 // reading each tap with FracDelayT::read_hermite_at.
163 void process_block(SampleType* l, SampleType* r, int n) {
164 static_assert(kNumEnsembleLfos == 6, "the tap grouping below is for six LFOs");
165 using lanes::D2;
166 using lanes::F4;
167 using lanes::I2;
168 left_.push_block(l, n);
169 right_.push_block(r, n);
170 const SampleType* rails[2] = {left_.taps_at(0), right_.taps_at(0)};
171 const I2 mask = lanes::splat_i(static_cast<std::int64_t>(left_.mask()));
172 const std::size_t end = left_.write_index();
173 const D2 base = lanes::splat(base_samples_);
174 const D2 depth = lanes::splat(depth_samples_);
175 const D2 lowest = lanes::splat(2.0);
176 const D2 highest = lanes::splat(static_cast<double>(left_.max_delay()));
177 const F4 half = lanes::splat(0.5f);
178 const F4 two_and_half = lanes::splat(2.5f);
179 const F4 two = lanes::splat(2.0f);
180 const F4 one_and_half = lanes::splat(1.5f);
181 const F4 one = lanes::splat(1.0f);
182 constexpr auto dry = static_cast<SampleType>(1.0 - kEnsembleWetMix);
183 constexpr auto wet = static_cast<SampleType>(kEnsembleWetMix / kNumEnsembleLfos);
184 for (int k = 0; k < n; ++k) {
185 lfo_.tick();
186 // Taps 0-5 read the left rail at the sines, 6-11 the right rail at
187 // the cosines: 90 degrees of phase between the rails.
188 const I2 origin = lanes::splat_i(
189 static_cast<std::int64_t>(end - static_cast<std::size_t>(n - 1 - k)) - 2);
190 alignas(16) std::int64_t start[12];
191 D2 f[6];
192 for (int p = 0; p < 6; ++p) {
193 const int i = (2 * p) % kNumEnsembleLfos;
194 const D2 phase = p < 3 ? lanes::make(lfo_.sin(i), lfo_.sin(i + 1))
195 : lanes::make(lfo_.cos(i), lfo_.cos(i + 1));
196 const D2 d = lanes::clamp(base + depth * phase, lowest, highest);
197 const I2 whole = lanes::to_int(d);
198 f[p] = d - lanes::to_double(whole);
199 const I2 at = (origin - whole) & mask;
200 start[2 * p] = lanes::lane(at, 0);
201 start[2 * p + 1] = lanes::lane(at, 1);
202 }
203 F4 out[3];
204 for (int g = 0; g < 3; ++g) {
205 F4 ym1 = lanes::load(rails[(4 * g) / 6] + start[4 * g]);
206 F4 y0 = lanes::load(rails[(4 * g + 1) / 6] + start[4 * g + 1]);
207 F4 y1 = lanes::load(rails[(4 * g + 2) / 6] + start[4 * g + 2]);
208 F4 y2 = lanes::load(rails[(4 * g + 3) / 6] + start[4 * g + 3]);
209 lanes::transpose(ym1, y0, y1, y2);
210 const F4 t = one - lanes::narrow(f[2 * g], f[2 * g + 1]);
211 const F4 c1 = half * (y1 - ym1);
212 const F4 c2 = ym1 - two_and_half * y0 + two * y1 - half * y2;
213 const F4 c3 = half * (y2 - ym1) + one_and_half * (y0 - y1);
214 out[g] = ((c3 * t + c2) * t + c1) * t + y0;
215 }
216 SampleType sum_l = 0;
217 SampleType sum_r = 0;
218 for (int tap = 0; tap < kNumEnsembleLfos; ++tap)
219 sum_l += lanes::lane(out[tap / 4], tap % 4);
220 for (int tap = kNumEnsembleLfos; tap < 2 * kNumEnsembleLfos; ++tap)
221 sum_r += lanes::lane(out[tap / 4], tap % 4);
222 l[k] = l[k] * dry + sum_l * wet;
223 r[k] = r[k] * dry + sum_r * wet;
224 }
225 }
226
227private:
231 double base_samples_ = 0.0;
232 double depth_samples_ = 0.0;
233};
234
235// Mid/side width. width 0 collapses to mono, 1 leaves the tank's natural
236// stereo alone, and the tank's own even/odd fan-out already carries most of
237// the separation — this only adjusts it.
238inline void apply_width(double& l, double& r, double width) {
239 const double mid = 0.5 * (l + r);
240 const double side = 0.5 * (l - r) * width;
241 l = mid + side;
242 r = mid - side;
243}
244
245// Last-resort wet limiter. At normal levels the curve is within a fraction of a
246// dB of unity; it exists so a pathological parameter combination clips softly
247// instead of squaring off. This one uses the real tanh rather than the Padé
248// approximation: it sits OUTSIDE the recursion, where the cost of one
249// transcendental per sample is irrelevant and an actually-bounded output
250// matters more than the 1-Lipschitz property the loop needs.
251inline double soft_limit(double x) {
252 return kWetLimiterHeadroom * std::tanh(x / kWetLimiterHeadroom);
253}
254
255// tanh in single precision as a [9/8] odd rational, fitted to tanh on
256// |x| <= 9 with the slope pinned to 1 at the origin: relative error below
257// 4e-7 everywhere in float, and the result is clamped to [-1, 1] so the curve
258// stays bounded. Branch-free, so a block of it vectorizes.
259inline float tanh_rational(float x) {
260 const float c = std::clamp(x, -9.0f, 9.0f);
261 const float s = c * c;
262 const float p =
263 (((1.3810137e-08f * s + 2.0946818e-05f) * s + 3.5165233e-03f) * s + 1.3398798e-01f) * s +
264 1.0f;
265 const float q =
266 (((7.9656114e-07f * s + 3.3195784e-04f) * s + 2.5957071e-02f) * s + 4.6732125e-01f) * s +
267 1.0f;
268 return std::clamp(c * p / q, -1.0f, 1.0f);
269}
270
271inline float soft_limit(float x) {
272 constexpr auto headroom = static_cast<float>(kWetLimiterHeadroom);
273 return headroom * tanh_rational(x / headroom);
274}
275
276} // namespace pulp::signal::fdn
Definition stages.hpp:123
void prepare(double max_sample_rate)
Definition stages.hpp:125
void process_block(SampleType *l, SampleType *r, int n)
Definition stages.hpp:163
void process(SampleType &l, SampleType &r)
Definition stages.hpp:153
void configure(double sample_rate)
Definition stages.hpp:133
void reset()
Definition stages.hpp:145
Definition frac_delay.hpp:36
Definition modulation.hpp:103
Definition stages.hpp:40
void reset()
Definition stages.hpp:47
double slow_envelope() const
Definition stages.hpp:92
double process_block(SampleType *l, SampleType *r, int n)
Definition stages.hpp:67
void configure(double sample_rate)
Definition stages.hpp:42
double fast_envelope() const
Definition stages.hpp:91
double process(double magnitude)
Definition stages.hpp:53
void heal()
Definition stages.hpp:86
Scalar math helpers for real-time audio DSP.
Definition config.hpp:15
constexpr double kDuckFastMs
Definition config.hpp:149
constexpr double kWetLimiterHeadroom
Definition config.hpp:164
constexpr double kEnsembleRateMaxHz
Definition config.hpp:158
T finite_or_zero(T value)
Definition sanitize.hpp:12
constexpr double kDuckKnee
Definition config.hpp:152
constexpr double kEnsembleRateMinHz
Definition config.hpp:157
constexpr int kNumEnsembleLfos
Definition config.hpp:22
void apply_width(double &l, double &r, double width)
Definition stages.hpp:238
constexpr double kEnsembleWetMix
Definition config.hpp:159
@ width
Definition config.hpp:225
constexpr double kEnsembleBaseMs
Definition config.hpp:155
constexpr double kDuckAmount
Definition config.hpp:151
constexpr int kHermiteGuard
Definition config.hpp:171
constexpr int kMaxBlockSamples
Definition config.hpp:190
constexpr double kEnsembleDepthMs
Definition config.hpp:156
constexpr double kDuckSlowMs
Definition config.hpp:150
float tanh_rational(float x)
Definition stages.hpp:259
double soft_limit(double x)
Definition stages.hpp:251
double lipschitz_tanh(double v) noexcept
A [3/2] Padé tanh that is 1-LIPSCHITZ and bounded — the saturator to reach for inside a feedback loop...
Definition fast_math.hpp:710
@ c
Definition spectrum_trace.hpp:35
@ c3
Full chorus. The lush one.
Definition scanner_vibrato.hpp:28
@ c2
Medium chorus.
Definition scanner_vibrato.hpp:27
@ c1
Shallow chorus — scanner plus dry.
Definition scanner_vibrato.hpp:26
T snap_to_zero(T x)
Returns 0 if |x| < snap_threshold, else x.
Definition denormal.hpp:45
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20