DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
TubePreamp.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
150#include "../Core/AudioBuffer.h"
151#include "../Core/AudioSpec.h"
152#include "../Core/Biquad.h"
153#include "../Core/DenormalGuard.h"
154#include "../Core/DspMath.h"
155#include "../Core/FIRFilter.h"
156#include "../Core/Oversampling.h"
157#include "../Core/SmoothedValue.h"
158#include "../Core/StateBlob.h"
159#include "../Core/WDF.h"
160
161#include <algorithm>
162#include <array>
163#include <atomic>
164#include <cmath>
165#include <cstddef>
166#include <cstdint>
167#include <limits>
168#include <memory>
169#include <numbers>
170#include <span>
171#include <utility>
172#include <vector>
173
174namespace dspark {
175
177namespace detail {
178 struct TubePreampGridClamp
179 {
181 static double value(double v) noexcept
182 {
183 if (v <= 0.0) return v;
184 if (v >= 13.0) return 0.7; // 0.7*(1 - 2e^-37) rounds to 0.7
185 const double e = std::expm1(v * (-2.0 / 0.7));
186 return -0.7 * e / (2.0 + e);
187 }
188 };
189
190 struct TubePreampCurrentTable
191 {
192 static constexpr double kMu = 100.0, kEx = 1.4, kKg1 = 1060.0;
193 static constexpr double kKp = 600.0, kKvb = 300.0;
194 static constexpr double kRL = 100e3, kRk = 1.5e3, kCk = 22e-6;
195
197 static void koren(double vpk, double vgk, double& ip,
198 double& dIpdVpk, double& dIpdVgk) noexcept
199 {
200 vpk = std::max(vpk, 0.0);
201 const double s = std::sqrt(kKvb + vpk * vpk);
202 const double u = kKp * (1.0 / kMu + vgk / s);
203
204 double sp = 0.0, sig = 0.0; // softplus(u), logistic(u)
205 if (u > 30.0) { sp = u; sig = 1.0; }
206 else if (u < -30.0) { sp = std::exp(u); sig = sp; }
207 else
208 {
209 const double eu = std::exp(u);
210 sp = std::log1p(eu);
211 sig = eu / (1.0 + eu);
212 }
213
214 const double e1 = (vpk / kKp) * sp;
215 if (e1 <= 0.0)
216 {
217 ip = 0.0;
218 dIpdVpk = 0.0;
219 dIpdVgk = 0.0;
220 return;
221 }
222 const double e1ex1 = std::pow(e1, kEx - 1.0);
223 ip = 2.0 * e1ex1 * e1 / kKg1;
224 const double dIpdE1 = 2.0 * kEx * e1ex1 / kKg1;
225
226 const double dUdVpk = -kKp * vgk * vpk / (s * s * s);
227 const double dE1dVpk = sp / kKp + (vpk / kKp) * sig * dUdVpk;
228 const double dE1dVgk = vpk * sig / s;
229 dIpdVpk = dIpdE1 * dE1dVpk;
230 dIpdVgk = dIpdE1 * dE1dVgk;
231 }
232
236 static double gridForCurrent(double plate, double current) noexcept
237 {
238 const double e1 = std::pow(current * kKg1 * 0.5, 1.0 / kEx);
239 const double z = e1 * kKp / plate;
240 const double u = z > 50.0 ? z : std::log(std::expm1(z));
241 return std::sqrt(kKvb + plate * plate) * (u / kKp - 1.0 / kMu);
242 }
243
244 // With S = B+ - A and G = Vgrid - A, the trapezoidal cathode
245 // equation Vk = A + B*Ip leaves a TWO-dimensional implicit load line:
246 // Ip = Koren(S - (RL+B)*Ip, G - B*Ip). B is fixed by the rate given
247 // to the constructor: the 1x point circuit passes its sample rate,
248 // the continuous core passes infinity (B = 0) and freezes A per
249 // interval. Use R = G/S to align the cutoff knee across supply
250 // voltages. The 156672-byte table has <5e-10 A error in the
251 // independent load-line check. Bicubic Hermite interpolation keeps
252 // current and both first derivatives continuous at cell edges.
253 // Every channel and both calibration passes share this instance.
254 struct Node
255 {
256 double y, ds, dg, dsg;
257 };
258 static constexpr int kNS = 18, kNG = 272;
259 std::array<Node, kNS * kNG> nodes;
260 double kb;
261
262 explicit TubePreampCurrentTable(double rate)
263 : kb((0.5 / (rate * kCk)) / (1.0 + 0.5 / (rate * kCk * kRk)))
264 {
265 for (int si = 0; si < kNS; ++si)
266 for (int gi = 0; gi < kNG; ++gi)
267 {
268 const double s = si <= 6 ? 80.0 + 8.0 * si
269 : 128.0 + 16.0 * (si - 6);
270 // Tail cells must still resolve the exponential enough
271 // to keep the cubic nonnegative.
272 const double r = gi <= 16 ? -0.08 + gi * 0.0025
273 : (gi <= 31 ? -0.04 + (gi - 16) * 0.001
274 : -0.025 + (gi - 31) / 6400.0);
275 Node n = solve(s, s * r);
276 n.ds += r * n.dg; // dI/dS with R held fixed
277 n.dg *= s; // dI/dR with S held fixed
278 // Differentiate the implicit dI/dR, not sampled currents;
279 // 0.001 V is much smaller than the 8/16 V supply cells.
280 n.dsg = ((s + 0.001) * solve(s + 0.001, (s + 0.001) * r).dg
281 - (s - 0.001) * solve(s - 0.001, (s - 0.001) * r).dg) / 0.002;
282 nodes[static_cast<size_t>(si * kNG + gi)] = n;
283 }
284 }
285
286 [[nodiscard]] Node solve(double supply, double grid) const noexcept
287 {
288 // Positive current is bounded by the plate load. Safeguard Newton
289 // with that bracket: setup converges faster than pure bisection
290 // without accepting a root on an unphysical branch.
291 double low = 0.0, high = supply / (kRL + kb);
292 double y = 0.5 * high, p = 0.0, dp = 0.0, dg = 0.0;
293 for (int k = 0; k < 60; ++k)
294 {
295 koren(supply - (kRL + kb) * y, grid - kb * y, p, dp, dg);
296 const double f = y - p;
297 // An absolute 1e-15 A stop leaves arbitrary residuals in
298 // cutoff nodes whose true current is much smaller. Those
299 // inconsistent values/slopes can make their cubic negative.
300 if (std::abs(f) <= std::max(1e-30, 1e-13 * std::max(y, p))) break;
301 if (f > 0.0) high = y;
302 else low = y;
303 const double slope = 1.0 + (kRL + kb) * dp + kb * dg;
304 const double next = y - f / slope;
305 y = next >= low && next <= high ? next : 0.5 * (low + high);
306 }
307 koren(supply - (kRL + kb) * y, grid - kb * y, p, dp, dg);
308 const double den = 1.0 + (kRL + kb) * dp + kb * dg;
309 return {y, dp / den, dg / den, 0.0};
310 }
311
312 static double cubic(double a, double b, double da, double db, double t) noexcept
313 {
314 const double diff = b - a;
315 return (((da + db - 2.0 * diff) * t + (3.0 * diff - 2.0 * da - db)) * t + da) * t + a;
316 }
317
318 [[nodiscard]] bool covers(double s, double g) const noexcept
319 {
320 return s >= 80.0 && s < 304.0 && std::isfinite(g) && g < 1.0;
321 }
322
323 [[nodiscard]] double eval(double s, double g) const noexcept
324 {
325 // Below this cutoff the unloaded Koren current is < 1e-27 A.
326 if (g <= -0.08 * s) return 0.0;
327 const double step = s < 128.0 ? 8.0 : 16.0;
328 const double r = g / s;
329 const double rstep = r < -0.04 ? 0.0025 : (r < -0.025 ? 0.001 : 1.0 / 6400.0);
330 const double sp = s < 128.0 ? (s - 80.0) / 8.0 : 6.0 + (s - 128.0) / 16.0;
331 const double gp = r < -0.04 ? (r + 0.08) * 400.0
332 : (r < -0.025 ? 16.0 + (r + 0.04) * 1000.0
333 : 31.0 + (r + 0.025) * 6400.0);
334 // Adding the grid offset can round a value just below the upper
335 // edge onto that edge. Use the final CELL, with t = 1, there.
336 const int si = std::min(static_cast<int>(sp), kNS - 2);
337 const int gi = std::min(static_cast<int>(gp), kNG - 2);
338 const double st = sp - si, gt = gp - gi;
339 const Node& a = nodes[static_cast<size_t>(si * kNG + gi)];
340 const Node& b = nodes[static_cast<size_t>((si + 1) * kNG + gi)];
341 const Node& c = nodes[static_cast<size_t>(si * kNG + gi + 1)];
342 const Node& d = nodes[static_cast<size_t>((si + 1) * kNG + gi + 1)];
343 const double p0 = cubic(a.y, b.y, a.ds * step, b.ds * step, st);
344 const double p1 = cubic(c.y, d.y, c.ds * step, d.ds * step, st);
345 const double m0 = rstep * cubic(a.dg, b.dg, a.dsg * step, b.dsg * step, st);
346 const double m1 = rstep * cubic(c.dg, d.dg, c.dsg * step, d.dsg * step, st);
347 return std::max(0.0, cubic(p0, p1, m0, m1, gt));
348 }
349
353 struct Slice
354 {
355 const Node* row0 = nullptr;
356 const Node* row1 = nullptr;
357 double s = 0.0, invS = 0.0, cut = 0.0;
358 double h00 = 0.0, h01 = 0.0, h10 = 0.0, h11 = 0.0;
359 bool inside = false;
360
361 void set(const TubePreampCurrentTable& table, double supply) noexcept
362 {
363 s = supply;
364 inside = supply >= 80.0 && supply < 304.0;
365 if (!inside) return;
366 invS = 1.0 / supply;
367 cut = -0.08 * supply;
368 const double step = supply < 128.0 ? 8.0 : 16.0;
369 const double sp = supply < 128.0 ? (supply - 80.0) / 8.0
370 : 6.0 + (supply - 128.0) / 16.0;
371 const int si = std::min(static_cast<int>(sp), kNS - 2);
372 const double t = sp - si;
373 h00 = (2.0 * t - 3.0) * t * t + 1.0;
374 h01 = (3.0 - 2.0 * t) * t * t;
375 h10 = step * ((t - 2.0) * t + 1.0) * t;
376 h11 = step * (t - 1.0) * t * t;
377 row0 = table.nodes.data() + si * kNG;
378 row1 = row0 + kNG;
379 }
380
382 [[nodiscard]] double eval(double g) const noexcept
383 {
384 if (g <= cut) return 0.0;
385 const double r = g * invS;
386 double gp, rstep;
387 if (r < -0.04) { gp = (r + 0.08) * 400.0; rstep = 0.0025; }
388 else if (r < -0.025) { gp = 16.0 + (r + 0.04) * 1000.0; rstep = 0.001; }
389 else { gp = 31.0 + (r + 0.025) * 6400.0; rstep = 1.0 / 6400.0; }
390 const int gi = std::min(static_cast<int>(gp), kNG - 2);
391 const double gt = gp - gi;
392 const Node& a = row0[gi];
393 const Node& c = row0[gi + 1];
394 const Node& b = row1[gi];
395 const Node& d = row1[gi + 1];
396 const double p0 = h00 * a.y + h01 * b.y + h10 * a.ds + h11 * b.ds;
397 const double p1 = h00 * c.y + h01 * d.y + h10 * c.ds + h11 * d.ds;
398 const double m0 = rstep * (h00 * a.dg + h01 * b.dg + h10 * a.dsg + h11 * b.dsg);
399 const double m1 = rstep * (h00 * c.dg + h01 * d.dg + h10 * c.dsg + h11 * d.dsg);
400 const double diff = p1 - p0;
401 const double v = (((m0 + m1 - 2.0 * diff) * gt + (3.0 * diff - 2.0 * m0 - m1)) * gt
402 + m0) * gt + p0;
403 return v > 0.0 ? v : 0.0;
404 }
405 };
406 };
407
409 struct TubePreampCoreDesign
410 {
411 static constexpr int kKernelOrder = 6;
412 int taps = 0;
413 int degree = 0;
414 const double* farrow = nullptr;
416 std::array<std::array<double, kKernelOrder>, kKernelOrder> kernel {};
417 std::vector<double> compensation;
418 int latency = 0;
419
420 explicit TubePreampCoreDesign(int factor)
421 {
422 // Least-squares Farrow fits over 0..22 kHz at 48 kHz (scaled with
423 // the base rate), interpolating the two interval samples. Max
424 // error vs the ideal band-limited interpolant: -82.8 dB (2x),
425 // -104.2 dB (4x), -108.2 dB (8x), -104.6 dB (16x). The droop
426 // compensation inverts sinc(f)^6 over the same band to better
427 // than 2e-6 dB.
428 static constexpr std::array<double, 100> kFarrow2 = {
429 4.2321905086886495e-10, 0.0059466381516058153, -0.002278523941500805,
430 -0.0054952548957599556, -0.014968825413515578, 0.054109227099701757,
431 -0.078269013079604377, 0.062385435811858708, -0.025290994449854602,
432 0.0038613094605228345, -2.2707239900454428e-09, -0.04509673531374573,
433 0.02130560198974633, 0.044927139987920424, 0.068139628956554815,
434 -0.29034355992063943, 0.41888697256651169, -0.33093026606533477,
435 0.13329150667830314, -0.020180282199047366, 6.3151437033992178e-09,
436 0.19516443759783259, -0.125629932201473, -0.18510777189666724,
437 -0.11518179249706405, 0.79144728674092368, -1.1628931982523558,
438 0.91250354516673049, -0.36515236023089487, 0.0548497671327925,
439 -1.1679462053512646e-08, -0.73810415625364523, 0.8470174584244009,
440 0.17008113400299396, 0.090159156301663379, -1.4018192300222059,
441 2.141392397438906, -1.6757774481811469, 0.66635226461915309,
442 -0.099301542452499336, 1.0000000156560307, -0.15092152990607394,
443 -1.4813400355254824, 0.32501007016593614, -0.10665904644708368,
444 1.7796410825385522, -2.8452200456412218, 2.2313784198552291,
445 -0.8823072013373332, 0.13041824116835923, -1.5700830890361179e-08,
446 1.0030991668852851, 0.84865934877928584, -0.74002126985840833,
447 0.25444017376234029, -1.7071024411426592, 2.8159173504397854,
448 -2.2212010942574856, 0.87438979546714801, -0.12818098509269454,
449 1.1781332260547313e-08, -0.37253030413198274, -0.12785657025701674,
450 0.56505142242672013, -0.33848444021117219, 1.2561039702068575,
451 -2.0800938843434165, 1.6517199081920575, -0.64812744525532984,
452 0.094217309828563259, -6.4097566291092411e-09, 0.13220572975363162,
453 0.022984358585025653, -0.23015119647358298, 0.23722601584356839,
454 -0.68523256280710365, 1.1138842977913868, -0.88863883259602172,
455 0.3478529827090574, -0.050130774674655959, 2.3207754129408065e-09,
456 -0.034616695799025515, -0.0030424597428906587, 0.06570936592805314,
457 -0.092489611102646821, 0.25062568572871546, -0.39729342975322812,
458 0.31732189085987816, -0.12390188239748591, 0.017687129761438363,
459 -4.3623536317367461e-10, 0.0049101708741372896, 0.00017308418349044246,
460 -0.010091070767974901, 0.017824294982185494, -0.04733411530281835,
461 0.073532096156291082, -0.058588400350969086, 0.022788475313836876,
462 -0.0032145338738844057
463 };
464 static constexpr std::array<double, 8> kComp2 = {
465 2.71867910879388, -1.2024111681479335, 0.46738582076148377,
466 -0.16331116573163179, 0.048832017855856398, -0.011604018931486495,
467 0.0019406397776269202, -0.00017174220997273029
468 };
469 static constexpr std::array<double, 64> kFarrow4 = {
470 -1.2900536553478881e-06, -0.0080151506262985533, -0.17777590204833338,
471 1.3139119710287313, -4.0413832196153869, 6.3642884759250222,
472 -4.992867908276553, 1.5418432994555842, 7.9871493323757734e-06,
473 0.088046779796333074, 1.0604467092541447, -8.1687752563172111,
474 25.099313210118467, -39.488286729375986, 30.975170253454831,
475 -9.5659246558470752, -2.2105953216776039e-05, -0.55815096062851943,
476 -2.3938696401524284, 22.40917634184877, -69.6019865941457,
477 109.51192256668676, -85.894556644533552, 26.527491731519852,
478 1.0000353884966167, -0.33265191629960283, 3.674166593014272,
479 -35.4328783727806, 111.52063276854375, -175.64384089126642,
480 137.76206099998669, -42.547532060929456, -3.5359293796327108e-05,
481 1.098889851519979, -4.2836042287146743, 35.135653508220415,
482 -111.44779322067839, 175.80749233768327, -137.90312082065205,
483 42.592525392586815, 2.205090483678061e-05, -0.37161767694349446,
484 3.0655130552579171, -21.923092860130158, 69.500034073335797,
485 -109.8183373403734, 86.15979318327652, -26.612319122360798,
486 -7.9535630817034395e-06, 0.095783904254697808, -1.1276037484567922,
487 7.9530205187897351, -25.075497255063709, 39.67283956818612,
488 -31.136230566192772, 9.6176971986688393, 1.2822935194890068e-06,
489 -0.012285737644740915, 0.18272380080694042, -1.2870126749889277,
490 4.0467156665242037, -6.4061717578660602, 5.0298415014987468,
491 -1.5538123482944795
492 };
493 static constexpr std::array<double, 7> kComp4 = {
494 0.77932253944179963, 0.48450011964418033, -0.63543552895607291,
495 0.36687547570613865, -0.13057476517676106, 0.027680644656114826,
496 -0.0027072253534879587
497 };
498 static constexpr std::array<double, 36> kFarrow8 = {
499 2.1428912092597181e-06, -0.068053152547346507, 0.82615315111595389,
500 -2.25344764450442, 2.3863455617383647, -0.89100287142393453,
501 -1.0400687109947751e-05, 0.076025067275552194, -3.5474940938342541,
502 10.692630341379365, -11.548180887960177, 4.3270436164777122,
503 1.0000204893461682, -1.474312011333621, 7.0561524197541718,
504 -20.727998495189027, 22.674059266177732, -8.5279485269151927,
505 -2.0475134054884214e-05, 2.1464836357794721, -7.6380358894712534,
506 20.54461472771839, -22.577478060052183, 8.5244628827979447,
507 1.0378974319923476e-05, -0.83433580274857921, 4.1702488442657986,
508 -10.417228839352022, 11.403009710027204, -4.3217178780150958,
509 -2.1353801282180795e-06, 0.15419278048117258, -0.8670223096885199,
510 2.1614225669978211, -2.3377483222244333, 0.88916021323615935
511 };
512 static constexpr std::array<double, 4> kComp8 = {
513 2.0670375203804419, -0.6482179311307088, 0.12681513715972098,
514 -0.012115968043045313
515 };
516 static constexpr std::array<double, 16> kFarrow16 = {
517 -4.7274945894591747e-08, -0.33475471468652312, 0.50168301501965318,
518 -0.16692822036410673, 1.0000001518786812, -0.49831699527905698,
519 -1.0024720850935107, 0.50078884218257014, -1.6291311300334343e-07,
520 1.0008871433692914, 0.4999030230846026, -0.50078992841108072,
521 5.8345140851491506e-08, -0.16782377216653666, 0.00089437399553421448,
522 0.16692931835075694
523 };
524 static constexpr std::array<double, 4> kComp16 = {
525 2.2958105734077243, -0.82005508291669238, 0.19585990127822545,
526 -0.023710110331518937
527 };
528 switch (factor)
529 {
530 case 2: taps = 10; farrow = kFarrow2.data(); setCompensation(kComp2, 0); break;
531 case 4: taps = 8; farrow = kFarrow4.data(); setCompensation(kComp4, 0); break;
532 case 8: taps = 6; farrow = kFarrow8.data(); setCompensation(kComp8, 0); break;
533 default: taps = 4; farrow = kFarrow16.data(); setCompensation(kComp16, 9); break;
534 }
535 degree = taps - 1;
536 // Interval n is processed when x[n + taps/2] arrives; its output
537 // moments complete z[n - 2]; the FIR centre adds its half length.
538 const int delay = taps / 2 + 2 + compensationHalf_ + padding_;
539 latency = delay / factor;
540 // Order-6 B-spline: B(u) = sum_j (-1)^j C(6,j) (u + 3 - j)_+^5 / 120.
541 // For output m = n + k and tau = 1/2 + d, u = k - 1/2 - d; every
542 // truncated power keeps one sign over the interval, so each piece
543 // is an exact polynomial in d.
544 constexpr double binom6[7] = {1, 6, 15, 20, 15, 6, 1};
545 constexpr double binom5[6] = {1, 5, 10, 10, 5, 1};
546 for (int ko = 0; ko < kKernelOrder; ++ko)
547 {
548 const int k = ko - 2;
549 for (int j = 0; j <= 6; ++j)
550 {
551 const double c = k + 2.5 - j;
552 if (c <= 0.0) continue;
553 const double sign = (j & 1) ? -1.0 : 1.0;
554 for (int p = 0; p <= 5; ++p)
555 {
556 const double term = sign * binom6[j] * binom5[p] * std::pow(c, 5 - p)
557 * ((p & 1) ? -1.0 : 1.0) / 120.0;
558 kernel[static_cast<size_t>(ko)][static_cast<size_t>(p)] += term;
559 }
560 }
561 }
562 }
563
564 private:
565 int padding_ = 0, compensationHalf_ = 0;
566
567 template <size_t N>
568 void setCompensation(const std::array<double, N>& half, int padding)
569 {
570 // half[0] is the centre tap; mirror, then delay by `padding`.
571 const int h = static_cast<int>(N) - 1;
572 padding_ = padding;
573 compensationHalf_ = h;
574 compensation.assign(static_cast<size_t>(2 * h + 1 + padding), 0.0);
575 for (int q = 0; q <= h; ++q)
576 {
577 compensation[static_cast<size_t>(padding + h + q)] = half[static_cast<size_t>(q)];
578 compensation[static_cast<size_t>(padding + h - q)] = half[static_cast<size_t>(q)];
579 }
580 }
581 };
582
584 struct TubePreampToneModes
585 {
586 double lambda[3] {};
587 double beta[3] {};
588 double gamma[3] {};
589 double direct = 0.0;
590 double toModal[3][3] {};
591 double toPhysical[3][3] {};
592
593 void design(const wdf::ToneStackFMV<double>::AnalogStateSpace& ss,
594 double sampleRate) noexcept
595 {
596 // a = -C^-1 G with symmetric G: S = C^(1/2) a C^(-1/2) is symmetric.
597 double r[3], ri[3];
598 for (int i = 0; i < 3; ++i)
599 {
600 r[i] = std::sqrt(ss.capacitance[i]);
601 ri[i] = 1.0 / r[i];
602 }
603 double s[3][3], q[3][3] {};
604 for (int i = 0; i < 3; ++i)
605 for (int j = 0; j < 3; ++j)
606 s[i][j] = r[i] * ss.a[i][j] * ri[j];
607 for (int i = 0; i < 3; ++i)
608 for (int j = i + 1; j < 3; ++j)
609 s[i][j] = s[j][i] = 0.5 * (s[i][j] + s[j][i]);
610 for (int i = 0; i < 3; ++i) q[i][i] = 1.0;
611 // Cyclic Jacobi rotations; robust also for coincident eigenvalues.
612 for (int sweep = 0; sweep < 32; ++sweep)
613 {
614 double off = 0.0, norm = 0.0;
615 for (int i = 0; i < 3; ++i)
616 for (int j = 0; j < 3; ++j)
617 {
618 norm += s[i][j] * s[i][j];
619 if (i != j) off += s[i][j] * s[i][j];
620 }
621 if (off <= 1e-30 * norm) break;
622 for (int p = 0; p < 2; ++p)
623 for (int k = p + 1; k < 3; ++k)
624 {
625 if (s[p][k] == 0.0) continue;
626 const double theta = 0.5 * (s[k][k] - s[p][p]) / s[p][k];
627 const double t = (theta >= 0.0 ? 1.0 : -1.0)
628 / (std::abs(theta) + std::sqrt(theta * theta + 1.0));
629 const double c = 1.0 / std::sqrt(t * t + 1.0), sn = t * c;
630 for (int i = 0; i < 3; ++i)
631 {
632 const double sip = s[i][p], sik = s[i][k];
633 s[i][p] = c * sip - sn * sik;
634 s[i][k] = sn * sip + c * sik;
635 }
636 for (int i = 0; i < 3; ++i)
637 {
638 const double spi = s[p][i], ski = s[k][i];
639 s[p][i] = c * spi - sn * ski;
640 s[k][i] = sn * spi + c * ski;
641 }
642 for (int i = 0; i < 3; ++i)
643 {
644 const double qip = q[i][p], qik = q[i][k];
645 q[i][p] = c * qip - sn * qik;
646 q[i][k] = sn * qip + c * qik;
647 }
648 }
649 }
650 const double period = 1.0 / sampleRate;
651 for (int m = 0; m < 3; ++m)
652 {
653 lambda[m] = s[m][m] * period;
654 double bm = 0.0, gm = 0.0;
655 for (int i = 0; i < 3; ++i)
656 {
657 toModal[m][i] = q[i][m] * r[i]; // Q^T C^(1/2)
658 toPhysical[i][m] = ri[i] * q[i][m]; // C^(-1/2) Q
659 bm += toModal[m][i] * ss.b[i];
660 gm += ss.c[i] * toPhysical[i][m];
661 }
662 beta[m] = bm * period;
663 gamma[m] = gm;
664 }
665 direct = ss.d;
666 }
667 };
668
672 inline void tubePreampPhi(double w, double* p) noexcept
673 {
674 const double aw = std::abs(w);
675 if (aw < 0.5)
676 {
677 static constexpr double inv[16] = {1.0, 1.0, 1.0 / 2, 1.0 / 6, 1.0 / 24, 1.0 / 120,
678 1.0 / 720, 1.0 / 5040, 1.0 / 40320, 1.0 / 362880, 1.0 / 3628800,
679 1.0 / 39916800, 1.0 / 479001600, 1.0 / 6227020800.0,
680 1.0 / 87178291200.0, 1.0 / 1307674368000.0};
681 const int n = aw < 1e-3 ? 3 : (aw < 0.02 ? 5 : (aw < 0.1 ? 7 : 10));
682 double v = inv[4 + n];
683 for (int j = n - 1; j >= 0; --j) v = v * w + inv[4 + j];
684 p[4] = v;
685 p[3] = w * v + inv[3];
686 p[2] = w * p[3] + inv[2];
687 p[1] = w * p[2] + 1.0;
688 p[0] = w * p[1] + 1.0;
689 }
690 else
691 {
692 p[0] = std::exp(w);
693 p[1] = (p[0] - 1.0) / w;
694 p[2] = (p[1] - 1.0) / w;
695 p[3] = (p[2] - 0.5) / w;
696 p[4] = (p[3] - 1.0 / 6.0) / w;
697 }
698 }
699} // namespace detail
701
708template <FloatType T>
710{
711public:
712 // -- Lifecycle ---------------------------------------------------------------
713
718 void prepare(const AudioSpec& spec)
719 {
720 if (!spec.isValid()) return;
721 prepared_.store(false, std::memory_order_relaxed);
722 spec_ = spec;
723 sampleRate_ = spec.sampleRate;
724 mixMaxStep_ = static_cast<T>(1.0 / std::max(1.0, sampleRate_ * 0.02));
725 // Internal processing rate = active oversampling factor x base rate
726 // (the factor is configurable; 1 = off, no resampling).
727 fs2_ = static_cast<double>(osFactor_) * sampleRate_;
728 numChannels_ = spec.numChannels;
729 maxBlock_ = std::max(spec.maxBlockSize, 1);
730 driveLogSmoother_.prepare(fs2_, 30.0);
731 for (auto& smoother : outputLogSmoothers_)
732 smoother.prepare(fs2_, 30.0);
734 stageBlend_.prepare(fs2_, 20.0);
735 for (auto& ramp : gainRamps_)
736 ramp.resize(static_cast<size_t>(maxBlock_) * static_cast<size_t>(osFactor_));
737 for (auto& scratch : compensationScratch_)
738 scratch.resize(static_cast<size_t>(maxBlock_) * static_cast<size_t>(osFactor_));
739 stageRamp_.resize(static_cast<size_t>(maxBlock_) * static_cast<size_t>(osFactor_));
740
741 if (osFactor_ > 1)
742 {
743 oversampler_ = std::make_unique<Oversampling<T>>(
745 oversampler_->prepare(spec);
746 design_ = std::make_unique<detail::TubePreampCoreDesign>(osFactor_);
747 }
748 else
749 {
750 oversampler_.reset();
751 design_.reset();
752 }
753
754 // The continuous core freezes the cathode per interval (B = 0); the
755 // 1x point circuit keeps the trapezoidal cathode coupling.
756 loadTable_ = std::make_unique<LoadTable>(
757 design_ ? std::numeric_limits<double>::infinity() : fs2_);
758 for (auto& lane : channels_)
759 {
760 lane.clear();
761 lane.resize(static_cast<size_t>(numChannels_));
762 for (auto& ch : lane)
763 ch = std::make_unique<ChannelState>(fs2_, loadTable_.get(), design_.get());
764 }
765
766 latency_ = (oversampler_ ? oversampler_->getLatency() : 0) + (design_ ? design_->latency : 0);
767 drySize_ = 1;
768 while (drySize_ < latency_ + maxBlock_ + 1) drySize_ <<= 1;
769 dryRing_.assign(static_cast<size_t>(numChannels_),
770 std::vector<T>(static_cast<size_t>(drySize_), T(0)));
771 dryPos_ = 0;
772
773 calibrateReference();
774
775 prepared_.store(true, std::memory_order_relaxed);
776 dirty_.store(true, std::memory_order_release);
777 reset();
778 }
779
781 void reset() noexcept
782 {
783 if (!prepared_.load(std::memory_order_relaxed)) return;
784 const double sagR = static_cast<double>(sag_.load(std::memory_order_relaxed)) * 40e3;
785 const int stages = stages_.load(std::memory_order_relaxed);
786 for (int st = 1; st <= 2; ++st)
787 for (auto& ch : channels_[static_cast<size_t>(st - 1)])
788 ch->reset(sagR, st);
789 numStagesActive_ = stages;
790 stageBlend_.reset(static_cast<double>(stages - 1));
791 stageWarmupRemaining_ = 0;
792 bothStagesActive_ = false;
793 for (auto& d : dryRing_)
794 std::fill(d.begin(), d.end(), T(0));
795 dryPos_ = 0;
796 if (oversampler_) oversampler_->reset();
797 // Seed the anti-zipper ramps at their targets: no fade-in on start.
798 gainsInitialized_ = false;
799 currentMix_ = mix_.load(std::memory_order_relaxed);
800 }
801
802 // -- Parameters (thread-safe) ---------------------------------------------------
803
806 void setDrive(T db) noexcept
807 {
808 if (!std::isfinite(db)) return;
809 driveDb_.store(std::clamp(db, T(-12), T(36)), std::memory_order_relaxed);
810 dirty_.store(true, std::memory_order_release);
811 }
812
814 void setTreble(T treble) noexcept
815 {
816 if (!std::isfinite(treble)) return;
817 treble_.store(std::clamp(treble, T(0), T(1)), std::memory_order_relaxed);
818 dirty_.store(true, std::memory_order_release);
819 }
820
823 void setBass(T bass) noexcept
824 {
825 if (!std::isfinite(bass)) return;
826 bass_.store(std::clamp(bass, T(0), T(1)), std::memory_order_relaxed);
827 dirty_.store(true, std::memory_order_release);
828 }
829
831 void setMiddle(T middle) noexcept
832 {
833 if (!std::isfinite(middle)) return;
834 middle_.store(std::clamp(middle, T(0), T(1)), std::memory_order_relaxed);
835 dirty_.store(true, std::memory_order_release);
836 }
837
839 void setSag(T sag) noexcept
840 {
841 if (!std::isfinite(sag)) return;
842 sag_.store(std::clamp(sag, T(0), T(1)), std::memory_order_relaxed);
843 dirty_.store(true, std::memory_order_release);
844 }
845
850 void setStages(int stages) noexcept
851 {
852 stages_.store(std::clamp(stages, 1, 2), std::memory_order_relaxed);
853 dirty_.store(true, std::memory_order_release);
854 }
855
869 void setOversampling(int factor)
870 {
871 if (factor < 1 || factor > 16 || (factor & (factor - 1)) != 0) return;
872 if (factor == osFactor_) return;
873 osFactor_ = factor;
874 // Rebuild the whole chain at the new internal rate if already prepared
875 // (fs2_, oversampler, per-channel circuits and calibration all depend
876 // on the factor). Same setup-thread cost as prepare().
877 if (prepared_.load(std::memory_order_relaxed))
878 prepare(spec_);
879 }
880
882 [[nodiscard]] int getOversamplingFactor() const noexcept { return osFactor_; }
883
885 void setOutput(T db) noexcept
886 {
887 if (!std::isfinite(db)) return;
888 outputDb_.store(std::clamp(db, T(-24), T(12)), std::memory_order_relaxed);
889 }
890
893 void setMix(T mix) noexcept
894 {
895 if (!std::isfinite(mix)) return;
896 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
897 }
898
899 [[nodiscard]] T getDrive() const noexcept { return driveDb_.load(std::memory_order_relaxed); }
900 [[nodiscard]] T getTreble() const noexcept { return treble_.load(std::memory_order_relaxed); }
901 [[nodiscard]] T getBass() const noexcept { return bass_.load(std::memory_order_relaxed); }
902 [[nodiscard]] T getMiddle() const noexcept { return middle_.load(std::memory_order_relaxed); }
903 [[nodiscard]] T getSag() const noexcept { return sag_.load(std::memory_order_relaxed); }
904 [[nodiscard]] int getStages() const noexcept { return stages_.load(std::memory_order_relaxed); }
905 [[nodiscard]] T getOutput() const noexcept { return outputDb_.load(std::memory_order_relaxed); }
906 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
907
911 [[nodiscard]] int getLatency() const noexcept { return latency_; }
912
914 [[nodiscard]] int getLatencySamples() const noexcept { return getLatency(); }
915
918 [[nodiscard]] T getSupplyVoltage() const noexcept
919 {
920 return supplyNow_.load(std::memory_order_relaxed);
921 }
922
924 [[nodiscard]] std::vector<uint8_t> getState() const
925 {
926 StateWriter w(stateId("TUBE"), 1);
927 // Explicit float casts: the blob stores float, and with T = double the
928 // unqualified write(key, double) would be ambiguous (float/int32/bool).
929 w.write("drive", static_cast<float>(driveDb_.load(std::memory_order_relaxed)));
930 w.write("treble", static_cast<float>(treble_.load(std::memory_order_relaxed)));
931 w.write("bass", static_cast<float>(bass_.load(std::memory_order_relaxed)));
932 w.write("middle", static_cast<float>(middle_.load(std::memory_order_relaxed)));
933 w.write("sag", static_cast<float>(sag_.load(std::memory_order_relaxed)));
934 w.write("stages", stages_.load(std::memory_order_relaxed));
935 w.write("output", static_cast<float>(outputDb_.load(std::memory_order_relaxed)));
936 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
937 w.write("oversampling", osFactor_);
938 return w.blob();
939 }
940
942 bool setState(const uint8_t* data, size_t size)
943 {
944 StateReader r(data, size);
945 if (!r.isValid() || r.processorId() != stateId("TUBE")) return false;
946 setDrive(static_cast<T>(r.read("drive", 0.0f)));
947 setTreble(static_cast<T>(r.read("treble", 0.5f)));
948 setBass(static_cast<T>(r.read("bass", 0.5f)));
949 setMiddle(static_cast<T>(r.read("middle", 0.5f)));
950 setSag(static_cast<T>(r.read("sag", 0.3f)));
951 setStages(r.read("stages", 1));
952 setOutput(static_cast<T>(r.read("output", 0.0f)));
953 setMix(static_cast<T>(r.read("mix", 1.0f)));
954 // Default 2 = the historical fixed factor, so blobs written before
955 // it was configurable restore the 2x behaviour they were captured with.
956 setOversampling(r.read("oversampling", 2));
957 return true;
958 }
959
960 // -- Processing -------------------------------------------------------------------
961
963 void processBlock(AudioBufferView<T> buffer) noexcept
964 {
965 if (!prepared_.load(std::memory_order_relaxed)) return;
966 DenormalGuard guard;
967
968 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
969 const int nS = buffer.getNumSamples();
970 if (nCh == 0 || nS == 0) return;
971
972 // Front-door non-finite guard: a single NaN/Inf input sample would
973 // poison the recursive circuit, tone-circuit, supply sag, output
974 // DC-blocker and flatten-EQ state PERMANENTLY (only reset() clears
975 // it, not clean input). Replace bad samples with silence before
976 // they reach any state, so a transient upstream glitch cannot corrupt
977 // the channel for the rest of the stream.
978 for (int ch = 0; ch < nCh; ++ch)
979 {
980 T* d = buffer.getChannel(ch);
981 for (int i = 0; i < nS; ++i)
982 if (!std::isfinite(d[i])) d[i] = T(0);
983 }
984
985 // Acquire pairs with the setters' release stores so the recompute
986 // always sees the values published before the flag.
987 if (dirty_.load(std::memory_order_relaxed)
988 && dirty_.exchange(false, std::memory_order_acquire))
989 recompute();
990
991 // Rate-limited mix ramp (moveTowards, exact landing; settled it
992 // reduces to the constant, bit-identically).
993 const T mixTarget = mix_.load(std::memory_order_relaxed);
994 const T mixStart = currentMix_;
995 const double trim = std::pow(10.0,
996 static_cast<double>(outputDb_.load(std::memory_order_relaxed)) / 20.0);
997 const std::array<double, 2> outGain { mScale_[0] * trim, mScale_[1] * trim };
998
999 // Dry snapshot.
1000 for (int ch = 0; ch < nCh; ++ch)
1001 {
1002 const T* in = buffer.getChannel(ch);
1003 auto& dry = dryRing_[static_cast<size_t>(ch)];
1004 int dp = dryPos_;
1005 for (int i = 0; i < nS; ++i)
1006 {
1007 dry[static_cast<size_t>(dp)] = in[i];
1008 dp = (dp + 1) & (drySize_ - 1);
1009 }
1010 }
1011
1012 // Nonlinear circuit at the active oversampling factor (1x = process the
1013 // base-rate buffer in place, no resampling).
1014 {
1015 const bool osOn = (oversampler_ != nullptr);
1016 auto osView = osOn ? oversampler_->upsample(buffer) : buffer;
1017 const int osN = osView.getNumSamples();
1018
1019 // Smooth the compensation pair in logarithmic gain at the
1020 // internal sample clock. Interpolating between block endpoints
1021 // changes the trajectory when a host changes its block size.
1022 // Core's sample-exact one-pole retains the 30 ms time constant.
1023 const double driveLog = std::log(hScale_);
1024 if (!gainsInitialized_)
1025 {
1026 driveLogSmoother_.reset(driveLog);
1027 for (size_t st = 0; st < 2; ++st)
1028 outputLogSmoothers_[st].reset(std::log(outGain[st]));
1029 gainsInitialized_ = true;
1030 }
1031 driveLogSmoother_.setTargetValue(driveLog);
1032 const bool driveRamping = driveLogSmoother_.isSmoothing();
1033 if (driveRamping)
1034 {
1035 driveLogSmoother_.processBlock(
1036 std::span<double>(gainRamps_[0].data(), static_cast<size_t>(osN)));
1037 for (int i = 0; i < osN; ++i)
1038 {
1039 auto& value = gainRamps_[0][static_cast<size_t>(i)];
1040 value = value == driveLog ? hScale_ : std::exp(value);
1041 }
1042 }
1043 std::array<bool, 2> outputRamping {};
1044 for (size_t st = 0; st < 2; ++st)
1045 {
1046 const double outputLog = std::log(outGain[st]);
1047 auto& smoother = outputLogSmoothers_[st];
1048 smoother.setTargetValue(outputLog);
1049 outputRamping[st] = smoother.isSmoothing();
1050 if (!outputRamping[st]) continue;
1051 smoother.processBlock(
1052 std::span<double>(gainRamps_[st + 1].data(), static_cast<size_t>(osN)));
1053 for (int i = 0; i < osN; ++i)
1054 {
1055 auto& value = gainRamps_[st + 1][static_cast<size_t>(i)];
1056 value = value == outputLog ? outGain[st] : std::exp(value);
1057 }
1058 }
1059
1060 // Advance the transition once per internal frame, shared by all
1061 // channels. The inactive circuit stops at the exact ramp endpoint,
1062 // even if the host's block extends beyond it.
1063 int transitionSamples = 0;
1064 while (bothStagesActive_ && transitionSamples < osN)
1065 {
1066 double w = stageBlend_.getCurrentValue();
1067 if (stageWarmupRemaining_ > 0) --stageWarmupRemaining_;
1068 else w = stageBlend_.getNextValue();
1069 stageRamp_[static_cast<size_t>(transitionSamples++)] = w * w * (3.0 - 2.0 * w);
1070 if (stageWarmupRemaining_ == 0 && !stageBlend_.isSmoothing())
1071 bothStagesActive_ = false;
1072 }
1073 const size_t target = static_cast<size_t>(numStagesActive_ - 1);
1074 for (int ch = 0; ch < nCh; ++ch)
1075 {
1076 T* d = osView.getChannel(ch);
1077 for (int i = 0; i < osN; ++i)
1078 {
1079 const double h = driveRamping ? gainRamps_[0][static_cast<size_t>(i)] : hScale_;
1080 const double x = h * static_cast<double>(d[i]);
1081 for (size_t st = 0; st < 2; ++st)
1082 {
1083 if (st != target && i >= transitionSamples) continue;
1084 compensationScratch_[st][static_cast<size_t>(i)] =
1085 channels_[st][static_cast<size_t>(ch)]->template processSample<false>(
1086 x, static_cast<int>(st) + 1, sagR_);
1087 }
1088 }
1089 if (osOn)
1090 {
1091 for (size_t st = 0; st < 2; ++st)
1092 {
1093 const int count = st == target ? osN : transitionSamples;
1094 if (count > 0)
1095 channels_[st][static_cast<size_t>(ch)]->compensateBlock(
1096 compensationScratch_[st].data(), count);
1097 }
1098 }
1099 for (int i = 0; i < osN; ++i)
1100 {
1101 const auto index = static_cast<size_t>(i);
1102 const double g = outputRamping[target] ? gainRamps_[target + 1][index] : outGain[target];
1103 double value = g * compensationScratch_[target][index];
1104 if (i < transitionSamples)
1105 {
1106 const size_t other = 1 - target;
1107 const double gOther = outputRamping[other] ? gainRamps_[other + 1][index] : outGain[other];
1108 const double alternate = gOther * compensationScratch_[other][index];
1109 const double w = target == 1 ? stageRamp_[index] : 1.0 - stageRamp_[index];
1110 value = alternate + w * (value - alternate);
1111 }
1112 d[i] = static_cast<T>(value);
1113 }
1114 }
1115 if (osOn) oversampler_->downsample(buffer);
1116 const double position = stageBlend_.getCurrentValue();
1117 const double w = position * position * (3.0 - 2.0 * position);
1118 const double current = (1.0 - w) * channels_[0][0]->supplyCurrent()
1119 + w * channels_[1][0]->supplyCurrent();
1120 supplyNow_.store(static_cast<T>(kBplus - sagR_ * current),
1121 std::memory_order_relaxed);
1122 }
1123
1124 // Latency-compensated mix (ramped: a hard flip on the distorted wet
1125 // stream clicked at 1.6x the steady-state sample delta).
1126 for (int ch = 0; ch < nCh; ++ch)
1127 {
1128 T* d = buffer.getChannel(ch);
1129 const auto& dry = dryRing_[static_cast<size_t>(ch)];
1130 for (int i = 0; i < nS; ++i)
1131 {
1132 const int idx = (dryPos_ + i - latency_) & (drySize_ - 1);
1133 const T drySample = dry[static_cast<size_t>(idx)];
1134 const T mixVal = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(i + 1));
1135 d[i] = drySample + (d[i] - drySample) * mixVal;
1136 }
1137 }
1138 currentMix_ = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(nS));
1139 dryPos_ = (dryPos_ + nS) & (drySize_ - 1);
1140 }
1141
1142private:
1143 // -- Circuit constants (classic 12AX7 common-cathode stage) -------------------
1144 static constexpr double kBplus = 300.0;
1145 static constexpr double kRL = detail::TubePreampCurrentTable::kRL;
1146 static constexpr double kRk = detail::TubePreampCurrentTable::kRk;
1147 static constexpr double kCk = detail::TubePreampCurrentTable::kCk;
1148 static constexpr double kInterstage = 0.12;
1149
1150 using LoadTable = detail::TubePreampCurrentTable;
1151 using Clamp = detail::TubePreampGridClamp;
1152
1153 static void koren(double vpk, double vgk, double& ip,
1154 double& dIpdVpk, double& dIpdVgk) noexcept
1155 {
1156 LoadTable::koren(vpk, vgk, ip, dIpdVpk, dIpdVgk);
1157 }
1158
1160 static double settleCurrent(double bplus, double grid = 0.0) noexcept
1161 {
1162 double i = 8e-4;
1163 for (int it = 0; it < 60; ++it)
1164 {
1165 const double vkS = i * kRk;
1166 const double vpk = bplus - i * kRL - vkS;
1167 double ipK = 0.0, dVpk = 0.0, dVgk = 0.0;
1168 koren(vpk, grid - vkS, ipK, dVpk, dVgk);
1169 const double f = i - ipK;
1170 const double fp = 1.0 - (dVpk * (-(kRL + kRk)) + dVgk * (-kRk));
1171 const double di = f / fp;
1172 i -= di;
1173 i = std::clamp(i, 0.0, bplus / (kRL + kRk));
1174 if (std::abs(di) < 1e-15) break;
1175 }
1176 return i;
1177 }
1178
1181 struct TriodeStage
1182 {
1183 const LoadTable* table = nullptr;
1184 double fs2 = 96000.0;
1185 double ip = 8e-4;
1186 double vk = 1.2;
1187 double fPrev = 0.0;
1188 double vpDC = 200.0;
1189
1190 void settleDC(double bplus) noexcept
1191 {
1192 ip = settleCurrent(bplus);
1193 vk = ip * kRk;
1194 fPrev = 0.0;
1195 vpDC = bplus - ip * kRL;
1196 }
1197
1199 [[nodiscard]] double processSample(double vg, double bplusEff) noexcept
1200 {
1201 vg = Clamp::value(vg);
1202 // Trapezoidal cathode bypass: Ck dVk/dt = Ip - Vk/Rk, with fPrev
1203 // holding the previous NET CURRENT (Ip - Vk/Rk), so the update is
1204 // Vk_n = Vk_{n-1} + (T/2Ck)(I_n + I_{n-1}) = kA + kB * Ip_n.
1205 const double h2c = 0.5 / (fs2 * kCk);
1206 const double denom = 1.0 + h2c / kRk;
1207 const double kA = (vk + h2c * fPrev) / denom;
1208 const double kB = h2c / denom;
1209
1210 const double iMax = bplusEff / kRL + 1e-3;
1211 double i = std::clamp(ip, 0.0, iMax);
1212 // Outside the prepared supply/grid range, retain the analytic
1213 // circuit solve. The table includes a bounded deep-cutoff limit.
1214 if (table->covers(bplusEff - kA, vg - kA))
1215 i = table->eval(bplusEff - kA, vg - kA);
1216 else for (int it = 0; it < 8; ++it)
1217 {
1218 const double vkN = kA + kB * i;
1219 const double vpk = bplusEff - i * kRL - vkN;
1220 const double vgk = vg - vkN;
1221 double ipK = 0.0, dVpk = 0.0, dVgk = 0.0;
1222 koren(vpk, vgk, ipK, dVpk, dVgk);
1223
1224 const double f = i - ipK;
1225 const double fp = 1.0 - (dVpk * (-kRL - kB) + dVgk * (-kB));
1226 const double di = f / std::max(fp, 1e-6);
1227 i -= di;
1228 i = std::clamp(i, 0.0, iMax);
1229 if (std::abs(di) < 1e-12) break;
1230 }
1231
1232 const double vkN = kA + kB * i;
1233 fPrev = i - vkN / kRk; // net capacitor current for the trapezoid
1234 vk = vkN;
1235 ip = i;
1236 return (bplusEff - i * kRL) - vpDC; // AC component
1237 }
1238 };
1239
1245 struct ContinuousCore
1246 {
1247 static constexpr int kLevels = 5;
1248 static constexpr int kMaxPieces = 48;
1249 static constexpr int kOrder = detail::TubePreampCoreDesign::kKernelOrder;
1250 static constexpr double kKneeRange = 1.0;
1251 static constexpr double kSmallZ = 0.3, kStiffZ = 40.0;
1252 static constexpr double kLongPiece = 0.5;
1253 static constexpr double kShortPiece = 0.03;
1254
1255 const LoadTable* table = nullptr;
1256 const detail::TubePreampCoreDesign* design = nullptr;
1257 double period = 1.0 / 96000.0, cathodeDecay = 0.0, sagDecay = 0.0;
1258 detail::TubePreampToneModes modes;
1259 std::array<double, 16> ring {};
1260 int ringPos = 0;
1261 double q[3] {};
1262 double vk1 = 0.0, vk2 = 0.0, ipLP = 0.0, i1Prev = 0.0, i2Prev = 0.0;
1263 double vp1 = 0.0, vp2 = 0.0;
1264 double out[kOrder] {};
1265 double cutSupply[2] = {-1.0, -1.0}, cutVgk[2] {};
1266 // Interval scratch
1267 typename LoadTable::Slice slice1, slice2;
1268 double supply = 0.0, a1 = 0.0, a2 = 0.0, s1 = 0.0, s2 = 0.0;
1269 double lev1[kLevels] {}, lev2[kLevels] {};
1270 double integral1 = 0.0, integral2 = 0.0, moments[kOrder] {};
1271 double hi1 = 0.0, hi2 = 0.0;
1272 bool haveHi1 = false, haveHi2 = false;
1273 int stages = 2;
1274 // Gauss-Legendre rules on [0, 1]
1275 static constexpr double kG4x[4] = {0.069431844202973713, 0.33000947820757187,
1276 0.66999052179242813, 0.93056815579702629};
1277 static constexpr double kG4w[4] = {0.17392742256872692, 0.32607257743127308,
1278 0.32607257743127308, 0.17392742256872692};
1279 static constexpr double kG3x[3] = {0.11270166537925831, 0.5, 0.88729833462074169};
1280 static constexpr double kG3w[3] = {5.0 / 18.0, 4.0 / 9.0, 5.0 / 18.0};
1281 double lagrange4[4][4] {};
1282 double lagrange3[3][3] {};
1283
1284 void init(double sampleRate, const LoadTable* tableIn,
1285 const detail::TubePreampCoreDesign* designIn) noexcept
1286 {
1287 table = tableIn;
1288 design = designIn;
1289 period = 1.0 / sampleRate;
1290 cathodeDecay = std::exp(-period / (kRk * kCk));
1291 sagDecay = std::exp(-period / 0.07);
1292 for (int k = 0; k < 4; ++k)
1293 {
1294 double poly[4] = {1.0, 0.0, 0.0, 0.0};
1295 double den = 1.0;
1296 int deg = 0;
1297 for (int o = 0; o < 4; ++o)
1298 {
1299 if (o == k) continue;
1300 double next[4] = {0.0, 0.0, 0.0, 0.0};
1301 for (int e = 0; e <= deg; ++e)
1302 {
1303 next[e + 1] += poly[e];
1304 next[e] -= kG4x[o] * poly[e];
1305 }
1306 ++deg;
1307 for (int e = 0; e < 4; ++e) poly[e] = next[e];
1308 den *= kG4x[k] - kG4x[o];
1309 }
1310 for (int e = 0; e < 4; ++e) lagrange4[k][e] = poly[e] / den;
1311 }
1312 for (int k = 0; k < 3; ++k)
1313 {
1314 const int o1 = (k + 1) % 3, o2 = (k + 2) % 3;
1315 const double den = (kG3x[k] - kG3x[o1]) * (kG3x[k] - kG3x[o2]);
1316 lagrange3[k][0] = kG3x[o1] * kG3x[o2] / den;
1317 lagrange3[k][1] = -(kG3x[o1] + kG3x[o2]) / den;
1318 lagrange3[k][2] = 1.0 / den;
1319 }
1320 }
1321
1323 void setTone(const wdf::ToneStackFMV<double>::AnalogStateSpace& ss) noexcept
1324 {
1325 double physical[3] = {0.0, 0.0, 0.0};
1326 for (int i = 0; i < 3; ++i)
1327 for (int m = 0; m < 3; ++m)
1328 physical[i] += modes.toPhysical[i][m] * q[m];
1329 modes.design(ss, 1.0 / period);
1330 for (int m = 0; m < 3; ++m)
1331 {
1332 q[m] = 0.0;
1333 for (int i = 0; i < 3; ++i) q[m] += modes.toModal[m][i] * physical[i];
1334 }
1335 }
1336
1337 static std::array<double, 3> dcBias(double sagR, int numStages, double grid = 0.0) noexcept
1338 {
1339 double bp = kBplus, i1 = 0.0, i2 = 0.0, iTotal = 0.0;
1340 for (int it = 0; it < 40; ++it)
1341 {
1342 i1 = settleCurrent(bp, grid);
1343 i2 = numStages > 1 && grid != 0.0 ? settleCurrent(bp) : i1;
1344 iTotal = i1 + (numStages > 1 ? i2 : 0.0);
1345 const double next = kBplus - sagR * iTotal;
1346 if (std::abs(next - bp) < 1e-12) { bp = next; break; }
1347 bp = next;
1348 }
1349 return {bp, i1, i2};
1350 }
1351
1352 void reset(double sagR, int numStages) noexcept
1353 {
1354 const auto bias = dcBias(sagR, numStages);
1355 const double bp = bias[0], i1 = bias[1];
1356 vk1 = vk2 = i1 * kRk;
1357 vp1 = vp2 = bp - i1 * kRL;
1358 ipLP = i1 * numStages;
1359 i1Prev = i2Prev = i1;
1360 q[0] = q[1] = q[2] = 0.0;
1361 ring.fill(0.0);
1362 ringPos = 0;
1363 for (double& v : out) v = 0.0;
1364 cutSupply[0] = cutSupply[1] = -1.0;
1365 }
1366
1367 static double cutoffVgk(double s) noexcept
1368 {
1369 // Grid voltage relative to the cathode below which the plate
1370 // current is < 1e-10 A (plate effect < 10 uV) at vpk ~= s.
1371 return LoadTable::gridForCurrent(s, 1e-10);
1372 }
1373
1374 static double currentSlow(double s, double g) noexcept
1375 {
1376 // Frozen-cathode load line outside the table: Ip = Koren(s - RL Ip, g).
1377 double i = 0.5 * std::max(s, 0.0) / kRL, p = 0.0, dp = 0.0, dg = 0.0;
1378 for (int it = 0; it < 60; ++it)
1379 {
1380 koren(s - kRL * i, g, p, dp, dg);
1381 const double next = std::clamp(i - (i - p) / (1.0 + kRL * dp), 0.0,
1382 std::max(s, 0.0) / kRL);
1383 if (std::abs(next - i) < 1e-16) { i = next; break; }
1384 i = next;
1385 }
1386 return i;
1387 }
1388
1389 template <int N>
1390 void currents(const typename LoadTable::Slice& sl, double cathode,
1391 const double* x, double* result) const noexcept
1392 {
1393 double g[N];
1394 for (int j = 0; j < N; ++j) g[j] = Clamp::value(x[j]) - cathode;
1395 for (int j = 0; j < N; ++j)
1396 result[j] = (sl.inside && g[j] < 1.0) ? sl.eval(g[j]) : currentSlow(sl.s, g[j]);
1397 }
1398
1399 double current(const typename LoadTable::Slice& sl, double cathode, double x) const noexcept
1400 {
1401 double r;
1402 currents<1>(sl, cathode, &x, &r);
1403 return r;
1404 }
1405
1406 static int classify(const double* lev, double v) noexcept
1407 {
1408 int k = 0;
1409 while (k < kLevels && v > lev[k]) ++k;
1410 return k;
1411 }
1412 static int classifyEdges(const double* lev, double v) noexcept
1413 {
1414 return v <= lev[0] ? 0 : (v > lev[kLevels - 1] ? kLevels : 1);
1415 }
1416
1417 void addMoments(double tau, double weight, double y) noexcept
1418 {
1419 const double d = tau - 0.5;
1420 double t = weight * y;
1421 for (int p = 0; p < kOrder; ++p) { moments[p] += t; t *= d; }
1422 }
1423 void addConstantMoments(double a, double b, double y) noexcept
1424 {
1425 const double pa = a - 0.5, pb = b - 0.5;
1426 double ea = pa, eb = pb;
1427 for (int p = 0; p < kOrder; ++p)
1428 {
1429 moments[p] += y * (eb - ea) / (p + 1);
1430 ea *= pa;
1431 eb *= pb;
1432 }
1433 }
1435 void addPolynomialMoments(double a, double h, const double* c, int degree) noexcept
1436 {
1437 static constexpr double inv[16] = {1.0, 1.0 / 2, 1.0 / 3, 1.0 / 4, 1.0 / 5, 1.0 / 6,
1438 1.0 / 7, 1.0 / 8, 1.0 / 9, 1.0 / 10, 1.0 / 11, 1.0 / 12, 1.0 / 13, 1.0 / 14,
1439 1.0 / 15, 1.0 / 16};
1440 static constexpr double binom[kOrder][kOrder] = {{1}, {1, 1}, {1, 2, 1},
1441 {1, 3, 3, 1}, {1, 4, 6, 4, 1}, {1, 5, 10, 10, 5, 1}};
1442 double sums[kOrder], hk = 1.0, dk[kOrder];
1443 dk[0] = 1.0;
1444 for (int k = 1; k < kOrder; ++k) dk[k] = dk[k - 1] * (a - 0.5);
1445 for (int k = 0; k < kOrder; ++k)
1446 {
1447 double acc = 0.0;
1448 for (int e = 0; e <= degree; ++e) acc += c[e] * inv[e + k];
1449 sums[k] = acc * hk;
1450 hk *= h;
1451 }
1452 for (int p = 0; p < kOrder; ++p)
1453 {
1454 double acc = 0.0;
1455 for (int k = 0; k <= p; ++k) acc += binom[p][k] * dk[p - k] * sums[k];
1456 moments[p] += h * acc;
1457 }
1458 }
1459
1463 {
1464 double c[6] {};
1465 int exactCount = 0;
1466 double q0[3] {}, z[3] {}, bt[3] {}, g[3] {};
1467 double u[4] {};
1468 int uTerms = 1;
1469
1470 double exactTerm(int k, double xi, double& derivative) const noexcept
1471 {
1472 double p[5];
1473 detail::tubePreampPhi(z[k] * xi, p);
1474 static constexpr double fact[4] = {1.0, 1.0, 2.0, 6.0};
1475 double forced = 0.0, xp = xi, ui = 0.0, xe = 1.0;
1476 for (int e = 0; e < uTerms; ++e)
1477 {
1478 forced += u[e] * fact[e] * xp * p[e + 1];
1479 xp *= xi;
1480 ui += u[e] * xe;
1481 xe *= xi;
1482 }
1483 const double qv = p[0] * q0[k] + bt[k] * forced;
1484 derivative = g[k] * (z[k] * qv + bt[k] * ui);
1485 return g[k] * qv;
1486 }
1487 double operator()(double xi) const noexcept
1488 {
1489 double v = ((((c[5] * xi + c[4]) * xi + c[3]) * xi + c[2]) * xi + c[1]) * xi + c[0];
1490 for (int k = 0; k < exactCount; ++k)
1491 {
1492 double dq;
1493 v += exactTerm(k, xi, dq);
1494 }
1495 return v;
1496 }
1497 double eval(double xi, double& derivative) const noexcept
1498 {
1499 double v = c[5], d = 0.0;
1500 for (int p = 4; p >= 0; --p)
1501 {
1502 d = d * xi + v;
1503 v = v * xi + c[p];
1504 }
1505 for (int k = 0; k < exactCount; ++k)
1506 {
1507 double dq;
1508 v += exactTerm(k, xi, dq);
1509 d += dq;
1510 }
1511 derivative = d;
1512 return v;
1513 }
1514 };
1515
1519 void propagate(double h, const double* u, int terms, Trajectory& tr) noexcept
1520 {
1521 static constexpr double fact[4] = {1.0, 1.0, 2.0, 6.0};
1522 tr.exactCount = 0;
1523 tr.uTerms = terms;
1524 for (int e = 0; e < 4; ++e) tr.u[e] = e < terms ? u[e] : 0.0;
1525 for (double& x : tr.c) x = 0.0;
1526 const double u0 = u[0], u0p = terms > 1 ? u[1] : 0.0;
1527 double u1 = 0.0, u1p = 0.0;
1528 for (int e = 0; e < terms; ++e)
1529 {
1530 u1 += u[e];
1531 u1p += e * u[e];
1532 }
1533 // Non-stiff modal sum: exact values and first/second derivatives
1534 // at both ends (from dq/dxi = z q + bt u), then a quintic Hermite.
1535 double h0 = 0.0, h0p = 0.0, h0pp = 0.0, h1 = 0.0, h1p = 0.0, h1pp = 0.0;
1536 for (int m = 0; m < 3; ++m)
1537 {
1538 const double z = modes.lambda[m] * h, bt = modes.beta[m] * h, g = modes.gamma[m];
1539 const double az = std::abs(z);
1540 if (az >= kStiffZ)
1541 {
1542 // Slow manifold of dq/dxi = z q + bt u:
1543 // q = -(bt/z) sum_k u^(k)(xi) / z^k (boundary layer < e^-40).
1544 const double iz = 1.0 / z;
1545 double der[4] = {0.0, 0.0, 0.0, 0.0};
1546 for (int e = 0; e < terms; ++e) der[e] = u[e];
1547 double izp = 1.0, endValue = 0.0;
1548 for (int k = 0; k < terms; ++k)
1549 {
1550 double sumAtEnd = 0.0;
1551 for (int e = 0; e < 4; ++e)
1552 {
1553 tr.c[e] += -g * bt * iz * izp * der[e];
1554 sumAtEnd += der[e];
1555 }
1556 endValue += izp * sumAtEnd;
1557 for (int e = 0; e < 3; ++e) der[e] = der[e + 1] * (e + 1);
1558 der[3] = 0.0;
1559 izp *= iz;
1560 }
1561 q[m] = -bt * iz * endValue;
1562 continue;
1563 }
1564 double p[5];
1565 detail::tubePreampPhi(z, p);
1566 double forced = 0.0;
1567 for (int e = 0; e < terms; ++e) forced += u[e] * fact[e] * p[e + 1];
1568 const double qEnd = p[0] * q[m] + bt * forced;
1569 if (az > kSmallZ)
1570 {
1571 const int k = tr.exactCount++;
1572 tr.q0[k] = q[m];
1573 tr.z[k] = z;
1574 tr.bt[k] = bt;
1575 tr.g[k] = g;
1576 q[m] = qEnd;
1577 continue;
1578 }
1579 const double d0 = z * q[m] + bt * u0, d1 = z * qEnd + bt * u1;
1580 h0 += g * q[m];
1581 h0p += g * d0;
1582 h0pp += g * (z * d0 + bt * u0p);
1583 h1 += g * qEnd;
1584 h1p += g * d1;
1585 h1pp += g * (z * d1 + bt * u1p);
1586 q[m] = qEnd;
1587 }
1588 const double quad = 0.5 * h0pp;
1589 const double r0 = h1 - (h0 + h0p + quad), r1 = h1p - (h0p + 2.0 * quad), r2 = h1pp - 2.0 * quad;
1590 tr.c[0] += h0;
1591 tr.c[1] += h0p;
1592 tr.c[2] += quad;
1593 tr.c[3] += 10.0 * r0 - 4.0 * r1 + 0.5 * r2;
1594 tr.c[4] += -15.0 * r0 + 7.0 * r1 - r2;
1595 tr.c[5] += 6.0 * r0 - 3.0 * r1 + 0.5 * r2;
1596 for (int e = 0; e < terms; ++e) tr.c[e] += modes.direct * u[e];
1597 }
1598
1600 void stage2(double a, double h, const Trajectory& tr) noexcept
1601 {
1602 double cuts[16];
1603 int count = 0;
1604 cuts[count++] = 0.0;
1605 const int samples = h > 0.5 ? 4 : 2;
1606 double sv[5];
1607 double lo = std::numeric_limits<double>::max(), hi = -lo;
1608 for (int k = 0; k <= samples; ++k)
1609 {
1610 sv[k] = kInterstage * tr(double(k) / samples);
1611 lo = std::min(lo, sv[k]);
1612 hi = std::max(hi, sv[k]);
1613 }
1614 const bool edges = hi - lo < kKneeRange;
1615 const auto cls = [&](double v) { return edges ? classifyEdges(lev2, v) : classify(lev2, v); };
1616 double pt = 0.0, pv = sv[0];
1617 int pc = cls(pv);
1618 const int first = pc;
1619 bool changed = false;
1620 for (int k = 1; k <= samples; ++k)
1621 {
1622 const double t = double(k) / samples, v = sv[k];
1623 const int cc = cls(v);
1624 if (cc != pc)
1625 {
1626 changed = true;
1627 const int low = std::min(cc, pc), high = std::max(cc, pc);
1628 for (int r = 0; r < high - low; ++r)
1629 {
1630 int li = cc > pc ? low + r : high - 1 - r;
1631 if (edges) li = li == 0 ? 0 : kLevels - 1;
1632 const double level = lev2[li];
1633 double tt = pt + (t - pt) * (pv - level) / (pv - v);
1634 for (int it = 0; it < 2; ++it)
1635 {
1636 double dv;
1637 const double f = kInterstage * tr.eval(tt, dv) - level;
1638 dv *= kInterstage;
1639 if (dv == 0.0) break;
1640 const double next = tt - f / dv;
1641 if (!(next > pt && next < t)) break;
1642 tt = next;
1643 }
1644 if (tt > cuts[count - 1] + 1e-12 && count < 14) cuts[count++] = tt;
1645 }
1646 }
1647 pt = t;
1648 pv = v;
1649 pc = cc;
1650 }
1651 cuts[count++] = 1.0;
1652 for (int k = 0; k + 1 < count; ++k)
1653 {
1654 const double xa = cuts[k], xb = cuts[k + 1], len = xb - xa;
1655 if (len <= 0.0) continue;
1656 const int cm = changed ? cls(kInterstage * tr(0.5 * (xa + xb))) : first;
1657 if (cm == 0 || cm == kLevels)
1658 {
1659 double i2 = 0.0;
1660 if (cm == kLevels)
1661 {
1662 if (!haveHi2) { hi2 = current(slice2, a2, 1e3); haveHi2 = true; }
1663 i2 = hi2;
1664 }
1665 integral2 += h * len * i2;
1666 addConstantMoments(a + h * xa, a + h * xb, supply - kRL * i2 - vp2);
1667 continue;
1668 }
1669 if (h * len > kLongPiece)
1670 {
1671 // Long smooth piece: a cubic interpolant keeps harmonics
1672 // near 0.8 cycles/sample from leaking past the kernel.
1673 double xs[4], is[4], y[4], poly[4];
1674 for (int j = 0; j < 4; ++j) xs[j] = kInterstage * tr(xa + len * kG4x[j]);
1675 currents<4>(slice2, a2, xs, is);
1676 for (int j = 0; j < 4; ++j)
1677 {
1678 integral2 += h * len * kG4w[j] * is[j];
1679 y[j] = supply - kRL * is[j] - vp2;
1680 }
1681 for (int e = 0; e < 4; ++e)
1682 poly[e] = lagrange4[0][e] * y[0] + lagrange4[1][e] * y[1]
1683 + lagrange4[2][e] * y[2] + lagrange4[3][e] * y[3];
1684 addPolynomialMoments(a + h * xa, h * len, poly, 3);
1685 continue;
1686 }
1687 if (h * len < kShortPiece)
1688 {
1689 // Short piece: two nodes and the exact moments of their
1690 // linear interpolant.
1691 constexpr double g0 = 0.21132486540518713, g1 = 0.78867513459481287;
1692 double xs[2] = {kInterstage * tr(xa + len * g0), kInterstage * tr(xa + len * g1)}, is[2];
1693 currents<2>(slice2, a2, xs, is);
1694 integral2 += h * len * 0.5 * (is[0] + is[1]);
1695 const double y0 = supply - kRL * is[0] - vp2, y1 = supply - kRL * is[1] - vp2;
1696 double poly[2];
1697 poly[1] = (y1 - y0) / (g1 - g0);
1698 poly[0] = y0 - poly[1] * g0;
1699 addPolynomialMoments(a + h * xa, h * len, poly, 1);
1700 continue;
1701 }
1702 double xs[3], is[3];
1703 for (int j = 0; j < 3; ++j) xs[j] = kInterstage * tr(xa + len * kG3x[j]);
1704 currents<3>(slice2, a2, xs, is);
1705 // Exact moments of the plate interpolant through the three
1706 // nodes: Gauss weights alone are not exact for the quintic
1707 // kernel weights times a curved plate trajectory.
1708 double y[3], poly[3];
1709 for (int j = 0; j < 3; ++j)
1710 {
1711 integral2 += h * len * kG3w[j] * is[j];
1712 y[j] = supply - kRL * is[j] - vp2;
1713 }
1714 for (int e = 0; e < 3; ++e)
1715 poly[e] = lagrange3[0][e] * y[0] + lagrange3[1][e] * y[1] + lagrange3[2][e] * y[2];
1716 addPolynomialMoments(a + h * xa, h * len, poly, 2);
1717 }
1718 }
1719
1720 void plate(double a, double h, const double* u, int terms) noexcept
1721 {
1722 Trajectory tr;
1723 propagate(h, u, terms, tr);
1724 if (stages > 1)
1725 stage2(a, h, tr);
1726 else if (tr.exactCount == 0)
1727 addPolynomialMoments(a, h, tr.c, 5);
1728 else
1729 {
1730 // Intermediate-stiffness mode present: interpolate the
1731 // tone-circuit output at the nodes, then take exact moments.
1732 const double y[3] = {tr(kG3x[0]), tr(kG3x[1]), tr(kG3x[2])};
1733 double poly[3];
1734 for (int e = 0; e < 3; ++e)
1735 poly[e] = lagrange3[0][e] * y[0] + lagrange3[1][e] * y[1] + lagrange3[2][e] * y[2];
1736 addPolynomialMoments(a, h, poly, 2);
1737 }
1738 }
1739
1740 double process(double x, int numStages, double sagR) noexcept
1741 {
1742 stages = numStages;
1743 const int taps = design->taps;
1744 ring[static_cast<size_t>(ringPos)] = x;
1745 ringPos = ringPos + 1 == taps ? 0 : ringPos + 1;
1746 interval(sagR);
1747 const double result = out[0];
1748 for (int k = 0; k + 1 < kOrder; ++k) out[k] = out[k + 1];
1749 out[kOrder - 1] = 0.0;
1750 return result;
1751 }
1752
1753 void interval(double sagR) noexcept
1754 {
1755 // Stage-1 grid polynomial on the interval [n, n+1].
1756 const int taps = design->taps, deg = design->degree;
1757 double c[16] {};
1758 int idx = ringPos;
1759 for (int j = 0; j < taps; ++j)
1760 {
1761 const double v = ring[static_cast<size_t>(idx)];
1762 idx = idx + 1 == taps ? 0 : idx + 1;
1763 const double* row = design->farrow + j * (deg + 1);
1764 for (int p = 0; p <= deg; ++p) c[p] += row[p] * v;
1765 }
1766 const auto xAt = [&](double tau) {
1767 double v = c[deg];
1768 for (int p = deg - 1; p >= 0; --p) v = v * tau + c[p];
1769 return v;
1770 };
1771 // Slow states frozen at the predicted interval midpoint.
1772 const double iPrev = i1Prev + (stages > 1 ? i2Prev : 0.0);
1773 supply = kBplus - sagR * (ipLP + 0.5 * (1.0 - sagDecay) * (iPrev - ipLP));
1774 a1 = vk1 + 0.5 * (1.0 - cathodeDecay) * (i1Prev * kRk - vk1);
1775 a2 = vk2 + 0.5 * (1.0 - cathodeDecay) * (i2Prev * kRk - vk2);
1776 s1 = supply - a1;
1777 s2 = supply - a2;
1778 slice1.set(*table, s1);
1779 slice2.set(*table, s2);
1780 if (std::abs(s1 - cutSupply[0]) > 0.05) { cutSupply[0] = s1; cutVgk[0] = cutoffVgk(s1); }
1781 if (std::abs(s2 - cutSupply[1]) > 0.05) { cutSupply[1] = s2; cutVgk[1] = cutoffVgk(s2); }
1782 lev1[0] = a1 + cutVgk[0]; lev1[1] = a1 - 3.0; lev1[2] = a1 - 1.0; lev1[3] = 1.0; lev1[4] = 6.0;
1783 lev2[0] = a2 + cutVgk[1]; lev2[1] = a2 - 3.0; lev2[2] = a2 - 1.0; lev2[3] = 1.0; lev2[4] = 6.0;
1784 for (int j = 1; j < kLevels; ++j)
1785 {
1786 lev1[j] = std::max(lev1[j], lev1[j - 1] + 1e-6);
1787 lev2[j] = std::max(lev2[j], lev2[j - 1] + 1e-6);
1788 }
1789 // Split the interval where the grid crosses the stage-1 levels;
1790 // interior knees only when the interval spans at least 1 V.
1791 double qv[5];
1792 double lo = std::numeric_limits<double>::max(), hi = -lo;
1793 for (int k = 0; k <= 4; ++k)
1794 {
1795 qv[k] = xAt(0.25 * k);
1796 lo = std::min(lo, qv[k]);
1797 hi = std::max(hi, qv[k]);
1798 }
1799 const bool edges = hi - lo < kKneeRange;
1800 const auto cls = [&](double v) { return edges ? classifyEdges(lev1, v) : classify(lev1, v); };
1801 double cuts[kMaxPieces];
1802 int count = 0;
1803 cuts[count++] = 0.0;
1804 double pt = 0.0, pv = qv[0];
1805 int pc = cls(pv);
1806 const int first = pc;
1807 bool changed = false;
1808 for (int k = 1; k <= 4; ++k)
1809 {
1810 const double t = 0.25 * k, v = qv[k];
1811 const int cc = cls(v);
1812 if (cc != pc)
1813 {
1814 changed = true;
1815 const int low = std::min(cc, pc), high = std::max(cc, pc);
1816 for (int r = 0; r < high - low; ++r)
1817 {
1818 int li = cc > pc ? low + r : high - 1 - r;
1819 if (edges) li = li == 0 ? 0 : kLevels - 1;
1820 const double level = lev1[li];
1821 double tt = pt + (t - pt) * (pv - level) / (pv - v);
1822 for (int it = 0; it < 2; ++it)
1823 {
1824 double f = c[deg], df = 0.0;
1825 for (int p = deg - 1; p >= 0; --p) { df = df * tt + f; f = f * tt + c[p]; }
1826 f -= level;
1827 if (df == 0.0) break;
1828 const double next = tt - f / df;
1829 if (!(next > pt && next < t)) break;
1830 tt = next;
1831 }
1832 if (tt > cuts[count - 1] + 1e-12 && count < kMaxPieces - 2) cuts[count++] = tt;
1833 }
1834 }
1835 pt = t;
1836 pv = v;
1837 pc = cc;
1838 }
1839 cuts[count++] = 1.0;
1840 integral1 = integral2 = 0.0;
1841 for (double& m : moments) m = 0.0;
1842 haveHi1 = haveHi2 = false;
1843 for (int k = 0; k + 1 < count; ++k)
1844 {
1845 const double a = cuts[k], b = cuts[k + 1], h = b - a;
1846 if (h <= 0.0) continue;
1847 const int cm = changed ? cls(xAt(0.5 * (a + b))) : first;
1848 if (cm == 0 || cm == kLevels)
1849 {
1850 // Stage 1 saturated: constant plate voltage on the piece.
1851 double i1 = 0.0;
1852 if (cm == kLevels)
1853 {
1854 if (!haveHi1) { hi1 = current(slice1, a1, 1e3); haveHi1 = true; }
1855 i1 = hi1;
1856 }
1857 integral1 += h * i1;
1858 const double u[1] = {supply - kRL * i1 - vp1};
1859 plate(a, h, u, 1);
1860 continue;
1861 }
1862 double xs[4], is[4];
1863 {
1864 double t0 = a + h * kG4x[0], t1 = a + h * kG4x[1];
1865 double t2 = a + h * kG4x[2], t3 = a + h * kG4x[3];
1866 double v0 = c[deg], v1 = c[deg], v2 = c[deg], v3 = c[deg];
1867 for (int p = deg - 1; p >= 0; --p)
1868 {
1869 v0 = v0 * t0 + c[p];
1870 v1 = v1 * t1 + c[p];
1871 v2 = v2 * t2 + c[p];
1872 v3 = v3 * t3 + c[p];
1873 }
1874 xs[0] = v0; xs[1] = v1; xs[2] = v2; xs[3] = v3;
1875 }
1876 currents<4>(slice1, a1, xs, is);
1877 double y[4];
1878 for (int j = 0; j < 4; ++j)
1879 {
1880 y[j] = supply - kRL * is[j] - vp1;
1881 integral1 += h * kG4w[j] * is[j];
1882 }
1883 double u[4];
1884 for (int e = 0; e < 4; ++e)
1885 u[e] = lagrange4[0][e] * y[0] + lagrange4[1][e] * y[1]
1886 + lagrange4[2][e] * y[2] + lagrange4[3][e] * y[3];
1887 plate(a, h, u, 4);
1888 }
1889 // Exact exponential updates with the interval-average currents.
1890 vk1 = vk1 * cathodeDecay + (1.0 - cathodeDecay) * kRk * integral1;
1891 if (stages > 1)
1892 {
1893 vk2 = vk2 * cathodeDecay + (1.0 - cathodeDecay) * kRk * integral2;
1894 i2Prev = integral2;
1895 }
1896 ipLP = ipLP * sagDecay + (1.0 - sagDecay) * (integral1 + (stages > 1 ? integral2 : 0.0));
1897 i1Prev = integral1;
1898 for (int k = 0; k < kOrder; ++k)
1899 {
1900 const auto& piece = design->kernel[static_cast<size_t>(k)];
1901 double acc = 0.0;
1902 for (int p = 0; p < kOrder; ++p) acc += piece[static_cast<size_t>(p)] * moments[p];
1903 out[k] += acc;
1904 }
1905 }
1906 };
1907
1909 struct ChannelState
1910 {
1911 explicit ChannelState(double fs2In, const LoadTable* table,
1912 const detail::TubePreampCoreDesign* designIn)
1913 : design(designIn), fmv(38e3, 1e6) // fixed source impedance
1914 {
1915 stage1.table = table;
1916 stage2.table = table;
1917 stage1.fs2 = fs2In;
1918 stage2.fs2 = fs2In;
1919 fs2 = fs2In;
1920 fmv.prepare(fs2In);
1921 if (design)
1922 {
1923 core.init(fs2In, table, design);
1924 core.setTone(fmv.analogStateSpace());
1925 compensation.prepare(static_cast<int>(design->compensation.size()), 1);
1926 compensation.setCoefficients(design->compensation);
1927 }
1928 }
1929
1936 void reset(double sagR, int numStages) noexcept
1937 {
1938 // Fixed point over the ACTIVE stage count: processing only
1939 // draws current from the stages in use, so seeding ipLP with both
1940 // stages' current at 1-stage settings left a ~70 ms sag transient.
1941 double bp = kBplus;
1942 double iTotal = 0.0;
1943 for (int it = 0; it < 8; ++it)
1944 {
1945 stage1.settleDC(bp);
1946 stage2.settleDC(bp);
1947 iTotal = stage1.ip + (numStages > 1 ? stage2.ip : 0.0);
1948 const double bpNew = kBplus - sagR * iTotal;
1949 if (std::abs(bpNew - bp) < 1e-9) break;
1950 bp = bpNew;
1951 }
1952 ipLP = iTotal;
1953 outHpX = outHpY = 0.0;
1954 fmv.reset();
1955 for (auto& set : flatten)
1956 for (auto& f : set)
1957 f.reset();
1958 if (design)
1959 {
1960 core.reset(sagR, numStages);
1961 compensation.reset();
1962 }
1963 }
1964
1971 void resumeFrom(const ChannelState& source, int numStages, double sagR) noexcept
1972 {
1973 // The bypass capacitor already measures mean plate current.
1974 // Invert Koren at that operating point, then solve the requested
1975 // load with the same effective grid bias. Using zero input as the
1976 // reference would still produce a pulse with DC-biased audio.
1977 const double current1 = (design ? source.core.vk1 : source.stage1.vk) / kRk;
1978 const double current2 = numStages == 1
1979 ? (design ? source.core.vk2 : source.stage2.vk) / kRk : 0.0;
1980 const double supply = kBplus - sagR * (current1 + current2);
1981 const double plate = std::max(1.0, supply - (kRL + kRk) * current1);
1982 const double grid = kRk * current1
1983 + LoadTable::gridForCurrent(plate, std::max(current1, 1e-18));
1984 const auto after = ContinuousCore::dcBias(sagR, numStages, grid);
1985 const double di = after[1] - current1;
1986 const double dTotal = after[1] + (numStages == 2 ? after[2] : 0.0) - current1 - current2;
1987 const double dPlate = after[0] - supply - kRL * di;
1988 const double reference = after[0] - kRL * after[1];
1989 const double previousReference = design ? source.core.vp1 : source.stage1.vpDC;
1990 const double inputOffset = previousReference + dPlate - reference;
1991 stage1 = source.stage1;
1992 stage1.vk += kRk * di;
1993 stage1.ip += di;
1994 stage1.vpDC = reference;
1995 stage2.ip = after[2];
1996 stage2.vk = kRk * after[2];
1997 stage2.vpDC = after[0] - kRL * after[2];
1998 stage2.fPrev = 0.0;
1999 ipLP = source.ipLP + dTotal;
2000 if (!design) fmv.copyStateFrom(source.fmv, inputOffset);
2001 core = source.core;
2002 core.vk1 += kRk * di;
2003 core.i1Prev += di;
2004 core.vp1 = reference;
2005 if (design)
2006 for (int mode = 0; mode < 3; ++mode)
2007 for (int capacitor = 0; capacitor < 3; ++capacitor)
2008 core.q[mode] += core.modes.toModal[mode][capacitor] * inputOffset;
2009 // A common DC shift of the three coupling-capacitor voltages
2010 // exactly cancels the changed stage-1 voltage reference. Rebase
2011 // that reference at each fork so repeated switches cannot drift
2012 // it (and the compensating capacitor state) without bound.
2013 core.ipLP += dTotal;
2014 core.vk2 = stage2.vk;
2015 core.vp2 = stage2.vpDC;
2016 core.i2Prev = stage2.ip;
2017 for (double& value : core.out) value = 0.0;
2018 outHpX = outHpY = 0.0;
2019 for (auto& filter : flatten[static_cast<size_t>(numStages - 1)])
2020 filter.reset();
2021 if (design) compensation.reset();
2022 }
2023
2025 void setFlattenCoeffs(int stageCount,
2026 const std::array<BiquadCoeffs, 3>& c) noexcept
2027 {
2028 auto& set = flatten[static_cast<size_t>(stageCount - 1)];
2029 for (int k = 0; k < 3; ++k)
2030 set[static_cast<size_t>(k)].setCoeffs(c[static_cast<size_t>(k)]);
2031 }
2032
2033 void setToneControls(double t, double b, double m) noexcept
2034 {
2035 fmv.setControls(t, b, m); // rebuilds the R-type scattering
2036 if (design) core.setTone(fmv.analogStateSpace());
2037 }
2038
2040 [[nodiscard]] double supplyCurrent() const noexcept { return design ? core.ipLP : ipLP; }
2041
2042 template <bool Compensate = true>
2043 [[nodiscard]] double processSample(double vgIn, int numStages, double sagR) noexcept
2044 {
2045 double v;
2046 if (design)
2047 v = core.process(vgIn, numStages, sagR);
2048 else
2049 {
2050 // Supply sag: B+ droops with smoothed total plate current.
2051 const double sagAlpha = 1.0 - std::exp(-1.0 / (0.07 * fs2));
2052 const double iTotal = stage1.ip + (numStages > 1 ? stage2.ip : 0.0);
2053 ipLP += sagAlpha * (iTotal - ipLP);
2054 const double bplusEff = kBplus - sagR * ipLP;
2055 // Stage 1 -> FMV tone stack -> (stage 2).
2056 v = stage1.processSample(vgIn, bplusEff);
2057 v = fmv.processSample(v);
2058 if (numStages > 1)
2059 v = stage2.processSample(v * kInterstage, bplusEff);
2060 }
2061
2062 // Output coupling high-pass (~8 Hz, removes residual sag drift).
2063 const double a = 1.0 - 2.0 * std::numbers::pi * 8.0 / fs2;
2064 const double y = a * (outHpY + v - outHpX);
2065 outHpX = v;
2066 outHpY = y;
2067 // Restore absolute polarity for single-stage use.
2068 double out = (numStages > 1) ? y : -y;
2069
2070 // Reference-flattening EQ: undoes the FMV stack's fixed envelope
2071 // at the neutral tone setting (designed in calibrateReference
2072 // from the measured response), so neutral knobs sound neutral
2073 // and the tone controls act RELATIVE to flat. This linear stage
2074 // adds no harmonics.
2075 auto& fl = flatten[numStages > 1 ? 1 : 0];
2076 out = fl[0].processSample(out, 0);
2077 out = fl[1].processSample(out, 0);
2078 out = fl[2].processSample(out, 0);
2079 if constexpr (!Compensate) return out;
2080 if (!design) return out;
2081 return compensation.processSample(out, 0);
2082 }
2083
2084 void compensateBlock(double* data, int count) noexcept
2085 {
2086 // Core block processing hoists coefficient publication/atomics
2087 // out of the inner loop. The calibration uses the same FIR.
2088 compensation.processBlock(AudioBufferView<double>(&data, 1, count));
2089 }
2090
2091 double fs2 = 96000.0;
2092 TriodeStage stage1, stage2;
2093 double ipLP = 1.6e-3;
2094 double outHpX = 0.0, outHpY = 0.0;
2095 std::array<std::array<Biquad<double, 1>, 3>, 2> flatten;
2096 const detail::TubePreampCoreDesign* design;
2097 ContinuousCore core;
2098 FIRFilter<double> compensation;
2099
2100 wdf::ToneStackFMV<double> fmv;
2101 };
2102
2104 void recompute() noexcept
2105 {
2106 const double drive = std::pow(10.0, static_cast<double>(
2107 driveDb_.load(std::memory_order_relaxed)) / 20.0);
2108 const double t = static_cast<double>(treble_.load(std::memory_order_relaxed));
2109 const double b = static_cast<double>(bass_.load(std::memory_order_relaxed));
2110 const double m = static_cast<double>(middle_.load(std::memory_order_relaxed));
2111 const double sag = static_cast<double>(sag_.load(std::memory_order_relaxed));
2112 const int requestedStages = stages_.load(std::memory_order_relaxed);
2113
2114 hScale_ = drive; // 0 dBFS -> 1 V grid at drive 0
2115 // Note on physics: a class-A preamp draws near-constant average
2116 // current, so supply sag shifts the operating point rather than
2117 // pumping like a push-pull power amp. The audible touch response of
2118 // this model comes from the cathode-bypass bias shift (modelled in
2119 // TriodeStage); the sag control changes voicing, not loudness.
2120 sagR_ = sag * 40e3;
2121 for (auto& lane : channels_)
2122 for (auto& ch : lane)
2123 ch->setToneControls(t, b, m);
2124
2125 if (!gainsInitialized_)
2126 {
2127 // A pre-stream edit selects the initial circuit, without a fade
2128 // or a supply transient from the other topology's DC point.
2129 for (auto& ch : channels_[static_cast<size_t>(requestedStages - 1)])
2130 ch->reset(sagR_, requestedStages);
2131 stageBlend_.reset(static_cast<double>(requestedStages - 1));
2132 bothStagesActive_ = false;
2133 stageWarmupRemaining_ = 0;
2134 }
2135 else if (requestedStages != numStagesActive_)
2136 {
2137 if (!bothStagesActive_)
2138 {
2139 auto& incoming = channels_[static_cast<size_t>(requestedStages - 1)];
2140 const auto& outgoing = channels_[static_cast<size_t>(numStagesActive_ - 1)];
2141 for (size_t ch = 0; ch < incoming.size(); ++ch)
2142 incoming[ch]->resumeFrom(*outgoing[ch], requestedStages, sagR_);
2143 stageWarmupRemaining_ = std::max(1, static_cast<int>(std::ceil(0.005 * fs2_)));
2144 bothStagesActive_ = true;
2145 }
2146 stageBlend_.setTargetValue(static_cast<double>(requestedStages - 1));
2147 if (!stageBlend_.isSmoothing())
2148 {
2149 bothStagesActive_ = false;
2150 stageWarmupRemaining_ = 0;
2151 }
2152 }
2153 numStagesActive_ = requestedStages;
2154
2155 // Loudness: divide out the circuit's program gain at the REFERENCE
2156 // tone setting (measured once in prepare on a settled channel) and
2157 // MOST of the drive factor. The link is partial (drive^0.75, i.e. a
2158 // residual +0.25 dB/dB slope): an exact 1/drive link leaves the knob
2159 // audibly dead below 0 dB (the circuit is still clean there) and
2160 // turns it into a pure attenuator above (compression eats level
2161 // faster than the link returns it). With the residual slope, -12 dB
2162 // drive sits ~3 dB lower and clean, high drive holds level while the
2163 // density grows. The tone knobs stay fully audible: their deviation
2164 // from the 0.5/0.5/0.5 reference is part of the tone, not the level.
2165 // Cheap by construction (no scratch processing on the audio thread).
2166 //
2167 // The divisor is the circuit's MEASURED program gain at this drive
2168 // (prepare-time LUT, log-interpolated) - same contract as
2169 // TapeMachine/TransformerModel, whose per-drive scratch calibration
2170 // is what kept their knobs healthy. The previous analytic
2171 // 1/drive^0.75 broke at high drive: once the triode pins at its
2172 // ceiling, output stops growing with drive while the divisor keeps
2173 // rising, so the level FELL hard instead of holding.
2174 const double driveDbNow = static_cast<double>(driveDb_.load(std::memory_order_relaxed));
2175 for (int st = 1; st <= 2; ++st)
2176 mScale_[static_cast<size_t>(st - 1)] = std::pow(drive, 0.25)
2177 / std::max(programGainAt(driveDbNow, st), 1e-9);
2178 }
2179
2181 [[nodiscard]] double programGainAt(double driveDb, int stages) const noexcept
2182 {
2183 const auto& lut = gProgLut_[static_cast<size_t>(stages - 1)];
2184 // Ordered min/max instead of clamp: a NaN input resolves to 0 here
2185 // (defence in depth; the setter already rejects non-finite values, and
2186 // an unguarded NaN would UB-cast into a wild LUT index).
2187 const double pos = std::min(std::max(0.0, (driveDb - kDriveLutMinDb) / kDriveLutStepDb),
2188 static_cast<double>(kDriveLutN - 1));
2189 const int idx = std::min(static_cast<int>(pos), kDriveLutN - 2);
2190 const double frac = pos - static_cast<double>(idx);
2191 const double a = std::max(lut[static_cast<size_t>(idx)], 1e-9);
2192 const double b = std::max(lut[static_cast<size_t>(idx) + 1], 1e-9);
2193 return a * std::pow(b / a, frac); // gains are smooth in dB
2194 }
2195
2211 void calibrateReference() noexcept
2212 {
2213 constexpr double kRefTones[] = { 100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0 };
2214 constexpr double kSagRef = 0.3 * 40e3;
2215 const int settle = static_cast<int>(0.060 * fs2_);
2216 const int meas = static_cast<int>(0.050 * fs2_);
2217 constexpr double kAmpPerTone = 0.095 / 2.6457513;
2218
2219 for (int st = 1; st <= 2; ++st)
2220 {
2221 ChannelState cal(fs2_, loadTable_.get(), design_.get());
2222 cal.setToneControls(0.5, 0.5, 0.5);
2223 cal.reset(kSagRef, st);
2224
2225 // Per-tone Goertzel over the measured tail.
2226 std::array<double, 7> gs1 {}, gs2 {}, gc {};
2227 for (int k = 0; k < 7; ++k)
2228 gc[static_cast<size_t>(k)] =
2229 2.0 * std::cos(2.0 * std::numbers::pi * kRefTones[k] / fs2_);
2230 for (int i = 0; i < settle + meas; ++i)
2231 {
2232 double x = 0.0;
2233 for (int k = 0; k < 7; ++k)
2234 x += std::sin(2.0 * std::numbers::pi * kRefTones[k] * i / fs2_ + k * 1.7);
2235 x *= kAmpPerTone;
2236 const double y = cal.processSample(x, st, kSagRef);
2237 if (i >= settle)
2238 for (int k = 0; k < 7; ++k)
2239 {
2240 auto& s1 = gs1[static_cast<size_t>(k)];
2241 auto& s2 = gs2[static_cast<size_t>(k)];
2242 const double s0 = y + gc[static_cast<size_t>(k)] * s1 - s2;
2243 s2 = s1; s1 = s0;
2244 }
2245 }
2246 std::array<double, 7> gainDb {};
2247 for (int k = 0; k < 7; ++k)
2248 {
2249 const double c = gc[static_cast<size_t>(k)] * 0.5;
2250 const double s1 = gs1[static_cast<size_t>(k)], s2 = gs2[static_cast<size_t>(k)];
2251 const double mag = std::sqrt(std::max(s1 * s1 + s2 * s2 - 2.0 * c * s1 * s2, 0.0))
2252 * 2.0 / meas;
2253 gainDb[static_cast<size_t>(k)] =
2254 20.0 * std::log10(std::max(mag / kAmpPerTone, 1e-9));
2255 }
2256
2257 // Flattening EQ from the band means, relative to the overall mean.
2258 double mean = 0.0;
2259 for (double g : gainDb) mean += g / 7.0;
2260 const double lo = 0.5 * (gainDb[0] + gainDb[1]) - mean;
2261 const double mid = (gainDb[2] + gainDb[3] + gainDb[4]) / 3.0 - mean;
2262 const double hi = 0.5 * (gainDb[5] + gainDb[6]) - mean;
2263
2264 std::array<BiquadCoeffs, 3> fc = {
2265 BiquadCoeffs::makeLowShelf(fs2_, 180.0, -lo),
2266 BiquadCoeffs::makePeak(fs2_, 800.0, 0.55, -mid),
2267 BiquadCoeffs::makeHighShelf(fs2_, 4500.0, -hi)
2268 };
2269 for (auto& lane : channels_)
2270 for (auto& ch : lane)
2271 ch->setFlattenCoeffs(st, fc);
2272
2273 // Phase 2: program gain vs DRIVE, on a chained scratch channel
2274 // with the flattener installed (the chain as it really sounds).
2275 // Each grid point gets a re-settle (bias/sag adapt to the new
2276 // level) before its measurement window. recompute() interpolates
2277 // this LUT, so the loudness link tracks the circuit's actual
2278 // compression - the fix for the level falling at high drive.
2279 ChannelState sweep(fs2_, loadTable_.get(), design_.get());
2280 sweep.setToneControls(0.5, 0.5, 0.5);
2281 sweep.setFlattenCoeffs(st, fc);
2282 sweep.reset(kSagRef, st);
2283 const int settle2 = static_cast<int>(0.030 * fs2_);
2284 const int meas2 = static_cast<int>(0.030 * fs2_);
2285 int t = 0; // continuous tone phase across the whole sweep
2286 for (int kd = 0; kd < kDriveLutN; ++kd)
2287 {
2288 const double d = std::pow(10.0,
2289 (kDriveLutMinDb + kd * kDriveLutStepDb) / 20.0);
2290 double inSq = 0.0, outSq = 0.0;
2291 for (int i = 0; i < settle2 + meas2; ++i, ++t)
2292 {
2293 double x = 0.0;
2294 for (int k = 0; k < 7; ++k)
2295 x += std::sin(2.0 * std::numbers::pi * kRefTones[k] * t / fs2_ + k * 1.7);
2296 x *= kAmpPerTone;
2297 const double y = sweep.processSample(d * x, st, kSagRef);
2298 if (i >= settle2)
2299 {
2300 inSq += x * x;
2301 outSq += y * y;
2302 }
2303 }
2304 gProgLut_[static_cast<size_t>(st - 1)][static_cast<size_t>(kd)] =
2305 (outSq > 0.0 && inSq > 0.0) ? std::sqrt(outSq / inSq) : 1.0;
2306 }
2307 }
2308 }
2309
2310 // -- Members --------------------------------------------------------------------
2311 AudioSpec spec_ {};
2312 double sampleRate_ = 48000.0;
2313 double fs2_ = 96000.0;
2314 int numChannels_ = 0;
2315 int maxBlock_ = 0;
2316 std::atomic<bool> prepared_ { false };
2317 int latency_ = 0;
2318 int drySize_ = 1;
2319 int osFactor_ = 2;
2320
2321 std::unique_ptr<LoadTable> loadTable_;
2322 std::unique_ptr<detail::TubePreampCoreDesign> design_;
2323 std::unique_ptr<Oversampling<T>> oversampler_;
2324 std::array<std::vector<std::unique_ptr<ChannelState>>, 2> channels_;
2325
2326 std::vector<std::vector<T>> dryRing_;
2327 int dryPos_ = 0;
2328
2329 static constexpr int kDriveLutN = 13;
2330 static constexpr double kDriveLutMinDb = -12.0;
2331 static constexpr double kDriveLutStepDb = 4.0;
2332
2333 double hScale_ = 1.0;
2334 std::array<double, 2> mScale_ { 1.0, 1.0 };
2335 double sagR_ = 0.0;
2336 int numStagesActive_ = 1;
2338 std::array<std::array<double, kDriveLutN>, 2> gProgLut_ {
2339 { { 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 },
2340 { 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 } } };
2341 SmoothedValue<double> driveLogSmoother_;
2342 std::array<SmoothedValue<double>, 2> outputLogSmoothers_;
2343 std::array<std::vector<double>, 3> gainRamps_;
2344 std::array<std::vector<double>, 2> compensationScratch_;
2345 SmoothedValue<double> stageBlend_;
2346 std::vector<double> stageRamp_;
2347 int stageWarmupRemaining_ = 0;
2348 bool bothStagesActive_ = false;
2349 bool gainsInitialized_ = false;
2350 T currentMix_ = T(1);
2351 T mixMaxStep_ = T(1.0 / 960.0);
2352
2353 std::atomic<T> driveDb_ { T(0) };
2354 std::atomic<T> treble_ { T(0.5) };
2355 std::atomic<T> bass_ { T(0.5) };
2356 std::atomic<T> middle_ { T(0.5) };
2357 std::atomic<T> sag_ { T(0.3) };
2358 std::atomic<int> stages_ { 1 };
2359 std::atomic<T> outputDb_ { T(0) };
2360 std::atomic<T> mix_ { T(1) };
2361 std::atomic<T> supplyNow_ { T(300) };
2362 std::atomic<bool> dirty_ { true };
2363};
2364
2365} // 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.
Zero-allocation parameter smoother for real-time audio.
void setSmoothingType(SmoothingType type) noexcept
Sets the smoothing algorithm.
void reset(T value=T(0)) noexcept
Hard-resets both current and target states to a specific value.
T getNextValue() noexcept
Calculates and returns the next smoothed value.
T getCurrentValue() const noexcept
Returns the current internal value without advancing the state.
bool isSmoothing() const noexcept
Evaluates if the smoother is actively transitioning.
void setTargetValue(T newTarget) noexcept
Updates the target value. Safe to call continuously (e.g., from host automation).
void processBlock(std::span< T > buffer) noexcept
Computes a block of smoothed values into an output buffer.
void prepare(double sampleRate, double rampTimeMs=20.0) noexcept
Precalculates internal coefficients based on sample rate and timing.
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:161
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:204
bool isValid() const noexcept
Definition StateBlob.h:199
uint32_t processorId() const noexcept
Definition StateBlob.h:200
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:53
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:105
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:71
One/two 12AX7 stages with sag and a WDF tone circuit.
Definition TubePreamp.h:710
T getBass() const noexcept
Definition TubePreamp.h:901
void setStages(int stages) noexcept
Number of triode stages (1 = clean/edge, 2 = high gain). RT-safe; the prepared latency does not chang...
Definition TubePreamp.h:850
T getSupplyVoltage() const noexcept
Effective B+ supply voltage of channel 0 (sag meter readout). During a stage transition,...
Definition TubePreamp.h:918
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition TubePreamp.h:942
int getLatencySamples() const noexcept
Compatibility alias of getLatency(), in prepared-rate samples.
Definition TubePreamp.h:914
T getDrive() const noexcept
Definition TubePreamp.h:899
T getOutput() const noexcept
Definition TubePreamp.h:905
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place. Pass-through until prepare() succeeds.
Definition TubePreamp.h:963
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:823
void setMiddle(T middle) noexcept
Middle control of the FMV stack [0, 1]. Non-finite values are ignored.
Definition TubePreamp.h:831
int getLatency() const noexcept
Latency in prepared-rate samples added by the oversampler and the continuous core (0 at 1x = off); re...
Definition TubePreamp.h:911
T getTreble() const noexcept
Definition TubePreamp.h:900
void reset() noexcept
Re-settles every stage at its DC operating point. RT-safe.
Definition TubePreamp.h:781
T getMiddle() const noexcept
Definition TubePreamp.h:902
int getStages() const noexcept
Definition TubePreamp.h:904
void setTreble(T treble) noexcept
Treble control of the FMV stack [0, 1]. Non-finite values are ignored.
Definition TubePreamp.h:814
void setDrive(T db) noexcept
Input drive in dB [-12, +36]; level-compensated. Non-finite values are ignored.
Definition TubePreamp.h:806
T getSag() const noexcept
Definition TubePreamp.h:903
void setSag(T sag) noexcept
Supply sag depth [0, 1] (0 = stiff supply). Non-finite values are ignored.
Definition TubePreamp.h:839
T getMix() const noexcept
Definition TubePreamp.h:906
void setOversampling(int factor)
Configures internal oversampling of the nonlinear circuit (visible, tunable and switchable off)....
Definition TubePreamp.h:869
void setMix(T mix) noexcept
Dry/wet mix [0, 1]; dry is latency-compensated and the mix is ramped over at least 20 ms....
Definition TubePreamp.h:893
void setOutput(T db) noexcept
Static output trim in dB [-24, +12]. Non-finite values are ignored.
Definition TubePreamp.h:885
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition TubePreamp.h:924
void prepare(const AudioSpec &spec)
Allocates both stage configurations per channel and runs the reference calibration....
Definition TubePreamp.h:718
int getOversamplingFactor() const noexcept
Active oversampling factor (1 = off, 2 = default).
Definition TubePreamp.h:882
constexpr int interval(const std::array< int, 7 > &deg, int degIdx, int skip) noexcept
Calculate semitone interval skipping 'skip' degrees in the scale.
Main namespace for the DSPark framework.
T moveTowards(T from, T to, T maxDelta) noexcept
Moves a value toward a target by at most a given distance.
Definition DspMath.h:134
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:71
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:58
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:53
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45
static BiquadCoeffs makePeak(double sampleRate, double freq, double Q, double gainDb) noexcept
Peak (parametric EQ) filter.
Definition Biquad.h:200
static BiquadCoeffs makeLowShelf(double sampleRate, double freq, double gainDb, double slope=1.0) noexcept
Low-shelf filter.
Definition Biquad.h:314
static BiquadCoeffs makeHighShelf(double sampleRate, double freq, double gainDb, double slope=1.0) noexcept
High-shelf filter.
Definition Biquad.h:344
double exactTerm(int k, double xi, double &derivative) const noexcept
double eval(double xi, double &derivative) const noexcept
double operator()(double xi) const noexcept