Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
tape.hpp
Go to the documentation of this file.
1#pragma once
2
3// Tape character, standard tier — the CPU-friendly default.
4//
5// Mechanisms follow the published tape-modeling literature (Chowdhury 2019 for
6// the model family; Wallace 1951 for why high frequencies die with speed and
7// spacing; Uhlenbeck & Ornstein 1930 for the drift process), scoped to what an
8// ECHO unit needs rather than to a full machine emulation. The physical tier
9// (tape_physical.hpp) replaces this tier's saturation and loss stages with the
10// actual physics; this one keeps the same audible outcomes at a fraction of
11// the cost, and is what ships as the default realization.
12//
13// Two things carry the character:
14//
15// * The EQ-bracketed saturator. Record EQ boosts the highs INTO the
16// nonlinearity and playback EQ cuts them after, so saturation works the
17// high frequencies harder than the lows — the published record/playback-EQ
18// rationale, and the reason tape distortion sounds like tape rather than
19// like a clipper. The two shelves are exact inverses (see FirstOrderShelf),
20// so with drive at zero the pair is transparent and adds no residual tilt
21// for the loop to compound.
22// * Wow and flutter. Both are pitch instability, but at different rates and
23// from different causes, so they are modeled separately: wow (published
24// band 0.5–6 Hz) as a periodic component plus an Ornstein-Uhlenbeck drift,
25// because a pure sine reads as an effect and pure noise reads as broken;
26// flutter (published band 5–100 Hz) as a small harmonic stack, because the
27// rotational components that cause it are harmonically related.
28//
29// Modulation is strictly zero-mean and the delay time is added on top, so the
30// MEAN read position equals the requested delay: instability changes the pitch,
31// never the tempo.
32
35
36#include <algorithm>
37#include <array>
38#include <cmath>
39#include <cstdint>
40
42
45public:
46 void prepare(double fs) {
47 sample_rate_ = fs;
48 control_rate_ = fs / static_cast<double>(kControlInterval);
49 innovation_filter_.set_cutoff(kWowOuNoiseLpHz, control_rate_);
50 reset();
51 }
52
56 void configure(std::uint32_t seed, double flutter_phase_offset) noexcept {
57 rng_.reseed(seed);
58 flutter_phase_offset_ = flutter_phase_offset;
59 }
60
61 void reset() noexcept {
62 rng_.reset();
63 innovation_filter_.reset();
64 wow_phase_ = 0.0;
65 flutter_phase_ = 0.0;
66 drift_ = 0.0;
67 drift_previous_ = 0.0;
68 control_counter_ = 0;
69 }
70
73 void tick() noexcept {
74 wow_phase_ += kWowRateHz / sample_rate_;
75 if (wow_phase_ >= 1.0) wow_phase_ -= 1.0;
76 flutter_phase_ += kFlutterBaseHz / sample_rate_;
77 if (flutter_phase_ >= 1.0) flutter_phase_ -= 1.0;
78
79 if (--control_counter_ <= 0) {
80 control_counter_ = kControlInterval;
81 step_drift();
82 }
83 drift_interpolation_ =
84 1.0 - static_cast<double>(control_counter_) / static_cast<double>(kControlInterval);
85 }
86
88 double offset_ms(double wow_depth_ms, double flutter_depth_ms) const noexcept {
89 const double drift =
90 drift_previous_ + drift_interpolation_ * (drift_ - drift_previous_);
91 const double wow = wow_depth_ms * (std::cos(2.0 * kPi * wow_phase_) + drift);
92
93 double flutter = 0.0;
94 for (std::size_t k = 0; k < kFlutterAmplitudes.size(); ++k) {
95 const double harmonic = static_cast<double>(k + 1);
96 flutter += kFlutterAmplitudes[k] *
97 std::sin(2.0 * kPi * (harmonic * flutter_phase_ + kFlutterPhases[k] +
98 flutter_phase_offset_));
99 }
100 return wow + flutter_depth_ms * flutter;
101 }
102
103private:
109 void step_drift() noexcept {
110 const double dt = static_cast<double>(kControlInterval) / sample_rate_;
111 const double innovation = innovation_filter_.lowpass(rng_.gaussian());
112 drift_previous_ = drift_;
113 drift_ = flush_denormal(drift_ + kWowOuTheta * (0.0 - drift_) * dt +
114 kWowOuSigma * std::sqrt(dt) * innovation);
115 }
116
117 Xorshift32 rng_{kPrngSeed};
118 OnePole innovation_filter_;
119 double sample_rate_ = 48000.0;
120 double control_rate_ = 1500.0;
121 double wow_phase_ = 0.0;
122 double flutter_phase_ = 0.0;
123 double flutter_phase_offset_ = 0.0;
124 double drift_ = 0.0;
125 double drift_previous_ = 0.0;
126 double drift_interpolation_ = 0.0;
127 int control_counter_ = 0;
128};
129
133public:
134 void prepare(double fs) {
135 sample_rate_ = fs;
136 const double emphasis_gain = std::pow(10.0, kTapeEmphasisDb / 20.0);
137 record_eq_.set(kTapeEmphasisHz, emphasis_gain, fs);
138 playback_eq_.set_inverse(kTapeEmphasisHz, emphasis_gain, fs);
139 dc_blocker_.set_cutoff(kTapeDcBlockHz, fs);
140 update(0.0);
141 reset();
142 }
143
144 void reset() noexcept {
145 record_eq_.reset();
146 playback_eq_.reset();
147 loss_filter_.reset();
148 head_bump_.reset();
149 dc_blocker_.reset();
150 }
151
152 void update(double character_amount) noexcept {
153 drive_ = interpolate_knots(kTapeAxis, kTapeDrive, character_amount);
154 bias_ = interpolate_knots(kTapeAxis, kTapeBias, character_amount);
155 const double loss_hz = interpolate_knots(kTapeAxis, kTapeLossLpHz, character_amount);
156 const double bump_db = interpolate_knots(kTapeAxis, kTapeBumpDb, character_amount);
157 loss_filter_.set_cutoff(loss_hz, sample_rate_);
158 head_bump_.set_bell(kTapeBumpHz, kTapeBumpQ, bump_db, sample_rate_);
159
160 // Pre-computed denominator of the DC-compensated biased tanh.
161 const double tanh_bias = std::tanh(bias_);
162 compensation_ = drive_ * (1.0 - tanh_bias * tanh_bias);
163 tanh_bias_ = tanh_bias;
164 }
165
167 double pre_process(double x) noexcept {
168 return playback_eq_.process(saturate(record_eq_.process(x)));
169 }
170
172 double post_process(double x) noexcept {
173 const double lossy = head_bump_.process(loss_filter_.lowpass(x));
174 return dc_blocker_.highpass(lossy);
175 }
176
177private:
182 double saturate(double x) const noexcept {
183 if (compensation_ <= 1e-12) return x;
184 return (std::tanh(drive_ * x + bias_) - tanh_bias_) / compensation_;
185 }
186
187 FirstOrderShelf record_eq_;
188 FirstOrderShelf playback_eq_;
189 OnePole loss_filter_;
190 Svf2 head_bump_;
191 OnePole dc_blocker_;
192
193 double sample_rate_ = 48000.0;
194 double drive_ = 1.0;
195 double bias_ = 0.0;
196 double tanh_bias_ = 0.0;
197 double compensation_ = 1.0;
198};
199
200} // namespace pulp::signal::chardelay
Xorshift32 (Marsaglia, "Xorshift RNGs", J.
Definition rng.hpp:33
double lowpass(double x) noexcept
Definition primitives.hpp:257
Wow + flutter generator for one channel. Shared by both tape tiers.
Definition tape.hpp:44
void reset() noexcept
Definition tape.hpp:61
void tick() noexcept
Advance one sample.
Definition tape.hpp:73
void prepare(double fs)
Definition tape.hpp:46
void configure(std::uint32_t seed, double flutter_phase_offset) noexcept
flutter_phase_offset is in cycles; the right channel starts at a fixed nonzero fraction so the two ch...
Definition tape.hpp:56
double offset_ms(double wow_depth_ms, double flutter_depth_ms) const noexcept
Zero-mean read-position offset, in milliseconds.
Definition tape.hpp:88
Standard-tier tape coloration for one channel: the pre-line record EQ + saturation + playback EQ,...
Definition tape.hpp:132
void prepare(double fs)
Definition tape.hpp:134
void update(double character_amount) noexcept
Definition tape.hpp:152
double pre_process(double x) noexcept
Record EQ → saturation → playback EQ. Runs before the delay line.
Definition tape.hpp:167
void reset() noexcept
Definition tape.hpp:144
double post_process(double x) noexcept
Loss stand-in + head bump + DC block. Runs after the delay line.
Definition tape.hpp:172
double gaussian() noexcept
Standard normal via Box-Muller (Box & Muller 1958).
Definition primitives.hpp:98
Definition bbd.hpp:36
constexpr double kTapeEmphasisDb
Record/playback EQ bracketing the saturator: boost HF into saturation, cut it after,...
Definition tables.hpp:158
constexpr std::array< double, 4 > kTapeBias
Definition tables.hpp:141
constexpr std::array< double, 3 > kFlutterAmplitudes
Definition tables.hpp:182
double flush_denormal(double v) noexcept
Definition primitives.hpp:45
constexpr std::array< double, 4 > kTapeBumpDb
Definition tables.hpp:145
constexpr std::array< double, 4 > kTapeLossLpHz
Definition tables.hpp:144
constexpr double kPi
Definition primitives.hpp:38
constexpr int kControlInterval
Coefficient recomputation and stochastic-walk stepping cadence, in samples.
Definition tables.hpp:40
constexpr double kWowOuSigma
Definition tables.hpp:175
constexpr std::array< double, 3 > kFlutterPhases
Definition tables.hpp:183
constexpr double kTapeDcBlockHz
In-loop DC blocker.
Definition tables.hpp:168
constexpr double kWowOuNoiseLpHz
Definition tables.hpp:176
constexpr double kWowOuTheta
Definition tables.hpp:174
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 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 std::array< double, 4 > kTapeAxis
Definition tables.hpp:139
constexpr double kFlutterBaseHz
Flutter: published band 5–100 Hz.
Definition tables.hpp:181
constexpr double kWowRateHz
Wow: periodic + stochastic.
Definition tables.hpp:173
constexpr double kTapeBumpHz
Loss stand-in head bump (the physical tier replaces this with a speed-dependent center; see kHeadBump...
Definition tables.hpp:163
@ e
Definition formant_filter_bank.hpp:20