diff --git a/examples/play.rs b/examples/play.rs index d6c7196d..6156cb7b 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -6,6 +6,7 @@ use librespot::core::session::Session; use librespot::core::spotify_id::SpotifyId; use librespot::playback::audio_backend; use librespot::playback::config::{AudioFormat, PlayerConfig}; +use librespot::playback::mixer::NoOpVolume; use librespot::playback::player::Player; #[tokio::main] @@ -30,7 +31,7 @@ async fn main() { .await .unwrap(); - let (mut player, _) = Player::new(player_config, session, None, move || { + let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || { backend(None, audio_format) }); diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index a3c7a5a1..0a8b8d6c 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -3,6 +3,8 @@ use crate::config::VolumeCtrl; pub mod mappings; use self::mappings::MappedCtrl; +pub struct NoOpVolume; + pub trait Mixer: Send { fn open(config: MixerConfig) -> Self where @@ -11,13 +13,19 @@ pub trait Mixer: Send { fn set_volume(&self, volume: u16); fn volume(&self) -> u16; - fn get_audio_filter(&self) -> Option> { - None + fn get_soft_volume(&self) -> Box { + Box::new(NoOpVolume) } } -pub trait AudioFilter { - fn modify_stream(&self, data: &mut [f64]); +pub trait VolumeGetter { + fn attenuation_factor(&self) -> f64; +} + +impl VolumeGetter for NoOpVolume { + fn attenuation_factor(&self) -> f64 { + 1.0 + } } pub mod softmixer; diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index cefc2de5..93da5fec 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use super::AudioFilter; +use super::VolumeGetter; use super::{MappedCtrl, VolumeCtrl}; use super::{Mixer, MixerConfig}; @@ -35,10 +35,8 @@ impl Mixer for SoftMixer { .store(mapped_volume.to_bits(), Ordering::Relaxed) } - fn get_audio_filter(&self) -> Option> { - Some(Box::new(SoftVolumeApplier { - volume: self.volume.clone(), - })) + fn get_soft_volume(&self) -> Box { + Box::new(SoftVolume(self.volume.clone())) } } @@ -46,17 +44,10 @@ impl SoftMixer { pub const NAME: &'static str = "softvol"; } -struct SoftVolumeApplier { - volume: Arc, -} +struct SoftVolume(Arc); -impl AudioFilter for SoftVolumeApplier { - fn modify_stream(&self, data: &mut [f64]) { - let volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); - if volume < 1.0 { - for x in data.iter_mut() { - *x *= volume; - } - } +impl VolumeGetter for SoftVolume { + fn attenuation_factor(&self) -> f64 { + f64::from_bits(self.0.load(Ordering::Relaxed)) } } diff --git a/playback/src/player.rs b/playback/src/player.rs index 74ba1fc4..a6935010 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -25,7 +25,7 @@ use crate::core::spotify_id::SpotifyId; use crate::core::util::SeqGenerator; use crate::decoder::{AudioDecoder, AudioPacket, DecoderError, PassthroughDecoder, VorbisDecoder}; use crate::metadata::{AudioItem, FileFormat}; -use crate::mixer::AudioFilter; +use crate::mixer::VolumeGetter; use crate::{MS_PER_PAGE, NUM_CHANNELS, PAGES_PER_MS, SAMPLES_PER_SECOND}; @@ -58,7 +58,7 @@ struct PlayerInternal { sink: Box, sink_status: SinkStatus, sink_event_callback: Option, - audio_filter: Option>, + volume_getter: Box, event_senders: Vec>, converter: Converter, @@ -319,7 +319,7 @@ impl Player { pub fn new( config: PlayerConfig, session: Session, - audio_filter: Option>, + volume_getter: Box, sink_builder: F, ) -> (Player, PlayerEventChannel) where @@ -369,7 +369,7 @@ impl Player { sink: sink_builder(), sink_status: SinkStatus::Closed, sink_event_callback: None, - audio_filter, + volume_getter, event_senders: [event_sender].to_vec(), converter, @@ -1314,109 +1314,110 @@ impl PlayerInternal { Some(mut packet) => { if !packet.is_empty() { if let AudioPacket::Samples(ref mut data) = packet { + // Get the volume for the packet. + // In the case of hardware volume control this will + // always be 1.0 (no change). + let volume = self.volume_getter.attenuation_factor(); + // For the basic normalisation method, a normalisation factor of 1.0 indicates that // there is nothing to normalise (all samples should pass unaltered). For the // dynamic method, there may still be peaks that we want to shave off. - if self.config.normalisation { - if self.config.normalisation_method == NormalisationMethod::Basic - && normalisation_factor < 1.0 - { - for sample in data.iter_mut() { - *sample *= normalisation_factor; - } - } else if self.config.normalisation_method - == NormalisationMethod::Dynamic - { - // zero-cost shorthands - let threshold_db = self.config.normalisation_threshold_dbfs; - let knee_db = self.config.normalisation_knee_db; - let attack_cf = self.config.normalisation_attack_cf; - let release_cf = self.config.normalisation_release_cf; - - for sample in data.iter_mut() { - *sample *= normalisation_factor; - - // Feedforward limiter in the log domain - // After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic - // Range Compressor Design—A Tutorial and Analysis. Journal of The Audio - // Engineering Society, 60, 399-408. - - // Some tracks have samples that are precisely 0.0. That's silence - // and we know we don't need to limit that, in which we can spare - // the CPU cycles. - // - // Also, calling `ratio_to_db(0.0)` returns `inf` and would get the - // peak detector stuck. Also catch the unlikely case where a sample - // is decoded as `NaN` or some other non-normal value. - let limiter_db = if sample.is_normal() { - // step 1-4: half-wave rectification and conversion into dB - // and gain computer with soft knee and subtractor - let bias_db = ratio_to_db(sample.abs()) - threshold_db; - let knee_boundary_db = bias_db * 2.0; - - if knee_boundary_db < -knee_db { - 0.0 - } else if knee_boundary_db.abs() <= knee_db { - // The textbook equation: - // ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db)) - // Simplifies to: - // ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db) - // Which in our case further simplifies to: - // (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db) - // because knee_boundary_db is 2.0 * bias_db. - (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db) - } else { - // Textbook: - // ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db. - bias_db - } - } else { - 0.0 - }; - - // Spare the CPU unless (1) the limiter is engaged, (2) we - // were in attack or (3) we were in release, and that attack/ - // release wasn't finished yet. - if limiter_db > 0.0 - || self.normalisation_integrator > 0.0 - || self.normalisation_peak > 0.0 - { - // step 5: smooth, decoupled peak detector - // Textbook: - // release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db - // Simplifies to: - // release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db - self.normalisation_integrator = f64::max( - limiter_db, - release_cf * self.normalisation_integrator - - release_cf * limiter_db - + limiter_db, - ); - // Textbook: - // attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator - // Simplifies to: - // attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator - self.normalisation_peak = attack_cf - * self.normalisation_peak - - attack_cf * self.normalisation_integrator - + self.normalisation_integrator; - - // step 6: make-up gain applied later (volume attenuation) - // Applying the standard normalisation factor here won't work, - // because there are tracks with peaks as high as 6 dB above - // the default threshold, so that would clip. - - // steps 7-8: conversion into level and multiplication into gain stage - *sample *= db_to_ratio(-self.normalisation_peak); - } - } + // No matter the case we apply volume attenuation last if there is any. + if !self.config.normalisation && volume < 1.0 { + for sample in data.iter_mut() { + *sample *= volume; } - } + } else if self.config.normalisation_method == NormalisationMethod::Basic + && (normalisation_factor < 1.0 || volume < 1.0) + { + for sample in data.iter_mut() { + *sample *= normalisation_factor * volume; + } + } else if self.config.normalisation_method == NormalisationMethod::Dynamic { + // zero-cost shorthands + let threshold_db = self.config.normalisation_threshold_dbfs; + let knee_db = self.config.normalisation_knee_db; + let attack_cf = self.config.normalisation_attack_cf; + let release_cf = self.config.normalisation_release_cf; - // Apply volume attenuation last. TODO: make this so we can chain - // the normaliser and mixer as a processing pipeline. - if let Some(ref editor) = self.audio_filter { - editor.modify_stream(data) + for sample in data.iter_mut() { + *sample *= normalisation_factor; + + // Feedforward limiter in the log domain + // After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic + // Range Compressor Design—A Tutorial and Analysis. Journal of The Audio + // Engineering Society, 60, 399-408. + + // Some tracks have samples that are precisely 0.0. That's silence + // and we know we don't need to limit that, in which we can spare + // the CPU cycles. + // + // Also, calling `ratio_to_db(0.0)` returns `inf` and would get the + // peak detector stuck. Also catch the unlikely case where a sample + // is decoded as `NaN` or some other non-normal value. + let limiter_db = if sample.is_normal() { + // step 1-4: half-wave rectification and conversion into dB + // and gain computer with soft knee and subtractor + let bias_db = ratio_to_db(sample.abs()) - threshold_db; + let knee_boundary_db = bias_db * 2.0; + + if knee_boundary_db < -knee_db { + 0.0 + } else if knee_boundary_db.abs() <= knee_db { + // The textbook equation: + // ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db)) + // Simplifies to: + // ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db) + // Which in our case further simplifies to: + // (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db) + // because knee_boundary_db is 2.0 * bias_db. + (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db) + } else { + // Textbook: + // ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db. + bias_db + } + } else { + 0.0 + }; + + // Spare the CPU unless (1) the limiter is engaged, (2) we + // were in attack or (3) we were in release, and that attack/ + // release wasn't finished yet. + if limiter_db > 0.0 + || self.normalisation_integrator > 0.0 + || self.normalisation_peak > 0.0 + { + // step 5: smooth, decoupled peak detector + // Textbook: + // release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db + // Simplifies to: + // release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db + self.normalisation_integrator = f64::max( + limiter_db, + release_cf * self.normalisation_integrator + - release_cf * limiter_db + + limiter_db, + ); + // Textbook: + // attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator + // Simplifies to: + // attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator + self.normalisation_peak = attack_cf * self.normalisation_peak + - attack_cf * self.normalisation_integrator + + self.normalisation_integrator; + + // step 6: make-up gain applied later (volume attenuation) + // Applying the standard normalisation factor here won't work, + // because there are tracks with peaks as high as 6 dB above + // the default threshold, so that would clip. + + // steps 7-8: conversion into level and multiplication into gain stage + *sample *= db_to_ratio(-self.normalisation_peak); + } + + *sample *= volume; + } } } diff --git a/src/main.rs b/src/main.rs index 8d81834d..59ab0ce6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1648,12 +1648,12 @@ async fn main() { let player_config = setup.player_config.clone(); let connect_config = setup.connect_config.clone(); - let audio_filter = mixer.get_audio_filter(); + let soft_volume = mixer.get_soft_volume(); let format = setup.format; let backend = setup.backend; let device = setup.device.clone(); let (player, event_channel) = - Player::new(player_config, session.clone(), audio_filter, move || { + Player::new(player_config, session.clone(), soft_volume, move || { (backend)(device, format) });