Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
analog_vcf.hpp
Go to the documentation of this file.
1#pragma once
2
3// Measured voicing layer for the shared four-pole OTA cascade engine.
4//
5// WHY THIS EXISTS
6// The panel controls on classic polysynth and monosynth filters are not generic
7// Hz/Q controls. Their cutoff laws, resonance tapers, loop ceilings, passband
8// compensation and saturation rails are part of the sound. AnalogVcfT keeps
9// those measured laws together while OtaCascadeFilterT owns the reusable DSP
10// topology. This boundary also keeps host/catalog code out of the signal layer:
11// Forge and other hosts expose knob-domain controls through a separate Pulp
12// CustomNodeType adapter.
13//
14// Cutoff tables interpolate in log-frequency space. Resonance, compensation,
15// headroom, cross-modulation, makeup and drift tables interpolate linearly in
16// their measured knob domains. Cutoff modulation first moves corner frequency
17// in octaves, then inverts the table so cutoff-dependent character follows the
18// equivalent panel position rather than remaining frozen at the unmodulated
19// knob.
20//
21// CALIBRATION PROVENANCE, AND WHY THE ROWS ARE READ DIFFERENTLY
22// The tables here are the measured data and are authoritative. The *acceptance
23// criteria* that once accompanied them were not: they were written by
24// summarizing a calibration pipeline at a single point in time while the
25// pipeline was still moving, and the prose was never revised as the tables and
26// laws were. Two consequences survive in test_analog_vcf.cpp and are worth
27// knowing before touching either:
28//
29// * Peak-height emphasis is read on the stimulus harmonic grid within an
30// octave of the ring, not as a global spectral maximum. The global form
31// reports the post-output cross-modulation sideband, or an inter-harmonic
32// line riding a resonance-zero valley -- noise over noise. The upstream
33// Juno calibration hit that same artifact and added a floor guard; the
34// acceptance prose predates the fix.
35// * The Juno and Jupiter-8 columns were refit on the canonical saw render, so
36// they are read there. The Prophet-5 taper was fitted analytically through
37// the ladder relation, so its two sub-oscillation rows are read with a
38// continuous swept probe instead -- its ring at cutoff knob 0.55 falls
39// between two harmonics of the 110 Hz stimulus and the saw grid cannot
40// resolve it. Above the self-oscillation crossing (res knob ~0.584) the
41// small-signal probe stops meaning anything and the saw recipe takes over.
42//
43// The Prophet-5 rising-rail and cross-mod laws below postdate the peak-height
44// column that was originally published with them, and no re-sweep of the
45// composite voicing followed. The engine reproduces the ring growth those laws
46// were built from; the amended targets in the tests are law-derived.
47
49
50#include <algorithm>
51#include <array>
52#include <cmath>
53#include <cstddef>
54#include <span>
55
56namespace pulp::signal {
57
66template <typename SampleType = float>
68public:
81
83 AnalogVcfT() noexcept {
84 update_voicing();
85 engine_.reset();
86 }
87
91 void set_sample_rate(double sample_rate) noexcept {
92 engine_.set_sample_rate(sample_rate);
93 update_voicing();
94 engine_.reset();
95 }
96
100 void set_oversampling(int factor) noexcept {
101 engine_.set_oversampling(factor);
102 update_voicing();
103 engine_.reset();
104 }
105
108 void set_voicing(Voicing voicing) noexcept {
109 if (voicing_ == voicing) return;
110 voicing_ = voicing;
111 update_voicing();
112 engine_.reset();
113 }
114
119 void set_cutoff(SampleType knob01, SampleType modulation_octaves = 0) noexcept {
120 const double cutoff = std::clamp(static_cast<double>(knob01), 0.0, 1.0);
121 const double modulation =
122 std::clamp(static_cast<double>(modulation_octaves), -16.0, 16.0);
123 if (cutoff_knob_ == cutoff && cutoff_modulation_octaves_ == modulation)
124 return;
125 cutoff_knob_ = cutoff;
126 cutoff_modulation_octaves_ = modulation;
127 update_voicing();
128 }
129
132 void set_resonance(SampleType knob01) noexcept {
133 const double resonance = std::clamp(static_cast<double>(knob01), 0.0, 1.0);
134 if (resonance_knob_ == resonance) return;
135 resonance_knob_ = resonance;
136 update_voicing();
137 }
138
141 void set_drive_db(SampleType drive_db) noexcept {
142 const double drive = static_cast<double>(drive_db);
143 if (drive_db_ == drive) return;
144 drive_db_ = drive;
145 engine_.set_drive_db(drive_db_);
146 }
147
157 void set_parameters(SampleType cutoff_knob01, SampleType modulation_octaves,
158 SampleType resonance_knob01, SampleType drive_db) noexcept {
159 const double cutoff =
160 std::clamp(static_cast<double>(cutoff_knob01), 0.0, 1.0);
161 const double modulation =
162 std::clamp(static_cast<double>(modulation_octaves), -16.0, 16.0);
163 const double resonance =
164 std::clamp(static_cast<double>(resonance_knob01), 0.0, 1.0);
165 const double drive = static_cast<double>(drive_db);
166 const bool calibration_changed =
167 cutoff_knob_ != cutoff ||
168 cutoff_modulation_octaves_ != modulation ||
169 resonance_knob_ != resonance;
170 const bool drive_changed = drive_db_ != drive;
171 if (!calibration_changed && !drive_changed) return;
172
173 cutoff_knob_ = cutoff;
174 cutoff_modulation_octaves_ = modulation;
175 resonance_knob_ = resonance;
176 drive_db_ = drive;
177 // Drive is independent of every measured panel law. A drive-only macro
178 // can move every sample, so do not make it pay for cutoff-table
179 // inversion, voicing-law interpolation and tan() coefficient work.
180 if (calibration_changed)
181 update_voicing();
182 else
183 engine_.set_drive_db(drive_db_);
184 }
185
189 void set_smoothing_time_ms(double milliseconds) noexcept {
190 engine_.set_smoothing_time_ms(milliseconds);
191 }
192
195 SampleType process(SampleType input) noexcept {
196 return engine_.process(input);
197 }
198
201 void process(SampleType* buffer, int num_samples) noexcept {
202 engine_.process(buffer, num_samples);
203 }
204
208 void reset() noexcept {
209 engine_.reset();
210 }
211
213 int latency_samples() const noexcept {
214 return engine_.latency_samples();
215 }
216
221 static constexpr int latency_samples_for_oversampling(int factor) noexcept {
223 }
224
229 double cutoff_hz() const noexcept {
230 return corner_hz_;
231 }
232
240 static double requested_cutoff_hz_for(Voicing voicing, double knob01,
241 double modulation_octaves = 0.0) noexcept {
242 const double cutoff = std::clamp(knob01, 0.0, 1.0);
243 const double modulation = std::clamp(modulation_octaves, -16.0, 16.0);
244 return log_frequency_table(cutoff, calibration_tables(voicing).cutoff) *
245 std::exp2(modulation);
246 }
247
249 int oversampling() const noexcept {
250 return engine_.oversampling();
251 }
252
254 Voicing voicing() const noexcept {
255 return voicing_;
256 }
257
258private:
259 struct TableView {
260 std::span<const double> knots;
261 std::span<const double> values;
262 };
263
264 struct CalibrationTables {
265 TableView cutoff;
266 TableView resonance;
267 };
268
269 static double linear_table(double knob, TableView table) noexcept {
270 const auto knots = table.knots;
271 const auto values = table.values;
272 if (knob <= knots.front())
273 return values.front();
274 if (knob >= knots.back())
275 return values.back();
276 for (std::size_t i = 1; i < knots.size(); ++i) {
277 if (knob <= knots[i]) {
278 const double fraction = (knob - knots[i - 1]) / (knots[i] - knots[i - 1]);
279 return values[i - 1] + fraction * (values[i] - values[i - 1]);
280 }
281 }
282 return values.back();
283 }
284
285 static double log_frequency_table(double knob, TableView table) noexcept {
286 const auto knots = table.knots;
287 const auto values = table.values;
288 if (knob <= knots.front())
289 return values.front();
290 if (knob >= knots.back())
291 return values.back();
292 for (std::size_t i = 1; i < knots.size(); ++i) {
293 if (knob <= knots[i]) {
294 const double fraction = (knob - knots[i - 1]) / (knots[i] - knots[i - 1]);
295 return values[i - 1] * std::pow(values[i] / values[i - 1], fraction);
296 }
297 }
298 return values.back();
299 }
300
301 static double invert_log_frequency_table(double frequency, TableView table) noexcept {
302 const auto knots = table.knots;
303 const auto values = table.values;
304 if (frequency <= values.front())
305 return knots.front();
306 if (frequency >= values.back())
307 return knots.back();
308 for (std::size_t i = 1; i < knots.size(); ++i) {
309 if (frequency <= values[i]) {
310 const double fraction =
311 std::log(frequency / values[i - 1]) / std::log(values[i] / values[i - 1]);
312 return knots[i - 1] + fraction * (knots[i] - knots[i - 1]);
313 }
314 }
315 return knots.back();
316 }
317
318 static CalibrationTables calibration_tables(Voicing voicing) noexcept {
319 switch (voicing) {
320 case Voicing::juno:
321 return {{kJunoCutoffKnots, kJunoCutoffHz}, {kJunoResonanceKnots, kJunoResonanceValues}};
322 case Voicing::jupiter:
323 return {{kJupiterCutoffKnots, kJupiterCutoffHz},
324 {kJupiterResonanceKnots, kJupiterResonanceValues}};
326 return {{kProphetCutoffKnots, kProphetCutoffHz},
327 {kProphetResonanceKnots, kProphetResonanceValues}};
329 return {{kMinimoogCutoffKnots, kMinimoogCutoffHz},
330 {kMinimoogResonanceKnots, kMinimoogResonanceValues}};
331 }
332 return {{kJunoCutoffKnots, kJunoCutoffHz}, {kJunoResonanceKnots, kJunoResonanceValues}};
333 }
334
335 void update_voicing() noexcept {
336 const CalibrationTables tables = calibration_tables(voicing_);
337 corner_hz_ = requested_cutoff_hz_for(
338 voicing_, cutoff_knob_, cutoff_modulation_octaves_);
339 const double equivalent_knob =
340 invert_log_frequency_table(corner_hz_, tables.cutoff);
341 const double resonance =
342 linear_table(resonance_knob_, tables.resonance);
343
345 engine_.set_bias(0.0);
346 engine_.set_drive_db(drive_db_);
347
348 switch (voicing_) {
349 case Voicing::juno:
350 engine_.set_k_max(4.30);
351 engine_.set_resonance(resonance);
352 engine_.set_compensation(0.21);
353 engine_.set_saturation_headroom(1.0);
354 engine_.set_output_gain(1.0);
355 engine_.set_cross_modulation(0.0, 2.0);
356 engine_.set_drift(0.0, 0.0);
357 engine_.set_pole_frequency(corner_hz_ / 0.4346);
358 break;
359
360 case Voicing::jupiter:
361 engine_.set_k_max(3.53);
362 engine_.set_resonance(resonance);
363 engine_.set_compensation(0.14);
364 engine_.set_saturation_headroom(1.5);
365 engine_.set_output_gain(1.0);
366 engine_.set_cross_modulation(0.0, 2.0);
367 engine_.set_drift(0.0, 0.0);
368 engine_.set_pole_frequency(corner_hz_ / 0.4346);
369 break;
370
371 case Voicing::prophet5: {
372 const double compensation =
373 linear_table(resonance_knob_, {kProphetLawKnots, kProphetCompensation});
374 const double headroom =
375 linear_table(resonance_knob_, {kProphetLawKnots, kProphetHeadroom});
376 const double cross_mod =
377 linear_table(resonance_knob_, {kProphetLawKnots, kProphetCrossMod});
378 engine_.set_k_max(4.30);
379 engine_.set_resonance(resonance);
380 engine_.set_compensation(compensation);
381 engine_.set_saturation_headroom(headroom);
382 engine_.set_output_gain(1.0);
383 engine_.set_cross_modulation(cross_mod, 2.0);
384 engine_.set_drift(0.0, 0.0);
385 engine_.set_pole_frequency(corner_hz_ / 0.4346);
386 break;
387 }
388
389 case Voicing::minimoog: {
390 constexpr double kFloorMakeup = 1.3396;
391 const double makeup =
392 linear_table(equivalent_knob, {kMinimoogMakeupKnots, kMinimoogMakeup});
393 const double cutoff_headroom =
394 linear_table(equivalent_knob, {kMinimoogHeadroomKnots, kMinimoogHeadroom});
395 const double drop_db =
396 linear_table(resonance_knob_, {kMinimoogLawKnots, kMinimoogDropDb});
397 const double headroom_multiplier =
398 linear_table(resonance_knob_, {kMinimoogLawKnots, kMinimoogHeadroomMultiplier});
399 const double cross_mod =
400 linear_table(resonance_knob_, {kMinimoogLawKnots, kMinimoogCrossMod});
401 const double cutoff_cents =
402 linear_table(resonance_knob_, {kMinimoogLawKnots, kMinimoogCutoffDriftCents});
403 const double resonance_fraction =
404 linear_table(resonance_knob_, {kMinimoogLawKnots, kMinimoogResonanceDriftFraction});
405 const double drift_fade =
406 corner_hz_ <= 900.0
407 ? 1.0
408 : (corner_hz_ >= 3000.0 ? 0.0 : (3000.0 - corner_hz_) / (3000.0 - 900.0));
409
410 engine_.set_k_max(4.30);
411 engine_.set_resonance(resonance);
412 engine_.set_compensation(0.0);
413 engine_.set_saturation_headroom(cutoff_headroom * headroom_multiplier / kFloorMakeup);
414 engine_.set_output_gain(makeup * std::pow(10.0, drop_db / 20.0));
415 engine_.set_cross_modulation(cross_mod, 2.0);
416 engine_.set_drift(cutoff_cents * drift_fade, resonance_fraction * drift_fade, 1.5,
417 10.0);
418 engine_.set_pole_frequency(corner_hz_ / 0.678);
419 break;
420 }
421 }
422 }
423
424 inline static constexpr std::array<double, 13> kJunoCutoffKnots{
425 0.0, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 1.0};
426 inline static constexpr std::array<double, 13> kJunoCutoffHz{
427 20.0, 45.0, 54.0, 72.0, 107.0, 166.0, 262.0, 424.0, 692.0, 1135.0, 1895.0, 3234.0, 18000.0};
428 inline static constexpr std::array<double, 12> kJunoResonanceKnots{
429 0.0, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1.0};
430 inline static constexpr std::array<double, 12> kJunoResonanceValues{
431 0.0, 0.213, 0.314, 0.407, 0.490, 0.573, 0.653, 0.743, 0.775, 0.870, 0.93, 1.0};
432
433 inline static constexpr std::array<double, 8> kJupiterCutoffKnots{0.0, 0.25, 0.35, 0.45,
434 0.55, 0.65, 0.75, 1.0};
435 inline static constexpr std::array<double, 8> kJupiterCutoffHz{20.0, 87.0, 157.0, 342.0,
436 760.0, 1668.0, 3440.0, 18000.0};
437 inline static constexpr std::array<double, 7> kJupiterResonanceKnots{0.0, 0.25, 0.50, 0.60,
438 0.75, 0.90, 1.0};
439 inline static constexpr std::array<double, 7> kJupiterResonanceValues{
440 0.0, 0.349, 0.579, 0.622, 0.793, 0.921, 1.0};
441
442 inline static constexpr std::array<double, 10> kProphetCutoffKnots{0.0, 0.25, 0.35, 0.45, 0.55,
443 0.65, 0.75, 0.85, 0.95, 1.0};
444 inline static constexpr std::array<double, 10> kProphetCutoffHz{
445 4.0, 17.8, 35.6, 71.3, 142.6, 287.8, 573.0, 1151.2, 2291.0, 3172.0};
446 inline static constexpr std::array<double, 7> kProphetResonanceKnots{0.0, 0.25, 0.50, 0.60,
447 0.75, 0.90, 1.0};
448 inline static constexpr std::array<double, 7> kProphetResonanceValues{
449 0.0, 0.583, 0.7634, 0.963, 0.975, 0.99, 1.0};
450 inline static constexpr std::array<double, 4> kProphetLawKnots{0.0, 0.60, 0.90, 1.0};
451 inline static constexpr std::array<double, 4> kProphetCompensation{0.0, 0.0, 0.066, 0.083};
452 inline static constexpr std::array<double, 4> kProphetHeadroom{1.0, 1.0, 1.90, 2.21};
453 inline static constexpr std::array<double, 4> kProphetCrossMod{0.0, 0.0, 0.30, 0.33};
454
455 inline static constexpr std::array<double, 9> kMinimoogCutoffKnots{0.0, 0.35, 0.45, 0.55, 0.65,
456 0.75, 0.85, 0.95, 1.0};
457 inline static constexpr std::array<double, 9> kMinimoogCutoffHz{
458 20.0, 126.0, 347.0, 821.0, 1641.0, 3414.0, 7393.0, 12975.0, 17187.0};
459 inline static constexpr std::array<double, 7> kMinimoogResonanceKnots{0.0, 0.25, 0.50, 0.60,
460 0.75, 0.90, 1.0};
461 inline static constexpr std::array<double, 7> kMinimoogResonanceValues{
462 0.079, 0.079, 0.079, 0.52, 0.90, 0.97, 1.0};
463 inline static constexpr std::array<double, 4> kMinimoogMakeupKnots{0.0, 0.10, 0.30, 1.0};
464 inline static constexpr std::array<double, 4> kMinimoogMakeup{1.0, 1.0, 1.3396, 1.3396};
465 inline static constexpr std::array<double, 7> kMinimoogHeadroomKnots{0.35, 0.55, 0.65, 0.75,
466 0.85, 0.95, 1.0};
467 inline static constexpr std::array<double, 7> kMinimoogHeadroom{1.0, 0.72, 1.163, 1.70,
468 0.92, 0.752, 0.752};
469 inline static constexpr std::array<double, 4> kMinimoogLawKnots{0.0, 0.60, 0.90, 1.0};
470 inline static constexpr std::array<double, 4> kMinimoogDropDb{0.0, 0.0, -2.3, -3.07};
471 inline static constexpr std::array<double, 4> kMinimoogHeadroomMultiplier{1.0, 1.0, 1.30,
472 1.421};
473 inline static constexpr std::array<double, 4> kMinimoogCrossMod{0.0, 0.0, 0.26, 0.29};
474 inline static constexpr std::array<double, 4> kMinimoogCutoffDriftCents{0.0, 0.0, 12.0, 13.0};
475 inline static constexpr std::array<double, 4> kMinimoogResonanceDriftFraction{0.0, 0.0, 0.0015,
476 0.0017};
477
478 OtaCascadeFilterT<SampleType> engine_;
479 Voicing voicing_ = Voicing::juno;
480 double cutoff_knob_ = 0.5;
481 double cutoff_modulation_octaves_ = 0.0;
482 double resonance_knob_ = 0.0;
483 double drive_db_ = 0.0;
484 double corner_hz_ = 262.0;
485};
486
491
492} // namespace pulp::signal
Measured panel voicings backed by a four-pole nonlinear OTA cascade.
Definition analog_vcf.hpp:67
AnalogVcfT() noexcept
Constructs the default configuration and clears all DSP history.
Definition analog_vcf.hpp:83
void set_sample_rate(double sample_rate) noexcept
Sets the base sample rate in Hz, with values below 64 Hz clamped to 64.
Definition analog_vcf.hpp:91
void set_drive_db(SampleType drive_db) noexcept
Sets input drive in dB; the underlying engine clamps it to [-96, 72] dB.
Definition analog_vcf.hpp:141
void process(SampleType *buffer, int num_samples) noexcept
Processes num_samples in place at the base sample rate.
Definition analog_vcf.hpp:201
double cutoff_hz() const noexcept
Returns the table-derived requested corner in Hz.
Definition analog_vcf.hpp:229
Voicing voicing() const noexcept
Definition analog_vcf.hpp:254
void set_smoothing_time_ms(double milliseconds) noexcept
Sets coefficient smoothing time in milliseconds.
Definition analog_vcf.hpp:189
Voicing
Selects the complete measured cutoff, resonance, headroom, compensation, saturation,...
Definition analog_vcf.hpp:71
@ jupiter
Roland Jupiter-8-style IR3109 voicing.
Definition analog_vcf.hpp:75
@ prophet5
Sequential Prophet-5-style SSM/CEM voicing.
Definition analog_vcf.hpp:77
@ juno
Roland Juno-style IR3109 voicing; the default.
Definition analog_vcf.hpp:73
@ minimoog
Minimoog-style transistor-ladder voicing.
Definition analog_vcf.hpp:79
void set_parameters(SampleType cutoff_knob01, SampleType modulation_octaves, SampleType resonance_knob01, SampleType drive_db) noexcept
Updates all sample-accurate controls as one transaction.
Definition analog_vcf.hpp:157
void set_cutoff(SampleType knob01, SampleType modulation_octaves=0) noexcept
Sets panel cutoff and modulation without clearing DSP history.
Definition analog_vcf.hpp:119
void set_voicing(Voicing voicing) noexcept
Selects a measured voicing and clears DSP history when it changes.
Definition analog_vcf.hpp:108
int oversampling() const noexcept
Returns the active oversampling factor: 1, 2, 4, 8, or 16.
Definition analog_vcf.hpp:249
static constexpr int latency_samples_for_oversampling(int factor) noexcept
Returns fixed base-rate latency for an oversampling factor.
Definition analog_vcf.hpp:221
static double requested_cutoff_hz_for(Voicing voicing, double knob01, double modulation_octaves=0.0) noexcept
Converts panel cutoff to the requested corner in Hz without an instance.
Definition analog_vcf.hpp:240
void reset() noexcept
Clears filter and oversampler history, restores deterministic drift state, and snaps smoothed values ...
Definition analog_vcf.hpp:208
SampleType process(SampleType input) noexcept
Processes one base-rate sample and advances filter, oversampling, smoothing, and deterministic drift ...
Definition analog_vcf.hpp:195
void set_resonance(SampleType knob01) noexcept
Sets the panel resonance position, clamped to [0, 1].
Definition analog_vcf.hpp:132
void set_oversampling(int factor) noexcept
Sets the oversampling factor to 1, 2, 4, 8, or 16.
Definition analog_vcf.hpp:100
int latency_samples() const noexcept
Returns the current fixed oversampling delay in base-rate samples.
Definition analog_vcf.hpp:213
@ lowpass24
Four-pole low-pass response (nominal 24 dB/octave).
Definition ota_cascade_filter.hpp:47
static constexpr int latency_samples_for_oversampling(int factor) noexcept
Returns round-trip FIR latency for an oversampling factor.
Definition ota_cascade_filter.hpp:291
Definition additive_bank.hpp:218
@ buffer
A borrowed static buffer with a traversable playhead.
Definition granular.hpp:110
@ i
Definition formant_filter_bank.hpp:20
AnalogVcfT< float > AnalogVcf
Single-precision measured analog VCF.
Definition analog_vcf.hpp:488
AnalogVcfT< double > AnalogVcf64
Double-precision measured analog VCF.
Definition analog_vcf.hpp:490
@ knob
Definition design_ir.hpp:260