use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::context::{AudioContextRegistration, AudioParamId, BaseAudioContext};
use crate::param::{AudioParam, AudioParamDescriptor};
use crate::render::{AudioParamValues, AudioProcessor, AudioRenderQuantum, RenderScope};
use crate::{AtomicF32, RENDER_QUANTUM_SIZE};
use super::{AudioNode, ChannelConfig, ChannelConfigOptions};
// Converting a value 𝑣 in decibels to linear gain unit means returning 10𝑣/20.
fn db_to_lin(val: f32) -> f32 {
(10.0_f32).powf(val / 20.)
}
// Converting a value 𝑣 in linear gain unit to decibel means executing the following steps:
// If 𝑣 is equal to zero, return -1000.
// Else, return 20log10𝑣.
fn lin_to_db(val: f32) -> f32 {
if val == 0. {
-1000.
} else {
20. * val.log10() // 20 * log10(val);
}
}
/// Options for constructing a [`DynamicsCompressorNode`]
// https://webaudio.github.io/web-audio-api/#DynamicsCompressorOptions
// dictionary DynamicsCompressorOptions : AudioNodeOptions {
// float attack = 0.003;
// float knee = 30;
// float ratio = 12;
// float release = 0.25;
// float threshold = -24;
// };
#[derive(Clone, Debug)]
pub struct DynamicsCompressorOptions {
pub attack: f32,
pub knee: f32,
pub ratio: f32,
pub release: f32,
pub threshold: f32,
pub channel_config: ChannelConfigOptions,
}
impl Default for DynamicsCompressorOptions {
fn default() -> Self {
Self {
attack: 0.003, // seconds
knee: 30., // dB
ratio: 12., // unit less
release: 0.25, // seconds
threshold: -24., // dB
channel_config: ChannelConfigOptions::default(),
}
}
}
/// `DynamicsCompressorNode` provides a compression effect.
///
/// It lowers the volume of the loudest parts of the signal and raises the volume
/// of the softest parts. Overall, a louder, richer, and fuller sound can be achieved.
/// It is especially important in games and musical applications where large numbers
/// of individual sounds are played simultaneous to control the overall signal level
/// and help avoid clipping (distorting) the audio output to the speakers.
///
/// - MDN documentation: <https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode>
/// - specification: <https://webaudio.github.io/web-audio-api/#DynamicsCompressorNode>
/// - see also: [`BaseAudioContext::create_dynamics_compressor`](crate::context::BaseAudioContext::create_dynamics_compressor)
///
/// # Usage
///
/// ```no_run
/// use std::fs::File;
/// use web_audio_api::context::{BaseAudioContext, AudioContext};
/// use web_audio_api::node::{AudioNode, AudioScheduledSourceNode};
///
/// // create an `AudioContext`
/// let context = AudioContext::default();
/// // load and decode a soundfile into an audio buffer
/// let file = File::open("samples/sample.wav").unwrap();
/// let buffer = context.decode_audio_data_sync(file).unwrap();
///
/// // create compressor and connect to destination
/// let compressor = context.create_dynamics_compressor();
/// compressor.connect(&context.destination());
///
/// // pipe the audio source in the compressor
/// let src = context.create_buffer_source();
/// src.connect(&compressor);
/// src.set_buffer(buffer.clone());
/// src.start();
/// ```
///
/// # Examples
///
/// - `cargo run --release --example compressor`
///
pub struct DynamicsCompressorNode {
registration: AudioContextRegistration,
channel_config: ChannelConfig,
attack: AudioParam,
knee: AudioParam,
ratio: AudioParam,
release: AudioParam,
threshold: AudioParam,
reduction: Arc<AtomicF32>,
}
impl AudioNode for DynamicsCompressorNode {
fn registration(&self) -> &AudioContextRegistration {
&self.registration
}
fn channel_config(&self) -> &ChannelConfig {
&self.channel_config
}
fn number_of_inputs(&self) -> usize {
1
}
fn number_of_outputs(&self) -> usize {
1
}
}
impl DynamicsCompressorNode {
pub fn new<C: BaseAudioContext>(context: &C, options: DynamicsCompressorOptions) -> Self {
context.register(move |registration| {
// attack, knee, ratio, release and threshold have automation rate constraints
// https://webaudio.github.io/web-audio-api/#audioparam-automation-rate-constraints
let attack_param_opts = AudioParamDescriptor {
min_value: 0.,
max_value: 1.,
default_value: 0.003,
automation_rate: crate::param::AutomationRate::K,
};
let (mut attack_param, attack_proc) =
context.create_audio_param(attack_param_opts, ®istration);
attack_param.set_automation_rate_constrained(true);
attack_param.set_value(options.attack);
let knee_param_opts = AudioParamDescriptor {
min_value: 0.,
max_value: 40.,
default_value: 30.,
automation_rate: crate::param::AutomationRate::K,
};
let (mut knee_param, knee_proc) =
context.create_audio_param(knee_param_opts, ®istration);
knee_param.set_automation_rate_constrained(true);
knee_param.set_value(options.knee);
let ratio_param_opts = AudioParamDescriptor {
min_value: 1.,
max_value: 20.,
default_value: 12.,
automation_rate: crate::param::AutomationRate::K,
};
let (mut ratio_param, ratio_proc) =
context.create_audio_param(ratio_param_opts, ®istration);
ratio_param.set_automation_rate_constrained(true);
ratio_param.set_value(options.ratio);
let release_param_opts = AudioParamDescriptor {
min_value: 0.,
max_value: 1.,
default_value: 0.25,
automation_rate: crate::param::AutomationRate::K,
};
let (mut release_param, release_proc) =
context.create_audio_param(release_param_opts, ®istration);
release_param.set_automation_rate_constrained(true);
release_param.set_value(options.release);
let threshold_param_opts = AudioParamDescriptor {
min_value: -100.,
max_value: 0.,
default_value: -24.,
automation_rate: crate::param::AutomationRate::K,
};
let (mut threshold_param, threshold_proc) =
context.create_audio_param(threshold_param_opts, ®istration);
threshold_param.set_automation_rate_constrained(true);
threshold_param.set_value(options.threshold);
let reduction = Arc::new(AtomicF32::new(0.));
// define the number of buffers we need to have a delay line of ~6ms
// const delay = new DelayNode(context, {delayTime: 0.006});
let ring_buffer_size =
(context.sample_rate() * 0.006 / RENDER_QUANTUM_SIZE as f32).ceil() as usize + 1;
let ring_buffer = Vec::<AudioRenderQuantum>::with_capacity(ring_buffer_size);
let render = DynamicsCompressorRenderer {
attack: attack_proc,
knee: knee_proc,
ratio: ratio_proc,
release: release_proc,
threshold: threshold_proc,
reduction: reduction.clone(),
ring_buffer,
ring_index: 0,
prev_detector_value: 0.,
};
let node = DynamicsCompressorNode {
registration,
channel_config: options.channel_config.into(),
attack: attack_param,
knee: knee_param,
ratio: ratio_param,
release: release_param,
threshold: threshold_param,
reduction,
};
(node, Box::new(render))
})
}
pub fn attack(&self) -> &AudioParam {
&self.attack
}
pub fn knee(&self) -> &AudioParam {
&self.knee
}
pub fn ratio(&self) -> &AudioParam {
&self.ratio
}
pub fn release(&self) -> &AudioParam {
&self.release
}
pub fn threshold(&self) -> &AudioParam {
&self.threshold
}
pub fn reduction(&self) -> f32 {
self.reduction.load(Ordering::SeqCst)
}
}
struct DynamicsCompressorRenderer {
attack: AudioParamId,
knee: AudioParamId,
ratio: AudioParamId,
release: AudioParamId,
threshold: AudioParamId,
reduction: Arc<AtomicF32>,
ring_buffer: Vec<AudioRenderQuantum>,
ring_index: usize,
prev_detector_value: f32,
}
// SAFETY:
// AudioRenderQuantums are not Send but we promise the `ring_buffer` Vec is
// empty before we ship it to the render thread.
#[allow(clippy::non_send_fields_in_send_ty)]
unsafe impl Send for DynamicsCompressorRenderer {}
// https://webaudio.github.io/web-audio-api/#DynamicsCompressorOptions-processing
// see also https://www.eecs.qmul.ac.uk/~josh/documents/2012/GiannoulisMassbergReiss-dynamicrangecompression-JAES2012.pdf
// follow Fig. 7 (c) diagram in paper
impl AudioProcessor for DynamicsCompressorRenderer {
fn process(
&mut self,
inputs: &[AudioRenderQuantum],
outputs: &mut [AudioRenderQuantum],
params: AudioParamValues,
scope: &RenderScope,
) -> bool {
// single input/output node
let input = inputs[0].clone();
let output = &mut outputs[0];
let sample_rate = scope.sample_rate;
let ring_size = self.ring_buffer.capacity();
// ensure ring buffer is filled with silence
if self.ring_buffer.len() < ring_size {
let mut silence = input.clone();
silence.make_silent();
self.ring_buffer.resize(ring_size, silence);
}
// setup values for compression curve
// https://webaudio.github.io/web-audio-api/#compression-curve
let threshold = params.get(&self.threshold)[0];
let knee = params.get(&self.knee)[0];
let ratio = params.get(&self.ratio)[0];
// @note: if knee != 0. we shadow threshold to match definitions of knee
// and threshold given in https://www.eecs.qmul.ac.uk/~josh/documents/2012/
// where knee is centered around threshold.
// We can thus reuse their formula for the gain computer stage.
// yG =
// xG if 2(xG − T) < −W
// xG + (1/R − 1)(xG − T + W/2)^2 / (2W) if 2|(xG − T)| ≤ W
// T + (xG − T)/R if 2(xG − T) > W
// This is weird, and probably wrong because `knee` and `threshold` are not
// independant, but matches the spec.
let threshold = if knee > 0. {
threshold + knee / 2.
} else {
threshold
};
let half_knee = knee / 2.;
// pre-compute for this block the constant part of the formula of the knee
let knee_partial = (1. / ratio - 1.) / (2. * knee);
// compute time constants for attack and release - eq. (7) in paper
let attack = params.get(&self.attack)[0];
let release = params.get(&self.release)[0];
let attack_tau = (-1. / (attack * sample_rate)).exp();
let release_tau = (-1. / (release * sample_rate)).exp();
// Computing the makeup gain means executing the following steps:
// - Let full range gain be the value returned by applying the compression curve to the value 1.0.
// - Let full range makeup gain be the inverse of full range gain.
// - Return the result of taking the 0.6 power of full range makeup gain.
// @note: this should be confirmed / simplified, maybe could do all this in dB
// seems coherent with chrome implementation
let full_range_gain = threshold + (-threshold / ratio);
let full_range_makeup = 1. / db_to_lin(full_range_gain);
let makeup_gain = lin_to_db(full_range_makeup.powf(0.6));
let mut prev_detector_value = self.prev_detector_value;
let mut reduction_gain = 0.; // dB
let mut reduction_gains = [0.; 128]; // lin
let mut detector_values = [0.; 128]; // lin
for i in 0..RENDER_QUANTUM_SIZE {
// pick highest value for this index across all input channels
// @tbc - this seems to be what is done in chrome
let mut max = f32::MIN;
for channel in input.channels().iter() {
let sample = channel[i].abs();
if sample > max {
max = sample;
}
}
// pick absolute value and convert to dB domain
// var xG in paper
let sample_db = lin_to_db(max);
// Gain Computer stage
// ------------------------------------------------
// var yG - eq. 4 in paper
// if knee == 0. (hard knee), the `else if` branch is bypassed
let sample_attenuated = if sample_db <= threshold - half_knee {
sample_db
} else if sample_db <= threshold + half_knee {
sample_db + (sample_db - threshold + half_knee).powi(2) * knee_partial
} else {
threshold + (sample_db - threshold) / ratio
};
// variable xL in paper
let sample_attenuation = sample_db - sample_attenuated;
// Level Detector stage
// ------------------------------------------------
// Branching peak detector - eq. 16 in paper - var yL
// attack branch
let detector_value = if sample_attenuation > prev_detector_value {
attack_tau * prev_detector_value + (1. - attack_tau) * sample_attenuation
// release branch
} else {
release_tau * prev_detector_value + (1. - release_tau) * sample_attenuation
};
detector_values[i] = detector_value;
// cdB = -yL + make up gain
reduction_gain = -1. * detector_value + makeup_gain;
// convert to lin now, so we just to multiply samples later
reduction_gains[i] = db_to_lin(reduction_gain);
// update prev_detector_value for next sample
prev_detector_value = detector_value;
}
// update prev_detector_value for next block
self.prev_detector_value = prev_detector_value;
// update reduction shared w/ main thread
self.reduction.store(reduction_gain, Ordering::SeqCst);
// store input in delay line
self.ring_buffer[self.ring_index] = input;
// apply compression to delayed signal
let read_index = (self.ring_index + 1) % ring_size;
let delayed = &self.ring_buffer[read_index];
self.ring_index = read_index;
*output = delayed.clone();
// if delayed signal is silent, there is no compression to apply
// thus we can consider the node has reach is tail time. (TBC)
if output.is_silent() {
output.make_silent(); // truncate to 1 channel if needed
return false;
}
output.channels_mut().iter_mut().for_each(|channel| {
channel
.iter_mut()
.zip(reduction_gains.iter())
.for_each(|(o, g)| *o *= g);
});
true
}
}
#[cfg(test)]
mod tests {
use float_eq::assert_float_eq;
use crate::context::OfflineAudioContext;
use crate::node::AudioScheduledSourceNode;
use super::*;
#[test]
fn test_constructor() {
{
let context = OfflineAudioContext::new(1, 0, 44_100.);
let compressor = DynamicsCompressorNode::new(&context, Default::default());
assert_float_eq!(compressor.attack().value(), 0.003, abs <= 0.);
assert_float_eq!(compressor.knee().value(), 30., abs <= 0.);
assert_float_eq!(compressor.ratio().value(), 12., abs <= 0.);
assert_float_eq!(compressor.release().value(), 0.25, abs <= 0.);
assert_float_eq!(compressor.threshold().value(), -24., abs <= 0.);
}
{
let context = OfflineAudioContext::new(1, 0, 44_100.);
let compressor = DynamicsCompressorNode::new(
&context,
DynamicsCompressorOptions {
attack: 0.5,
knee: 12.,
ratio: 1.,
release: 0.75,
threshold: -60.,
..DynamicsCompressorOptions::default()
},
);
assert_float_eq!(compressor.attack().value(), 0.5, abs <= 0.);
assert_float_eq!(compressor.knee().value(), 12., abs <= 0.);
assert_float_eq!(compressor.ratio().value(), 1., abs <= 0.);
assert_float_eq!(compressor.release().value(), 0.75, abs <= 0.);
assert_float_eq!(compressor.threshold().value(), -60., abs <= 0.);
}
}
#[test]
fn test_inner_delay() {
let sample_rate = 44_100.;
let compressor_delay = 0.006;
// index of the first non zero sample, rounded at next block after
// compressor theoretical delay, i.e. 3 blocks at this sample_rate
let non_zero_index = (compressor_delay * sample_rate / RENDER_QUANTUM_SIZE as f32).ceil()
as usize
* RENDER_QUANTUM_SIZE;
let context = OfflineAudioContext::new(1, 128 * 8, sample_rate);
let compressor = DynamicsCompressorNode::new(&context, Default::default());
compressor.connect(&context.destination());
let mut buffer = context.create_buffer(1, 128 * 5, sample_rate);
let signal = [1.; 128 * 5];
buffer.copy_to_channel(&signal, 0);
let src = context.create_buffer_source();
src.set_buffer(buffer);
src.connect(&compressor);
src.start();
let res = context.start_rendering_sync();
let chan = res.channel_data(0).as_slice();
// this is the delay
assert_float_eq!(
chan[0..non_zero_index],
vec![0.; non_zero_index][..],
abs_all <= 0.
);
// as some compresssion is applied, we just check the remaining is non zero
for sample in chan.iter().take(128 * 8).skip(non_zero_index) {
assert!(*sample != 0.);
}
}
#[test]
fn test_db_to_lin() {
assert_float_eq!(db_to_lin(0.), 1., abs <= 0.);
assert_float_eq!(db_to_lin(-20.), 0.1, abs <= 1e-8);
assert_float_eq!(db_to_lin(-40.), 0.01, abs <= 1e-8);
assert_float_eq!(db_to_lin(-60.), 0.001, abs <= 1e-8);
}
#[test]
fn test_lin_to_db() {
assert_float_eq!(lin_to_db(1.), 0., abs <= 0.);
assert_float_eq!(lin_to_db(0.1), -20., abs <= 0.);
assert_float_eq!(lin_to_db(0.01), -40., abs <= 0.);
assert_float_eq!(lin_to_db(0.001), -60., abs <= 0.);
// special case
assert_float_eq!(lin_to_db(0.), -1000., abs <= 0.);
}
// @note: keep this, is usefull to grab some internal value to be plotted
// #[test]
// fn test_attenuated_values() {
// // threshold: -40.
// // knee: 0.
// // ratio: 12.
// let sample_rate = 1_000.;
// let context = OfflineAudioContext::new(1, 128, sample_rate);
// let compressor = DynamicsCompressorNode::new(&context, Default::default());
// compressor.knee().set_value(0.);
// compressor.threshold().set_value(-30.);
// compressor.attack().set_value(0.05);
// compressor.release().set_value(0.1);
// compressor.connect(&context.destination());
// let mut buffer = context.create_buffer(1, 128 * 8, sample_rate);
// let mut signal = [0.; 128 * 8];
// for (i, s) in signal.iter_mut().enumerate() {
// *s = if i < 300 { 1. } else { 0.3 };
// }
// // println!("{:?}", signal);
// buffer.copy_to_channel(&signal, 0);
// let src = context.create_buffer_source();
// src.set_buffer(buffer);
// src.connect(&compressor);
// src.start();
// let _res = context.start_rendering_sync();
// }
}