Skip to content

Commit 9456a02

Browse files
authored
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.
1 parent 19f635f commit 9456a02

2 files changed

Lines changed: 91 additions & 103 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- [metadata] Replaced `AudioFileFormat` with own enum. (breaking)
2222
- [playback] Changed trait `Mixer::open` to return `Result<Self, Error>` instead of `Self` (breaking)
2323
- [playback] Changed type alias `MixerFn` to return `Result<Arc<dyn Mixer>, Error>` instead of `Arc<dyn Mixer>` (breaking)
24-
- [playback] Optimize audio conversion to always dither at 16-bit level and use bit shifts for scaling
24+
- [playback] Optimize audio conversion to always dither at 16-bit level, and improve performance
25+
- [playback] Normalizer maintains better stereo imaging, while also being faster
2526

2627
### Added
2728

playback/src/player.rs

Lines changed: 89 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@ struct PlayerInternal {
7878
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
7979
converter: Converter,
8080

81-
normalisation_integrator: f64,
82-
normalisation_peak: f64,
81+
normalisation_integrators: [f64; 2],
82+
normalisation_peaks: [f64; 2],
83+
normalisation_channel: usize,
84+
normalisation_knee_factor: f64,
8385

8486
auto_normalise_as_album: bool,
8587

@@ -466,6 +468,7 @@ impl Player {
466468
debug!("new Player [{player_id}]");
467469

468470
let converter = Converter::new(config.ditherer);
471+
let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db);
469472

470473
let internal = PlayerInternal {
471474
session,
@@ -482,8 +485,10 @@ impl Player {
482485
event_senders: vec![],
483486
converter,
484487

485-
normalisation_peak: 0.0,
486-
normalisation_integrator: 0.0,
488+
normalisation_peaks: [0.0; 2],
489+
normalisation_integrators: [0.0; 2],
490+
normalisation_channel: 0,
491+
normalisation_knee_factor,
487492

488493
auto_normalise_as_album: false,
489494

@@ -1574,112 +1579,94 @@ impl PlayerInternal {
15741579
Some((_, mut packet)) => {
15751580
if !packet.is_empty() {
15761581
if let AudioPacket::Samples(ref mut data) = packet {
1577-
// Get the volume for the packet.
1578-
// In the case of hardware volume control this will
1579-
// always be 1.0 (no change).
1582+
// Get the volume for the packet. In the case of hardware volume control
1583+
// this will always be 1.0 (no change).
15801584
let volume = self.volume_getter.attenuation_factor();
15811585

1582-
// For the basic normalisation method, a normalisation factor of 1.0 indicates that
1583-
// there is nothing to normalise (all samples should pass unaltered). For the
1584-
// dynamic method, there may still be peaks that we want to shave off.
1585-
1586+
// For the basic normalisation method, a normalisation factor of 1.0
1587+
// indicates that there is nothing to normalise (all samples should pass
1588+
// unaltered). For the dynamic method, there may still be peaks that we
1589+
// want to shave off.
1590+
//
15861591
// No matter the case we apply volume attenuation last if there is any.
1587-
if !self.config.normalisation {
1588-
if volume < 1.0 {
1589-
for sample in data.iter_mut() {
1590-
*sample *= volume;
1592+
match (self.config.normalisation, self.config.normalisation_method) {
1593+
(false, _) => {
1594+
if volume < 1.0 {
1595+
for sample in data.iter_mut() {
1596+
*sample *= volume;
1597+
}
15911598
}
15921599
}
1593-
} else if self.config.normalisation_method == NormalisationMethod::Basic
1594-
&& (normalisation_factor < 1.0 || volume < 1.0)
1595-
{
1596-
for sample in data.iter_mut() {
1597-
*sample *= normalisation_factor * volume;
1598-
}
1599-
} else if self.config.normalisation_method == NormalisationMethod::Dynamic {
1600-
// zero-cost shorthands
1601-
let threshold_db = self.config.normalisation_threshold_dbfs;
1602-
let knee_db = self.config.normalisation_knee_db;
1603-
let attack_cf = self.config.normalisation_attack_cf;
1604-
let release_cf = self.config.normalisation_release_cf;
1605-
1606-
for sample in data.iter_mut() {
1607-
*sample *= normalisation_factor;
1608-
1609-
// Feedforward limiter in the log domain
1610-
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic
1611-
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio
1612-
// Engineering Society, 60, 399-408.
1613-
1614-
// Some tracks have samples that are precisely 0.0. That's silence
1615-
// and we know we don't need to limit that, in which we can spare
1616-
// the CPU cycles.
1617-
//
1618-
// Also, calling `ratio_to_db(0.0)` returns `inf` and would get the
1619-
// peak detector stuck. Also catch the unlikely case where a sample
1620-
// is decoded as `NaN` or some other non-normal value.
1621-
let limiter_db = if sample.is_normal() {
1622-
// step 1-4: half-wave rectification and conversion into dB
1623-
// and gain computer with soft knee and subtractor
1624-
let bias_db = ratio_to_db(sample.abs()) - threshold_db;
1625-
let knee_boundary_db = bias_db * 2.0;
1626-
1627-
if knee_boundary_db < -knee_db {
1628-
0.0
1629-
} else if knee_boundary_db.abs() <= knee_db {
1630-
// The textbook equation:
1631-
// ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db))
1632-
// Simplifies to:
1633-
// ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db)
1634-
// Which in our case further simplifies to:
1635-
// (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
1636-
// because knee_boundary_db is 2.0 * bias_db.
1637-
(knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
1638-
} else {
1639-
// Textbook:
1640-
// ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db.
1641-
bias_db
1642-
}
1643-
} else {
1644-
0.0
1645-
};
1646-
1647-
// Spare the CPU unless (1) the limiter is engaged, (2) we
1648-
// were in attack or (3) we were in release, and that attack/
1649-
// release wasn't finished yet.
1650-
if limiter_db > 0.0
1651-
|| self.normalisation_integrator > 0.0
1652-
|| self.normalisation_peak > 0.0
1653-
{
1654-
// step 5: smooth, decoupled peak detector
1655-
// Textbook:
1656-
// release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db
1657-
// Simplifies to:
1658-
// release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db
1659-
self.normalisation_integrator = f64::max(
1600+
(true, NormalisationMethod::Dynamic) => {
1601+
// zero-cost shorthands
1602+
let threshold_db = self.config.normalisation_threshold_dbfs;
1603+
let knee_db = self.config.normalisation_knee_db;
1604+
let attack_cf = self.config.normalisation_attack_cf;
1605+
let release_cf = self.config.normalisation_release_cf;
1606+
1607+
for sample in data.iter_mut() {
1608+
// Feedforward limiter in the log domain
1609+
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012).
1610+
// Digital Dynamic Range Compressor Design—A Tutorial and
1611+
// Analysis. Journal of The Audio Engineering Society, 60,
1612+
// 399-408.
1613+
1614+
// This implementation assumes audio is stereo.
1615+
1616+
// step 0: apply gain stage
1617+
*sample *= normalisation_factor;
1618+
1619+
// step 1-4: half-wave rectification and conversion into dB, and
1620+
// gain computer with soft knee and subtractor
1621+
let limiter_db = {
1622+
// Add slight DC offset. Some samples are silence, which is
1623+
// -inf dB and gets the limiter stuck. Adding a small
1624+
// positive offset prevents this.
1625+
*sample += f64::MIN_POSITIVE;
1626+
1627+
let bias_db = ratio_to_db(sample.abs()) - threshold_db;
1628+
let knee_boundary_db = bias_db * 2.0;
1629+
if knee_boundary_db < -knee_db {
1630+
0.0
1631+
} else if knee_boundary_db.abs() <= knee_db {
1632+
let term = knee_boundary_db + knee_db;
1633+
term * term * self.normalisation_knee_factor
1634+
} else {
1635+
bias_db
1636+
}
1637+
};
1638+
1639+
// track left/right channel
1640+
let channel = self.normalisation_channel;
1641+
self.normalisation_channel ^= 1;
1642+
1643+
// step 5: smooth, decoupled peak detector for each channel
1644+
// Use direct references to reduce repeated array indexing
1645+
let integrator = &mut self.normalisation_integrators[channel];
1646+
let peak = &mut self.normalisation_peaks[channel];
1647+
1648+
*integrator = f64::max(
16601649
limiter_db,
1661-
release_cf * self.normalisation_integrator
1662-
- release_cf * limiter_db
1663-
+ limiter_db,
1650+
release_cf * *integrator + (1.0 - release_cf) * limiter_db,
1651+
);
1652+
*peak = attack_cf * *peak + (1.0 - attack_cf) * *integrator;
1653+
1654+
// steps 6-8: conversion into level and multiplication into gain
1655+
// stage. Find maximum peak across both channels to couple the
1656+
// gain and maintain stereo imaging.
1657+
let max_peak = f64::max(
1658+
self.normalisation_peaks[0],
1659+
self.normalisation_peaks[1],
16641660
);
1665-
// Textbook:
1666-
// attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator
1667-
// Simplifies to:
1668-
// attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator
1669-
self.normalisation_peak = attack_cf * self.normalisation_peak
1670-
- attack_cf * self.normalisation_integrator
1671-
+ self.normalisation_integrator;
1672-
1673-
// step 6: make-up gain applied later (volume attenuation)
1674-
// Applying the standard normalisation factor here won't work,
1675-
// because there are tracks with peaks as high as 6 dB above
1676-
// the default threshold, so that would clip.
1677-
1678-
// steps 7-8: conversion into level and multiplication into gain stage
1679-
*sample *= db_to_ratio(-self.normalisation_peak);
1661+
*sample *= db_to_ratio(-max_peak) * volume;
1662+
}
1663+
}
1664+
(true, NormalisationMethod::Basic) => {
1665+
if normalisation_factor < 1.0 || volume < 1.0 {
1666+
for sample in data.iter_mut() {
1667+
*sample *= normalisation_factor * volume;
1668+
}
16801669
}
1681-
1682-
*sample *= volume;
16831670
}
16841671
}
16851672
}

0 commit comments

Comments
 (0)