DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
TransformerModel.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
60#include "../Core/AudioBuffer.h"
61#include "../Core/AudioSpec.h"
62#include "../Core/Biquad.h"
63#include "../Core/DenormalGuard.h"
64#include "../Core/DspMath.h"
65#include "../Core/Hysteresis.h"
66#include "../Core/StateBlob.h"
67
68#include <algorithm>
69#include <atomic>
70#include <cmath>
71#include <cstddef>
72#include <cstdint>
73#include <numbers>
74#include <vector>
75
76namespace dspark {
77
84template <FloatType T>
86{
87public:
88 // -- Lifecycle ---------------------------------------------------------------
89
94 void prepare(const AudioSpec& spec)
95 {
96 if (!spec.isValid()) return;
97 prepared_.store(false, std::memory_order_relaxed);
98 sampleRate_ = spec.sampleRate;
99 numChannels_ = spec.numChannels;
100
101 channels_.assign(static_cast<size_t>(numChannels_), {});
102 for (auto& ch : channels_)
103 {
104 ch.hyst.prepare(sampleRate_);
105 // Unbiased iron: wider loop than biased tape (low reversible c).
106 // Constant material - set once here, not on every recompute().
107 ch.hyst.setParameters(3.5e5, 2.2e4, 1.6e-3, 3.2e4, 0.25);
108 }
109
110 prepared_.store(true, std::memory_order_relaxed);
111 dirty_.store(true, std::memory_order_release);
112 reset();
113 }
114
116 void reset() noexcept
117 {
118 if (!prepared_.load(std::memory_order_relaxed)) return;
119 for (auto& ch : channels_)
120 {
121 ch.hyst.reset();
122 ch.flux = 0.0;
123 ch.vPrev = 0.0;
124 ch.vPrev2 = 0.0;
125 ch.mPrev = 0.0;
126 ch.hpX = ch.hpY = 0.0;
127 ch.bell = {};
128 }
129 // Seed the anti-zipper ramps at their targets: no fade-in on start.
130 hScaleSm_ = -1.0;
131 mScaleSm_ = -1.0;
132 currentMix_ = mix_.load(std::memory_order_relaxed);
133 }
134
135 // -- Parameters (thread-safe) ---------------------------------------------------
136
139 void setDrive(T db) noexcept
140 {
141 if (!std::isfinite(db)) return;
142 driveDb_.store(std::clamp(db, T(-12), T(24)), std::memory_order_relaxed);
143 dirty_.store(true, std::memory_order_release);
144 }
145
149 void setCoreSize(T size) noexcept
150 {
151 if (!std::isfinite(size)) return;
152 coreSize_.store(std::clamp(size, T(0), T(1)), std::memory_order_relaxed);
153 dirty_.store(true, std::memory_order_release);
154 }
155
158 void setResonance(T amount) noexcept
159 {
160 if (!std::isfinite(amount)) return;
161 resonance_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
162 dirty_.store(true, std::memory_order_release);
163 }
164
167 void setMix(T mix) noexcept
168 {
169 if (!std::isfinite(mix)) return;
170 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
171 }
172
173 [[nodiscard]] T getDrive() const noexcept { return driveDb_.load(std::memory_order_relaxed); }
174 [[nodiscard]] T getCoreSize() const noexcept { return coreSize_.load(std::memory_order_relaxed); }
175 [[nodiscard]] T getResonance() const noexcept { return resonance_.load(std::memory_order_relaxed); }
176 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
177
179 [[nodiscard]] static constexpr int getLatency() noexcept { return 0; }
180
182 [[nodiscard]] std::vector<uint8_t> getState() const
183 {
184 StateWriter w(stateId("XFMR"), 1);
185 // Explicit float casts: the blob stores float, and with T = double the
186 // unqualified write(key, double) would be ambiguous (float/int32/bool).
187 w.write("drive", static_cast<float>(driveDb_.load(std::memory_order_relaxed)));
188 w.write("coreSize", static_cast<float>(coreSize_.load(std::memory_order_relaxed)));
189 w.write("resonance", static_cast<float>(resonance_.load(std::memory_order_relaxed)));
190 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
191 return w.blob();
192 }
193
195 bool setState(const uint8_t* data, size_t size)
196 {
197 StateReader r(data, size);
198 if (!r.isValid() || r.processorId() != stateId("XFMR")) return false;
199 setDrive(static_cast<T>(r.read("drive", 0.0f)));
200 setCoreSize(static_cast<T>(r.read("coreSize", 0.5f)));
201 setResonance(static_cast<T>(r.read("resonance", 0.3f)));
202 setMix(static_cast<T>(r.read("mix", 1.0f)));
203 return true;
204 }
205
206 // -- Processing -------------------------------------------------------------------
207
209 void processBlock(AudioBufferView<T> buffer) noexcept
210 {
211 if (!prepared_.load(std::memory_order_relaxed)) return;
212 DenormalGuard guard;
213
214 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
215 const int nS = buffer.getNumSamples();
216 if (nCh == 0 || nS == 0) return;
217
218 // Acquire pairs with the setters' release stores so the recompute
219 // always sees the values published before the flag.
220 if (dirty_.load(std::memory_order_relaxed)
221 && dirty_.exchange(false, std::memory_order_acquire))
222 recompute();
223
224 // Linear per-block mix ramp with exact landing (settled: step == 0
225 // and the per-sample value reduces to the constant, bit-identically).
226 // A hard flip on the differentiated wet stream clicked at 4.6x the
227 // steady-state sample delta.
228 const T mixTarget = mix_.load(std::memory_order_relaxed);
229 const T mixStart = currentMix_;
230 const T mixStep = (mixTarget - mixStart) / static_cast<T>(nS);
231
232 // Anti-zipper: GEOMETRIC in-block ramps toward the recompute()
233 // targets (~50 ms across blocks), shared by all channels. Two
234 // reasons: this model differentiates its output (x 2*fs), so a flat
235 // per-block gain step clicks; and (hScale, mScale) is a calibrated
236 // compensation pair spanning orders of magnitude - interpolating it
237 // LINEARLY passes through wildly over-gained states (+24 dB
238 // measured mid-drag), while power-law interpolation keeps the pair
239 // on the calibration curve m ~ C/h^g throughout the transition.
240 if (hScaleSm_ <= 0.0) { hScaleSm_ = hScale_; mScaleSm_ = mScale_; }
241 const double kSm = 1.0 - std::exp(-static_cast<double>(nS) / (0.050 * sampleRate_));
242 const double hEnd = hScaleSm_ * std::pow(hScale_ / hScaleSm_, kSm);
243 const double mEnd = mScaleSm_ * std::pow(mScale_ / mScaleSm_, kSm);
244 const double hRat = std::pow(hEnd / hScaleSm_, 1.0 / static_cast<double>(nS));
245 const double mRat = std::pow(mEnd / mScaleSm_, 1.0 / static_cast<double>(nS));
246
247 for (int ch = 0; ch < nCh; ++ch)
248 {
249 T* d = buffer.getChannel(ch);
250 auto& st = channels_[static_cast<size_t>(ch)];
251 double hSm = hScaleSm_, mSm = mScaleSm_;
252
253 for (int i = 0; i < nS; ++i)
254 {
255 hSm *= hRat;
256 mSm *= mRat;
257 // Non-finite guard: a NaN/Inf input would poison the leaky
258 // integrator (flux/vPrev), the algebraic inverse (vPrev2/mPrev),
259 // the DC-blocker (hpX/hpY) and the bell permanently. Replace the
260 // bad sample with silence (dry too) so a transient glitch cannot
261 // kill the channel for the rest of the stream.
262 double x = static_cast<double>(d[i]);
263 if (!std::isfinite(x)) { x = 0.0; d[i] = T(0); }
264
265 // Leaky trapezoidal integrator: winding voltage -> flux.
266 const double fluxNew = leak_ * st.flux + halfT_ * (x + st.vPrev);
267 st.vPrev = x;
268
269 // Core hysteresis on the flux (JA, unbiased-iron parameters).
270 const double m = mSm * static_cast<double>(
271 st.hyst.processSample(static_cast<T>(hSm * fluxNew)));
272
273 // Algebraic inverse of the integrator (flux -> voltage). The
274 // damped feedback pole (rho < 1) tames z = -1: hysteresis
275 // distortion is new content the inverse never saw, and an
276 // undamped differentiator would ring at Nyquist forever. The
277 // leak alpha still cancels exactly; the only linear residue
278 // is (1+z^-1)/(1+rho z^-1), transparent to within 0.15 dB.
279 double v = (m - leak_ * st.mPrev) * invHalfT_ - kDiffRho * st.vPrev2;
280 st.vPrev2 = v;
281 st.mPrev = m;
282 st.flux = fluxNew;
283
284 // Magnetizing-inductance corner (one-pole high-pass).
285 const double hp = hpA_ * (st.hpY + v - st.hpX);
286 st.hpX = v;
287 st.hpY = hp;
288
289 // Leakage/capacitance bell.
290 const double y = st.bell.process(hp);
291
292 const T wet = static_cast<T>(y);
293 const T mixVal = mixStart + mixStep * static_cast<T>(i);
294 d[i] = d[i] + (wet - d[i]) * mixVal;
295 }
296 }
297 currentMix_ = mixTarget; // exact landing
298 hScaleSm_ = hEnd;
299 mScaleSm_ = mEnd;
300 }
301
302private:
303 static constexpr double kDiffRho = 0.974;
304
305 struct BellSection
306 {
307 double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
308 double z1 = 0.0, z2 = 0.0;
309
310 [[nodiscard]] double process(double x) noexcept
311 {
312 const double y = b0 * x + z1;
313 z1 = b1 * x - a1 * y + z2;
314 z2 = b2 * x - a2 * y;
315 return y;
316 }
317 };
318
319 struct ChannelState
320 {
321 Hysteresis<T> hyst;
322 double flux = 0.0;
323 double vPrev = 0.0;
324 double vPrev2 = 0.0;
325 double mPrev = 0.0;
326 double hpX = 0.0, hpY = 0.0;
327 BellSection bell;
328 };
329
330 void recompute() noexcept
331 {
332 const double drive = std::pow(10.0, static_cast<double>(
333 driveDb_.load(std::memory_order_relaxed)) / 20.0);
334 const double size = static_cast<double>(coreSize_.load(std::memory_order_relaxed));
335 const double res = static_cast<double>(resonance_.load(std::memory_order_relaxed));
336
337 // The integrator leak keeps the flux bounded; its exact algebraic
338 // inverse cancels it, so the audible LF rolloff comes only from the
339 // explicit magnetizing-corner high-pass below.
340 const double cornerHz = 40.0 * std::pow(0.125, size); // 40 -> 5 Hz
341 leak_ = std::exp(-2.0 * std::numbers::pi * cornerHz / sampleRate_);
342 hpA_ = 1.0 - 2.0 * std::numbers::pi * cornerHz / sampleRate_;
343 halfT_ = 0.5 / sampleRate_;
344 invHalfT_ = 2.0 * sampleRate_;
345
346 // Flux scale: 0 dBFS at 30 Hz reaches H = 1.1a at nominal drive and
347 // nominal core; bigger cores take proportionally more flux.
348 const double fluxRef = 1.0 / (2.0 * std::numbers::pi * 30.0);
349 const double headroom = 0.6 + 0.8 * size;
350 hScale_ = drive * 1.1 * 2.2e4 / (fluxRef * headroom);
351
352 // Loudness calibration at PROGRAM level: 100 Hz at -12 dBFS, the
353 // flux regime music actually drives the core into (flux ~ V/f, so
354 // lows dominate). Calibrating small-signal (the old 0.05 @ 1 kHz)
355 // probed the JA virgin curve, whose susceptibility is an order of
356 // magnitude below the major loop's mean slope - at high drive the
357 // output then came out ~+21 dB over the input (measured x11),
358 // heard as violent level jumps while dragging the drive slider.
359 {
360 Hysteresis<T> cal;
361 cal.prepare(sampleRate_);
362 cal.setParameters(3.5e5, 2.2e4, 1.6e-3, 3.2e4, 0.25);
363 double flux = 0.0, vPrev = 0.0, mPrev = 0.0, vPrev2 = 0.0;
364 double inSq = 0.0, outSq = 0.0;
365 const int n = static_cast<int>(0.04 * sampleRate_); // 2+2 cycles of 100 Hz
366 for (int i = 0; i < n; ++i)
367 {
368 const double x = 0.25 * std::sin(2.0 * std::numbers::pi * 100.0 * i / sampleRate_);
369 const double fluxNew = leak_ * flux + halfT_ * (x + vPrev);
370 vPrev = x;
371 const double m = static_cast<double>(
372 cal.processSample(static_cast<T>(hScale_ * fluxNew)));
373 double v = (m - leak_ * mPrev) * invHalfT_ - kDiffRho * vPrev2;
374 vPrev2 = v;
375 mPrev = m;
376 flux = fluxNew;
377 if (i >= n / 2)
378 {
379 inSq += x * x;
380 outSq += v * v;
381 }
382 }
383 mScale_ = (outSq > 0.0) ? std::sqrt(inSq / outSq) : 1.0;
384 }
385
386 // HF bell: Jensen-style leakage resonance mapped into the top octave.
387 const double bellHz = std::min(12000.0 + 6000.0 * res, 0.42 * sampleRate_);
388 const double bellDb = 2.5 * res;
389 const auto bc = BiquadCoeffs::makePeak(sampleRate_, bellHz, 0.8, bellDb);
390 for (auto& ch : channels_)
391 {
392 const double pz1 = ch.bell.z1, pz2 = ch.bell.z2;
393 ch.bell = BellSection { bc.b0, bc.b1, bc.b2, bc.a1, bc.a2, 0.0, 0.0 };
394 ch.bell.z1 = pz1;
395 ch.bell.z2 = pz2;
396 }
397 }
398
399 // -- Members --------------------------------------------------------------------
400 double sampleRate_ = 48000.0;
401 int numChannels_ = 0;
402 std::atomic<bool> prepared_ { false };
403
404 std::vector<ChannelState> channels_;
405
406 double leak_ = 0.999;
407 double hpA_ = 0.999;
408 double halfT_ = 0.5 / 48000.0;
409 double invHalfT_ = 96000.0;
410 double hScale_ = 1.0;
411 double mScale_ = 1.0;
412 double hScaleSm_ = -1.0;
413 double mScaleSm_ = -1.0;
414 T currentMix_ = T(1);
415
416 std::atomic<T> driveDb_ { T(0) };
417 std::atomic<T> coreSize_ { T(0.5) };
418 std::atomic<T> resonance_ { T(0.3) };
419 std::atomic<T> mix_ { T(1) };
420 std::atomic<bool> dirty_ { true };
421};
422
423} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:160
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:203
bool isValid() const noexcept
Definition StateBlob.h:198
uint32_t processorId() const noexcept
Definition StateBlob.h:199
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:52
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:104
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:70
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).
void setResonance(T amount) noexcept
Leakage/capacitance HF bell amount [0, 1] (Jensen-style). Non-finite values are ignored.
static constexpr int getLatency() noexcept
Zero - the model is all minimum-phase IIR and memoryless NR.
T getMix() const noexcept
T getDrive() const noexcept
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place. Pass-through until prepare() succeeds.
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]; smoothed linearly over one block. Zero latency: no compensation needed....
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
T getResonance() const noexcept
T getCoreSize() const noexcept
Main namespace for the DSPark framework.
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:639
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:56
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43
static BiquadCoeffs makePeak(double sampleRate, double freq, double Q, double gainDb) noexcept
Peak (parametric EQ) filter.
Definition Biquad.h:185