Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
modulation.hpp
Go to the documentation of this file.
1#pragma once
2
3// Motion primitives for the FDN tank: a deterministic PRNG, a mean-reverting
4// random walk, a phase oscillator, and the per-channel delay-modulation bank
5// that layers the last two.
6//
7// Sixteen lines each doing something slightly different is what keeps a
8// 16-channel tank from sounding like one metallic tube. Sine alone is periodic
9// (it beats against itself); a walk alone wanders sameily. Layering them breaks
10// both, and every source is seeded from a fixed constant so a render is
11// bit-reproducible across runs and machines.
12
14
15#include <array>
16#include <cmath>
17#include <cstdint>
18
19namespace pulp::signal::fdn {
20
21// Deterministic 32-bit xorshift. Seeded, never from a clock or random_device:
22// two renders of the same material must be bit-identical.
24public:
25 XorShift32() = default;
26 explicit XorShift32(std::uint32_t seed) { reset(seed); }
27
28 void reset(std::uint32_t seed) { state_ = seed ? seed : 0x9E3779B9u; }
29
30 std::uint32_t next() {
31 std::uint32_t x = state_;
32 x ^= x << 13;
33 x ^= x >> 17;
34 x ^= x << 5;
35 state_ = x;
36 return x;
37 }
38
39 // Uniform in [0, 1).
40 double next_unit() { return static_cast<double>(next() >> 8) * (1.0 / 16777216.0); }
41
42 // Uniform in [-1, 1).
43 double next_bipolar() { return next_unit() * 2.0 - 1.0; }
44
45private:
46 std::uint32_t state_ = 0x9E3779B9u;
47};
48
49// Mean-reverting (Ornstein-Uhlenbeck-style) random walk, stepped at the control
50// rate. `reversion` pulls the value back toward zero every step, which is what
51// keeps a long render from drifting off and parking against a limit — a plain
52// random walk has no such bound and would eventually pin the delay modulation.
54public:
55 void reset() { value_ = 0.0; }
56
57 // One control-rate step. Returns the new value, bounded in [-1, 1].
58 double step(XorShift32& rng, double reversion, double step_size) {
59 value_ += step_size * rng.next_bipolar() - reversion * value_;
60 if (value_ > 1.0) value_ = 1.0;
61 if (value_ < -1.0) value_ = -1.0;
62 return value_;
63 }
64
65 double value() const { return value_; }
66
67private:
68 double value_ = 0.0;
69};
70
71// Unit-amplitude sine from a wrapped phase accumulator. Phase math is double so
72// a 60 s render at 96 kHz does not accumulate a perceptible frequency error.
73class PhaseOsc {
74public:
75 void reset(double phase = 0.0) { phase_ = phase; }
76
77 void set_rate(double hz, double sample_rate) {
78 increment_ = (sample_rate > 0.0) ? hz / sample_rate : 0.0;
79 }
80
81 double tick() {
82 phase_ += increment_;
83 if (phase_ >= 1.0) phase_ -= std::floor(phase_);
84 return std::sin(phase_ * 6.283185307179586);
85 }
86
87 double phase() const { return phase_; }
88
89private:
90 double phase_ = 0.0;
91 double increment_ = 0.0;
92};
93
94// A bank of N unit sines advanced in lockstep by rotation: one complex
95// multiply per oscillator per sample instead of a sin() call. The exact phase
96// is carried separately and the rotation re-derived from it every
97// kOscReanchorSamples steps, so rounding cannot accumulate; the re-anchor
98// points depend only on how many samples have been rendered, never on how
99// they were split into blocks, which keeps renders block-partition exact.
100//
101// Each oscillator matches PhaseOsc: a tick advances the phase, then reports
102// sin (and cos) of the new phase.
103template <int N> class QuadratureBank {
104 public:
105 // Restart the shared clock. Follow with reset(i, phase) for every
106 // oscillator.
107 void restart() {
108 count_ = 0;
109 }
110
111 // Place oscillator i at `phase` (in cycles) without ticking it.
112 void reset(int i, double phase) {
113 const auto u = static_cast<std::size_t>(i);
114 phase_[u] = wrap(phase - static_cast<double>(count_) * increment_[u]);
115 derive(u, wrap(phase));
116 }
117
118 // Change oscillator i's rate, keeping its current phase.
119 void set_rate(int i, double hz, double sample_rate) {
120 const auto u = static_cast<std::size_t>(i);
121 const double current = position(i);
122 increment_[u] = (sample_rate > 0.0) ? hz / sample_rate : 0.0;
123 phase_[u] = wrap(current - static_cast<double>(count_) * increment_[u]);
124 derive(u, current);
125 }
126
127 // Advance every oscillator one sample.
128 void tick() {
129 if (++count_ >= kOscReanchorSamples) {
130 for (std::size_t u = 0; u < static_cast<std::size_t>(N); ++u) {
131 phase_[u] = wrap(phase_[u] + static_cast<double>(count_) * increment_[u]);
132 derive(u, phase_[u]);
133 }
134 count_ = 0;
135 return;
136 }
137 for (std::size_t u = 0; u < static_cast<std::size_t>(N); ++u) {
138 const double s = sin_[u] * step_cos_[u] + cos_[u] * step_sin_[u];
139 const double c = cos_[u] * step_cos_[u] - sin_[u] * step_sin_[u];
140 sin_[u] = s;
141 cos_[u] = c;
142 }
143 }
144
145 double sin(int i) const {
146 return sin_[static_cast<std::size_t>(i)];
147 }
148 double cos(int i) const {
149 return cos_[static_cast<std::size_t>(i)];
150 }
151 const double* sin_data() const {
152 return sin_.data();
153 }
154
155 // Current phase in cycles, [0, 1).
156 double position(int i) const {
157 const auto u = static_cast<std::size_t>(i);
158 return wrap(phase_[u] + static_cast<double>(count_) * increment_[u]);
159 }
160
161 private:
162 static constexpr double kTwoPi = 6.283185307179586;
163
164 static double wrap(double phase) {
165 return phase - std::floor(phase);
166 }
167
168 void derive(std::size_t u, double position) {
169 sin_[u] = std::sin(position * kTwoPi);
170 cos_[u] = std::cos(position * kTwoPi);
171 step_sin_[u] = std::sin(increment_[u] * kTwoPi);
172 step_cos_[u] = std::cos(increment_[u] * kTwoPi);
173 }
174
175 // Phase at the start of the shared clock, so phase + count * increment is
176 // the current phase of every oscillator.
177 std::array<double, N> phase_{};
178 std::array<double, N> increment_{};
179 std::array<double, N> sin_{};
180 std::array<double, N> cos_{};
181 std::array<double, N> step_sin_{};
182 std::array<double, N> step_cos_{};
183 int count_ = 0;
184};
185
186// How much the modulation depth shrinks as the decay lengthens. Constant-depth
187// modulation on a very long tail accumulates audible pitch drift; scaling depth
188// down with T60 keeps long tails still while short tails stay lush.
189inline double mod_depth_shrink(double decay_seconds) {
190 if (decay_seconds <= kModShrinkFullSeconds) return 1.0;
191 if (decay_seconds >= kModShrinkMinSeconds) return kModShrinkFloor;
192 const double t = (decay_seconds - kModShrinkFullSeconds) /
194 return 1.0 + t * (kModShrinkFloor - 1.0);
195}
196
197// Per-channel delay modulation: sine + mean-reverting walk, mixed by the
198// kModSineMix / kModWalkMix pair. One instance drives all kNumChannels lines in
199// lockstep; the rates are spread across the channels so no two lines beat
200// together.
202public:
203 // Seeds are fixed per channel; `reset()` restores them, so a reset render
204 // reproduces the previous one exactly.
205 void reset() {
206 osc_.restart();
207 for (int i = 0; i < kNumChannels; ++i) {
208 rng_[i].reset(kSeedBase + static_cast<std::uint32_t>(i) * kSeedStride);
209 walk_[i].reset();
210 // Spread the starting phases evenly so the lines never start in
211 // lockstep (a 16-line unison sweep is exactly the artifact the
212 // per-line rate spread exists to avoid).
213 osc_.reset(i, static_cast<double>(i) / static_cast<double>(kNumChannels));
214 }
215 }
216
217 void configure(double tank_rate) {
218 for (int i = 0; i < kNumChannels; ++i) {
219 const double frac = (kNumChannels > 1)
220 ? static_cast<double>(i) /
221 static_cast<double>(kNumChannels - 1)
222 : 0.0;
223 osc_.set_rate(i, kModRateMinHz + frac * (kModRateMaxHz - kModRateMinHz), tank_rate);
224 }
225 }
226
227 // Control-rate update of the walk sources.
228 void step_walks() {
229 for (int i = 0; i < kNumChannels; ++i)
230 walk_[i].step(rng_[i], kWalkReversion, kWalkStep);
231 }
232
233 // Advance every channel one sample and write each channel's modulation
234 // signal, in [-1, 1], to out[0 .. kNumChannels).
235 void tick(double* out) {
236 osc_.tick();
237 const double* s = osc_.sin_data();
238 for (int i = 0; i < kNumChannels; ++i)
239 out[i] = kModSineMix * s[i] + kModWalkMix * walk_[i].value();
240 }
241
242private:
243 static constexpr std::uint32_t kSeedBase = 0x1234567u;
244 static constexpr std::uint32_t kSeedStride = 0x9E3779B9u;
245
246 std::array<XorShift32, kNumChannels> rng_{};
247 std::array<MeanRevertingWalk, kNumChannels> walk_{};
248 QuadratureBank<kNumChannels> osc_{};
249};
250
251} // namespace pulp::signal::fdn
Definition modulation.hpp:201
void configure(double tank_rate)
Definition modulation.hpp:217
void reset()
Definition modulation.hpp:205
void tick(double *out)
Definition modulation.hpp:235
void step_walks()
Definition modulation.hpp:228
Definition modulation.hpp:53
void reset()
Definition modulation.hpp:55
double value() const
Definition modulation.hpp:65
double step(XorShift32 &rng, double reversion, double step_size)
Definition modulation.hpp:58
Definition modulation.hpp:73
double phase() const
Definition modulation.hpp:87
double tick()
Definition modulation.hpp:81
void set_rate(double hz, double sample_rate)
Definition modulation.hpp:77
void reset(double phase=0.0)
Definition modulation.hpp:75
Definition modulation.hpp:103
const double * sin_data() const
Definition modulation.hpp:151
void restart()
Definition modulation.hpp:107
double position(int i) const
Definition modulation.hpp:156
void tick()
Definition modulation.hpp:128
double sin(int i) const
Definition modulation.hpp:145
double cos(int i) const
Definition modulation.hpp:148
void set_rate(int i, double hz, double sample_rate)
Definition modulation.hpp:119
void reset(int i, double phase)
Definition modulation.hpp:112
Definition modulation.hpp:23
XorShift32(std::uint32_t seed)
Definition modulation.hpp:26
double next_unit()
Definition modulation.hpp:40
double next_bipolar()
Definition modulation.hpp:43
void reset(std::uint32_t seed)
Definition modulation.hpp:28
std::uint32_t next()
Definition modulation.hpp:30
Definition config.hpp:15
double mod_depth_shrink(double decay_seconds)
Definition modulation.hpp:189
constexpr double kWalkStep
Definition config.hpp:93
constexpr double kWalkReversion
Definition config.hpp:92
constexpr double kModRateMinHz
Definition config.hpp:79
constexpr double kModWalkMix
Definition config.hpp:78
constexpr int kNumChannels
Definition config.hpp:18
@ tank_rate
Definition config.hpp:226
constexpr double kModRateMaxHz
Definition config.hpp:80
constexpr int kOscReanchorSamples
Definition config.hpp:202
constexpr double kModSineMix
Definition config.hpp:77
constexpr double kModShrinkMinSeconds
Definition config.hpp:88
constexpr double kModShrinkFloor
Definition config.hpp:89
constexpr double kModShrinkFullSeconds
Definition config.hpp:87
@ c
Definition spectrum_trace.hpp:35
@ u
Definition formant_filter_bank.hpp:20
@ i
Definition formant_filter_bank.hpp:20