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
This commit is contained in:
Roderick van Domburg 2021-02-24 21:39:42 +01:00
parent 56f1fb6dae
commit f29e5212c4
17 changed files with 327 additions and 53 deletions

View file

@ -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::<lewton::samples::InterleavedSamples<f32>>()
{
Ok(Some(packet)) => return Ok(Some(AudioPacket::Samples(packet.samples))),
Ok(None) => return Ok(None),
Err(BadAudio(AudioIsHeader)) => (),

View file

@ -33,12 +33,12 @@ pub use fetch::{
use std::fmt;
pub enum AudioPacket {
Samples(Vec<i16>),
Samples(Vec<f32>),
OggData(Vec<u8>),
}
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"),

View file

@ -39,7 +39,16 @@ where
fn next_packet(&mut self) -> Result<Option<AudioPacket>, 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)) => (),

View file

@ -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<PCM>,
device: String,
buffer: Vec<i16>,
buffer: Vec<f32>,
}
fn list_outputs() {
@ -36,19 +36,19 @@ fn list_outputs() {
fn open_device(dev_name: &str) -> Result<(PCM, Frames), Box<Error>> {
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(),

View file

@ -15,7 +15,7 @@ pub struct GstreamerSink {
impl Open for GstreamerSink {
fn open(device: Option<String>) -> 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),

View file

@ -7,20 +7,18 @@ use std::io;
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
pub struct JackSink {
send: SyncSender<i16>,
send: SyncSender<f32>,
// 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<i16>,
rec: Receiver<f32>,
port_l: Port<AudioOut>,
port_r: Port<AudioOut>,
}
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
}

View file

@ -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::<i16>(),
data.len() * mem::size_of::<f32>(),
)
},
AudioPacket::OggData(data) => data,

View file

@ -8,8 +8,8 @@ use std::process::exit;
use std::time::Duration;
pub struct PortAudioSink<'a>(
Option<portaudio_rs::stream::Stream<'a, i16, i16>>,
StreamParameters<i16>,
Option<portaudio_rs::stream::Stream<'a, f32, f32>>,
StreamParameters<f32>,
);
fn output_devices() -> Box<dyn Iterator<Item = (DeviceIndex, DeviceInfo)>> {
@ -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)

View file

@ -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::<f32>(),
)
};

View file

@ -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);

View file

@ -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<Channel>,
@ -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));
}

View file

@ -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::<i16>(),
packet.samples().len() * mem::size_of::<f32>(),
)
};
if let Some(child) = &mut self.child {

View file

@ -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<Self, Self::Err> {
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,
}

View file

@ -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")]

View file

@ -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;
}
}
}

View file

@ -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<futures::sync::mpsc::UnboundedSender<PlayerCommand>>,
@ -54,6 +55,13 @@ struct PlayerInternal {
sink_event_callback: Option<SinkEventCallback>,
audio_filter: Option<Box<dyn AudioFilter + Send>>,
event_senders: Vec<futures::sync::mpsc::UnboundedSender<PlayerEvent>>,
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<PlayerEvent>;
#[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<T: Read + Seek>(mut file: T) -> Result<NormalisationData> {
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,

View file

@ -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::<f32>().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::<f32>()
.expect("Invalid threshold float value")
})
.unwrap_or(PlayerConfig::default().normalisation_threshold),
),
normalisation_attack: matches
.opt_str("normalisation-attack")
.map(|attack| attack.parse::<f32>().expect("Invalid attack float value"))
.unwrap_or(PlayerConfig::default().normalisation_attack * MILLIS)
/ MILLIS,
normalisation_release: matches
.opt_str("normalisation-release")
.map(|release| release.parse::<f32>().expect("Invalid release float value"))
.unwrap_or(PlayerConfig::default().normalisation_release * MILLIS)
/ MILLIS,
normalisation_steepness: matches
.opt_str("normalisation-steepness")
.map(|steepness| {
steepness
.parse::<f32>()
.expect("Invalid steepness float value")
})
.unwrap_or(PlayerConfig::default().normalisation_steepness),
passthrough,
}
};