Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
hysteresis.hpp
Go to the documentation of this file.
1#pragma once
2
3// Jiles-Atherton magnetic hysteresis, and the oversampling wrap it needs.
4//
5// This is what makes the physical tape tier a MODEL rather than a curve. A
6// static waveshaper maps input to output; magnetization does not, because the
7// state of the medium depends on where it has been. Feed a slow sine through
8// this stage and plot output against input and you get a loop with area — the
9// same input value produces different outputs on the rising and falling edges.
10// That memory is why real tape compresses transients differently from steady
11// tone, and it is the property a waveshaper cannot fake at any drive.
12//
13// The magnetization ODE and the audio-rate discrete form follow Jiles &
14// Atherton (1986) and Chowdhury (2019):
15//
16// dM/dH = [(1−c)·δ_M·(M_an − M)] / [(1−c)·δ·k − α·(M_an − M)] + c·dM_an/dH
17// M_an = M_s·L(H_e/a), H_e = H + α·M, L(x) = coth(x) − 1/x
18//
19// Everything is solved in units NORMALIZED by M_s, so the state stays O(1) in
20// double precision instead of swinging over ±3.5×10^5 A/m — the ODE is stiff
21// enough without also asking the solver to work at that scale.
22//
23// Two implementation choices are load-bearing:
24//
25// * A Newton-Raphson solve on the TRAPEZOIDAL form, not forward Euler.
26// Explicit integration of this ODE is unstable at the drives that make it
27// interesting, and an unstable magnetization in a FEEDBACK LOOP is not a
28// glitch, it is a divergence.
29// * Oversampling of this stage ONLY. The nonlinearity generates harmonics
30// well past Nyquist; everything else in the tape chain is linear and gains
31// nothing from running faster. Chowdhury's finding is that 2× is acceptable
32// at moderate drive and 4× is good across the range, with ≥8× showing
33// diminishing returns.
34
38
39#include <algorithm>
40#include <array>
41#include <cmath>
42#include <cstddef>
43
45
65struct Langevin {
66 static constexpr double kSeriesLimit = 1.0;
67
68 static double value(double x) noexcept {
69 if (std::abs(x) < kSeriesLimit) {
70 const double x2 = x * x;
71 return x * (1.0 / 3.0 + x2 * (-1.0 / 45.0 + x2 * (2.0 / 945.0 - x2 / 4725.0)));
72 }
73 return coth(x) - 1.0 / x;
74 }
75
76 static double derivative(double x) noexcept {
77 if (std::abs(x) < kSeriesLimit) {
78 const double x2 = x * x;
79 return 1.0 / 3.0 + x2 * (-1.0 / 15.0 + x2 * (2.0 / 189.0 - x2 / 675.0));
80 }
81 const double c = coth(x);
82 return 1.0 / (x * x) - c * c + 1.0;
83 }
84
85 static double second_derivative(double x) noexcept {
86 if (std::abs(x) < kSeriesLimit) {
87 const double x2 = x * x;
88 return x * (-2.0 / 15.0 + x2 * (8.0 / 189.0 - x2 * (2.0 / 225.0)));
89 }
90 const double c = coth(x);
91 return -2.0 / (x * x * x) + 2.0 * c * (c * c - 1.0);
92 }
93
97 static double coth(double x) noexcept {
98 const double t = fast_tanh(x);
99 const double magnitude = std::max(std::abs(t), 1e-12);
100 return (t < 0.0 ? -1.0 : 1.0) / magnitude;
101 }
102};
103
106public:
107 void prepare(double solver_rate_hz) noexcept {
108 period_ = 1.0 / std::max(solver_rate_hz, 1.0);
109 reset();
110 }
111
112 void reset() noexcept {
113 magnetization_ = 0.0;
114 field_ = 0.0;
115 field_derivative_ = 0.0;
116 rate_previous_ = 0.0;
117 }
118
128 void set_character(double drive, double bias) noexcept {
129 const double reference_drive = kTapeDrive.front();
130 const double safe_drive = std::max(drive, 1e-3);
132 (reference_drive / safe_drive);
133 shape_ = std::max(shape_, 1e-4);
135
136 const double bias_span = std::max(kTapeBias.back(), 1e-9);
137 const double t = std::clamp(bias / bias_span, 0.0, 1.0);
138 reversibility_ = 0.3 + t * (0.1 - 0.3); // published c range, new → worn
139 }
140
143 double process(double field) noexcept {
144 // Trapezoidal recurrence for dH/dt — the standard companion to a
145 // trapezoidal integration of the state.
146 const double rate =
147 (2.0 / period_) * (field - field_) - rate_previous_;
148
149 // Silence snap: with no field and no field motion, hold zero rather
150 // than letting the solver park on a frozen DC magnetization that would
151 // bias every later repeat.
152 if (std::abs(field) < kJaSilenceThreshold && std::abs(rate) < kJaSilenceThreshold) {
153 magnetization_ = 0.0;
154 field_ = field;
155 field_derivative_ = 0.0;
156 rate_previous_ = 0.0;
157 return 0.0;
158 }
159
160 const double derivative_previous = field_derivative_;
161 // Start from a forward-Euler extrapolation rather than from the previous
162 // state. Newton converges from either, but the extrapolated guess is
163 // already first-order correct, which is the difference between two
164 // iterations and running out the cap at high drive.
165 double m = magnetization_ + period_ * derivative_previous;
166
167 // δ_M is a SWITCH on sign(M_an − M), so it flips as Newton moves the
168 // iterate across the anhysteretic curve — and a residual that changes
169 // definition mid-solve makes Newton oscillate instead of converge. It is
170 // decided once from the initial guess and held for the step, which is
171 // the standard linearization: over one audio-rate step the branch is a
172 // property of where the material is, not of where the solver is looking.
173 const bool irreversible_active = branch_active(m, field, rate);
174
175 for (int iteration = 0; iteration < kJaMaxIterations; ++iteration) {
176 double slope = 0.0;
177 const double derivative_now = evaluate(m, field, rate, slope, irreversible_active);
178 const double residual =
179 m - magnetization_ - 0.5 * period_ * (derivative_now + derivative_previous);
180 // Test the RESIDUAL as well as the step. At a field reversal the
181 // δ_M switch makes dM/dH piecewise, so Newton's step can stay
182 // finite while the equation is already satisfied to well below the
183 // 24-bit floor; without this the solver reports a cap-out once per
184 // cycle at high drive even though the answer is correct.
185 if (std::abs(residual) < kJaConvergenceThreshold) {
186 ++converged_;
187 break;
188 }
189 const double jacobian = 1.0 - 0.5 * period_ * slope;
190 if (std::abs(jacobian) < 1e-12) break;
191 const double delta = residual / jacobian;
192 m -= delta;
193 if (std::abs(delta) < kJaConvergenceThreshold) {
194 ++converged_;
195 break;
196 }
197 if (iteration == kJaMaxIterations - 1) ++capped_;
198 }
199
200 double unused_slope = 0.0;
201 field_derivative_ = evaluate(m, field, rate, unused_slope, irreversible_active);
202
203 if (!std::isfinite(m) || !std::isfinite(field_derivative_)) {
204 // Reset BOTH the state and the derivative: restoring only the
205 // magnetization re-poisons it from the stale derivative on the very
206 // next trapezoidal step.
207 m = 0.0;
208 field_derivative_ = 0.0;
209 rate_previous_ = 0.0;
210 field_ = 0.0;
211 magnetization_ = 0.0;
212 return 0.0;
213 }
214
215 magnetization_ = flush_denormal(m);
216 field_ = field;
217 rate_previous_ = rate;
218 return magnetization_;
219 }
220
223 std::size_t converged_steps() const noexcept { return converged_; }
224 std::size_t capped_steps() const noexcept { return capped_; }
225 void clear_solver_counters() noexcept {
226 converged_ = 0;
227 capped_ = 0;
228 }
229
230private:
236 bool branch_active(double m, double h, double rate) const noexcept {
237 const double effective_field = h + kJaInterdomainCoupling * m;
238 const double q =
239 std::clamp(effective_field / shape_, -kJaLangevinClamp, kJaLangevinClamp);
240 const double difference = Langevin::value(q) - m;
241 return (difference > 0.0) == (rate >= 0.0) && difference != 0.0;
242 }
243
244 double evaluate(double m, double h, double rate, double& slope,
245 bool irreversible_active) const noexcept {
246 const double effective_field = h + kJaInterdomainCoupling * m;
247 const double q =
248 std::clamp(effective_field / shape_, -kJaLangevinClamp, kJaLangevinClamp);
249
250 const double anhysteretic = Langevin::value(q);
251 const double langevin_first = Langevin::derivative(q);
252 const double langevin_second = Langevin::second_derivative(q);
253
254 const double difference = anhysteretic - m;
255 const double delta = (rate >= 0.0) ? 1.0 : -1.0;
256 const double delta_m = irreversible_active ? 1.0 : 0.0;
257
258 const double one_minus_c = 1.0 - reversibility_;
259 double denominator = one_minus_c * delta * pinning_ - kJaInterdomainCoupling * difference;
260 if (std::abs(denominator) < 1e-12) denominator = (denominator < 0.0 ? -1e-12 : 1e-12);
261
262 const double irreversible = one_minus_c * delta_m * difference / denominator;
263 const double reversible = reversibility_ * langevin_first / shape_;
264 const double dm_dh = irreversible + reversible;
265
266 // ∂(dM/dH)/∂M, with dQ/dM = α/a.
267 const double dq_dm = kJaInterdomainCoupling / shape_;
268 const double u = langevin_first * dq_dm - 1.0;
269 const double irreversible_slope = one_minus_c * delta_m * u * (one_minus_c * delta * pinning_) /
270 (denominator * denominator);
271 const double reversible_slope = reversibility_ * langevin_second * dq_dm / shape_;
272
273 slope = (irreversible_slope + reversible_slope) * rate;
274 return dm_dh * rate;
275 }
276
277 double period_ = 1.0 / 192000.0;
278 double magnetization_ = 0.0;
279 double field_ = 0.0;
280 double field_derivative_ = 0.0;
281 double rate_previous_ = 0.0;
282
284 double pinning_ = kJaPinning / kJaSaturationMagnetization;
285 double reversibility_ = kJaReversibility;
286
287 std::size_t converged_ = 0;
288 std::size_t capped_ = 0;
289};
290
298template <std::size_t StageCount>
300public:
301 static_assert(StageCount > 0);
302
303 static constexpr int oversampling_factor() noexcept {
304 return 1 << StageCount;
305 }
306
307 void prepare() {
308 // Each stage's passband is the house stage-one corner referred to its
309 // own input rate, so a deeper cascade preserves the same absolute
310 // 0.45 * host fs corner rather than re-stating a constant per depth.
311 // Halving is exact in binary floating point, so the derivation and the
312 // named stage-two constant cannot drift apart.
313 static_assert(kHouseHalfBandStageOnePassband / 2.0 ==
315 for (std::size_t stage = 0; stage < StageCount; ++stage) {
316 const double passband = kHouseHalfBandStageOnePassband /
317 static_cast<double>(1u << stage);
318 stages_[stage].configure(passband, kHysteresisHalfBandStopbandDb,
320 }
321 reset();
322 }
323
324 void reset() noexcept {
325 for (auto& stage : stages_) stage.reset();
326 }
327
329 static int latency_samples() noexcept {
330 const auto taps = static_cast<int>(kHysteresisHalfBandTaps);
331 int latency = 0;
332 for (std::size_t stage = 0; stage < StageCount; ++stage)
333 latency += (taps - 1) / static_cast<int>(1u << (stage + 1u));
334 return latency;
335 }
336
337 template <typename Callback>
338 double process(double x, Callback&& callback) {
339 return process_stage<0>(x, callback);
340 }
341
342private:
343 template <std::size_t Stage, typename Callback>
344 double process_stage(double x, Callback& callback) {
345 if constexpr (Stage == StageCount) {
346 return callback(x);
347 } else {
348 double even = 0.0;
349 double odd = 0.0;
350 stages_[Stage].upsample(x, even, odd);
351 // The callback owns time-dependent magnetic state. Function-argument
352 // evaluation order is not left-to-right, so evaluate the phases
353 // explicitly in chronological order before passing them onward.
354 const double processed_even = process_stage<Stage + 1u>(even, callback);
355 const double processed_odd = process_stage<Stage + 1u>(odd, callback);
356 return stages_[Stage].downsample(processed_even, processed_odd);
357 }
358 }
359
360 std::array<detail::LinearPhaseOversamplingStage2x<double>, StageCount> stages_{};
361};
362
380
385template <typename Wrapper>
387public:
388 static constexpr int oversampling_factor() noexcept {
389 return Wrapper::oversampling_factor();
390 }
391
392 static int latency_samples() noexcept {
393 return Wrapper::latency_samples();
394 }
395
396 void prepare(double host_rate_hz) {
397 wrapper_.prepare();
398 solver_.prepare(host_rate_hz * static_cast<double>(oversampling_factor()));
399 }
400
401 void reset() noexcept {
402 wrapper_.reset();
403 solver_.reset();
404 }
405
406 void set_character(double drive, double bias) noexcept {
407 solver_.set_character(drive, bias);
408 }
409
410 double process(double input) {
411 return wrapper_.process(
412 input, [this](double sample) { return solver_.process(sample); });
413 }
414
415private:
416 Wrapper wrapper_;
418};
419
422
423} // namespace pulp::signal::chardelay
Fixed-depth linear-phase half-band oversampling around a per-sample callback.
Definition hysteresis.hpp:299
static constexpr int oversampling_factor() noexcept
Definition hysteresis.hpp:303
void reset() noexcept
Definition hysteresis.hpp:324
static int latency_samples() noexcept
Group delay of the complete up/down cascade, in host samples.
Definition hysteresis.hpp:329
void prepare()
Definition hysteresis.hpp:307
double process(double x, Callback &&callback)
Definition hysteresis.hpp:338
The hysteresis stage for one channel, in normalized units.
Definition hysteresis.hpp:105
void prepare(double solver_rate_hz) noexcept
Definition hysteresis.hpp:107
void clear_solver_counters() noexcept
Definition hysteresis.hpp:225
void set_character(double drive, double bias) noexcept
drive and bias come from the tape character table; both are re-mapped onto physical constants here ra...
Definition hysteresis.hpp:128
void reset() noexcept
Definition hysteresis.hpp:112
std::size_t converged_steps() const noexcept
Diagnostics for the acceptance suite: how often the solver converged inside the iteration cap versus ...
Definition hysteresis.hpp:223
std::size_t capped_steps() const noexcept
Definition hysteresis.hpp:224
double process(double field) noexcept
One solver step.
Definition hysteresis.hpp:143
Production-owned composition of a fixed wrapper and magnetic solver.
Definition hysteresis.hpp:386
double process(double input)
Definition hysteresis.hpp:410
void prepare(double host_rate_hz)
Definition hysteresis.hpp:396
void set_character(double drive, double bias) noexcept
Definition hysteresis.hpp:406
static int latency_samples() noexcept
Definition hysteresis.hpp:392
static constexpr int oversampling_factor() noexcept
Definition hysteresis.hpp:388
void reset() noexcept
Definition hysteresis.hpp:401
Definition bbd.hpp:36
HysteresisHalfBandOversampler< 2 > HalfBandOversampler4x
The two depths the module has a measured reason to name.
Definition hysteresis.hpp:378
OversampledHysteresis< HysteresisOversampler8x > OversampledHysteresis8x
Definition hysteresis.hpp:421
constexpr int kJaMaxIterations
Newton-Raphson budget.
Definition tables.hpp:373
OversampledHysteresis< HalfBandOversampler4x > OversampledHysteresis4x
Definition hysteresis.hpp:420
constexpr std::array< double, 4 > kTapeBias
Definition tables.hpp:141
double flush_denormal(double v) noexcept
Definition primitives.hpp:45
constexpr double kJaPinning
Definition tables.hpp:366
constexpr double kJaSilenceThreshold
Below this |H| and |dH/dt|, magnetization is snapped to 0 so a silent loop cannot hold a frozen DC ma...
Definition tables.hpp:381
double fast_tanh(double v) noexcept
Definition primitives.hpp:57
constexpr double kJaInterdomainCoupling
Definition tables.hpp:368
constexpr double kHysteresisHalfBandStopbandDb
Definition tables.hpp:388
constexpr std::array< double, 4 > kTapeDrive
Definition tables.hpp:140
constexpr double kJaLangevinClamp
Langevin argument clamp — keeps coth() and its derivative in the range where the rational approximati...
Definition tables.hpp:378
constexpr double kJaReversibility
Definition tables.hpp:367
constexpr double kJaAnhystereticShape
Definition tables.hpp:365
constexpr double kJaConvergenceThreshold
Definition tables.hpp:374
constexpr double kJaSaturationMagnetization
PUBLISHED γ-Fe2O3 material constants (Jiles, Thoelke & Devine 1992, reproduced in Chowdhury 2019).
Definition tables.hpp:364
constexpr std::size_t kHysteresisHalfBandTaps
Hysteresis oversampling factor and half-band design.
Definition tables.hpp:387
HysteresisHalfBandOversampler< 3 > HysteresisOversampler8x
Definition hysteresis.hpp:379
@ c
Definition spectrum_trace.hpp:35
constexpr double kHouseHalfBandStageTwoPassband
Definition oversampling_fir.hpp:24
@ u
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20
constexpr double kHouseHalfBandStageOnePassband
Definition oversampling_fir.hpp:23
Linear-phase 2x FIR stage used by the realtime oversampler.
Langevin function L(x) = coth(x) − 1/x and its first two derivatives.
Definition hysteresis.hpp:65
static double value(double x) noexcept
Definition hysteresis.hpp:68
static double coth(double x) noexcept
coth via the sanctioned rational tanh approximation and a reciprocal.
Definition hysteresis.hpp:97
static constexpr double kSeriesLimit
Definition hysteresis.hpp:66
static double derivative(double x) noexcept
Definition hysteresis.hpp:76
static double second_derivative(double x) noexcept
Definition hysteresis.hpp:85