KWeighting is one channel of the standard's pre-filter and RLB high-pass, computed from the analog prototypes so every sample rate is exact. lufs() turns a mean square summed over channels into LUFS. A full-scale 997 Hz sine reads -3.01 LUFS within 0.03 LU from 44.1 to 192 kHz.
109 lines
3.4 KiB
Rust
109 lines
3.4 KiB
Rust
use common_dsp::{hz_to_midi, midi_to_hz, note_name, wav, Crossover, Saw, Yin};
|
||
|
||
fn cents(a: f32, b: f32) -> f32 {
|
||
1200. * (a / b).log2()
|
||
}
|
||
|
||
#[test]
|
||
fn yin_tracks_bass_fundamentals_within_two_cents() {
|
||
let rate = 48000.;
|
||
let mut yin = Yin::new(2400);
|
||
for hz in [43.65, 55., 110., 220.] {
|
||
let mut saw = Saw::new(rate, hz);
|
||
let x: Vec<f32> = (0..6000).map(|_| saw.sample()).collect();
|
||
let (got, clarity) = yin.estimate(&x, rate, 25., 1000.).unwrap();
|
||
assert!(cents(got, hz).abs() < 2., "{hz}: {got}");
|
||
assert!(clarity > 0.8);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn yin_rejects_silence_and_noise() {
|
||
let mut yin = Yin::new(2400);
|
||
assert!(yin.estimate(&[0.; 6000], 48000., 25., 1000.).is_none());
|
||
let mut seed = 1u32;
|
||
let noise: Vec<f32> = (0..6000)
|
||
.map(|_| {
|
||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||
(seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
|
||
})
|
||
.collect();
|
||
assert!(yin.estimate(&noise, 48000., 25., 1000.).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn crossover_bands_sum_to_flat_magnitude() {
|
||
let rate = 48000.;
|
||
for hz in [20., 45., 90., 180., 1000., 8000., 18000.] {
|
||
let mut x = Crossover::new(rate, 90.);
|
||
let (mut a, mut b) = (0f64, 0f64);
|
||
let n = 96000;
|
||
for i in 0..n {
|
||
let s = (std::f32::consts::TAU * hz * i as f32 / rate).sin();
|
||
let (l, h) = x.process(s);
|
||
if i > n / 2 {
|
||
a += (s as f64).powi(2);
|
||
b += ((l + h) as f64).powi(2);
|
||
}
|
||
}
|
||
let db = 10. * (b / a).log10();
|
||
assert!(db.abs() < 0.1, "{hz} Hz: {db} dB");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn notes_round_trip() {
|
||
assert!((midi_to_hz(29.) - 43.65).abs() < 0.01);
|
||
assert!((hz_to_midi(440.) - 69.).abs() < 1e-4);
|
||
assert_eq!(note_name(29), "F1");
|
||
assert_eq!(note_name(61), "C#4");
|
||
}
|
||
|
||
#[test]
|
||
fn wave_round_trip_keeps_root_and_wavetable_chunks() {
|
||
let w = wav::Wave {
|
||
channels: 2,
|
||
rate: 44100,
|
||
samples: (0..999)
|
||
.map(|i| (i as f32 * 0.01).sin())
|
||
.collect::<Vec<_>>()[..998]
|
||
.to_vec(),
|
||
root: Some(29),
|
||
frame: Some(2048),
|
||
};
|
||
let bytes = wav::encode(&w);
|
||
assert_eq!(&bytes[..4], b"RIFF");
|
||
assert_eq!(
|
||
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
|
||
bytes.len() - 8
|
||
);
|
||
assert_eq!(wav::decode(&bytes).unwrap(), w);
|
||
assert!(wav::decode(&bytes[..40]).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn k_weighting_reads_a_full_scale_997_hz_sine_at_minus_3_lufs() {
|
||
use common_dsp::loudness::{lufs, KWeighting};
|
||
let level = |rate: f32, hz: f64| {
|
||
let mut k = KWeighting::new(rate);
|
||
let n = rate as usize;
|
||
let mut sum = 0f64;
|
||
for i in 0..2 * n {
|
||
let x = (std::f64::consts::TAU * hz * i as f64 / rate as f64).sin() as f32;
|
||
let y = k.process(x) as f64;
|
||
if i >= n {
|
||
sum += y * y;
|
||
}
|
||
}
|
||
lufs(sum / n as f64)
|
||
};
|
||
// BS.1770-4: a 0 dBFS 997 Hz sine in one channel reads −3.01 LKFS, at every rate.
|
||
for rate in [44100., 48000., 96000., 192000.] {
|
||
let got = level(rate, 997.);
|
||
// BS.1770 allows meters 0.1 LU; the prototypes land within 0.03 up to 192 kHz.
|
||
assert!((got + 3.01).abs() < 0.05, "{rate}: {got}");
|
||
}
|
||
// The RLB high-pass counts a 30 Hz sub much less than the mids.
|
||
assert!(level(48000., 30.) < level(48000., 997.) - 5.);
|
||
}
|