use common_dsp::meter::HarmonicMeter; use common_dsp::Saw; use std::f32::consts::TAU; use temper_core::{Curve, Engine, Settings, Source}; const RATE: f32 = 48000.; fn engine(s: Settings) -> (Engine, usize) { let mut e = Engine::new(RATE, 2).unwrap(); e.set_settings(s).unwrap(); let l = e.latency_samples() as usize; (e, l) } fn saw(hz: f32, seconds: f32, amp: f32) -> Vec { let mut s = Saw::new(RATE, hz); (0..(seconds * RATE) as usize) .map(|_| s.sample() * amp) .collect() } fn render(e: &mut Engine, x: &[f32]) -> Vec { x.iter().map(|&v| e.process_sample([v, v])[0]).collect() } fn harmonics(x: &[f32], hz: f32) -> [f32; 32] { let mut m = HarmonicMeter::new(RATE, 85.); for &v in x { m.push(v, hz); } *m.levels() } /// Harmonics 1–n relative to the fundamental. fn profile(x: &[f32], hz: f32, n: usize) -> Vec { let h = harmonics(x, hz); (0..n).map(|k| h[k] - h[0]).collect() } fn rms_db(x: &[f32]) -> f32 { 10. * (x.iter().map(|v| v * v).sum::() / x.len() as f32).log10() } #[test] fn quiet_and_loud_patches_distort_the_same() { let s = Settings { drive: 18., auto_gain: false, ..Settings::default() }; let mut profiles = vec![]; for amp in [0.5, 0.06] { let (mut e, _) = engine(s); let y = render(&mut e, &saw(55., 1.2, amp)); profiles.push(profile(&y[y.len() / 2..], 55., 8)); } for k in 1..8 { let d = profiles[0][k] - profiles[1][k]; assert!(d.abs() < 1., "harmonic {}: {profiles:?}", k + 1); } // Without normalisation the quiet patch is barely distorted. let s = Settings { normalize: 0., ..s }; let (mut e, _) = engine(s); let y = render(&mut e, &saw(55., 1.2, 0.06)); let quiet = profile(&y[y.len() / 2..], 55., 8); assert!( (quiet[2] - profiles[0][2]).abs() > 2., "{quiet:?} vs {profiles:?}" ); } #[test] fn bright_follows_the_note() { let s = Settings { drive: 24., curve: Curve::Hard, bright: 8., ..Settings::default() }; let mut profiles = vec![]; for hz in [43.65, 87.31] { let (mut e, _) = engine(s); let y = render(&mut e, &saw(hz, 1.2, 0.4)); profiles.push(profile(&y[y.len() / 2..], hz, 14)); } for k in 1..14 { let d = profiles[0][k] - profiles[1][k]; assert!(d.abs() < 1.5, "harmonic {}: {profiles:?}", k + 1); } // Above the cutoff the harmonics fall away on both notes. assert!(profiles[0][12] < profiles[0][4] - 10.); } /// Energy away from the harmonics of `hz`, relative to the harmonics, in dB. fn alias_ratio(curve: Curve, drive: f32, hz: f32) -> f32 { let s = Settings { drive, curve, auto_gain: false, ..Settings::default() }; let (mut e, _) = engine(s); let x: Vec = (0..36000) .map(|i| 0.3 * (TAU * hz * i as f32 / RATE).sin()) .collect(); let y = render(&mut e, &x); let tail = &y[12000..]; let n = tail.len(); let window: Vec = (0..n) .map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos()) .collect(); let (mut harmonic, mut other) = (0f64, 0f64); for f in (20..23000).step_by(10) { // Goertzel power at f. let c = 2. * (TAU as f64 * f as f64 / RATE as f64).cos(); let (mut s1, mut s2) = (0f64, 0f64); for (v, w) in tail.iter().zip(&window) { let s0 = (v * w) as f64 + c * s1 - s2; s2 = s1; s1 = s0; } let p = s1 * s1 + s2 * s2 - c * s1 * s2; let near = (f as f32 / hz - (f as f32 / hz).round()).abs() * hz < 30.; if near { harmonic += p; } else { other += p; } } (10. * (other / harmonic).log10()) as f32 } #[test] fn oversampling_keeps_aliasing_down_in_the_bass_range() { // 2 kHz stands for the upper harmonics of a growl, 440 Hz for a high bass fundamental. for curve in Curve::ALL { let r = alias_ratio(curve, 24., 2000.); assert!(r < -60., "{} 2 kHz: {r}", curve.name()); let r = alias_ratio(curve, 36., 440.); assert!(r < -55., "{} 440 Hz: {r}", curve.name()); } } #[test] #[ignore] fn print_alias_ratios() { for curve in Curve::ALL { for hz in [440., 2000., 5000.] { for drive in [12., 24., 36.] { println!( "{} {hz} {drive}: {:.1}", curve.name(), alias_ratio(curve, drive, hz) ); } } } } #[test] fn auto_gain_matches_input_loudness() { for drive in [6., 24., 36.] { let s = Settings { drive, ..Settings::default() }; let (mut e, l) = engine(s); let x = saw(55., 2., 0.3); let y = render(&mut e, &x); let d = rms_db(&y[y.len() / 2..]) - rms_db(&x[x.len() / 2 - l..x.len() - l]); assert!(d.abs() < 1., "{drive}: {d}"); } } #[test] fn zero_mix_is_the_delayed_dry_signal_and_latency_is_exact() { let s = Settings { mix: 0., ..Settings::default() }; let (mut e, l) = engine(s); let x = saw(55., 1., 0.5); let y = render(&mut e, &x); for i in RATE as usize / 2..x.len() { assert_eq!(y[i], x[i - l], "{i}"); } // With the shaper nearly linear the wet path lines up with the dry one. let s = Settings { drive: 0., normalize: 0., curve: Curve::Soft, auto_gain: false, ..Settings::default() }; let (mut e, l) = engine(s); let mut x = vec![0f32; 9600]; x[100] = 0.01; let y = render(&mut e, &x); let at = y .iter() .enumerate() .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())) .unwrap() .0; assert_eq!(at, 100 + l); } #[test] fn midi_source_tunes_filters_at_the_delayed_note_time() { let s = Settings { source: Source::Midi, bright: 4., drive: 18., curve: Curve::Hard, ..Settings::default() }; let (mut e, _) = engine(s); e.note_on(29); let y = render(&mut e, &saw(43.65, 1., 0.4)); let p = profile(&y[y.len() / 2..], 43.65, 12); assert!(p[10] < p[2] - 12., "{p:?}"); let f = e.take_analyzer(); assert_eq!(f.note, Some(29)); } #[test] fn settings_are_validated_and_serialise() { let s = Settings::default(); let json = serde_json::to_string(&s).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), s); for bad in [ Settings { floor: 0.1, ..s }, Settings { bright: 2., ..s }, Settings { push_harmonic: 0, ..s }, Settings { drive: f32::NAN, ..s }, ] { assert!(bad.validate().is_err(), "{bad:?}"); } for c in Curve::ALL { assert_eq!(c.shape(0.), 0.); assert!(c.shape(100.).abs() <= 1.0001); } }