Skip to content

Commit

Permalink
Move audio processing out of player
Browse files Browse the repository at this point in the history
  • Loading branch information
JasonLG1979 committed Jun 18, 2023
1 parent 85a7d26 commit 28fe6d1
Show file tree
Hide file tree
Showing 7 changed files with 386 additions and 297 deletions.
12 changes: 12 additions & 0 deletions playback/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,23 @@ pub mod convert;
pub mod decoder;
pub mod dither;
pub mod mixer;
pub mod normaliser;
pub mod player;
pub mod resampler;
pub mod sample_pipeline;

pub const SAMPLE_RATE: u32 = 44100;
pub const NUM_CHANNELS: u8 = 2;
pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE * NUM_CHANNELS as u32;
pub const PAGES_PER_MS: f64 = SAMPLE_RATE as f64 / 1000.0;
pub const MS_PER_PAGE: f64 = 1000.0 / SAMPLE_RATE as f64;
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
pub const PCM_AT_0DBFS: f64 = 1.0;

fn db_to_ratio(db: f64) -> f64 {
f64::powf(10.0, db / DB_VOLTAGE_RATIO)
}

fn ratio_to_db(ratio: f64) -> f64 {
ratio.log10() * DB_VOLTAGE_RATIO
}
2 changes: 1 addition & 1 deletion playback/src/mixer/alsamixer.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::player::{db_to_ratio, ratio_to_db};
use crate::{db_to_ratio, ratio_to_db};

use super::mappings::{LogMapping, MappedCtrl, VolumeMapping};
use super::{Mixer, MixerConfig, VolumeCtrl};
Expand Down
2 changes: 1 addition & 1 deletion playback/src/mixer/mappings.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::VolumeCtrl;
use crate::player::db_to_ratio;
use crate::db_to_ratio;

pub trait MappedCtrl {
fn to_mapped(&self, volume: u16) -> f64;
Expand Down
312 changes: 312 additions & 0 deletions playback/src/normaliser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
use crate::{
config::{NormalisationMethod, NormalisationType, PlayerConfig},
db_to_ratio,
mixer::VolumeGetter,
player::NormalisationData,
ratio_to_db, PCM_AT_0DBFS,
};

trait Normalisation: Send {
fn new(config: &PlayerConfig) -> Self
where
Self: Sized;

fn stop(&mut self) {}
fn normalise(&mut self, samples: &[f64], volume: f64, factor: f64) -> Vec<f64>;
}

struct NoNormalisation;

impl Normalisation for NoNormalisation {
fn new(_: &PlayerConfig) -> Self {
Self
}

fn normalise(&mut self, samples: &[f64], volume: f64, _: f64) -> Vec<f64> {
if volume < 1.0 {
let mut output = Vec::with_capacity(samples.len());

output.extend(samples.iter().map(|sample| sample * volume));

output
} else {
samples.to_vec()
}
}
}

struct BasicNormalisation;

impl Normalisation for BasicNormalisation {
fn new(config: &PlayerConfig) -> Self {
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);

Self
}

fn normalise(&mut self, samples: &[f64], volume: f64, factor: f64) -> Vec<f64> {
if volume < 1.0 || factor < 1.0 {
let mut output = Vec::with_capacity(samples.len());

output.extend(samples.iter().map(|sample| sample * factor * volume));

output
} else {
samples.to_vec()
}
}
}

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

impl Normalisation for DynamicNormalisation {
fn new(config: &PlayerConfig) -> Self {
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);

// as_millis() has rounding errors (truncates)
debug!(
"Normalisation Attack: {:.0} ms",
config
.sample_rate
.normalisation_coefficient_to_duration(config.normalisation_attack_cf)
.as_secs_f64()
* 1000.
);

debug!(
"Normalisation Release: {:.0} ms",
config
.sample_rate
.normalisation_coefficient_to_duration(config.normalisation_release_cf)
.as_secs_f64()
* 1000.
);

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

fn stop(&mut self) {
self.integrator = 0.0;
self.peak = 0.0;
}

fn normalise(&mut self, samples: &[f64], volume: f64, factor: f64) -> Vec<f64> {
let mut output = Vec::with_capacity(samples.len());

output.extend(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 * integrator + 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
}));

