use anvil_core::{Engine, Monitor, Settings, Source}; use common_dsp::Saw; const RATE: f32 = 48000.; /// Saw notes (start s, end s, Hz, peak) with 1 ms fades. fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec { let mut x = vec![0.; (total * RATE) as usize]; for &(a, b, hz, amp) in list { let (a, b) = ((a * RATE) as usize, (b * RATE) as usize); let mut saw = Saw::new(RATE, hz); let fade = (0.001 * RATE) as usize; for i in a..b.min(x.len()) { let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.); x[i] += saw.sample() * amp * edge; } } x } fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec { x.iter() .map(|&s| e.process_sample([s, s], Monitor { solo })[0]) .collect() } fn rms(x: &[f32]) -> f32 { (x.iter().map(|v| v * v).sum::() / x.len() as f32).sqrt() } fn db(x: f32) -> f32 { 20. * x.max(1e-9).log10() } /// Frequency from interpolated rising zero crossings. fn frequency(x: &[f32]) -> f32 { let mut first = None; let mut last = 0.; let mut count = 0; for i in 1..x.len() { if x[i - 1] < 0. && x[i] >= 0. { let t = (i - 1) as f32 + x[i - 1] / (x[i - 1] - x[i]); if first.is_none() { first = Some(t); } else { count += 1; } last = t; } } count as f32 * RATE / (last - first.unwrap()) } fn seconds(t: f32) -> usize { (t * RATE) as usize } 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) } #[test] fn every_note_gets_the_same_sub_level() { let (mut e, l) = engine(Settings::default()); let x = notes(&[(0.1, 0.6, 55., 0.5), (0.8, 1.3, 55., 0.1)], 1.6); let y = render(&mut e, &x, true); let loud = rms(&y[seconds(0.3) + l..seconds(0.5) + l]); let quiet = rms(&y[seconds(1.0) + l..seconds(1.2) + l]); assert!( (db(loud) - db(quiet)).abs() < 0.5, "{} vs {}", db(loud), db(quiet) ); // −9 dBFS peak sine. assert!((db(loud * 2f32.sqrt()) + 9.).abs() < 0.5, "{}", db(loud)); } #[test] fn sub_follows_the_detected_pitch_and_octave() { for (octave, expect) in [(0, 43.654), (-1, 21.827)] { let (mut e, l) = engine(Settings { octave, ..Settings::default() }); let x = notes(&[(0.1, 1.1, 43.65, 0.5)], 1.3); let y = render(&mut e, &x, true); let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]); assert!((f / expect - 1.).abs() < 0.005, "octave {octave}: {f}"); } } #[test] fn a_pitch_change_without_a_gap_is_a_new_note() { let (mut e, l) = engine(Settings::default()); let x = notes(&[(0.1, 0.6, 55., 0.5), (0.6, 1.1, 82.41, 0.5)], 1.3); let y = render(&mut e, &x, true); let f = frequency(&y[seconds(0.75) + l..seconds(1.05) + l]); assert!((f / 82.41 - 1.).abs() < 0.005, "{f}"); } #[test] fn every_note_restarts_at_the_start_phase() { let (mut e, l) = engine(Settings { phase: 90., ..Settings::default() }); let x = notes(&[(0.1, 0.4, 55., 0.5), (0.6, 0.9, 55., 0.3)], 1.1); let y = render(&mut e, &x, true); let period = (RATE / 55.) as usize; let a = &y[seconds(0.1) + l..seconds(0.1) + l + 2 * period]; let b = &y[seconds(0.6) + l..seconds(0.6) + l + 2 * period]; let peak = y.iter().fold(0f32, |m, v| m.max(v.abs())); let worst = a.iter().zip(b).fold(0f32, |m, (p, q)| m.max((p - q).abs())); assert!(worst < 0.1 * peak, "{worst} of {peak}"); // Fit the phase over the second period, after the 2 ms attack. let w = std::f32::consts::TAU * 55. / RATE; let (mut s, mut c) = (0., 0.); for (k, v) in a.iter().enumerate().skip(period) { s += v * (w * k as f32).sin(); c += v * (w * k as f32).cos(); } let phase = c.atan2(s).to_degrees(); assert!((phase - 90.).abs() < 20., "{phase}"); } #[test] fn midi_notes_set_pitch_and_are_delayed_by_the_latency() { let (mut e, l) = engine(Settings { source: Source::Midi, ..Settings::default() }); let mut y = Vec::new(); for i in 0..seconds(0.8) { if i == 1000 { e.note_on(33); } if i == seconds(0.6) { e.note_off(33); } y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]); } assert!(y[..1000 + l].iter().all(|v| *v == 0.)); assert!(y[1000 + l + 50].abs() > 0.); let f = frequency(&y[seconds(0.2) + l..seconds(0.5)]); assert!((f / 55. - 1.).abs() < 0.001, "{f}"); assert!(y[seconds(0.6) + l + seconds(0.05)..] .iter() .all(|v| v.abs() < 1e-6)); } #[test] fn note_changes_do_not_click() { let (mut e, _) = engine(Settings::default()); let x = notes( &[ (0.1, 0.4, 55., 0.5), (0.4, 0.7, 73.42, 0.5), (0.7, 1.0, 41.2, 0.5), ], 1.3, ); let y = render(&mut e, &x, true); let step = y.windows(2).fold(0f32, |m, w| m.max((w[1] - w[0]).abs())); // A −9 dBFS sine at 73 Hz moves at most 0.0034 per sample. assert!(step < 0.012, "{step}"); } #[test] fn keep_without_notes_is_an_allpass() { let (mut e, _) = engine(Settings { source: Source::Midi, keep: 100., ..Settings::default() }); for hz in [40., 90., 300., 3000.] { e.reset(); let x: Vec = (0..seconds(1.)) .map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / RATE).sin()) .collect(); let y = render(&mut e, &x, false); let d = db(rms(&y[seconds(0.5)..])) - db(rms(&x[seconds(0.5)..])); assert!(d.abs() < 0.1, "{hz}: {d}"); } } #[test] fn split_removes_original_sub_and_keeps_mids() { let (mut e, l) = engine(Settings { source: Source::Midi, ..Settings::default() }); let x: Vec = (0..seconds(1.)) .map(|i| 0.3 * (std::f32::consts::TAU * 40. * i as f32 / RATE).sin()) .collect(); let y = render(&mut e, &x, false); assert!(db(rms(&y[seconds(0.5) + l..])) < db(0.3) - 18.); } #[test] fn settings_are_validated_and_serialise() { let mut e = Engine::new(RATE, 1).unwrap(); assert!(e .set_settings(Settings { level: 3., ..Settings::default() }) .is_err()); let json = serde_json::to_string(&Settings::default()).unwrap(); assert_eq!( serde_json::from_str::(&json).unwrap(), Settings::default() ); assert!(Engine::new(1000., 2).is_err()); assert!(Engine::new(48000., 3).is_err()); }