DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
TapeMachine.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
93#include "../Core/AudioBuffer.h"
94#include "../Core/AudioSpec.h"
95#include "../Core/Biquad.h"
96#include "../Core/DenormalGuard.h"
97#include "../Core/DspMath.h"
98#include "../Core/Hysteresis.h"
99#include "../Core/Oversampling.h"
100#include "../Core/SimdOps.h"
101#include "../Core/StateBlob.h"
102
103#include <algorithm>
104#include <atomic>
105#include <cmath>
106#include <cstddef>
107#include <cstdint>
108#include <memory>
109#include <numbers>
110#include <vector>
111
112namespace dspark {
113
120template <FloatType T>
122{
123public:
125 enum class Standard { NAB, CCIR };
126
128 enum class Speed { IPS_7_5, IPS_15, IPS_30 };
129
130 // -- Lifecycle ---------------------------------------------------------------
131
135 void prepare(const AudioSpec& spec)
136 {
137 if (!spec.isValid()) return;
138 prepared_.store(false, std::memory_order_relaxed);
139 spec_ = spec;
140 sampleRate_ = spec.sampleRate;
141 mixMaxStep_ = static_cast<T>(1.0 / std::max(1.0, sampleRate_ * 0.02));
142 numChannels_ = spec.numChannels;
143 maxBlock_ = std::max(spec.maxBlockSize, 1);
144
145 // The hysteresis core runs at the active oversampling factor (4x
146 // default) so the quarter-internal-rate AC bias carrier and its
147 // sidebands stay clear of the audio band. factor = 1 = OFF: no
148 // resampling and zero added latency; the carrier is then in-band.
149 const double osRate = sampleRate_ * static_cast<double>(osFactor_);
150 if (osFactor_ > 1)
151 {
152 oversampler_ = std::make_unique<Oversampling<T>>(
154 oversampler_->prepare(spec);
155 }
156 else
157 {
158 oversampler_.reset();
159 }
160
161 // Push-pull pair per channel: +carrier and -carrier instances, output
162 // averaged. Odd-in-bias terms (the carrier, its 3rd harmonic fold and
163 // the loop's remanence DC) cancel exactly, as in the centre-tapped
164 // record-head circuits of real machines.
165 hysteresis_.assign(static_cast<size_t>(numChannels_), {});
166 hysteresisN_.assign(static_cast<size_t>(numChannels_), {});
167 for (auto& h : hysteresis_)
168 {
169 h.prepare(osRate);
170 h.setParameters(3.5e5, 2.2e4, 1.6e-3, 2.7e4, 0.17);
171 }
172 for (auto& h : hysteresisN_)
173 {
174 h.prepare(osRate);
175 h.setParameters(3.5e5, 2.2e4, 1.6e-3, 2.7e4, 0.17);
176 }
177
178 recordHF_.assign(static_cast<size_t>(numChannels_), {});
179 recordLF_.assign(static_cast<size_t>(numChannels_), {});
180 playHF_.assign(static_cast<size_t>(numChannels_), {});
181 playLF_.assign(static_cast<size_t>(numChannels_), {});
182 headBump_.assign(static_cast<size_t>(numChannels_), {});
183 overBiasLp_.assign(static_cast<size_t>(numChannels_), 0.0);
184
185 // AC-coupled playback amplifier: fixed 2nd-order 24 Hz high-pass
186 // (real machines roll off there; also blocks remanence DC).
187 outHp_.assign(static_cast<size_t>(numChannels_), {});
188 {
189 const auto hc = BiquadCoeffs::makeHighPass(sampleRate_, 24.0, 0.707);
190 for (auto& h : outHp_)
191 h = PeakSection { hc.b0, hc.b1, hc.b2, hc.a1, hc.a2, 0.0, 0.0 };
192 }
193
194 firState_.assign(static_cast<size_t>(numChannels_),
195 std::vector<T>(static_cast<size_t>(kFirRing), T(0)));
196 firPos_ = 0;
197 firTaps_.assign(static_cast<size_t>(kFirLen), T(0));
198
199 // Transport delay scales with the rate so the wow excursion (sized in
200 // samples at 48k, scaled by fs/48k) always fits under the centre.
201 delayCenter_ = std::max(96, static_cast<int>(96.0 * sampleRate_ / 48000.0));
202 int dsz = 1;
203 while (dsz < 2 * delayCenter_ + 8) dsz <<= 1;
204 delayMask_ = dsz - 1;
205 delayRing_.assign(static_cast<size_t>(numChannels_),
206 std::vector<T>(static_cast<size_t>(dsz), T(0)));
207 delayPos_ = 0;
208
209 // Per-sample modulation smoothing constants (precomputed once).
210 const double dt = 1.0 / sampleRate_;
211 driftA_ = std::exp(-2.0 * std::numbers::pi * 0.10 * dt);
212 scrapeA1_ = std::exp(-2.0 * std::numbers::pi * 90.0 * dt);
213 scrapeA2_ = std::exp(-2.0 * std::numbers::pi * 40.0 * dt);
214
215 latency_ = (oversampler_ ? oversampler_->getLatency() : 0) + kFirCenter + delayCenter_;
216 drySize_ = 1;
217 while (drySize_ < latency_ + maxBlock_ + 1) drySize_ <<= 1;
218 dryRing_.assign(static_cast<size_t>(numChannels_),
219 std::vector<T>(static_cast<size_t>(drySize_), T(0)));
220 dryPos_ = 0;
221
222 prepared_.store(true, std::memory_order_relaxed);
223 dirty_.store(true, std::memory_order_release);
224 reset();
225 }
226
228 void reset() noexcept
229 {
230 if (!prepared_.load(std::memory_order_relaxed)) return;
231 for (auto& h : hysteresis_) h.reset();
232 for (auto& h : hysteresisN_) h.reset();
233 for (auto& f : firState_) std::fill(f.begin(), f.end(), T(0));
234 for (auto& d : delayRing_) std::fill(d.begin(), d.end(), T(0));
235 for (auto& d : dryRing_) std::fill(d.begin(), d.end(), T(0));
236 for (auto& s : recordHF_) s.clear();
237 for (auto& s : recordLF_) s.clear();
238 for (auto& s : playHF_) s.clear();
239 for (auto& s : playLF_) s.clear();
240 for (auto& v : overBiasLp_) v = 0.0;
241 // Clear STATE only: wiping coefficients here used to leave the head
242 // bump at identity until the next parameter change re-ran recompute.
243 for (auto& b : headBump_) b.clear();
244 for (auto& h : outHp_) h.clear();
245 firPos_ = 0;
246 delayPos_ = 0;
247 dryPos_ = 0;
248 currentMix_ = mix_.load(std::memory_order_relaxed); // no fade-in on start
249 biasPhase_ = 0;
250 modPhaseWow_ = 0.0;
251 modPhaseFlut_ = 0.0;
252 modPhaseFlut2_ = 0.0;
253 driftState_ = 0.0;
254 scrapeLp1_ = scrapeLp2_ = 0.0;
255 rng_ = 0x1357feedu;
256 rngNoise_ = 0x2468beefu;
257 if (oversampler_) oversampler_->reset();
258 }
259
260 // -- Parameters (thread-safe) ---------------------------------------------------
261
264 void setDrive(T driveDb) noexcept
265 {
266 if (!std::isfinite(driveDb)) return;
267 driveDb_.store(std::clamp(driveDb, T(-12), T(24)), std::memory_order_relaxed);
268 dirty_.store(true, std::memory_order_release);
269 }
270
277 void setBias(T bias) noexcept
278 {
279 if (!std::isfinite(bias)) return;
280 bias_.store(std::clamp(bias, T(0), T(1)), std::memory_order_relaxed);
281 dirty_.store(true, std::memory_order_release);
282 }
283
297 void setOversampling(int factor)
298 {
299 if (factor < 1 || factor > 16 || (factor & (factor - 1)) != 0) return;
300 if (factor == osFactor_) return;
301 osFactor_ = factor;
302 if (prepared_.load(std::memory_order_relaxed))
303 prepare(spec_);
304 }
305
307 [[nodiscard]] int getOversamplingFactor() const noexcept { return osFactor_; }
308
311 void setSpeed(Speed s) noexcept
312 {
313 const int v = std::clamp(static_cast<int>(s),
314 static_cast<int>(Speed::IPS_7_5),
315 static_cast<int>(Speed::IPS_30));
316 speed_.store(v, std::memory_order_relaxed);
317 dirty_.store(true, std::memory_order_release);
318 }
319
322 void setStandard(Standard s) noexcept
323 {
324 const int v = std::clamp(static_cast<int>(s),
325 static_cast<int>(Standard::NAB),
326 static_cast<int>(Standard::CCIR));
327 standard_.store(v, std::memory_order_relaxed);
328 dirty_.store(true, std::memory_order_release);
329 }
330
333 void setLossEffects(T amount) noexcept
334 {
335 if (!std::isfinite(amount)) return;
336 loss_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
337 dirty_.store(true, std::memory_order_release);
338 }
339
342 void setHeadBump(T amount) noexcept
343 {
344 if (!std::isfinite(amount)) return;
345 headBumpAmt_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
346 dirty_.store(true, std::memory_order_release);
347 }
348
351 void setWowFlutter(T amount) noexcept
352 {
353 if (!std::isfinite(amount)) return;
354 wowFlutter_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
355 }
356
359 void setNoise(T dbfs) noexcept
360 {
361 if (!std::isfinite(dbfs)) return;
362 noiseDb_.store(std::clamp(dbfs, T(-200), T(-20)), std::memory_order_relaxed);
363 }
364
367 void setMix(T mix) noexcept
368 {
369 if (!std::isfinite(mix)) return;
370 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
371 }
372
373 [[nodiscard]] T getDrive() const noexcept { return driveDb_.load(std::memory_order_relaxed); }
374 [[nodiscard]] T getBias() const noexcept { return bias_.load(std::memory_order_relaxed); }
375 [[nodiscard]] Speed getSpeed() const noexcept
376 {
377 return static_cast<Speed>(speed_.load(std::memory_order_relaxed));
378 }
379 [[nodiscard]] Standard getStandard() const noexcept
380 {
381 return static_cast<Standard>(standard_.load(std::memory_order_relaxed));
382 }
383 [[nodiscard]] T getLossEffects() const noexcept { return loss_.load(std::memory_order_relaxed); }
384 [[nodiscard]] T getHeadBump() const noexcept { return headBumpAmt_.load(std::memory_order_relaxed); }
385 [[nodiscard]] T getWowFlutter() const noexcept { return wowFlutter_.load(std::memory_order_relaxed); }
386 [[nodiscard]] T getNoise() const noexcept { return noiseDb_.load(std::memory_order_relaxed); }
387 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
388
391 [[nodiscard]] int getLatency() const noexcept { return latency_; }
392
394 [[nodiscard]] int getLatencySamples() const noexcept { return getLatency(); }
395
397 [[nodiscard]] std::vector<uint8_t> getState() const
398 {
399 StateWriter w(stateId("TAPE"), 1);
400 // Explicit float casts: the blob stores float, and with T = double the
401 // unqualified write(key, double) would be ambiguous (float/int32/bool).
402 w.write("drive", static_cast<float>(driveDb_.load(std::memory_order_relaxed)));
403 w.write("bias", static_cast<float>(bias_.load(std::memory_order_relaxed)));
404 w.write("speed", speed_.load(std::memory_order_relaxed));
405 w.write("standard", standard_.load(std::memory_order_relaxed));
406 w.write("loss", static_cast<float>(loss_.load(std::memory_order_relaxed)));
407 w.write("headBump", static_cast<float>(headBumpAmt_.load(std::memory_order_relaxed)));
408 w.write("wowFlutter", static_cast<float>(wowFlutter_.load(std::memory_order_relaxed)));
409 w.write("noise", static_cast<float>(noiseDb_.load(std::memory_order_relaxed)));
410 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
411 w.write("oversampling", osFactor_);
412 return w.blob();
413 }
414
416 bool setState(const uint8_t* data, size_t size)
417 {
418 StateReader r(data, size);
419 if (!r.isValid() || r.processorId() != stateId("TAPE")) return false;
420 setDrive(static_cast<T>(r.read("drive", 0.0f)));
421 setBias(static_cast<T>(r.read("bias", 0.5f)));
422 setSpeed(static_cast<Speed>(r.read("speed", 1)));
423 setStandard(static_cast<Standard>(r.read("standard", 0)));
424 setLossEffects(static_cast<T>(r.read("loss", 0.5f)));
425 setHeadBump(static_cast<T>(r.read("headBump", 0.5f)));
426 setWowFlutter(static_cast<T>(r.read("wowFlutter", 0.15f)));
427 setNoise(static_cast<T>(r.read("noise", -200.0f)));
428 setMix(static_cast<T>(r.read("mix", 1.0f)));
429 // Default 4 = the historical fixed factor (older blobs restore 4x).
430 setOversampling(r.read("oversampling", 4));
431 return true;
432 }
433
434 // -- Processing -------------------------------------------------------------------
435
437 void processBlock(AudioBufferView<T> buffer) noexcept
438 {
439 if (!prepared_.load(std::memory_order_relaxed)) return;
440 DenormalGuard guard;
441
442 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
443 const int nS = buffer.getNumSamples();
444 if (nCh == 0 || nS == 0) return;
445
446 // Front-door non-finite guard: a single NaN/Inf input sample would
447 // poison the recursive record/playback EQ, push-pull JA hysteresis,
448 // loss-FIR ring, transport delay and over-bias/DC state PERMANENTLY -
449 // the JA core mutes to exact silence forever (only reset() clears it,
450 // not clean input). Replace bad samples with silence before they reach
451 // any state, so a transient upstream glitch cannot mute the channel for
452 // the rest of the stream.
453 for (int ch = 0; ch < nCh; ++ch)
454 {
455 T* d = buffer.getChannel(ch);
456 for (int i = 0; i < nS; ++i)
457 if (!std::isfinite(d[i])) d[i] = T(0);
458 }
459
460 // Acquire pairs with the setters' release stores so the recompute
461 // always sees the values published before the flag.
462 if (dirty_.load(std::memory_order_relaxed)
463 && dirty_.exchange(false, std::memory_order_acquire))
464 recompute();
465
466 const T mixTarget = mix_.load(std::memory_order_relaxed);
467 const T mixStart = currentMix_;
468 const double noiseAmp = std::pow(10.0, static_cast<double>(
469 noiseDb_.load(std::memory_order_relaxed)) / 20.0);
470 const bool noiseOn = noiseDb_.load(std::memory_order_relaxed) > T(-120);
471 const double wfDepth = static_cast<double>(wowFlutter_.load(std::memory_order_relaxed));
472
473 // 1. Dry snapshot for the latency-compensated mix.
474 for (int ch = 0; ch < nCh; ++ch)
475 {
476 const T* in = buffer.getChannel(ch);
477 auto& dry = dryRing_[static_cast<size_t>(ch)];
478 int dp = dryPos_;
479 for (int i = 0; i < nS; ++i)
480 {
481 dry[static_cast<size_t>(dp)] = in[i];
482 dp = (dp + 1) & (drySize_ - 1);
483 }
484 }
485
486 // 2. Record EQ (emphasis) at base rate.
487 for (int ch = 0; ch < nCh; ++ch)
488 {
489 T* d = buffer.getChannel(ch);
490 auto& hf = recordHF_[static_cast<size_t>(ch)];
491 auto& lf = recordLF_[static_cast<size_t>(ch)];
492 for (int i = 0; i < nS; ++i)
493 d[i] = static_cast<T>(hf.process(lf.process(static_cast<double>(d[i]))));
494 }
495
496 // 3. Push-pull AC-bias hysteresis at 4x. One shared carrier phase for
497 // all channels (a machine has a single bias oscillator); each channel
498 // runs a +carrier and a -carrier JA instance and averages them, which
499 // cancels every odd-in-bias term exactly (carrier, its folded 3rd
500 // harmonic, remanence DC) like a centre-tapped record head.
501 {
502 const bool osOn = (oversampler_ != nullptr);
503 auto osView = osOn ? oversampler_->upsample(buffer) : buffer;
504 const int osN = osView.getNumSamples();
505 const int phaseStart = biasPhase_;
506 for (int ch = 0; ch < nCh; ++ch)
507 {
508 T* d = osView.getChannel(ch);
509 auto& hp = hysteresis_[static_cast<size_t>(ch)];
510 auto& hn = hysteresisN_[static_cast<size_t>(ch)];
511 const double inScale = hScale_;
512 const double outScale = mScale_ * 0.5;
513 const double B = biasAmp_;
514 int phase = phaseStart;
515 for (int i = 0; i < osN; ++i)
516 {
517 const double x = inScale * static_cast<double>(d[i]);
518 const double c = B * kBiasTable[static_cast<size_t>(phase)];
519 phase = (phase + 1) & 7;
520 const double mp = static_cast<double>(hp.processSample(static_cast<T>(x + c)));
521 const double mn = static_cast<double>(hn.processSample(static_cast<T>(x - c)));
522 d[i] = static_cast<T>(outScale * (mp + mn));
523 }
524 }
525 biasPhase_ = (phaseStart + osN) & 7;
526 if (osOn) oversampler_->downsample(buffer);
527 }
528
529 // 4-6. Loss FIR + head bump, transport modulation, playback EQ, noise, mix.
530 for (int i = 0; i < nS; ++i)
531 {
532 // One shared transport modulation per frame (all channels move
533 // together, like tape past a single capstan).
534 const double mod = nextTransportMod(wfDepth);
535 const double readPos = static_cast<double>(delayCenter_) + mod;
536 const auto readInt = static_cast<int>(std::floor(readPos));
537 const T frac = static_cast<T>(readPos - readInt);
538
539 for (int ch = 0; ch < nCh; ++ch)
540 {
541 T* d = buffer.getChannel(ch);
542
543 // Loss FIR (linear phase, 63 taps) on a per-channel ring.
544 auto& fir = firState_[static_cast<size_t>(ch)];
545 fir[static_cast<size_t>(firPos_)] = d[i];
546 fir[static_cast<size_t>(firPos_ + kFirRing / 2)] = d[i]; // mirrored
547 const T* win = &fir[static_cast<size_t>(firPos_ + kFirRing / 2 - (kFirLen - 1))];
548 T y = simd::dotProduct(firTaps_.data(), win, kFirLen);
549
550 // Head bump resonance.
551 y = headBump_[static_cast<size_t>(ch)].process(y);
552
553 // Transport (wow & flutter) fractional delay: interpolate at
554 // t - readInt - frac, i.e. between p1 = x(t-readInt) and
555 // p2 = x(t-readInt-1), with Catmull-Rom neighbours around them.
556 auto& dl = delayRing_[static_cast<size_t>(ch)];
557 dl[static_cast<size_t>(delayPos_)] = y;
558 const int base = delayPos_ - readInt;
559 const int dm = delayMask_;
560 const T p0 = dl[static_cast<size_t>((base + 1) & dm)];
561 const T p1 = dl[static_cast<size_t>(base & dm)];
562 const T p2 = dl[static_cast<size_t>((base - 1) & dm)];
563 const T p3 = dl[static_cast<size_t>((base - 2) & dm)];
564 T w = p1 + T(0.5) * frac * (p2 - p0
565 + frac * (T(2) * p0 - T(5) * p1 + T(4) * p2 - p3
566 + frac * (T(3) * (p1 - p2) + p3 - p0)));
567
568 // Playback EQ (exact inverse de-emphasis).
569 w = static_cast<T>(playLF_[static_cast<size_t>(ch)].process(
570 playHF_[static_cast<size_t>(ch)].process(static_cast<double>(w))));
571
572 // Over-bias self-erasure: wider recording zone partially
573 // erases short wavelengths (one-pole LP engaged above nominal
574 // bias only; identity at or below nominal).
575 if (overBiasA_ > 0.0)
576 {
577 auto& lp = overBiasLp_[static_cast<size_t>(ch)];
578 lp += overBiasA_ * (static_cast<double>(w) - lp);
579 w = static_cast<T>(lp);
580 }
581
582 // AC-coupled playback amplifier (2nd-order 24 Hz high-pass:
583 // subsonic/DC roll-off of the real hardware).
584 w = outHp_[static_cast<size_t>(ch)].process(w);
585
586 // Hiss (own RNG stream: enabling it must not change the
587 // transport modulation's realisation).
588 if (noiseOn)
589 {
590 rngNoise_ = rngNoise_ * 1664525u + 1013904223u;
591 const double n1 = static_cast<double>(rngNoise_ >> 8) / 8388608.0 - 1.0;
592 w += static_cast<T>(noiseAmp * n1 * 0.35);
593 }
594
595 // Latency-compensated mix.
596 const auto& dry = dryRing_[static_cast<size_t>(ch)];
597 const int dryIdx = (dryPos_ + i - latency_) & (drySize_ - 1);
598 const T drySample = dry[static_cast<size_t>(dryIdx)];
599 const T mixVal = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(i + 1));
600 d[i] = drySample + (w - drySample) * mixVal;
601 }
602
603 firPos_ = (firPos_ + 1) & (kFirRing / 2 - 1);
604 delayPos_ = (delayPos_ + 1) & delayMask_;
605 }
606 dryPos_ = (dryPos_ + nS) & (drySize_ - 1);
607 currentMix_ = moveTowards(mixStart, mixTarget, mixMaxStep_ * static_cast<T>(nS));
608 }
609
610private:
611 // -- Building blocks -----------------------------------------------------------
612
614 struct ShelfSection
615 {
616 double b0 = 1.0, b1 = 0.0, a1 = 0.0;
617 double x1 = 0.0, y1 = 0.0;
618
619 void clear() noexcept { x1 = 0.0; y1 = 0.0; }
620 [[nodiscard]] double process(double x) noexcept
621 {
622 const double y = b0 * x + b1 * x1 - a1 * y1;
623 x1 = x;
624 y1 = y;
625 return y;
626 }
627 };
628
630 struct PeakSection
631 {
632 double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
633 double z1 = 0.0, z2 = 0.0;
634
635 void clear() noexcept { z1 = 0.0; z2 = 0.0; }
636 [[nodiscard]] T process(T x) noexcept
637 {
638 const double in = static_cast<double>(x);
639 const double y = b0 * in + z1;
640 z1 = b1 * in - a1 * y + z2;
641 z2 = b2 * in - a2 * y;
642 return static_cast<T>(y);
643 }
644 };
645
648 static ShelfSection makeHFShelf(double t, double kHi, double fs, bool inverse) noexcept
649 {
650 const double kt = 2.0 * fs * t;
651 double n0 = kHi * kt + 1.0, n1 = 1.0 - kHi * kt;
652 double d0 = kt + 1.0, d1 = 1.0 - kt;
653 if (inverse) { std::swap(n0, d0); std::swap(n1, d1); }
654 return { n0 / d0, n1 / d0, d1 / d0, 0.0, 0.0 };
655 }
656
657 static ShelfSection makeLFShelf(double t, double kLo, double fs, bool inverse) noexcept
658 {
659 const double kt = 2.0 * fs * t;
660 double n0 = kt + kLo, n1 = kLo - kt;
661 double d0 = kt + 1.0, d1 = 1.0 - kt;
662 if (inverse) { std::swap(n0, d0); std::swap(n1, d1); }
663 return { n0 / d0, n1 / d0, d1 / d0, 0.0, 0.0 };
664 }
665
667 void recompute() noexcept
668 {
669 const double driveDbV = static_cast<double>(driveDb_.load(std::memory_order_relaxed));
670 const double drive = std::pow(10.0, driveDbV / 20.0);
671 const double bias = static_cast<double>(bias_.load(std::memory_order_relaxed));
672 const auto speed = static_cast<Speed>(speed_.load(std::memory_order_relaxed));
673 const auto standard = static_cast<Standard>(standard_.load(std::memory_order_relaxed));
674 const double lossAmt = static_cast<double>(loss_.load(std::memory_order_relaxed));
675 const double bumpAmt = static_cast<double>(headBumpAmt_.load(std::memory_order_relaxed));
676
677 // --- bias -> carrier amplitude and over-bias erasure -------------------
678 // Real AC bias: the control maps to the push-pull carrier amplitude in
679 // units of the JA 'a' parameter. Nominal (0.5) sits at B = 3a: the
680 // carrier sweeps well past the coercivity every cycle, erasing the
681 // loop's branch memory exactly like hardware bias (measured: LF gain
682 // history-independent to < 0.1 dB). Under-bias drops B below the
683 // erase threshold: the loop keeps partial branch memory - the REAL
684 // grit and level instability of an under-biased machine. Over-bias
685 // adds the self-erasure of short wavelengths (wider recording zone)
686 // as a one-pole roll-off.
687 const double biasB = std::min(6.0, 3.0 * std::pow(9.0, bias - 0.5));
688 biasAmp_ = biasB * 2.2e4;
689 if (biasB > 3.5)
690 {
691 // Gentle self-erasure: ~-1.5 dB at 10 kHz per +1 B/a over nominal
692 // (one-pole corner gliding 22 kHz -> ~12.8 kHz at full over-bias).
693 const double fc = 22000.0 * (3.5 / biasB);
694 overBiasA_ = 1.0 - std::exp(-2.0 * std::numbers::pi * fc / sampleRate_);
695 }
696 else
697 {
698 overBiasA_ = 0.0;
699 }
700
701 // --- EQ time constants per standard and speed --------------------------
702 double t2 = 50e-6; // HF time constant
703 bool useLF = false; // NAB LF constant 3180 us
704 switch (standard)
705 {
706 case Standard::NAB:
707 t2 = (speed == Speed::IPS_30) ? 17.5e-6 : 50e-6;
708 useLF = speed != Speed::IPS_30;
709 break;
710 case Standard::CCIR:
711 t2 = (speed == Speed::IPS_7_5) ? 70e-6
712 : (speed == Speed::IPS_15) ? 35e-6 : 17.5e-6;
713 useLF = false;
714 break;
715 }
716 constexpr double kHiCap = 4.0; // +12 dB emphasis cap (practical alignment)
717 constexpr double kLoBoost = 2.0; // +6 dB NAB LF record boost
718 const double tLo = 3180e-6;
719
720 // --- drive scaling with operating-level makeup -------------------------
721 // 0 dBFS at nominal drive maps to H = 1.2a: moderate saturation. Tape
722 // gain is level-dependent, so unity can only be defined at one
723 // operating point: calibrate empirically at -12 dBFS programme level
724 // by running a short 1 kHz burst through the same push-pull biased
725 // chain on two scratch instances (a few thousand model samples, only
726 // on parameter changes). Quieter material reads slightly low, hotter
727 // material blooms into compression - like tape.
728 hScale_ = drive * 1.2 * 2.2e4;
729 {
730 // -12 dBFS reference, seen through the record emphasis: at 1 kHz
731 // the HF emphasis already lifts the level (about +3.7 dB NAB-15),
732 // so calibrating with the raw amplitude would leave the whole
733 // path low by the differential compression. Align at the level
734 // the loop actually sees, like a real machine's record-level
735 // alignment.
736 const double w1 = 2.0 * std::numbers::pi * 1000.0 * t2;
737 const double emph1k = std::sqrt((1.0 + kHiCap * kHiCap * w1 * w1)
738 / (1.0 + w1 * w1));
739 const double kCalAmp = 0.25 * emph1k;
740 // Calibrate through the same internal rate the biased chain runs at.
741 const double fs4 = sampleRate_ * static_cast<double>(osFactor_);
742 // 16 ms settle + 8 ms measured: the biased loop's mean state
743 // needs a few hundred carrier cycles to reach its steady branch.
744 const int calN = static_cast<int>(0.024 * fs4);
745 const int calFrom = (calN * 2) / 3;
746 calib_.prepare(fs4);
747 calib_.setParameters(3.5e5, 2.2e4, 1.6e-3, 2.7e4, 0.17);
748 calib2_.prepare(fs4);
749 calib2_.setParameters(3.5e5, 2.2e4, 1.6e-3, 2.7e4, 0.17);
750 // Measure the FUNDAMENTAL of the averaged pair (Goertzel-style
751 // correlation), not the raw RMS: the raw output still carries the
752 // even-order carrier residue that the downsampler removes in the
753 // real path, and it would inflate the measurement.
754 const double wCal = 2.0 * std::numbers::pi * 1000.0 / fs4;
755 double outRe = 0.0, outIm = 0.0;
756 int meas = 0;
757 for (int i = 0; i < calN; ++i)
758 {
759 const double s = std::sin(wCal * i);
760 const double x = hScale_ * kCalAmp * s;
761 const double c = biasAmp_ * kBiasTable[static_cast<size_t>(i & 7)];
762 const double m = 0.5
763 * (static_cast<double>(calib_.processSample(static_cast<T>(x + c)))
764 + static_cast<double>(calib2_.processSample(static_cast<T>(x - c))));
765 if (i >= calFrom)
766 {
767 outRe += m * std::cos(wCal * i);
768 outIm += m * std::sin(wCal * i);
769 ++meas;
770 }
771 }
772 const double fund = 2.0 * std::sqrt(outRe * outRe + outIm * outIm)
773 / std::max(1, meas);
774 mScale_ = (fund > 0.0) ? kCalAmp / fund : 1.0;
775
776 // Partial loudness link: the per-drive calibration above pins the
777 // reference level EXACTLY, which kills the drive knob (inaudible
778 // below 0 dB where tape stays clean, a pure attenuator above as
779 // compression eats level). A +0.25 dB/dB residual slope keeps it
780 // alive: backing off cleans AND drops slightly, pushing holds
781 // level while the tape density grows.
782 mScale_ *= std::pow(drive, 0.25);
783 }
784
785 for (int ch = 0; ch < numChannels_; ++ch)
786 {
787 auto& rhf = recordHF_[static_cast<size_t>(ch)];
788 auto& rlf = recordLF_[static_cast<size_t>(ch)];
789 auto& phf = playHF_[static_cast<size_t>(ch)];
790 auto& plf = playLF_[static_cast<size_t>(ch)];
791
792 const double sx1 = rhf.x1, sy1 = rhf.y1;
793 rhf = makeHFShelf(t2, kHiCap, sampleRate_, false);
794 rhf.x1 = sx1; rhf.y1 = sy1;
795
796 const double lx1 = rlf.x1, ly1 = rlf.y1;
797 rlf = useLF ? makeLFShelf(tLo, kLoBoost, sampleRate_, false) : ShelfSection {};
798 rlf.x1 = lx1; rlf.y1 = ly1;
799
800 const double px1 = phf.x1, py1 = phf.y1;
801 phf = makeHFShelf(t2, kHiCap, sampleRate_, true);
802 phf.x1 = px1; phf.y1 = py1;
803
804 const double qx1 = plf.x1, qy1 = plf.y1;
805 plf = useLF ? makeLFShelf(tLo, kLoBoost, sampleRate_, true) : ShelfSection {};
806 plf.x1 = qx1; plf.y1 = qy1;
807 }
808
809 // --- loss-effect FIR (63 taps, linear phase) ---------------------------
810 const double ips = (speed == Speed::IPS_7_5) ? 7.5
811 : (speed == Speed::IPS_15) ? 15.0 : 30.0;
812 const double v = ips * 0.0254; // m/s
813 constexpr double gap = 3.0e-6; // playback head gap (m)
814 constexpr double spacing = 0.5e-6; // head-tape spacing (m)
815 constexpr double thickness = 1.0e-6; // effective coating depth (m)
816
817 constexpr int kGrid = kFirLen + 1; // 64-point design grid
818 double mags[kGrid / 2 + 1];
819 for (int kBin = 0; kBin <= kGrid / 2; ++kBin)
820 {
821 const double f = kBin * sampleRate_ / kGrid;
822 double mag = 1.0;
823 if (f > 1.0)
824 {
825 const double lambda = v / f;
826 const double spacingLoss = std::pow(10.0, -54.6 * (spacing / lambda) / 20.0);
827 const double gx = std::numbers::pi * gap / lambda;
828 const double gapLoss = (gx < 1e-9) ? 1.0
829 : std::abs(std::sin(gx) / gx);
830 const double tx = 4.0 * std::numbers::pi * thickness / lambda;
831 const double thickLoss = (tx < 1e-9) ? 1.0 : (1.0 - std::exp(-tx)) / tx;
832 mag = spacingLoss * gapLoss * thickLoss;
833 }
834 mags[kBin] = (1.0 - lossAmt) + lossAmt * mag;
835 // Physical reproduce-gap cutoff: no real head reads anything
836 // above ~21.5 kHz. Always active (independent of the loss
837 // amount); it also removes the last even-order bias
838 // intermodulation sidebands just below the base-rate Nyquist.
839 // Raised-cosine transition 19.5k -> 21.5k avoids the passband
840 // Gibbs ripple of a hard step on the 64-point design grid.
841 if (f >= 21500.0)
842 mags[kBin] = 0.0;
843 else if (f > 19500.0)
844 mags[kBin] *= 0.5 + 0.5 * std::cos(std::numbers::pi * (f - 19500.0) / 2000.0);
845 }
846 double taps[kFirLen];
847 for (int n = 0; n < kFirLen; ++n)
848 {
849 double acc = mags[0];
850 for (int kBin = 1; kBin < kGrid / 2; ++kBin)
851 acc += 2.0 * mags[kBin]
852 * std::cos(2.0 * std::numbers::pi * kBin * (n - kFirCenter)
853 / static_cast<double>(kGrid));
854 acc += mags[kGrid / 2] * std::cos(std::numbers::pi * (n - kFirCenter));
855 const double hann = 0.5 - 0.5 * std::cos(2.0 * std::numbers::pi * (n + 1)
856 / (kFirLen + 1));
857 taps[n] = acc * hann / kGrid;
858 }
859 // Normalize to exact unity at the 1 kHz calibration frequency so the
860 // windowing/gap-cut of the design never shifts the calibrated level.
861 {
862 const double w1k = 2.0 * std::numbers::pi * 1000.0 / sampleRate_;
863 double re = 0.0, im = 0.0;
864 for (int n = 0; n < kFirLen; ++n)
865 {
866 re += taps[n] * std::cos(w1k * n);
867 im -= taps[n] * std::sin(w1k * n);
868 }
869 const double g = std::sqrt(re * re + im * im);
870 const double norm = (g > 1e-9) ? 1.0 / g : 1.0;
871 for (int n = 0; n < kFirLen; ++n) taps[n] *= norm;
872 }
873 for (int n = 0; n < kFirLen; ++n)
874 firTaps_[static_cast<size_t>(kFirLen - 1 - n)] =
875 static_cast<T>(taps[n]); // reversed for dotProduct
876
877 // --- head bump ----------------------------------------------------------
878 const double bumpHz = (speed == Speed::IPS_7_5) ? 45.0
879 : (speed == Speed::IPS_15) ? 90.0 : 180.0;
880 const double bumpDb = 2.5 * bumpAmt;
881 const auto bc = BiquadCoeffs::makePeak(sampleRate_, bumpHz, 0.9, bumpDb);
882 for (auto& b : headBump_)
883 {
884 const double pz1 = b.z1, pz2 = b.z2;
885 b = PeakSection { bc.b0, bc.b1, bc.b2, bc.a1, bc.a2, 0.0, 0.0 };
886 b.z1 = pz1;
887 b.z2 = pz2;
888 }
889 }
890
892 [[nodiscard]] double nextTransportMod(double depth) noexcept
893 {
894 // Always advance the transport state so engaging the control later
895 // does not jump phases; only the output is scaled by depth.
896 const double dt = 1.0 / sampleRate_;
897 modPhaseWow_ += 0.55 * dt;
898 if (modPhaseWow_ >= 1.0) modPhaseWow_ -= 1.0;
899 modPhaseFlut_ += 8.3 * dt;
900 if (modPhaseFlut_ >= 1.0) modPhaseFlut_ -= 1.0;
901 modPhaseFlut2_ += 23.0 * dt;
902 if (modPhaseFlut2_ >= 1.0) modPhaseFlut2_ -= 1.0;
903
904 rng_ = rng_ * 1664525u + 1013904223u;
905 const double n = static_cast<double>(rng_ >> 8) / 8388608.0 - 1.0;
906
907 // Slow drift: heavily low-passed random walk, clamped to +/-18 samples.
908 driftState_ = driftA_ * driftState_ + (1.0 - driftA_) * n * 600.0;
909 const double drift = std::clamp(driftState_, -18.0, 18.0);
910
911 // Scrape band (~40-90 Hz): difference of two one-poles on noise.
912 scrapeLp1_ = scrapeA1_ * scrapeLp1_ + (1.0 - scrapeA1_) * n;
913 scrapeLp2_ = scrapeA2_ * scrapeLp2_ + (1.0 - scrapeA2_) * n;
914 const double scrape = (scrapeLp1_ - scrapeLp2_) * 0.6;
915
916 if (depth <= 0.0)
917 return 0.0;
918
919 const double twoPi = 2.0 * std::numbers::pi;
920 // Component amplitudes in delay samples at 48k, scaled to the actual
921 // rate (the delay centre scales identically). The 0.4 factor calibrates
922 // full depth to ~0.6 % peak-to-peak measured pitch deviation (a worn
923 // machine); the 0.15 default lands near healthy-transport spec.
924 const double rateScale = sampleRate_ / 48000.0;
925 const double wow = 27.8 * std::sin(twoPi * modPhaseWow_);
926 const double flut = 0.55 * std::sin(twoPi * modPhaseFlut_);
927 const double flut2 = 0.066 * std::sin(twoPi * modPhaseFlut2_);
928
929 return 0.4 * depth * rateScale * (drift + wow + flut + flut2 + scrape);
930 }
931
932 // -- Members --------------------------------------------------------------------
933 static constexpr int kFirLen = 63;
934 static constexpr int kFirCenter = 31;
935 static constexpr int kFirRing = 128; // double-write mirrored ring
936
937 AudioSpec spec_ {};
938 double sampleRate_ = 48000.0;
939 int numChannels_ = 0;
940 int maxBlock_ = 0;
941 std::atomic<bool> prepared_ { false };
942 int latency_ = 0;
943 int drySize_ = 1;
944 int osFactor_ = 4;
945
946 std::unique_ptr<Oversampling<T>> oversampler_;
947 std::vector<Hysteresis<T>> hysteresis_;
948 std::vector<Hysteresis<T>> hysteresisN_;
949 Hysteresis<T> calib_;
950 Hysteresis<T> calib2_;
951
964 static constexpr double kBiasTable[8] = {
965 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0
966 };
967
968 std::vector<ShelfSection> recordHF_, recordLF_, playHF_, playLF_;
969 std::vector<PeakSection> headBump_;
970 std::vector<PeakSection> outHp_;
971 std::vector<double> overBiasLp_;
972
973 std::vector<T> firTaps_;
974 std::vector<std::vector<T>> firState_;
975 int firPos_ = 0;
976
977 std::vector<std::vector<T>> delayRing_;
978 int delayPos_ = 0;
979 int delayCenter_ = 96;
980 int delayMask_ = 255;
981 double driftA_ = 0.0, scrapeA1_ = 0.0, scrapeA2_ = 0.0;
982
983 std::vector<std::vector<T>> dryRing_;
984 int dryPos_ = 0;
985
986 double hScale_ = 1.0, mScale_ = 1.0;
987 double biasAmp_ = 3.0 * 2.2e4;
988 double overBiasA_ = 0.0;
989 int biasPhase_ = 0;
990
991 double modPhaseWow_ = 0.0, modPhaseFlut_ = 0.0, modPhaseFlut2_ = 0.0;
992 double driftState_ = 0.0, scrapeLp1_ = 0.0, scrapeLp2_ = 0.0;
993 uint32_t rng_ = 0x1357feedu;
994 uint32_t rngNoise_ = 0x2468beefu;
995
996 std::atomic<T> driveDb_ { T(0) };
997 std::atomic<T> bias_ { T(0.5) };
998 std::atomic<int> speed_ { static_cast<int>(Speed::IPS_15) };
999 std::atomic<int> standard_ { static_cast<int>(Standard::NAB) };
1000 std::atomic<T> loss_ { T(0.5) };
1001 std::atomic<T> headBumpAmt_ { T(0.5) };
1002 std::atomic<T> wowFlutter_ { T(0.15) };
1003 std::atomic<T> noiseDb_ { T(-200) };
1004 std::atomic<T> mix_ { T(1) };
1005 T currentMix_ = T(1);
1006 T mixMaxStep_ = T(1.0 / 960.0);
1007 std::atomic<bool> dirty_ { true };
1008};
1009
1010} // 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
Reel-to-reel tape emulation with physical hysteresis and transport.
void reset() noexcept
Clears all signal state (keeps parameters). RT-safe.
int getLatencySamples() const noexcept
Compatibility alias of getLatency(), in prepared-rate samples.
void setStandard(Standard s) noexcept
Equalization standard (NAB adds the LF time constant). Out-of-range values are clamped.
T getDrive() const noexcept
void setNoise(T dbfs) noexcept
Tape hiss level in dBFS (e.g. -55 for audible vintage hiss); values <= -120 disable it (default)....
void setOversampling(int factor)
Configures internal oversampling of the biased hysteresis core (visible, tunable and switchable off)....
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block in-place. Pass-through until prepare() succeeds.
T getBias() const noexcept
T getLossEffects() const noexcept
Standard getStandard() const noexcept
void prepare(const AudioSpec &spec)
Allocates the whole chain. Invalid specs (non-positive or non-finite rate, block size or channel coun...
void setBias(T bias) noexcept
Bias setting [0, 1]; 0.5 is nominal calibration (carrier at 3x the JA field constant: full branch-mem...
T getHeadBump() const noexcept
void setSpeed(Speed s) noexcept
Tape speed (changes EQ time constants, losses and head bump). Out-of-range values are clamped.
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
T getWowFlutter() const noexcept
int getLatency() const noexcept
Total latency in samples (active oversampler + loss FIR + transport delay); reflects the current fact...
void setDrive(T driveDb) noexcept
Input drive in dB [-12, +24]. Level-compensated: more drive means more saturation at roughly constant...
T getNoise() const noexcept
Standard
Playback equalization standard.
void setLossEffects(T amount) noexcept
Playback loss intensity [0, 1] (0 bypasses the loss FIR). Non-finite values are ignored.
T getMix() const noexcept
Speed getSpeed() const noexcept
void setWowFlutter(T amount) noexcept
Wow & flutter depth [0, 1] (~0.25% peak pitch deviation at 1). Non-finite values are ignored.
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
void setHeadBump(T amount) noexcept
Head-bump resonance intensity [0, 1] (~2.5 dB at full). Non-finite values are ignored.
void setMix(T mix) noexcept
Dry/wet mix [0, 1]; dry is latency-compensated. Non-finite values are ignored.
int getOversamplingFactor() const noexcept
Active oversampling factor (1 = off, 4 = default).
float dotProduct(const float *DSPARK_RESTRICT a, const float *DSPARK_RESTRICT b, int count) noexcept
Computes the dot product of two arrays.
Definition SimdOps.h:419
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
constexpr T twoPi
2 * Pi (6.28318...).
Definition DspMath.h:48
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 makeHighPass(double sampleRate, double freq, double Q=0.7071067811865476) noexcept
High-pass filter.
Definition Biquad.h:142
static BiquadCoeffs makePeak(double sampleRate, double freq, double Q, double gainDb) noexcept
Peak (parametric EQ) filter.
Definition Biquad.h:200