Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
tape_physical.hpp
Go to the documentation of this file.
1#pragma once
2
3// Physical tape tier — the standard tier's saturation and loss stages replaced
4// by the actual physics, plus the wear artifacts a used machine has.
5//
6// * Saturation → Jiles-Atherton magnetization (hysteresis.hpp).
7// * Loss stand-in → Wallace's playback-loss physics (Wallace 1951; physical
8// ranges from Bertram 1994), split across a fitted IIR cascade and a
9// minimum-phase FIR (tape_loss.hpp).
10// * Age → a wear axis: spacing grows, hiss rises, dropouts ("chew") appear,
11// and continuous degradation closes the bandwidth down.
12//
13// The loss stage is a cascade of two realizations — a fitted IIR for the smooth
14// spacing/thickness tilt and a minimum-phase FIR for the gap null. tape_loss.hpp
15// carries the reasoning and the normative crossfade rule; this file just wires
16// them into the channel.
17//
18// Why nothing here is designed on the audio thread: the age macro is a
19// per-sample parameter, and fitting a filter — a simplex search over 24
20// frequency points, or an FFT and a cepstral fold — is not something an audio
21// callback may do. So the age axis is fitted at kLossAgeKnots when the sample
22// rate is set, and the audio thread only interpolates four physical parameters
23// between neighbouring knots.
24
29
30#include <algorithm>
31#include <array>
32#include <cmath>
33#include <cstddef>
34#include <cstdint>
35#include <vector>
36
38
47inline double physical_tape_effective_feedback(double requested_feedback,
48 double age) noexcept {
49 const double feedback = std::clamp(requested_feedback, 0.0,
51 double loop_gain = interpolate_knots(kTapeAxis,
53 age);
54 if (feedback > 1.0) {
55 const double over_unity =
56 (feedback - 1.0) / (kSaturatedFeedbackMax - 1.0);
57 loop_gain += (1.0 - loop_gain) * over_unity;
58 }
59 return feedback * loop_gain;
60}
61
65class TapeChew {
66public:
67 void prepare(double fs) {
68 sample_rate_ = fs;
69 transition_samples_ = std::max(1, static_cast<int>(kChewTransitionS * fs));
70 lowpass_.set_cutoff(kChewLpHz, fs);
71 reset();
72 }
73
74 void reset() noexcept {
75 rng_.reset();
76 lowpass_.reset();
77 degraded_ = false;
78 envelope_ = 0.0;
79 remaining_ = draw_duration(kChewCleanScaleS);
80 state_index_ = 0;
81 }
82
83 void set_seed(std::uint32_t seed) noexcept { rng_.reseed(seed); }
84
87 std::size_t state_index() const noexcept { return state_index_; }
88
89 double process(double x, double depth) noexcept {
90 if (--remaining_ <= 0) {
91 degraded_ = !degraded_;
92 ++state_index_;
93 remaining_ = draw_duration(degraded_ ? kChewDegradedScaleS : kChewCleanScaleS);
94 }
95
96 const double target = degraded_ ? 1.0 : 0.0;
97 const double increment = 1.0 / static_cast<double>(transition_samples_);
98 envelope_ = std::clamp(envelope_ + (target > envelope_ ? increment : -increment), 0.0, 1.0);
99 if (depth <= 0.0 || envelope_ <= 0.0) {
100 lowpass_.lowpass(x); // keep the filter's state warm so re-entry is smooth
101 return x;
102 }
103
104 const double window = 0.5 * (1.0 - std::cos(kPi * envelope_));
105 const double magnitude = std::pow(std::abs(x), kChewPower);
106 const double shaped = lowpass_.lowpass((x < 0.0 ? -magnitude : magnitude));
107 return x + depth * window * (shaped - x);
108 }
109
110private:
111 int draw_duration(double scale_seconds) noexcept {
112 const double r = std::max(rng_.uniform(), 1e-6);
113 const double seconds = scale_seconds * std::pow(r, kChewVariance);
114 return std::max(1, static_cast<int>(seconds * sample_rate_));
115 }
116
117 Xorshift32 rng_{kPrngSeed};
118 OnePole lowpass_;
119 double sample_rate_ = 48000.0;
120 double envelope_ = 0.0;
121 int transition_samples_ = 1;
122 int remaining_ = 1;
123 bool degraded_ = false;
124 std::size_t state_index_ = 0;
125};
126
129public:
130 void prepare(double fs) {
131 sample_rate_ = fs;
132
133 const double emphasis_gain = std::pow(10.0, kTapeEmphasisDb / 20.0);
134 record_eq_.set(kTapeEmphasisHz, emphasis_gain, fs);
135 playback_eq_.set_inverse(kTapeEmphasisHz, emphasis_gain, fs);
136 dc_blocker_.set_cutoff(kTapeDcBlockHz, fs);
137 hiss_filter_.set_cutoff(kHissLpHz, fs);
138
139 hysteresis_.prepare(fs);
140 chew_.prepare(fs);
141
142 design_.prepare(fs, speed_ips_);
143 previous_design_ = design_;
144 const std::size_t taps = design_.gap_taps().size();
145 active_.prepare(taps);
146 previous_.prepare(taps);
147
148 crossfade_samples_ = std::max(1, static_cast<int>(kLossBankCrossfadeS * fs));
149 degrade_filter_.set_cutoff(20000.0, fs);
150
151 build_makeup_table();
152 update(0.0);
153 reset();
154 }
155
156 void reset() noexcept {
157 record_eq_.reset();
158 playback_eq_.reset();
159 dc_blocker_.reset();
160 hiss_filter_.reset();
161 degrade_filter_.reset();
162 head_bump_.reset();
163 hysteresis_.reset();
164 chew_.reset();
165 active_.reset();
166 previous_.reset();
167 hiss_rng_.reset();
168 degrade_rng_.reset();
169 crossfade_remaining_ = 0;
170 }
171
173 void recover_audio_fault() noexcept {
174 record_eq_.reset();
175 playback_eq_.reset();
176 dc_blocker_.reset();
177 hiss_filter_.reset();
178 degrade_filter_.reset();
179 head_bump_.reset();
180 hysteresis_.reset();
181 chew_.reset();
182 active_.discard_history();
183 previous_.discard_history();
184 hiss_rng_.reset();
185 degrade_rng_.reset();
186 crossfade_remaining_ = 0;
187 }
188
189 void set_seeds(std::uint32_t base) noexcept {
190 hiss_rng_.reseed(base ^ 0x9E3779B9u);
191 degrade_rng_.reseed(base ^ 0x85EBCA6Bu);
192 chew_.set_seed(base ^ 0xC2B2AE35u);
193 }
194
204 void set_speed_ips(double ips) {
205 const double clamped = std::clamp(ips, kTapeSpeedsIps.front(), kTapeSpeedsIps.back());
206 if (std::abs(clamped - speed_ips_) < 1e-9) return;
207 speed_ips_ = clamped;
208 // Callers legitimately configure the speed before the sample rate is
209 // known (the catalog node does exactly that). Until prepare() has run
210 // there is nothing designed to swap, so record the speed and let
211 // prepare() design against it.
212 if (design_.empty()) return;
213
214 previous_ = active_;
215 previous_design_ = design_;
216 design_.set_speed(speed_ips_);
217 active_.copy_history_from(previous_);
218 active_.reset_recursive();
219 active_.set_parameters(design_.parameters_at(age_), sample_rate_);
220 crossfade_remaining_ = crossfade_samples_;
221 }
222
223 double speed_ips() const noexcept { return speed_ips_; }
224 const std::vector<double>& gap_coefficients() const noexcept { return design_.gap_taps(); }
225 TapeLossIirParams loss_parameters() const noexcept { return active_.parameters(); }
226
228 void update(double age) noexcept {
229 age_ = std::clamp(age, 0.0, 1.0);
230
231 const double drive = interpolate_knots(kTapeAxis, kTapeDrive, age_);
232 const double bias = interpolate_knots(kTapeAxis, kTapeBias, age_);
233 hysteresis_.set_character(drive, bias);
234 makeup_ = makeup_for(age_);
235
236 hiss_gain_ = std::pow(10.0, interpolate_knots(kAgeAxis, kAgeHissDbfs, age_) / 20.0);
237 chew_depth_ = interpolate_knots(kAgeAxis, kAgeChewDepth, age_);
238 degrade_ = interpolate_knots(kAgeAxis, kAgeDegrade, age_);
239
240 const double bump_db = interpolate_knots(kTapeAxis, kTapeBumpDb, age_);
241 head_bump_.set_bell(interpolate_knots(kHeadBumpIps, kHeadBumpHz, speed_ips_), kTapeBumpQ,
242 bump_db, sample_rate_);
243
244 // Recomputing the IIR from continuously interpolated PHYSICAL
245 // parameters is ordinary filter modulation, not a coefficient blend
246 // between two designs — see tape_loss.hpp. During a speed crossfade the
247 // outgoing stage is deliberately frozen at the parameters it had.
248 active_.set_parameters(design_.parameters_at(age_), sample_rate_);
249
250 // Continuous wear: bandwidth falls exponentially toward the floor, and
251 // the level dips slightly — a tired machine is darker AND quieter.
252 const double nyquist_guard = 0.45 * sample_rate_;
253 const double top = std::min(20000.0, nyquist_guard);
254 const double degrade_hz = top * std::pow(kDegradeMinLpHz / top, degrade_);
255 degrade_filter_.set_cutoff(degrade_hz, sample_rate_);
256 degrade_gain_ = std::pow(10.0, kDegradeGainDipDb * degrade_ / 20.0);
257 }
258
260 double pre_process(double x) noexcept {
261 const double emphasized = record_eq_.process(x);
262 const double magnetized = hysteresis_.process(emphasized);
263 return playback_eq_.process(magnetized * makeup_);
264 }
265
267 double post_process(double x) noexcept {
268 active_.push(x);
269 double lossy = active_.process(design_);
270 if (crossfade_remaining_ > 0) {
271 previous_.push(x);
272 const double outgoing = previous_.process(previous_design_);
273 const double t = 1.0 - static_cast<double>(crossfade_remaining_) /
274 static_cast<double>(crossfade_samples_);
275 const double weight = 0.5 * (1.0 - std::cos(kPi * t));
276 lossy = outgoing + weight * (lossy - outgoing);
277 --crossfade_remaining_;
278 }
279
280 double y = head_bump_.process(lossy);
281 y += hiss_filter_.lowpass(hiss_rng_.bipolar()) * hiss_gain_;
282 y = chew_.process(y, chew_depth_);
283 if (degrade_ > 0.0) {
284 y = degrade_filter_.lowpass(y) * degrade_gain_;
285 y += degrade_rng_.bipolar() * 1e-4 * degrade_;
286 }
287 return dc_blocker_.highpass(y);
288 }
289
294
299 double in_loop_delay_samples() const noexcept {
300 return static_cast<double>(oversampler_latency_samples()) +
301 design_.gap_group_delay_samples() +
302 active_.dc_group_delay_seconds() * sample_rate_;
303 }
304
305 std::size_t chew_state_index() const noexcept { return chew_.state_index(); }
306
307private:
308 static constexpr std::size_t kMakeupKnots = 11;
309
316 void build_makeup_table() {
317 for (std::size_t knot = 0; knot < kMakeupKnots; ++knot) {
318 const double age =
319 static_cast<double>(knot) / static_cast<double>(kMakeupKnots - 1u);
320 const double drive = interpolate_knots(kTapeAxis, kTapeDrive, age);
321 const double bias = interpolate_knots(kTapeAxis, kTapeBias, age);
322
324 probe.prepare(sample_rate_);
325 probe.set_character(drive, bias);
326
327 constexpr double kProbeAmplitude = 0.02;
328 constexpr double kProbeHz = 1000.0;
329 const auto period_samples =
330 static_cast<int>(std::llround(sample_rate_ / kProbeHz));
331 double input_energy = 0.0;
332 double output_energy = 0.0;
333 for (int n = 0; n < period_samples * 8; ++n) {
334 const double phase = 2.0 * kPi * static_cast<double>(n) / static_cast<double>(period_samples);
335 const double in = kProbeAmplitude * std::sin(phase);
336 const double out = probe.process(in);
337 if (n >= period_samples * 4) {
338 input_energy += in * in;
339 output_energy += out * out;
340 }
341 }
342 const double ratio = (output_energy > 1e-30)
343 ? std::sqrt(input_energy / output_energy)
344 : 1.0;
345 makeup_table_[knot] = std::clamp(ratio, 0.05, 50.0);
346 }
347 }
348
349 double makeup_for(double age) const noexcept {
350 const double position =
351 std::clamp(age, 0.0, 1.0) * static_cast<double>(kMakeupKnots - 1u);
352 auto lower = static_cast<std::size_t>(position);
353 if (lower >= kMakeupKnots - 1u) lower = kMakeupKnots - 2u;
354 const double t = position - static_cast<double>(lower);
355 return makeup_table_[lower] + t * (makeup_table_[lower + 1u] - makeup_table_[lower]);
356 }
357
358 FirstOrderShelf record_eq_;
359 FirstOrderShelf playback_eq_;
360 OnePole dc_blocker_;
361 OnePole hiss_filter_;
362 OnePole degrade_filter_;
363 Svf2 head_bump_;
364 OversampledHysteresis8x hysteresis_;
365 TapeChew chew_;
366 TapeLossDesign design_;
367 TapeLossDesign previous_design_;
368 TapeLossStage active_;
369 TapeLossStage previous_;
370 Xorshift32 hiss_rng_{kPrngSeed};
371 Xorshift32 degrade_rng_{kPrngSeed};
372
373 std::array<double, kMakeupKnots> makeup_table_{};
374
375 double sample_rate_ = 48000.0;
376 double speed_ips_ = 7.5;
377 double age_ = 0.0;
378 double makeup_ = 1.0;
379 double hiss_gain_ = 0.0;
380 double chew_depth_ = 0.0;
381 double degrade_ = 0.0;
382 double degrade_gain_ = 1.0;
383 int crossfade_samples_ = 1;
384 int crossfade_remaining_ = 0;
385};
386
387} // namespace pulp::signal::chardelay
Xorshift32 (Marsaglia, "Xorshift RNGs", J.
Definition rng.hpp:33
double process(double input)
Definition hysteresis.hpp:410
void prepare(double host_rate_hz)
Definition hysteresis.hpp:396
void set_character(double drive, double bias) noexcept
Definition hysteresis.hpp:406
static int latency_samples() noexcept
Definition hysteresis.hpp:392
Intermittent dropout.
Definition tape_physical.hpp:65
void prepare(double fs)
Definition tape_physical.hpp:67
std::size_t state_index() const noexcept
State-transition count since reset — the acceptance suite's determinism check compares this sequence ...
Definition tape_physical.hpp:87
void set_seed(std::uint32_t seed) noexcept
Definition tape_physical.hpp:83
double process(double x, double depth) noexcept
Definition tape_physical.hpp:89
void reset() noexcept
Definition tape_physical.hpp:74
The physical tier for one channel.
Definition tape_physical.hpp:128
double speed_ips() const noexcept
Definition tape_physical.hpp:223
void set_speed_ips(double ips)
Control-thread only: redesigns both filters and starts the crossfade.
Definition tape_physical.hpp:204
void recover_audio_fault() noexcept
Fresh-state recovery without clearing dynamically sized FIR storage.
Definition tape_physical.hpp:173
void prepare(double fs)
Definition tape_physical.hpp:130
static int oversampler_latency_samples() noexcept
Group delay the oversampling wrap adds inside the loop, in host samples.
Definition tape_physical.hpp:291
double pre_process(double x) noexcept
Record EQ → oversampled hysteresis → makeup → playback EQ.
Definition tape_physical.hpp:260
void reset() noexcept
Definition tape_physical.hpp:156
void update(double age) noexcept
Control-rate update. In this tier character_amount IS the age axis.
Definition tape_physical.hpp:228
void set_seeds(std::uint32_t base) noexcept
Definition tape_physical.hpp:189
const std::vector< double > & gap_coefficients() const noexcept
Definition tape_physical.hpp:224
double in_loop_delay_samples() const noexcept
Total in-loop delay this tier contributes ahead of the delay line's read: the oversampler's group del...
Definition tape_physical.hpp:299
std::size_t chew_state_index() const noexcept
Definition tape_physical.hpp:305
TapeLossIirParams loss_parameters() const noexcept
Definition tape_physical.hpp:225
double post_process(double x) noexcept
Wallace loss → head bump → hiss → chew → degrade → DC block.
Definition tape_physical.hpp:267
double uniform() noexcept
Uniform in [0, 1].
Definition primitives.hpp:87
@ OnePole
Definition sample_heritage_schema.hpp:50
Definition bbd.hpp:36
constexpr std::array< double, 4 > kTapePhysicalFeedbackCompensation
Physical-loop compensation at each age knot.
Definition tables.hpp:152
OversampledHysteresis< HysteresisOversampler8x > OversampledHysteresis8x
Definition hysteresis.hpp:421
constexpr double kTapeEmphasisDb
Record/playback EQ bracketing the saturator: boost HF into saturation, cut it after,...
Definition tables.hpp:158
constexpr std::array< double, 5 > kHeadBumpIps
Head-bump center against speed.
Definition tables.hpp:513
constexpr std::array< double, 4 > kTapeBias
Definition tables.hpp:141
constexpr double kHissLpHz
Hiss: white noise through a 1-pole lowpass, injected AFTER the delay line so it accumulates per repea...
Definition tables.hpp:526
constexpr std::array< double, 4 > kTapeBumpDb
Definition tables.hpp:145
constexpr std::array< double, 5 > kTapeSpeedsIps
The IEC/NAB standard tape-speed set, in inches per second (published).
Definition tables.hpp:509
constexpr double kChewVariance
Definition tables.hpp:532
constexpr double kPi
Definition primitives.hpp:38
constexpr std::array< double, 3 > kAgeDegrade
Definition tables.hpp:522
constexpr std::array< double, 3 > kAgeAxis
Age macro.
Definition tables.hpp:518
constexpr double kChewCleanScaleS
Chew — an intermittent dropout modeled as a two-state machine.
Definition tables.hpp:530
constexpr std::array< double, 3 > kAgeChewDepth
Definition tables.hpp:521
constexpr double kTapeDcBlockHz
In-loop DC blocker.
Definition tables.hpp:168
constexpr double kTapeBumpQ
Definition tables.hpp:164
constexpr std::uint32_t kPrngSeed
PRNG seed.
Definition tables.hpp:92
constexpr double kTapeEmphasisHz
Definition tables.hpp:159
constexpr double kLossBankCrossfadeS
NOTE: there are no age knots and no interpolation along the age axis.
Definition tables.hpp:500
constexpr std::array< double, 4 > kTapeDrive
Definition tables.hpp:140
double interpolate_knots(const std::array< double, N > &axis, const std::array< double, N > &values, double c) noexcept
Definition primitives.hpp:125
constexpr double kChewLpHz
Definition tables.hpp:535
constexpr double kSaturatedFeedbackMax
Feedback ceiling for the saturating characters.
Definition tables.hpp:54
constexpr std::array< double, 3 > kAgeHissDbfs
Definition tables.hpp:520
constexpr double kDegradeGainDipDb
Definition tables.hpp:540
constexpr double kDegradeMinLpHz
Degrade — continuous wear.
Definition tables.hpp:539
constexpr std::array< double, 4 > kTapeAxis
Definition tables.hpp:139
constexpr double kChewPower
Definition tables.hpp:534
double physical_tape_effective_feedback(double requested_feedback, double age) noexcept
Apply the physical tier's measured whole-loop stability calibration.
Definition tape_physical.hpp:47
constexpr double kChewDegradedScaleS
Definition tables.hpp:531
constexpr std::array< double, 5 > kHeadBumpHz
Definition tables.hpp:514
constexpr double kChewTransitionS
Definition tables.hpp:533
@ feedback
Definition comb_filter.hpp:18
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
@ e
Definition formant_filter_bank.hpp:20
@ t
Definition input_events.hpp:41
The realized cascade's parameters in hertz, poles and shelves from both shapes concatenated.
Definition tape_loss.hpp:143