diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d434dc7..92be40ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `Source::loudness` adapter that measures perceptual loudness (LUFS, + EBU R128 / ITU-R BS.1770) via the `ebur128` crate, behind the new `loudness` + feature. Passes audio through unchanged. - Added `Skippable::skipped` function to check if the inner source was skipped. - All sources now implement `ExactSizeIterator` when their inner source does. - All sources now implement `Iterator::size_hint()`. diff --git a/Cargo.lock b/Cargo.lock index f2ac235d8..197db69d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,6 +410,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -498,6 +507,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "either" version = "1.16.0" @@ -1616,6 +1637,7 @@ dependencies = [ "crossbeam-channel", "dasp_sample", "divan", + "ebur128", "hound", "inquire", "lewton", diff --git a/Cargo.toml b/Cargo.toml index 38f286335..a92d443ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,11 @@ dither = ["noise"] # Enable noise generation (white noise, pink noise, etc.) noise = ["rand", "rand_distr"] +# Audio analysis features +# +# Enable perceptual loudness measurement (EBU R128 / ITU-R BS.1770) via the `ebur128` crate +loudness = ["dep:ebur128"] + # Performance features # # Perform all calculations with 64-bit floats (instead of 32) @@ -159,6 +164,8 @@ num-rational = "0.4.2" symphonia-adapter-libopus = { version = "0.2", optional = true } +ebur128 = { version = "0.1.10", optional = true } + [dev-dependencies] quickcheck = "1" rstest = "0.26" @@ -192,6 +199,10 @@ required-features = ["wav"] name = "automatic_gain_control" required-features = ["playback", "flac"] +[[example]] +name = "loudness" +required-features = ["playback", "loudness"] + [[example]] name = "basic" required-features = ["playback", "vorbis"] diff --git a/examples/loudness.rs b/examples/loudness.rs new file mode 100644 index 000000000..d19c3fffb --- /dev/null +++ b/examples/loudness.rs @@ -0,0 +1,27 @@ +use rodio::source::Source; +use std::error::Error; +use std::time::Duration; + +fn main() -> Result<(), Box> { + let stream_handle = rodio::DeviceSinkBuilder::open_default_sink()?; + let player = rodio::Player::connect_new(stream_handle.mixer()); + + // Generate a 440 Hz sine wave and wrap it in the loudness meter. + let source = rodio::source::SineWave::new(440.0) + .take_duration(Duration::from_secs(10)) + .loudness(); + + // periodic_access lets us read loudness readings while the audio plays. + let metered = source.periodic_access(Duration::from_millis(500), |src| { + println!( + "momentary: {:.1} LUFS short-term: {:.1} LUFS integrated: {:.1} LUFS", + src.momentary_lufs(), + src.short_term_lufs(), + src.integrated_lufs(), + ); + }); + + player.append(metered); + player.sleep_until_end(); + Ok(()) +} diff --git a/src/source/loudness.rs b/src/source/loudness.rs new file mode 100644 index 000000000..5219f7526 --- /dev/null +++ b/src/source/loudness.rs @@ -0,0 +1,204 @@ +//! Perceptual loudness measurement (EBU R128 / ITU-R BS.1770). +//! +//! This source will pass audio through unchanged while measuring its loudness with the ebur128 crate. +//! This crate is a port of `libebur128` which produces results identical to the C reference (including K-weighting and gating). +//! +//! Read the current loudness in LUFS (Loudness Units relative to Full Scale) at any point via Loudness::momentary_lufs (in 400 ms window), +//! Loudness::short_term_lufs (in 3 s window) or Loudness::integrated_lufs (gated, whole-program). +//! +//! # Limitation right now: +//! The analyzer is configured for the stream's channel count and sample rate at the construction time. +//! Sources whose parameters change mid-stream (e.g. a queue of files with differing sample rates) +//! are not yet reconfigured; that would need the analyzer to be rebuilt at span boundaries. + +use std::time::Duration; + +use ebur128::{EbuR128, Mode}; + +use super::SeekError; +use crate::common::{ChannelCount, Float, SampleRate}; +use crate::Source; + +// ebu r128 measurement modes: momentary, short term and integrated loudness. +fn measurement_mode() -> Mode { + Mode::M | Mode::S | Mode::I +} + +/// passthrough source that measures perceptual loudness or LUFS. +/// +/// The audio is forwarded unchanged. Read the current loudness via momentary_lufs, short_term_lufs or integrated_lufs +pub struct Loudness { + input: I, + analyzer: EbuR128, + channels: usize, + // Accumulates one interleaved frame before handing it to the analyzer since ebur128 consumes whole frames! + frame: Vec, +} + +// construct a loudness passthrough source. +pub(crate) fn loudness(input: I) -> Loudness +where + I: Source, +{ + let channels = input.channels(); + let sample_rate = input.sample_rate(); + + let analyzer = EbuR128::new(channels.get() as u32, sample_rate.get(), measurement_mode()) + .expect("EbuR128 accepts any non-zero channel count and sample rate"); + + Loudness { + input, + analyzer, + channels: channels.get() as usize, + frame: Vec::with_capacity(channels.get() as usize), + } +} + +impl Loudness +where + I: Source, +{ + /// momentary loudness (in 400 ms window) in LUFS. + /// will return f64::NEG_INFINITY until enough audio has been measured. + #[inline] + pub fn momentary_lufs(&self) -> f64 { + self.analyzer + .loudness_momentary() + .unwrap_or(f64::NEG_INFINITY) + } + + /// short term loudness (in 3 s window) in LUFS. + #[inline] + pub fn short_term_lufs(&self) -> f64 { + self.analyzer + .loudness_shortterm() + .unwrap_or(f64::NEG_INFINITY) + } + + /// integrated loudness in LUFS: gated, whole program. + #[inline] + pub fn integrated_lufs(&self) -> f64 { + self.analyzer.loudness_global().unwrap_or(f64::NEG_INFINITY) + } + + /// this returns a reference to inner source. + #[inline] + pub fn inner(&self) -> &I { + &self.input + } + + /// returns a mutable reference to inner source. + #[inline] + pub fn inner_mut(&mut self) -> &mut I { + &mut self.input + } + + /// to unwrap this adapter, returning the inner source. + #[inline] + pub fn into_inner(self) -> I { + self.input + } + + // function to feed the buffered interleaved frame into the analyzer once complete. + #[inline] + fn feed(&mut self, temp: Float) { + self.frame.push(temp); + + if self.frame.len() == self.channels { + #[cfg(not(feature = "64bit"))] + let result = self.analyzer.add_frames_f32(&self.frame); + + #[cfg(feature = "64bit")] + let result = self.analyzer.add_frames_f64(&self.frame); + + // error will occur here only on allocation failure. + // if we drop the frame the measurement just degrades slightly + // otherwise it could break the playback. + let _ = result; + self.frame.clear(); + } + } +} + +impl Iterator for Loudness +where + I: Source, +{ + type Item = I::Item; + + #[inline] + fn next(&mut self) -> Option { + let sample = self.input.next()?; + self.feed(sample); + Some(sample) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.input.size_hint() + } +} + +impl ExactSizeIterator for Loudness where I: Source + ExactSizeIterator {} + +impl Source for Loudness +where + I: Source, +{ + #[inline] + fn current_span_len(&self) -> Option { + self.input.current_span_len() + } + + #[inline] + fn channels(&self) -> ChannelCount { + self.input.channels() + } + + #[inline] + fn sample_rate(&self) -> SampleRate { + self.input.sample_rate() + } + + #[inline] + fn total_duration(&self) -> Option { + self.input.total_duration() + } + + #[inline] + fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> { + self.input.try_seek(pos)?; + // since loudness history is position-dependent, we restart the analyzer. + if let Ok(fresh) = EbuR128::new( + self.channels as u32, + self.input.sample_rate().get(), + measurement_mode(), + ) { + self.analyzer = fresh; + } + self.frame.clear(); + Ok(()) + } +} + +impl std::fmt::Debug for Loudness { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Loudness") + .field("input", &self.input) + .field("channels", &self.channels) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::SineWave; + + #[test] + fn passes_samples_through_unchanged() { + let original: Vec = SineWave::new(440.0).take(500).collect(); + let measured: Vec = loudness(SineWave::new(440.0)).take(500).collect(); + assert_eq!(original, measured); + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index 63d5233e9..bf42e9818 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -29,6 +29,8 @@ pub use self::from_factory::{from_factory, FromFactoryIter}; pub use self::from_iter::{from_iter, FromIter}; pub use self::limit::{Limit, LimitSettings}; pub use self::linear_ramp::LinearGainRamp; +#[cfg(feature = "loudness")] +pub use self::loudness::Loudness; pub use self::mix::Mix; pub use self::pausable::Pausable; pub use self::periodic::PeriodicAccess; @@ -66,6 +68,8 @@ mod from_factory; mod from_iter; mod limit; mod linear_ramp; +#[cfg(feature = "loudness")] +mod loudness; mod mix; mod pausable; mod periodic; @@ -441,6 +445,35 @@ pub trait Source: Iterator { ) } + /// Wraps the source in a [`Loudness`] adapter that measures perceptual loudness + /// (EBU R128 / ITU-R BS.1770) in LUFS, passing the audio through unchanged. + /// + /// Read the current loudness at any point via [`Loudness::momentary_lufs`], + /// [`Loudness::short_term_lufs`], or [`Loudness::integrated_lufs`]. + /// + /// Requires the `loudness` feature. + /// + /// # Example + /// ```no_run + /// use rodio::{decoder::Decoder, source::Source}; + /// use std::fs::File; + /// use std::io::BufReader; + /// + /// let source = Decoder::new(BufReader::new(File::open("audio.wav")?))?; + /// let mut metered = source.loudness(); + /// // consume samples (e.g. play them), then: + /// println!("Momentary loudness: {} LUFS", metered.momentary_lufs()); + /// # Ok::<(), Box>(()) + /// ``` + #[cfg(feature = "loudness")] + #[inline] + fn loudness(self) -> Loudness + where + Self: Sized, + { + loudness::loudness(self) + } + /// Mixes this sound fading out with another sound fading in for the given duration. /// /// Only the crossfaded portion (beginning of self, beginning of other) is returned. diff --git a/tests/loudness.rs b/tests/loudness.rs new file mode 100644 index 000000000..6bf98df35 --- /dev/null +++ b/tests/loudness.rs @@ -0,0 +1,91 @@ +#![cfg(feature = "loudness")] + +use std::num::NonZero; +use std::time::Duration; + +use rodio::buffer::SamplesBuffer; +use rodio::source::Source; +use rodio::Sample; + +/// One second is comfortably longer than the 400 ms momentary window, so the +/// reading has settled. +fn one_second(freq: f32) -> impl Source { + rodio::source::SineWave::new(freq).take_duration(Duration::from_secs(1)) +} + +#[test] +fn loud_sine_reads_plausible_lufs() { + let mut metered = one_second(1000.0).loudness(); + // Drive the whole second through the meter. + let _: Vec = metered.by_ref().collect(); + + let momentary = metered.momentary_lufs(); + let integrated = metered.integrated_lufs(); + + // A full-scale sine sits a few LUFS below 0; well within a sane band. + assert!( + momentary.is_finite() && (-30.0..0.0).contains(&momentary), + "momentary out of range: {momentary}" + ); + assert!( + integrated.is_finite() && (-30.0..0.0).contains(&integrated), + "integrated out of range: {integrated}" + ); +} + +#[test] +fn silence_is_negative_infinity() { + let channels = NonZero::new(1).unwrap(); + let rate = NonZero::new(48_000).unwrap(); + let silence: Vec = vec![0.0; 48_000]; + let silence = SamplesBuffer::new(channels, rate, silence); + + let mut metered = silence.loudness(); + let _: Vec = metered.by_ref().collect(); + + assert_eq!(metered.integrated_lufs(), f64::NEG_INFINITY); +} + +#[test] +fn louder_signal_reads_higher() { + let quiet = { + let mut m = one_second(1000.0).amplify(0.1).loudness(); + let _: Vec = m.by_ref().collect(); + m.integrated_lufs() + }; + let loud = { + let mut m = one_second(1000.0).amplify(0.5).loudness(); + let _: Vec = m.by_ref().collect(); + m.integrated_lufs() + }; + + assert!( + loud > quiet, + "louder should read higher: loud={loud}, quiet={quiet}" + ); +} + +#[test] +fn stereo_source_is_measured_and_passed_through() { + // Interleaved stereo: identical tone in both channels. + let frames = 48_000; + let mut samples: Vec = Vec::with_capacity(frames * 2); + for i in 0..frames { + let s = (i as Sample * 0.05).sin() * 0.5; + samples.push(s); // left + samples.push(s); // right + } + + let channels = NonZero::new(2).unwrap(); + let rate = NonZero::new(48_000).unwrap(); + let buffer = SamplesBuffer::new(channels, rate, samples.clone()); + + let mut metered = buffer.loudness(); + let passed: Vec = metered.by_ref().collect(); + + assert_eq!(passed, samples, "stereo audio must pass through unchanged"); + assert!( + metered.integrated_lufs().is_finite(), + "stereo loudness should be finite" + ); +}