Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
vintage.hpp
Go to the documentation of this file.
1#pragma once
2
3// Vintage Digital character — a modeled converter loop at a reduced internal
4// rate, entirely INSIDE the feedback path.
5//
6// Honesty note: no open paper documents the early-80s rack delays this evokes.
7// What IS published is every mechanism used here — band-limited clocked
8// conversion, pre/de-emphasis around a quantizer, and low-bit PCM artifacts
9// (Välimäki et al. 2008 for the general antiquing model; Lipshitz, Wannamaker &
10// Vanderkooy 1992 for why the dither is TPDF at ±1 LSB; IEC 60908 for the 50 µs
11// emphasis time constant). Every NUMBER in the character's table is a design
12// parameter, and this comment is here so nobody later mistakes them for
13// measurements.
14//
15// The defining behavior is that the whole converter sits in the loop: every
16// repeat is re-converted, so quantization grit and band-limiting compound —
17// repeat 1 has been through one converter, repeat 10 through ten. A converter
18// modeled once at the input would sound like a bitcrusher in front of a clean
19// delay, which is a different and much less interesting effect.
20//
21// Because the internal grid is a real clock, a delay-time change slides the
22// read position across it and the buckets repitch — the same glide the BBD
23// character gets, for the same physical reason.
24//
25// latency_samples() stays 0: this chain is all IIR plus a sample-and-hold. Its
26// group delay is phase, not a bufferable constant, and there is nothing here to
27// report to a host as latency.
28
32
33#include <algorithm>
34#include <cmath>
35#include <cstddef>
36#include <cstdint>
37
39
41public:
42 void prepare(double fs) {
43 sample_rate_ = fs;
44 // The internal grid can never exceed the host rate; on a low-rate host
45 // the top table knots simply collapse onto it.
46 const double highest_internal = std::min(kVintageInternalRateHz.front(), fs);
47 const auto capacity = static_cast<std::size_t>(
48 std::ceil(kMaxAddressableDelayMs * 0.001 * highest_internal) + 8.0);
49 line_.prepare(capacity);
50 update(0.0);
51 reset();
52 }
53
54 void reset() noexcept {
55 line_.reset();
56 anti_alias_.reset();
57 reconstruction_.reset();
58 pre_emphasis_.reset();
59 de_emphasis_.reset();
60 phase_ = 0.0;
61 held_ = 0.0;
62 rng_.reset();
63 }
64
65 void set_seed(std::uint32_t seed) noexcept { rng_.reseed(seed); }
66
70 void update(double character_amount) noexcept {
71 internal_rate_ =
72 std::min(interpolate_knots(kVintageAxis, kVintageInternalRateHz, character_amount),
73 sample_rate_);
74 const double bits =
76 quantization_step_ = std::pow(2.0, -(bits - 1.0));
77
78 const double band_edge = kVintageAntiAliasFraction * internal_rate_;
79 anti_alias_.set_cutoff(band_edge, sample_rate_);
80 reconstruction_.set_cutoff(band_edge, sample_rate_);
81
82 const double emphasis_gain = std::pow(10.0, kVintageEmphasisDb / 20.0);
83 pre_emphasis_.set(kVintageEmphasisHz, emphasis_gain, sample_rate_);
84 de_emphasis_.set_inverse(kVintageEmphasisHz, emphasis_gain, sample_rate_);
85 }
86
87 double internal_rate_hz() const noexcept { return internal_rate_; }
88 double band_edge_hz() const noexcept { return kVintageAntiAliasFraction * internal_rate_; }
89
90 double process(double x, double delay_seconds) noexcept {
91 const double band_limited = pre_emphasis_.process(anti_alias_.process(x));
92
93 // "ADC": clock the host-rate signal onto the internal grid and quantize
94 // whatever lands on a tick. This is where the character actually lives
95 // — reduced bandwidth and word length.
96 phase_ += internal_rate_ / sample_rate_;
97 while (phase_ >= 1.0) {
98 phase_ -= 1.0;
99 line_.push(quantize(band_limited));
100 }
101
102 // "DAC": read at the fractional grid position that corresponds to NOW.
103 //
104 // `phase_` is how far the host clock has advanced past the last internal
105 // tick, so subtracting it holds the delay CONSTANT in host time. Taking
106 // the read inside the tick loop instead — a plain zero-order hold —
107 // snaps the effective delay to the internal grid, and because the two
108 // clocks are not locked that snap walks between 0 and one internal
109 // sample. It is heard as a warble with the modulation depth at zero,
110 // and the feedback loop compounds it on every repeat.
111 // Lagrange, not linear. A 2-point interpolator's gain depends on the
112 // fractional read position, so as `phase_` walks between ticks the
113 // output amplitude ripples — 0.5% at the 32 kHz internal rate and far
114 // worse at 8 kHz, where the band edge sits at 0.45x the internal rate
115 // and linear interpolation is nowhere near flat. That ripple is heard
116 // as modulation with the depth control at zero.
117 held_ = line_.read(std::max(1.0, delay_seconds * internal_rate_ - phase_));
118
119 // ...then the inverse emphasis and the matching reconstruction filter.
120 return reconstruction_.process(de_emphasis_.process(held_));
121 }
122
123private:
129 double quantize(double x) noexcept {
130 const double dither =
131 tpdf_centered_sum(rng_.uniform(), rng_.uniform()) * quantization_step_;
132 const double q = std::round((x + dither) / quantization_step_) * quantization_step_;
133 return std::clamp(q, -1.0, 1.0 - quantization_step_);
134 }
135
137 Butterworth6Lowpass anti_alias_;
138 Butterworth6Lowpass reconstruction_;
139 FirstOrderShelf pre_emphasis_;
140 FirstOrderShelf de_emphasis_;
141 Xorshift32 rng_{kPrngSeed};
142
143 double sample_rate_ = 48000.0;
144 double internal_rate_ = 32000.0;
145 double quantization_step_ = 1.0 / 8192.0;
146 double phase_ = 0.0;
147 double held_ = 0.0;
148};
149
150} // namespace pulp::signal::chardelay
Xorshift32 (Marsaglia, "Xorshift RNGs", J.
Definition rng.hpp:33
double process(double x, double delay_seconds) noexcept
Definition vintage.hpp:90
void update(double character_amount) noexcept
Control-rate update: pick the "range switch" position (internal rate and word length) for this charac...
Definition vintage.hpp:70
void prepare(double fs)
Definition vintage.hpp:42
double internal_rate_hz() const noexcept
Definition vintage.hpp:87
double band_edge_hz() const noexcept
Definition vintage.hpp:88
void reset() noexcept
Definition vintage.hpp:54
void set_seed(std::uint32_t seed) noexcept
Definition vintage.hpp:65
double uniform() noexcept
Uniform in [0, 1].
Definition primitives.hpp:87
Deterministic TPDF dither and bounded error-feedback quantization.
Definition bbd.hpp:36
constexpr double kVintageEmphasisDb
Definition tables.hpp:135
constexpr double kVintageEmphasisHz
3183 Hz is the 50 µs time constant — the published CD/FM-era emphasis standard (IEC 60908),...
Definition tables.hpp:134
constexpr std::array< double, 4 > kVintageInternalRateHz
Definition tables.hpp:124
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 double kVintageAntiAliasFraction
Anti-alias / reconstruction cutoff as a fraction of the internal rate.
Definition tables.hpp:129
constexpr std::array< double, 4 > kVintageAxis
Definition tables.hpp:123
constexpr std::array< double, 4 > kVintageBits
Definition tables.hpp:126
constexpr double kMaxAddressableDelayMs
Definition tables.hpp:35
FractionalDelayLineT< float > FractionalDelayLine
Definition fractional_delay.hpp:610
constexpr SampleType tpdf_centered_sum(SampleType first, SampleType second) noexcept
Definition dither.hpp:37