Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
vco.hpp
Go to the documentation of this file.
1#pragma once
2
67
68#include "blep.hpp"
69#include "phase.hpp"
70#include "va.hpp"
71
73#include <algorithm>
74
75#include <array>
76#include <cmath>
77#include <cstddef>
78#include <cstdint>
79#include <limits>
80#include <numbers>
81
82namespace pulp::signal::osc {
83
91struct VcoTuning {
93 double reference_hz = 261.625565; // C4.
95 double volts_per_octave = 1.0;
97 double tune_offset_cents = 0.0;
101 double scale_error = 1.0;
105 double hf_compression = 0.0;
106 double hf_knee_octaves = 3.0;
107
109 double frequency_hz(double control_volts) const noexcept {
110 double octaves = (control_volts / volts_per_octave) * scale_error
111 + tune_offset_cents / 1200.0;
112 if (hf_compression > 0.0 && octaves > hf_knee_octaves)
113 octaves -= hf_compression * (octaves - hf_knee_octaves);
114 return reference_hz * std::exp2(octaves);
115 }
116
118 double phase_increment(double control_volts, double sample_rate) const noexcept {
119 return frequency_hz(control_volts) / sample_rate;
120 }
121};
122
132 double amount = 0.0;
134 double drive = 1.0;
137 double asymmetry = 0.0;
138};
139
145public:
149 VcoOscillator() noexcept { update_drift_coeffs(); }
150
151 void prepare(double sample_rate) noexcept {
152 sample_rate_ = sample_rate > 0.0 ? sample_rate : sample_rate_;
153 update_ac_pole();
154 update_drift_coeffs();
155 }
156
157 void set_shape(VaShape shape) noexcept { core_.set_shape(shape); }
158 VaShape shape() const noexcept { return core_.shape(); }
159
160 void set_pulse_width(double width) noexcept { core_.set_pulse_width(width); }
161 double pulse_width() const noexcept { return core_.pulse_width(); }
162
163 // ── Integrator-leak bow ────────────────────────────────────────────────
170 void set_bow(double curvature) noexcept { bow_ = curvature > 0.0 ? curvature : 0.0; }
171 double bow() const noexcept { return bow_; }
172
173 // ── Per-shape waveshaper ───────────────────────────────────────────────
174 void set_waveshaper(VaShape shape, const WaveshaperParams& params) noexcept {
175 // `amount` is a blend, so it is clamped to [0, 1] like every other
176 // unit-range control here: outside that range it extrapolates the blend
177 // (amount 2 reaches 3.0 on a full-scale shape) and breaks the [-1, 1]
178 // output contract the boundedness gate relies on.
179 WaveshaperParams p = params;
180 p.amount = std::clamp(p.amount, 0.0, 1.0);
181 shapers_[static_cast<std::size_t>(shape)] = p;
182 }
184 return shapers_[static_cast<std::size_t>(shape)];
185 }
186
187 // ── Level vs pitch ─────────────────────────────────────────────────────
193 void set_level_tilt(double db_per_octave) noexcept { level_tilt_db_per_octave_ = db_per_octave; }
194 double level_tilt() const noexcept { return level_tilt_db_per_octave_; }
195
201 void set_core_reset_time(double seconds) noexcept {
202 core_reset_seconds_ = seconds > 0.0 ? seconds : 0.0;
203 }
204 double core_reset_time() const noexcept { return core_reset_seconds_; }
205
206 // ── AC coupling (output DC blocker) ────────────────────────────────────
211 void set_ac_coupling(double corner_hz) noexcept {
212 ac_corner_hz_ = corner_hz > 0.0 ? corner_hz : 0.0;
213 update_ac_pole();
214 }
215 double ac_coupling() const noexcept { return ac_corner_hz_; }
219 double ac_pole() const noexcept { return ac_enabled_ ? ac_pole_ : 1.0; }
220
221 // ── Drift / jitter (seeded pitch noise) ─────────────────────────────────
227 void set_drift_depth(double depth_cents) noexcept { drift_depth_ = depth_cents; }
228 double drift_depth() const noexcept { return drift_depth_; }
229
233 void set_drift_rate_hz(double corner_hz) noexcept {
234 drift_rate_hz_ = corner_hz > 0.0 ? corner_hz : 0.0;
235 update_drift_coeffs();
236 }
237 double drift_rate_hz() const noexcept { return drift_rate_hz_; }
238
242 void set_jitter_depth(double depth_cents) noexcept { jitter_depth_ = depth_cents; }
243 double jitter_depth() const noexcept { return jitter_depth_; }
244
249 void set_seed(std::uint64_t seed) noexcept {
250 seed_ = seed;
251 seed_streams();
252 }
253 std::uint64_t seed() const noexcept { return seed_; }
254
257 static constexpr double kDefaultDriftRateHz = 0.4;
258
262 void reset(double phase = 0.0) noexcept {
263 core_.reset(phase);
264 dc_blocker_.reset();
265 drift_state_ = 0.0;
266 seed_streams();
267 }
268
269 double phase() const noexcept { return core_.phase(); }
270
276 double next(double increment) noexcept {
277 const double modulated = increment * pitch_noise_factor();
278 double v = core_.next(modulated);
279 v = apply_bow(v);
280 v = apply_waveshaper(v);
281 v *= level_for(modulated);
282 return ac_enabled_ ? dc_blocker_.process(v) : v;
283 }
284
286 static constexpr double kLevelReferenceHz = 1000.0;
287
288private:
291 double apply_bow(double v) const noexcept {
292 if (bow_ == 0.0 || core_.shape() != VaShape::saw) return v;
293 // Saw value in [-1, 1] ↔ phase in [0, 1); clamp the tiny polyBLEP
294 // overshoot so the charge curve stays monotone and bounded.
295 double p = 0.5 * (v + 1.0);
296 p = p < 0.0 ? 0.0 : (p > 1.0 ? 1.0 : p);
297 // Normalized capacitor charge (1 - e^{-k p}) / (1 - e^{-k}); k → 0 is the
298 // linear ramp, guarded above by the exact bypass.
299 const double charged = (1.0 - std::exp(-bow_ * p)) / (1.0 - std::exp(-bow_));
300 return 2.0 * charged - 1.0;
301 }
302
304 double apply_waveshaper(double v) const noexcept {
305 const WaveshaperParams& w = shapers_[static_cast<std::size_t>(core_.shape())];
306 if (w.amount == 0.0) return v;
307 // A tanh saturator biased by `asymmetry`, mapped so the full-scale ends
308 // ±1 land exactly on ±1 and the curve is DC-free (its midpoint removed).
309 // Anchoring on the two ends rather than a single-point norm is what keeps
310 // the shaped signal inside [-1, 1] for any bias — a monotone curve pinned
311 // at both endpoints cannot overshoot between them.
312 const double lo = std::tanh(-w.drive + w.asymmetry);
313 const double hi = std::tanh(w.drive + w.asymmetry);
314 const double mid = 0.5 * (hi + lo); // the DC the bias introduces.
315 const double half = 0.5 * (hi - lo); // > 0 for drive > 0.
316 const double shaped =
317 half != 0.0 ? (std::tanh(w.drive * v + w.asymmetry) - mid) / half : v;
318 return (1.0 - w.amount) * v + w.amount * shaped;
319 }
320
324 double level_for(double increment) const noexcept {
325 if (level_tilt_db_per_octave_ == 0.0 && core_reset_seconds_ == 0.0) return 1.0;
326 const double frequency = std::fabs(increment) * sample_rate_;
327 if (!(frequency > 0.0)) return 1.0;
328
329 double gain = 1.0;
330 if (level_tilt_db_per_octave_ != 0.0) {
331 const double octaves = std::log2(frequency / kLevelReferenceHz);
332 gain *= std::pow(10.0, -level_tilt_db_per_octave_ * octaves / 20.0);
333 }
334 if (core_reset_seconds_ != 0.0) {
335 const double eaten = core_reset_seconds_ * frequency; // fraction of the period.
336 gain *= eaten < 1.0 ? (1.0 - eaten) : 0.0;
337 }
338 return gain;
339 }
340
351 double pitch_noise_factor() noexcept {
352 if (drift_depth_ == 0.0 && jitter_depth_ == 0.0) return 1.0;
353
354 double cents = 0.0;
355 if (drift_depth_ != 0.0) {
356 drift_state_ = drift_pole_ * drift_state_ + drift_norm_ * drift_rng_.next_gaussian();
357 cents += drift_depth_ * drift_state_;
358 }
359 if (jitter_depth_ != 0.0)
360 cents += jitter_depth_ * jitter_rng_.next_gaussian();
361
362 return std::exp2(cents / 1200.0);
363 }
364
370 struct NoiseSource {
371 std::uint64_t state = 0;
372
373 void seed(std::uint64_t s) noexcept { state = s; }
374
375 std::uint64_t next_u64() noexcept {
376 std::uint64_t z = (state += 0x9E3779B97F4A7C15ull);
377 z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
378 z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
379 return z ^ (z >> 31);
380 }
381
384 double next_uniform() noexcept {
385 const double u =
386 static_cast<double>(next_u64() >> 11) * (1.0 / 9007199254740992.0);
387 return u > 0.0 ? u : std::numeric_limits<double>::min();
388 }
389
390 double next_gaussian() noexcept {
391 const double u1 = next_uniform();
392 const double u2 = next_uniform();
393 return std::sqrt(-2.0 * std::log(u1)) *
394 std::cos(2.0 * std::numbers::pi * u2);
395 }
396 };
397
400 void seed_streams() noexcept {
401 drift_rng_.seed(seed_);
402 jitter_rng_.seed(seed_ ^ 0xD1B54A32D192ED03ull);
403 }
404
410 void update_drift_coeffs() noexcept {
411 // Lower rate is a slower wander, so the pole rises toward 1 as the rate
412 // falls; the limit as rate -> 0 is a frozen walk (pole 1), which the
413 // unit-variance gain below turns into no wander at all. A non-positive
414 // rate takes that limit rather than the opposite extreme: mapping it to
415 // pole 0 would make drift full-depth per-sample white noise -- the
416 // FASTEST wander -- so a caller passing 0 to mean "off" or "very slow"
417 // would get audible hiss-like FM instead.
418 drift_pole_ = drift_rate_hz_ > 0.0
419 ? std::exp(-2.0 * std::numbers::pi * drift_rate_hz_ / sample_rate_)
420 : 1.0;
421 drift_norm_ = std::sqrt(1.0 - drift_pole_ * drift_pole_);
422 }
423
424 void update_ac_pole() noexcept {
425 ac_enabled_ = ac_corner_hz_ > 0.0;
426 if (ac_enabled_) {
427 ac_pole_ = std::exp(-2.0 * std::numbers::pi * ac_corner_hz_ / sample_rate_);
428 dc_blocker_.set_pole(ac_pole_);
429 } else {
430 ac_pole_ = 1.0;
431 }
432 }
433
434 VaOscillator core_;
435 double sample_rate_ = 48000.0;
436
437 double bow_ = 0.0;
438 std::array<WaveshaperParams, 4> shapers_{};
439
440 double level_tilt_db_per_octave_ = 0.0;
441 double core_reset_seconds_ = 0.0;
442
443 double ac_corner_hz_ = 0.0;
444 double ac_pole_ = 1.0;
445 bool ac_enabled_ = false;
446 pulp::signal::DcBlocker<double> dc_blocker_;
447
448 double drift_depth_ = 0.0;
449 double jitter_depth_ = 0.0;
450 double drift_rate_hz_ = kDefaultDriftRateHz;
451 double drift_pole_ = 0.0;
452 double drift_norm_ = 1.0;
453 double drift_state_ = 0.0;
454 std::uint64_t seed_ = 0x0123456789ABCDEFull;
455 NoiseSource drift_rng_{seed_};
456 NoiseSource jitter_rng_{seed_ ^ 0xD1B54A32D192ED03ull};
457};
458
459} // namespace pulp::signal::osc
Bandlimited-step (BLEP) and bandlimited-ramp (BLAMP) correction kernels.
VaShape shape() const noexcept
Definition va.hpp:177
void set_ac_coupling(double corner_hz) noexcept
Output DC-blocking corner, in Hz.
Definition vco.hpp:211
WaveshaperParams waveshaper(VaShape shape) const noexcept
Definition vco.hpp:183
double drift_depth() const noexcept
Definition vco.hpp:228
VaShape shape() const noexcept
Definition vco.hpp:158
void set_drift_rate_hz(double corner_hz) noexcept
Corner of the drift coloring filter, in Hz.
Definition vco.hpp:233
void set_shape(VaShape shape) noexcept
Definition vco.hpp:157
double drift_rate_hz() const noexcept
Definition vco.hpp:237
std::uint64_t seed() const noexcept
Definition vco.hpp:253
double pulse_width() const noexcept
Definition vco.hpp:161
void set_drift_depth(double depth_cents) noexcept
RMS of the slow drift wander, in cents.
Definition vco.hpp:227
void set_level_tilt(double db_per_octave) noexcept
Output level tilt, in dB per octave relative to kLevelReferenceHz.
Definition vco.hpp:193
VcoOscillator() noexcept
Seed the drift filter coefficients for the default sample rate so a default-constructed oscillator is...
Definition vco.hpp:149
void prepare(double sample_rate) noexcept
Definition vco.hpp:151
void set_pulse_width(double width) noexcept
Definition vco.hpp:160
void set_core_reset_time(double seconds) noexcept
Finite core-reset time, in seconds.
Definition vco.hpp:201
double phase() const noexcept
Definition vco.hpp:269
double bow() const noexcept
Definition vco.hpp:171
static constexpr double kLevelReferenceHz
Reference level frequency for the tilt curve.
Definition vco.hpp:286
double core_reset_time() const noexcept
Definition vco.hpp:204
double ac_pole() const noexcept
The DC blocker's realized pole for the current corner and sample rate; 1.0 when the stage is bypassed...
Definition vco.hpp:219
double jitter_depth() const noexcept
Definition vco.hpp:243
void set_waveshaper(VaShape shape, const WaveshaperParams &params) noexcept
Definition vco.hpp:174
double level_tilt() const noexcept
Definition vco.hpp:194
static constexpr double kDefaultDriftRateHz
Default drift corner (Hz): a wander whose correlation time is a few hundred milliseconds — slow relat...
Definition vco.hpp:257
double next(double increment) noexcept
Generate one sample and advance by increment cycles.
Definition vco.hpp:276
void reset(double phase=0.0) noexcept
Reset the phase (and the AC-coupling and correction state) to a known point, and restart the noise st...
Definition vco.hpp:262
void set_seed(std::uint64_t seed) noexcept
Seed for both noise streams.
Definition vco.hpp:249
void set_bow(double curvature) noexcept
Curvature of the saw/ramp bow.
Definition vco.hpp:170
double ac_coupling() const noexcept
Definition vco.hpp:215
void set_jitter_depth(double depth_cents) noexcept
RMS of the fast, near-white cycle-to-cycle jitter, in cents per sample.
Definition vco.hpp:242
Definition blep.hpp:72
VaShape
The static shapes.
Definition va.hpp:113
@ saw
Value step of -2 at the wrap.
Definition va.hpp:115
@ u
Definition formant_filter_bank.hpp:20
@ s
Definition input_events.hpp:41
@ z
Definition input_events.hpp:42
The analog exponential pitch converter, as a pure control-voltage → frequency map.
Definition vco.hpp:91
double volts_per_octave
Volts spanning one octave (the converter's nominal sensitivity).
Definition vco.hpp:95
double tune_offset_cents
Per-oscillator calibration offset, in cents. Shifts every note equally.
Definition vco.hpp:97
double reference_hz
Frequency produced at 0 V with no calibration offset.
Definition vco.hpp:93
double hf_compression
High-end tracking compression: above hf_knee_octaves the converter tracks flat by this many octaves p...
Definition vco.hpp:105
double scale_error
V/oct scaling error: the realized octave span as a multiple of the ideal.
Definition vco.hpp:101
double hf_knee_octaves
Definition vco.hpp:106
double phase_increment(double control_volts, double sample_rate) const noexcept
Per-sample phase increment (cycles) for a control voltage at sample_rate.
Definition vco.hpp:118
double frequency_hz(double control_volts) const noexcept
Frequency for a control voltage, in Hz. Pure; allocates nothing.
Definition vco.hpp:109
A per-shape lumped memoryless nonlinearity — the "analog character" the output stage imprints.
Definition vco.hpp:130
double asymmetry
Even-harmonic bias.
Definition vco.hpp:137
double amount
Blend from clean (0) to fully shaped (1). Exactly 0 is a bit-exact bypass.
Definition vco.hpp:132
double drive
Pre-gain into the saturator. Higher drive = harder curve = more harmonics.
Definition vco.hpp:134
Virtual-analog oscillator: the classic static shapes, bandlimited by composing PhaseAccumulator (the ...