Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
character_delay.hpp
Go to the documentation of this file.
1#pragma once
2
3// Multi-character delay — one engine, five characters.
4//
5// The premise: what makes a delay sound like a Space Echo, a Memory Man, or an
6// early-80s rack unit is not the delay, it is what happens to a repeat as it
7// CIRCULATES. So the frame here (dual delay lines, feedback, crossfeed,
8// modulation, reverse/freeze/ducking) is shared, and each character plugs its
9// own processing INSIDE the feedback loop.
10//
11// That placement is the whole trick. In-loop coloration accumulates
12// exponentially per repeat — repeat 1 has been through the character once,
13// repeat 10 has been through it ten times — and that accumulation is the
14// vintage sound. The same processing placed after the delay would colour every
15// repeat identically and sound like an effect on a delay, not like a machine.
16//
17// * Clean — transparent full-bandwidth repeats. The null-test
18// baseline and the reference modern digital delay.
19// * Vintage Digital— a modeled converter loop at a reduced internal rate:
20// band-limited, pre-emphasized, low-bit. Time changes
21// glide in pitch because the converter is clocked.
22// * Tape — wow/flutter instability, EQ-bracketed saturation that
23// compounds per pass, head-bump low-mids, dark decaying
24// repeats, long motor-lag swoops. Self-oscillates
25// musically. A physical tier adds true magnetic
26// hysteresis, physics-derived loss and wear artifacts.
27// * BBD — bandwidth tied to clock rate (short = bright, long =
28// dark), compander pumping, clock artifacts.
29// * Diffusion — repeats smear into soft clouds, approaching reverb
30// texture as they recirculate.
31//
32// Concept-to-citation map for the mechanisms used here:
33// fractional-delay interpolation ....... Laakso et al. 1996; Dattorro 1997 Pt 2
34// allpass diffusion + network values ... Schroeder 1962; Dattorro 1997 Pt 1
35// BBD device / compander / bandwidth ... Raffel & Smith DAFx-10;
36// Holters & Parker DAFx-18; NE570 datasheet
37// BBD stage counts ..................... Panasonic MN3005/3007/3008 datasheets
38// tape model family, oversampling ...... Chowdhury DAFx-19 (2019)
39// hysteresis physics + constants ....... Jiles & Atherton 1986;
40// Jiles, Thoelke & Devine 1992
41// playback loss physics ................ Wallace 1951; Bertram 1994
42// drift process ........................ Uhlenbeck & Ornstein 1930
43// antiquing artifacts .................. Välimäki et al. JAES 2008
44// dither correctness ................... Lipshitz/Wannamaker/Vanderkooy 1992
45// emphasis time constant ............... IEC 60908
46// PRNG / normal deviates ............... Marsaglia 2003; Box & Muller 1958
47// SVF / TPT filter forms ............... Zavalishin; Simper (Cytomic)
48//
49// Contract:
50// * WET ONLY. Dry/wet mixing is the graph's job (make_drywet_node()), so
51// there is deliberately no mix parameter in the DSP.
52// * Zero allocations in process() and reset(). Every buffer is sized in
53// set_sample_rate() for the 2 s maximum, including the reverse
54// double-buffers and both tape tiers. That costs a few megabytes per
55// stereo instance and buys an audio thread that never touches an
56// allocator, which is the right trade for a module this size.
57// * latency_samples() is 0 in every configuration. The physical tier's
58// hysteresis oversampler has a fixed group delay, but it lives INSIDE the
59// loop and is folded out of the delay line's read distance rather than
60// reported to the host.
61// * Delay-time changes always glide. Every character reads through a slewed
62// continuous time, so the pitch shift is physically correct and per
63// character; there is no crossfade-jump path anywhere.
64
73#include <pulp/signal/svf.hpp>
74
75#include <algorithm>
76#include <array>
77#include <cmath>
78#include <cstddef>
79#include <cstdint>
80#include <vector>
81
82namespace pulp::signal {
83
84template <typename SampleType>
86public:
88 enum class TapeTier { standard, physical };
89
90 // Thread contract: this object is deliberately lock-free and is not safe
91 // for concurrent mutation. Call set_sample_rate()/set_character()/the tape
92 // configuration methods while processing is stopped. Character selection
93 // is switchable between realizations/processing runs, not concurrently in
94 // a live render. Parameter setters are
95 // realtime-safe and belong on the SAME audio thread that calls process()
96 // (the catalog adapter follows this contract). A host whose controls arrive
97 // on another thread must publish them through its parameter store before
98 // the audio thread calls these setters.
99
100 // ── Configuration (processing stopped) ────────────────────────────────
101
105 void set_sample_rate(double sample_rate) {
106 if (!std::isfinite(sample_rate)) return;
107 sample_rate_ = std::max(sample_rate, 1000.0);
108 const auto line_capacity =
109 static_cast<std::size_t>(
110 std::ceil(chardelay::kMaxAddressableDelayMs * 0.001 * sample_rate_) + 4.0);
111
112 for (auto& channel : channels_) {
113 channel.line.prepare(line_capacity);
114 channel.reverse.prepare(line_capacity);
115 channel.diffusion.prepare(sample_rate_);
116 channel.bbd.prepare(sample_rate_);
117 channel.vintage.prepare(sample_rate_);
118 channel.tape.prepare(sample_rate_);
119 channel.tape_physical.prepare(sample_rate_);
120 channel.instability.prepare(sample_rate_);
121 }
122 channels_[0].instability.configure(chardelay::kPrngSeed, 0.0);
123 channels_[1].instability.configure(chardelay::kPrngSeed ^ 0x5BF03635u, chardelay::kFlutterPhaseRight);
124 channels_[0].bbd.set_seed(chardelay::kPrngSeed);
125 channels_[1].bbd.set_seed(chardelay::kPrngSeed ^ 0x27D4EB2Fu);
126 channels_[0].vintage.set_seed(chardelay::kPrngSeed);
127 channels_[1].vintage.set_seed(chardelay::kPrngSeed ^ 0x165667B1u);
128 channels_[0].tape_physical.set_seeds(chardelay::kPrngSeed);
129 channels_[1].tape_physical.set_seeds(chardelay::kPrngSeed ^ 0x2545F491u);
130
131 for (auto* smoother : {&feedback_, &crossfeed_, &duck_, &freeze_, &character_,
132 &diffusion_amount_})
133 smoother->configure(chardelay::kParamGlideS, sample_rate_);
134
135 duck_follower_.configure(chardelay::kDuckAttackS, chardelay::kDuckReleaseS,
136 sample_rate_ / static_cast<double>(chardelay::kControlInterval));
137 configure_time_slew();
138 apply_tape_speed();
139 update_loop_tone();
140 reset();
141 }
142
143 void set_character(Character character) {
144 character_type_ = character;
145 configure_time_slew();
146 }
147
148 void set_tape_tier(TapeTier tier) { tape_tier_ = tier; }
149
150 void set_tape_speed_ips(SampleType ips) {
151 tape_speed_ips_ = std::clamp(finite_or(static_cast<double>(ips), tape_speed_ips_),
154 apply_tape_speed();
155 }
156
157 // ── Parameters (audio thread; smoothed in process()) ──────────────────
158
159 void set_time_ms(SampleType left_ms) {
160 time_ms_ = std::clamp(finite_or(static_cast<double>(left_ms), time_ms_), 1.0,
162 }
163
167 void set_time_offset(SampleType multiplier) {
168 const double candidate = static_cast<double>(multiplier);
169 if (!std::isfinite(candidate)) return;
170 time_offset_ = std::clamp(candidate, chardelay::kTimeOffsetMin,
172 right_time_absolute_ = false;
173 }
174
182 void set_right_time_ms(SampleType right_ms) {
183 const double candidate = static_cast<double>(right_ms);
184 if (!std::isfinite(candidate)) return;
185 right_time_ms_ =
186 std::clamp(candidate, 1.0, chardelay::kMaxAddressableDelayMs);
187 right_time_absolute_ = true;
188 }
189
191 bool right_time_is_absolute() const noexcept { return right_time_absolute_; }
192
194 double right_time_ms() const noexcept {
195 return right_time_absolute_ ? right_time_ms_ : time_ms_ * time_offset_;
196 }
197
198 void set_feedback(SampleType feedback) {
199 feedback_target_ = std::clamp(
200 finite_or(static_cast<double>(feedback), feedback_target_), 0.0,
202 }
203
204 void set_crossfeed(SampleType crossfeed) {
205 crossfeed_target_ =
206 std::clamp(finite_or(static_cast<double>(crossfeed), crossfeed_target_), 0.0, 1.0);
207 }
208
209 void set_character_amount(SampleType amount) {
210 character_target_ =
211 std::clamp(finite_or(static_cast<double>(amount), character_target_), 0.0, 1.0);
212 }
213
214 // Cross-character diffusion: smears ANY character's wet output through the
215 // diffusion network by this 0..1 amount. It stays outside the feedback
216 // loop, so increasing the amount cannot dissipate or destabilize the echo
217 // train. 0 leaves the selected character untouched.
218 void set_diffusion_amount(SampleType amount) {
219 diffusion_amount_target_ =
220 std::clamp(finite_or(static_cast<double>(amount), diffusion_amount_target_), 0.0, 1.0);
221 }
222
223 void set_mod(SampleType rate01, SampleType depth01) {
224 if (const double candidate = static_cast<double>(rate01); std::isfinite(candidate)) {
225 const double rate = std::clamp(candidate, 0.0, 1.0);
226 mod_rate_hz_ = chardelay::kModRateMinHz *
227 std::pow(chardelay::kModRateMaxHz /
229 rate);
230 }
231 if (const double candidate = static_cast<double>(depth01); std::isfinite(candidate))
232 mod_depth_ = std::clamp(candidate, 0.0, 1.0) * chardelay::kModMaxDepth;
233 }
234
235 void set_duck(SampleType amount01) {
236 duck_target_ =
237 std::clamp(finite_or(static_cast<double>(amount01), duck_target_), 0.0, 1.0);
238 }
239
240 void set_freeze(bool on) { freeze_on_ = on; }
241 void set_reverse(bool on) { reverse_on_ = on; }
242
243 // ── In-loop tone (audio thread) ───────────────────────────────────────
244 //
245 // A resonant 12 dB/oct high-pass (low cut) and low-pass (high cut) in the
246 // RECIRCULATION path, so each repeat is filtered again and the tail evolves
247 // — the behavior a tone control after the delay cannot produce. Orthogonal
248 // to the character's own loop bandwidth: this is the player's control, that
249 // is the realization's model.
250 //
251 // Bypassed bit-exactly while low cut is at its minimum and high cut at its
252 // maximum, so an untouched delay is unchanged. Also bypassed under freeze,
253 // which needs a unity loop to hold its content. Crossing the explicit
254 // bypass boundary clears the integrators so suspended resonance cannot
255 // return as a transient later.
256 void set_loop_low_cut_hz(SampleType hz) {
257 const double next = std::clamp(finite_or(static_cast<double>(hz), loop_low_cut_hz_),
259 if (next == loop_low_cut_hz_) return; // per-block pushes are free
260 loop_low_cut_hz_ = next;
261 update_loop_tone();
262 }
263
264 void set_loop_low_cut_resonance(SampleType q) {
265 const double next = std::clamp(finite_or(static_cast<double>(q), loop_low_cut_q_),
267 if (next == loop_low_cut_q_) return; // per-block pushes are free
268 loop_low_cut_q_ = next;
269 update_loop_tone();
270 }
271
272 void set_loop_high_cut_hz(SampleType hz) {
273 const double next = std::clamp(finite_or(static_cast<double>(hz), loop_high_cut_hz_),
275 if (next == loop_high_cut_hz_) return; // per-block pushes are free
276 loop_high_cut_hz_ = next;
277 update_loop_tone();
278 }
279
280 void set_loop_high_cut_resonance(SampleType q) {
281 const double next = std::clamp(finite_or(static_cast<double>(q), loop_high_cut_q_),
283 if (next == loop_high_cut_q_) return; // per-block pushes are free
284 loop_high_cut_q_ = next;
285 update_loop_tone();
286 }
287
289 bool loop_tone_active() const noexcept { return loop_tone_active_; }
290
291 // ── Runtime ───────────────────────────────────────────────────────────
292
296 int latency_samples() const noexcept { return 0; }
297
303 void reset() noexcept {
304 for (auto& channel : channels_) {
305 channel.line.reset();
306 channel.reverse.reset();
307 channel.diffusion.reset();
308 channel.bbd.reset();
309 channel.vintage.reset();
310 channel.tape.reset();
311 channel.tape_physical.reset();
312 channel.instability.reset();
313 channel.loop_highpass.reset();
314 channel.loop_lowpass.reset();
315 channel.tone_high_pass.reset();
316 channel.tone_low_pass.reset();
317 channel.wet = 0.0;
318 }
319 feedback_.snap(feedback_target_);
320 crossfeed_.snap(crossfeed_target_);
321 duck_.snap(duck_target_);
322 character_.snap(character_target_);
323 diffusion_amount_.snap(diffusion_amount_target_);
324 freeze_.snap(freeze_on_ ? 1.0 : 0.0);
325
326 duck_follower_.reset(0.0);
327 duck_gain_ = 1.0;
328 duck_gain_step_ = 0.0;
329 duck_peak_ = 0.0;
330 lfo_phase_ = 0.0;
331 bbd_flutter_phase_ = 0.0;
332 control_counter_ = 0;
333
334 time_slew_[0].snap(time_ms_);
335 time_slew_[1].snap(right_time_ms());
336 update_control_rate();
337 }
338
340 void process(SampleType* left, SampleType* right, int num_samples) noexcept {
341 if (left == nullptr || right == nullptr) return;
342 for (int i = 0; i < num_samples; ++i) {
343 if (control_counter_-- <= 0) {
344 control_counter_ = chardelay::kControlInterval - 1;
345 update_control_rate();
346 }
347
348 const double character_amount = character_.process(character_target_);
349 diffusion_amount_value_ = diffusion_amount_.process(diffusion_amount_target_);
350 const double freeze = freeze_.process(freeze_on_ ? 1.0 : 0.0);
351 duck_.process(duck_target_);
352
353 // Crossfeed is forced to zero in reverse: the channels hold
354 // independently segmented buffers there, so "bouncing" between them
355 // exchanges unrelated fragments and collapses toward mono instead
356 // of producing a stereo bounce. It glides to zero through the same
357 // smoother rather than stepping, so engaging reverse on a
358 // ping-ponging delay is not itself a click.
359 const double crossfeed = crossfeed_.process(reverse_on_ ? 0.0 : crossfeed_target_);
360
361 double feedback = std::min(feedback_.process(feedback_target_), feedback_ceiling());
362 if (physical_tape())
364 feedback, character_amount);
365 // Freeze overrides the parameter AND the per-character clamp: the
366 // loop must be exactly unity for the frozen content to hold.
367 feedback += freeze * (1.0 - feedback);
368
369 const double in_left = static_cast<double>(left[i]);
370 const double in_right = static_cast<double>(right[i]);
371 duck_peak_ = std::max(duck_peak_, std::max(std::abs(in_left), std::abs(in_right)));
372
373 const double tap_left =
374 (1.0 - crossfeed) * channels_[0].wet + crossfeed * channels_[1].wet;
375 const double tap_right =
376 (1.0 - crossfeed) * channels_[1].wet + crossfeed * channels_[0].wet;
377 const double input_gain = 1.0 - freeze;
378 const double line_in_left = in_left * input_gain + loop_saturate(tap_left * feedback);
379 const double line_in_right =
380 in_right * input_gain + loop_saturate(tap_right * feedback);
381
382 advance_modulation(character_amount);
383 const double time_left = modulated_time_ms(0, character_amount);
384 const double time_right = modulated_time_ms(1, character_amount);
385
386 channels_[0].wet =
387 process_character(channels_[0], line_in_left, time_left, character_amount, 1.0);
388 channels_[1].wet = process_character(channels_[1], line_in_right, time_right,
389 character_amount, chardelay::kStereoDecorr);
390
391 // The player's tone stage sits on the delay's OUTPUT, which is also
392 // what the feedback tap reads. So the very first repeat is filtered
393 // once — a high cut at its minimum really does mute the character —
394 // and every recirculation is filtered again on top, which is what
395 // makes the tail keep darkening. Filtering only the feedback path
396 // would leave that first repeat unfiltered and let the character
397 // through no matter where the cut sits.
398 channels_[0].wet = apply_loop_tone(channels_[0], channels_[0].wet, freeze);
399 channels_[1].wet = apply_loop_tone(channels_[1], channels_[1].wet, freeze);
400
401 // Ducking is applied to the WET output only, sidechained from the
402 // dry input before the loop — so repeats bloom in the gaps rather
403 // than being pushed down along with the source.
404 duck_gain_ = std::clamp(duck_gain_ + duck_gain_step_, 0.0, 1.0);
405 const double gain = duck_gain_;
406
407 // Cross-character diffusion on the OUTPUT only (post-loop, post-duck):
408 // applied to out_* rather than channels_[c].wet, so the feedback tap
409 // — which reads channels_[c].wet next sample — stays clean and the
410 // repeats don't recirculate through the network. One-pass smear.
411 double out_l = channels_[0].wet * gain;
412 double out_r = channels_[1].wet * gain;
413 if (character_type_ == Character::diffusion) {
414 // The character's cloud: every repeat leaving the loop excites
415 // the tank afresh, so the cloud still blooms per repeat, but
416 // its energy never re-enters the line. The in-loop path above
417 // already ran the diffuser and ticked the LFOs this sample.
418 out_l = channels_[0].diffusion.process_cloud(out_l, 1.0);
419 out_r = channels_[1].diffusion.process_cloud(out_r, chardelay::kStereoDecorr);
420 } else if (diffusion_amount_value_ > 0.0) {
421 out_l += diffusion_amount_value_ *
422 (channels_[0].diffusion.process(out_l, 1.0) - out_l);
423 channels_[0].diffusion.tick_modulation();
424 out_r += diffusion_amount_value_ *
425 (channels_[1].diffusion.process(out_r, chardelay::kStereoDecorr) - out_r);
426 channels_[1].diffusion.tick_modulation();
427 }
428 left[i] = static_cast<SampleType>(out_l);
429 right[i] = static_cast<SampleType>(out_r);
430 }
431 }
432
433 // ── Introspection and test hooks ──────────────────────────────────────
436 void set_bbd_compander_enabled(bool enabled) noexcept {
437 for (auto& channel : channels_) channel.bbd.set_compander_enabled(enabled);
438 }
439
440 double bbd_bandwidth_hz() const noexcept { return channels_[0].bbd.bandwidth_hz(); }
441 std::size_t bbd_stages() const noexcept { return channels_[0].bbd.stages(); }
442 double vintage_band_edge_hz() const noexcept { return channels_[0].vintage.band_edge_hz(); }
443 double vintage_internal_rate_hz() const noexcept {
444 return channels_[0].vintage.internal_rate_hz();
445 }
446 std::size_t chew_state_index(int channel) const noexcept {
447 return channels_[static_cast<std::size_t>(channel)].tape_physical.chew_state_index();
448 }
449 const std::vector<double>& tape_gap_coefficients(int channel) const noexcept {
450 return channels_[static_cast<std::size_t>(channel)].tape_physical.gap_coefficients();
451 }
452private:
453 static double finite_or(double value, double fallback) noexcept {
454 return std::isfinite(value) ? value : fallback;
455 }
456
457 struct ChannelState {
458 chardelay::FractionalDelayLine line;
459 chardelay::ReverseSegmenter reverse;
460 chardelay::DiffusionChain diffusion;
461 chardelay::BbdChannel bbd;
462 chardelay::VintageChannel vintage;
463 chardelay::TapeStandardChannel tape;
464 chardelay::TapePhysicalChannel tape_physical;
465 chardelay::TapeInstability instability;
466 chardelay::OnePole loop_highpass;
467 chardelay::OnePole loop_lowpass;
468 // Player-facing in-loop tone (see set_loop_low_cut_hz). Separate from
469 // loop_highpass/loop_lowpass above, which the character owns.
470 Svf64 tone_high_pass;
471 Svf64 tone_low_pass;
472 double wet = 0.0;
473 };
474
477 void update_loop_tone() noexcept {
478 const bool low_cut_active = loop_low_cut_hz_ > chardelay::kLoopToneHpMinHz;
479 const bool high_cut_active = loop_high_cut_hz_ < chardelay::kLoopToneLpMaxHz;
480 const bool low_cut_boundary_crossed = low_cut_active != loop_low_cut_active_;
481 const bool high_cut_boundary_crossed = high_cut_active != loop_high_cut_active_;
482 loop_tone_active_ = low_cut_active || high_cut_active;
483 const double guarded_nyquist = chardelay::kLoopToneNyquistFraction * sample_rate_;
484 const double effective_low_cut = std::clamp(loop_low_cut_hz_, 0.1, guarded_nyquist);
485 const double effective_high_cut = std::clamp(loop_high_cut_hz_, 0.1, guarded_nyquist);
486 for (auto& channel : channels_) {
487 channel.tone_high_pass.configure(Svf64::Mode::highpass, sample_rate_, effective_low_cut,
488 loop_low_cut_q_);
489 channel.tone_low_pass.configure(Svf64::Mode::lowpass, sample_rate_, effective_high_cut,
490 loop_high_cut_q_);
491 // Each bypass is its own abrupt topology change. The other filter
492 // may keep the combined stage active, so reset the integrators
493 // whose individual boundary crossed rather than keying this to the
494 // aggregate loop_tone_active_ flag.
495 if (low_cut_boundary_crossed) channel.tone_high_pass.reset();
496 if (high_cut_boundary_crossed) channel.tone_low_pass.reset();
497 }
498 loop_low_cut_active_ = low_cut_active;
499 loop_high_cut_active_ = high_cut_active;
500
501 const auto peak_bound = [](double q) noexcept {
502 constexpr double kButterworthQ = 0.7071067811865476;
503 if (q <= kButterworthQ) return 1.0;
504 // Worst-case magnitude of a normalized second-order LP/HP with Q
505 // above Butterworth. Cascaded active stages multiply their bounds.
506 return q / std::sqrt(1.0 - 1.0 / (4.0 * q * q));
507 };
508 double tone_gain_bound = 1.0;
509 if (loop_low_cut_hz_ > chardelay::kLoopToneHpMinHz)
510 tone_gain_bound *= peak_bound(loop_low_cut_q_);
511 if (loop_high_cut_hz_ < chardelay::kLoopToneLpMaxHz)
512 tone_gain_bound *= peak_bound(loop_high_cut_q_);
513 loop_tone_feedback_ceiling_ = chardelay::kUnsaturatedFeedbackMax / tone_gain_bound;
514 }
515
520 double apply_loop_tone(ChannelState& channel, double x, double freeze) noexcept {
521 if (!loop_tone_active_) return x;
522 double y = x;
523 if (loop_low_cut_hz_ > chardelay::kLoopToneHpMinHz)
524 y = channel.tone_high_pass.process(y);
525 if (loop_high_cut_hz_ < chardelay::kLoopToneLpMaxHz)
526 y = channel.tone_low_pass.process(y);
527 return freeze >= 1.0 ? x : y + freeze * (x - y);
528 }
529
530 bool physical_tape() const noexcept {
531 return character_type_ == Character::tape && tape_tier_ == TapeTier::physical;
532 }
533
538 double feedback_ceiling() const noexcept {
539 switch (character_type_) {
540 case Character::tape:
541 case Character::bbd:
544 case Character::clean:
546 default:
547 return std::min(chardelay::kUnsaturatedFeedbackMax, loop_tone_feedback_ceiling_);
548 }
549 }
550
551 double loop_saturate(double v) const noexcept {
552 switch (character_type_) {
553 case Character::tape:
554 case Character::bbd:
556 return chardelay::fast_tanh(v);
557 case Character::clean:
559 default:
560 return v;
561 }
562 }
563
564 double time_slew_seconds() const noexcept {
565 switch (character_type_) {
567 case Character::tape: return chardelay::kTimeSlewTapeMs * 0.001;
568 case Character::bbd: return chardelay::kTimeSlewBbdMs * 0.001;
570 case Character::clean:
571 default: return chardelay::kTimeSlewCleanMs * 0.001;
572 }
573 }
574
575 void configure_time_slew() {
576 for (auto& slew : time_slew_) slew.configure(time_slew_seconds(), sample_rate_);
577 }
578
579 void apply_tape_speed() {
580 for (auto& channel : channels_) channel.tape_physical.set_speed_ips(tape_speed_ips_);
581 }
582
587 void update_control_rate() noexcept {
588 const double amount = character_.current();
589
590 // Ducking: a control-rate follower fed the interval's PEAK, so a
591 // transient between ticks still opens the ducker. The resulting gain is
592 // ramped across the next interval rather than stepped.
593 // The gain law consumes only env/threshold clamped to [0, 1]. Letting
594 // the follower retain values above that ceiling cannot increase the
595 // duck, but it does make release time depend on how far above the
596 // threshold the source happened to peak. Cap at the last meaningful
597 // detector value so every fully-ducked passage recovers on the stated
598 // release constant (and therefore within its three-tau contract).
599 const double detector = std::min(duck_peak_, chardelay::kDuckThreshold);
600 const double envelope = duck_follower_.process(detector);
601 duck_peak_ = 0.0;
602 const double normalized = std::clamp(envelope / chardelay::kDuckThreshold, 0.0, 1.0);
603 const double knee = normalized * normalized * (3.0 - 2.0 * normalized);
604 const double target = 1.0 - duck_.current() * knee;
605 duck_gain_step_ = (target - duck_gain_) / static_cast<double>(chardelay::kControlInterval);
606
607 switch (character_type_) {
608 case Character::clean: {
609 const double lowpass_hz =
611 const double highpass_hz =
613 clean_lowpass_bypassed_ = lowpass_hz >= chardelay::kLoopLpBypassHz;
614 for (auto& channel : channels_) {
615 channel.loop_highpass.set_cutoff(highpass_hz, sample_rate_);
616 channel.loop_lowpass.set_cutoff(lowpass_hz, sample_rate_);
617 }
618 break;
619 }
621 clean_lowpass_bypassed_ = false;
622 for (auto& channel : channels_) {
623 channel.loop_highpass.set_cutoff(chardelay::kDiffusionLoopHpHz, sample_rate_);
624 channel.loop_lowpass.set_cutoff(chardelay::kDiffusionLoopLpHz, sample_rate_);
625 }
626 break;
627 case Character::tape:
628 for (auto& channel : channels_) {
629 if (physical_tape()) {
630 channel.tape_physical.update(amount);
631 } else {
632 channel.tape.update(amount);
633 }
634 }
635 break;
636 case Character::bbd:
637 // In reverse the segmenter carries the requested time and the
638 // clocked line runs short and fixed, so the two delays sum to
639 // roughly the requested time rather than double it.
640 for (std::size_t c = 0; c < channels_.size(); ++c)
641 channels_[c].bbd.update(amount, reverse_on_
643 : time_slew_[c].current() * 0.001);
644 break;
646 for (auto& channel : channels_) channel.vintage.update(amount);
647 break;
648 }
649
650 // The diffusion network serves two masters, never both at once. When the
651 // diffusion CHARACTER is selected, its chain (and tank) are tuned by the
652 // character amount — the knob that has always shaped the cloud. For every
653 // other character the same chain is the cross-character output smear,
654 // tuned by the diffusion amount. Hoisting this update out of the
655 // diffusion-only case and sourcing it ONLY from diffusion_amount_ severed
656 // the diffusion character's amount control: a host that never calls
657 // set_diffusion_amount() ran the character at zero — minimum smear, tank
658 // silent — no matter where its knob sat.
659 for (auto& channel : channels_)
660 channel.diffusion.update(character_type_ == Character::diffusion
661 ? amount
662 : diffusion_amount_.current());
663 }
664
665 void advance_modulation(double character_amount) noexcept {
666 lfo_phase_ += mod_rate_hz_ / sample_rate_;
667 if (lfo_phase_ >= 1.0) lfo_phase_ -= 1.0;
668
669 if (character_type_ == Character::bbd) {
670 const double rate = chardelay::kBbdFlutterRateBaseHz + chardelay::kBbdFlutterRateSpanHz * character_amount;
671 bbd_flutter_phase_ += rate / sample_rate_;
672 if (bbd_flutter_phase_ >= 1.0) bbd_flutter_phase_ -= 1.0;
673 }
674 }
675
680 double modulated_time_ms(std::size_t channel, double character_amount) noexcept {
681 double target = (channel == 0) ? time_ms_ : right_time_ms();
682 const double decorrelation = (channel == 0) ? 1.0 : chardelay::kStereoDecorr;
683
684 if (character_type_ == Character::bbd) {
685 const double depth =
687 target *= 1.0 + depth * decorrelation * std::sin(2.0 * chardelay::kPi * bbd_flutter_phase_);
688 }
689
690 const double slewed = time_slew_[channel].process(target);
691 const double modulation =
692 mod_depth_ * decorrelation * std::sin(2.0 * chardelay::kPi * lfo_phase_);
693 return slewed * (1.0 + modulation);
694 }
695
696 double process_character(ChannelState& channel, double x, double time_ms,
697 double character_amount, double decorrelation) noexcept {
698 const double base_samples = time_ms * 0.001 * sample_rate_;
699 double y = 0.0;
700 switch (character_type_) {
701 case Character::clean: {
702 y = read_line(channel, x, base_samples, base_samples, 0.0);
703 y = channel.loop_highpass.highpass(y);
704 if (!clean_lowpass_bypassed_) y = channel.loop_lowpass.lowpass(y);
705 break;
706 }
708 y = read_line(channel, x, base_samples, base_samples, 0.0);
709 // Diffuser ONLY in the loop: an allpass is unity-safe to
710 // recirculate, so the progressive per-repeat smear is kept.
711 // The tank is applied on the OUTPUT stage — a recirculating
712 // reverb inside a recirculating delay composes into growth
713 // even when both individually decay (see process_cloud).
714 y = channel.diffusion.process_diffuser(y, decorrelation);
715 channel.diffusion.tick_modulation();
716 y = channel.loop_highpass.highpass(y);
717 y = channel.loop_lowpass.lowpass(y);
718 break;
719 }
720 case Character::tape: {
721 channel.instability.tick();
722 const double wow =
724 const double flutter = chardelay::interpolate_knots(
726 const double offset_samples =
727 channel.instability.offset_ms(wow, flutter) * 0.001 * sample_rate_;
728
729 if (physical_tape()) {
730 // Fold the tier's in-loop group delay (the hysteresis
731 // oversampler plus both halves of the loss cascade) out of
732 // the line, so the echo lands where the time knob says and
733 // latency_samples() can stay 0. Below that floor the tier
734 // simply cannot deliver a shorter delay — a few hundred
735 // microseconds at typical rates — and the line clamps.
736 const double folded =
737 std::max(base_samples - channel.tape_physical.in_loop_delay_samples(), 1.0);
738 const double pre = channel.tape_physical.pre_process(x);
739 const double delayed =
740 read_line(channel, pre, folded + offset_samples, folded, offset_samples);
741 y = channel.tape_physical.post_process(delayed);
742 } else {
743 const double pre = channel.tape.pre_process(x);
744 const double delayed = read_line(channel, pre, base_samples + offset_samples,
745 base_samples, offset_samples);
746 y = channel.tape.post_process(delayed);
747 }
748 break;
749 }
750 case Character::bbd: {
751 // Clock-domain characters own their own line, so reverse runs
752 // at the character's INPUT instead of replacing the line. The
753 // segment carries the requested time and the line is shortened
754 // to kReverseLineMs so the two delays sum to about the
755 // requested time — see that constant for why the alternative
756 // (both at full time) is unacceptable.
757 const double v = reverse_on_
758 ? channel.reverse.process(x, base_samples, 0.0)
759 : x;
760 // The Mod LFO drives the bucket clock directly — a varying clock
761 // is how a BBD choruses (delay + pitch wobble together). The
762 // modulated time_ms is not used here because the bucket chain
763 // owns its clock, set at control rate in update().
764 const double clock_mod =
765 mod_depth_ * decorrelation * std::sin(2.0 * chardelay::kPi * lfo_phase_);
766 y = channel.bbd.process(v, clock_mod);
767 break;
768 }
770 const double v = reverse_on_
771 ? channel.reverse.process(x, base_samples, 0.0)
772 : x;
773 const double line_ms =
774 reverse_on_ ? chardelay::kReverseLineMs : time_ms;
775 y = channel.vintage.process(v, line_ms * 0.001);
776 break;
777 }
778 }
779
780 // Cross-character diffusion is applied to the wet OUTPUT in process(),
781 // NOT here: this value is the feedback tap, so smearing it in-loop would
782 // recirculate through the diffusion network every repeat and dissipate
783 // the echo train. The diffusion *character* (above) keeps its in-loop
784 // cloud; the diffusion *amount* colors the output only.
785 return y;
786 }
787
793 double read_line(ChannelState& channel, double x, double delay_samples,
794 double segment_samples, double offset_samples) noexcept {
795 // The forward line keeps being written even while reverse is engaged.
796 // It costs one store per sample and it is what stops toggling reverse
797 // OFF from replaying whatever fragment happened to be sitting in the
798 // buffer from before reverse was switched on — which, at a two-second
799 // maximum delay, can be a minute-old fragment of a different take.
800 channel.line.push(x);
801 if (reverse_on_) return channel.reverse.process(x, segment_samples, offset_samples);
802 return channel.line.read(delay_samples);
803 }
804
805 std::array<ChannelState, 2> channels_{};
806 std::array<chardelay::Smoother, 2> time_slew_{};
807 chardelay::Smoother feedback_;
808 chardelay::Smoother crossfeed_;
809 chardelay::Smoother duck_;
810 chardelay::Smoother freeze_;
811 chardelay::Smoother character_;
812 chardelay::Smoother diffusion_amount_;
813 chardelay::EnvelopeFollower duck_follower_;
814
815 Character character_type_ = Character::clean;
816 TapeTier tape_tier_ = TapeTier::standard;
817
818 double sample_rate_ = 48000.0;
819 double time_ms_ = 350.0;
820 double time_offset_ = 1.0;
821 double right_time_ms_ = 350.0;
822 bool right_time_absolute_ = false;
823 double feedback_target_ = 0.35;
824 double crossfeed_target_ = 0.0;
825 double character_target_ = 0.5;
826 double diffusion_amount_target_ = 0.0;
827 double diffusion_amount_value_ = 0.0; // smoothed once per sample in process()
828 double duck_target_ = 0.0;
829 double mod_rate_hz_ = 0.5;
830 double mod_depth_ = 0.0;
831 double tape_speed_ips_ = 7.5;
832 bool freeze_on_ = false;
833 bool reverse_on_ = false;
834
835 // Player-facing in-loop tone. Defaults are fully open, which bypasses.
836 double loop_low_cut_hz_ = chardelay::kLoopToneHpMinHz;
837 double loop_low_cut_q_ = 0.707;
838 double loop_high_cut_hz_ = chardelay::kLoopToneLpMaxHz;
839 double loop_high_cut_q_ = 0.707;
840 bool loop_low_cut_active_ = false;
841 bool loop_high_cut_active_ = false;
842 bool loop_tone_active_ = false;
843 double loop_tone_feedback_ceiling_ = chardelay::kUnsaturatedFeedbackMax;
844 bool clean_lowpass_bypassed_ = true;
845
846 double lfo_phase_ = 0.0;
847 double bbd_flutter_phase_ = 0.0;
848 double duck_gain_ = 1.0;
849 double duck_gain_step_ = 0.0;
850 double duck_peak_ = 0.0;
851 int control_counter_ = 0;
852};
853
855
856} // namespace pulp::signal
Definition character_delay.hpp:85
void set_reverse(bool on)
Definition character_delay.hpp:241
bool loop_tone_active() const noexcept
True when the in-loop tone stage is doing anything at all.
Definition character_delay.hpp:289
double bbd_bandwidth_hz() const noexcept
Definition character_delay.hpp:440
void set_bbd_compander_enabled(bool enabled) noexcept
Bypass the BBD compander.
Definition character_delay.hpp:436
void set_freeze(bool on)
Definition character_delay.hpp:240
void reset() noexcept
Allocation-free.
Definition character_delay.hpp:303
bool right_time_is_absolute() const noexcept
True while the right channel is following set_right_time_ms().
Definition character_delay.hpp:191
void set_right_time_ms(SampleType right_ms)
Right-channel time in MILLISECONDS, independent of the left.
Definition character_delay.hpp:182
void set_time_ms(SampleType left_ms)
Definition character_delay.hpp:159
void set_loop_high_cut_hz(SampleType hz)
Definition character_delay.hpp:272
void process(SampleType *left, SampleType *right, int num_samples) noexcept
Stereo, in place, WET ONLY.
Definition character_delay.hpp:340
void set_tape_speed_ips(SampleType ips)
Definition character_delay.hpp:150
void set_tape_tier(TapeTier tier)
Definition character_delay.hpp:148
void set_character(Character character)
Definition character_delay.hpp:143
void set_sample_rate(double sample_rate)
Allocates every buffer for the 2 s left-channel maximum times the largest right-channel offset,...
Definition character_delay.hpp:105
void set_feedback(SampleType feedback)
Definition character_delay.hpp:198
Character
Definition character_delay.hpp:87
@ clean
Definition character_delay.hpp:87
@ tape
Definition character_delay.hpp:87
@ bbd
Definition character_delay.hpp:87
@ diffusion
Definition character_delay.hpp:87
@ vintage_digital
Definition character_delay.hpp:87
void set_mod(SampleType rate01, SampleType depth01)
Definition character_delay.hpp:223
void set_crossfeed(SampleType crossfeed)
Definition character_delay.hpp:204
void set_duck(SampleType amount01)
Definition character_delay.hpp:235
void set_loop_low_cut_resonance(SampleType q)
Definition character_delay.hpp:264
const std::vector< double > & tape_gap_coefficients(int channel) const noexcept
Definition character_delay.hpp:449
std::size_t chew_state_index(int channel) const noexcept
Definition character_delay.hpp:446
void set_diffusion_amount(SampleType amount)
Definition character_delay.hpp:218
void set_loop_high_cut_resonance(SampleType q)
Definition character_delay.hpp:280
void set_loop_low_cut_hz(SampleType hz)
Definition character_delay.hpp:256
int latency_samples() const noexcept
Always 0.
Definition character_delay.hpp:296
double vintage_internal_rate_hz() const noexcept
Definition character_delay.hpp:443
double vintage_band_edge_hz() const noexcept
Definition character_delay.hpp:442
double right_time_ms() const noexcept
The right channel's target time in ms, whichever mode is selected.
Definition character_delay.hpp:194
void set_character_amount(SampleType amount)
Definition character_delay.hpp:209
TapeTier
Definition character_delay.hpp:88
@ physical
Definition character_delay.hpp:88
@ standard
Definition character_delay.hpp:88
void set_time_offset(SampleType multiplier)
Right-channel time as a MULTIPLE of the left.
Definition character_delay.hpp:167
std::size_t bbd_stages() const noexcept
Definition character_delay.hpp:441
@ envelope
Definition plugin_state_io.hpp:26
constexpr double kTimeSlewCleanMs
Definition tables.hpp:99
constexpr double kTimeOffsetMax
Definition tables.hpp:34
constexpr double kLoopToneNyquistFraction
Keep the TPT prewarp strictly below Nyquist.
Definition tables.hpp:76
constexpr double kModRateMaxHz
Definition tables.hpp:59
constexpr std::array< double, 5 > kTapeSpeedsIps
The IEC/NAB standard tape-speed set, in inches per second (published).
Definition tables.hpp:509
constexpr double kDuckReleaseS
Definition tables.hpp:355
constexpr std::array< double, 3 > kBbdFlutterDepth
Definition tables.hpp:199
constexpr double kTimeOffsetMin
Definition tables.hpp:33
constexpr double kLoopToneHpMaxHz
Definition tables.hpp:71
constexpr double kPi
Definition primitives.hpp:38
constexpr int kControlInterval
Coefficient recomputation and stochastic-walk stepping cadence, in samples.
Definition tables.hpp:40
constexpr double kFlutterPhaseRight
Right-channel flutter phase offset, in cycles.
Definition tables.hpp:187
constexpr double kLoopToneHpMinHz
User-facing in-loop tone stage.
Definition tables.hpp:70
double fast_tanh(double v) noexcept
Definition primitives.hpp:57
constexpr std::array< double, 3 > kBbdAxis
Definition tables.hpp:190
constexpr double kLoopToneResMin
Resonance range for both loop-tone filters.
Definition tables.hpp:82
constexpr double kReverseLineMs
Delay the CLOCK-DOMAIN characters' own line runs at while reverse is engaged, in milliseconds.
Definition tables.hpp:350
constexpr double kLoopToneLpMinHz
Definition tables.hpp:72
constexpr double kTimeSlewBbdMs
Definition tables.hpp:102
constexpr double kStereoDecorr
Right-channel depth scaler for every shared-LFO modulation.
Definition tables.hpp:88
constexpr double kModRateMinHz
Generic modulation LFO: log rate map and maximum depth as a fraction of delay time.
Definition tables.hpp:58
constexpr double kDiffusionLoopHpHz
Diffusion loop filters. [LP is a design parameter].
Definition tables.hpp:264
constexpr double kTimeSlewVintageMs
Definition tables.hpp:100
constexpr double kDuckThreshold
Definition tables.hpp:356
constexpr double kMaxDelayMs
Maximum left-channel base delay and stereo-offset bounds.
Definition tables.hpp:32
constexpr double kTimeSlewTapeMs
Definition tables.hpp:101
constexpr std::array< double, 3 > kCleanAxis
Definition tables.hpp:107
constexpr std::uint32_t kPrngSeed
PRNG seed.
Definition tables.hpp:92
constexpr double kDiffusionLoopLpHz
Definition tables.hpp:265
constexpr double kLoopLpBypassHz
A loop lowpass at or above this cutoff is BYPASSED rather than run.
Definition tables.hpp:116
double interpolate_knots(const std::array< double, N > &axis, const std::array< double, N > &values, double c) noexcept
Definition primitives.hpp:125
constexpr double kBbdFlutterRateBaseHz
BBD flutter rate map, Hz.
Definition tables.hpp:238
constexpr double kDuckAttackS
Definition tables.hpp:354
constexpr std::array< double, 3 > kCleanLoopHpHz
Definition tables.hpp:109
constexpr double kLoopToneResMax
Definition tables.hpp:83
constexpr double kSaturatedFeedbackMax
Feedback ceiling for the saturating characters.
Definition tables.hpp:54
constexpr std::array< double, 4 > kTapeWowDepthMs
Definition tables.hpp:142
constexpr double kLoopToneLpMaxHz
Definition tables.hpp:73
constexpr std::array< double, 4 > kTapeAxis
Definition tables.hpp:139
constexpr double kParamGlideS
De-zipper glide for scalar params (feedback, crossfeed, duck, freeze, and the character table's per-s...
Definition tables.hpp:44
double physical_tape_effective_feedback(double requested_feedback, double age) noexcept
Apply the physical tier's measured whole-loop stability calibration.
Definition tape_physical.hpp:47
constexpr std::array< double, 4 > kTapeFlutterDepthMs
Definition tables.hpp:143
constexpr double kUnsaturatedFeedbackMax
Feedback ceiling for the characters with NO in-loop saturator.
Definition tables.hpp:49
constexpr double kModMaxDepth
Definition tables.hpp:60
constexpr std::array< double, 3 > kCleanLoopLpHz
Definition tables.hpp:108
constexpr double kMaxAddressableDelayMs
Definition tables.hpp:35
constexpr double kBbdFlutterRateSpanHz
Definition tables.hpp:239
constexpr double kTimeSlewDiffusionMs
Definition tables.hpp:103
Published tables and closed forms, free of any instance so a test can assert the SHIPPED constant rat...
Definition tape_machine_components.hpp:41
Definition additive_bank.hpp:218
@ feedback
Definition comb_filter.hpp:18
@ right
Definition multi_channel_meter.hpp:67
@ left
Definition multi_channel_meter.hpp:66
@ c
Definition spectrum_trace.hpp:35
@ i
Definition formant_filter_bank.hpp:20
CharacterDelayT< float > CharacterDelay
Definition character_delay.hpp:854
SvfT< double > Svf64
Definition svf.hpp:149
@ y
Definition input_events.hpp:42
@ q
Definition input_events.hpp:41
@ x
Definition input_events.hpp:41
@ v
Definition input_events.hpp:41