Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
primitives.hpp
Go to the documentation of this file.
1#pragma once
2
3// Shared building blocks for the multi-character delay (character_delay.hpp).
4//
5// Why these live here rather than being pulled from the wider signal library:
6// every block in this file is used INSIDE a recirculating feedback loop, where
7// a repeat is processed once per pass and coloration compounds exponentially.
8// That imposes two requirements the general-purpose blocks do not make:
9//
10// * Double-precision state. A 20 Hz highpass at 96 kHz has a pole close
11// enough to z = 1 that float state accumulates audible error after a few
12// hundred recirculations; the delay READ POSITION likewise needs double so
13// a 2 s line still resolves sub-sample motion. Audio STORAGE stays float
14// (the buffers dominate the module's memory), state does not.
15// * Exactly invertible shelves. The emphasis pairs (tape record/playback EQ,
16// vintage pre/de-emphasis) exist to bracket a nonlinearity: whatever the
17// shelf does on the way in must be undone exactly on the way out, or the
18// residual error is itself compounded per repeat. A naive "same corner,
19// negated dB" mirror does not invert a first-order shelf; see
20// FirstOrderShelf::set_inverse for the pole/zero swap that does.
21//
22// All filters are TPT/Zavalishin trapezoidal forms (Zavalishin, *The Art of VA
23// Filter Design*; Simper/Cytomic's trapezoidal SVF note for the 2-pole mixing
24// coefficients), which stay well-behaved under per-sample coefficient
25// modulation — the delay's cutoffs move with delay time (BBD) and with the
26// character macro.
27
28#include <algorithm>
30#include <array>
31#include <cmath>
32#include <cstddef>
33#include <cstdint>
34#include <vector>
35
37
38inline constexpr double kPi = 3.14159265358979323846;
39
40// Denormal guard for IIR state. -360 dBFS is far below any audible or
41// measurable level, so flushing below it is free of audio consequence while
42// keeping the loop off the denormal slow path during long decays.
43inline constexpr double kDenormalFloor = 1e-18;
44
45inline double flush_denormal(double v) noexcept {
46 return (v <= kDenormalFloor && v >= -kDenormalFloor) ? 0.0 : v;
47}
48
49// ── Loop saturator ────────────────────────────────────────────────────────
50// Padé-form rational approximation of tanh (classic public-domain numerical
51// approximation). At |v| = 3 it evaluates to exactly ±1, which is also where
52// tanh has effectively reached its asymptote — so clamping the argument there
53// makes the function both continuous AND bounded to ±1. The unclamped rational
54// is asymptotically v/9, i.e. NOT bounded; the clamp is what lets a >unity
55// feedback loop (the deliberate self-oscillating dub behavior) settle at a
56// bounded level instead of merely decaying more slowly than it grows.
57inline double fast_tanh(double v) noexcept { return lipschitz_tanh(v); }
58
59// ── Deterministic randomness ──────────────────────────────────────────────
60// xorshift32 (Marsaglia 2003). Per-instance state, re-seeded on reset(), so
61// two renders from a fresh reset() are bit-identical — the determinism the
62// acceptance suite asserts. Streams that must not correlate (hiss vs chew vs
63// clock jitter, left vs right) each own an instance with a distinct seed.
65public:
66 explicit Xorshift32(std::uint32_t seed = 74207u) : state_(seed | 1u), seed_(seed | 1u) {}
67
68 void reseed(std::uint32_t seed) noexcept {
69 seed_ = seed | 1u;
70 state_ = seed_;
71 gauss_valid_ = false;
72 }
73
74 void reset() noexcept {
75 state_ = seed_;
76 gauss_valid_ = false;
77 }
78
79 std::uint32_t next() noexcept {
80 state_ ^= state_ << 13;
81 state_ ^= state_ >> 17;
82 state_ ^= state_ << 5;
83 return state_;
84 }
85
87 double uniform() noexcept {
88 return static_cast<double>(next()) / static_cast<double>(UINT32_MAX);
89 }
90
92 double bipolar() noexcept { return uniform() * 2.0 - 1.0; }
93
98 double gaussian() noexcept {
99 if (gauss_valid_) {
100 gauss_valid_ = false;
101 return gauss_spare_;
102 }
103 double u1 = uniform();
104 if (u1 <= 0.0) u1 = 1.0 / static_cast<double>(UINT32_MAX); // guard log(0)
105 const double u2 = uniform();
106 const double r = std::sqrt(-2.0 * std::log(u1));
107 const double a = 2.0 * kPi * u2;
108 gauss_spare_ = r * std::sin(a);
109 gauss_valid_ = true;
110 return r * std::cos(a);
111 }
112
113private:
114 std::uint32_t state_;
115 std::uint32_t seed_;
116 double gauss_spare_ = 0.0;
117 bool gauss_valid_ = false;
118};
119
120// ── Calibration-table interpolation ───────────────────────────────────────
121// Every character's tuning lives in a knot table (see tables.hpp): an ascending
122// c01 axis plus one value row per tuned quantity. Reading through this function
123// is what makes retuning a data edit rather than a logic edit.
124template <std::size_t N>
125inline double interpolate_knots(const std::array<double, N>& axis,
126 const std::array<double, N>& values, double c) noexcept {
127 static_assert(N >= 2, "a knot table needs at least two knots");
128 if (c <= axis[0]) return values[0];
129 for (std::size_t i = 1; i < N; ++i) {
130 if (c <= axis[i]) {
131 const double span = axis[i] - axis[i - 1];
132 const double t = span > 0.0 ? (c - axis[i - 1]) / span : 0.0;
133 return values[i - 1] + t * (values[i] - values[i - 1]);
134 }
135 }
136 return values[N - 1];
137}
138
139// ── One-pole parameter glide ──────────────────────────────────────────────
140// A single exponential smoother, used for both the ~5 ms de-zipper on scalar
141// params and the per-character delay-time slew (which is not de-zippering at
142// all but modeled transport inertia — the reason a tape time change swoops).
143class Smoother {
144public:
145 void configure(double time_constant_s, double fs) noexcept {
146 coefficient_ = (time_constant_s <= 0.0 || fs <= 0.0)
147 ? 0.0
148 : std::exp(-1.0 / (time_constant_s * fs));
149 }
150
151 void snap(double v) noexcept { current_ = v; }
152 double current() const noexcept { return current_; }
153
154 double process(double target) noexcept {
155 current_ = target + coefficient_ * (current_ - target);
156 return current_;
157 }
158
159private:
160 double coefficient_ = 0.0;
161 double current_ = 0.0;
162};
163
164// ── Fractional delay line ─────────────────────────────────────────────────
165// Circular buffer, float storage, 3rd-order (4-point) Lagrange fractional read
166// (Laakso et al. 1996; Dattorro 1997 Part 2) — flat passband magnitude at a
167// cost that suits a per-sample-modulated read position.
168//
169// Kernel centering: the four taps sit at delays {D-1, D, D+1, D+2}, so a
170// fractional part in [0, 1) already places the read point in the CENTRAL
171// D..D+1 segment, which is where a Lagrange kernel is most accurate and where
172// its response is continuous as the read position sweeps across integer
173// boundaries. Shifting the window by another sample (a re-centering step that
174// is correct for tap layouts anchored differently) would move the read point
175// into the outer segment and reintroduce the kernel-edge discontinuity it is
176// meant to remove, so this implementation does not do it.
178public:
180 void prepare(std::size_t capacity_samples) {
181 capacity_ = std::max<std::size_t>(capacity_samples, 8u);
182 buffer_.assign(capacity_, 0.0f);
183 write_ = 0;
184 valid_ = 0;
185 }
186
187 void reset() noexcept {
188 std::fill(buffer_.begin(), buffer_.end(), 0.0f);
189 write_ = 0;
190 valid_ = 0;
191 }
192
194 void discard_history() noexcept { write_ = valid_ = 0; }
195
196 std::size_t capacity() const noexcept { return capacity_; }
197
200 double max_delay() const noexcept {
201 return capacity_ > 4 ? static_cast<double>(capacity_ - 4) : 1.0;
202 }
203
204 void push(double x) noexcept {
205 buffer_[write_] = static_cast<float>(x);
206 if (++write_ >= capacity_) write_ = 0;
207 if (valid_ < capacity_) ++valid_;
208 }
209
211 double tap(std::size_t m) const noexcept {
212 if (m >= valid_) return 0.0;
213 const std::size_t index = (write_ + capacity_ - 1u - m) % capacity_;
214 return static_cast<double>(buffer_[index]);
215 }
216
217 double read(double delay_samples) const noexcept {
218 const double d = std::clamp(delay_samples, 1.0, max_delay());
219 const auto integer = static_cast<std::size_t>(d);
220 const double frac = d - static_cast<double>(integer);
221
222 const double s0 = tap(integer - 1u);
223 const double s1 = tap(integer);
224 const double s2 = tap(integer + 1u);
225 const double s3 = tap(integer + 2u);
226
227 const double d0 = frac + 1.0;
228 const double d1 = frac;
229 const double d2 = frac - 1.0;
230 const double d3 = frac - 2.0;
231
232 return (-d1 * d2 * d3 / 6.0) * s0 + (d0 * d2 * d3 * 0.5) * s1 +
233 (-d0 * d1 * d3 * 0.5) * s2 + (d0 * d1 * d2 / 6.0) * s3;
234 }
235
236private:
237 std::vector<float> buffer_;
238 std::size_t capacity_ = 8;
239 std::size_t write_ = 0;
240 std::size_t valid_ = 0;
241};
242
243// ── 1-pole TPT filter ─────────────────────────────────────────────────────
244// Produces lowpass and highpass from one state. Used for DC blocking, loop
245// damping, and as the pole of the first-order shelves below.
246class OnePole {
247public:
248 void set_cutoff(double cutoff_hz, double fs) noexcept {
249 const double nyquist_guard = 0.49 * fs;
250 const double fc = std::clamp(cutoff_hz, 0.1, nyquist_guard);
251 const double g = std::tan(kPi * fc / fs);
252 gain_ = g / (1.0 + g);
253 }
254
255 void reset() noexcept { state_ = 0.0; }
256
257 double lowpass(double x) noexcept {
258 const double v = (x - state_) * gain_;
259 const double y = v + state_;
260 state_ = flush_denormal(y + v);
261 return y;
262 }
263
264 double highpass(double x) noexcept { return x - lowpass(x); }
265
266private:
267 double gain_ = 0.5;
268 double state_ = 0.0;
269};
270
271// ── First-order shelf, exactly invertible ─────────────────────────────────
272// H(s) = (1 + G·τs) / (1 + τs), τ = 1/(2π·fc): unity at DC, gain G above fc.
273// Realized as lowpass + G·highpass off one TPT pole.
274//
275// The exact inverse is (1 + τs)/(1 + G·τs) — the pole and zero swapped, which
276// as a shelf is gain 1/G with its pole at fc/G. set_inverse() applies that
277// substitution, so a boost/cut pair cancels to unity everywhere instead of
278// leaving a residual mid-band bump for the feedback loop to compound.
280public:
281 void set(double corner_hz, double gain_linear, double fs) noexcept {
282 gain_ = gain_linear;
283 pole_.set_cutoff(corner_hz, fs);
284 }
285
287 void set_inverse(double corner_hz, double gain_linear, double fs) noexcept {
288 const double g = (gain_linear > 1e-9) ? gain_linear : 1e-9;
289 set(corner_hz / g, 1.0 / g, fs);
290 }
291
292 void reset() noexcept { pole_.reset(); }
293
294 double process(double x) noexcept {
295 const double low = pole_.lowpass(x);
296 return low + gain_ * (x - low);
297 }
298
299private:
300 OnePole pole_;
301 double gain_ = 1.0;
302};
303
304// ── 2-pole TPT state-variable filter ──────────────────────────────────────
305// Simper/Cytomic trapezoidal SVF. One structure, several responses selected by
306// the (m0, m1, m2) output mix — including the bell used for the tape head bump
307// and the Butterworth sections of the vintage converter's anti-alias cascade.
308class Svf2 {
309public:
310 void set_lowpass(double cutoff_hz, double q, double fs) noexcept {
311 configure(std::tan(kPi * clamp_cutoff(cutoff_hz, fs) / fs), 1.0 / std::max(q, 1e-4));
312 m0_ = 0.0;
313 m1_ = 0.0;
314 m2_ = 1.0;
315 }
316
317 void set_highpass(double cutoff_hz, double q, double fs) noexcept {
318 configure(std::tan(kPi * clamp_cutoff(cutoff_hz, fs) / fs), 1.0 / std::max(q, 1e-4));
319 m0_ = 1.0;
320 m1_ = -k_;
321 m2_ = -1.0;
322 }
323
325 void set_bell(double cutoff_hz, double q, double gain_db, double fs) noexcept {
326 const double a = std::pow(10.0, gain_db / 40.0);
327 configure(std::tan(kPi * clamp_cutoff(cutoff_hz, fs) / fs),
328 1.0 / (std::max(q, 1e-4) * a));
329 m0_ = 1.0;
330 m1_ = k_ * (a * a - 1.0);
331 m2_ = 0.0;
332 }
333
334 void reset() noexcept {
335 ic1_ = 0.0;
336 ic2_ = 0.0;
337 }
338
339 double process(double v0) noexcept {
340 const double v3 = v0 - ic2_;
341 const double v1 = a1_ * ic1_ + a2_ * v3;
342 const double v2 = ic2_ + a2_ * ic1_ + a3_ * v3;
343 ic1_ = flush_denormal(2.0 * v1 - ic1_);
344 ic2_ = flush_denormal(2.0 * v2 - ic2_);
345 return m0_ * v0 + m1_ * v1 + m2_ * v2;
346 }
347
348private:
349 static double clamp_cutoff(double cutoff_hz, double fs) noexcept {
350 return std::clamp(cutoff_hz, 1.0, 0.49 * fs);
351 }
352
353 void configure(double g, double k) noexcept {
354 g_ = g;
355 k_ = k;
356 a1_ = 1.0 / (1.0 + g * (g + k));
357 a2_ = g * a1_;
358 a3_ = g * a2_;
359 }
360
361 double g_ = 0.0, k_ = 1.0;
362 double a1_ = 1.0, a2_ = 0.0, a3_ = 0.0;
363 double m0_ = 0.0, m1_ = 0.0, m2_ = 1.0;
364 double ic1_ = 0.0, ic2_ = 0.0;
365};
366
367// ── Butterworth lowpass cascade ───────────────────────────────────────────
368// Three 2-pole SVF sections at the analytic 6th-order Butterworth pole Qs,
369// Q_k = 1/(2·cos((2k+1)π/12)). These are textbook pole placements, not tuned
370// values. Used as the vintage converter's matched anti-alias / reconstruction
371// pair, where a gentler rolloff would let the internal grid alias audibly.
373public:
374 static constexpr std::array<double, 3> kSectionQ = {0.5176380902, 0.7071067812,
375 1.9318516526};
376
377 void set_cutoff(double cutoff_hz, double fs) noexcept {
378 for (std::size_t i = 0; i < sections_.size(); ++i)
379 sections_[i].set_lowpass(cutoff_hz, kSectionQ[i], fs);
380 }
381
382 void reset() noexcept {
383 for (auto& s : sections_) s.reset();
384 }
385
386 double process(double x) noexcept {
387 for (auto& s : sections_) x = s.process(x);
388 return x;
389 }
390
391private:
392 std::array<Svf2, 3> sections_;
393};
394
395// ── FIR with externally owned coefficients ────────────────────────────────
396// The wider signal library's FIR owns (and reallocates) its coefficients,
397// which is the right shape when the coefficients are set once. The physical
398// tape tier's loss filter is different: its coefficients are re-derived at
399// CONTROL RATE by interpolating pre-designed banks, and during a tape-speed
400// change two coefficient sets are convolved against the same history so the
401// banks can be crossfaded instead of swapped. Separating the history from the
402// coefficients makes both of those allocation-free.
403class FixedFir {
404public:
406 void prepare(std::size_t max_taps) {
407 capacity_ = std::max<std::size_t>(max_taps, 1u);
408 history_.assign(capacity_, 0.0);
409 write_ = 0;
410 valid_ = 0;
411 }
412
413 void reset() noexcept {
414 std::fill(history_.begin(), history_.end(), 0.0);
415 write_ = 0;
416 valid_ = 0;
417 }
418
420 void discard_history() noexcept { write_ = valid_ = 0; }
421
422 void push(double x) noexcept {
423 history_[write_] = x;
424 if (++write_ >= capacity_) write_ = 0;
425 if (valid_ < capacity_) ++valid_;
426 }
427
428 double convolve(const double* coefficients, std::size_t taps) const noexcept {
429 const std::size_t n = std::min({taps, capacity_, valid_});
430 double sum = 0.0;
431 std::size_t index = write_;
432 for (std::size_t i = 0; i < n; ++i) {
433 index = (index == 0) ? capacity_ - 1u : index - 1u;
434 sum += coefficients[i] * history_[index];
435 }
436 return sum;
437 }
438
439private:
440 std::vector<double> history_;
441 std::size_t capacity_ = 1;
442 std::size_t write_ = 0;
443 std::size_t valid_ = 0;
444};
445
446// ── Attack/release envelope follower ──────────────────────────────────────
447// Peak-fed, asymmetric one-pole. Used by the ducker's sidechain and by the
448// BBD compander's rectifier loop.
450public:
451 void configure(double attack_s, double release_s, double rate_hz) noexcept {
452 attack_ = (attack_s <= 0.0) ? 0.0 : std::exp(-1.0 / (attack_s * rate_hz));
453 release_ = (release_s <= 0.0) ? 0.0 : std::exp(-1.0 / (release_s * rate_hz));
454 }
455
456 void reset(double value = 0.0) noexcept { env_ = value; }
457 double value() const noexcept { return env_; }
458
459 double process(double magnitude) noexcept {
460 const double c = (magnitude > env_) ? attack_ : release_;
461 env_ = magnitude + c * (env_ - magnitude);
462 return env_;
463 }
464
465private:
466 double attack_ = 0.0;
467 double release_ = 0.0;
468 double env_ = 0.0;
469};
470
471} // namespace pulp::signal::chardelay
double process(double x) noexcept
Definition primitives.hpp:386
static constexpr std::array< double, 3 > kSectionQ
Definition primitives.hpp:374
void set_cutoff(double cutoff_hz, double fs) noexcept
Definition primitives.hpp:377
void reset() noexcept
Definition primitives.hpp:382
Definition primitives.hpp:449
double process(double magnitude) noexcept
Definition primitives.hpp:459
void reset(double value=0.0) noexcept
Definition primitives.hpp:456
double value() const noexcept
Definition primitives.hpp:457
void configure(double attack_s, double release_s, double rate_hz) noexcept
Definition primitives.hpp:451
Definition primitives.hpp:279
double process(double x) noexcept
Definition primitives.hpp:294
void set_inverse(double corner_hz, double gain_linear, double fs) noexcept
Configure as the exact inverse of set(corner_hz, gain_linear, fs).
Definition primitives.hpp:287
void set(double corner_hz, double gain_linear, double fs) noexcept
Definition primitives.hpp:281
void reset() noexcept
Definition primitives.hpp:292
Definition primitives.hpp:403
double convolve(const double *coefficients, std::size_t taps) const noexcept
Definition primitives.hpp:428
void reset() noexcept
Definition primitives.hpp:413
void push(double x) noexcept
Definition primitives.hpp:422
void discard_history() noexcept
Logically clear history without walking the allocation.
Definition primitives.hpp:420
void prepare(std::size_t max_taps)
Allocates. Control thread only.
Definition primitives.hpp:406
std::size_t capacity() const noexcept
Definition primitives.hpp:196
void push(double x) noexcept
Definition primitives.hpp:204
void reset() noexcept
Definition primitives.hpp:187
void discard_history() noexcept
Logically clear history without walking the allocation.
Definition primitives.hpp:194
double read(double delay_samples) const noexcept
Definition primitives.hpp:217
void prepare(std::size_t capacity_samples)
Allocates. Control thread only.
Definition primitives.hpp:180
double tap(std::size_t m) const noexcept
Sample m samples in the past; m = 0 is the most recent push().
Definition primitives.hpp:211
double max_delay() const noexcept
Largest delay the Lagrange kernel can address without walking off the buffer (the top three slots are...
Definition primitives.hpp:200
Definition primitives.hpp:246
double highpass(double x) noexcept
Definition primitives.hpp:264
double lowpass(double x) noexcept
Definition primitives.hpp:257
void set_cutoff(double cutoff_hz, double fs) noexcept
Definition primitives.hpp:248
void reset() noexcept
Definition primitives.hpp:255
Definition primitives.hpp:143
void configure(double time_constant_s, double fs) noexcept
Definition primitives.hpp:145
double process(double target) noexcept
Definition primitives.hpp:154
double current() const noexcept
Definition primitives.hpp:152
void snap(double v) noexcept
Definition primitives.hpp:151
Definition primitives.hpp:308
double process(double v0) noexcept
Definition primitives.hpp:339
void set_bell(double cutoff_hz, double q, double gain_db, double fs) noexcept
Peaking ("bell") section: gain_db at cutoff_hz, unity far away.
Definition primitives.hpp:325
void set_lowpass(double cutoff_hz, double q, double fs) noexcept
Definition primitives.hpp:310
void set_highpass(double cutoff_hz, double q, double fs) noexcept
Definition primitives.hpp:317
void reset() noexcept
Definition primitives.hpp:334
Xorshift32(std::uint32_t seed=74207u)
Definition primitives.hpp:66
void reset() noexcept
Definition primitives.hpp:74
double gaussian() noexcept
Standard normal via Box-Muller (Box & Muller 1958).
Definition primitives.hpp:98
double bipolar() noexcept
Uniform in [-1, 1].
Definition primitives.hpp:92
double uniform() noexcept
Uniform in [0, 1].
Definition primitives.hpp:87
void reseed(std::uint32_t seed) noexcept
Definition primitives.hpp:68
std::uint32_t next() noexcept
Definition primitives.hpp:79
Scalar math helpers for real-time audio DSP.
Definition bbd.hpp:36
double flush_denormal(double v) noexcept
Definition primitives.hpp:45
constexpr double kPi
Definition primitives.hpp:38
double fast_tanh(double v) noexcept
Definition primitives.hpp:57
double interpolate_knots(const std::array< double, N > &axis, const std::array< double, N > &values, double c) noexcept
Definition primitives.hpp:125
constexpr double kDenormalFloor
Definition primitives.hpp:43
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
@ v2
Medium vibrato.
Definition scanner_vibrato.hpp:24
@ v3
Full vibrato.
Definition scanner_vibrato.hpp:25
@ v1
Shallow vibrato — scanner output only.
Definition scanner_vibrato.hpp:23
@ a
Definition formant_filter_bank.hpp:20
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20