DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
TransformerModel.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
71#include "../Core/AudioBuffer.h"
72#include "../Core/AudioSpec.h"
73#include "../Core/Biquad.h"
74#include "../Core/DenormalGuard.h"
75#include "../Core/DspMath.h"
76#include "../Core/Hysteresis.h"
77#include "../Core/Oversampling.h"
78#include "../Core/StateBlob.h"
79
80#include <algorithm>
81#include <atomic>
82#include <cmath>
83#include <cstddef>
84#include <cstdint>
85#include <memory>
86#include <numbers>
87#include <vector>
88
89namespace dspark {
90
97template <FloatType T>
99{
100public:
101 // -- Lifecycle ---------------------------------------------------------------
102
107 void prepare(const AudioSpec& spec)
108 {
109 if (!spec.isValid()) return;
110 prepared_.store(false, std::memory_order_relaxed);
111 spec_ = spec;
112 // Everything but the mix runs at the internal rate: the active
113 // oversampling factor times the base rate.
114 sampleRate_ = static_cast<double>(osFactor_) * spec.sampleRate;
115 mixMaxStep_ = static_cast<T>(1.0 / std::max(1.0, spec.sampleRate * 0.02));
116 numChannels_ = spec.numChannels;
117 maxBlock_ = std::max(spec.maxBlockSize, 1);
118
119 if (osFactor_ > 1)
120 {
121 oversampler_ = std::make_unique<Oversampling<T>>(
123 oversampler_->prepare(spec);
124 }
125 else
126 {
127 oversampler_.reset();
128 }
129 latency_ = oversampler_ ? oversampler_->getLatency() : 0;
130 drySize_ = 1;
131 while (drySize_ < latency_ + maxBlock_ + 1) drySize_ <<= 1;
132 dryRing_.assign(static_cast<size_t>(numChannels_),
133 std::vector<T>(static_cast<size_t>(drySize_), T(0)));
134 dryPos_ = 0;
135
136 channels_.assign(static_cast<size_t>(numChannels_), {});
137 for (auto& ch : channels_)
138 {
139 ch.hyst.prepare(sampleRate_);
140 // Unbiased iron: wider loop than biased tape (low reversible c).
141 // Constant material - set once here, not on every recompute().
142 ch.hyst.setParameters(3.5e5, 2.2e4, 1.6e-3, 3.2e4, 0.25);
143 }
144
145 prepared_.store(true, std::memory_order_relaxed);
146 dirty_.store(true, std::memory_order_release);
147 reset();
148 }
149
151 void reset() noexcept
152 {
153 if (!prepared_.load(std::memory_order_relaxed)) return;
154 for (auto& ch : channels_)
155 {
156 ch.hyst.reset();
157 ch.flux = 0.0;
158 ch.vPrev = 0.0;
159 ch.vPrev2 = 0.0;
160 ch.mPrev = 0.0;
161 ch.hpX = ch.hpY = 0.0;
162 ch.bell = {};
163 }
164 for (auto& d : dryRing_)
165 std::fill(d.begin(), d.end(), T(0));
166 dryPos_ = 0;
167 if (oversampler_) oversampler_->reset();
168 // Seed the anti-zipper ramps at their targets: no fade-in on start.
169 hScaleSm_ = -1.0;
170 mScaleSm_ = -1.0;
171 currentMix_ = mix_.load(std::memory_order_relaxed);
172 }
173
174 // -- Parameters (thread-safe) ---------------------------------------------------
175
178 void setDrive(T db) noexcept
179 {
180 if (!std::isfinite(db)) return;
181 driveDb_.store(std::clamp(db, T(-12), T(24)), std::memory_order_relaxed);
182 dirty_.store(true, std::memory_order_release);
183 }
184
188 void setCoreSize(T size) noexcept
189 {
190 if (!std::isfinite(size)) return;
191 coreSize_.store(std::clamp(size, T(0), T(1)), std::memory_order_relaxed);
192 dirty_.store(true, std::memory_order_release);
193 }
194
197 void setResonance(T amount) noexcept
198 {
199 if (!std::isfinite(amount)) return;
200 resonance_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
201 dirty_.store(true, std::memory_order_release);
202 }
203
207 void setMix(T mix) noexcept
208 {
209 if (!std::isfinite(mix)) return;
210 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
211 }
212
213 [[nodiscard]] T getDrive() const noexcept { return driveDb_.load(std::memory_order_relaxed); }
214 [[nodiscard]] T getCoreSize() const noexcept { return coreSize_.load(std::memory_order_relaxed); }
215 [[nodiscard]] T getResonance() const noexcept { return resonance_.load(std::memory_order_relaxed); }
216 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
217
228 void setOversampling(int factor)
229 {
230 if (factor < 1 || factor > 16 || (factor & (factor - 1)) != 0) return;
231 if (factor == osFactor_) return;
232 osFactor_ = factor;
233 if (prepared_.load(std::memory_order_relaxed))
234 prepare(spec_);
235 }
236
238 [[nodiscard]] int getOversamplingFactor() const noexcept { return osFactor_; }
239
242 [[nodiscard]] int getLatency() const noexcept { return latency_; }
243
245 [[nodiscard]] int getLatencySamples() const noexcept { return getLatency(); }
246
248 [[nodiscard]] std::vector<uint8_t> getState() const
249 {
250 StateWriter w(stateId("XFMR"), 2);
251 // Explicit float casts: the blob stores float, and with T = double the
252 // unqualified write(key, double) would be ambiguous (float/int32/bool).
253 w.write("drive", static_cast<float>(driveDb_.load(std::memory_order_relaxed)));
254 w.write("coreSize", static_cast<float>(coreSize_.load(std::memory_order_relaxed)));
255 w.write("resonance", static_cast<float>(resonance_.load(std::memory_order_relaxed)));
256 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
257 w.write("oversampling", osFactor_);
258 return w.blob();
259 }
260
265 bool setState(const uint8_t* data, size_t size)
266 {
267 StateReader r(data, size);
268 if (!r.isValid() || r.processorId() != stateId("XFMR")) return false;
269 setDrive(static_cast<T>(r.read("drive", 0.0f)));
270 setCoreSize(static_cast<T>(r.read("coreSize", 0.5f)));
271 setResonance(static_cast<T>(r.read("resonance", 0.3f)));
272 setMix(static_cast<T>(r.read("mix", 1.0f)));
273 setOversampling(r.read("oversampling", 2));
274 return true;
275 }
276
277 // -- Processing -------------------------------------------------------------------
278
281 void processBlock(AudioBufferView<T> buffer) noexcept
282 {
283 if (!prepared_.load(std::memory_order_relaxed)) return;
284 DenormalGuard guard;
285
286 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
287 const int nS = buffer.getNumSamples();
288 if (nCh == 0 || nS == 0) return;
289
290 // Acquire pairs with the setters' release stores so the recompute
291 // always sees the values published before the flag.
292 if (dirty_.load(std::memory_order_relaxed)
293 && dirty_.exchange(false, std::memory_order_acquire))
294 recompute();
295
296 for (int off = 0; off < nS; off += maxBlock_)
297 processChunk(buffer.getSubView(off, std::min(maxBlock_, nS - off)), nCh);
298 }
299
300private:
301 void processChunk(AudioBufferView<T> buffer, int nCh) noexcept
302 {
303 const int nS = buffer.getNumSamples();
304
305 // Non-finite guard: a NaN/Inf input would poison the leaky
306 // integrator (flux/vPrev), the algebraic inverse (vPrev2/mPrev), the
307 // DC-blocker (hpX/hpY), the bell and the oversampler permanently.
308 // Replace the bad sample with silence (dry too) so a transient glitch
309 // cannot kill the channel for the rest of the stream.
310 for (int ch = 0; ch < nCh; ++ch)
311 {
312 T* d = buffer.getChannel(ch);
313 for (int i = 0; i < nS; ++i)
314 if (!std::isfinite(d[i])) d[i] = T(0);
315 }
316
317 // Rate-limited mix ramp (moveTowards, exact landing; settled it
318 // reduces to the constant, bit-identically). A per-block ramp landed
319 // in 0.7 ms with 32-sample blocks.
320 // A hard flip on the differentiated wet stream clicked at 4.6x the
321 // steady-state sample delta.
322 const T mixTarget = mix_.load(std::memory_order_relaxed);
323 const T mixStart = currentMix_;
324
325 // Dry snapshot, read back delayed to the oversampler's latency.
326 for (int ch = 0; ch < nCh; ++ch)
327 {
328 const T* in = buffer.getChannel(ch);
329 auto& dry = dryRing_[static_cast<size_t>(ch)];
330 int dp = dryPos_;
331 for (int i = 0; i < nS; ++i)
332 {
333 dry[static_cast<size_t>(dp)] = in[i];
334 dp = (dp + 1) & (drySize_ - 1);
335 }
336 }
337
338 const bool osOn = (oversampler_ != nullptr);
339 auto osView = osOn ? oversampler_->upsample(buffer) : buffer;
340 const int osN = osView.getNumSamples();
341
342 // Anti-zipper: GEOMETRIC in-block ramps toward the recompute()
343 // targets (~50 ms across blocks), shared by all channels. Two
344 // reasons: this model differentiates its output (x 2*fs), so a flat
345 // per-block gain step clicks; and (hScale, mScale) is a calibrated
346 // compensation pair spanning orders of magnitude - interpolating it
347 // LINEARLY passes through wildly over-gained states (+24 dB
348 // measured mid-drag), while power-law interpolation keeps the pair
349 // on the calibration curve m ~ C/h^g throughout the transition.
350 if (hScaleSm_ <= 0.0) { hScaleSm_ = hScale_; mScaleSm_ = mScale_; }
351 const double kSm = 1.0 - std::exp(-static_cast<double>(osN) / (0.050 * sampleRate_));
352 const double hEnd = hScaleSm_ * std::pow(hScale_ / hScaleSm_, kSm);
353 const double mEnd = mScaleSm_ * std::pow(mScale_ / mScaleSm_, kSm);
354 const double hRat = std::pow(hEnd / hScaleSm_, 1.0 / static_cast<double>(osN));
355 const double mRat = std::pow(mEnd / mScaleSm_, 1.0 / static_cast<double>(osN));
356
357 for (int ch = 0; ch < nCh; ++ch)
358 {
359 T* d = osView.getChannel(ch);
360 auto& st = channels_[static_cast<size_t>(ch)];
361 double hSm = hScaleSm_, mSm = mScaleSm_;
362
363 for (int i = 0; i < osN; ++i)
364 {
365 hSm *= hRat;
366 mSm *= mRat;
367 const double x = static_cast<double>(d[i]);
368
369 // Leaky trapezoidal integrator: winding voltage -> flux.
370 const double fluxNew = leak_ * st.flux + halfT_ * (x + st.vPrev);
371 st.vPrev = x;
372
373 // Core hysteresis on the flux (JA, unbiased-iron parameters).
374 const double m = mSm * static_cast<double>(
375 st.hyst.processSample(static_cast<T>(hSm * fluxNew)));
376
377 // Algebraic inverse of the integrator (flux -> voltage). The
378 // damped feedback pole (rho < 1) tames z = -1: hysteresis
379 // distortion is new content the inverse never saw, and an
380 // undamped differentiator would ring at Nyquist forever. The
381 // leak alpha still cancels exactly; the only linear residue
382 // is (1+z^-1)/(1+rho z^-1), transparent to within 0.15 dB.
383 double v = (m - leak_ * st.mPrev) * invHalfT_ - kDiffRho * st.vPrev2;
384 st.vPrev2 = v;
385 st.mPrev = m;
386 st.flux = fluxNew;
387
388 // Magnetizing-inductance corner (one-pole high-pass).
389 const double hp = hpA_ * (st.hpY + v - st.hpX);
390 st.hpX = v;
391 st.hpY = hp;
392
393 // Leakage/capacitance bell.
394 d[i] = static_cast<T>(st.bell.process(hp));
395 }
396 }
397 hScaleSm_ = hEnd;
398 mScaleSm_ = mEnd;
399 if (osOn) oversampler_->downsample(buffer);
400
401 for (int ch = 0; ch < nCh; ++ch)
402 {
403 T* d = buffer.getChannel(ch);
404 const auto& dry = dryRing_[static_cast<size_t>(ch)];
405 for (int i = 0; i < nS; ++i)
406 {
407 const int idx = (dryPos_ + i - latency_) & (drySize_ - 1);
408 const T drySample = dry[static_cast<size_t>(idx)];
409 const T mixVal = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(i + 1));
410 d[i] = drySample + (d[i] - drySample) * mixVal;
411 }
412 }
413 currentMix_ = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(nS));
414 dryPos_ = (dryPos_ + nS) & (drySize_ - 1);
415 }
416
417 static constexpr double kDiffRho = 0.974;
418
419 struct BellSection
420 {
421 double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
422 double z1 = 0.0, z2 = 0.0;
423
424 [[nodiscard]] double process(double x) noexcept
425 {
426 const double y = b0 * x + z1;
427 z1 = b1 * x - a1 * y + z2;
428 z2 = b2 * x - a2 * y;
429 return y;
430 }
431 };
432
433 struct ChannelState
434 {
435 Hysteresis<T> hyst;
436 double flux = 0.0;
437 double vPrev = 0.0;
438 double vPrev2 = 0.0;
439 double mPrev = 0.0;
440 double hpX = 0.0, hpY = 0.0;
441 BellSection bell;
442 };
443
444 void recompute() noexcept
445 {
446 const double drive = std::pow(10.0, static_cast<double>(
447 driveDb_.load(std::memory_order_relaxed)) / 20.0);
448 const double size = static_cast<double>(coreSize_.load(std::memory_order_relaxed));
449 const double res = static_cast<double>(resonance_.load(std::memory_order_relaxed));
450
451 // The integrator leak keeps the flux bounded; its exact algebraic
452 // inverse cancels it, so the audible LF rolloff comes only from the
453 // explicit magnetizing-corner high-pass below.
454 const double cornerHz = 40.0 * std::pow(0.125, size); // 40 -> 5 Hz
455 leak_ = std::exp(-2.0 * std::numbers::pi * cornerHz / sampleRate_);
456 hpA_ = 1.0 - 2.0 * std::numbers::pi * cornerHz / sampleRate_;
457 halfT_ = 0.5 / sampleRate_;
458 invHalfT_ = 2.0 * sampleRate_;
459
460 // Flux scale: 0 dBFS at 30 Hz reaches H = 1.1a at nominal drive and
461 // nominal core; bigger cores take proportionally more flux.
462 const double fluxRef = 1.0 / (2.0 * std::numbers::pi * 30.0);
463 const double headroom = 0.6 + 0.8 * size;
464 hScale_ = drive * 1.1 * 2.2e4 / (fluxRef * headroom);
465
466 // Loudness calibration at PROGRAM level: 100 Hz at -12 dBFS, the
467 // flux regime music actually drives the core into (flux ~ V/f, so
468 // lows dominate). Calibrating small-signal (the old 0.05 @ 1 kHz)
469 // probed the JA virgin curve, whose susceptibility is an order of
470 // magnitude below the major loop's mean slope - at high drive the
471 // output then came out ~+21 dB over the input (measured x11),
472 // heard as violent level jumps while dragging the drive slider.
473 {
474 Hysteresis<T> cal;
475 cal.prepare(sampleRate_);
476 cal.setParameters(3.5e5, 2.2e4, 1.6e-3, 3.2e4, 0.25);
477 double flux = 0.0, vPrev = 0.0, mPrev = 0.0, vPrev2 = 0.0;
478 double inSq = 0.0, outSq = 0.0;
479 const int n = static_cast<int>(0.04 * sampleRate_); // 2+2 cycles of 100 Hz
480 for (int i = 0; i < n; ++i)
481 {
482 const double x = 0.25 * std::sin(2.0 * std::numbers::pi * 100.0 * i / sampleRate_);
483 const double fluxNew = leak_ * flux + halfT_ * (x + vPrev);
484 vPrev = x;
485 const double m = static_cast<double>(
486 cal.processSample(static_cast<T>(hScale_ * fluxNew)));
487 double v = (m - leak_ * mPrev) * invHalfT_ - kDiffRho * vPrev2;
488 vPrev2 = v;
489 mPrev = m;
490 flux = fluxNew;
491 if (i >= n / 2)
492 {
493 inSq += x * x;
494 outSq += v * v;
495 }
496 }
497 mScale_ = (outSq > 0.0) ? std::sqrt(inSq / outSq) : 1.0;
498 }
499
500 // HF bell: Jensen-style leakage resonance mapped into the top octave.
501 const double bellHz = std::min(12000.0 + 6000.0 * res, 0.42 * sampleRate_);
502 const double bellDb = 2.5 * res;
503 const auto bc = BiquadCoeffs::makePeak(sampleRate_, bellHz, 0.8, bellDb);
504 for (auto& ch : channels_)
505 {
506 const double pz1 = ch.bell.z1, pz2 = ch.bell.z2;
507 ch.bell = BellSection { bc.b0, bc.b1, bc.b2, bc.a1, bc.a2, 0.0, 0.0 };
508 ch.bell.z1 = pz1;
509 ch.bell.z2 = pz2;
510 }
511 }
512
513 // -- Members --------------------------------------------------------------------
514 AudioSpec spec_ {};
515 double sampleRate_ = 48000.0;
516 int numChannels_ = 0;
517 int maxBlock_ = 1;
518 int osFactor_ = 2;
519 int latency_ = 0;
520 std::unique_ptr<Oversampling<T>> oversampler_;
521 std::vector<std::vector<T>> dryRing_;
522 int drySize_ = 1;
523 int dryPos_ = 0;
524 std::atomic<bool> prepared_ { false };
525
526 std::vector<ChannelState> channels_;
527
528 double leak_ = 0.999;
529 double hpA_ = 0.999;
530 double halfT_ = 0.5 / 48000.0;
531 double invHalfT_ = 96000.0;
532 double hScale_ = 1.0;
533 double mScale_ = 1.0;
534 double hScaleSm_ = -1.0;
535 double mScaleSm_ = -1.0;
536 T currentMix_ = T(1);
537 T mixMaxStep_ = T(1.0 / 960.0);
538
539 std::atomic<T> driveDb_ { T(0) };
540 std::atomic<T> coreSize_ { T(0.5) };
541 std::atomic<T> resonance_ { T(0.3) };
542 std::atomic<T> mix_ { T(1) };
543 std::atomic<bool> dirty_ { true };
544};
545
546} // 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: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
Physical audio-transformer coloration (flux-domain JA hysteresis).
void prepare(const AudioSpec &spec)
Allocates per-channel circuit state. Invalid specs (non-positive or non-finite rate,...
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
int getLatencySamples() const noexcept
Compatibility alias of getLatency(), in prepared-rate samples.
void setResonance(T amount) noexcept
Leakage/capacitance HF bell amount [0, 1] (Jensen-style). Non-finite values are ignored.
void setOversampling(int factor)
Configures internal oversampling of the core. SETUP THREAD ONLY: it reallocates the filters and re-ru...
T getMix() const noexcept
int getLatency() const noexcept
Latency in samples: the active oversampler's group delay (0 at 1x). The model itself is minimum-phase...
T getDrive() const noexcept
int getOversamplingFactor() const noexcept
Active oversampling factor (1 = off, 2 = default).
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place. Pass-through until prepare() succeeds. Blocks longer than the prepared ma...
void setDrive(T db) noexcept
Core drive in dB [-12, +24]; loudness-compensated. Non-finite values are ignored.
void setCoreSize(T size) noexcept
Core size [0, 1]: small chokes at 40 Hz and saturates early, big rings to 5 Hz with more low-end head...
void reset() noexcept
Clears all signal state. RT-safe.
void setMix(T mix) noexcept
Dry/wet mix [0, 1]; the dry path is delayed to getLatency() and the mix is ramped over at least 20 ms...
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids). Setup thread: a stored oversampling ...
T getResonance() const noexcept
T getCoreSize() const noexcept
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