Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modularize Normalisation a bit #1163

Closed
wants to merge 2 commits into from
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ https://github.com/librespot-org/librespot
- [playback] The passthrough decoder is now feature-gated (breaking)
- [playback] `rodio`: call play and pause
- [protocol] protobufs have been updated
- [playback] Modularize Normalisation in `player.rs`

### Added

Expand Down
301 changes: 166 additions & 135 deletions playback/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,10 @@ struct PlayerInternal {
sink_status: SinkStatus,
sink_event_callback: Option<SinkEventCallback>,
volume_getter: Box<dyn VolumeGetter + Send>,
normaliser: Normaliser,
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
converter: Converter,

normalisation_integrator: f64,
normalisation_peak: f64,

auto_normalise_as_album: bool,

player_id: usize,
Expand Down Expand Up @@ -282,6 +280,166 @@ pub fn coefficient_to_duration(coefficient: f64) -> Duration {
Duration::from_secs_f64(-1.0 / f64::ln(coefficient) / SAMPLES_PER_SECOND as f64)
}

struct DynamicNormalisation {
threshold_db: f64,
attack_cf: f64,
release_cf: f64,
knee_db: f64,
integrator: f64,
peak: f64,
}

impl DynamicNormalisation {
fn normalise(&mut self, samples: &[f64], volume: f64, factor: f64) -> Vec<f64> {
samples
.iter()
.map(|sample| {
let mut sample = sample * 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()) - self.threshold_db;
let knee_boundary_db = bias_db * 2.0;

if knee_boundary_db < -self.knee_db {
0.0
} else if knee_boundary_db.abs() <= self.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 + self.knee_db).powi(2) / (8.0 * self.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.integrator > 0.0 || self.peak > 0.0 {
// step 5: smooth, decoupled peak detector
// Textbook:
// release_cf * integrator + (1.0 - release_cf) * limiter_db
// Simplifies to:
// release_cf * integrator - release_cf * limiter_db + limiter_db
self.integrator = limiter_db.max(
self.release_cf * self.integrator - self.release_cf * limiter_db
+ limiter_db,
);
// Textbook:
// attack_cf * peak + (1.0 - attack_cf) * integrator
// Simplifies to:
// attack_cf * peak - attack_cf * sintegrator + integrator
self.peak = self.attack_cf * self.peak - self.attack_cf * self.integrator
+ self.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.peak);
}

sample * volume
})
.collect()
}
}

enum Normaliser {
No,
Basic,
Dynamic(DynamicNormalisation),
}

impl Normaliser {
fn new(config: &PlayerConfig) -> Self {
if config.normalisation {
debug!("Normalisation Type: {:?}", config.normalisation_type);
debug!(
"Normalisation Pregain: {:.1} dB",
config.normalisation_pregain_db
);
debug!(
"Normalisation Threshold: {:.1} dBFS",
config.normalisation_threshold_dbfs
);
debug!("Normalisation Method: {:?}", config.normalisation_method);

if config.normalisation_method == NormalisationMethod::Dynamic {
// as_millis() has rounding errors (truncates)
debug!(
"Normalisation Attack: {:.0} ms",
coefficient_to_duration(config.normalisation_attack_cf).as_secs_f64() * 1000.
);
debug!(
"Normalisation Release: {:.0} ms",
coefficient_to_duration(config.normalisation_release_cf).as_secs_f64() * 1000.
);

Normaliser::Dynamic(DynamicNormalisation {
threshold_db: config.normalisation_threshold_dbfs,
attack_cf: config.normalisation_attack_cf,
release_cf: config.normalisation_release_cf,
knee_db: config.normalisation_knee_db,
integrator: 0.0,
peak: 0.0,
})
} else {
Normaliser::Basic
}
} else {
Normaliser::No
}
}

fn normalise(&mut self, samples: &[f64], volume: f64, factor: f64) -> Vec<f64> {
match self {
Normaliser::Dynamic(d) => d.normalise(samples, volume, factor),
Normaliser::No => {
if volume < 1.0 {
samples.iter().map(|sample| sample * volume).collect()
} else {
samples.to_vec()
}
}
Normaliser::Basic => {
if volume < 1.0 || factor < 1.0 {
samples
.iter()
.map(|sample| sample * factor * volume)
.collect()
} else {
samples.to_vec()
}
}
}
}
}

#[derive(Clone, Copy, Debug)]
pub struct NormalisationData {
// Spotify provides these as `f32`, but audio metadata can contain up to `f64`.
Expand Down Expand Up @@ -417,37 +575,12 @@ impl Player {
{
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();

if config.normalisation {
debug!("Normalisation Type: {:?}", config.normalisation_type);
debug!(
"Normalisation Pregain: {:.1} dB",
config.normalisation_pregain_db
);
debug!(
"Normalisation Threshold: {:.1} dBFS",
config.normalisation_threshold_dbfs
);
debug!("Normalisation Method: {:?}", config.normalisation_method);

if config.normalisation_method == NormalisationMethod::Dynamic {
// as_millis() has rounding errors (truncates)
debug!(
"Normalisation Attack: {:.0} ms",
coefficient_to_duration(config.normalisation_attack_cf).as_secs_f64() * 1000.
);
debug!(
"Normalisation Release: {:.0} ms",
coefficient_to_duration(config.normalisation_release_cf).as_secs_f64() * 1000.
);
debug!("Normalisation Knee: {} dB", config.normalisation_knee_db);
}
}

let handle = thread::spawn(move || {
let player_id = PLAYER_COUNTER.fetch_add(1, Ordering::AcqRel);
debug!("new Player [{}]", player_id);

let converter = Converter::new(config.ditherer);
let normaliser = Normaliser::new(&config);

let internal = PlayerInternal {
session,
Expand All @@ -461,12 +594,10 @@ impl Player {
sink_status: SinkStatus::Closed,
sink_event_callback: None,
volume_getter,
normaliser,
event_senders: vec![],
converter,

normalisation_peak: 0.0,
normalisation_integrator: 0.0,

auto_normalise_as_album: false,

player_id,
Expand Down Expand Up @@ -1540,109 +1671,9 @@ impl PlayerInternal {
// 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.

// No matter the case we apply volume attenuation last if there is any.
if !self.config.normalisation {
if 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;

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;
}
}
*data = self
.normaliser
.normalise(data, volume, normalisation_factor);
}

if let Err(e) = self.sink.write(packet, &mut self.converter) {
Expand Down