#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::similar_names)]
#![allow(clippy::unreadable_literal)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::many_single_char_names)]
#![allow(clippy::if_same_then_else)]
#![allow(clippy::unused_self)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::fn_params_excessive_bools)]
#![allow(clippy::let_and_return)]
#![allow(clippy::match_same_arms)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::struct_excessive_bools)]
#![allow(clippy::format_push_string)]
#![allow(clippy::trivially_copy_pass_by_ref)]
#![allow(clippy::missing_panics_doc)]
#![allow(dead_code)]
#![allow(
clippy::float_cmp,
clippy::too_many_lines,
clippy::return_self_not_must_use
)]
pub mod atsc;
pub mod ballistics;
pub mod bs2051_weights;
pub mod bs2132;
pub mod clip_counter;
pub mod correlation;
pub mod dr_meter;
pub mod dynamics;
pub mod ebu;
pub mod ebu_r128_impl;
pub mod filters;
pub mod gating;
pub mod k_weighting;
pub mod leq;
pub mod lkfs;
pub mod loudness_gate;
pub mod loudness_history;
pub mod m_s_meter;
pub mod meter_type_config;
pub mod ms_ssim;
pub mod octave_bands;
pub mod peak;
pub mod phase;
pub mod phase_scope;
pub mod ppm;
pub mod range;
pub mod render;
pub mod report;
pub mod rms_envelope;
pub mod silence_detect;
pub mod spectral_balance;
pub mod spectral_energy;
pub mod spectrum;
pub mod spectrum_bands;
pub mod true_peak_meter;
pub mod truepeak;
pub mod video_color;
pub mod video_luminance;
pub mod video_quality;
pub mod vmaf_estimate;
pub mod vmaf_features;
pub mod vu_meter;
pub use correlation as correlation_meter;
pub use dynamics as dynamic_range_meter;
pub use peak as peak_meter;
pub use phase as phase_analysis;
pub use truepeak as true_peak;
pub mod crest_factor;
pub mod k_weighted;
pub mod meter_bridge;
pub mod loudness_trend;
pub mod noise_floor;
pub mod stereo_balance;
pub mod k_weight_simd;
pub mod temporal_noise;
use oximedia_core::types::SampleFormat;
use thiserror::Error;
pub use atsc::{AtscA85Compliance, AtscA85Meter};
pub use ballistics::{BallisticProcessor, BallisticType, MultiChannelBallistics};
pub use correlation::{
CorrelationMeter, FrequencyBand, Goniometer as CorrelationGoniometer,
GoniometerPoint as CorrelationGoniometerPoint, MultibandMeter, PhaseRelationship,
};
pub use dynamics::{DynamicRangeMeter, PlrMeter};
pub use ebu::{EbuR128Compliance, EbuR128Meter};
pub use filters::{KWeightFilter, KWeightFilterBank};
pub use gating::{GatingProcessor, GatingResult};
pub use lkfs::{LkfsCalculator, LufsValue};
pub use peak::{
dbfs_to_linear, linear_to_dbfs, KSystemMeter, KSystemType, PeakMeter, PeakMeterType,
};
pub use phase::{Goniometer, GoniometerPoint, PhaseCorrelationMeter, StereoWidthAnalyzer};
pub use range::{LoudnessRange, LraCalculator};
pub use render::{
colors, generate_db_scale, BarMeterConfig, BarMeterData, CircularMeterConfig, Color,
ColorGradient, Orientation, ScaleMark, ScaleType,
};
pub use report::{ComplianceReport, LoudnessReport, MeteringReport};
pub use spectrum::{
CachedSpectrumAnalyzer, OctaveBand, OctaveBandAnalyzer, SpectrumAnalyzer, WeightingCurve,
WindowFunction,
};
pub use truepeak::{TruePeak, TruePeakDetector};
pub use video_color::{
ColorGamut, ColorTemperatureMeter, GamutMeter, HsvColor, RgbColor, SaturationMeter,
};
pub use video_luminance::{BlackWhiteLevelMeter, LuminanceMeter};
pub use video_quality::{
BlockinessDetector, Frame2D, PsnrCalculator, QualityAnalyzer, QualityMetrics, SsimCalculator,
};
#[derive(Error, Debug)]
pub enum MeteringError {
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Insufficient data: {0}")]
InsufficientData(String),
#[error("Unsupported sample format: {0:?}")]
UnsupportedFormat(SampleFormat),
#[error("Channel error: {0}")]
ChannelError(String),
#[error("Calculation error: {0}")]
CalculationError(String),
}
pub type MeteringResult<T> = std::result::Result<T, MeteringError>;
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum Standard {
#[default]
EbuR128,
AtscA85,
Spotify,
YouTube,
AppleMusic,
Netflix,
AmazonPrime,
TidalHiFi,
AmazonMusicHd,
Custom {
target_lufs: f64,
max_peak_dbtp: f64,
tolerance_lu: f64,
},
}
impl Standard {
pub fn target_lufs(&self) -> f64 {
match self {
Self::EbuR128 => -23.0,
Self::AtscA85 | Self::AmazonPrime => -24.0,
Self::Spotify | Self::YouTube | Self::TidalHiFi | Self::AmazonMusicHd => -14.0,
Self::AppleMusic => -16.0,
Self::Netflix => -27.0,
Self::Custom { target_lufs, .. } => *target_lufs,
}
}
pub fn max_true_peak_dbtp(&self) -> f64 {
match self {
Self::EbuR128
| Self::Spotify
| Self::YouTube
| Self::AppleMusic
| Self::TidalHiFi
| Self::AmazonMusicHd => -1.0,
Self::AtscA85 | Self::Netflix | Self::AmazonPrime => -2.0,
Self::Custom { max_peak_dbtp, .. } => *max_peak_dbtp,
}
}
pub fn tolerance_lu(&self) -> f64 {
match self {
Self::EbuR128
| Self::Spotify
| Self::YouTube
| Self::AppleMusic
| Self::TidalHiFi
| Self::AmazonMusicHd => 1.0,
Self::AtscA85 | Self::Netflix | Self::AmazonPrime => 2.0,
Self::Custom { tolerance_lu, .. } => *tolerance_lu,
}
}
pub fn name(&self) -> &str {
match self {
Self::EbuR128 => "EBU R128",
Self::AtscA85 => "ATSC A/85",
Self::Spotify => "Spotify",
Self::YouTube => "YouTube",
Self::AppleMusic => "Apple Music",
Self::Netflix => "Netflix",
Self::AmazonPrime => "Amazon Prime Video",
Self::TidalHiFi => "Tidal HiFi",
Self::AmazonMusicHd => "Amazon Music HD",
Self::Custom { .. } => "Custom",
}
}
}
#[derive(Clone, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct MeterConfig {
pub standard: Standard,
pub sample_rate: f64,
pub channels: usize,
pub enable_true_peak: bool,
pub enable_lra: bool,
pub enable_momentary: bool,
pub enable_short_term: bool,
pub enable_integrated: bool,
}
impl MeterConfig {
pub fn new(standard: Standard, sample_rate: f64, channels: usize) -> Self {
Self {
standard,
sample_rate,
channels,
enable_true_peak: true,
enable_lra: true,
enable_momentary: true,
enable_short_term: true,
enable_integrated: true,
}
}
pub fn minimal(standard: Standard, sample_rate: f64, channels: usize) -> Self {
Self {
standard,
sample_rate,
channels,
enable_true_peak: true,
enable_lra: false,
enable_momentary: false,
enable_short_term: false,
enable_integrated: true,
}
}
pub fn validate(&self) -> MeteringResult<()> {
if self.sample_rate < 8000.0 || self.sample_rate > 192_000.0 {
return Err(MeteringError::InvalidConfig(format!(
"Sample rate {} Hz is out of valid range (8000-192000 Hz)",
self.sample_rate
)));
}
if self.channels == 0 || self.channels > 16 {
return Err(MeteringError::InvalidConfig(format!(
"Channel count {} is out of valid range (1-16)",
self.channels
)));
}
if !self.enable_integrated && !self.enable_momentary && !self.enable_short_term {
return Err(MeteringError::InvalidConfig(
"At least one loudness measurement must be enabled".to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ChannelLayout {
Mono,
Stereo,
Surround51,
Surround71,
Atmos714,
Nhk222,
Custom(usize),
}
impl ChannelLayout {
pub fn channel_count(&self) -> usize {
match self {
Self::Mono => 1,
Self::Stereo => 2,
Self::Surround51 => 6,
Self::Surround71 => 8,
Self::Atmos714 => 12,
Self::Nhk222 => 24,
Self::Custom(n) => *n,
}
}
pub fn channel_weights(&self) -> Vec<f64> {
match self {
Self::Mono => vec![1.0],
Self::Stereo => vec![1.0, 1.0],
Self::Surround51 => {
vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41]
}
Self::Surround71 => {
vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41]
}
Self::Atmos714 => {
vec![
1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41,
]
}
Self::Nhk222 => {
const W_SURROUND: f64 = 1.188_502_227_4; const W_FRONT: f64 = 1.0;
const W_LFE: f64 = 0.0;
vec![
W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_FRONT, W_FRONT, W_FRONT, W_LFE, W_SURROUND, W_SURROUND, W_FRONT, W_FRONT, W_SURROUND, W_LFE, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, W_SURROUND, ]
}
Self::Custom(n) => vec![1.0; *n],
}
}
pub fn from_channel_count(count: usize) -> Self {
match count {
1 => Self::Mono,
2 => Self::Stereo,
6 => Self::Surround51,
8 => Self::Surround71,
12 => Self::Atmos714,
24 => Self::Nhk222,
n => Self::Custom(n),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct LoudnessMetrics {
pub momentary_lufs: f64,
pub short_term_lufs: f64,
pub integrated_lufs: f64,
pub loudness_range: f64,
pub true_peak_dbtp: f64,
pub true_peak_linear: f64,
pub max_momentary: f64,
pub max_short_term: f64,
pub channel_peaks_dbtp: Vec<f64>,
}
pub struct LoudnessMeter {
config: MeterConfig,
lkfs_calculator: LkfsCalculator,
gating_processor: GatingProcessor,
true_peak_detector: Option<TruePeakDetector>,
lra_calculator: Option<LraCalculator>,
filter_bank: KWeightFilterBank,
channel_layout: ChannelLayout,
samples_processed: usize,
}
impl LoudnessMeter {
pub fn new(config: MeterConfig) -> MeteringResult<Self> {
config.validate()?;
let channel_layout = ChannelLayout::from_channel_count(config.channels);
let filter_bank = KWeightFilterBank::new(config.channels, config.sample_rate);
let lkfs_calculator = LkfsCalculator::new(config.sample_rate, config.channels);
let gating_processor = GatingProcessor::new(config.sample_rate, config.channels);
let true_peak_detector = if config.enable_true_peak {
Some(TruePeakDetector::new(config.sample_rate, config.channels))
} else {
None
};
let lra_calculator = if config.enable_lra {
Some(LraCalculator::new())
} else {
None
};
Ok(Self {
config,
lkfs_calculator,
gating_processor,
true_peak_detector,
lra_calculator,
filter_bank,
channel_layout,
samples_processed: 0,
})
}
pub fn process_f32(&mut self, samples: &[f32]) {
let f64_samples: Vec<f64> = samples.iter().map(|&s| f64::from(s)).collect();
self.process_f64(&f64_samples);
}
pub fn process_f64(&mut self, samples: &[f64]) {
if samples.is_empty() {
return;
}
let mut filtered = vec![0.0; samples.len()];
self.filter_bank
.process_interleaved(samples, self.config.channels, &mut filtered);
self.lkfs_calculator.process_interleaved(&filtered);
self.gating_processor.process_interleaved(&filtered);
if let Some(ref mut detector) = self.true_peak_detector {
detector.process_interleaved(samples);
}
self.samples_processed += samples.len() / self.config.channels;
}
pub fn metrics(&mut self) -> LoudnessMetrics {
let momentary = if self.config.enable_momentary {
self.lkfs_calculator.momentary_loudness()
} else {
f64::NEG_INFINITY
};
let short_term = if self.config.enable_short_term {
self.lkfs_calculator.short_term_loudness()
} else {
f64::NEG_INFINITY
};
let integrated = if self.config.enable_integrated {
self.gating_processor.integrated_loudness()
} else {
f64::NEG_INFINITY
};
let loudness_range = if let Some(ref mut lra_calc) = self.lra_calculator {
let blocks = self.gating_processor.get_blocks_for_lra();
lra_calc.calculate(&blocks)
} else {
0.0
};
let (true_peak_dbtp, true_peak_linear, channel_peaks_dbtp) =
if let Some(ref detector) = self.true_peak_detector {
let peaks = detector.channel_peaks_dbtp();
let max_peak = detector.true_peak_dbtp();
let max_linear = detector.true_peak_linear();
(max_peak, max_linear, peaks)
} else {
(f64::NEG_INFINITY, 0.0, vec![])
};
LoudnessMetrics {
momentary_lufs: momentary,
short_term_lufs: short_term,
integrated_lufs: integrated,
loudness_range,
true_peak_dbtp,
true_peak_linear,
max_momentary: self.lkfs_calculator.max_momentary(),
max_short_term: self.lkfs_calculator.max_short_term(),
channel_peaks_dbtp,
}
}
pub fn check_compliance(&mut self) -> ComplianceResult {
let metrics = self.metrics();
let standard = &self.config.standard;
let target = standard.target_lufs();
let tolerance = standard.tolerance_lu();
let max_peak = standard.max_true_peak_dbtp();
let loudness_compliant = if metrics.integrated_lufs.is_finite() {
metrics.integrated_lufs >= target - tolerance
&& metrics.integrated_lufs <= target + tolerance
} else {
false
};
let peak_compliant = metrics.true_peak_dbtp <= max_peak;
let lra_acceptable = metrics.loudness_range >= 1.0 && metrics.loudness_range <= 30.0;
ComplianceResult {
standard: *standard,
loudness_compliant,
peak_compliant,
lra_acceptable,
integrated_lufs: metrics.integrated_lufs,
true_peak_dbtp: metrics.true_peak_dbtp,
loudness_range: metrics.loudness_range,
target_lufs: target,
max_peak_dbtp: max_peak,
deviation_lu: if metrics.integrated_lufs.is_finite() {
metrics.integrated_lufs - target
} else {
0.0
},
}
}
#[allow(clippy::cast_precision_loss)]
pub fn generate_report(&mut self) -> LoudnessReport {
let metrics = self.metrics();
let compliance = self.check_compliance();
let duration_seconds = self.samples_processed as f64 / self.config.sample_rate;
LoudnessReport::new(metrics, compliance, duration_seconds)
}
pub fn reset(&mut self) {
self.lkfs_calculator.reset();
self.gating_processor.reset();
if let Some(ref mut detector) = self.true_peak_detector {
detector.reset();
}
if let Some(ref mut lra_calc) = self.lra_calculator {
lra_calc.reset();
}
self.filter_bank.reset();
self.samples_processed = 0;
}
pub fn config(&self) -> &MeterConfig {
&self.config
}
pub fn samples_processed(&self) -> usize {
self.samples_processed
}
#[allow(clippy::cast_precision_loss)]
pub fn duration_seconds(&self) -> f64 {
self.samples_processed as f64 / self.config.sample_rate
}
}
#[derive(Clone, Debug)]
pub struct ComplianceResult {
pub standard: Standard,
pub loudness_compliant: bool,
pub peak_compliant: bool,
pub lra_acceptable: bool,
pub integrated_lufs: f64,
pub true_peak_dbtp: f64,
pub loudness_range: f64,
pub target_lufs: f64,
pub max_peak_dbtp: f64,
pub deviation_lu: f64,
}
impl ComplianceResult {
pub fn is_compliant(&self) -> bool {
self.loudness_compliant && self.peak_compliant
}
pub fn standard_name(&self) -> &str {
self.standard.name()
}
pub fn recommended_gain_db(&self) -> f64 {
if self.integrated_lufs.is_finite() {
self.target_lufs - self.integrated_lufs
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ebu_r128_reference_signal() {
let sample_rate = 48000.0_f64;
let channels = 2_usize;
let duration_secs = 10.0_f64;
let freq_hz = 997.0_f64;
let k_weight_power_gain_db = 3.41_f64;
let target_power = 10.0_f64.powf((-23.0_f64 + 0.691) / 10.0);
let filter_power_gain = 10.0_f64.powf(k_weight_power_gain_db / 10.0);
let amplitude = (2.0 * target_power / filter_power_gain).sqrt();
let total_samples = (sample_rate * duration_secs) as usize;
let mut interleaved = Vec::with_capacity(total_samples * channels);
for i in 0..total_samples {
let t = i as f64 / sample_rate;
let sample = amplitude * (2.0 * std::f64::consts::PI * freq_hz * t).sin();
interleaved.push(sample);
interleaved.push(sample);
}
let config = MeterConfig::new(Standard::EbuR128, sample_rate, channels);
let mut meter = LoudnessMeter::new(config).expect("Failed to create LoudnessMeter");
meter.process_f64(&interleaved);
let metrics = meter.metrics();
let integrated = metrics.integrated_lufs;
assert!(
integrated.is_finite(),
"Integrated loudness should be finite, got {integrated}"
);
assert!(
(integrated - (-23.0)).abs() <= 0.5,
"Expected -23.0 LUFS ±0.5, got {integrated:.2} LUFS"
);
}
#[test]
fn test_tidal_hifi_standard() {
let s = Standard::TidalHiFi;
assert_eq!(s.target_lufs(), -14.0);
assert_eq!(s.max_true_peak_dbtp(), -1.0);
assert_eq!(s.name(), "Tidal HiFi");
}
#[test]
fn test_amazon_music_hd_standard() {
let s = Standard::AmazonMusicHd;
assert_eq!(s.target_lufs(), -14.0);
assert_eq!(s.max_true_peak_dbtp(), -1.0);
assert_eq!(s.name(), "Amazon Music HD");
}
}