Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down
27 changes: 27 additions & 0 deletions examples/loudness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use rodio::source::Source;
use std::error::Error;
use std::time::Duration;

fn main() -> Result<(), Box<dyn Error>> {
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(())
}
204 changes: 204 additions & 0 deletions src/source/loudness.rs
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should soon (I've got time again) merge sources that cannot change parameters mid-stream. How do you feel about postponing this addition until we have those? Most users will probably move over to those (their API is nicer).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes total sense. the loudness source would be built cleaner on top of them!


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<I> {
input: I,
analyzer: EbuR128,
channels: usize,
// Accumulates one interleaved frame before handing it to the analyzer since ebur128 consumes whole frames!
frame: Vec<Float>,
}

// construct a loudness passthrough source.
pub(crate) fn loudness<I>(input: I) -> Loudness<I>
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<I> Loudness<I>
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<I> Iterator for Loudness<I>
where
I: Source,
{
type Item = I::Item;

#[inline]
fn next(&mut self) -> Option<Self::Item> {
let sample = self.input.next()?;
self.feed(sample);
Some(sample)
}

#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.input.size_hint()
}
}

impl<I> ExactSizeIterator for Loudness<I> where I: Source + ExactSizeIterator {}

impl<I> Source for Loudness<I>
where
I: Source,
{
#[inline]
fn current_span_len(&self) -> Option<usize> {
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<Duration> {
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<I: std::fmt::Debug> std::fmt::Debug for Loudness<I> {
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<Float> = SineWave::new(440.0).take(500).collect();
let measured: Vec<Float> = loudness(SineWave::new(440.0)).take(500).collect();
assert_eq!(original, measured);
}
}
33 changes: 33 additions & 0 deletions src/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -441,6 +445,35 @@ pub trait Source: Iterator<Item = Sample> {
)
}

/// 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<dyn std::error::Error>>(())
/// ```
#[cfg(feature = "loudness")]
#[inline]
fn loudness(self) -> Loudness<Self>
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.
Expand Down
Loading
Loading