1
0
Fork 0
mirror of https://github.com/librespot-org/librespot.git synced 2025-10-03 17:59:24 +02:00

perf(playback): optimize audio normalization for stereo processing (#1485)

- Add pre-computed knee factor to eliminate division in sample loop
- Replace if-else chain with match pattern for cleaner branching
- Use direct references to reduce repeated array indexing
- Maintain existing stereo imaging via channel coupling

Addresses review comments from #1485 and incorporates optimizations
inspired by Rodio's limiter implementation for improved performance
in the stereo case.
This commit is contained in:
Roderick van Domburg 2025-08-14 12:00:48 +02:00 committed by GitHub
parent 19f635f90b
commit 9456a02afa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 93 additions and 105 deletions

View file

@ -21,7 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [metadata] Replaced `AudioFileFormat` with own enum. (breaking) - [metadata] Replaced `AudioFileFormat` with own enum. (breaking)
- [playback] Changed trait `Mixer::open` to return `Result<Self, Error>` instead of `Self` (breaking) - [playback] Changed trait `Mixer::open` to return `Result<Self, Error>` instead of `Self` (breaking)
- [playback] Changed type alias `MixerFn` to return `Result<Arc<dyn Mixer>, Error>` instead of `Arc<dyn Mixer>` (breaking) - [playback] Changed type alias `MixerFn` to return `Result<Arc<dyn Mixer>, Error>` instead of `Arc<dyn Mixer>` (breaking)
- [playback] Optimize audio conversion to always dither at 16-bit level and use bit shifts for scaling - [playback] Optimize audio conversion to always dither at 16-bit level, and improve performance
- [playback] Normalizer maintains better stereo imaging, while also being faster
### Added ### Added

View file

@ -78,8 +78,10 @@ struct PlayerInternal {
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>, event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
converter: Converter, converter: Converter,
normalisation_integrator: f64, normalisation_integrators: [f64; 2],
normalisation_peak: f64, normalisation_peaks: [f64; 2],
normalisation_channel: usize,
normalisation_knee_factor: f64,
auto_normalise_as_album: bool, auto_normalise_as_album: bool,
@ -466,6 +468,7 @@ impl Player {
debug!("new Player [{player_id}]"); debug!("new Player [{player_id}]");
let converter = Converter::new(config.ditherer); let converter = Converter::new(config.ditherer);
let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db);
let internal = PlayerInternal { let internal = PlayerInternal {
session, session,
@ -482,8 +485,10 @@ impl Player {
event_senders: vec![], event_senders: vec![],
converter, converter,
normalisation_peak: 0.0, normalisation_peaks: [0.0; 2],
normalisation_integrator: 0.0, normalisation_integrators: [0.0; 2],
normalisation_channel: 0,
normalisation_knee_factor,
auto_normalise_as_album: false, auto_normalise_as_album: false,
@ -1574,29 +1579,25 @@ impl PlayerInternal {
Some((_, mut packet)) => { Some((_, mut packet)) => {
if !packet.is_empty() { if !packet.is_empty() {
if let AudioPacket::Samples(ref mut data) = packet { if let AudioPacket::Samples(ref mut data) = packet {
// Get the volume for the packet. // Get the volume for the packet. In the case of hardware volume control
// In the case of hardware volume control this will // this will always be 1.0 (no change).
// always be 1.0 (no change).
let volume = self.volume_getter.attenuation_factor(); let volume = self.volume_getter.attenuation_factor();
// For the basic normalisation method, a normalisation factor of 1.0 indicates that // For the basic normalisation method, a normalisation factor of 1.0
// there is nothing to normalise (all samples should pass unaltered). For the // indicates that there is nothing to normalise (all samples should pass
// dynamic method, there may still be peaks that we want to shave off. // 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. // No matter the case we apply volume attenuation last if there is any.
if !self.config.normalisation { match (self.config.normalisation, self.config.normalisation_method) {
(false, _) => {
if volume < 1.0 { if volume < 1.0 {
for sample in data.iter_mut() { for sample in data.iter_mut() {
*sample *= volume; *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 { (true, NormalisationMethod::Dynamic) => {
// zero-cost shorthands // zero-cost shorthands
let threshold_db = self.config.normalisation_threshold_dbfs; let threshold_db = self.config.normalisation_threshold_dbfs;
let knee_db = self.config.normalisation_knee_db; let knee_db = self.config.normalisation_knee_db;
@ -1604,82 +1605,68 @@ impl PlayerInternal {
let release_cf = self.config.normalisation_release_cf; let release_cf = self.config.normalisation_release_cf;
for sample in data.iter_mut() { for sample in data.iter_mut() {
// 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.
// This implementation assumes audio is stereo.
// step 0: apply gain stage
*sample *= normalisation_factor; *sample *= normalisation_factor;
// Feedforward limiter in the log domain // step 1-4: half-wave rectification and conversion into dB, and
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic // gain computer with soft knee and subtractor
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio let limiter_db = {
// Engineering Society, 60, 399-408. // Add slight DC offset. Some samples are silence, which is
// -inf dB and gets the limiter stuck. Adding a small
// positive offset prevents this.
*sample += f64::MIN_POSITIVE;
// 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 bias_db = ratio_to_db(sample.abs()) - threshold_db;
let knee_boundary_db = bias_db * 2.0; let knee_boundary_db = bias_db * 2.0;
if knee_boundary_db < -knee_db { if knee_boundary_db < -knee_db {
0.0 0.0
} else if knee_boundary_db.abs() <= knee_db { } else if knee_boundary_db.abs() <= knee_db {
// The textbook equation: let term = knee_boundary_db + knee_db;
// ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db)) term * term * self.normalisation_knee_factor
// 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 { } else {
// Textbook:
// ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db.
bias_db bias_db
} }
} else {
0.0
}; };
// Spare the CPU unless (1) the limiter is engaged, (2) we // track left/right channel
// were in attack or (3) we were in release, and that attack/ let channel = self.normalisation_channel;
// release wasn't finished yet. self.normalisation_channel ^= 1;
if limiter_db > 0.0
|| self.normalisation_integrator > 0.0 // step 5: smooth, decoupled peak detector for each channel
|| self.normalisation_peak > 0.0 // Use direct references to reduce repeated array indexing
{ let integrator = &mut self.normalisation_integrators[channel];
// step 5: smooth, decoupled peak detector let peak = &mut self.normalisation_peaks[channel];
// Textbook:
// release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db *integrator = f64::max(
// Simplifies to:
// release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db
self.normalisation_integrator = f64::max(
limiter_db, limiter_db,
release_cf * self.normalisation_integrator release_cf * *integrator + (1.0 - release_cf) * limiter_db,
- release_cf * limiter_db
+ limiter_db,
); );
// Textbook: *peak = attack_cf * *peak + (1.0 - attack_cf) * *integrator;
// 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) // steps 6-8: conversion into level and multiplication into gain
// Applying the standard normalisation factor here won't work, // stage. Find maximum peak across both channels to couple the
// because there are tracks with peaks as high as 6 dB above // gain and maintain stereo imaging.
// the default threshold, so that would clip. let max_peak = f64::max(
self.normalisation_peaks[0],
// steps 7-8: conversion into level and multiplication into gain stage self.normalisation_peaks[1],
*sample *= db_to_ratio(-self.normalisation_peak); );
*sample *= db_to_ratio(-max_peak) * volume;
}
}
(true, NormalisationMethod::Basic) => {
if normalisation_factor < 1.0 || volume < 1.0 {
for sample in data.iter_mut() {
*sample *= normalisation_factor * volume;
}
} }
*sample *= volume;
} }
} }
} }