Files
nvrl d77b88c7a9 feat(common-dsp): add ITU-R BS.1770 K-weighting
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.
2026-11-06 16:05:19 +01:00

109 lines
3.4 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.);
}