use common_dsp::meter::HarmonicMeter; use common_dsp::Saw; use planish_core::{compressor_gain, Engine, Settings, Source, BANDS}; use std::f32::consts::{PI, TAU}; 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() } /// Sum of sines `(harmonic, amplitude)` on `hz`, with the phase in f64 so long signals stay /// clean. fn tones(hz: f32, seconds: f32, parts: &[(usize, f32)]) -> Vec { (0..(seconds * RATE) as usize) .map(|i| { let t = i as f64 / RATE as f64; parts .iter() .map(|&(k, a)| { let phase = (TAU as f64 * hz as f64 * k as f64 * t) % (TAU as f64); a * (phase as f32).sin() }) .sum() }) .collect() } fn sine(hz: f32, seconds: f32, amp: f32) -> Vec { tones(hz, seconds, &[(1, amp)]) } fn render(e: &mut Engine, x: &[f32]) -> Vec { x.iter().map(|&v| e.process_sample([v, v])[0]).collect() } /// Harmonic levels of the last complete window of `x`. 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() } fn rms_db(x: &[f32]) -> f32 { let p: f32 = x.iter().map(|v| v * v).sum::() / x.len() as f32; 10. * (p + 1e-20).log10() } fn uncompressed() -> Settings { Settings { depth: 0., ..Settings::default() } } #[test] fn bands_sum_flat_without_compression() { for hz in [35., 80., 250., 1000., 4000., 12000.] { let x = sine(hz, 0.6, 0.5); let (mut e, l) = engine(uncompressed()); let y = render(&mut e, &x); let tail = y.len() / 2; let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]); assert!(d.abs() < 0.05, "{hz} Hz: {d:+.3} dB"); } } #[test] fn band_edges_sit_between_the_same_harmonics_on_every_note() { // A saw with amplitude a has harmonic k at 2a/(πk). Band 1 holds harmonic 1 minus what // its slope gives away; the split of the series between the bands is the same on every // note. let a = 0.5; let fundamental = 10. * ((2. * a / PI).powi(2) / 2.).log10(); let mut first: Option<[f32; BANDS]> = None; for hz in [43.65, 65.41, 130.81] { let (mut e, _) = engine(uncompressed()); render(&mut e, &saw(hz, 1., a)); let f = e.take_analyzer(); assert!((f.hz - hz).abs() < 0.5, "{hz}: tracked {}", f.hz); assert!((f.edges[0] - 2f32.sqrt() * hz).abs() < 1., "{:?}", f.edges); assert!((f.edges[1] - 20f32.sqrt() * hz).abs() < 2., "{:?}", f.edges); assert!( (f.levels[0] - fundamental).abs() < 2.5, "{hz}: {:?}", f.levels ); let levels = *first.get_or_insert(f.levels); // The top band also holds the saw's own roll-off near Nyquist, which is not harmonic. for (b, tolerance) in [0.4, 0.4, 0.4, 1.].into_iter().enumerate() { let d = f.levels[b] - levels[b]; assert!(d.abs() < tolerance, "{hz}: {:?} vs {levels:?}", f.levels); } } } #[test] fn the_same_setting_changes_the_same_harmonics_on_every_note() { let s = Settings { depth: 100., ..Settings::default() }; let notes = [43.65, 65.41, 98.]; let mut changes = Vec::new(); for hz in notes { let x = saw(hz, 1.5, 0.5); let (mut e, l) = engine(s); let y = render(&mut e, &x); let a = harmonics(&x[..x.len() - l], hz); let b = harmonics(&y[l..], hz); changes.push((0..12).map(|k| b[k] - a[k]).collect::>()); } // Compression must actually happen for the comparison to mean anything. assert!(changes[0][0] < -6., "{:?}", changes[0]); for (n, c) in changes.iter().enumerate().skip(1) { for k in 0..12 { let d = c[k] - changes[0][k]; assert!( d.abs() < 1.5, "note {n} harmonic {}: {c:?} vs {:?}", k + 1, changes[0] ); } } } #[test] fn upward_lifts_a_quiet_band_and_downward_pushes_a_loud_one() { // Loud fundamental in band 1, a quiet 8th harmonic in band 3. let hz = 55.; let x = tones(hz, 1.5, &[(1, 0.7), (8, 0.003)]); let a = harmonics(&x, hz); let run = |upward: f32, downward: f32| { let s = Settings { depth: 100., upward, downward, ..Settings::default() }; let (mut e, l) = engine(s); let y = render(&mut e, &x); let b = harmonics(&y[l..], hz); [b[0] - a[0], b[7] - a[7]] }; let [body, top] = run(100., 0.); assert!(body.abs() < 0.5 && top > 5., "upward: {body:+.2} {top:+.2}"); let [body, top] = run(0., 100.); assert!( body < -10. && top.abs() < 1., "downward: {body:+.2} {top:+.2}" ); } /// Level in dB of harmonic `k` of `hz` in `x`, over whole periods (Goertzel). fn partial(x: &[f32], hz: f32, k: usize) -> f32 { let w = TAU as f64 * hz as f64 * k as f64 / RATE as f64; let (mut re, mut im) = (0f64, 0f64); for (i, &v) in x.iter().enumerate() { re += v as f64 * (w * i as f64).cos(); im += v as f64 * (w * i as f64).sin(); } let amp = 2. * (re * re + im * im).sqrt() / x.len() as f64; 20. * (amp + 1e-12).log10() as f32 } #[test] fn heavy_compression_does_not_ripple_at_the_fundamental() { // A per-period level is steady on a steady note, so even fast, hard compression adds no // harmonics to a sine. 40 Hz has a period of exactly 1200 samples; Snap is off so the // window is the measured period, not the nearest semitone's. let hz = 40.; let x = sine(hz, 1.5, 0.5); let distortion = |depth: f32| { let s = Settings { depth, drive: 12., time: 10., snap: false, ..Settings::default() }; let (mut e, _) = engine(s); let y = render(&mut e, &x); let tail = &y[y.len() - 24000..]; let fundamental = partial(tail, hz, 1); (2..6) .map(|k| partial(tail, hz, k) - fundamental) .fold(f32::MIN, f32::max) }; let (dry, squashed) = (distortion(0.), distortion(100.)); assert!(squashed < -80., "{squashed:.1} dB (dry {dry:.1} dB)"); } #[test] fn silence_and_noise_floors_are_not_lifted() { assert_eq!(compressor_gain(-120., 1., 1.), 0.); assert!(compressor_gain(-60., 1., 1.) > 10.); let s = Settings { depth: 100., ..Settings::default() }; let x = saw(55., 1., 2e-5); let (mut e, l) = engine(s); let y = render(&mut e, &x); let tail = y.len() / 2; let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]); assert!(d < 2., "{d:+.2} dB"); } #[test] fn bypass_passes_the_delayed_input_exactly() { let s = Settings { depth: 100., bypass: true, ..Settings::default() }; let x = saw(55., 0.5, 0.5); let (mut e, l) = engine(s); let y = render(&mut e, &x); for i in l..x.len() { assert_eq!(y[i], x[i - l]); } } #[test] fn note_changes_do_not_click() { let s = Settings { depth: 100., ..Settings::default() }; let mut x = saw(43.65, 0.5, 0.5); x.extend(saw(58.27, 0.5, 0.5)); x.extend(saw(87.31, 0.5, 0.5)); let (mut e, _) = engine(s); let y = render(&mut e, &x); let step = y .windows(2) .map(|w| (w[1] - w[0]).abs()) .fold(0f32, f32::max); let in_step = x .windows(2) .map(|w| (w[1] - w[0]).abs()) .fold(0f32, f32::max); assert!(y.iter().all(|v| v.is_finite())); assert!(step < 2. * in_step, "{step} vs {in_step}"); } #[test] fn midi_source_moves_the_edges_at_the_delayed_note_time() { let s = Settings { source: Source::Midi, ..uncompressed() }; let (mut e, l) = engine(s); // 43.65 Hz is MIDI 29. e.note_on(29); render(&mut e, &saw(43.65, l as f32 / RATE - 0.002, 0.5)); assert_eq!(e.take_analyzer().hz, 0.); render(&mut e, &saw(43.65, 0.01, 0.5)); let f = e.take_analyzer(); assert_eq!(f.note, Some(29)); assert!( (f.edges[0] - 2f32.sqrt() * 43.65).abs() < 0.2, "{:?}", f.edges ); } #[test] fn edges_in_the_wrong_order_are_pushed_up() { let s = Settings { low: 4, mid: 3, high: 3, ..Settings::default() }; assert!(s.validate().is_ok()); assert_eq!(s.tops(), [4, 5, 6]); assert_eq!(s.band_label(0), "1–4"); assert_eq!(s.band_label(1), "5"); assert_eq!(s.band_label(3), "7+"); let d = Settings::default(); let labels: Vec = (0..BANDS).map(|b| d.band_label(b)).collect(); assert_eq!(labels, ["1", "2–4", "5–16", "17+"]); } #[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); let mut bad = s; bad.gains[2] = 40.; assert!(bad.validate().is_err()); bad = s; bad.low = 0; assert!(bad.validate().is_err()); bad = s; bad.time = f32::NAN; assert!(bad.validate().is_err()); let mut e = Engine::new(RATE, 2).unwrap(); assert!(e.set_settings(bad).is_err()); assert_eq!(e.settings(), s); }