Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
Loading...
Searching...
No Matches
tape_delay.hpp
Go to the documentation of this file.
1// sbl/dsp/modules/tape_delay.hpp — Stereo tape echo (Audio Stack — Modules)
2//
3// Opinionated tape delay with Roland Space Echo / Echoplex character.
4// Models tape transport phenomena: head gap loss, magnetic saturation,
5// wow/flutter, speed drift, and tape hiss — all composed from existing
6// SBL atoms (ModulatedDelayLine, OnePole, ParameterSmoother, WarmSaturator,
7// SlewLimiter, AnalogDrift, Rng).
8//
9// Wet-only output — designed for send/return bus architecture.
10// Caller handles dry/wet balance via send level.
11//
12// Float-domain processing — STM32H7 (Cortex-M7) has hardware FPU with
13// single-cycle float ops.
14//
15// Signal Unit API (FDP-052):
16// Widgets expose normalized [0,1] inputs via _su() methods. The widget
17// owns internal mapping and parameter conditioning. TapeDelay already
18// owned most of its smoothing (motor slew, TIME_SMOOTH, per-sample
19// ramps). The _su API adds feedback smoothing (OnePole 50ms) and
20// formalizes the input contract.
21//
22// Usage (new — signal units):
23// sbl::dsp::modules::TapeDelay delay;
24// delay.init(buf_l, buf_r, 48000);
25// delay.set_time_su(0.3f); // [0,1] → 0–max samples (motor slew)
26// delay.set_feedback_su(0.5f); // [0,1] → 0–1.15 (OnePole 50ms)
27// delay.set_tone_su(0.6f); // [0,1] → 200–20000 Hz (exp)
28// delay.process(left, right, frames); // wet-only output
29// delay.tick(); // call at ~200 Hz
30//
31// See: FDP-042, FDP-048, FDP-052
32
33#ifndef SBL_DSP_MODULES_TAPE_DELAY_HPP_
34#define SBL_DSP_MODULES_TAPE_DELAY_HPP_
35
36#include <cmath>
37#include <cstdint>
38
54
55namespace sbl::dsp::modules {
56
57/// Musical note subdivisions for tempo-synced delay
58enum class Subdivision : uint8_t {
59 Whole, // 4 beats
60 HalfDotted, // 3 beats
61 Half, // 2 beats
62 QuarterDotted, // 1.5 beats
63 QuarterTriplet, // 1.333 beats
64 Quarter, // 1 beat
65 EighthDotted, // 0.75 beats
66 EighthTriplet, // 0.667 beats
67 Eighth, // 0.5 beats
68 SixteenthDotted, // 0.375 beats
69 Sixteenth, // 0.25 beats
70};
71
72class TapeDelay {
73public:
74 /// @note All public methods are ISR-safe — bounded computation, no I/O.
75
76 // === Parameter range constants (for test helpers) ===
77 static constexpr float kMinFeedback = 0.0f;
78 static constexpr float kMaxFeedback = 1.15f;
79 static constexpr uint32_t kModHeadroom = 128; // ~2.7ms at 48kHz
80 static constexpr float kMinToneHz = 200.0f;
81 static constexpr float kMaxToneHz = 20000.0f;
82 static constexpr float kMinBassLossHz = 0.0f; // 0 = disabled
83 static constexpr float kMaxBassLossHz = 500.0f;
84 static constexpr float kMinBassLossFilterHz = 1.0f; // OnePoleHP range min
85 static constexpr float kMinDrive = 1.0f;
86 static constexpr float kMaxDrive = 3.0f;
87 static constexpr float kMaxWobble = 2.0f;
88 static constexpr float kMaxHiss = 1.0f;
89 static constexpr float kMaxDrift = 3.0f;
90 static constexpr float kMinTransportSpeed = 1.0f;
91 static constexpr float kMaxTransportSpeed = 20.0f;
92
93 TapeDelay() = default;
94
95 /**
96 * @brief Initialize with pre-allocated stereo delay buffers
97 * @param buf_l Left delay buffer (must outlive the Delay)
98 * @param buf_r Right delay buffer (must outlive the Delay)
99 * @param max_samples Maximum delay in samples (buffer size)
100 */
101 void init(float* buf_l, float* buf_r, uint32_t max_samples) {
102 dl_l_.init(buf_l, max_samples);
103 dl_r_.init(buf_r, max_samples);
104 configure_rate_dependent();
105 }
106
107 // === Signal Unit API (FDP-052) ===
108
109 /**
110 * @brief Set delay time from normalized [0,1] signal unit
111 * @param su Unipolar [0,1] — linearly mapped to 0–max_samples
112 *
113 * Feeds into the existing motor slew model. No additional smoothing.
114 */
115 void set_time_su(float su) {
116 if (su < 0.0f) su = 0.0f;
117 if (su > 1.0f) su = 1.0f;
118 uint32_t max = dl_l_.max_delay();
119 if (max <= kModHeadroom) max = kModHeadroom + 1;
120 uint32_t usable = max - kModHeadroom;
121 set_time(static_cast<uint32_t>(su * static_cast<float>(usable)));
122 }
123
124 /**
125 * @brief Tempo-synced delay time (internally slewed)
126 * @param bpm Tempo in beats per minute
127 * @param div Musical subdivision
128 *
129 * Public because tempo sync requires structured input (BPM + subdivision),
130 * not a normalized signal.
131 */
132 void set_time_bpm(float bpm, Subdivision div) {
133 if (bpm < 1.0f) bpm = 1.0f;
134 float seconds = (60.0f / bpm) * subdivision_beats(div);
135 set_time(static_cast<uint32_t>(seconds * types::SAMPLE_RATE_F));
136 }
137
138 /**
139 * @brief Set feedback from normalized [0,1] signal unit
140 * @param su Unipolar [0,1] — linearly mapped to 0–1.15 (FDP-060)
141 *
142 * Linear mapping. For musical response curves (more resolution at the
143 * top of the range), apply a cam profile in the control stack before
144 * this port. Smoothed via OnePole (50ms structural settling), then
145 * per-sample linear ramp across the block.
146 */
147 void set_feedback_su(float su) {
148 if (su < 0.0f) su = 0.0f;
149 if (su > 1.0f) su = 1.0f;
150 // Linear mapping — response curve shaping belongs in the control
151 // stack (cam profile), not inside the widget (FDP-060).
152 float fb = su * kMaxFeedback;
153 feedback_ = fb;
154 ensure_fb_smoother();
155 if (!fb_init_) {
156 op_feedback_.reset(fb);
157 fb_init_ = true;
158 }
159 }
160
161 /**
162 * @brief Set tone (head gap loss LP) from normalized [0,1] signal unit
163 * @param su Unipolar [0,1] — exponentially mapped to 200–20000 Hz
164 *
165 * 0 = dark (200 Hz LP), 1 = bright (20000 Hz, near-passthrough).
166 * Exponential mapping is handled by OnePole::set_cutoff_su() using
167 * the configured range.
168 */
169 void set_tone_su(float su) {
170 if (su < 0.0f) su = 0.0f;
171 if (su > 1.0f) su = 1.0f;
172 tone_su_ = su;
173 }
174
175 /**
176 * @brief Set bass loss (feedback HP) from normalized [0,1] signal unit
177 * @param su Unipolar [0,1] — linearly mapped to 0–500 Hz
178 *
179 * 0 = disabled (no HP), 1 = maximum bass removal (500 Hz).
180 * OnePoleHP maps [0,1] exponentially across its configured range.
181 */
182 void set_bass_loss_su(float su) {
183 if (su < 0.0f) su = 0.0f;
184 if (su > 1.0f) su = 1.0f;
185 hp_su_ = su;
186 }
187
188 /**
189 * @brief Set drive (magnetic saturation) from normalized [0,1] signal unit
190 * @param su Unipolar [0,1] — linearly mapped to 1.0–3.0
191 */
192 void set_drive_su(float su) {
193 if (su < 0.0f) su = 0.0f;
194 if (su > 1.0f) su = 1.0f;
195 drive_ = kMinDrive + su * (kMaxDrive - kMinDrive);
196 }
197
198 /**
199 * @brief Set wow+flutter from normalized [0,1] signal unit
200 * @param su Unipolar [0,1] — linearly mapped to 0–2.0
201 */
202 void set_wobble_su(float su) {
203 if (su < 0.0f) su = 0.0f;
204 if (su > 1.0f) su = 1.0f;
205 set_wobble(su * kMaxWobble);
206 }
207
208 /**
209 * @brief Set tape hiss from normalized [0,1] signal unit
210 * @param su Unipolar [0,1] — linearly mapped to 0–1.0
211 */
212 void set_hiss_su(float su) {
213 if (su < 0.0f) su = 0.0f;
214 if (su > 1.0f) su = 1.0f;
215 set_hiss(su * kMaxHiss);
216 }
217
218 /**
219 * @brief Set speed drift from normalized [0,1] signal unit
220 * @param su Unipolar [0,1] — linearly mapped to 0–3.0
221 */
222 void set_drift_su(float su) {
223 if (su < 0.0f) su = 0.0f;
224 if (su > 1.0f) su = 1.0f;
225 set_drift(su * kMaxDrift);
226 }
227
228 /**
229 * @brief Set transport speed (motor inertia) from normalized [0,1] signal unit
230 * @param su Unipolar [0,1] — linearly mapped to 1.0–20.0
231 */
232 void set_transport_speed_su(float su) {
233 if (su < 0.0f) su = 0.0f;
234 if (su > 1.0f) su = 1.0f;
235 set_transport_speed(kMinTransportSpeed + su * (kMaxTransportSpeed - kMinTransportSpeed));
236 }
237
238 /** @brief Enable stereo ping-pong (alternating L/R repeats). Default: false. */
239 void set_ping_pong(bool enable) { ping_pong_ = enable; }
240
241 // ─── Accessors ───────────────────────────────────────────────
242
243 uint32_t time() const { return static_cast<uint32_t>(target_time_); }
244 float feedback() const { return feedback_; }
245 bool ping_pong() const { return ping_pong_; }
246
247 // ─── Processing ──────────────────────────────────────────────
248
249 /**
250 * @brief Process a stereo block in-place (wet-only output)
251 *
252 * Input: dry signal from send bus.
253 * Output: wet delay signal (replaces input in-place).
254 * Caller mixes into return bus.
255 */
256 void process(float* left, float* right, uint16_t frames) {
257 if (!smoothing_initialized_) {
258 smooth_feedback_ = feedback_;
259 smooth_drive_ = drive_;
260 block_drive_ = drive_;
261 smooth_tone_su_ = tone_su_;
262 smooth_hp_su_ = hp_su_;
263 smooth_time_ = target_time_;
264 smooth_wow_depth_ = wow_depth_;
265 smooth_flutter_depth_ = flutter_depth_;
266 smooth_hiss_level_ = hiss_level_;
267 smooth_drift_amount_ = drift_amount_;
268 op_feedback_.reset(feedback_);
269 smoothing_initialized_ = true;
270 }
271 if (!time_initialized_) {
272 time_slew_.reset(target_time_);
273 time_initialized_ = true;
274 }
275
276 // Block-rate smoothing: eliminates CC zipper on all parameters.
277 // Filter coefficients recalculated once per block (too expensive per-sample).
278 // Time pre-filtered before motor slew — the staircase from TIME_SMOOTH
279 // is gentler than raw target jumps, which produce harsher digital artifacts.
280 // Character params use TIME_SMOOTH to settle at the same rate as time,
281 // keeping all transport parameters synchronized during macro sweeps (FDP-048).
282 smooth_time_ += TIME_SMOOTH * (target_time_ - smooth_time_);
283
284 // Tape character — match time settling so macro sweeps are cohesive
285 block_drive_ += TIME_SMOOTH * (drive_ - block_drive_);
286 smooth_wow_depth_ += TIME_SMOOTH * (wow_depth_ - smooth_wow_depth_);
287 smooth_flutter_depth_ += TIME_SMOOTH * (flutter_depth_ - smooth_flutter_depth_);
288 smooth_hiss_level_ += TIME_SMOOTH * (hiss_level_ - smooth_hiss_level_);
289 smooth_drift_amount_ += TIME_SMOOTH * (drift_amount_ - smooth_drift_amount_);
290
291 smooth_tone_su_ += BLOCK_SMOOTH * (tone_su_ - smooth_tone_su_);
292 tone_lp_l_.set_cutoff_su(smooth_tone_su_);
293 tone_lp_r_.set_cutoff_su(smooth_tone_su_);
294
295 if (hp_su_ > 0.0f) {
296 smooth_hp_su_ += BLOCK_SMOOTH * (hp_su_ - smooth_hp_su_);
297 feedback_hp_l_.set_cutoff_su(smooth_hp_su_);
298 feedback_hp_r_.set_cutoff_su(smooth_hp_su_);
299 }
300
301 const bool hp = hp_su_ > 0.0f;
302 const float hiss = smooth_hiss_level_;
303
304 // Feedback: OnePole smoothing (50ms structural settling) absorbs
305 // external smoother from Davis Jr. Per-sample linear ramp on top
306 // eliminates block-edge steps entirely (FDP-048 Phase 4).
307 float smoothed_fb = op_feedback_.process(feedback_);
308
309 float prev_fb = smooth_feedback_;
310 float prev_drv = smooth_drive_;
311 smooth_feedback_ = smoothed_fb;
312 smooth_drive_ = block_drive_; // block-rate TIME_SMOOTH handles settling
313
314 float fb_inc = (smooth_feedback_ - prev_fb) / static_cast<float>(frames);
315 float drv_inc = (smooth_drive_ - prev_drv) / static_cast<float>(frames);
316 float fb_val = prev_fb;
317 float drv_val = prev_drv;
318
319 for (uint16_t i = 0; i < frames; ++i) {
320 // Capture dry input before overwriting
321 float in_l = left[i];
322 float in_r = right[i];
323
324 // Per-sample linear ramp (replaces exponential PARAM_SMOOTH)
325 fb_val += fb_inc;
326 drv_val += drv_inc;
327
328 // Distance-proportional motor acceleration: big jumps spool fast,
329 // small adjustments arrive gently. Models motor torque proportional
330 // to error signal — real servo behavior. Decelerates naturally as
331 // distance shrinks. (FDP-048, FDP-060: reduced from 4.0 to 1.5
332 // for heavier reel-to-reel feel)
333 float motor_distance = smooth_time_ - time_slew_.value();
334 if (motor_distance < 0.0f) motor_distance = -motor_distance;
335 float accel = motor_distance * (1.0f / 4800.0f); // normalize ~100ms
336 float effective_rate = motor_slew_rate_ * (1.0f + accel * 1.0f);
337 time_slew_.set_rate(effective_rate);
338
339 // Time modulation: motor slew + wow + flutter + drift
340 float slewed = time_slew_.process(smooth_time_);
341
342 // Scale down wow/flutter during active motor slew to prevent muddy warble.
343 // Drift is unscaled — it IS part of the motor behavior.
344 float slew_delta = time_slew_.delta();
345 float slew_scale = (slew_delta < 1.0f) ? 1.0f :
346 (slew_delta > 4800.0f) ? 0.5f :
347 1.0f - 0.5f * (slew_delta / 4800.0f);
348
349 wow_phase_.advance();
350 flutter_phase_.advance();
351
352 float time_mod = wow_reader_.read(wow_phase_.phase()) * smooth_wow_depth_ * slew_scale
353 + flutter_reader_.read(flutter_phase_.phase()) * smooth_flutter_depth_ * slew_scale
354 + time_drift_.value() * smooth_drift_amount_;
355
356 float read_pos = slewed + time_mod;
357
358 // Read from delay lines (cubic Hermite interpolation)
359 float wet_l = dl_l_.read_cubic(read_pos);
360 float wet_r = dl_r_.read_cubic(read_pos);
361
362 // Head gap loss (LP) — applied to everything read from "tape",
363 // including the first repeat. Cumulative darkening per pass.
364 wet_l = tone_lp_l_.process(wet_l);
365 wet_r = tone_lp_r_.process(wet_r);
366
367 // Output tap: this is what the listener hears
368 left[i] = wet_l;
369 right[i] = wet_r;
370
371 // === Everything below is feedback-only (2nd repeat onward) ===
372
373 // Bass loss (HP) — thin out the low end per repeat
374 float fb_l = hp ? feedback_hp_l_.process(wet_l) : wet_l;
375 float fb_r = hp ? feedback_hp_r_.process(wet_r) : wet_r;
376
377 // Ping-pong: cross-feed channels (one transport, two heads)
378 if (ping_pong_) {
379 float tmp = fb_l;
380 fb_l = fb_r;
381 fb_r = tmp;
382 }
383
384 // Safety limiter — always-on (FDP-060). SoftLimiter provides
385 // natural compression that enables safe self-oscillation at
386 // feedback > 1.0. WarmSaturator (asymmetric harmonics) only
387 // engages when drive > 1.0 — at drive=1.0 the harmonics
388 // accumulate through feedback and produce noise artifacts.
389 if (drv_val > 1.01f) {
390 fb_l = saturator_.process(fb_l, drv_val);
391 fb_r = saturator_.process(fb_r, drv_val);
392 } else {
393 fb_l = limiter_.process(fb_l);
394 fb_r = limiter_.process(fb_r);
395 }
396
397 // DC blocker — catch offset from asymmetric saturation (FDP-060)
398 fb_l = dc_l_.process(fb_l);
399 fb_r = dc_r_.process(fb_r);
400
401 // Tape hiss — pink noise injection (accumulates per pass)
402 if (hiss > 0.0f) {
403 float noise = hiss_pink_.process(hiss_rng_.bipolar()) * hiss;
404 fb_l += noise;
405 fb_r += noise;
406 }
407
408 // Feedback gain
409 fb_l *= fb_val;
410 fb_r *= fb_val;
411
412 // Write input + feedback to delay line
413 dl_l_.write(in_l + fb_l);
414 dl_r_.write(in_r + fb_r);
415 }
416 }
417
418 /**
419 * @brief Tick control-rate modulation (~200 Hz)
420 *
421 * Advances AnalogDrift. Call from control loop, not audio callback.
422 */
423 void tick() {
424 time_drift_.process(1.0f);
425 }
426
427 /** @brief Clear delay buffers and reset all internal state */
428 void reset() {
429 dl_l_.reset();
430 dl_r_.reset();
431 tone_lp_l_.reset();
432 tone_lp_r_.reset();
433 feedback_hp_l_.reset();
434 feedback_hp_r_.reset();
435 time_drift_.reset();
436 wow_phase_.reset();
437 flutter_phase_.reset();
438 hiss_pink_.reset();
439 dc_l_.reset();
440 dc_r_.reset();
441 block_drive_ = 0.0f;
442 smooth_wow_depth_ = 0.0f;
443 smooth_flutter_depth_ = 0.0f;
444 smooth_hiss_level_ = 0.0f;
445 smooth_drift_amount_ = 0.0f;
446 time_initialized_ = false;
447 smoothing_initialized_ = false;
448 fb_init_ = false;
449 }
450
451private:
452 // Test-only access to engineering-unit setters
453 friend struct TapeDelayTestAccess;
454
455 // ─── Engineering-unit setters (private — tests via TapeDelayTestAccess) ──
456
457 /**
458 * @brief Set delay time in samples (internally slewed — tape motor)
459 * @param samples Delay length (clamped to max_samples - 1)
460 */
461 void set_time(uint32_t samples) {
462 uint32_t max = dl_l_.max_delay();
463 target_time_ = static_cast<float>((samples < max) ? samples : max - 1);
464 if (!time_initialized_) {
465 time_slew_.reset(target_time_);
466 time_initialized_ = true;
467 }
468 }
469
470 /** @brief Set delay time in milliseconds (internally slewed) */
471 void set_time_ms(float ms) {
472 set_time(static_cast<uint32_t>(ms * types::SAMPLE_RATE_F / 1000.0f));
473 }
474
475 /**
476 * @brief Set feedback gain (0.0–1.15, clamped to kMaxFeedback)
477 *
478 * Immediate — no smoothing. For internal use and backward-compatible tests.
479 */
480 void set_feedback(float fb) {
481 feedback_ = (fb > kMaxFeedback) ? kMaxFeedback : (fb < 0.0f ? 0.0f : fb);
482 op_feedback_.reset(feedback_);
483 }
484
485 /**
486 * @brief Head gap loss — LP cutoff in Hz
487 */
488 void set_tone(float freq_hz) {
489 // Convert Hz to _su via inverse exponential mapping
490 // su = log2(hz / min) / log2(max / min)
491 float su = log2f(freq_hz / kMinToneHz) / log2f(kMaxToneHz / kMinToneHz);
492 if (su < 0.0f) su = 0.0f;
493 if (su > 1.0f) su = 1.0f;
494 tone_su_ = su;
495 }
496
497 /**
498 * @brief Bass loss — HP cutoff in the feedback path
499 */
500 void set_bass_loss(float freq_hz) {
501 // Convert Hz to _su via inverse exponential mapping
502 float su = log2f(freq_hz / kMinBassLossFilterHz) / log2f(kMaxBassLossHz / kMinBassLossFilterHz);
503 if (su < 0.0f) su = 0.0f;
504 if (su > 1.0f) su = 1.0f;
505 hp_su_ = su;
506 }
507
508 /**
509 * @brief Magnetic saturation drive in the feedback path
510 */
511 void set_drive(float drive) {
512 drive_ = (drive < 1.0f) ? 1.0f : drive;
513 }
514
515 /**
516 * @brief Wow + flutter — mechanical tape speed variation
517 */
518 void set_wobble(float amount) {
519 wow_depth_ = amount * 3.0f;
520 flutter_depth_ = amount * 2.0f;
521 }
522
523 /**
524 * @brief Tape hiss — pink noise injection into feedback path
525 */
526 void set_hiss(float amount) {
527 hiss_level_ = amount * 0.003f;
528 }
529
530 /**
531 * @brief Speed drift — slow non-periodic delay time wander
532 */
533 void set_drift(float amount) {
534 drift_amount_ = amount * 10.0f;
535 }
536
537 /**
538 * @brief Transport inertia — how quickly the motor changes speed
539 */
540 void set_transport_speed(float samples_per_sample) {
541 motor_slew_rate_ = (samples_per_sample < 0.5f) ? 0.5f : samples_per_sample;
542 time_slew_.set_rate(motor_slew_rate_);
543 }
544
545 void ensure_fb_smoother() {
546 if (!fb_smoother_configured_) {
547 op_feedback_.set_time_ms(50.0f, 1000.0f); // 50ms at ~1kHz block rate
548 fb_smoother_configured_ = true;
549 }
550 }
551
552 static constexpr float BLOCK_SMOOTH = 0.2f; // per-block (~5 blocks settle)
553 // Time needs a much slower smoother than other params because
554 // any rate-of-change in read position = Doppler pitch shift.
555 // At 0.02, a 200-sample CC step produces ~0.08 samples/audio-sample
556 // of read position change — imperceptible pitch wobble.
557 // Big jumps still trigger the motor slew naturally.
558 static constexpr float TIME_SMOOTH = 0.02f; // per-block (~50 blocks settle)
559
560 // Motor slew rate: 5 samples/audio-sample.
561 // A 1-second time change (48000 samples) slews over ~200ms.
562 // Small CC steps (~200 samples) complete in <1ms (nearly instant).
563 static constexpr float MOTOR_SLEW_RATE = 5.0f;
564
565 void configure_rate_dependent() {
566 float sr = types::SAMPLE_RATE_F;
567 wow_reader_.set_table(lut::sin_256);
568 flutter_reader_.set_table(lut::triangle_256);
571 time_slew_.set_rate(motor_slew_rate_);
572 time_drift_.set_rate(200.0f);
573 // Hiss pink noise coloring — fixed 3000 Hz LP, not signal-driven
575 // Tape saturation character — mild asymmetric warmth
576 saturator_.set_asymmetry(0.1f);
577 // Tone LP: configure range, then drive via _su
578 tone_lp_l_.set_range(kMinToneHz, kMaxToneHz);
579 tone_lp_r_.set_range(kMinToneHz, kMaxToneHz);
580 tone_lp_l_.set_cutoff_su(tone_su_);
581 tone_lp_r_.set_cutoff_su(tone_su_);
582 // Bass loss HP: configure range, then drive via _su
585 if (hp_su_ > 0.0f) {
586 feedback_hp_l_.set_cutoff_su(hp_su_);
587 feedback_hp_r_.set_cutoff_su(hp_su_);
588 }
589 // Configure feedback smoother at ~1kHz block rate (50ms structural settling)
590 op_feedback_.set_time_ms(50.0f, 1000.0f);
591 op_feedback_.reset(feedback_);
592 fb_smoother_configured_ = true;
593 }
594
595 static float subdivision_beats(Subdivision div) {
596 switch (div) {
597 case Subdivision::Whole: return 4.0f;
598 case Subdivision::HalfDotted: return 3.0f;
599 case Subdivision::Half: return 2.0f;
600 case Subdivision::QuarterDotted: return 1.5f;
601 case Subdivision::QuarterTriplet: return 2.0f / 3.0f;
602 case Subdivision::Quarter: return 1.0f;
603 case Subdivision::EighthDotted: return 0.75f;
604 case Subdivision::EighthTriplet: return 1.0f / 3.0f;
605 case Subdivision::Eighth: return 0.5f;
606 case Subdivision::SixteenthDotted: return 0.375f;
607 case Subdivision::Sixteenth: return 0.25f;
608 default: return 1.0f;
609 }
610 }
611
612 // ─── Per-channel state ───────────────────────────────────────
613 primitives::DelayLine dl_l_;
614 primitives::DelayLine dl_r_;
615 primitives::OnePole tone_lp_l_; // head gap loss (pre-output tap)
616 primitives::OnePole tone_lp_r_;
617 primitives::OnePoleHP feedback_hp_l_; // bass loss (feedback-only)
618 primitives::OnePoleHP feedback_hp_r_;
619
620 // ─── Shared modulation (mono — one transport) ────────────────
621 primitives::PhaseAccumulator wow_phase_;
622 primitives::PhaseAccumulator flutter_phase_;
623 primitives::WavetableReader<256> wow_reader_;
624 primitives::WavetableReader<256> flutter_reader_;
625 comp::AnalogDrift time_drift_{0xDEADCAFE};
626 primitives::SlewLimiter time_slew_;
627 primitives::Rng hiss_rng_{0xBEEFF00D};
628 primitives::OnePole hiss_pink_;
629 comp::WarmSaturator saturator_;
630 primitives::SoftLimiter limiter_; // safety-only (no asymmetric harmonics)
631 primitives::DcBlocker dc_l_;
632 primitives::DcBlocker dc_r_;
633
634 // ─── Parameters ──────────────────────────────────────────────
635 float feedback_ = 0.4f;
636 float drive_ = 1.2f;
637 float tone_su_ = 0.5f; // [0,1] signal unit for tone LP
638 float hp_su_ = 0.0f; // [0,1] signal unit for bass loss HP (0 = disabled)
639 float wow_depth_ = 0.0f; // samples excursion (set by set_wobble)
640 float flutter_depth_ = 0.0f; // samples excursion (set by set_wobble)
641 float hiss_level_ = 0.0f; // linear amplitude (set by set_hiss)
642 float drift_amount_ = 0.0f; // samples excursion (set by set_drift)
643 bool ping_pong_ = false;
644 float motor_slew_rate_ = MOTOR_SLEW_RATE;
645 float target_time_ = 12000.0f; // samples (250 ms default)
646
647 // ─── Feedback ParameterSmoother (FDP-052) ─────────────────────
648 primitives::ParameterSmoother op_feedback_;
649 bool fb_init_ = false;
650 bool fb_smoother_configured_ = false;
651
652 // ─── Smoothing state ─────────────────────────────────────────
653 float smooth_feedback_ = 0.0f;
654 float smooth_drive_ = 0.0f;
655 float block_drive_ = 0.0f; // block-rate pre-smoother for drive
656 float smooth_tone_su_ = 0.0f;
657 float smooth_hp_su_ = 0.0f;
658 float smooth_time_ = 0.0f;
659 float smooth_wow_depth_ = 0.0f; // block-rate character smoothing (FDP-048)
660 float smooth_flutter_depth_ = 0.0f;
661 float smooth_hiss_level_ = 0.0f;
662 float smooth_drift_amount_ = 0.0f;
663 bool smoothing_initialized_ = false;
664 bool time_initialized_ = false;
665};
666
667} // namespace sbl::dsp::modules
668
669#endif // SBL_DSP_MODULES_TAPE_DELAY_HPP_
Analog oscillator drift simulation.
void set_rate(float control_rate_hz)
void reset()
Reset drift state to zero.
float value() const
Current drift value (before amount scaling), for diagnostics.
float process(float amount)
void set_asymmetry(float k)
Set asymmetry depth (structural character parameter)
float process(float x, float drive)
Process a single sample with per-sample drive.
static constexpr float kMinFeedback
void init(float *buf_l, float *buf_r, uint32_t max_samples)
Initialize with pre-allocated stereo delay buffers.
static constexpr float kMaxFeedback
static constexpr float kMinDrive
void tick()
Tick control-rate modulation (~200 Hz)
static constexpr float kMinBassLossFilterHz
static constexpr float kMinToneHz
static constexpr float kMinBassLossHz
static constexpr float kMaxDrift
void set_drive_su(float su)
Set drive (magnetic saturation) from normalized [0,1] signal unit.
static constexpr float kMaxToneHz
static constexpr float kMaxTransportSpeed
void set_transport_speed_su(float su)
Set transport speed (motor inertia) from normalized [0,1] signal unit.
void set_time_su(float su)
Set delay time from normalized [0,1] signal unit.
static constexpr float kMaxDrive
void set_wobble_su(float su)
Set wow+flutter from normalized [0,1] signal unit.
void set_hiss_su(float su)
Set tape hiss from normalized [0,1] signal unit.
void set_drift_su(float su)
Set speed drift from normalized [0,1] signal unit.
void reset()
Clear delay buffers and reset all internal state.
static constexpr float kMinTransportSpeed
static constexpr float kMaxHiss
void set_bass_loss_su(float su)
Set bass loss (feedback HP) from normalized [0,1] signal unit.
static constexpr float kMaxBassLossHz
void set_time_bpm(float bpm, Subdivision div)
Tempo-synced delay time (internally slewed)
void set_ping_pong(bool enable)
Enable stereo ping-pong (alternating L/R repeats). Default: false.
static constexpr uint32_t kModHeadroom
void set_feedback_su(float su)
Set feedback from normalized [0,1] signal unit.
static constexpr float kMaxWobble
void set_tone_su(float su)
Set tone (head gap loss LP) from normalized [0,1] signal unit.
void process(float *left, float *right, uint16_t frames)
Process a stereo block in-place (wet-only output)
float process(float x)
Process one sample through the DC blocker.
void reset()
Reset filter state to zero.
uint32_t max_delay() const
Maximum delay in samples (buffer size)
void write(float sample)
Write a sample to the delay line.
float read_cubic(float delay_samples) const
Read at fractional delay with 4-point Hermite cubic interpolation.
void init(float *buffer, uint32_t max_delay)
Initialize after default construction.
void reset()
Zero all samples in the buffer and reset write position.
void set_range(float min_hz, float max_hz)
Definition one_pole.hpp:146
void reset()
Reset filter state to zero.
Definition one_pole.hpp:103
void set_range(float min_hz, float max_hz)
Set frequency range for _su mapping (configuration, not a signal)
Definition one_pole.hpp:51
float process(float x)
Process a single sample.
Definition one_pole.hpp:80
void set_cutoff_su(float su)
Set cutoff frequency via signal unit.
Definition one_pole.hpp:64
void set_time_ms(float ms, float rate_hz)
Compute coefficient from settling time in milliseconds.
float process(float target)
Process one sample toward target.
void set_increment(uint32_t inc)
Set the phase increment per sample.
static uint32_t freq_to_inc(float freq_hz, float sr=types::SAMPLE_RATE_F)
Convert frequency in Hz to phase increment.
uint32_t phase() const
Current phase value.
void advance()
Advance phase by one sample.
float bipolar()
Random float in [-1.0, 1.0].
Definition rng.hpp:39
void set_rate(float rate)
Set both rise and fall rates to the same value.
float value() const
Current output value.
float process(float target)
Process one sample toward target with rate limiting.
float delta() const
Distance from current state to last processed target (absolute)
float process(float x)
Process a single sample.
void set_table(const float *table)
Set the wavetable to read from (must have Size+1 guard points for linear)
float read(uint32_t phase) const
Read a single sample with linear interpolation.
DC offset removal filter.
Circular buffer delay line.
Fast analytical approximations (Audio Stack — Atoms)
Fixed-point constants and audio sample types.
constexpr float sin_256[258]
Definition sin_256.hpp:10
constexpr float triangle_256[258]
Complete musical tools.
Definition ladder.hpp:59
Subdivision
Musical note subdivisions for tempo-synced delay.
float SAMPLE_RATE_F
Definition fixed.hpp:17
Single-pole IIR filters (LP and HP)
One-pole parameter smoother.
Phase accumulator for oscillators and LFOs.
Lightweight pseudorandom number generator.
Generated by LUTE — do not edit.
Slew rate limiter (float)
Soft limiter primitive.
static void set_frequency(OnePole &f, float hz)
Definition one_pole.hpp:129
Generated by LUTE — do not edit.
Warm saturation composition.
Wavetable reader with interpolation.