Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
bbd.hpp
Go to the documentation of this file.
1#pragma once
2
3// BBD character — a bucket-brigade device modeled as what it physically is.
4//
5// Structure and concepts follow the open BBD-modeling literature: Raffel &
6// Smith (DAFx-10) for the device model, compander and the roles of the input /
7// output filters; Holters & Parker (DAFx-18) for the variable-sample-rate view
8// of the clocked line; the NE570/SA571 datasheet for compander topology; the
9// Panasonic MN300x datasheets for stage counts.
10//
11// The device is N charge buckets clocked at f_clock, so total delay = N /
12// f_clock. Modeling the CLOCK rather than a delay-in-samples is what makes the
13// character behave like the hardware for free:
14//
15// * Time changes move the clock, so the contents of the buckets repitch —
16// a BBD delay glides when you turn the time knob, it does not crossfade.
17// * Usable bandwidth is tied to the clock (a fixed fraction of f_clock/2, the
18// aliasing constraint both papers impose), so short delays are bright and
19// long delays go dark ON THEIR OWN. That relationship is the sound of the
20// device and it falls out of the model instead of being drawn as a curve.
21//
22// The compander is the other half of the sound. It is a companding NOISE
23// reduction system wrapped around a noisy line, and it is imperfect: the
24// expander's envelope lags the signal, so the noise floor audibly breathes
25// around transients. That pumping is not an artifact to remove.
26
29
30#include <algorithm>
31#include <array>
32#include <cmath>
33#include <cstddef>
34#include <cstdint>
35
37
40public:
41 void prepare(double fs) {
42 sample_rate_ = fs;
43 oversampled_rate_ = fs * static_cast<double>(kBbdOversample);
44 compander_coefficient_ =
45 std::exp(-1.0 / (kBbdCompanderTauMs * 0.001 * fs));
46 pre_highpass_.set_highpass(20.0, 0.70710678, fs);
47 update(0.0, 0.35);
48 reset();
49 }
50
51 void reset() noexcept {
52 buckets_.fill(0.0f);
53 write_ = 0;
54 time_accumulator_ = 0.0;
55 jitter_state_ = 0.0;
56 previous_input_ = 0.0;
57 compress_envelope_ = kBbdCompanderFloor;
58 expand_envelope_ = kBbdCompanderFloor;
59 pre_lowpass_.reset();
60 pre_highpass_.reset();
61 post_lowpass_.reset();
62 rng_.reset();
63 }
64
65 void set_seed(std::uint32_t seed) noexcept { rng_.reseed(seed); }
66
70 void set_compander_enabled(bool enabled) noexcept { compander_enabled_ = enabled; }
71
75 void update(double character_amount, double delay_seconds) noexcept {
76 stages_ = static_cast<std::size_t>(std::llround(
77 interpolate_knots(kBbdAxis, kBbdStages, character_amount)));
78 stages_ = std::clamp<std::size_t>(stages_, 1u, kBbdMaxStages);
79 drive_ = interpolate_knots(kBbdAxis, kBbdDrive, character_amount);
80 jitter_amount_ = interpolate_knots(kBbdAxis, kBbdJitterAmount, character_amount);
81
82 const double delay = std::max(delay_seconds, 1e-6);
83 clock_period_ = delay / static_cast<double>(stages_);
84 const double clock_hz = 1.0 / clock_period_;
85 bandwidth_hz_ = std::clamp(clock_hz / kBbdBandwidthDivisor, kBbdBandwidthMinHz,
87
88 // Matched anti-alias / reconstruction pair, as in the hardware and in
89 // both published models. The pre-filter runs at the host rate (it
90 // band-limits before the clocked write); the post-filter runs at the
91 // internal oversampled rate, where it smooths the stepped read-out.
92 const double filter_cutoff = bandwidth_hz_ * kBbdFilterCutoffScale;
93 pre_lowpass_.set_lowpass(filter_cutoff, 0.70710678, sample_rate_);
94 post_lowpass_.set_lowpass(filter_cutoff, 0.70710678, oversampled_rate_);
95
96 const double effective_drive = drive_ * kBbdCompanderDriveScale;
97 shaper_gain_ = 1.0 + effective_drive * kBbdDriveGainSpan;
98 shaper_offset_ = kBbdShape * 0.5;
99 shaper_offset_tanh_ = fast_tanh(shaper_offset_);
100
101 // Normalize the shaper to unity small-signal gain, measured rather than
102 // derived so it tracks whatever curve the shaper actually uses. Without
103 // this the shaper's drive multiplies the LOOP gain: at the top of the
104 // character table the raw slope is well over 2, so a feedback setting of
105 // 0.5 would put the loop above unity and the echo train would grow. The
106 // character macro must change the colour of a repeat, not how long the
107 // repeats last — that is what the feedback control is for. (The tape
108 // saturator is DC-compensated for the same reason.)
109 shaper_compensation_ = 1.0;
110 constexpr double kProbe = 1e-4;
111 const double slope = (waveshape(kProbe) - waveshape(-kProbe)) / (2.0 * kProbe);
112 shaper_compensation_ = (std::abs(slope) > 1e-9) ? 1.0 / slope : 1.0;
113 }
114
115 double bandwidth_hz() const noexcept { return bandwidth_hz_; }
116 std::size_t stages() const noexcept { return stages_; }
117
118 // `clock_mod` scales the bucket clock for this sample (LFO chorus: a varying
119 // clock varies both delay time and pitch, which is how a BBD choruses). 0
120 // leaves the control-rate clock_period_ unchanged.
121 double process(double x, double clock_mod = 0.0) noexcept {
122 // ── Compander, compress half ──────────────────────────────────────
123 // NE570-class parts close the rectifier loop around the COMPRESSED
124 // signal, not the input; that feedback typology is why the compressed
125 // level sits near a fixed operating point regardless of input level.
126 const double compressed =
127 compander_enabled_ ? x / std::max(compress_envelope_, kBbdCompanderFloor) : x;
128 compress_envelope_ = flush_denormal(
129 compander_coefficient_ * compress_envelope_ +
130 (1.0 - compander_coefficient_) * std::abs(compressed));
131
132 const double filtered = pre_highpass_.process(pre_lowpass_.process(compressed));
133
134 // ── Clock-domain core ─────────────────────────────────────────────
135 // The pre-filter has already band-limited the signal, so straight-line
136 // interpolation across the U internal steps is a faithful, cheap
137 // reconstruction of what the bucket chain samples between host frames.
138 const double step =
139 (filtered - previous_input_) / static_cast<double>(kBbdOversample - 1);
140 double value = previous_input_;
141 const double internal_period = 1.0 / oversampled_rate_;
142 double first_read = 0.0;
143 const double period = std::max(clock_period_ * (1.0 + clock_mod), 1e-9);
144
145 for (int u = 0; u < kBbdOversample; ++u) {
146 time_accumulator_ += internal_period;
147 // Zero, one, or several ticks: at short delays with a long bucket
148 // chain the clock genuinely outruns the internal rate. The bound is
149 // belt-and-braces — the delay time is clamped at 1 ms, which caps
150 // the real tick count in the single digits — but an unbounded loop
151 // on the audio thread is not something to leave to a clamp two
152 // layers away.
153 int ticks = 0;
154 while (time_accumulator_ >= period && ticks++ < kMaxTicksPerStep) {
155 const double jittered = period + next_jitter();
156 buckets_[write_] = static_cast<float>(value);
157 if (++write_ >= kBbdMaxStages) write_ = 0;
158 time_accumulator_ -= std::max(jittered, 1e-9);
159 }
160 value += step;
161
162 const std::size_t read_index =
163 (write_ + kBbdMaxStages - stages_) % kBbdMaxStages;
164 double read = static_cast<double>(buckets_[read_index]);
165 if (drive_ > 0.0) read = waveshape(read);
166 read = post_lowpass_.process(read);
167 if (u == 0) first_read = read;
168 }
169 previous_input_ = filtered;
170
171 // ── Compander, expand half ────────────────────────────────────────
172 // The expander's envelope is floored by the SAME constant that guards
173 // the compressor's divide, and for the same reason: the two halves have
174 // to stay inverse. Without the floor the pair's round-trip gain is
175 // env_out/env_in, which collapses to zero after silence — the compressed
176 // side is multiplying by 100 while the expanded side multiplies by
177 // nothing. Audibly that shows up two ways: a quiet passage after silence
178 // arrives late and small while the expander catches up, and a
179 // self-oscillating loop (feedback above unity, which this character is
180 // specified to support) dies instead of sustaining, because each pass
181 // loses the whole compander mismatch.
182 //
183 // The expander multiplies by the envelope as it stood BEFORE this
184 // sample's update, mirroring the compressor, which divides by its
185 // envelope before updating. Matching the two matters: with the
186 // compressor reading the old envelope and the expander the new one, a
187 // signal arriving after silence is divided by the floor (a gain of 100)
188 // and multiplied by an envelope that has already jumped off the floor,
189 // so the pair's round-trip gain is several times unity. In a feedback
190 // loop that compounds — measured as an echo train that GREW at a
191 // feedback setting of 0.5, which is a runaway, not character.
192 const double gain = std::max(expand_envelope_, kBbdCompanderFloor);
193 expand_envelope_ = flush_denormal(
194 compander_coefficient_ * expand_envelope_ +
195 (1.0 - compander_coefficient_) * std::abs(first_read));
196 return compander_enabled_ ? first_read * gain : first_read;
197 }
198
199private:
202 static constexpr int kMaxTicksPerStep = 64;
203
209 double waveshape(double x) const noexcept {
210 return (fast_tanh(shaper_gain_ * x + shaper_offset_) - shaper_offset_tanh_) *
211 shaper_compensation_;
212 }
213
217 double next_jitter() noexcept {
218 if (jitter_amount_ <= 0.0) return 0.0;
219 const double coefficient = 0.9 + kBbdJitterSmoothness * 0.0999;
220 jitter_state_ = coefficient * jitter_state_ + (1.0 - coefficient) * rng_.bipolar();
221 return jitter_state_ * jitter_amount_ * kBbdJitterMax * clock_period_;
222 }
223
224 std::array<float, kBbdMaxStages> buckets_{};
225 std::size_t write_ = 0;
226 std::size_t stages_ = kBbdMaxStages;
227
228 Svf2 pre_lowpass_;
229 Svf2 pre_highpass_;
230 Svf2 post_lowpass_;
231 Xorshift32 rng_{kPrngSeed};
232
233 double sample_rate_ = 48000.0;
234 double oversampled_rate_ = 48000.0 * kBbdOversample;
235 double clock_period_ = 1.0 / 48000.0;
236 double bandwidth_hz_ = kBbdBandwidthMaxHz;
237 double time_accumulator_ = 0.0;
238 double previous_input_ = 0.0;
239 double jitter_state_ = 0.0;
240 double jitter_amount_ = 0.0;
241 double drive_ = 0.0;
242 double shaper_gain_ = 1.0;
243 double shaper_offset_ = 0.0;
244 double shaper_offset_tanh_ = 0.0;
245 double shaper_compensation_ = 1.0;
246 double compander_coefficient_ = 0.0;
247 double compress_envelope_ = 1.0;
248 double expand_envelope_ = 1.0;
249 bool compander_enabled_ = true;
250};
251
252} // namespace pulp::signal::chardelay
One channel of the clocked BBD core, compander included.
Definition bbd.hpp:39
std::size_t stages() const noexcept
Definition bbd.hpp:116
void prepare(double fs)
Definition bbd.hpp:41
double process(double x, double clock_mod=0.0) noexcept
Definition bbd.hpp:121
double bandwidth_hz() const noexcept
Definition bbd.hpp:115
void set_compander_enabled(bool enabled) noexcept
Test hook: bypass the compander so the raw bucket-line noise floor is observable.
Definition bbd.hpp:70
void set_seed(std::uint32_t seed) noexcept
Definition bbd.hpp:65
void update(double character_amount, double delay_seconds) noexcept
Control-rate coefficient update.
Definition bbd.hpp:75
void reset() noexcept
Definition bbd.hpp:51
double bipolar() noexcept
Uniform in [-1, 1].
Definition primitives.hpp:92
Definition bbd.hpp:36
constexpr double kBbdBandwidthMinHz
Definition tables.hpp:212
constexpr double kBbdShape
Definition tables.hpp:231
double flush_denormal(double v) noexcept
Definition primitives.hpp:45
constexpr std::array< double, 3 > kBbdDrive
Definition tables.hpp:197
constexpr double kBbdCompanderTauMs
Compander: hardware τ is set by an external capacitor, so real units vary.
Definition tables.hpp:222
constexpr double kBbdFilterCutoffScale
The clock law names the complete wet path's audible -3 dB edge.
Definition tables.hpp:218
constexpr std::array< double, 3 > kBbdJitterAmount
Definition tables.hpp:198
double fast_tanh(double v) noexcept
Definition primitives.hpp:57
constexpr std::array< double, 3 > kBbdAxis
Definition tables.hpp:190
constexpr double kBbdCompanderFloor
Definition tables.hpp:223
constexpr double kBbdBandwidthDivisor
Bandwidth law: the usable passband must sit well below the clock's Nyquist (the published aliasing co...
Definition tables.hpp:211
constexpr std::uint32_t kPrngSeed
PRNG seed.
Definition tables.hpp:92
double interpolate_knots(const std::array< double, N > &axis, const std::array< double, N > &values, double c) noexcept
Definition primitives.hpp:125
constexpr int kBbdOversample
Internal oversampling of the clocked core.
Definition tables.hpp:206
constexpr std::array< double, 3 > kBbdStages
PUBLISHED chip stage counts (Panasonic MN3005 / MN3008 / MN3007 datasheets), not design parameters: t...
Definition tables.hpp:195
constexpr double kBbdJitterSmoothness
Definition tables.hpp:235
constexpr double kBbdJitterMax
Clock jitter, as a fraction of the clock period. [1e-4–2e-3].
Definition tables.hpp:234
constexpr double kBbdBandwidthMaxHz
Definition tables.hpp:213
constexpr double kBbdCompanderDriveScale
The compander holds loop level near the rail, so the waveshaper's drive is backed off when it runs be...
Definition tables.hpp:227
constexpr std::size_t kBbdMaxStages
Largest stage count any table knot may request; sizes the bucket buffer.
Definition tables.hpp:202
constexpr double kBbdDriveGainSpan
Asymmetric soft clip: gain map and DC offset for even-harmonic content.
Definition tables.hpp:230
@ delay
Definition envelope.hpp:57
@ u
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20