1use std::f64::consts::TAU;
15
16pub const VINYL_VFX_NONE: u32 = 0;
17pub const VINYL_VFX_ADJACENT_GHOST: u32 = 1;
18pub const VINYL_VFX_THREE_NEEDLES: u32 = 2;
19pub const VINYL_VFX_CUT_CONSTELLATION: u32 = 3;
20pub const VINYL_VFX_INNER_FIRE: u32 = 4;
21pub const VINYL_VFX_SPLIT_WALLS: u32 = 5;
22pub const VINYL_VFX_WORN_HALO: u32 = 6;
23pub const VINYL_VFX_PINCH: u32 = 7;
24pub const VINYL_VFX_NULL_POINTS: u32 = 8;
28pub const VINYL_VFX_OVERCUT: u32 = 9;
29pub const VINYL_VFX_MAX_SCENE: u32 = VINYL_VFX_OVERCUT;
30
31const THREE_NEEDLE_SPACINGS: [f64; 6] = [
45 1.0 / 16.0,
46 1.0 / 8.0,
47 3.0 / 16.0,
48 1.0 / 4.0,
49 1.0 / 3.0,
50 3.0 / 8.0,
51];
52
53fn three_needle_anchor(index: usize) -> f64 {
57 (index as f64 + 0.5) / THREE_NEEDLE_SPACINGS.len() as f64
58}
59
60pub(crate) fn three_needle_spacing(amount: f64) -> f64 {
69 let count = THREE_NEEDLE_SPACINGS.len();
70 let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { three_needle_anchor(4) };
71 if amount <= three_needle_anchor(0) {
72 return THREE_NEEDLE_SPACINGS[0];
73 }
74 for index in 1..count {
75 let (low, high) = (three_needle_anchor(index - 1), three_needle_anchor(index));
76 if amount <= high {
77 let along = (amount - low) / (high - low);
78 return THREE_NEEDLE_SPACINGS[index - 1] + (THREE_NEEDLE_SPACINGS[index] - THREE_NEEDLE_SPACINGS[index - 1]) * along;
79 }
80 }
81 THREE_NEEDLE_SPACINGS[count - 1]
82}
83
84pub(crate) fn three_needle_amount(spacing: f64) -> f64 {
86 let count = THREE_NEEDLE_SPACINGS.len();
87 let spacing = if spacing.is_finite() { spacing } else { THREE_NEEDLE_SPACINGS[4] };
88 if spacing <= THREE_NEEDLE_SPACINGS[0] {
89 return three_needle_anchor(0);
90 }
91 for index in 1..count {
92 let (low, high) = (THREE_NEEDLE_SPACINGS[index - 1], THREE_NEEDLE_SPACINGS[index]);
93 if spacing <= high {
94 let along = (spacing - low) / (high - low);
95 return three_needle_anchor(index - 1) + (three_needle_anchor(index) - three_needle_anchor(index - 1)) * along;
96 }
97 }
98 three_needle_anchor(count - 1)
99}
100
101const PINCH_SWING_HZ: f64 = 900.0;
105const PINCH_RIDE: f64 = 2.5;
110const PINCH_HEADROOM: f64 = 0.35;
120const PINCH_RIDE_FLOOR_HZ: f64 = 300.0;
134const OVERCUT_FLOOR_HZ: f64 = 28.0;
138
139const ELLIPTICAL_HZ: f64 = 200.0;
156const ELLIPTICAL_RATIO: f64 = 0.50;
157const ELLIPTICAL_ATTACK_HZ: f64 = 60.0;
158const ELLIPTICAL_RELEASE_HZ: f64 = 3.0;
159const OVERCUT_LAYER_MINIMUM: f64 = 0.10;
200const OVERCUT_LAYER_RANGE: f64 = 0.35;
201
202const NULL_POINT_OUTER: f64 = 0.29;
207const NULL_POINT_INNER: f64 = 0.93;
208const NULL_POINT_ERROR_SCALE: f64 = 3.6;
210
211const POLAR_BINS: usize = 1 << 17;
212const WEAR_BINS: usize = 2_048;
213const WEAR_FULL_SECONDS: f64 = 20.0;
216const POLAR_TAG_TOLERANCE: f64 = 2.5 / POLAR_BINS as f64;
219const CONSTELLATION_SECTORS: u32 = 12;
220const CONSTELLATION_DEFAULT_PATTERN: u32 = 0b1011_0100_1101;
221
222#[derive(Clone, Copy, Debug)]
223pub struct VinylVfxContext {
224 pub sample_rate: f64,
225 pub rpm: f64,
226 pub start_turns: f64,
227 pub end_turns: f64,
228 pub start_position: f64,
229 pub end_position: f64,
230 pub total_frames: usize,
231 pub pressing_seed: u32,
232}
233
234impl Default for VinylVfxContext {
235 fn default() -> Self {
236 Self {
237 sample_rate: 48_000.0,
238 rpm: 33.333_333,
239 start_turns: 0.0,
240 end_turns: 0.0,
241 start_position: 0.0,
242 end_position: 0.0,
243 total_frames: 0,
244 pressing_seed: 0,
245 }
246 }
247}
248
249#[derive(Clone)]
258pub struct VinylVfxProcessor {
259 scene: u32,
260 amount: f64,
261 polar_samples: Vec<[f32; 2]>,
262 polar_written: Vec<f64>,
263 polar_filled: usize,
267 last_write: Option<(f64, [f32; 2])>,
268 wear: Vec<f32>,
269 lowpass: [f64; 2],
270 highpass: [f64; 3],
274 elliptical: [f64; 4],
277 gate_gain: f64,
278}
279
280impl std::fmt::Debug for VinylVfxProcessor {
283 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 formatter
285 .debug_struct("VinylVfxProcessor")
286 .field("scene", &self.scene)
287 .field("amount", &self.amount)
288 .field("polar_filled", &self.polar_filled)
289 .field("wear_level", &self.wear_level())
290 .finish_non_exhaustive()
291 }
292}
293
294impl Default for VinylVfxProcessor {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300impl VinylVfxProcessor {
301 pub fn new() -> Self {
302 Self {
303 scene: VINYL_VFX_NONE,
304 amount: 1.0,
305 polar_samples: vec![[0.0; 2]; POLAR_BINS],
306 polar_written: vec![f64::NEG_INFINITY; POLAR_BINS],
307 polar_filled: 0,
308 last_write: None,
309 wear: vec![0.0; WEAR_BINS],
310 lowpass: [0.0; 2],
311 highpass: [0.0; 3],
312 elliptical: [0.0; 4],
313 gate_gain: 1.0,
314 }
315 }
316
317 pub fn set_scene(&mut self, scene: u32, amount: f64) {
318 let scene = scene.min(VINYL_VFX_MAX_SCENE);
319 let amount = finite_or(amount, 1.0).clamp(0.0, 1.0);
320 if self.scene != scene {
321 self.scene = scene;
322 self.reset_transient_state();
323 }
324 self.amount = amount;
325 }
326
327 pub fn scene(&self) -> u32 {
328 self.scene
329 }
330
331 pub fn amount(&self) -> f64 {
332 self.amount
333 }
334
335 pub fn reset_transient_state(&mut self) {
336 self.polar_samples.fill([0.0; 2]);
337 self.polar_written.fill(f64::NEG_INFINITY);
338 self.polar_filled = 0;
339 self.last_write = None;
340 self.lowpass = [0.0; 2];
341 self.highpass = [0.0; 3];
342 self.elliptical = [0.0; 4];
343 self.gate_gain = 1.0;
344 }
345
346 pub fn reset_wear(&mut self) {
353 self.wear.fill(0.0);
354 }
355
356 pub fn reset_all(&mut self) {
359 self.reset_transient_state();
360 self.reset_wear();
361 }
362
363 pub fn halo_wear_map(&self) -> Vec<f32> {
366 self.wear.clone()
367 }
368
369 pub fn restore_halo_wear(&mut self, map: &[f32]) {
372 self.wear.fill(0.0);
373 for (slot, value) in self.wear.iter_mut().zip(map.iter()) {
374 *slot = if value.is_finite() { value.clamp(0.0, 1.0) } else { 0.0 };
375 }
376 }
377
378 pub fn wear_level(&self) -> f64 {
380 if self.wear.is_empty() {
381 return 0.0;
382 }
383 let total: f64 = self.wear.iter().map(|value| f64::from(*value)).sum();
384 total / self.wear.len() as f64
385 }
386
387 pub fn wear_peak(&self) -> f64 {
390 self.wear
391 .iter()
392 .fold(0.0_f64, |peak, value| peak.max(f64::from(*value)))
393 }
394
395 pub fn polar_fill_ratio(&self) -> f64 {
397 self.polar_filled as f64 / POLAR_BINS as f64
398 }
399
400 pub fn memory_bytes(&self) -> usize {
404 self.polar_samples.len() * std::mem::size_of::<[f32; 2]>()
405 + self.polar_written.len() * std::mem::size_of::<f64>()
406 + self.wear.len() * std::mem::size_of::<f32>()
407 }
408
409 pub const fn wear_bin_count() -> usize {
410 WEAR_BINS
411 }
412
413 pub const fn polar_bin_count() -> usize {
414 POLAR_BINS
415 }
416
417 pub fn process_interleaved(
418 &mut self,
419 samples: &mut [f32],
420 channel_count: usize,
421 context: VinylVfxContext,
422 ) {
423 if self.scene == VINYL_VFX_NONE
424 || self.amount <= f64::EPSILON
425 || !(1..=2).contains(&channel_count)
426 {
427 return;
428 }
429 let frame_count = samples.len() / channel_count;
430 if frame_count == 0 {
431 return;
432 }
433 for frame_index in 0..frame_count {
434 let offset = frame_index * channel_count;
435 let mut frame = [samples[offset], samples[offset]];
436 if channel_count == 2 {
437 frame[1] = samples[offset + 1];
438 }
439 self.process_frame(&mut frame, channel_count, frame_index, frame_count, context);
440 samples[offset] = frame[0];
441 if channel_count == 2 {
442 samples[offset + 1] = frame[1];
443 }
444 }
445 }
446
447 pub fn process_planar(
448 &mut self,
449 left: &mut [f32],
450 right: &mut [f32],
451 context: VinylVfxContext,
452 ) {
453 if self.scene == VINYL_VFX_NONE || self.amount <= f64::EPSILON {
454 return;
455 }
456 let frame_count = left.len().min(right.len());
457 for frame_index in 0..frame_count {
458 let mut frame = [left[frame_index], right[frame_index]];
459 self.process_frame(&mut frame, 2, frame_index, frame_count, context);
460 left[frame_index] = frame[0];
461 right[frame_index] = frame[1];
462 }
463 }
464
465 fn bass_to_middle(&mut self, frame: &mut [f32; 2], sample_rate: f64) {
468 let mid = (f64::from(frame[0]) + f64::from(frame[1])) * 0.5;
469 let side = (f64::from(frame[0]) - f64::from(frame[1])) * 0.5;
470 let corner = 1.0 - (-TAU * ELLIPTICAL_HZ / sample_rate).exp();
471 self.elliptical[0] += (side - self.elliptical[0]) * corner;
472 self.elliptical[1] += (mid - self.elliptical[1]) * corner;
473 let side_low = self.elliptical[0];
474 let mid_low = self.elliptical[1];
475
476 let attack = 1.0 - (-TAU * ELLIPTICAL_ATTACK_HZ / sample_rate).exp();
479 let release = 1.0 - (-TAU * ELLIPTICAL_RELEASE_HZ / sample_rate).exp();
480 for (slot, level) in [(2usize, side_low.abs()), (3usize, mid_low.abs())] {
481 let rate = if level > self.elliptical[slot] { attack } else { release };
482 self.elliptical[slot] += (level - self.elliptical[slot]) * rate;
483 }
484
485 let allowed = self.elliptical[3] * ELLIPTICAL_RATIO;
486 let carried = self.elliptical[2];
487 let held = if carried > allowed && carried > 1.0e-9 {
490 side_low * (1.0 - allowed / carried)
491 } else {
492 0.0
493 };
494 let corrected = side - held;
495 frame[0] = soft_limit(mid + corrected);
496 frame[1] = soft_limit(mid - corrected);
497 }
498
499 fn process_frame(
500 &mut self,
501 frame: &mut [f32; 2],
502 channel_count: usize,
503 frame_index: usize,
504 frame_count: usize,
505 context: VinylVfxContext,
506 ) {
507 let progress = if frame_count > 1 {
508 frame_index as f64 / (frame_count - 1) as f64
509 } else {
510 0.0
511 };
512 let turns = interpolate(context.start_turns, context.end_turns, progress);
513 let position = interpolate(context.start_position, context.end_position, progress);
514 let total_frames = context.total_frames.max(1) as f64;
515 let radius = (position / total_frames).clamp(0.0, 1.0);
516 let signed_motion = context.end_turns - context.start_turns;
517 let direction = if signed_motion < 0.0 { -1.0 } else { 1.0 };
518 let dry = *frame;
519
520 match self.scene {
521 VINYL_VFX_ADJACENT_GHOST => {
522 let previous_turn = self.read_polar(turns - direction, channel_count);
523 let transfer = self.amount * 0.72;
524 let normalise = 1.0 / (1.0 + transfer);
529 for channel in 0..channel_count {
530 self.lowpass[channel] +=
531 (f64::from(previous_turn[channel]) - self.lowpass[channel]) * 0.22;
532 frame[channel] = soft_limit(
533 (f64::from(dry[channel]) + self.lowpass[channel] * transfer) * normalise,
534 );
535 }
536 }
537 VINYL_VFX_THREE_NEEDLES => {
538 let spacing = three_needle_spacing(self.amount);
541 let second = self.read_polar(turns - direction * spacing, channel_count);
542 let third = self.read_polar(turns - direction * spacing * 2.0, channel_count);
543 let level = 0.52;
544 for channel in 0..channel_count {
545 let head_mix =
546 f64::from(second[channel]) * 0.58 + f64::from(third[channel]) * 0.42;
547 frame[channel] = soft_limit(f64::from(dry[channel]) + head_mix * level);
548 }
549 }
550 VINYL_VFX_CUT_CONSTELLATION => {
551 let phase = turns.rem_euclid(1.0);
552 let sector = ((phase * f64::from(CONSTELLATION_SECTORS)).floor() as u32)
553 .min(CONSTELLATION_SECTORS - 1);
554 let pattern = constellation_pattern(context.pressing_seed);
555 let open = pattern & (1 << sector) != 0;
556 let target = if open { 1.0 } else { 1.0 - self.amount * 0.96 };
557 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
558 let alpha = 1.0 - (-1.0 / (sample_rate * 0.0018)).exp();
559 self.gate_gain += (target - self.gate_gain) * alpha;
560 for sample in frame.iter_mut().take(channel_count) {
561 *sample = (f64::from(*sample) * self.gate_gain) as f32;
562 }
563 }
564 VINYL_VFX_INNER_FIRE => {
565 let heat = self.amount * (0.35 + 0.65 * radius.powf(1.35));
566 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
567 let cutoff = 18_000.0 - heat * 11_000.0;
568 let alpha = 1.0 - (-TAU * cutoff / sample_rate).exp();
569 for channel in 0..channel_count {
570 let driven = (f64::from(dry[channel]) * (1.0 + heat * 1.8)).tanh();
571 self.lowpass[channel] += (driven - self.lowpass[channel]) * alpha;
572 frame[channel] = soft_limit(self.lowpass[channel] * (1.0 - heat * 0.22));
573 }
574 if channel_count == 2 {
575 narrow_stereo(frame, heat * 0.6);
576 }
577 }
578 VINYL_VFX_SPLIT_WALLS => {
579 let left_head = self.read_polar(turns - direction * 0.25, channel_count);
580 let right_head = self.read_polar(turns - direction * 0.625, channel_count);
581 let wall = self.amount * 0.68;
582 if channel_count == 2 {
583 let lateral = (f64::from(dry[0]) + f64::from(dry[1])) * 0.5;
584 let vertical = (f64::from(dry[0]) - f64::from(dry[1])) * 0.5;
585 let delayed_lateral = (f64::from(left_head[0]) + f64::from(left_head[1])) * 0.5;
586 let delayed_vertical =
587 (f64::from(right_head[0]) - f64::from(right_head[1])) * 0.5;
588 frame[0] = soft_limit(lateral + vertical + delayed_lateral * wall);
589 frame[1] = soft_limit(lateral - vertical - delayed_vertical * wall);
590 } else {
591 frame[0] = soft_limit(
592 f64::from(dry[0])
593 + (f64::from(left_head[0]) - f64::from(right_head[0])) * wall,
594 );
595 }
596 }
597 VINYL_VFX_WORN_HALO => {
598 let wear_index = wear_bin(turns);
599 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
600 let increment =
607 self.amount * WEAR_BINS as f64 / (sample_rate * WEAR_FULL_SECONDS);
608 self.wear[wear_index] =
609 (f64::from(self.wear[wear_index]) + increment).clamp(0.0, 1.0) as f32;
610 let worn = f64::from(self.wear[wear_index]).sqrt() * self.amount;
611 let cutoff = 19_000.0 - worn * 11_000.0;
612 let alpha = 1.0 - (-TAU * cutoff / sample_rate).exp();
613 let crackle = deterministic_crackle(
614 wear_index as u64,
615 turns.floor() as i64,
616 context.pressing_seed,
617 worn,
618 );
619 for channel in 0..channel_count {
620 self.lowpass[channel] +=
621 (f64::from(dry[channel]) - self.lowpass[channel]) * alpha;
622 frame[channel] = soft_limit(self.lowpass[channel] + crackle);
623 }
624 }
625 VINYL_VFX_PINCH => {
626 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
644 let lateral = (f64::from(dry[0]) + f64::from(dry[1])) * 0.5;
645 let vertical = (f64::from(dry[0]) - f64::from(dry[1])) * 0.5;
646 let swing_alpha = 1.0 - (-TAU * PINCH_SWING_HZ / sample_rate).exp();
647 self.lowpass[1] += (lateral - self.lowpass[1]) * swing_alpha;
648 let squared = self.lowpass[1] * self.lowpass[1];
649 let floor_alpha = 1.0 - (-TAU * PINCH_RIDE_FLOOR_HZ / sample_rate).exp();
655 self.lowpass[0] += (squared - self.lowpass[0]) * floor_alpha;
656 let mut ridden = squared - self.lowpass[0];
657 for pole in 0..3 {
658 self.highpass[pole] += (ridden - self.highpass[pole]) * floor_alpha;
659 ridden -= self.highpass[pole];
660 }
661 let ride = (ridden * self.amount * PINCH_RIDE).tanh();
665 let headroom = 1.0 / (1.0 + self.amount * PINCH_HEADROOM);
666 frame[0] = soft_limit((lateral + vertical + ride) * headroom);
667 frame[1] = soft_limit((lateral - vertical - ride) * headroom);
668 if channel_count == 2 {
669 self.bass_to_middle(frame, sample_rate);
670 }
671 }
672 VINYL_VFX_NULL_POINTS => {
673 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
681 let tangency = (radius - NULL_POINT_OUTER) * (radius - NULL_POINT_INNER);
682 let bend = (tangency.abs() * NULL_POINT_ERROR_SCALE).min(1.0)
683 * self.amount
684 * 0.9;
685 let dc_alpha = 1.0 - (-TAU * 18.0 / sample_rate).exp();
686 let lean = [bend, bend * 0.72];
687 for channel in 0..channel_count {
688 let sample = f64::from(dry[channel]);
689 let squared = sample * sample;
692 self.lowpass[channel] += (squared - self.lowpass[channel]) * dc_alpha;
693 frame[channel] =
694 soft_limit(sample + (squared - self.lowpass[channel]) * lean[channel] * 2.4);
695 }
696 }
697 VINYL_VFX_OVERCUT => {
698 let sample_rate = finite_or(context.sample_rate, 48_000.0).max(1.0);
710 let previous = self.read_polar(turns - direction, channel_count);
711 let layer = OVERCUT_LAYER_MINIMUM + self.amount * OVERCUT_LAYER_RANGE;
712 let alpha = 1.0 - (-TAU * 7_000.0 / sample_rate).exp();
713 let floor_alpha = 1.0 - (-TAU * OVERCUT_FLOOR_HZ / sample_rate).exp();
718 for channel in 0..channel_count {
719 self.lowpass[channel] +=
720 (f64::from(previous[channel]) - self.lowpass[channel]) * alpha;
721 self.highpass[channel] +=
722 (self.lowpass[channel] - self.highpass[channel]) * floor_alpha;
723 let layered = self.lowpass[channel] - self.highpass[channel];
724 frame[channel] =
725 (f64::from(dry[channel]) + layered * layer).tanh() as f32;
726 }
727 if channel_count == 2 {
730 self.bass_to_middle(frame, sample_rate);
731 }
732 }
733 _ => {}
734 }
735
736 let cut = if self.scene == VINYL_VFX_OVERCUT {
739 *frame
740 } else {
741 dry
742 };
743 self.write_polar(turns, cut);
744 }
745
746 fn read_polar(&self, turns: f64, channel_count: usize) -> [f32; 2] {
750 let turns = finite_or(turns, 0.0);
751 let bin_position = turns * POLAR_BINS as f64;
752 let base = bin_position.floor();
753 let fraction = bin_position - base;
754 let mut value = [0.0_f64; 2];
755 for (step, weight) in [(0.0, 1.0 - fraction), (1.0, fraction)] {
756 let unwrapped = base + step;
757 let index = wrap_bin(unwrapped);
758 let expected = unwrapped / POLAR_BINS as f64;
759 if (self.polar_written[index] - expected).abs() <= POLAR_TAG_TOLERANCE {
760 value[0] += f64::from(self.polar_samples[index][0]) * weight;
761 value[1] += f64::from(self.polar_samples[index][1]) * weight;
762 }
763 }
764 let mut frame = [value[0] as f32, value[1] as f32];
765 if channel_count == 1 {
766 frame[1] = frame[0];
767 }
768 frame
769 }
770
771 fn write_polar(&mut self, turns: f64, frame: [f32; 2]) {
775 let turns = finite_or(turns, 0.0);
776 let bin_position = turns * POLAR_BINS as f64;
777 if let Some((previous_turns, previous_frame)) = self.last_write {
778 let previous_position = previous_turns * POLAR_BINS as f64;
779 let span = bin_position - previous_position;
780 if span != 0.0 && span.abs() <= POLAR_BINS as f64 * 0.25 {
782 let low = previous_position.min(bin_position);
783 let high = previous_position.max(bin_position);
784 let mut bin = low.ceil();
785 while bin <= high {
786 let t = ((bin - previous_position) / span).clamp(0.0, 1.0);
787 let index = wrap_bin(bin);
788 self.polar_samples[index] = [
789 lerp(previous_frame[0], frame[0], t),
790 lerp(previous_frame[1], frame[1], t),
791 ];
792 if !self.polar_written[index].is_finite() {
793 self.polar_filled += 1;
794 }
795 self.polar_written[index] = bin / POLAR_BINS as f64;
796 bin += 1.0;
797 }
798 self.last_write = Some((turns, frame));
799 return;
800 }
801 }
802 let index = wrap_bin(bin_position.floor());
803 self.polar_samples[index] = frame;
804 if !self.polar_written[index].is_finite() {
805 self.polar_filled += 1;
806 }
807 self.polar_written[index] = bin_position.floor() / POLAR_BINS as f64;
808 self.last_write = Some((turns, frame));
809 }
810}
811
812fn wrap_bin(unwrapped: f64) -> usize {
813 (unwrapped.rem_euclid(POLAR_BINS as f64) as usize).min(POLAR_BINS - 1)
814}
815
816fn wear_bin(turns: f64) -> usize {
817 let phase = finite_or(turns, 0.0).rem_euclid(1.0);
818 ((phase * WEAR_BINS as f64).floor() as usize).min(WEAR_BINS - 1)
819}
820
821fn lerp(start: f32, end: f32, t: f64) -> f32 {
822 (f64::from(start) + (f64::from(end) - f64::from(start)) * t) as f32
823}
824
825fn constellation_pattern(seed: u32) -> u32 {
829 if seed == 0 {
830 return CONSTELLATION_DEFAULT_PATTERN;
831 }
832 let mut hash = u64::from(seed).wrapping_mul(0x9E37_79B9_7F4A_7C15);
833 for _ in 0..8 {
834 hash ^= hash >> 30;
835 hash = hash.wrapping_mul(0xBF58_476D_1CE4_E5B9);
836 hash ^= hash >> 27;
837 let pattern = (hash as u32) & 0xFFF;
838 let open = pattern.count_ones();
839 if (3..=9).contains(&open) {
840 return pattern;
841 }
842 hash = hash.wrapping_add(0x9E37_79B9);
843 }
844 CONSTELLATION_DEFAULT_PATTERN
845}
846
847fn interpolate(start: f64, end: f64, progress: f64) -> f64 {
848 finite_or(start, 0.0) + (finite_or(end, start) - finite_or(start, 0.0)) * progress
849}
850
851fn finite_or(value: f64, fallback: f64) -> f64 {
852 if value.is_finite() {
853 value
854 } else {
855 fallback
856 }
857}
858
859fn soft_limit(value: f64) -> f32 {
868 const KNEE: f64 = 0.75;
869 let magnitude = value.abs();
870 if magnitude <= KNEE {
871 return value as f32;
872 }
873 let over = (magnitude - KNEE) / (1.0 - KNEE);
874 (value.signum() * (KNEE + (1.0 - KNEE) * over.tanh())) as f32
875}
876
877fn narrow_stereo(frame: &mut [f32; 2], amount: f64) {
878 let amount = amount.clamp(0.0, 1.0);
879 let mid = (f64::from(frame[0]) + f64::from(frame[1])) * 0.5;
880 let side = (f64::from(frame[0]) - f64::from(frame[1])) * 0.5 * (1.0 - amount);
881 frame[0] = soft_limit(mid + side);
882 frame[1] = soft_limit(mid - side);
883}
884
885fn deterministic_crackle(index: u64, turn: i64, seed: u32, worn: f64) -> f64 {
886 if worn <= f64::EPSILON {
887 return 0.0;
888 }
889 let mut hash = index
890 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
891 .wrapping_add(turn as u64)
892 .wrapping_add(u64::from(seed));
893 hash ^= hash >> 30;
894 hash = hash.wrapping_mul(0xBF58_476D_1CE4_E5B9);
895 hash ^= hash >> 27;
896 let chance = hash & 0x7ff;
897 if chance >= (worn * 22.0) as u64 {
898 return 0.0;
899 }
900 let bipolar = ((hash >> 16) & 0xffff) as f64 / 32_767.5 - 1.0;
901 bipolar * worn * 0.09
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn spread_walks_the_three_needle_ladder_and_still_reaches_even_thirds() {
910 let walked = (0..THREE_NEEDLE_SPACINGS.len())
913 .map(|index| three_needle_spacing(three_needle_anchor(index)))
914 .collect::<Vec<_>>();
915 assert_eq!(walked, THREE_NEEDLE_SPACINGS.to_vec());
916 let mut last = 0.0;
920 for step in 0..=200 {
921 let spacing = three_needle_spacing(step as f64 / 200.0);
922 assert!(spacing >= last, "the ladder went backwards at {step}");
923 last = spacing;
924 assert!((three_needle_spacing(three_needle_amount(spacing)) - spacing).abs() < 1e-9);
925 }
926 assert!((three_needle_spacing(three_needle_amount(0.267)) - 0.267).abs() < 1e-9);
927 assert!(THREE_NEEDLE_SPACINGS.contains(&(1.0 / 3.0)));
929 assert_eq!(three_needle_spacing(0.0), THREE_NEEDLE_SPACINGS[0]);
932 for spacing in THREE_NEEDLE_SPACINGS {
935 assert!(spacing * 2.0 < 1.0);
936 }
937 }
938
939 fn context(turns: f64, frames: usize) -> VinylVfxContext {
940 VinylVfxContext {
941 start_turns: turns,
942 end_turns: turns + frames as f64 / 4_800.0,
943 end_position: frames as f64,
944 total_frames: frames * 4,
945 ..VinylVfxContext::default()
946 }
947 }
948
949 #[test]
950 fn bypass_is_bit_exact() {
951 let mut processor = VinylVfxProcessor::new();
952 let mut samples = vec![0.25_f32, -0.5, 0.75, -0.125];
953 let expected = samples.clone();
954 processor.process_interleaved(&mut samples, 2, context(0.0, 2));
955 assert_eq!(samples, expected);
956 }
957
958 #[test]
959 fn constellation_is_painted_on_turns_not_clock_time() {
960 let mut processor = VinylVfxProcessor::new();
961 processor.set_scene(VINYL_VFX_CUT_CONSTELLATION, 1.0);
962 let mut first = vec![0.5_f32; 9_600];
963 processor.process_interleaved(&mut first, 2, context(0.0, 4_800));
964 assert!(first.iter().any(|sample| sample.abs() < 0.2));
965 assert!(first.iter().any(|sample| sample.abs() > 0.45));
966 }
967
968 #[test]
969 fn constellation_repeats_the_same_cut_every_revolution() {
970 let mut processor = VinylVfxProcessor::new();
971 processor.set_scene(VINYL_VFX_CUT_CONSTELLATION, 1.0);
972 let mut first = vec![0.5_f32; 9_600];
973 processor.process_interleaved(&mut first, 2, context(0.0, 4_800));
974 let mut second = vec![0.5_f32; 9_600];
975 processor.process_interleaved(&mut second, 2, context(1.0, 4_800));
976 let mut third = vec![0.5_f32; 9_600];
977 processor.process_interleaved(&mut third, 2, context(2.0, 4_800));
978 let drift = second
981 .iter()
982 .zip(third.iter())
983 .skip(1_000)
984 .map(|(a, b)| (a - b).abs())
985 .fold(0.0_f32, f32::max);
986 assert!(drift < 0.01, "cut drifted between revolutions: {drift}");
987 }
988
989 #[test]
990 fn constellation_cut_belongs_to_the_pressing() {
991 let mut house = VinylVfxProcessor::new();
992 house.set_scene(VINYL_VFX_CUT_CONSTELLATION, 1.0);
993 let mut pressed = VinylVfxProcessor::new();
994 pressed.set_scene(VINYL_VFX_CUT_CONSTELLATION, 1.0);
995 let mut house_output = vec![0.5_f32; 9_600];
996 house.process_interleaved(&mut house_output, 2, context(0.0, 4_800));
997 let mut pressed_output = vec![0.5_f32; 9_600];
998 let seeded = VinylVfxContext {
999 pressing_seed: 0xB17_5EED,
1000 ..context(0.0, 4_800)
1001 };
1002 pressed.process_interleaved(&mut pressed_output, 2, seeded);
1003 assert!(house_output
1004 .iter()
1005 .zip(pressed_output.iter())
1006 .any(|(a, b)| (a - b).abs() > 0.1));
1007 }
1008
1009 #[test]
1010 fn adjacent_ghost_reads_the_previous_revolution() {
1011 let mut processor = VinylVfxProcessor::new();
1012 processor.set_scene(VINYL_VFX_ADJACENT_GHOST, 1.0);
1013 let mut first = vec![0.4_f32; 9_600];
1014 processor.process_interleaved(&mut first, 2, context(0.0, 4_800));
1015 let mut second = vec![0.0_f32; 9_600];
1016 processor.process_interleaved(&mut second, 2, context(1.0, 4_800));
1017 assert!(second.iter().any(|sample| sample.abs() > 0.05));
1018 }
1019
1020 #[test]
1021 fn ghost_echo_is_smooth_not_stair_stepped() {
1022 let mut processor = VinylVfxProcessor::new();
1027 processor.set_scene(VINYL_VFX_ADJACENT_GHOST, 1.0);
1028 let frames = 4_800_usize;
1029 let mut first: Vec<f32> = (0..frames)
1030 .flat_map(|i| {
1031 let value = (i as f64 / frames as f64 * TAU * 96.0).sin() as f32 * 0.6;
1032 [value, value]
1033 })
1034 .collect();
1035 processor.process_interleaved(&mut first, 2, context(0.0, frames));
1036 let mut second = vec![0.0_f32; frames * 2];
1037 processor.process_interleaved(&mut second, 2, context(1.0, frames));
1038 let ghost: Vec<f32> = second.chunks_exact(2).map(|f| f[0]).collect();
1039 let peak = ghost.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1040 assert!(peak > 0.05, "no ghost came back: {peak}");
1041 let max_jump = ghost
1042 .windows(2)
1043 .skip(200)
1044 .map(|w| (w[1] - w[0]).abs())
1045 .fold(0.0_f32, f32::max);
1046 assert!(
1049 max_jump < peak * 0.15,
1050 "ghost is stair-stepped: jump {max_jump} against peak {peak}"
1051 );
1052 }
1053
1054 #[test]
1055 fn pinch_makes_width_out_of_a_mono_cut() {
1056 let mut processor = VinylVfxProcessor::new();
1057 processor.set_scene(VINYL_VFX_PINCH, 1.0);
1058 let frames = 4_800_usize;
1059 let mut samples: Vec<f32> = (0..frames)
1061 .flat_map(|i| {
1062 let value = (i as f64 / frames as f64 * TAU * 40.0).sin() as f32 * 0.6;
1063 [value, value]
1064 })
1065 .collect();
1066 processor.process_interleaved(&mut samples, 2, context(0.0, frames));
1067 let side = samples
1068 .chunks_exact(2)
1069 .fold(0.0_f32, |peak, frame| peak.max((frame[0] - frame[1]).abs()));
1070 assert!(side > 0.05, "a mono cut should ride its way into width");
1071 let peak = samples.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1072 assert!(peak <= 1.0);
1073
1074 let side_track: Vec<f32> = samples
1080 .chunks_exact(2)
1081 .map(|frame| (frame[0] - frame[1]) * 0.5)
1082 .collect();
1083 let energy = |track: &[f32]| -> f64 {
1089 (track.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::<f64>()
1090 / track.len() as f64)
1091 .sqrt()
1092 };
1093 let steps: Vec<f32> = side_track
1094 .windows(2)
1095 .map(|pair| pair[1] - pair[0])
1096 .collect();
1097 let brightness = energy(&steps) / energy(&side_track).max(1.0e-9);
1098 assert!(
1099 brightness < 0.2,
1100 "the ride should be an octave, not a fuzzbox: {brightness}"
1101 );
1102 }
1103
1104 #[test]
1105 fn null_points_are_clean_and_the_edge_is_not() {
1106 fn grit(radius_progress: f64) -> f32 {
1107 let mut processor = VinylVfxProcessor::new();
1108 processor.set_scene(VINYL_VFX_NULL_POINTS, 1.0);
1109 let frames = 4_800_usize;
1110 let mut samples: Vec<f32> = (0..frames)
1111 .flat_map(|i| {
1112 let value = (i as f64 / frames as f64 * TAU * 40.0).sin() as f32 * 0.6;
1113 [value, value]
1114 })
1115 .collect();
1116 let dry = samples.clone();
1117 let mut context = context(0.0, frames);
1118 context.start_position = radius_progress * frames as f64 * 4.0;
1120 context.end_position = context.start_position;
1121 processor.process_interleaved(&mut samples, 2, context);
1122 samples
1123 .iter()
1124 .zip(dry.iter())
1125 .fold(0.0_f32, |peak, (wet, dry)| peak.max((wet - dry).abs()))
1126 }
1127 assert!(grit(NULL_POINT_OUTER) < 0.001);
1129 assert!(grit(NULL_POINT_INNER) < 0.001);
1130 assert!(grit(0.0) > grit(0.6));
1131 assert!(grit(0.6) > grit(NULL_POINT_OUTER));
1132 }
1133
1134 #[test]
1135 fn overcut_cuts_its_own_pass_back_into_the_groove() {
1136 let mut processor = VinylVfxProcessor::new();
1137 processor.set_scene(VINYL_VFX_OVERCUT, 1.0);
1138 let frames = 4_800_usize;
1139 let loud = vec![0.4_f32; frames * 2];
1140 let mut first = loud.clone();
1142 processor.process_interleaved(&mut first, 2, context(0.0, frames));
1143 let mut second = vec![0.0_f32; frames * 2];
1144 processor.process_interleaved(&mut second, 2, context(1.0, frames));
1145 let mut third = vec![0.0_f32; frames * 2];
1146 processor.process_interleaved(&mut third, 2, context(2.0, frames));
1147 let second_peak = second.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1148 let third_peak = third.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1149 assert!(second_peak > 0.05);
1152 assert!(third_peak > 0.05);
1153 assert!(third.iter().all(|sample| sample.abs() <= 1.0));
1154 }
1155
1156 #[test]
1157 fn split_walls_keeps_stereo_channels_distinct() {
1158 let mut processor = VinylVfxProcessor::new();
1159 processor.set_scene(VINYL_VFX_SPLIT_WALLS, 1.0);
1160 let mut warmup = Vec::with_capacity(9_600);
1161 for _ in 0..4_800 {
1162 warmup.extend_from_slice(&[0.7, -0.2]);
1163 }
1164 processor.process_interleaved(&mut warmup, 2, context(0.0, 4_800));
1165 let mut output = warmup.clone();
1166 processor.process_interleaved(&mut output, 2, context(1.0, 4_800));
1167 assert!(output.chunks_exact(2).any(|frame| frame[0] != frame[1]));
1168 }
1169
1170 #[test]
1171 fn inner_fire_is_audible_but_not_blown_out() {
1172 let mut processor = VinylVfxProcessor::new();
1173 processor.set_scene(VINYL_VFX_INNER_FIRE, 1.0);
1174 let frames = 4_800_usize;
1175 let mut samples: Vec<f32> = (0..frames)
1176 .flat_map(|i| {
1177 let value = (i as f64 / frames as f64 * TAU * 48.0).sin() as f32 * 0.5;
1178 [value, value]
1179 })
1180 .collect();
1181 let dry = samples.clone();
1182 processor.process_interleaved(&mut samples, 2, context(0.0, frames));
1183 let wet_peak = samples.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1184 let dry_peak = dry.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1185 assert!(
1186 samples
1187 .iter()
1188 .zip(dry.iter())
1189 .any(|(a, b)| (a - b).abs() > 0.02),
1190 "fire did nothing"
1191 );
1192 assert!(
1193 wet_peak < dry_peak * 1.6,
1194 "fire blew out: {wet_peak} against dry {dry_peak}"
1195 );
1196 }
1197
1198 const REVOLUTION: usize = 86_400;
1202
1203 fn revolution_programme() -> Vec<f32> {
1204 let bin = |k: f64| k / 1.8;
1205 (0..REVOLUTION)
1206 .flat_map(|i| {
1207 let t = i as f64 / 48_000.0;
1208 let mono = (t * TAU * bin(99.0)).sin() * 0.45
1209 + (t * TAU * bin(148.0)).sin() * 0.20
1210 + (t * TAU * bin(396.0)).sin() * 0.16
1211 + (t * TAU * bin(594.0)).sin() * 0.12;
1212 let side = (t * TAU * bin(5_580.0)).sin() * 0.05;
1213 [(mono + side) as f32, (mono - side) as f32]
1214 })
1215 .collect()
1216 }
1217
1218 fn locked_groove(turn: usize) -> VinylVfxContext {
1219 VinylVfxContext {
1220 start_turns: turn as f64,
1221 end_turns: turn as f64 + 1.0,
1222 start_position: 0.55 * REVOLUTION as f64 * 40.0,
1223 end_position: 0.55 * REVOLUTION as f64 * 40.0,
1224 total_frames: REVOLUTION * 40,
1225 ..VinylVfxContext::default()
1226 }
1227 }
1228
1229 fn sub_audio(track: &[f64]) -> f64 {
1232 let alpha = 1.0 - (-TAU * 20.0 / 48_000.0f64).exp();
1233 let mut state = [0.0f64; 6];
1234 let mut sum = 0.0;
1235 for sample in track {
1236 state[0] += (sample - state[0]) * alpha;
1237 for stage in 1..6 {
1238 state[stage] += (state[stage - 1] - state[stage]) * alpha;
1239 }
1240 sum += state[5] * state[5];
1241 }
1242 (sum / track.len() as f64).sqrt()
1243 }
1244
1245 fn mid_of(frames: &[f32]) -> Vec<f64> {
1246 frames
1247 .chunks_exact(2)
1248 .map(|f| (f64::from(f[0]) + f64::from(f[1])) * 0.5)
1249 .collect()
1250 }
1251
1252 fn side_of(frames: &[f32]) -> Vec<f64> {
1253 frames
1254 .chunks_exact(2)
1255 .map(|f| (f64::from(f[0]) - f64::from(f[1])) * 0.5)
1256 .collect()
1257 }
1258
1259 fn last_turn_of(scene: u32, turns: usize) -> Vec<f32> {
1260 let mut processor = VinylVfxProcessor::new();
1261 processor.set_scene(scene, 1.0);
1262 let mut last = Vec::new();
1263 for turn in 0..turns {
1264 let mut wet = revolution_programme();
1265 processor.process_interleaved(&mut wet, 2, locked_groove(turn));
1266 last = wet;
1267 }
1268 last
1269 }
1270
1271 #[test]
1272 fn pinch_keeps_the_sub_audio_out_of_the_sides() {
1273 let wet = last_turn_of(VINYL_VFX_PINCH, 6);
1279 let side = sub_audio(&side_of(&wet));
1280 let mid = sub_audio(&mid_of(&wet));
1281 assert!(
1282 side < 0.005,
1283 "pinch put sub-audio in the sides: {side} against {mid} in the middle"
1284 );
1285 let spread = |frames: &[f32]| -> f64 {
1291 let sum = |v: Vec<f64>| v.iter().map(|s| s.abs()).sum::<f64>();
1292 sum(side_of(frames)) / sum(mid_of(frames)).max(1.0e-12)
1293 };
1294 let width = spread(&wet) / spread(&revolution_programme());
1295 assert!(width > 1.2, "pinch stopped making width: {width}");
1296 }
1297
1298 #[test]
1299 fn overcut_does_not_sum_its_own_offset() {
1300 let wet = last_turn_of(VINYL_VFX_OVERCUT, 12);
1304 let dry = revolution_programme();
1305 let mid = sub_audio(&mid_of(&wet));
1306 let dry_mid = sub_audio(&mid_of(&dry));
1307 assert!(
1308 mid < dry_mid * 3.0,
1309 "overcut piled up under the music: {mid} against {dry_mid} dry"
1310 );
1311 let departure = wet
1314 .iter()
1315 .zip(dry.iter())
1316 .fold(0.0_f32, |peak, (w, d)| peak.max((w - d).abs()));
1317 assert!(departure > 0.1, "overcut stopped layering: {departure}");
1318 assert!(wet.iter().all(|sample| sample.abs() <= 1.0));
1319 }
1320
1321 #[test]
1322 fn worn_halo_wears_in_seconds_rather_than_hours() {
1323 let mut processor = VinylVfxProcessor::new();
1328 processor.set_scene(VINYL_VFX_WORN_HALO, 1.0);
1329 let dry = revolution_programme();
1330 let rms = |v: &[f32]| -> f64 {
1331 (v.iter().map(|s| f64::from(*s) * f64::from(*s)).sum::<f64>() / v.len() as f64).sqrt()
1332 };
1333 let departure = |wet: &[f32]| -> f64 {
1334 let delta: Vec<f32> = wet.iter().zip(dry.iter()).map(|(w, d)| w - d).collect();
1335 rms(&delta) / rms(&dry)
1336 };
1337 let mut early = 0.0;
1339 let mut late = 0.0;
1340 for turn in 0..12 {
1341 let mut wet = revolution_programme();
1342 processor.process_interleaved(&mut wet, 2, locked_groove(turn));
1343 if turn == 0 {
1344 early = departure(&wet);
1345 }
1346 late = departure(&wet);
1347 }
1348 assert!(early > 0.004, "the first pass left no mark at all: {early}");
1349 assert!(
1350 late > early * 2.5,
1351 "the halo stopped building: {early} then {late}"
1352 );
1353 }
1354
1355 #[test]
1356 fn overcut_converges_instead_of_running_away() {
1357 let dry: Vec<f32> = revolution_programme().iter().map(|s| s * 0.55).collect();
1365 let rms = |v: &[f32]| -> f64 {
1366 (v.iter().map(|s| f64::from(*s) * f64::from(*s)).sum::<f64>() / v.len() as f64).sqrt()
1367 };
1368 let peak = |v: &[f32]| v.iter().fold(0.0_f32, |a, s| a.max(s.abs()));
1369 let mut processor = VinylVfxProcessor::new();
1370 processor.set_scene(VINYL_VFX_OVERCUT, 1.0);
1371 let mut first = 0.0;
1372 let mut sixth = 0.0;
1373 let mut twelfth = 0.0;
1374 let mut top = 0.0_f32;
1375 for turn in 0..12 {
1376 let mut wet = dry.clone();
1377 processor.process_interleaved(&mut wet, 2, locked_groove(turn));
1378 top = top.max(peak(&wet));
1379 match turn {
1380 0 => first = rms(&wet) / rms(&dry),
1381 5 => sixth = rms(&wet) / rms(&dry),
1382 11 => twelfth = rms(&wet) / rms(&dry),
1383 _ => {}
1384 }
1385 }
1386 let drift = (twelfth - sixth).abs() / sixth;
1388 assert!(
1389 drift < 0.06,
1390 "overcut had not settled by the twelfth turn: {sixth} then {twelfth}"
1391 );
1392 assert!(twelfth > first * 1.2, "overcut stopped layering: {first} then {twelfth}");
1394 assert!(top < 0.99, "overcut reached the ceiling: {top}");
1396 }
1397 #[test]
1398 fn the_elliptical_circuit_takes_wide_bass_to_the_middle() {
1399 const TURN: usize = 86_400;
1404 let wide: Vec<f32> = (0..TURN)
1405 .flat_map(|i| {
1406 let t = i as f64 / 48_000.0;
1407 let middle = (t * TAU * 220.0).sin() * 0.30 + (t * TAU * 660.0).sin() * 0.12;
1408 let bass = (t * TAU * 55.0).sin() * 0.40;
1409 [(middle + bass) as f32, (middle - bass) as f32]
1410 })
1411 .collect();
1412
1413 let low_side = |frames: &[f32]| -> f64 {
1415 let alpha = 1.0 - (-TAU * 200.0 / 48_000.0f64).exp();
1416 let mut state = [0.0f64; 4];
1417 let mut sum = 0.0;
1418 for pair in frames.chunks_exact(2) {
1419 let side = (f64::from(pair[0]) - f64::from(pair[1])) * 0.5;
1420 state[0] += (side - state[0]) * alpha;
1421 for stage in 1..4 {
1422 state[stage] += (state[stage - 1] - state[stage]) * alpha;
1423 }
1424 sum += state[3] * state[3];
1425 }
1426 (sum / (frames.len() / 2) as f64).sqrt()
1427 };
1428
1429 let before = low_side(&wide);
1430 let mut processor = VinylVfxProcessor::new();
1431 processor.set_scene(VINYL_VFX_OVERCUT, 1.0);
1432 let mut wet = wide.clone();
1433 processor.process_interleaved(&mut wet, 2, locked_groove(0));
1434 let after = low_side(&wet);
1435
1436 assert!(
1438 after < before * 0.5,
1439 "the sides kept their bass: {before} then {after}"
1440 );
1441 let middle = |frames: &[f32]| -> f64 {
1443 let m: Vec<f64> = frames
1444 .chunks_exact(2)
1445 .map(|p| (f64::from(p[0]) + f64::from(p[1])) * 0.5)
1446 .collect();
1447 (m.iter().map(|s| s * s).sum::<f64>() / m.len() as f64).sqrt()
1448 };
1449 let kept = middle(&wet) / middle(&wide);
1450 assert!(kept > 0.8, "the circuit took the middle with it: {kept}");
1451 }
1452
1453 #[test]
1458 fn ghost_and_overcut_do_not_overdrive_the_limiter() {
1459 let bass: Vec<f32> = (0..REVOLUTION)
1460 .flat_map(|i| {
1461 let t = i as f64 / 48_000.0;
1462 let mid = (t * TAU * 82.0).sin() * 0.55;
1463 let side = (t * TAU * 55.0).sin() * 0.42;
1464 [(mid + side) as f32, (mid - side) as f32]
1465 })
1466 .collect();
1467 let hot = |frames: &[f32]| frames.iter().filter(|s| s.abs() > 0.95).count();
1468 for (name, scene, turns) in [
1469 ("adjacent ghost", VINYL_VFX_ADJACENT_GHOST, 3),
1470 ("overcut", VINYL_VFX_OVERCUT, 12),
1471 ] {
1472 let mut processor = VinylVfxProcessor::new();
1473 processor.set_scene(scene, 1.0);
1474 let mut worst = 0;
1475 for turn in 0..turns {
1476 let mut wet = bass.clone();
1477 processor.process_interleaved(&mut wet, 2, locked_groove(turn));
1478 worst = worst.max(hot(&wet));
1479 }
1480 assert_eq!(worst, 0, "{name} drove the limiter into saturation");
1481 }
1482 }
1483}