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, ..Monitor::default() }, )[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, ..Monitor::default() }, )[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()); } /// Peak amplitude of the component at `hz`, over a window of whole cycles. fn amplitude(x: &[f32], hz: f32) -> f32 { let w = std::f32::consts::TAU * hz / RATE; let (re, im) = x.iter().enumerate().fold((0., 0.), |(re, im), (n, v)| { (re + v * (w * n as f32).cos(), im - v * (w * n as f32).sin()) }); 2. * (re * re + im * im).sqrt() / x.len() as f32 } /// Sines (Hz, peak) from 0.1 s to `total` − 0.1 s, with 1 ms fades. fn tones(list: &[(f32, f32)], total: f32) -> Vec { let n = (total * RATE) as usize; let (a, b, fade) = (seconds(0.1), n - seconds(0.1), seconds(0.001)); (0..n) .map(|i| { if i < a || i >= b { return 0.; } let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.); let t = (i - a) as f32 / RATE; edge * list .iter() .map(|&(hz, amp)| amp * (std::f32::consts::TAU * hz * t).sin()) .sum::() }) .collect() } fn ghost(amount: f32) -> Settings { Settings { ghost: amount, ghost_from: 300., ..Settings::default() } } #[test] fn ghost_fills_the_harmonics_a_clean_sub_lacks() { let (mut e, l) = engine(ghost(100.)); let x = tones(&[(55., 0.5)], 1.); let y = render(&mut e, &x, true); // 0.2 s holds exactly 11 cycles of 55 Hz. let w = &y[seconds(0.5) + l..seconds(0.7) + l]; let sub = amplitude(w, 55.); assert!((db(sub) + 9.).abs() < 0.5, "{}", db(sub)); for k in 2..=5 { // Below Ghost from: nothing added, so the sub stays a clean sine there. let a = amplitude(w, 55. * k as f32); assert!(db(a / sub) < -60., "harmonic {k}: {}", db(a / sub)); } for k in 8..=10 { let a = amplitude(w, 55. * k as f32); let expect = 20. * (2. / k as f32).log10(); assert!( (db(a / sub) - expect).abs() < 1., "harmonic {k}: {}", db(a / sub) ); } // Harmonic 6 (330 Hz) sits in the fade above 300 Hz: present, but quieter. let six = db(amplitude(w, 330.) / sub); assert!(six < 20. * (2f32 / 6.).log10() - 3. && six > -40., "{six}"); } #[test] fn ghost_leaves_harmonics_the_patch_already_has() { let (mut e, l) = engine(ghost(100.)); // The patch carries its 8th harmonic (440 Hz) louder than Ghost would add it. let x = tones(&[(55., 0.5), (440., 0.2)], 1.); let y = render(&mut e, &x, false); let w = &y[seconds(0.5) + l..seconds(0.7) + l]; let eighth = amplitude(w, 440.); assert!((db(eighth) - db(0.2)).abs() < 0.5, "{}", db(eighth)); // The 9th is missing from the patch, so Ghost fills it. let ninth = amplitude(w, 495.) / amplitude(w, 55.); assert!( (db(ninth) - 20. * (2f32 / 9.).log10()).abs() < 1., "{}", db(ninth) ); } #[test] fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() { let x = tones(&[(55., 0.5)], 1.); let (mut e, l) = engine(ghost(0.)); let y = render(&mut e, &x, true); let w = &y[seconds(0.5) + l..seconds(0.7) + l]; for k in 2..=10 { assert!(amplitude(w, 55. * k as f32) < 1e-4, "harmonic {k}"); } let phone = |amount: f32| { let (mut e, l) = engine(ghost(amount)); let y: Vec = x .iter() .map(|&s| { e.process_sample( [s, s], Monitor { solo: true, phone: true, audition: false, }, )[0] }) .collect(); rms(&y[seconds(0.5) + l..seconds(0.7) + l]) }; // A clean 55 Hz sub all but vanishes on a phone; Ghost makes the note heard. let (plain, ghosted) = (phone(0.), phone(100.)); assert!(db(plain) < -50., "{}", db(plain)); assert!( db(ghosted) - db(plain) > 25., "{} vs {}", db(ghosted), db(plain) ); } /// A kick-like burst: a 55 Hz sine starting at phase 0 with a 60 ms decay, at each time. fn kicks(times: &[f32], total: f32) -> Vec { let mut k = vec![0.; seconds(total)]; for &t in times { for i in 0..seconds(0.2) { let n = seconds(t) + i; if n < k.len() { let t = i as f32 / RATE; k[n] += 0.8 * (std::f32::consts::TAU * 55. * t).sin() * (-t / 0.06).exp(); } } } k } fn render_kick(e: &mut Engine, x: &[f32], k: &[f32], monitor: Monitor) -> Vec { x.iter() .zip(k) .map(|(&s, &k)| e.process_with_kick([s, s], k, monitor)[0]) .collect() } /// Normalised correlation of `y[a..b]` with a sine of `hz` at phase 0 at sample `zero`. fn sine_match(y: &[f32], a: usize, b: usize, hz: f32, zero: f32) -> f32 { let w = std::f32::consts::TAU * hz / RATE; let (mut xy, mut xx, mut yy) = (0., 0., 0.); for (i, &v) in y.iter().enumerate().take(b).skip(a) { let r = (w * (i as f32 - zero)).sin(); xy += v * r; xx += r * r; yy += v * v; } xy / (xx * yy).sqrt() } const SOLO: Monitor = Monitor { solo: true, phone: false, audition: false, }; fn kick_lock(offset: f32) -> Settings { Settings { kick_lock: true, kick_offset: offset, ..Settings::default() } } #[test] fn a_kick_restarts_the_sub_at_the_start_phase_and_ducks_it() { // A bass note that started off the kick: before the hit, the sub runs at its own phase. let x = tones(&[(55., 0.5)], 1.4); let k = kicks(&[0.613], 1.4); let (mut e, l) = engine(kick_lock(0.)); let y = render_kick(&mut e, &x, &k, SOLO); let hit = seconds(0.613) + l; let after = sine_match( &y, hit + seconds(0.004), hit + seconds(0.04), 55., hit as f32, ); assert!(after > 0.99, "{after}"); let before = rms(&y[hit - seconds(0.1)..hit - seconds(0.01)]); let ducked = rms(&y[hit + seconds(0.004)..hit + seconds(0.022)]); let back = rms(&y[hit + seconds(0.4)..hit + seconds(0.5)]); assert!( db(before) - db(ducked) > 2., "{} vs {}", db(before), db(ducked) ); assert!( (db(back) - db(before)).abs() < 0.3, "{} vs {}", db(back), db(before) ); assert_eq!(e.take_analyzer().kick_hits, 1); } #[test] fn the_offset_restarts_the_sub_before_or_after_the_kick() { let x = tones(&[(55., 0.5)], 1.2); let k = kicks(&[0.613], 1.2); for offset in [-10., 7.] { // No duck: only the phase is checked here. let (mut e, l) = engine(Settings { kick_duck: 0., ..kick_lock(offset) }); let y = render_kick(&mut e, &x, &k, SOLO); let start = (seconds(0.613) + l) as f32 + offset * 0.001 * RATE; let a = start as usize + seconds(0.004); let m = sine_match(&y, a, a + seconds(0.03), 55., start); assert!(m > 0.99, "{offset}: {m}"); } } #[test] fn without_kick_lock_the_sidechain_changes_nothing() { let x = tones(&[(55., 0.5)], 1.); let k = kicks(&[0.3, 0.6], 1.); let (mut a, _) = engine(Settings::default()); let (mut b, _) = engine(Settings::default()); let with = render_kick(&mut a, &x, &k, SOLO); let without = render(&mut b, &x, true); assert_eq!(with, without); } #[test] fn the_audition_finds_where_kick_and_sub_sum() { use anvil_core::{AUDITION, AUDITION_HITS}; let hits = AUDITION.len() * AUDITION_HITS as usize; let total = 0.5 + hits as f32 * 0.2 + 0.4; let x = tones(&[(55., 0.5)], total); let times: Vec = (0..hits).map(|i| 0.5 + i as f32 * 0.2).collect(); let k = kicks(×, total); let (mut e, _) = engine(kick_lock(0.)); let monitor = Monitor { audition: true, ..SOLO }; render_kick(&mut e, &x, &k, monitor); let f = e.take_analyzer(); assert_eq!(f.kick_hits as usize, hits); let sums = f.audition_sums; assert!(sums.iter().all(|v| v.is_finite()), "{sums:?}"); // The kick is a 55 Hz burst at phase 0, like the sub: they sum fully at 0 ms and // cancel half a period (9 ms) away. let best = (0..sums.len()) .max_by(|&a, &b| sums[a].total_cmp(&sums[b])) .unwrap(); assert_eq!(AUDITION[best], 0., "{sums:?}"); let zero = sums[6]; assert!(zero > 2., "{zero}"); for i in [4, 8] { assert!( zero - sums[i] > 4., "{} ms: {} vs {zero}", AUDITION[i], sums[i] ); } }