SvfCoef::allpass has the phase of a Linkwitz-Riley split at the same frequency, so a crossover tree can keep its lower bands in phase with the upper ones. The test checks it against a real LR4 split sample by sample and for flat magnitude.
251 lines
7.3 KiB
Rust
251 lines
7.3 KiB
Rust
use common_dsp::meter::HarmonicMeter;
|
||
use common_dsp::oversample::Oversampler;
|
||
use common_dsp::svf::{Svf, SvfCoef};
|
||
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
|
||
use common_dsp::{hz_to_midi, Saw};
|
||
use std::f32::consts::TAU;
|
||
|
||
const RATE: f32 = 48000.;
|
||
|
||
/// Steady-state gain in dB of a sine at `hz` through `f`.
|
||
fn sine_gain(mut f: impl FnMut(f32) -> f32, hz: f32) -> f32 {
|
||
let n = (RATE * 0.5) as usize;
|
||
let (mut pin, mut pout) = (0., 0.);
|
||
for i in 0..n {
|
||
let x = (TAU * hz * i as f32 / RATE).sin();
|
||
let y = f(x);
|
||
if i > n / 2 {
|
||
pin += x * x;
|
||
pout += y * y;
|
||
}
|
||
}
|
||
10. * (pout / pin).log10()
|
||
}
|
||
|
||
#[test]
|
||
fn svf_bell_hits_its_gain_and_leaves_the_rest() {
|
||
for (hz, db) in [(130., -12.), (400., 6.), (2000., -24.)] {
|
||
let c = SvfCoef::bell(RATE, hz, 4., db);
|
||
let mut s = Svf::default();
|
||
let g = sine_gain(|x| s.process(&c, x), hz);
|
||
assert!((g - db).abs() < 0.1, "{hz}: {g}");
|
||
let mut s = Svf::default();
|
||
let g = sine_gain(|x| s.process(&c, x), hz * 4.);
|
||
assert!(g.abs() < 0.3, "{hz} away: {g}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn svf_bell_at_zero_db_is_an_exact_identity() {
|
||
let c = SvfCoef::bell(RATE, 100., 2., 0.);
|
||
let mut s = Svf::default();
|
||
let mut saw = Saw::new(RATE, 55.);
|
||
for _ in 0..4800 {
|
||
let x = saw.sample();
|
||
assert_eq!(s.process(&c, x), x);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn svf_allpass_matches_a_linkwitz_riley_split() {
|
||
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||
let (low, high, all) = (
|
||
SvfCoef::lowpass(RATE, 300., q),
|
||
SvfCoef::highpass(RATE, 300., q),
|
||
SvfCoef::allpass(RATE, 300., q),
|
||
);
|
||
let mut s = [Svf::default(); 5];
|
||
let mut saw = Saw::new(RATE, 55.);
|
||
for _ in 0..9600 {
|
||
let x = saw.sample();
|
||
let l = s[0].process(&low, x);
|
||
let l = s[1].process(&low, l);
|
||
let h = s[2].process(&high, x);
|
||
let h = s[3].process(&high, h);
|
||
let a = s[4].process(&all, x);
|
||
assert!((l + h - a).abs() < 1e-4, "{} vs {a}", l + h);
|
||
}
|
||
let mut s = Svf::default();
|
||
for hz in [30., 300., 3000.] {
|
||
assert!(sine_gain(|x| s.process(&all, x), hz).abs() < 0.01);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn svf_survives_retuning_every_sample() {
|
||
let mut s = Svf::default();
|
||
let mut saw = Saw::new(RATE, 55.);
|
||
let mut peak: f32 = 0.;
|
||
for i in 0..48000 {
|
||
let hz = 40. + 4000. * (0.5 + 0.5 * (i as f32 * 0.003).sin());
|
||
let c = SvfCoef::bell(RATE, hz, 8., 12.);
|
||
let y = s.process(&c, saw.sample());
|
||
assert!(y.is_finite());
|
||
peak = peak.max(y.abs());
|
||
}
|
||
assert!(peak < 8., "{peak}");
|
||
let c = SvfCoef::lowpass(RATE, 200., 0.707);
|
||
let mut s = Svf::default();
|
||
assert!(sine_gain(|x| s.process(&c, x), 2000.) < -35.);
|
||
let c = SvfCoef::highpass(RATE, 200., 0.707);
|
||
let mut s = Svf::default();
|
||
assert!(sine_gain(|x| s.process(&c, x), 20.) < -35.);
|
||
}
|
||
|
||
#[test]
|
||
fn oversampler_round_trip_is_a_whole_sample_delay() {
|
||
let mut o = Oversampler::new();
|
||
let l = Oversampler::latency();
|
||
let mut saw = Saw::new(RATE, 97.);
|
||
let x: Vec<f32> = (0..9600).map(|_| saw.sample() * 0.5).collect();
|
||
let y: Vec<f32> = x
|
||
.iter()
|
||
.map(|&v| {
|
||
let u = o.up(v);
|
||
o.down(u)
|
||
})
|
||
.collect();
|
||
// Band-limited content passes; compare after the delay, away from the start.
|
||
let mut err: f32 = 0.;
|
||
let mut sig: f32 = 0.;
|
||
for i in 2000..9600 {
|
||
let d = y[i] - x[i - l];
|
||
err += d * d;
|
||
sig += x[i - l] * x[i - l];
|
||
}
|
||
let null = 10. * (err / sig).log10();
|
||
// The saw has energy above the passband edge, so the null is limited by that, not by timing.
|
||
assert!(null < -40., "{null}");
|
||
}
|
||
|
||
#[test]
|
||
fn oversampler_passband_is_flat_and_images_are_removed() {
|
||
for hz in [50., 1000., 10000., 18000.] {
|
||
let mut o = Oversampler::new();
|
||
let g = sine_gain(
|
||
|x| {
|
||
let u = o.up(x);
|
||
o.down(u)
|
||
},
|
||
hz,
|
||
);
|
||
assert!(g.abs() < 0.1, "{hz}: {g}");
|
||
}
|
||
// An image of a 1 kHz tone at 4× would sit at 47 kHz; check the 4× signal there.
|
||
let mut o = Oversampler::new();
|
||
let n = 48000;
|
||
let (mut a, mut b) = (0f64, 0f64);
|
||
let w = TAU as f64 * 47000. / (4. * RATE as f64);
|
||
for i in 0..n {
|
||
let x = (TAU * 1000. * i as f32 / RATE).sin();
|
||
for (k, v) in o.up(x).into_iter().enumerate() {
|
||
let t = (4 * i + k) as f64;
|
||
a += v as f64 * (w * t).cos();
|
||
b += v as f64 * (w * t).sin();
|
||
}
|
||
}
|
||
let image = 20.
|
||
* ((a * a + b * b).sqrt() / (2. * n as f64))
|
||
.max(1e-12)
|
||
.log10();
|
||
assert!(image < -85., "{image}");
|
||
}
|
||
|
||
#[test]
|
||
fn harmonic_meter_reads_saw_harmonics() {
|
||
let hz = 55.;
|
||
let mut m = HarmonicMeter::new(RATE, 85.);
|
||
let mut saw = Saw::new(RATE, hz);
|
||
let mut done = 0;
|
||
for _ in 0..48000 {
|
||
if m.push(saw.sample(), hz) {
|
||
done += 1;
|
||
}
|
||
}
|
||
assert!(done >= 5);
|
||
// A saw's k-th harmonic has peak 2/(πk).
|
||
for k in 1..=16 {
|
||
let want = 20. * (2. / (std::f32::consts::PI * k as f32)).log10();
|
||
let got = m.levels()[k - 1];
|
||
assert!((got - want).abs() < 0.5, "harmonic {k}: {got} vs {want}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tracker_reports_notes_at_output_time() {
|
||
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
|
||
t.set(false, -42., false);
|
||
let l = t.latency();
|
||
assert!(l < (0.08 * RATE) as usize, "{l}");
|
||
let onset = 4800;
|
||
let mut events = vec![];
|
||
let mut saw_a = Saw::new(RATE, 87.31);
|
||
let mut saw_b = Saw::new(RATE, 220.);
|
||
for i in 0..onset + 48000 {
|
||
let x = if i < onset {
|
||
0.
|
||
} else if i < onset + 24000 {
|
||
0.5 * saw_a.sample()
|
||
} else {
|
||
0.5 * saw_b.sample()
|
||
};
|
||
t.process(x, |e| events.push((i, e)));
|
||
}
|
||
let starts: Vec<_> = events
|
||
.iter()
|
||
.filter_map(|(i, e)| match e {
|
||
NoteEvent::Start { pitch, .. } => Some((*i, *pitch)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(starts.len(), 2, "{starts:?}");
|
||
assert!(
|
||
(starts[0].0 as i64 - (onset + l) as i64).abs() <= 24,
|
||
"{starts:?}"
|
||
);
|
||
assert!((starts[0].1 - hz_to_midi(87.31)).abs() < 0.2);
|
||
assert!((starts[1].1 - hz_to_midi(220.)).abs() < 0.2);
|
||
}
|
||
|
||
#[test]
|
||
fn tracker_delays_midi_by_the_latency() {
|
||
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
|
||
assert!(t.set(true, -42., true));
|
||
let l = t.latency();
|
||
let mut events = vec![];
|
||
for i in 0..l + 2000 {
|
||
if i == 100 {
|
||
t.note_on(41);
|
||
}
|
||
if i == 600 {
|
||
t.note_on(43);
|
||
}
|
||
if i == 900 {
|
||
// Releasing the lower held note first leaves the top note; then the top ends it.
|
||
t.note_off(41);
|
||
t.note_off(43);
|
||
}
|
||
t.process(0., |e| events.push((i, e)));
|
||
}
|
||
assert_eq!(
|
||
events,
|
||
vec![
|
||
(
|
||
100 + l,
|
||
NoteEvent::Start {
|
||
pitch: 41.,
|
||
glide: false
|
||
}
|
||
),
|
||
(
|
||
600 + l,
|
||
NoteEvent::Start {
|
||
pitch: 43.,
|
||
glide: true
|
||
}
|
||
),
|
||
(900 + l, NoteEvent::Stop),
|
||
]
|
||
);
|
||
}
|