#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Bs2051ChannelGroup {
TopLayer,
MidLayer,
BottomLayer,
Lfe,
}
impl Bs2051ChannelGroup {
#[must_use]
pub fn weight(self) -> f32 {
match self {
Self::TopLayer => 0.707,
Self::MidLayer => 1.000,
Self::BottomLayer => 0.707,
Self::Lfe => 0.000,
}
}
}
#[derive(Clone, Debug)]
pub struct Bs2051Weights {
pub channel_groups: Vec<Bs2051ChannelGroup>,
}
impl Bs2051Weights {
#[must_use]
pub fn nhk_22_2() -> Self {
let mut groups = Vec::with_capacity(24);
for _ in 0..9 {
groups.push(Bs2051ChannelGroup::TopLayer);
}
for _ in 0..10 {
groups.push(Bs2051ChannelGroup::MidLayer);
}
for _ in 0..3 {
groups.push(Bs2051ChannelGroup::BottomLayer);
}
for _ in 0..2 {
groups.push(Bs2051ChannelGroup::Lfe);
}
Self {
channel_groups: groups,
}
}
#[must_use]
pub fn from_groups(channel_groups: Vec<Bs2051ChannelGroup>) -> Self {
Self { channel_groups }
}
#[must_use]
pub fn weight_for(&self, channel: usize) -> Option<f32> {
self.channel_groups.get(channel).map(|g| g.weight())
}
#[must_use]
pub fn num_channels(&self) -> usize {
self.channel_groups.len()
}
}
fn rms_to_lkfs(samples: &[f32]) -> f32 {
if samples.is_empty() {
return f32::NEG_INFINITY;
}
let mean_sq: f32 = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
if mean_sq <= 0.0 {
f32::NEG_INFINITY
} else {
-0.691 + 10.0 * mean_sq.log10()
}
}
#[must_use]
pub fn compute_integrated_loudness_bs2051(channels: &[Vec<f32>], layout: &Bs2051Weights) -> f32 {
if layout.num_channels() == 0 {
return f32::NEG_INFINITY;
}
let mut weighted_sum = 0.0_f32;
let mut weight_total = 0.0_f32;
for (ch_idx, group) in layout.channel_groups.iter().enumerate() {
let weight = group.weight();
if weight == 0.0 {
continue;
}
let mean_sq = if let Some(samples) = channels.get(ch_idx) {
if samples.is_empty() {
0.0_f32
} else {
samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32
}
} else {
0.0_f32
};
if mean_sq > 0.0 {
weighted_sum += weight * mean_sq;
weight_total = weight_total.max(weight); }
}
if weighted_sum <= 0.0 || weight_total == 0.0 {
return f32::NEG_INFINITY;
}
-0.691 + 10.0 * weighted_sum.log10()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_channel_group_weights() {
assert!((Bs2051ChannelGroup::TopLayer.weight() - 0.707).abs() < 1e-6);
assert!((Bs2051ChannelGroup::MidLayer.weight() - 1.000).abs() < 1e-6);
assert!((Bs2051ChannelGroup::BottomLayer.weight() - 0.707).abs() < 1e-6);
assert_eq!(Bs2051ChannelGroup::Lfe.weight(), 0.0);
}
#[test]
fn test_nhk_22_2_channel_count() {
let weights = Bs2051Weights::nhk_22_2();
assert_eq!(weights.num_channels(), 24);
}
#[test]
fn test_nhk_22_2_top_layer_channels() {
let weights = Bs2051Weights::nhk_22_2();
for i in 0..9 {
assert_eq!(
weights.channel_groups[i],
Bs2051ChannelGroup::TopLayer,
"channel {i} should be TopLayer"
);
}
}
#[test]
fn test_nhk_22_2_mid_layer_channels() {
let weights = Bs2051Weights::nhk_22_2();
for i in 9..19 {
assert_eq!(
weights.channel_groups[i],
Bs2051ChannelGroup::MidLayer,
"channel {i} should be MidLayer"
);
}
}
#[test]
fn test_nhk_22_2_bottom_layer_channels() {
let weights = Bs2051Weights::nhk_22_2();
for i in 19..22 {
assert_eq!(
weights.channel_groups[i],
Bs2051ChannelGroup::BottomLayer,
"channel {i} should be BottomLayer"
);
}
}
#[test]
fn test_nhk_22_2_lfe_channels() {
let weights = Bs2051Weights::nhk_22_2();
assert_eq!(weights.channel_groups[22], Bs2051ChannelGroup::Lfe);
assert_eq!(weights.channel_groups[23], Bs2051ChannelGroup::Lfe);
}
#[test]
fn test_weight_for_out_of_range() {
let weights = Bs2051Weights::nhk_22_2();
assert!(weights.weight_for(24).is_none());
assert!(weights.weight_for(100).is_none());
}
#[test]
fn test_weight_for_in_range() {
let weights = Bs2051Weights::nhk_22_2();
let w = weights.weight_for(0).expect("channel 0 should exist");
assert!((w - 0.707).abs() < 1e-6);
let w9 = weights.weight_for(9).expect("channel 9 should exist");
assert!((w9 - 1.0).abs() < 1e-6);
}
#[test]
fn test_loudness_empty_layout() {
let layout = Bs2051Weights::from_groups(vec![]);
let result = compute_integrated_loudness_bs2051(&[], &layout);
assert_eq!(result, f32::NEG_INFINITY);
}
#[test]
fn test_loudness_silent_channels() {
let layout = Bs2051Weights::nhk_22_2();
let silent: Vec<Vec<f32>> = (0..24).map(|_| vec![0.0f32; 1000]).collect();
let result = compute_integrated_loudness_bs2051(&silent, &layout);
assert_eq!(result, f32::NEG_INFINITY);
}
#[test]
fn test_loudness_mid_layer_only_matches_rms_lkfs() {
let layout = Bs2051Weights::from_groups(vec![Bs2051ChannelGroup::MidLayer]);
let samples: Vec<f32> = (0..48000)
.map(|i| 0.5 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48000.0).sin())
.collect();
let expected_lkfs = rms_to_lkfs(&samples);
let result = compute_integrated_loudness_bs2051(&[samples], &layout);
assert!(
(result - expected_lkfs).abs() < 0.5,
"Expected ≈ {expected_lkfs:.2} LKFS, got {result:.2}"
);
}
#[test]
fn test_lfe_channels_excluded() {
let layout =
Bs2051Weights::from_groups(vec![Bs2051ChannelGroup::Lfe, Bs2051ChannelGroup::Lfe]);
let loud: Vec<Vec<f32>> = (0..2).map(|_| vec![1.0f32; 1000]).collect();
let result = compute_integrated_loudness_bs2051(&loud, &layout);
assert_eq!(result, f32::NEG_INFINITY);
}
#[test]
fn test_loudness_finite_for_non_silent_signal() {
let layout = Bs2051Weights::nhk_22_2();
let mut channels: Vec<Vec<f32>> = vec![vec![0.0f32; 100]; 24];
for (i, ch) in channels.iter_mut().enumerate() {
if i < 22 {
for s in ch.iter_mut() {
*s = 0.3;
}
}
}
let result = compute_integrated_loudness_bs2051(&channels, &layout);
assert!(result.is_finite(), "Expected finite LKFS, got {result}");
}
#[test]
fn test_loudness_top_weighted_less_than_mid() {
let top_layout = Bs2051Weights::from_groups(vec![Bs2051ChannelGroup::TopLayer]);
let mid_layout = Bs2051Weights::from_groups(vec![Bs2051ChannelGroup::MidLayer]);
let signal = vec![0.5f32; 48000];
let top_loudness =
compute_integrated_loudness_bs2051(std::slice::from_ref(&signal), &top_layout);
let mid_loudness = compute_integrated_loudness_bs2051(&[signal], &mid_layout);
assert!(
top_loudness < mid_loudness,
"Top ({top_loudness:.2}) should be quieter than mid ({mid_loudness:.2})"
);
}
}