From f29e5212c402074c1a12eb493af7e5d4c966dcdf Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 24 Feb 2021 21:39:42 +0100 Subject: [PATCH] High-resolution volume control and normalisation - Store and output samples as 32-bit floats instead of 16-bit integers. This provides 24-25 bits of transparency, allowing for 42-48 dB of headroom to do volume control and normalisation without throwing away bits or dropping dynamic range below 96 dB CD quality. - Perform volume control and normalisation in 64-bit arithmetic. - Add a dynamic limiter with configurable threshold, attack time, release or decay time, and steepness for the sigmoid transfer function. This mimics the native Spotify limiter, offering greater dynamic range than the old limiter, that just reduced overall gain to prevent clipping. - Make the configurable threshold also apply to the old limiter, which is still available. Resolves: librespot-org/librespot#608 --- audio/src/lewton_decoder.rs | 7 +- audio/src/lib.rs | 4 +- audio/src/libvorbis_decoder.rs | 11 +- playback/src/audio_backend/alsa.rs | 20 +-- playback/src/audio_backend/gstreamer.rs | 2 +- playback/src/audio_backend/jackaudio.rs | 14 +- playback/src/audio_backend/pipe.rs | 2 +- playback/src/audio_backend/portaudio.rs | 6 +- playback/src/audio_backend/pulseaudio.rs | 11 +- playback/src/audio_backend/rodio.rs | 2 +- playback/src/audio_backend/sdl.rs | 4 +- playback/src/audio_backend/subprocess.rs | 2 +- playback/src/config.rs | 33 ++++ playback/src/mixer/mod.rs | 2 +- playback/src/mixer/softmixer.rs | 5 +- playback/src/player.rs | 183 +++++++++++++++++++++-- src/main.rs | 72 ++++++++- 17 files changed, 327 insertions(+), 53 deletions(-) diff --git a/audio/src/lewton_decoder.rs b/audio/src/lewton_decoder.rs index 1addaa01..8e7d089e 100644 --- a/audio/src/lewton_decoder.rs +++ b/audio/src/lewton_decoder.rs @@ -37,8 +37,11 @@ where use self::lewton::VorbisError::BadAudio; use self::lewton::VorbisError::OggError; loop { - match self.0.read_dec_packet_itl() { - Ok(Some(packet)) => return Ok(Some(AudioPacket::Samples(packet))), + match self + .0 + .read_dec_packet_generic::>() + { + Ok(Some(packet)) => return Ok(Some(AudioPacket::Samples(packet.samples))), Ok(None) => return Ok(None), Err(BadAudio(AudioIsHeader)) => (), diff --git a/audio/src/lib.rs b/audio/src/lib.rs index fd764071..c4d862b3 100644 --- a/audio/src/lib.rs +++ b/audio/src/lib.rs @@ -33,12 +33,12 @@ pub use fetch::{ use std::fmt; pub enum AudioPacket { - Samples(Vec), + Samples(Vec), OggData(Vec), } impl AudioPacket { - pub fn samples(&self) -> &[i16] { + pub fn samples(&self) -> &[f32] { match self { AudioPacket::Samples(s) => s, AudioPacket::OggData(_) => panic!("can't return OggData on samples"), diff --git a/audio/src/libvorbis_decoder.rs b/audio/src/libvorbis_decoder.rs index 8aced556..e7ccc984 100644 --- a/audio/src/libvorbis_decoder.rs +++ b/audio/src/libvorbis_decoder.rs @@ -39,7 +39,16 @@ where fn next_packet(&mut self) -> Result, AudioError> { loop { match self.0.packets().next() { - Some(Ok(packet)) => return Ok(Some(AudioPacket::Samples(packet.data))), + Some(Ok(packet)) => { + // Losslessly represent [-32768, 32767] to [-1.0, 1.0] while maintaining DC linearity. + return Ok(Some(AudioPacket::Samples( + packet + .data + .iter() + .map(|sample| ((*sample as f64 + 0.5) / (0x7FFF as f64 + 0.5)) as f32) + .collect(), + ))); + } None => return Ok(None), Some(Err(vorbis::VorbisError::Hole)) => (), diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index bf7b1376..9bc17fe6 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -8,13 +8,13 @@ use std::ffi::CString; use std::io; use std::process::exit; -const PREFERED_PERIOD_SIZE: Frames = 5512; // Period of roughly 125ms +const PREFERRED_PERIOD_SIZE: Frames = 11025; // Period of roughly 125ms const BUFFERED_PERIODS: Frames = 4; pub struct AlsaSink { pcm: Option, device: String, - buffer: Vec, + buffer: Vec, } fn list_outputs() { @@ -36,19 +36,19 @@ fn list_outputs() { fn open_device(dev_name: &str) -> Result<(PCM, Frames), Box> { let pcm = PCM::new(dev_name, Direction::Playback, false)?; - let mut period_size = PREFERED_PERIOD_SIZE; + let mut period_size = PREFERRED_PERIOD_SIZE; // http://www.linuxjournal.com/article/6735?page=0,1#N0x19ab2890.0x19ba78d8 // latency = period_size * periods / (rate * bytes_per_frame) - // For 16 Bit stereo data, one frame has a length of four bytes. - // 500ms = buffer_size / (44100 * 4) - // buffer_size_bytes = 0.5 * 44100 / 4 + // For stereo samples encoded as 32-bit floats, one frame has a length of eight bytes. + // 500ms = buffer_size / (44100 * 8) + // buffer_size_bytes = 0.5 * 44100 / 8 // buffer_size_frames = 0.5 * 44100 = 22050 { - // Set hardware parameters: 44100 Hz / Stereo / 16 bit + // Set hardware parameters: 44100 Hz / Stereo / 32-bit float let hwp = HwParams::any(&pcm)?; hwp.set_access(Access::RWInterleaved)?; - hwp.set_format(Format::s16())?; + hwp.set_format(Format::float())?; hwp.set_rate(44100, ValueOr::Nearest)?; hwp.set_channels(2)?; period_size = hwp.set_period_size_near(period_size, ValueOr::Greater)?; @@ -114,7 +114,7 @@ impl Sink for AlsaSink { let pcm = self.pcm.as_mut().unwrap(); // Write any leftover data in the period buffer // before draining the actual buffer - let io = pcm.io_i16().unwrap(); + let io = pcm.io_f32().unwrap(); match io.writei(&self.buffer[..]) { Ok(_) => (), Err(err) => pcm.try_recover(err, false).unwrap(), @@ -138,7 +138,7 @@ impl Sink for AlsaSink { processed_data += data_to_buffer; if self.buffer.len() == self.buffer.capacity() { let pcm = self.pcm.as_mut().unwrap(); - let io = pcm.io_i16().unwrap(); + let io = pcm.io_f32().unwrap(); match io.writei(&self.buffer) { Ok(_) => (), Err(err) => pcm.try_recover(err, false).unwrap(), diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index 6be6dd72..1ad3631e 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -15,7 +15,7 @@ pub struct GstreamerSink { impl Open for GstreamerSink { fn open(device: Option) -> GstreamerSink { gst::init().expect("Failed to init gstreamer!"); - let pipeline_str_preamble = r#"appsrc caps="audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=44100" block=true max-bytes=4096 name=appsrc0 "#; + let pipeline_str_preamble = r#"appsrc caps="audio/x-raw,format=F32,layout=interleaved,channels=2,rate=44100" block=true max-bytes=4096 name=appsrc0 "#; let pipeline_str_rest = r#" ! audioconvert ! autoaudiosink"#; let pipeline_str: String = match device { Some(x) => format!("{}{}", pipeline_str_preamble, x), diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index 4699c182..e95933fc 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -7,20 +7,18 @@ use std::io; use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; pub struct JackSink { - send: SyncSender, + send: SyncSender, + // We have to keep hold of this object, or the Sink can't play... + #[allow(dead_code)] active_client: AsyncClient<(), JackData>, } pub struct JackData { - rec: Receiver, + rec: Receiver, port_l: Port, port_r: Port, } -fn pcm_to_f32(sample: i16) -> f32 { - sample as f32 / 32768.0 -} - impl ProcessHandler for JackData { fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control { // get output port buffers @@ -33,8 +31,8 @@ impl ProcessHandler for JackData { let buf_size = buf_r.len(); for i in 0..buf_size { - buf_r[i] = pcm_to_f32(queue_iter.next().unwrap_or(0)); - buf_l[i] = pcm_to_f32(queue_iter.next().unwrap_or(0)); + buf_r[i] = queue_iter.next().unwrap_or(0.0); + buf_l[i] = queue_iter.next().unwrap_or(0.0); } Control::Continue } diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 210c0ce9..5516ee94 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -32,7 +32,7 @@ impl Sink for StdoutSink { AudioPacket::Samples(data) => unsafe { slice::from_raw_parts( data.as_ptr() as *const u8, - data.len() * mem::size_of::(), + data.len() * mem::size_of::(), ) }, AudioPacket::OggData(data) => data, diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index 0e25021e..0b8eac0b 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -8,8 +8,8 @@ use std::process::exit; use std::time::Duration; pub struct PortAudioSink<'a>( - Option>, - StreamParameters, + Option>, + StreamParameters, ); fn output_devices() -> Box> { @@ -65,7 +65,7 @@ impl<'a> Open for PortAudioSink<'a> { device: device_idx, channel_count: 2, suggested_latency: latency, - data: 0i16, + data: 0.0, }; PortAudioSink(None, params) diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 11ea026a..4dca2108 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -3,6 +3,7 @@ use crate::audio::AudioPacket; use libpulse_binding::{self as pulse, stream::Direction}; use libpulse_simple_binding::Simple; use std::io; +use std::mem; const APP_NAME: &str = "librespot"; const STREAM_NAME: &str = "Spotify endpoint"; @@ -18,7 +19,7 @@ impl Open for PulseAudioSink { debug!("Using PulseAudio sink"); let ss = pulse::sample::Spec { - format: pulse::sample::Format::S16le, + format: pulse::sample::Format::F32le, channels: 2, // stereo rate: 44100, }; @@ -68,13 +69,13 @@ impl Sink for PulseAudioSink { fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { if let Some(s) = &self.s { - // SAFETY: An i16 consists of two bytes, so that the given slice can be interpreted - // as a byte array of double length. Each byte pointer is validly aligned, and so - // is the newly created slice. + // SAFETY: An f32 consists of four bytes, so that the given slice can be interpreted + // as a byte array of four. Each byte pointer is validly aligned, and so is the newly + // created slice. let d: &[u8] = unsafe { std::slice::from_raw_parts( packet.samples().as_ptr() as *const u8, - packet.samples().len() * 2, + packet.samples().len() * mem::size_of::(), ) }; diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 3b920c30..6c996e85 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -198,7 +198,7 @@ impl Sink for JackRodioSink { Ok(()) } - fn write(&mut self, data: &[i16]) -> io::Result<()> { + fn write(&mut self, data: &[f32]) -> io::Result<()> { let source = rodio::buffer::SamplesBuffer::new(2, 44100, data); self.jackrodio_sink.append(source); diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index 27d650f9..727615d1 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -3,7 +3,7 @@ use crate::audio::AudioPacket; use sdl2::audio::{AudioQueue, AudioSpecDesired}; use std::{io, thread, time}; -type Channel = i16; +type Channel = f32; pub struct SdlSink { queue: AudioQueue, @@ -47,7 +47,7 @@ impl Sink for SdlSink { } fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { - while self.queue.size() > (2 * 2 * 44_100) { + while self.queue.size() > (2 * 4 * 44_100) { // sleep and wait for sdl thread to drain the queue a bit thread::sleep(time::Duration::from_millis(10)); } diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index 0dd25638..123e0233 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -48,7 +48,7 @@ impl Sink for SubprocessSink { let data: &[u8] = unsafe { slice::from_raw_parts( packet.samples().as_ptr() as *const u8, - packet.samples().len() * mem::size_of::(), + packet.samples().len() * mem::size_of::(), ) }; if let Some(child) = &mut self.child { diff --git a/playback/src/config.rs b/playback/src/config.rs index 31f63626..be15b268 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -48,12 +48,40 @@ impl Default for NormalisationType { } } +#[derive(Clone, Debug, PartialEq)] +pub enum NormalisationMethod { + Basic, + Dynamic, +} + +impl FromStr for NormalisationMethod { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "basic" => Ok(NormalisationMethod::Basic), + "dynamic" => Ok(NormalisationMethod::Dynamic), + _ => Err(()), + } + } +} + +impl Default for NormalisationMethod { + fn default() -> NormalisationMethod { + NormalisationMethod::Dynamic + } +} + #[derive(Clone, Debug)] pub struct PlayerConfig { pub bitrate: Bitrate, pub normalisation: bool, pub normalisation_type: NormalisationType, + pub normalisation_method: NormalisationMethod, pub normalisation_pregain: f32, + pub normalisation_threshold: f32, + pub normalisation_attack: f32, + pub normalisation_release: f32, + pub normalisation_steepness: f32, pub gapless: bool, pub passthrough: bool, } @@ -64,7 +92,12 @@ impl Default for PlayerConfig { bitrate: Bitrate::default(), normalisation: false, normalisation_type: NormalisationType::default(), + normalisation_method: NormalisationMethod::default(), normalisation_pregain: 0.0, + normalisation_threshold: -1.0, + normalisation_attack: 0.005, + normalisation_release: 0.1, + normalisation_steepness: 1.0, gapless: true, passthrough: false, } diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index 325c1e18..3424526a 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -12,7 +12,7 @@ pub trait Mixer: Send { } pub trait AudioFilter { - fn modify_stream(&self, data: &mut [i16]); + fn modify_stream(&self, data: &mut [f32]); } #[cfg(feature = "alsa-backend")] diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index 28e1cf57..ec8ed6b2 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -35,11 +35,12 @@ struct SoftVolumeApplier { } impl AudioFilter for SoftVolumeApplier { - fn modify_stream(&self, data: &mut [i16]) { + fn modify_stream(&self, data: &mut [f32]) { let volume = self.volume.load(Ordering::Relaxed) as u16; if volume != 0xFFFF { + let volume_factor = volume as f64 / 0xFFFF as f64; for x in data.iter_mut() { - *x = (*x as i32 * volume as i32 / 0xFFFF) as i16; + *x = (*x as f64 * volume_factor) as f32; } } } diff --git a/playback/src/player.rs b/playback/src/player.rs index 9b4eefb9..c7edf3a4 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -9,7 +9,7 @@ use std::mem; use std::thread; use std::time::{Duration, Instant}; -use crate::config::{Bitrate, NormalisationType, PlayerConfig}; +use crate::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; use librespot_core::session::Session; use librespot_core::spotify_id::SpotifyId; @@ -26,6 +26,7 @@ use crate::metadata::{AudioItem, FileFormat}; use crate::mixer::AudioFilter; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; +const SAMPLES_PER_SECOND: u32 = 44100 * 2; pub struct Player { commands: Option>, @@ -54,6 +55,13 @@ struct PlayerInternal { sink_event_callback: Option, audio_filter: Option>, event_senders: Vec>, + + limiter_active: bool, + limiter_attack_counter: u32, + limiter_release_counter: u32, + limiter_peak_sample: f32, + limiter_factor: f32, + limiter_strength: f32, } enum PlayerCommand { @@ -185,7 +193,7 @@ impl PlayerEvent { pub type PlayerEventChannel = futures::sync::mpsc::UnboundedReceiver; #[derive(Clone, Copy, Debug)] -struct NormalisationData { +pub struct NormalisationData { track_gain_db: f32, track_peak: f32, album_gain_db: f32, @@ -193,6 +201,14 @@ struct NormalisationData { } impl NormalisationData { + pub fn db_to_ratio(db: f32) -> f32 { + return f32::powf(10.0, db / 20.0); + } + + pub fn ratio_to_db(ratio: f32) -> f32 { + return ratio.log10() * 20.0; + } + fn parse_from_file(mut file: T) -> Result { const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144; file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET)) @@ -218,17 +234,44 @@ impl NormalisationData { NormalisationType::Album => [data.album_gain_db, data.album_peak], NormalisationType::Track => [data.track_gain_db, data.track_peak], }; - let mut normalisation_factor = - f32::powf(10.0, (gain_db + config.normalisation_pregain) / 20.0); - if normalisation_factor * gain_peak > 1.0 { - warn!("Reducing normalisation factor to prevent clipping. Please add negative pregain to avoid."); - normalisation_factor = 1.0 / gain_peak; + let normalisation_power = gain_db + config.normalisation_pregain; + let mut normalisation_factor = Self::db_to_ratio(normalisation_power); + + if normalisation_factor * gain_peak > config.normalisation_threshold { + let limited_normalisation_factor = config.normalisation_threshold / gain_peak; + let limited_normalisation_power = Self::ratio_to_db(limited_normalisation_factor); + + if config.normalisation_method == NormalisationMethod::Basic { + warn!("Limiting gain to {:.2} for the duration of this track to stay under normalisation threshold.", limited_normalisation_power); + normalisation_factor = limited_normalisation_factor; + } else { + warn!( + "This track will at its peak be subject to {:.2} dB of dynamic limiting.", + normalisation_power - limited_normalisation_power + ); + } + + warn!("Please lower pregain to avoid."); } debug!("Normalisation Data: {:?}", data); debug!("Normalisation Type: {:?}", config.normalisation_type); - debug!("Applied normalisation factor: {}", normalisation_factor); + debug!( + "Normalisation Threshold: {:.1}", + Self::ratio_to_db(config.normalisation_threshold) + ); + debug!("Normalisation Method: {:?}", config.normalisation_method); + debug!("Normalisation Factor: {}", normalisation_factor); + + if config.normalisation_method == NormalisationMethod::Dynamic { + debug!("Normalisation Attack: {:?}", config.normalisation_attack); + debug!("Normalisation Release: {:?}", config.normalisation_release); + debug!( + "Normalisation Steepness: {:?}", + config.normalisation_steepness + ); + } normalisation_factor } @@ -262,6 +305,13 @@ impl Player { sink_event_callback: None, audio_filter: audio_filter, event_senders: [event_sender].to_vec(), + + limiter_active: false, + limiter_attack_counter: 0, + limiter_release_counter: 0, + limiter_peak_sample: 0.0, + limiter_factor: 1.0, + limiter_strength: 0.0, }; // While PlayerInternal is written as a future, it still contains blocking code. @@ -1113,9 +1163,111 @@ impl PlayerInternal { editor.modify_stream(data) } - if self.config.normalisation && normalisation_factor != 1.0 { - for x in data.iter_mut() { - *x = (*x as f32 * normalisation_factor) as i16; + if self.config.normalisation + && (normalisation_factor != 1.0 + || self.config.normalisation_method != NormalisationMethod::Basic) + { + for sample in data.iter_mut() { + let mut actual_normalisation_factor = normalisation_factor; + if self.config.normalisation_method == NormalisationMethod::Dynamic + { + if self.limiter_active { + // "S"-shaped curve with a configurable steepness during attack and release: + // - > 1.0 yields soft knees at start and end, steeper in between + // - 1.0 yields a linear function from 0-100% + // - between 0.0 and 1.0 yields hard knees at start and end, flatter in between + // - 0.0 yields a step response to 50%, causing distortion + // - Rates < 0.0 invert the limiter and are invalid + let mut shaped_limiter_strength = self.limiter_strength; + if shaped_limiter_strength > 0.0 + && shaped_limiter_strength < 1.0 + { + shaped_limiter_strength = 1.0 + / (1.0 + + f32::powf( + shaped_limiter_strength + / (1.0 - shaped_limiter_strength), + -1.0 * self.config.normalisation_steepness, + )); + } + actual_normalisation_factor = + (1.0 - shaped_limiter_strength) * normalisation_factor + + shaped_limiter_strength * self.limiter_factor; + }; + + // Always check for peaks, even when the limiter is already active. + // There may be even higher peaks than we initially targeted. + // Check against the normalisation factor that would be applied normally. + let abs_sample = + ((*sample as f64 * normalisation_factor as f64) as f32) + .abs(); + if abs_sample > self.config.normalisation_threshold { + self.limiter_active = true; + if self.limiter_release_counter > 0 { + // A peak was encountered while releasing the limiter; + // synchronize with the current release limiter strength. + self.limiter_attack_counter = (((SAMPLES_PER_SECOND + as f32 + * self.config.normalisation_release) + - self.limiter_release_counter as f32) + / (self.config.normalisation_release + / self.config.normalisation_attack)) + as u32; + self.limiter_release_counter = 0; + } + + self.limiter_attack_counter = + self.limiter_attack_counter.saturating_add(1); + self.limiter_strength = self.limiter_attack_counter as f32 + / (SAMPLES_PER_SECOND as f32 + * self.config.normalisation_attack); + + if abs_sample > self.limiter_peak_sample { + self.limiter_peak_sample = abs_sample; + self.limiter_factor = + self.config.normalisation_threshold + / self.limiter_peak_sample; + } + } else if self.limiter_active { + if self.limiter_attack_counter > 0 { + // Release may start within the attack period, before + // the limiter reached full strength. For that reason + // start the release by synchronizing with the current + // attack limiter strength. + self.limiter_release_counter = (((SAMPLES_PER_SECOND + as f32 + * self.config.normalisation_attack) + - self.limiter_attack_counter as f32) + * (self.config.normalisation_release + / self.config.normalisation_attack)) + as u32; + self.limiter_attack_counter = 0; + } + + self.limiter_release_counter = + self.limiter_release_counter.saturating_add(1); + + if self.limiter_release_counter + > (SAMPLES_PER_SECOND as f32 + * self.config.normalisation_release) + as u32 + { + self.reset_limiter(); + } else { + self.limiter_strength = ((SAMPLES_PER_SECOND as f32 + * self.config.normalisation_release) + - self.limiter_release_counter as f32) + / (SAMPLES_PER_SECOND as f32 + * self.config.normalisation_release); + } + } + } + + // Extremely sharp attacks, however unlikely, *may* still clip and provide + // undefined results, so strictly enforce output within [-1.0, 1.0]. + *sample = (*sample as f64 * actual_normalisation_factor as f64) + .clamp(-1.0, 1.0) + as f32; } } } @@ -1146,6 +1298,15 @@ impl PlayerInternal { } } + fn reset_limiter(&mut self) { + self.limiter_active = false; + self.limiter_release_counter = 0; + self.limiter_attack_counter = 0; + self.limiter_peak_sample = 0.0; + self.limiter_factor = 1.0; + self.limiter_strength = 0.0; + } + fn start_playback( &mut self, track_id: SpotifyId, diff --git a/src/main.rs b/src/main.rs index 53603152..91a58659 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,13 +22,15 @@ use librespot::core::version; use librespot::connect::discovery::{discovery, DiscoveryStream}; use librespot::connect::spirc::{Spirc, SpircTask}; use librespot::playback::audio_backend::{self, Sink, BACKENDS}; -use librespot::playback::config::{Bitrate, NormalisationType, PlayerConfig}; +use librespot::playback::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; use librespot::playback::mixer::{self, Mixer, MixerConfig}; -use librespot::playback::player::{Player, PlayerEvent}; +use librespot::playback::player::{NormalisationData, Player, PlayerEvent}; mod player_event_handler; use crate::player_event_handler::{emit_sink_event, run_program_on_events}; +const MILLIS: f32 = 1000.0; + fn device_id(name: &str) -> String { hex::encode(Sha1::digest(name.as_bytes())) } @@ -188,6 +190,12 @@ fn setup(args: &[String]) -> Setup { "enable-volume-normalisation", "Play all tracks at the same volume", ) + .optopt( + "", + "normalisation-method", + "Specify the normalisation method to use - [basic, dynamic]. Default is dynamic.", + "NORMALISATION_METHOD", + ) .optopt( "", "normalisation-gain-type", @@ -200,6 +208,30 @@ fn setup(args: &[String]) -> Setup { "Pregain (dB) applied by volume normalisation", "PREGAIN", ) + .optopt( + "", + "normalisation-threshold", + "Threshold (dBFS) to prevent clipping. Default is -1.0.", + "THRESHOLD", + ) + .optopt( + "", + "normalisation-attack", + "Attack time (ms) in which the dynamic limiter is reducing gain. Default is 5.", + "ATTACK", + ) + .optopt( + "", + "normalisation-release", + "Release or decay time (ms) in which the dynamic limiter is restoring gain. Default is 100.", + "RELEASE", + ) + .optopt( + "", + "normalisation-steepness", + "Steepness of the dynamic limiting curve. Default is 1.0.", + "STEEPNESS", + ) .optopt( "", "volume-ctrl", @@ -390,15 +422,51 @@ fn setup(args: &[String]) -> Setup { NormalisationType::from_str(gain_type).expect("Invalid normalisation type") }) .unwrap_or(NormalisationType::default()); + let normalisation_method = matches + .opt_str("normalisation-method") + .as_ref() + .map(|gain_type| { + NormalisationMethod::from_str(gain_type).expect("Invalid normalisation method") + }) + .unwrap_or(NormalisationMethod::default()); PlayerConfig { bitrate: bitrate, gapless: !matches.opt_present("disable-gapless"), normalisation: matches.opt_present("enable-volume-normalisation"), + normalisation_method: normalisation_method, normalisation_type: gain_type, normalisation_pregain: matches .opt_str("normalisation-pregain") .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) .unwrap_or(PlayerConfig::default().normalisation_pregain), + normalisation_threshold: NormalisationData::db_to_ratio( + matches + .opt_str("normalisation-threshold") + .map(|threshold| { + threshold + .parse::() + .expect("Invalid threshold float value") + }) + .unwrap_or(PlayerConfig::default().normalisation_threshold), + ), + normalisation_attack: matches + .opt_str("normalisation-attack") + .map(|attack| attack.parse::().expect("Invalid attack float value")) + .unwrap_or(PlayerConfig::default().normalisation_attack * MILLIS) + / MILLIS, + normalisation_release: matches + .opt_str("normalisation-release") + .map(|release| release.parse::().expect("Invalid release float value")) + .unwrap_or(PlayerConfig::default().normalisation_release * MILLIS) + / MILLIS, + normalisation_steepness: matches + .opt_str("normalisation-steepness") + .map(|steepness| { + steepness + .parse::() + .expect("Invalid steepness float value") + }) + .unwrap_or(PlayerConfig::default().normalisation_steepness), passthrough, } };