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