Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
tape_machine_components.hpp
Go to the documentation of this file.
1#pragma once
2
5
13#include <pulp/signal/units.hpp>
14#include <algorithm>
15#include <array>
16#include <cmath>
17#include <complex>
18#include <cstddef>
19#include <cstdint>
20#include <vector>
21
22namespace pulp::signal {
23
25enum class TapeCurve : std::uint8_t {
30};
31
38
41namespace tape {
42
46inline constexpr std::array<double, 5> kSpeedsIps = {1.875, 3.75, 7.5, 15.0, 30.0};
47
49inline constexpr double kMetresPerInch = 0.0254;
50
55inline constexpr double kEqReferenceHz = 1000.0;
56
61 double bass_s = 0.0;
62 double treble_s = 0.0;
63
64 bool has_bass_shelf() const noexcept { return bass_s > 0.0; }
65};
66
74inline EqTimeConstants eq_time_constants(TapeCurve curve, double speed_ips) noexcept {
75 switch (curve) {
76 case TapeCurve::cassette_type1: return {3180e-6, 120e-6};
77 case TapeCurve::cassette_type2: return {3180e-6, 70e-6};
79 // CCIR adopted 70 µs (from 100 µs) in 1966; 35 µs at 15 ips and
80 // 17.5 µs at 30 ips per the A800 technical data. No bass shelf at
81 // any reel speed — the structural difference from NAB.
82 if (speed_ips >= 22.5) return {0.0, 17.5e-6};
83 if (speed_ips >= 11.25) return {0.0, 35e-6};
84 return {0.0, 70e-6};
85 case TapeCurve::nab:
86 default:
87 // The professional convention extends NAB to 30 ips by dropping the
88 // bass shelf and halving the treble constant again.
89 if (speed_ips >= 22.5) return {0.0, 17.5e-6};
90 return {3180e-6, 50e-6};
91 }
92}
93
95inline double eq_corner_hz(double tau_s) noexcept {
96 return tau_s > 0.0 ? 1.0 / (2.0 * chardelay::kPi * tau_s) : 0.0;
97}
98
106inline double eq_record_response_db(const EqTimeConstants& tc, double hz) noexcept {
107 auto magnitude = [&tc](double f) {
108 const double w = 2.0 * chardelay::kPi * f;
109 const double numerator = tc.has_bass_shelf() ? std::hypot(1.0, w * tc.bass_s) : 1.0;
110 return numerator / std::hypot(1.0, w * tc.treble_s);
111 };
112 return 20.0 * std::log10(magnitude(hz) / magnitude(kEqReferenceHz));
113}
114
117inline double eq_playback_response_db(const EqTimeConstants& tc, double hz) noexcept {
118 return -eq_record_response_db(tc, hz);
119}
120
121// ── Reproduce and record head gaps ────────────────────────────────────────
122
125 double reproduce_m = 3.5e-6;
126 double record_m = 10e-6;
127};
128
140inline HeadGapGeometry head_gap_geometry(double speed_ips) noexcept {
141 if (speed_ips >= 11.25) return {3.5e-6, 10e-6}; // studio 15 / 30 ips
142 if (speed_ips >= 2.8125) return {5e-6, 12e-6}; // consumer reel 7.5 / 3.75 ips
143 return {0.8e-6, 3e-6}; // cassette 1.875 ips
144}
145
147inline double speed_m_per_s(double speed_ips) noexcept {
148 return speed_ips * kMetresPerInch;
149}
150
157inline double gap_loss_magnitude(double hz, double gap_m, double speed_ips) noexcept {
158 const double x = chardelay::kPi * gap_m * hz / speed_m_per_s(speed_ips);
159 if (x < 1e-9) return 1.0;
160 return std::abs(std::sin(x) / x);
161}
162
163inline double gap_loss_db(double hz, double gap_m, double speed_ips) noexcept {
164 return 20.0 * std::log10(std::max(gap_loss_magnitude(hz, gap_m, speed_ips), 1e-12));
165}
166
168inline double gap_null_hz(double gap_m, double speed_ips) noexcept {
169 return speed_m_per_s(speed_ips) / std::max(gap_m, 1e-12);
170}
171
172// ── The age axis this module adds on top of the reused one ────────────────
173
179inline constexpr std::array<double, 3> kAgeAxis = {0.0, 0.5, 1.0};
180
183inline constexpr std::array<double, 3> kAgePrintThroughDb = {-80.0, -55.0, -38.0};
184
188inline constexpr std::array<double, 3> kAgeBiasDriftUnits = {0.0, 0.15, 0.4};
189
190inline double age_print_through_db(double age01) noexcept {
192 std::clamp(age01, 0.0, 1.0));
193}
194
195inline double age_bias_drift(double age01) noexcept {
197 std::clamp(age01, 0.0, 1.0));
198}
199
212inline double first_order_shelf_db(double corner_hz, double gain_linear, double hz,
213 double sample_rate) noexcept {
214 const double fc = std::clamp(corner_hz, 0.1, 0.49 * sample_rate);
215 const double t = std::tan(chardelay::kPi * fc / sample_rate);
216 const double g = t / (1.0 + t);
217 const double theta = 2.0 * chardelay::kPi * hz / sample_rate;
218 const std::complex<double> z_inv(std::cos(theta), -std::sin(theta));
219 const std::complex<double> lp = g * (1.0 + z_inv) / (1.0 - (1.0 - 2.0 * g) * z_inv);
220 const std::complex<double> shelf = gain_linear + (1.0 - gain_linear) * lp;
221 return 20.0 * std::log10(std::max(std::abs(shelf), 1e-12));
222}
223
224// ── Archetype presets ─────────────────────────────────────────────────────
225
229 std::array<double, 3> legal_speeds_ips{};
231 double default_speed_ips = 15.0;
233 double crosstalk_db = -40.0;
234 bool companding = false;
235 double age01 = 0.0;
236};
237
258 switch (archetype) {
260 p.legal_speeds_ips = {7.5, 15.0, 30.0};
261 p.legal_speed_count = 3;
262 p.default_speed_ips = 15.0;
263 p.default_curve = TapeCurve::nab;
264 p.crosstalk_db = -40.0;
265 p.companding = false;
266 p.age01 = 0.05;
267 return p;
269 p.legal_speeds_ips = {1.875, 0.0, 0.0};
270 p.legal_speed_count = 1;
271 p.default_speed_ips = 1.875;
272 p.default_curve = TapeCurve::cassette_type1;
273 p.crosstalk_db = -30.0;
274 // Companding on by default: dbx-equipped decks were a documented
275 // cassette-era convention, and the reel machines of the other two
276 // classes predate consumer companding entirely.
277 p.companding = true;
278 p.age01 = 0.3;
279 return p;
281 default:
282 p.legal_speeds_ips = {7.5, 15.0, 0.0};
283 p.legal_speed_count = 2;
284 p.default_speed_ips = 15.0;
285 p.default_curve = TapeCurve::nab;
286 p.crosstalk_db = -38.0;
287 p.companding = false;
288 p.age01 = 0.2;
289 return p;
290 }
291}
292
295inline double snap_speed_ips(TapeArchetype archetype, double requested_ips) noexcept {
296 const ArchetypePreset preset = archetype_preset(archetype);
297 double best = preset.legal_speeds_ips[0];
298 double best_distance = std::abs(std::log(std::max(requested_ips, 1e-6) / best));
299 for (int i = 1; i < preset.legal_speed_count; ++i) {
300 const double candidate = preset.legal_speeds_ips[static_cast<std::size_t>(i)];
301 // Nearest in RATIO, not in difference: the ladder is geometric, so
302 // 5 ips is equidistant from 3.75 and 7.5 the way an ear hears it, and a
303 // linear metric would bias every midpoint upward.
304 const double distance = std::abs(std::log(std::max(requested_ips, 1e-6) / candidate));
305 if (distance < best_distance) {
306 best = candidate;
307 best_distance = distance;
308 }
309 }
310 return best;
311}
312
313} // namespace tape
314
342public:
364 static constexpr double kInversePoleDamping = 0.90;
365
368 static constexpr double kPrewarpCeiling = 0.49;
369
370 void set(const tape::EqTimeConstants& tc, double sample_rate, bool inverse) noexcept {
371 const double fs = sample_rate > 0.0 ? sample_rate : 48000.0;
372 const double treble_hz =
373 std::min(tape::eq_corner_hz(tc.treble_s), kPrewarpCeiling * fs);
374 const double u_treble = std::tan(chardelay::kPi * treble_hz / fs);
375
376 double b0 = 0.0, b1 = 0.0;
377 const double a1 = (u_treble - 1.0) / (u_treble + 1.0);
378 if (tc.has_bass_shelf()) {
379 const double bass_hz =
380 std::min(tape::eq_corner_hz(tc.bass_s), kPrewarpCeiling * fs);
381 const double u_bass = std::tan(chardelay::kPi * bass_hz / fs);
382 const double scale = (u_treble / u_bass) / (u_treble + 1.0);
383 b0 = scale * (u_bass + 1.0);
384 b1 = scale * (u_bass - 1.0);
385 } else {
386 b0 = u_treble / (u_treble + 1.0);
387 b1 = b0;
388 }
389
390 normalize(b0, b1, a1, fs);
391
392 if (inverse) {
393 const double inv = 1.0 / b0;
394 const double damping = tc.has_bass_shelf() ? 1.0 : kInversePoleDamping;
395 const double new_b0 = inv;
396 const double new_b1 = a1 * inv;
397 const double new_a1 = damping * b1 * inv;
398 b0 = new_b0;
399 b1 = new_b1;
400 normalize(b0, b1, new_a1, fs);
401 b0_ = b0;
402 b1_ = b1;
403 a1_ = new_a1;
404 } else {
405 b0_ = b0;
406 b1_ = b1;
407 a1_ = a1;
408 }
409 }
410
411 void reset() noexcept {
412 x1_ = 0.0;
413 y1_ = 0.0;
414 }
415
416 double process(double x) noexcept {
417 const double y = b0_ * x + b1_ * x1_ - a1_ * y1_;
418 x1_ = x;
419 y1_ = snap_to_zero(y);
420 return y;
421 }
422
425 double response_db(double hz, double sample_rate) const noexcept {
426 return 20.0 * std::log10(std::max(magnitude(b0_, b1_, a1_, hz, sample_rate), 1e-12));
427 }
428
429private:
430 static double magnitude(double b0, double b1, double a1, double hz,
431 double sample_rate) noexcept {
432 const double theta = 2.0 * chardelay::kPi * hz / sample_rate;
433 const double c = std::cos(theta), s = std::sin(theta);
434 // e^{-jθ} = c − js.
435 const double num = std::hypot(b0 + b1 * c, -b1 * s);
436 const double den = std::hypot(1.0 + a1 * c, -a1 * s);
437 return den > 1e-30 ? num / den : 0.0;
438 }
439
440 static void normalize(double& b0, double& b1, double a1, double fs) noexcept {
441 const double gain = magnitude(b0, b1, a1, tape::kEqReferenceHz, fs);
442 if (gain > 1e-30) {
443 b0 /= gain;
444 b1 /= gain;
445 }
446 }
447
448 double b0_ = 1.0, b1_ = 0.0, a1_ = 0.0;
449 double x1_ = 0.0, y1_ = 0.0;
450};
451
468template <typename SampleType = float>
470public:
474 static constexpr double kAttackMs = 1.0;
475
477 static constexpr double kReleaseMs = 100.0;
478
482 static constexpr double kRefLevelDbfs = -20.0;
483
484 void prepare(double sample_rate) {
485 for (auto* follower : {&encode_env_, &decode_env_}) {
486 follower->prepare(static_cast<SampleType>(sample_rate));
487 follower->set_attack_ms(static_cast<SampleType>(kAttackMs));
488 follower->set_release_ms(static_cast<SampleType>(kReleaseMs));
489 }
491 reset();
492 }
493
494 void reset() noexcept {
495 encode_env_.reset();
496 decode_env_.reset();
497 }
498
500 double encode(double x) noexcept { return x * gain(encode_env_, x, -0.5); }
501
503 double decode(double x) noexcept { return x * gain(decode_env_, x, +1.0); }
504
505private:
506 double gain(BallisticsFilterT<SampleType>& follower, double x, double slope) noexcept {
507 const auto envelope =
508 static_cast<double>(follower.process(static_cast<SampleType>(x)));
509 const double level_db = units::linear_to_db(envelope / reference_);
510 return units::db_to_linear(slope * level_db);
511 }
512
513 BallisticsFilterT<SampleType> encode_env_{};
514 BallisticsFilterT<SampleType> decode_env_{};
515 double reference_ = 0.1;
516};
517
518} // namespace pulp::signal
Peak/RMS envelope follower with independent attack and release curves.
Envelope follower with configurable attack and release times.
Definition ballistics_filter.hpp:31
The 2:1 / 1:2 linear-dB compander pair.
Definition tape_machine_components.hpp:469
static constexpr double kReleaseMs
Definition tape_machine_components.hpp:477
static constexpr double kRefLevelDbfs
Definition tape_machine_components.hpp:482
static constexpr double kAttackMs
Definition tape_machine_components.hpp:474
double decode(double x) noexcept
Playback side: 1:2 expansion, exact inverse slope.
Definition tape_machine_components.hpp:503
double encode(double x) noexcept
Record side: 2:1 compression toward the reference.
Definition tape_machine_components.hpp:500
void reset() noexcept
Definition tape_machine_components.hpp:494
void prepare(double sample_rate)
Definition tape_machine_components.hpp:484
One first-order EQ section — a (curve, speed) record network, or its exact inverse.
Definition tape_machine_components.hpp:341
void set(const tape::EqTimeConstants &tc, double sample_rate, bool inverse) noexcept
Definition tape_machine_components.hpp:370
static constexpr double kInversePoleDamping
How far inside the unit circle the inverse's pole is pulled when the forward section's numerator is (...
Definition tape_machine_components.hpp:364
void reset() noexcept
Definition tape_machine_components.hpp:411
double response_db(double hz, double sample_rate) const noexcept
Magnitude response in dB.
Definition tape_machine_components.hpp:425
static constexpr double kPrewarpCeiling
Highest corner the prewarp will place, as a fraction of the sample rate.
Definition tape_machine_components.hpp:368
double process(double x) noexcept
Definition tape_machine_components.hpp:416
constexpr double kPi
Definition primitives.hpp:38
double interpolate_knots(const std::array< double, N > &axis, const std::array< double, N > &values, double c) noexcept
Definition primitives.hpp:125
Published tables and closed forms, free of any instance so a test can assert the SHIPPED constant rat...
Definition tape_machine_components.hpp:41
EqTimeConstants eq_time_constants(TapeCurve curve, double speed_ips) noexcept
The published time-constant table, selected by (curve, speed).
Definition tape_machine_components.hpp:74
HeadGapGeometry head_gap_geometry(double speed_ips) noexcept
Gap widths by speed class.
Definition tape_machine_components.hpp:140
constexpr double kMetresPerInch
Inches to metres. Exact by definition of the international inch.
Definition tape_machine_components.hpp:49
constexpr double kEqReferenceHz
The frequency every EQ curve in this module is normalized to be unity at.
Definition tape_machine_components.hpp:55
double age_bias_drift(double age01) noexcept
Definition tape_machine_components.hpp:195
constexpr std::array< double, 3 > kAgeAxis
The reused kAgeAxis table drives spacing, hiss, chew and degrade, and kTapeAxis drives wow/flutter de...
Definition tape_machine_components.hpp:179
constexpr std::array< double, 3 > kAgePrintThroughDb
Storage print grows with age.
Definition tape_machine_components.hpp:183
constexpr std::array< double, 5 > kSpeedsIps
The halving speed ladder, in inches per second.
Definition tape_machine_components.hpp:46
double eq_record_response_db(const EqTimeConstants &tc, double hz) noexcept
The analog record (pre-emphasis) prototype H(s) = (1 + s·t₁)/(1 + s·t₂), in dB, normalized to 0 dB at...
Definition tape_machine_components.hpp:106
double first_order_shelf_db(double corner_hz, double gain_linear, double hz, double sample_rate) noexcept
Magnitude, in dB, of the REALIZED chardelay::FirstOrderShelf — the TPT one-pole shelf this module use...
Definition tape_machine_components.hpp:212
double eq_playback_response_db(const EqTimeConstants &tc, double hz) noexcept
The playback (de-emphasis) network is the exact reciprocal of the record network, so its response is ...
Definition tape_machine_components.hpp:117
double snap_speed_ips(TapeArchetype archetype, double requested_ips) noexcept
Nearest legal speed for an archetype.
Definition tape_machine_components.hpp:295
double gap_loss_magnitude(double hz, double gap_m, double speed_ips) noexcept
Westmijze's reproduce gap loss: |sin(π·g/λ) / (π·g/λ)|, λ = v/f.
Definition tape_machine_components.hpp:157
double gap_null_hz(double gap_m, double speed_ips) noexcept
First extinction null, where λ = g: f = v / g.
Definition tape_machine_components.hpp:168
double speed_m_per_s(double speed_ips) noexcept
Tape speed in metres per second.
Definition tape_machine_components.hpp:147
double gap_loss_db(double hz, double gap_m, double speed_ips) noexcept
Definition tape_machine_components.hpp:163
ArchetypePreset archetype_preset(TapeArchetype archetype) noexcept
Baked defaults per §5 of the source spec.
Definition tape_machine_components.hpp:256
double eq_corner_hz(double tau_s) noexcept
Corner frequency of a time constant, in Hz. f = 1 / (2π·t).
Definition tape_machine_components.hpp:95
double age_print_through_db(double age01) noexcept
Definition tape_machine_components.hpp:190
constexpr std::array< double, 3 > kAgeBiasDriftUnits
An aging bias supply drifts toward OVER-bias — the documented failure direction, since oscillator com...
Definition tape_machine_components.hpp:188
T linear_to_db(T linear)
Linear gain → amplitude dB, floored at kDbFloorAmplitude so silence maps to a finite −200 dB rather t...
Definition units.hpp:79
T db_to_linear(T db)
Amplitude dB → linear gain. 0 dB → 1, −6.0206 dB → 0.5.
Definition units.hpp:71
Definition additive_bank.hpp:218
TapeCurve
Which recording standard's record/playback network pair is in force.
Definition tape_machine_components.hpp:25
@ iec_ccir
IEC/CCIR reel. Treble turnover only — no bass shelf.
Definition tape_machine_components.hpp:27
@ cassette_type1
Compact cassette, ferric ("Normal"). 3180 / 120 µs.
Definition tape_machine_components.hpp:28
@ cassette_type2
Compact cassette, chrome. 3180 / 70 µs.
Definition tape_machine_components.hpp:29
@ nab
NAB reel. Bass turnover AND treble turnover.
Definition tape_machine_components.hpp:26
@ c
Definition spectrum_trace.hpp:35
T snap_to_zero(T x)
Returns 0 if |x| < snap_threshold, else x.
Definition denormal.hpp:45
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20
TapeArchetype
Which machine's full preset a fresh instance starts from.
Definition tape_machine_components.hpp:33
@ cassette_deck
Fixed 1.875 ips, Type I / Type II EQ switch.
Definition tape_machine_components.hpp:36
@ ampex_350_440
Documented NAB-native two-speed studio recorder lineage.
Definition tape_machine_components.hpp:34
@ studer_a800
Documented NAB and CCIR-switchable multitrack.
Definition tape_machine_components.hpp:35
The complete baked preset for one machine.
Definition tape_machine_components.hpp:228
std::array< double, 3 > legal_speeds_ips
Definition tape_machine_components.hpp:229
TapeCurve default_curve
Definition tape_machine_components.hpp:232
double crosstalk_db
Definition tape_machine_components.hpp:233
double age01
Definition tape_machine_components.hpp:235
double default_speed_ips
Definition tape_machine_components.hpp:231
bool companding
Definition tape_machine_components.hpp:234
int legal_speed_count
Definition tape_machine_components.hpp:230
One standard's pair of time constants, in SECONDS.
Definition tape_machine_components.hpp:60
double bass_s
Definition tape_machine_components.hpp:61
double treble_s
Definition tape_machine_components.hpp:62
bool has_bass_shelf() const noexcept
Definition tape_machine_components.hpp:64
Reproduce and record gap widths for one speed class, in METRES.
Definition tape_machine_components.hpp:124
double record_m
Definition tape_machine_components.hpp:126
double reproduce_m
Definition tape_machine_components.hpp:125
The real-unit vocabulary shared by every DSP block in the catalog.