use quench_core::{shape, Engine, Monitor, Settings}; use std::f64::consts::TAU; const RATE: f32 = 48000.; /// Partials of the top: not harmonics of the sub, so their clipping products can be told /// apart from the sub's own harmonics. const TOPS: [f64; 5] = [437., 611., 1013., 1597., 2399.]; fn top(t: f64) -> f64 { TOPS.iter() .enumerate() .map(|(i, f)| (TAU * f * t + i as f64).sin()) .sum::() / 4.2 } /// Mono render of `seconds` of `signal`, returned as the left channel. fn render( engine: &mut Engine, seconds: f32, monitor: Monitor, signal: impl Fn(f64) -> f64, ) -> Vec { let n = (seconds * RATE) as usize; (0..n) .map(|i| { let v = signal(i as f64 / RATE as f64) as f32; engine.process_sample([v, v], monitor)[0] }) .collect() } fn engine(s: Settings) -> Engine { let mut e = Engine::new(RATE, 2).unwrap(); e.set_settings(s).unwrap(); e } /// Amplitude of `hz` over a whole number of its periods at the end of `x`. fn amplitude(x: &[f32], hz: f64) -> f64 { let n = (RATE as f64 / hz * (0.5 * hz).floor()) as usize; let x = &x[x.len() - n..]; let (mut re, mut im) = (0., 0.); for (i, &v) in x.iter().enumerate() { let a = TAU * hz * i as f64 / RATE as f64; re += v as f64 * a.cos(); im += v as f64 * a.sin(); } 2. * (re * re + im * im).sqrt() / n as f64 } /// Hann-windowed spectrum magnitude of the last half second, 2 Hz bins up to `top_bin`. fn spectrum(x: &[f32], top_bin: usize) -> Vec { let n = RATE as usize / 2; let x = &x[x.len() - n..]; let w: Vec = (0..n) .map(|i| 0.5 - 0.5 * (TAU * i as f64 / n as f64).cos()) .collect(); let norm: f64 = w.iter().sum(); (0..=top_bin) .map(|b| { let (mut re, mut im) = (0., 0.); for i in 0..n { let a = TAU * b as f64 * i as f64 / n as f64; re += x[i] as f64 * w[i] * a.cos(); im += x[i] as f64 * w[i] * a.sin(); } 2. * (re * re + im * im).sqrt() / norm }) .collect() } /// Everything below 110 Hz except the 50 Hz sub, against the sub, in dB. fn low_junk(x: &[f32]) -> f64 { let s = spectrum(x, 55); let junk: f64 = s .iter() .enumerate() .filter(|(b, _)| !(22..=28).contains(b) && *b > 0) .map(|(_, v)| v * v) .sum(); let sub: f64 = s[22..=28].iter().map(|v| v * v).sum(); 10. * (junk / sub).log10() } fn db(r: f64) -> f64 { 20. * r.log10() } #[test] fn below_the_ceiling_the_signal_passes_unchanged() { let mut e = engine(Settings::default()); let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 0.2 * (TAU * 1000. * t).sin(); let out = render(&mut e, 1., Monitor::Quench, signal); for (hz, a) in [(50., 0.3), (1000., 0.2)] { let got = db(amplitude(&out, hz) / a); assert!(got.abs() < 0.02, "{hz} Hz: {got:.3} dB"); } let f = e.take_analyzer(); assert_eq!(f.top_clip, 0.); assert_eq!(f.safety, 0.); let mut e = engine(Settings::default()); let delta = render(&mut e, 0.2, Monitor::Delta, signal); assert!(delta.iter().all(|v| v.abs() < 1e-4)); } #[test] fn the_output_never_passes_the_ceiling() { let mut seed = 1u32; let noise: Vec = (0..RATE as usize) .map(|_| { seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); (seed >> 8) as f64 / (1u32 << 24) as f64 * 2. - 1. }) .collect(); for ceiling in [-6., -0.3, 0.] { for soft in [0., 50., 100.] { for monitor in [Monitor::Quench, Monitor::Plain] { let mut e = engine(Settings { drive: 24., ceiling, soft, ..Settings::default() }); let out = render(&mut e, 1., monitor, |t| { let i = ((t * RATE as f64) as usize).min(noise.len() - 1); 0.4 * (TAU * 41. * t).sin() + 0.3 * top(t) + 0.2 * noise[i] }); let limit = 10f32.powf(ceiling / 20.) * (1. + 1e-6); let peak = out.iter().fold(0f32, |m, v| m.max(v.abs())); assert!( peak <= limit, "{ceiling} dB, soft {soft}, {monitor:?}: {peak}" ); } } } } #[test] fn the_sub_keeps_its_level_and_the_low_band_stays_clean() { // A quiet sub under a top that peaks far over the ceiling: the hard case for a clipper. let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 2. * top(t); let mut results = Vec::new(); for monitor in [Monitor::Quench, Monitor::Plain] { let mut e = engine(Settings::default()); let out = render(&mut e, 1.5, monitor, signal); let sub = db(amplitude(&out, 50.) / 0.3); results.push((sub, low_junk(&out))); } let [(q_sub, q_junk), (p_sub, p_junk)] = [results[0], results[1]]; assert!(q_sub.abs() < 0.2, "Quench changes the sub by {q_sub:.2} dB"); assert!( p_sub < -0.8, "a plain clip should squash the sub: {p_sub:.2} dB" ); assert!( q_junk < p_junk - 10., "low-band products: Quench {q_junk:.1} dB, plain {p_junk:.1} dB" ); } #[test] fn the_meter_shows_how_much_less_the_low_band_changes() { let mut e = engine(Settings::default()); render(&mut e, 1.5, Monitor::Quench, |t| { 0.3 * (TAU * 50. * t).sin() + 2. * top(t) }); let f = e.take_analyzer(); assert!( f.low_quench < f.low_plain - 10., "Quench {:.1} dB, plain {:.1} dB", f.low_quench, f.low_plain ); assert!(f.top_clip > 6.); // The oversampled clip leaves the safety clip after the downsampler almost idle. assert!(f.safety < 0.2, "{}", f.safety); let mut e = engine(Settings::default()); render(&mut e, 0.5, Monitor::Quench, |t| { 0.5 * (TAU * 1000. * t).sin() }); assert!( e.take_analyzer().low_quench.is_nan(), "no sub, nothing to compare" ); } #[test] fn a_sub_over_the_ceiling_leaves_no_room_and_is_still_limited() { let mut e = engine(Settings { ceiling: -6., ..Settings::default() }); let out = render(&mut e, 1., Monitor::Quench, |t| { 0.8 * (TAU * 50. * t).sin() + 0.3 * top(t) }); let f = e.take_analyzer(); assert!(f.sub_peak > -6., "{}", f.sub_peak); assert!(f.safety > 0.); let peak = out.iter().fold(0f32, |m, v| m.max(v.abs())); assert!(peak <= 0.5012 * (1. + 1e-6)); let room = f.scope_room.iter().fold(0f32, |m, v| m.max(*v)); assert_eq!(room, 0., "the held sub peak is over the ceiling"); } #[test] fn the_held_peak_keeps_the_tops_room_steady_through_the_cycle() { let room_ripple = |hold: f32, hz: f64| { let mut e = engine(Settings { hold, ..Settings::default() }); render(&mut e, 1., Monitor::Quench, |t| { 0.5 * (TAU * hz * t).sin() + 0.3 * top(t) }); let f = e.take_analyzer(); let (lo, hi) = f .scope_room .iter() .fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v))); (hi - lo) / hi }; // 30 Hz: the sub peaks every 16.7 ms. let held = room_ripple(60., 30.); let short = room_ripple(10., 30.); assert!(held < 0.01, "{held}"); assert!(short > 0.1, "{short}"); } #[test] fn bypass_is_the_input_delayed_by_the_latency() { let mut e = engine(Settings { bypass: true, drive: 12., ..Settings::default() }); let latency = e.latency_samples() as usize; let x: Vec = (0..4800) .map(|i| (0.9 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32 + 0.5) .collect(); let out: Vec = x .iter() .map(|&v| e.process_sample([v, v], Monitor::Quench)[0]) .collect(); for i in latency..x.len() { assert_eq!(out[i], x[i - latency]); } } #[test] fn mono_works_and_the_shape_never_passes_its_limit() { let mut e = Engine::new(44100., 1).unwrap(); e.set_settings(Settings { drive: 12., ..Settings::default() }) .unwrap(); for i in 0..44100 { let v = (TAU * 55. * i as f64 / 44100.).sin() as f32; let o = e.process_sample([v, 0.], Monitor::Quench); assert!(o[0].abs() <= 0.9661 && o[1] == 0.); } for knee in [0., 0.3, 1.] { for h in [-5., -1., -0.2, 0., 0.5, 0.99, 3.] { let y = shape(h, 0.8, knee); assert!(y.abs() <= 0.8 && y * h >= 0.); } } assert_eq!(shape(0.5, 0.8, 0.), 0.5); assert_eq!(shape(0.5, 0., 0.), 0.); } #[test] fn settings_are_validated_and_serialise() { assert!(Settings::default().validate().is_ok()); for bad in [ Settings { drive: -1., ..Settings::default() }, Settings { ceiling: 0.5, ..Settings::default() }, Settings { split: 20., ..Settings::default() }, Settings { hold: f32::NAN, ..Settings::default() }, ] { assert!(bad.validate().is_err()); assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err()); } let s = Settings { drive: 9., soft: 40., ..Settings::default() }; let json = serde_json::to_string(&s).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), s); assert!(serde_json::from_str::(r#"{"drive":0,"oops":1}"#).is_err()); }