output
}
}

pub struct Normaliser {
normalisation: Box<dyn Normalisation>,
volume_getter: Box<dyn VolumeGetter + Send>,
factor: f64,
}

impl Normaliser {
pub fn new(config: &PlayerConfig, volume_getter: Box<dyn VolumeGetter + Send>) -> Self {
let normalisation: Box<dyn Normalisation> =
match (config.normalisation, config.normalisation_method) {
(true, NormalisationMethod::Dynamic) => Box::new(DynamicNormalisation::new(config)),
(true, NormalisationMethod::Basic) => Box::new(BasicNormalisation::new(config)),
_ => Box::new(NoNormalisation::new(config)),
};

Self {
normalisation,
volume_getter,
factor: 0.0,
}
}

pub fn normalise(&mut self, samples: &[f64]) -> Vec<f64> {
let volume = self.volume_getter.attenuation_factor();

self.normalisation.normalise(samples, volume, self.factor)
}

pub fn stop(&mut self) {
self.normalisation.stop();
}

pub fn set_factor(&mut self, config: &PlayerConfig, data: NormalisationData) {
self.factor = Self::get_factor(config, data);
}

fn get_factor(config: &PlayerConfig, data: NormalisationData) -> f64 {
if !config.normalisation {
return 1.0;
}

let (gain_db, gain_peak) = if config.normalisation_type == NormalisationType::Album {
(data.album_gain_db, data.album_peak)
} else {
(data.track_gain_db, data.track_peak)
};

// As per the ReplayGain 1.0 & 2.0 (proposed) spec:
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Clipping_prevention
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Clipping_prevention
let normalisation_factor = if config.normalisation_method == NormalisationMethod::Basic {
// For Basic Normalisation, factor = min(ratio of (ReplayGain + PreGain), 1.0 / peak level).
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Peak_amplitude
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Peak_amplitude
// We then limit that to 1.0 as not to exceed dBFS (0.0 dB).
let factor = f64::min(
db_to_ratio(gain_db + config.normalisation_pregain_db),
PCM_AT_0DBFS / gain_peak,
);

if factor > PCM_AT_0DBFS {
info!(
"Lowering gain by {:.2} dB for the duration of this track to avoid potentially exceeding dBFS.",
ratio_to_db(factor)
);

PCM_AT_0DBFS
} else {
factor
}
} else {
// For Dynamic Normalisation it's up to the player to decide,
// factor = ratio of (ReplayGain + PreGain).
// We then let the dynamic limiter handle gain reduction.
let factor = db_to_ratio(gain_db + config.normalisation_pregain_db);
let threshold_ratio = db_to_ratio(config.normalisation_threshold_dbfs);

if factor > PCM_AT_0DBFS {
let factor_db = gain_db + config.normalisation_pregain_db;
let limiting_db = factor_db + config.normalisation_threshold_dbfs.abs();

warn!(
"This track may exceed dBFS by {:.2} dB and be subject to {:.2} dB of dynamic limiting at it's peak.",
factor_db, limiting_db
);
} else if factor > threshold_ratio {
let limiting_db = gain_db
+ config.normalisation_pregain_db
+ config.normalisation_threshold_dbfs.abs();

info!(
"This track may be subject to {:.2} dB of dynamic limiting at it's peak.",
limiting_db
);
}

factor
};

debug!("Normalisation Data: {:?}", data);
debug!(
"Calculated Normalisation Factor for {:?}: {:.2}%",
config.normalisation_type,
normalisation_factor * 100.0
);

normalisation_factor
}
}
Loading

0 comments on commit 28fe6d1

Please sign in to comment.