DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
TubePreamp.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
73#include "../Core/AudioBuffer.h"
74#include "../Core/AudioSpec.h"
75#include "../Core/Biquad.h"
76#include "../Core/DenormalGuard.h"
77#include "../Core/DspMath.h"
78#include "../Core/Oversampling.h"
79#include "../Core/StateBlob.h"
80#include "../Core/WDF.h"
81
82#include <algorithm>
83#include <array>
84#include <atomic>
85#include <cmath>
86#include <cstddef>
87#include <cstdint>
88#include <memory>
89#include <numbers>
90#include <vector>
91
92namespace dspark {
93
100template <FloatType T>
102{
103public:
104 // -- Lifecycle ---------------------------------------------------------------
105
110 void prepare(const AudioSpec& spec)
111 {
112 if (!spec.isValid()) return;
113 prepared_.store(false, std::memory_order_relaxed);
114 spec_ = spec;
115 sampleRate_ = spec.sampleRate;
116 // Internal processing rate = active oversampling factor x base rate
117 // (RF-009/ADR-011: factor is configurable, 1 = off).
118 fs2_ = static_cast<double>(osFactor_) * sampleRate_;
119 numChannels_ = spec.numChannels;
120 maxBlock_ = std::max(spec.maxBlockSize, 1);
121
122 if (osFactor_ > 1)
123 {
124 oversampler_ = std::make_unique<Oversampling<T>>(
126 oversampler_->prepare(spec);
127 }
128 else
129 {
130 oversampler_.reset();
131 }
132
133 channels_.clear();
134 channels_.resize(static_cast<size_t>(numChannels_));
135 for (auto& ch : channels_)
136 ch = std::make_unique<ChannelState>(fs2_);
137
138 latency_ = oversampler_ ? oversampler_->getLatency() : 0;
139 drySize_ = 1;
140 while (drySize_ < latency_ + maxBlock_ + 1) drySize_ <<= 1;
141 dryRing_.assign(static_cast<size_t>(numChannels_),
142 std::vector<T>(static_cast<size_t>(drySize_), T(0)));
143 dryPos_ = 0;
144
145 calibrateReference();
146
147 prepared_.store(true, std::memory_order_relaxed);
148 dirty_.store(true, std::memory_order_release);
149 reset();
150 }
151
153 void reset() noexcept
154 {
155 if (!prepared_.load(std::memory_order_relaxed)) return;
156 const double sagR = static_cast<double>(sag_.load(std::memory_order_relaxed)) * 40e3;
157 const int stages = stages_.load(std::memory_order_relaxed);
158 for (auto& ch : channels_)
159 ch->reset(sagR, stages);
160 for (auto& d : dryRing_)
161 std::fill(d.begin(), d.end(), T(0));
162 dryPos_ = 0;
163 if (oversampler_) oversampler_->reset();
164 // Seed the anti-zipper ramps at their targets: no fade-in on start.
165 hScaleSm_ = -1.0;
166 outGainSm_ = -1.0;
167 currentMix_ = mix_.load(std::memory_order_relaxed);
168 }
169
170 // -- Parameters (thread-safe) ---------------------------------------------------
171
174 void setDrive(T db) noexcept
175 {
176 if (!std::isfinite(db)) return;
177 driveDb_.store(std::clamp(db, T(-12), T(36)), std::memory_order_relaxed);
178 dirty_.store(true, std::memory_order_release);
179 }
180
182 void setTreble(T treble) noexcept
183 {
184 if (!std::isfinite(treble)) return;
185 treble_.store(std::clamp(treble, T(0), T(1)), std::memory_order_relaxed);
186 dirty_.store(true, std::memory_order_release);
187 }
188
191 void setBass(T bass) noexcept
192 {
193 if (!std::isfinite(bass)) return;
194 bass_.store(std::clamp(bass, T(0), T(1)), std::memory_order_relaxed);
195 dirty_.store(true, std::memory_order_release);
196 }
197
199 void setMiddle(T middle) noexcept
200 {
201 if (!std::isfinite(middle)) return;
202 middle_.store(std::clamp(middle, T(0), T(1)), std::memory_order_relaxed);
203 dirty_.store(true, std::memory_order_release);
204 }
205
207 void setSag(T sag) noexcept
208 {
209 if (!std::isfinite(sag)) return;
210 sag_.store(std::clamp(sag, T(0), T(1)), std::memory_order_relaxed);
211 dirty_.store(true, std::memory_order_release);
212 }
213
215 void setStages(int stages) noexcept
216 {
217 stages_.store(std::clamp(stages, 1, 2), std::memory_order_relaxed);
218 dirty_.store(true, std::memory_order_release);
219 }
220
234 void setOversampling(int factor)
235 {
236 if (factor < 1 || factor > 16 || (factor & (factor - 1)) != 0) return;
237 if (factor == osFactor_) return;
238 osFactor_ = factor;
239 // Rebuild the whole chain at the new internal rate if already prepared
240 // (fs2_, oversampler, per-channel circuits and calibration all depend
241 // on the factor). Same setup-thread cost as prepare().
242 if (prepared_.load(std::memory_order_relaxed))
243 prepare(spec_);
244 }
245
247 [[nodiscard]] int getOversamplingFactor() const noexcept { return osFactor_; }
248
250 void setOutput(T db) noexcept
251 {
252 if (!std::isfinite(db)) return;
253 outputDb_.store(std::clamp(db, T(-24), T(12)), std::memory_order_relaxed);
254 }
255
258 void setMix(T mix) noexcept
259 {
260 if (!std::isfinite(mix)) return;
261 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
262 }
263
264 [[nodiscard]] T getDrive() const noexcept { return driveDb_.load(std::memory_order_relaxed); }
265 [[nodiscard]] T getTreble() const noexcept { return treble_.load(std::memory_order_relaxed); }
266 [[nodiscard]] T getBass() const noexcept { return bass_.load(std::memory_order_relaxed); }
267 [[nodiscard]] T getMiddle() const noexcept { return middle_.load(std::memory_order_relaxed); }
268 [[nodiscard]] T getSag() const noexcept { return sag_.load(std::memory_order_relaxed); }
269 [[nodiscard]] int getStages() const noexcept { return stages_.load(std::memory_order_relaxed); }
270 [[nodiscard]] T getOutput() const noexcept { return outputDb_.load(std::memory_order_relaxed); }
271 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
272
275 [[nodiscard]] int getLatency() const noexcept { return latency_; }
276
279 [[nodiscard]] int getLatencySamples() const noexcept { return latency_; }
280
282 [[nodiscard]] T getSupplyVoltage() const noexcept
283 {
284 return supplyNow_.load(std::memory_order_relaxed);
285 }
286
288 [[nodiscard]] std::vector<uint8_t> getState() const
289 {
290 StateWriter w(stateId("TUBE"), 1);
291 // Explicit float casts: the blob stores float, and with T = double the
292 // unqualified write(key, double) would be ambiguous (float/int32/bool).
293 w.write("drive", static_cast<float>(driveDb_.load(std::memory_order_relaxed)));
294 w.write("treble", static_cast<float>(treble_.load(std::memory_order_relaxed)));
295 w.write("bass", static_cast<float>(bass_.load(std::memory_order_relaxed)));
296 w.write("middle", static_cast<float>(middle_.load(std::memory_order_relaxed)));
297 w.write("sag", static_cast<float>(sag_.load(std::memory_order_relaxed)));
298 w.write("stages", stages_.load(std::memory_order_relaxed));
299 w.write("output", static_cast<float>(outputDb_.load(std::memory_order_relaxed)));
300 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
301 w.write("oversampling", osFactor_);
302 return w.blob();
303 }
304
306 bool setState(const uint8_t* data, size_t size)
307 {
308 StateReader r(data, size);
309 if (!r.isValid() || r.processorId() != stateId("TUBE")) return false;
310 setDrive(static_cast<T>(r.read("drive", 0.0f)));
311 setTreble(static_cast<T>(r.read("treble", 0.5f)));
312 setBass(static_cast<T>(r.read("bass", 0.5f)));
313 setMiddle(static_cast<T>(r.read("middle", 0.5f)));
314 setSag(static_cast<T>(r.read("sag", 0.3f)));
315 setStages(r.read("stages", 1));
316 setOutput(static_cast<T>(r.read("output", 0.0f)));
317 setMix(static_cast<T>(r.read("mix", 1.0f)));
318 // Default 2 = the historical fixed factor (blobs written before RF-009
319 // restore the 2x behaviour they were captured with).
320 setOversampling(r.read("oversampling", 2));
321 return true;
322 }
323
324 // -- Processing -------------------------------------------------------------------
325
327 void processBlock(AudioBufferView<T> buffer) noexcept
328 {
329 if (!prepared_.load(std::memory_order_relaxed)) return;
330 DenormalGuard guard;
331
332 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
333 const int nS = buffer.getNumSamples();
334 if (nCh == 0 || nS == 0) return;
335
336 // Front-door non-finite guard: a single NaN/Inf input sample would
337 // poison the recursive triode Newton-Raphson / WDF tone stack / supply
338 // sag / output DC-blocker / flatten-EQ state PERMANENTLY (only reset()
339 // clears it, not clean input). Replace bad samples with silence before
340 // they reach any state, so a transient upstream glitch cannot corrupt
341 // the channel for the rest of the stream (M-005 C1).
342 for (int ch = 0; ch < nCh; ++ch)
343 {
344 T* d = buffer.getChannel(ch);
345 for (int i = 0; i < nS; ++i)
346 if (!std::isfinite(d[i])) d[i] = T(0);
347 }
348
349 // Acquire pairs with the setters' release stores so the recompute
350 // always sees the values published before the flag.
351 if (dirty_.load(std::memory_order_relaxed)
352 && dirty_.exchange(false, std::memory_order_acquire))
353 recompute();
354
355 // Linear per-block mix ramp with exact landing (settled: step == 0
356 // and the per-sample value reduces to the constant, bit-identically).
357 const T mixTarget = mix_.load(std::memory_order_relaxed);
358 const T mixStart = currentMix_;
359 const T mixStep = (mixTarget - mixStart) / static_cast<T>(nS);
360 const double outGain = mScale_
361 * std::pow(10.0, static_cast<double>(outputDb_.load(std::memory_order_relaxed)) / 20.0);
362
363 // Dry snapshot.
364 for (int ch = 0; ch < nCh; ++ch)
365 {
366 const T* in = buffer.getChannel(ch);
367 auto& dry = dryRing_[static_cast<size_t>(ch)];
368 int dp = dryPos_;
369 for (int i = 0; i < nS; ++i)
370 {
371 dry[static_cast<size_t>(dp)] = in[i];
372 dp = (dp + 1) & (drySize_ - 1);
373 }
374 }
375
376 // Nonlinear circuit at the active oversampling factor (1x = process the
377 // base-rate buffer in place, no resampling; RF-009/ADR-011).
378 {
379 const bool osOn = (oversampler_ != nullptr);
380 auto osView = osOn ? oversampler_->upsample(buffer) : buffer;
381 const int osN = osView.getNumSamples();
382
383 // Anti-zipper: GEOMETRIC in-block ramps toward the current
384 // targets (~30 ms across blocks), shared by all channels. A flat
385 // per-block step clicked while dragging drive; and since
386 // (hScale, outGain) is a compensation pair spanning orders of
387 // magnitude (outGain ~ 1/drive), linear interpolation transits
388 // through over-gained states - power-law interpolation keeps
389 // the pair on the calibration curve.
390 if (outGainSm_ <= 0.0) outGainSm_ = outGain;
391 if (hScaleSm_ <= 0.0) hScaleSm_ = hScale_;
392 const double kSm = 1.0 - std::exp(-static_cast<double>(osN) / (0.030 * fs2_));
393 const double outEnd = outGainSm_ * std::pow(outGain / outGainSm_, kSm);
394 const double hEnd = hScaleSm_ * std::pow(hScale_ / hScaleSm_, kSm);
395 const double outRat = std::pow(outEnd / outGainSm_, 1.0 / static_cast<double>(osN));
396 const double hRat = std::pow(hEnd / hScaleSm_, 1.0 / static_cast<double>(osN));
397
398 for (int ch = 0; ch < nCh; ++ch)
399 {
400 T* d = osView.getChannel(ch);
401 auto& state = *channels_[static_cast<size_t>(ch)];
402 double g = outGainSm_, h = hScaleSm_;
403 for (int i = 0; i < osN; ++i)
404 {
405 g *= outRat;
406 h *= hRat;
407 d[i] = static_cast<T>(g
408 * state.processSample(h * static_cast<double>(d[i]),
409 numStagesActive_, sagR_));
410 }
411 }
412 outGainSm_ = outEnd;
413 hScaleSm_ = hEnd;
414 if (osOn) oversampler_->downsample(buffer);
415 supplyNow_.store(static_cast<T>(kBplus - sagR_ * channels_[0]->ipLP),
416 std::memory_order_relaxed);
417 }
418
419 // Latency-compensated mix (ramped: a hard flip on the distorted wet
420 // stream clicked at 1.6x the steady-state sample delta).
421 for (int ch = 0; ch < nCh; ++ch)
422 {
423 T* d = buffer.getChannel(ch);
424 const auto& dry = dryRing_[static_cast<size_t>(ch)];
425 for (int i = 0; i < nS; ++i)
426 {
427 const int idx = (dryPos_ + i - latency_) & (drySize_ - 1);
428 const T drySample = dry[static_cast<size_t>(idx)];
429 const T mixVal = mixStart + mixStep * static_cast<T>(i);
430 d[i] = drySample + (d[i] - drySample) * mixVal;
431 }
432 }
433 currentMix_ = mixTarget; // exact landing
434 dryPos_ = (dryPos_ + nS) & (drySize_ - 1);
435 }
436
437private:
438 // -- Circuit constants (classic 12AX7 common-cathode stage) -------------------
439 static constexpr double kMu = 100.0, kEx = 1.4, kKg1 = 1060.0;
440 static constexpr double kKp = 600.0, kKvb = 300.0;
441 static constexpr double kBplus = 300.0;
442 static constexpr double kRL = 100e3;
443 static constexpr double kRk = 1.5e3;
444 static constexpr double kCk = 22e-6;
445 static constexpr double kInterstage = 0.12;
446
448 static void koren(double vpk, double vgk, double& ip,
449 double& dIpdVpk, double& dIpdVgk) noexcept
450 {
451 vpk = std::max(vpk, 0.0);
452 const double s = std::sqrt(kKvb + vpk * vpk);
453 const double u = kKp * (1.0 / kMu + vgk / s);
454
455 double sp = 0.0, sig = 0.0; // softplus(u), logistic(u)
456 if (u > 30.0) { sp = u; sig = 1.0; }
457 else if (u < -30.0) { sp = std::exp(u); sig = sp; }
458 else
459 {
460 const double eu = std::exp(u);
461 sp = std::log1p(eu);
462 sig = eu / (1.0 + eu);
463 }
464
465 const double e1 = (vpk / kKp) * sp;
466 if (e1 <= 0.0)
467 {
468 ip = 0.0;
469 dIpdVpk = 0.0;
470 dIpdVgk = 0.0;
471 return;
472 }
473 const double e1ex1 = std::pow(e1, kEx - 1.0);
474 ip = 2.0 * e1ex1 * e1 / kKg1;
475 const double dIpdE1 = 2.0 * kEx * e1ex1 / kKg1;
476
477 const double dUdVpk = -kKp * vgk * vpk / (s * s * s);
478 const double dE1dVpk = sp / kKp + (vpk / kKp) * sig * dUdVpk;
479 const double dE1dVgk = vpk * sig / s;
480 dIpdVpk = dIpdE1 * dE1dVpk;
481 dIpdVgk = dIpdE1 * dE1dVgk;
482 }
483
485 struct TriodeStage
486 {
487 double fs2 = 96000.0;
488 double ip = 8e-4;
489 double vk = 1.2;
490 double fPrev = 0.0;
491 double vpDC = 200.0;
492
493 void settleDC(double bplus) noexcept
494 {
495 // Static operating point: Vk = Ip*Rk (capacitor fully charged).
496 double i = 8e-4;
497 for (int it = 0; it < 60; ++it)
498 {
499 const double vkS = i * kRk;
500 const double vpk = bplus - i * kRL - vkS;
501 double ipK = 0.0, dVpk = 0.0, dVgk = 0.0;
502 koren(vpk, -vkS, ipK, dVpk, dVgk);
503 const double f = i - ipK;
504 const double fp = 1.0 - (dVpk * (-(kRL + kRk)) + dVgk * (-kRk));
505 const double di = f / fp;
506 i -= di;
507 i = std::clamp(i, 0.0, bplus / (kRL + kRk));
508 if (std::abs(di) < 1e-15) break;
509 }
510 ip = i;
511 vk = i * kRk;
512 fPrev = 0.0;
513 vpDC = bplus - i * kRL;
514 }
515
517 [[nodiscard]] double processSample(double vg, double bplusEff) noexcept
518 {
519 // Soft grid-conduction clamp toward +0.7 V.
520 if (vg > 0.0)
521 vg = 0.7 * std::tanh(vg / 0.7);
522
523 // Trapezoidal cathode bypass: Ck dVk/dt = Ip - Vk/Rk, with fPrev
524 // holding the previous NET CURRENT (Ip - Vk/Rk), so the update is
525 // Vk_n = Vk_{n-1} + (T/2Ck)(I_n + I_{n-1}) = kA + kB * Ip_n.
526 const double h2c = 0.5 / (fs2 * kCk);
527 const double denom = 1.0 + h2c / kRk;
528 const double kA = (vk + h2c * fPrev) / denom;
529 const double kB = h2c / denom;
530
531 const double iMax = bplusEff / kRL + 1e-3;
532 double i = std::clamp(ip, 0.0, iMax);
533
534 for (int it = 0; it < 8; ++it)
535 {
536 const double vkN = kA + kB * i;
537 const double vpk = bplusEff - i * kRL - vkN;
538 const double vgk = vg - vkN;
539 double ipK = 0.0, dVpk = 0.0, dVgk = 0.0;
540 koren(vpk, vgk, ipK, dVpk, dVgk);
541
542 const double f = i - ipK;
543 const double fp = 1.0 - (dVpk * (-kRL - kB) + dVgk * (-kB));
544 const double di = f / std::max(fp, 1e-6);
545 i -= di;
546 i = std::clamp(i, 0.0, iMax);
547 if (std::abs(di) < 1e-12) break;
548 }
549
550 const double vkN = kA + kB * i;
551 fPrev = i - vkN / kRk; // net capacitor current for the trapezoid
552 vk = vkN;
553 ip = i;
554 return (bplusEff - i * kRL) - vpDC; // AC component
555 }
556 };
557
559 struct ChannelState
560 {
561 explicit ChannelState(double fs2In)
562 : fmv(38e3, 1e6) // driven by the stage's real output impedance
563 {
564 stage1.fs2 = fs2In;
565 stage2.fs2 = fs2In;
566 fs2 = fs2In;
567 fmv.prepare(fs2In);
568 }
569
577 void reset(double sagR, int numStages) noexcept
578 {
579 // Fixed point over the ACTIVE stage count: processSample only
580 // draws current from the stages in use, so seeding ipLP with both
581 // stages' current at 1-stage settings left a ~70 ms sag transient
582 // (audible activation drift, and it polluted the noise floor).
583 double bp = kBplus;
584 double iTotal = 0.0;
585 for (int it = 0; it < 8; ++it)
586 {
587 stage1.settleDC(bp);
588 stage2.settleDC(bp);
589 iTotal = stage1.ip + (numStages > 1 ? stage2.ip : 0.0);
590 const double bpNew = kBplus - sagR * iTotal;
591 if (std::abs(bpNew - bp) < 1e-9) break;
592 bp = bpNew;
593 }
594 ipLP = iTotal;
595 outHpX = outHpY = 0.0;
596 fmv.reset();
597 for (auto& set : flatten)
598 for (auto& f : set)
599 f.reset();
600 }
601
603 void setFlattenCoeffs(int stageCount,
604 const std::array<BiquadCoeffs, 3>& c) noexcept
605 {
606 auto& set = flatten[static_cast<size_t>(stageCount - 1)];
607 for (int k = 0; k < 3; ++k)
608 set[static_cast<size_t>(k)].setCoeffs(c[static_cast<size_t>(k)]);
609 }
610
611 void setToneControls(double t, double b, double m) noexcept
612 {
613 fmv.setControls(t, b, m); // rebuilds the R-type scattering
614 }
615
616 [[nodiscard]] double processSample(double vgIn, int numStages, double sagR) noexcept
617 {
618 // Supply sag: B+ droops with smoothed total plate current.
619 const double sagAlpha = 1.0 - std::exp(-1.0 / (0.07 * fs2));
620 const double iTotal = stage1.ip + (numStages > 1 ? stage2.ip : 0.0);
621 ipLP += sagAlpha * (iTotal - ipLP);
622 const double bplusEff = kBplus - sagR * ipLP;
623
624 // Stage 1 -> FMV tone stack -> (stage 2) -> output high-pass.
625 double v = stage1.processSample(vgIn, bplusEff);
626 v = fmv.processSample(v);
627 if (numStages > 1)
628 v = stage2.processSample(v * kInterstage, bplusEff);
629
630 // Output coupling high-pass (~8 Hz, removes residual sag drift).
631 const double a = 1.0 - 2.0 * std::numbers::pi * 8.0 / fs2;
632 const double y = a * (outHpY + v - outHpX);
633 outHpX = v;
634 outHpY = y;
635 // Restore absolute polarity for single-stage use.
636 double out = (numStages > 1) ? y : -y;
637
638 // Reference-flattening EQ: undoes the FMV stack's fixed envelope
639 // at the neutral tone setting (designed in calibrateReference
640 // from the measured response), so neutral knobs sound neutral
641 // and the tone controls act RELATIVE to flat. The triode's
642 // harmonic character is untouched (this stage is linear).
643 auto& fl = flatten[numStages > 1 ? 1 : 0];
644 out = fl[0].processSample(out, 0);
645 out = fl[1].processSample(out, 0);
646 out = fl[2].processSample(out, 0);
647 return out;
648 }
649
650 double fs2 = 96000.0;
651 TriodeStage stage1, stage2;
652 double ipLP = 1.6e-3;
653 double outHpX = 0.0, outHpY = 0.0;
654 std::array<std::array<Biquad<double, 1>, 3>, 2> flatten;
655
656 wdf::ToneStackFMV<double> fmv;
657 };
658
660 void recompute() noexcept
661 {
662 const double drive = std::pow(10.0, static_cast<double>(
663 driveDb_.load(std::memory_order_relaxed)) / 20.0);
664 const double t = static_cast<double>(treble_.load(std::memory_order_relaxed));
665 const double b = static_cast<double>(bass_.load(std::memory_order_relaxed));
666 const double m = static_cast<double>(middle_.load(std::memory_order_relaxed));
667 const double sag = static_cast<double>(sag_.load(std::memory_order_relaxed));
668 numStagesActive_ = stages_.load(std::memory_order_relaxed);
669
670 hScale_ = drive; // 0 dBFS -> 1 V grid at drive 0
671 // Note on physics: a class-A preamp draws near-constant average
672 // current, so supply sag shifts the operating point rather than
673 // pumping like a push-pull power amp. The audible touch response of
674 // this model comes from the cathode-bypass bias shift (modelled in
675 // TriodeStage); the sag control changes voicing, not loudness.
676 sagR_ = sag * 40e3;
677 for (auto& ch : channels_)
678 ch->setToneControls(t, b, m);
679
680 // Loudness: divide out the circuit's program gain at the REFERENCE
681 // tone setting (measured once in prepare on a settled channel) and
682 // MOST of the drive factor. The link is partial (drive^0.75, i.e. a
683 // residual +0.25 dB/dB slope): an exact 1/drive link leaves the knob
684 // audibly dead below 0 dB (the circuit is still clean there) and
685 // turns it into a pure attenuator above (compression eats level
686 // faster than the link returns it). With the residual slope, -12 dB
687 // drive sits ~3 dB lower and clean, high drive holds level while the
688 // density grows. The tone knobs stay fully audible: their deviation
689 // from the 0.5/0.5/0.5 reference is part of the tone, not the level.
690 // Cheap by construction (no scratch processing on the audio thread).
691 //
692 // The divisor is the circuit's MEASURED program gain at this drive
693 // (prepare-time LUT, log-interpolated) - same contract as
694 // TapeMachine/TransformerModel, whose per-drive scratch calibration
695 // is what kept their knobs healthy. The previous analytic
696 // 1/drive^0.75 broke at high drive: once the triode pins at its
697 // ceiling, output stops growing with drive while the divisor keeps
698 // rising, so the level FELL hard instead of holding.
699 const double driveDbNow = static_cast<double>(driveDb_.load(std::memory_order_relaxed));
700 mScale_ = std::pow(drive, 0.25)
701 / std::max(programGainAt(driveDbNow, numStagesActive_), 1e-9);
702 }
703
705 [[nodiscard]] double programGainAt(double driveDb, int stages) const noexcept
706 {
707 const auto& lut = gProgLut_[static_cast<size_t>(stages - 1)];
708 // Ordered min/max instead of clamp: a NaN input resolves to 0 here
709 // (defence in depth; the setter already rejects non-finite values, and
710 // an unguarded NaN would UB-cast into a wild LUT index).
711 const double pos = std::min(std::max(0.0, (driveDb - kDriveLutMinDb) / kDriveLutStepDb),
712 static_cast<double>(kDriveLutN - 1));
713 const int idx = std::min(static_cast<int>(pos), kDriveLutN - 2);
714 const double frac = pos - static_cast<double>(idx);
715 const double a = std::max(lut[static_cast<size_t>(idx)], 1e-9);
716 const double b = std::max(lut[static_cast<size_t>(idx) + 1], 1e-9);
717 return a * std::pow(b / a, frac); // gains are smooth in dB
718 }
719
735 void calibrateReference() noexcept
736 {
737 constexpr double kRefTones[] = { 100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0 };
738 constexpr double kSagRef = 0.3 * 40e3;
739 const int settle = static_cast<int>(0.060 * fs2_);
740 const int meas = static_cast<int>(0.050 * fs2_);
741 constexpr double kAmpPerTone = 0.095 / 2.6457513;
742
743 for (int st = 1; st <= 2; ++st)
744 {
745 ChannelState cal(fs2_); // fresh: flattener is passthrough here
746 cal.setToneControls(0.5, 0.5, 0.5);
747 cal.reset(kSagRef, st);
748
749 // Per-tone Goertzel over the measured tail.
750 std::array<double, 7> gs1 {}, gs2 {}, gc {};
751 for (int k = 0; k < 7; ++k)
752 gc[static_cast<size_t>(k)] =
753 2.0 * std::cos(2.0 * std::numbers::pi * kRefTones[k] / fs2_);
754 for (int i = 0; i < settle + meas; ++i)
755 {
756 double x = 0.0;
757 for (int k = 0; k < 7; ++k)
758 x += std::sin(2.0 * std::numbers::pi * kRefTones[k] * i / fs2_ + k * 1.7);
759 x *= kAmpPerTone;
760 const double y = cal.processSample(x, st, kSagRef);
761 if (i >= settle)
762 for (int k = 0; k < 7; ++k)
763 {
764 auto& s1 = gs1[static_cast<size_t>(k)];
765 auto& s2 = gs2[static_cast<size_t>(k)];
766 const double s0 = y + gc[static_cast<size_t>(k)] * s1 - s2;
767 s2 = s1; s1 = s0;
768 }
769 }
770 std::array<double, 7> gainDb {};
771 for (int k = 0; k < 7; ++k)
772 {
773 const double c = gc[static_cast<size_t>(k)] * 0.5;
774 const double s1 = gs1[static_cast<size_t>(k)], s2 = gs2[static_cast<size_t>(k)];
775 const double mag = std::sqrt(std::max(s1 * s1 + s2 * s2 - 2.0 * c * s1 * s2, 0.0))
776 * 2.0 / meas;
777 gainDb[static_cast<size_t>(k)] =
778 20.0 * std::log10(std::max(mag / kAmpPerTone, 1e-9));
779 }
780
781 // Flattening EQ from the band means, relative to the overall mean.
782 double mean = 0.0;
783 for (double g : gainDb) mean += g / 7.0;
784 const double lo = 0.5 * (gainDb[0] + gainDb[1]) - mean;
785 const double mid = (gainDb[2] + gainDb[3] + gainDb[4]) / 3.0 - mean;
786 const double hi = 0.5 * (gainDb[5] + gainDb[6]) - mean;
787
788 std::array<BiquadCoeffs, 3> fc = {
789 BiquadCoeffs::makeLowShelf(fs2_, 180.0, -lo),
790 BiquadCoeffs::makePeak(fs2_, 800.0, 0.55, -mid),
791 BiquadCoeffs::makeHighShelf(fs2_, 4500.0, -hi)
792 };
793 for (auto& ch : channels_)
794 ch->setFlattenCoeffs(st, fc);
795
796 // Phase 2: program gain vs DRIVE, on a chained scratch channel
797 // with the flattener installed (the chain as it really sounds).
798 // Each grid point gets a re-settle (bias/sag adapt to the new
799 // level) before its measurement window. recompute() interpolates
800 // this LUT, so the loudness link tracks the circuit's actual
801 // compression - the fix for the level falling at high drive.
802 ChannelState sweep(fs2_);
803 sweep.setToneControls(0.5, 0.5, 0.5);
804 sweep.setFlattenCoeffs(st, fc);
805 sweep.reset(kSagRef, st);
806 const int settle2 = static_cast<int>(0.030 * fs2_);
807 const int meas2 = static_cast<int>(0.030 * fs2_);
808 int t = 0; // continuous tone phase across the whole sweep
809 for (int kd = 0; kd < kDriveLutN; ++kd)
810 {
811 const double d = std::pow(10.0,
812 (kDriveLutMinDb + kd * kDriveLutStepDb) / 20.0);
813 double inSq = 0.0, outSq = 0.0;
814 for (int i = 0; i < settle2 + meas2; ++i, ++t)
815 {
816 double x = 0.0;
817 for (int k = 0; k < 7; ++k)
818 x += std::sin(2.0 * std::numbers::pi * kRefTones[k] * t / fs2_ + k * 1.7);
819 x *= kAmpPerTone;
820 const double y = sweep.processSample(d * x, st, kSagRef);
821 if (i >= settle2)
822 {
823 inSq += x * x;
824 outSq += y * y;
825 }
826 }
827 gProgLut_[static_cast<size_t>(st - 1)][static_cast<size_t>(kd)] =
828 (outSq > 0.0 && inSq > 0.0) ? std::sqrt(outSq / inSq) : 1.0;
829 }
830 }
831 }
832
833 // -- Members --------------------------------------------------------------------
834 AudioSpec spec_ {};
835 double sampleRate_ = 48000.0;
836 double fs2_ = 96000.0;
837 int numChannels_ = 0;
838 int maxBlock_ = 0;
839 std::atomic<bool> prepared_ { false };
840 int latency_ = 0;
841 int drySize_ = 1;
842 int osFactor_ = 2;
843
844 std::unique_ptr<Oversampling<T>> oversampler_;
845 std::vector<std::unique_ptr<ChannelState>> channels_;
846
847 std::vector<std::vector<T>> dryRing_;
848 int dryPos_ = 0;
849
850 static constexpr int kDriveLutN = 13;
851 static constexpr double kDriveLutMinDb = -12.0;
852 static constexpr double kDriveLutStepDb = 4.0;
853
854 double hScale_ = 1.0;
855 double mScale_ = 1.0;
856 double sagR_ = 0.0;
857 int numStagesActive_ = 1;
859 std::array<std::array<double, kDriveLutN>, 2> gProgLut_ {
860 { { 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 },
861 { 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 } } };
862 double hScaleSm_ = -1.0;
863 double outGainSm_ = -1.0;
864 T currentMix_ = T(1);
865
866 std::atomic<T> driveDb_ { T(0) };
867 std::atomic<T> treble_ { T(0.5) };
868 std::atomic<T> bass_ { T(0.5) };
869 std::atomic<T> middle_ { T(0.5) };
870 std::atomic<T> sag_ { T(0.3) };
871 std::atomic<int> stages_ { 1 };
872 std::atomic<T> outputDb_ { T(0) };
873 std::atomic<T> mix_ { T(1) };
874 std::atomic<T> supplyNow_ { T(300) };
875 std::atomic<bool> dirty_ { true };
876};
877
878} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Power-of-two oversampling processor with polyphase anti-aliasing.
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:160
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:203
bool isValid() const noexcept
Definition StateBlob.h:198
uint32_t processorId() const noexcept
Definition StateBlob.h:199
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:52
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:104
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:70
One/two 12AX7 stages with sag and a WDF tone circuit.
Definition TubePreamp.h:102
T getBass() const noexcept
Definition TubePreamp.h:266
void setStages(int stages) noexcept
Number of triode stages (1 = clean/edge, 2 = high gain).
Definition TubePreamp.h:215
T getSupplyVoltage() const noexcept
Effective B+ supply voltage of channel 0 (sag meter readout).
Definition TubePreamp.h:282
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition TubePreamp.h:306
int getLatencySamples() const noexcept
Alias of getLatency() under the framework-wide latency-reporter name (RNF-005), so ProcessorChain::ge...
Definition TubePreamp.h:279
T getDrive() const noexcept
Definition TubePreamp.h:264
T getOutput() const noexcept
Definition TubePreamp.h:270
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place. Pass-through until prepare() succeeds.
Definition TubePreamp.h:327
void setBass(T bass) noexcept
Bass control of the FMV stack [0, 1] (log-taper, like the original). Non-finite values are ignored.
Definition TubePreamp.h:191
void setMiddle(T middle) noexcept
Middle control of the FMV stack [0, 1]. Non-finite values are ignored.
Definition TubePreamp.h:199
int getLatency() const noexcept
Latency in samples the active oversampler adds (0 at 1x = off); reflects the current factor for host ...
Definition TubePreamp.h:275
T getTreble() const noexcept
Definition TubePreamp.h:265
void reset() noexcept
Re-settles every stage at its DC operating point. RT-safe.
Definition TubePreamp.h:153
T getMiddle() const noexcept
Definition TubePreamp.h:267
int getStages() const noexcept
Definition TubePreamp.h:269
void setTreble(T treble) noexcept
Treble control of the FMV stack [0, 1]. Non-finite values are ignored.
Definition TubePreamp.h:182
void setDrive(T db) noexcept
Input drive in dB [-12, +36]; level-compensated. Non-finite values are ignored.
Definition TubePreamp.h:174
T getSag() const noexcept
Definition TubePreamp.h:268
void setSag(T sag) noexcept
Supply sag depth [0, 1] (0 = stiff supply). Non-finite values are ignored.
Definition TubePreamp.h:207
T getMix() const noexcept
Definition TubePreamp.h:271
void setOversampling(int factor)
Configures internal oversampling of the nonlinear circuit (RF-009/ADR-011 transparency policy)....
Definition TubePreamp.h:234
void setMix(T mix) noexcept
Dry/wet mix [0, 1]; dry is latency-compensated and the mix is smoothed linearly over one block....
Definition TubePreamp.h:258
void setOutput(T db) noexcept
Static output trim in dB [-24, +12]. Non-finite values are ignored.
Definition TubePreamp.h:250
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition TubePreamp.h:288
void prepare(const AudioSpec &spec)
Allocates the chain (one circuit instance per channel) and runs the reference calibration....
Definition TubePreamp.h:110
int getOversamplingFactor() const noexcept
Active oversampling factor (1 = off, 2 = default).
Definition TubePreamp.h:247
Main namespace for the DSPark framework.
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:639
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:56
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:51
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43
static BiquadCoeffs makePeak(double sampleRate, double freq, double Q, double gainDb) noexcept
Peak (parametric EQ) filter.
Definition Biquad.h:185
static BiquadCoeffs makeLowShelf(double sampleRate, double freq, double gainDb, double slope=1.0) noexcept
Low-shelf filter.
Definition Biquad.h:283
static BiquadCoeffs makeHighShelf(double sampleRate, double freq, double gainDb, double slope=1.0) noexcept
High-shelf filter.
Definition Biquad.h:313