web_audio_api/node/
dynamics_compressor.rs

1use std::sync::atomic::Ordering;
2use std::sync::Arc;
3
4use crate::context::{AudioContextRegistration, AudioParamId, BaseAudioContext};
5use crate::param::{AudioParam, AudioParamDescriptor};
6use crate::render::{
7    AudioParamValues, AudioProcessor, AudioRenderQuantum, AudioWorkletGlobalScope,
8};
9use crate::{AtomicF32, RENDER_QUANTUM_SIZE};
10
11use super::{AudioNode, AudioNodeOptions, ChannelConfig, ChannelCountMode, ChannelInterpretation};
12
13// Converting a value 𝑣 in decibels to linear gain unit means returning 10𝑣/20.
14fn db_to_lin(val: f32) -> f32 {
15    (10.0_f32).powf(val / 20.)
16}
17
18// Converting a value 𝑣 in linear gain unit to decibel means executing the following steps:
19// If 𝑣 is equal to zero, return -1000.
20// Else, return 20log10𝑣.
21fn lin_to_db(val: f32) -> f32 {
22    if val == 0. {
23        -1000.
24    } else {
25        20. * val.log10() // 20 * log10(val);
26    }
27}
28
29/// Options for constructing a [`DynamicsCompressorNode`]
30// https://webaudio.github.io/web-audio-api/#DynamicsCompressorOptions
31// dictionary DynamicsCompressorOptions : AudioNodeOptions {
32//   float attack = 0.003;
33//   float knee = 30;
34//   float ratio = 12;
35//   float release = 0.25;
36//   float threshold = -24;
37// };
38#[derive(Clone, Debug)]
39pub struct DynamicsCompressorOptions {
40    pub attack: f32,
41    pub knee: f32,
42    pub ratio: f32,
43    pub release: f32,
44    pub threshold: f32,
45    pub audio_node_options: AudioNodeOptions,
46}
47
48impl Default for DynamicsCompressorOptions {
49    fn default() -> Self {
50        Self {
51            attack: 0.003,   // seconds
52            knee: 30.,       // dB
53            ratio: 12.,      // unit less
54            release: 0.25,   // seconds
55            threshold: -24., // dB
56            audio_node_options: AudioNodeOptions {
57                channel_count: 2,
58                channel_count_mode: ChannelCountMode::ClampedMax,
59                channel_interpretation: ChannelInterpretation::Speakers,
60            },
61        }
62    }
63}
64
65/// Assert that the channel count is valid for the DynamicsCompressorNode
66/// see <https://webaudio.github.io/web-audio-api/#audionode-channelcount-constraints>
67///
68/// # Panics
69///
70/// This function panics if given count is greater than 2
71///
72#[track_caller]
73#[inline(always)]
74fn assert_valid_channel_count(count: usize) {
75    assert!(
76        count <= 2,
77        "NotSupportedError - DynamicsCompressorNode channel count cannot be greater than two"
78    );
79}
80
81/// Assert that the channel count is valid for the DynamicsCompressorNode
82/// see <https://webaudio.github.io/web-audio-api/#audionode-channelcountmode-constraints>
83///
84/// # Panics
85///
86/// This function panics if given count mode is [`ChannelCountMode::Max`]
87///
88#[track_caller]
89#[inline(always)]
90fn assert_valid_channel_count_mode(mode: ChannelCountMode) {
91    assert_ne!(
92        mode,
93        ChannelCountMode::Max,
94        "NotSupportedError - DynamicsCompressorNode channel count mode cannot be set to max"
95    );
96}
97
98/// `DynamicsCompressorNode` provides a compression effect.
99///
100/// It lowers the volume of the loudest parts of the signal and raises the volume
101/// of the softest parts. Overall, a louder, richer, and fuller sound can be achieved.
102/// It is especially important in games and musical applications where large numbers
103/// of individual sounds are played simultaneous to control the overall signal level
104/// and help avoid clipping (distorting) the audio output to the speakers.
105///
106/// - MDN documentation: <https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode>
107/// - specification: <https://webaudio.github.io/web-audio-api/#DynamicsCompressorNode>
108/// - see also: [`BaseAudioContext::create_dynamics_compressor`]
109///
110/// # Usage
111///
112/// ```no_run
113/// use std::fs::File;
114/// use web_audio_api::context::{BaseAudioContext, AudioContext};
115/// use web_audio_api::node::{AudioNode, AudioScheduledSourceNode};
116///
117/// // create an `AudioContext`
118/// let context = AudioContext::default();
119/// // load and decode a soundfile into an audio buffer
120/// let file = File::open("samples/sample.wav").unwrap();
121/// let buffer = context.decode_audio_data_sync(file).unwrap();
122///
123/// // create compressor and connect to destination
124/// let compressor = context.create_dynamics_compressor();
125/// compressor.connect(&context.destination());
126///
127/// // pipe the audio source in the compressor
128/// let mut src = context.create_buffer_source();
129/// src.connect(&compressor);
130/// src.set_buffer(buffer.clone());
131/// src.start();
132/// ```
133///
134/// # Examples
135///
136/// - `cargo run --release --example compressor`
137///
138#[derive(Debug)]
139pub struct DynamicsCompressorNode {
140    registration: AudioContextRegistration,
141    channel_config: ChannelConfig,
142    attack: AudioParam,
143    knee: AudioParam,
144    ratio: AudioParam,
145    release: AudioParam,
146    threshold: AudioParam,
147    reduction: Arc<AtomicF32>,
148}
149
150impl AudioNode for DynamicsCompressorNode {
151    fn registration(&self) -> &AudioContextRegistration {
152        &self.registration
153    }
154
155    fn channel_config(&self) -> &ChannelConfig {
156        &self.channel_config
157    }
158
159    fn number_of_inputs(&self) -> usize {
160        1
161    }
162
163    fn number_of_outputs(&self) -> usize {
164        1
165    }
166
167    // see <https://webaudio.github.io/web-audio-api/#audionode-channelcount-constraints>
168    fn set_channel_count(&self, count: usize) {
169        assert_valid_channel_count(count);
170        self.channel_config.set_count(count, self.registration());
171    }
172
173    // see <https://webaudio.github.io/web-audio-api/#audionode-channelcountmode-constraints>
174    fn set_channel_count_mode(&self, mode: ChannelCountMode) {
175        assert_valid_channel_count_mode(mode);
176        self.channel_config
177            .set_count_mode(mode, self.registration());
178    }
179}
180
181impl DynamicsCompressorNode {
182    pub fn new<C: BaseAudioContext>(context: &C, options: DynamicsCompressorOptions) -> Self {
183        context.base().register(move |registration| {
184            assert_valid_channel_count(options.audio_node_options.channel_count);
185            assert_valid_channel_count_mode(options.audio_node_options.channel_count_mode);
186
187            // attack, knee, ratio, release and threshold have automation rate constraints
188            // https://webaudio.github.io/web-audio-api/#audioparam-automation-rate-constraints
189            let attack_param_opts = AudioParamDescriptor {
190                name: String::new(),
191                min_value: 0.,
192                max_value: 1.,
193                default_value: 0.003,
194                automation_rate: crate::param::AutomationRate::K,
195            };
196            let (mut attack_param, attack_proc) =
197                context.create_audio_param(attack_param_opts, &registration);
198            attack_param.set_automation_rate_constrained(true);
199            attack_param.set_value(options.attack);
200
201            let knee_param_opts = AudioParamDescriptor {
202                name: String::new(),
203                min_value: 0.,
204                max_value: 40.,
205                default_value: 30.,
206                automation_rate: crate::param::AutomationRate::K,
207            };
208            let (mut knee_param, knee_proc) =
209                context.create_audio_param(knee_param_opts, &registration);
210            knee_param.set_automation_rate_constrained(true);
211            knee_param.set_value(options.knee);
212
213            let ratio_param_opts = AudioParamDescriptor {
214                name: String::new(),
215                min_value: 1.,
216                max_value: 20.,
217                default_value: 12.,
218                automation_rate: crate::param::AutomationRate::K,
219            };
220            let (mut ratio_param, ratio_proc) =
221                context.create_audio_param(ratio_param_opts, &registration);
222            ratio_param.set_automation_rate_constrained(true);
223            ratio_param.set_value(options.ratio);
224
225            let release_param_opts = AudioParamDescriptor {
226                name: String::new(),
227                min_value: 0.,
228                max_value: 1.,
229                default_value: 0.25,
230                automation_rate: crate::param::AutomationRate::K,
231            };
232            let (mut release_param, release_proc) =
233                context.create_audio_param(release_param_opts, &registration);
234            release_param.set_automation_rate_constrained(true);
235            release_param.set_value(options.release);
236
237            let threshold_param_opts = AudioParamDescriptor {
238                name: String::new(),
239                min_value: -100.,
240                max_value: 0.,
241                default_value: -24.,
242                automation_rate: crate::param::AutomationRate::K,
243            };
244            let (mut threshold_param, threshold_proc) =
245                context.create_audio_param(threshold_param_opts, &registration);
246            threshold_param.set_automation_rate_constrained(true);
247            threshold_param.set_value(options.threshold);
248
249            let reduction = Arc::new(AtomicF32::new(0.));
250
251            // define the number of buffers we need to have a delay line of ~6ms
252            // const delay = new DelayNode(context, {delayTime: 0.006});
253            let ring_buffer_size =
254                (context.sample_rate() * 0.006 / RENDER_QUANTUM_SIZE as f32).ceil() as usize + 1;
255            let ring_buffer = Vec::<AudioRenderQuantum>::with_capacity(ring_buffer_size);
256
257            let render = DynamicsCompressorRenderer {
258                attack: attack_proc,
259                knee: knee_proc,
260                ratio: ratio_proc,
261                release: release_proc,
262                threshold: threshold_proc,
263                reduction: Arc::clone(&reduction),
264                ring_buffer,
265                ring_index: 0,
266                prev_detector_value: 0.,
267            };
268
269            let node = DynamicsCompressorNode {
270                registration,
271                channel_config: options.audio_node_options.into(),
272                attack: attack_param,
273                knee: knee_param,
274                ratio: ratio_param,
275                release: release_param,
276                threshold: threshold_param,
277                reduction,
278            };
279
280            (node, Box::new(render))
281        })
282    }
283
284    pub fn attack(&self) -> &AudioParam {
285        &self.attack
286    }
287
288    pub fn knee(&self) -> &AudioParam {
289        &self.knee
290    }
291
292    pub fn ratio(&self) -> &AudioParam {
293        &self.ratio
294    }
295
296    pub fn release(&self) -> &AudioParam {
297        &self.release
298    }
299
300    pub fn threshold(&self) -> &AudioParam {
301        &self.threshold
302    }
303
304    pub fn reduction(&self) -> f32 {
305        self.reduction.load(Ordering::Relaxed)
306    }
307}
308
309struct DynamicsCompressorRenderer {
310    attack: AudioParamId,
311    knee: AudioParamId,
312    ratio: AudioParamId,
313    release: AudioParamId,
314    threshold: AudioParamId,
315    reduction: Arc<AtomicF32>,
316    ring_buffer: Vec<AudioRenderQuantum>,
317    ring_index: usize,
318    prev_detector_value: f32,
319}
320
321// SAFETY:
322// AudioRenderQuantums are not Send but we promise the `ring_buffer` Vec is
323// empty before we ship it to the render thread.
324#[allow(clippy::non_send_fields_in_send_ty)]
325unsafe impl Send for DynamicsCompressorRenderer {}
326
327// https://webaudio.github.io/web-audio-api/#DynamicsCompressorOptions-processing
328// see also https://www.eecs.qmul.ac.uk/~josh/documents/2012/GiannoulisMassbergReiss-dynamicrangecompression-JAES2012.pdf
329// follow Fig. 7 (c) diagram in paper
330impl AudioProcessor for DynamicsCompressorRenderer {
331    fn process(
332        &mut self,
333        inputs: &[AudioRenderQuantum],
334        outputs: &mut [AudioRenderQuantum],
335        params: AudioParamValues<'_>,
336        scope: &AudioWorkletGlobalScope,
337    ) -> bool {
338        // single input/output node
339        let input = inputs[0].clone();
340        let output = &mut outputs[0];
341        let sample_rate = scope.sample_rate;
342
343        let ring_size = self.ring_buffer.capacity();
344        // ensure ring buffer is filled with silence
345        if self.ring_buffer.len() < ring_size {
346            let mut silence = input.clone();
347            silence.make_silent();
348            self.ring_buffer.resize(ring_size, silence);
349        }
350
351        // setup values for compression curve
352        // https://webaudio.github.io/web-audio-api/#compression-curve
353        let threshold = params.get(&self.threshold)[0];
354        let knee = params.get(&self.knee)[0];
355        let ratio = params.get(&self.ratio)[0];
356        // @note: if knee != 0. we shadow threshold to match definitions of knee
357        //   and threshold given in https://www.eecs.qmul.ac.uk/~josh/documents/2012/
358        //   where knee is centered around threshold.
359        //   We can thus reuse their formula for the gain computer stage.
360        // yG =
361        //     xG                                      if 2(xG − T) < −W
362        //     xG + (1/R − 1)(xG − T + W/2)^2 / (2W)   if 2|(xG − T)| ≤ W
363        //     T + (xG − T)/R                          if 2(xG − T) > W
364        // This is weird, and probably wrong because `knee` and `threshold` are not
365        // independent, but matches the spec.
366        let threshold = if knee > 0. {
367            threshold + knee / 2.
368        } else {
369            threshold
370        };
371        let half_knee = knee / 2.;
372        // pre-compute for this block the constant part of the formula of the knee
373        let knee_partial = (1. / ratio - 1.) / (2. * knee);
374
375        // compute time constants for attack and release - eq. (7) in paper
376        let attack = params.get(&self.attack)[0];
377        let release = params.get(&self.release)[0];
378        let attack_tau = (-1. / (attack * sample_rate)).exp();
379        let release_tau = (-1. / (release * sample_rate)).exp();
380
381        // Computing the makeup gain means executing the following steps:
382        // - Let full range gain be the value returned by applying the compression curve to the value 1.0.
383        // - Let full range makeup gain be the inverse of full range gain.
384        // - Return the result of taking the 0.6 power of full range makeup gain.
385        // @note: this should be confirmed / simplified, maybe could do all this in dB
386        // seems coherent with chrome implementation
387        let full_range_gain = threshold + (-threshold / ratio);
388        let full_range_makeup = 1. / db_to_lin(full_range_gain);
389        let makeup_gain = lin_to_db(full_range_makeup.powf(0.6));
390
391        let mut prev_detector_value = self.prev_detector_value;
392
393        let mut reduction_gain = 0.; // dB
394        let mut reduction_gains = [0.; 128]; // lin
395        let mut detector_values = [0.; 128]; // lin
396
397        for i in 0..RENDER_QUANTUM_SIZE {
398            // pick highest value for this index across all input channels
399            // @tbc - this seems to be what is done in chrome
400            let mut max = f32::MIN;
401
402            for channel in input.channels().iter() {
403                let sample = channel[i].abs();
404                if sample > max {
405                    max = sample;
406                }
407            }
408
409            // pick absolute value and convert to dB domain
410            // var xG in paper
411            let sample_db = lin_to_db(max);
412
413            // Gain Computer stage
414            // ------------------------------------------------
415            // var yG - eq. 4 in paper
416            // if knee == 0. (hard knee), the `else if` branch is bypassed
417            let sample_attenuated = if sample_db <= threshold - half_knee {
418                sample_db
419            } else if sample_db <= threshold + half_knee {
420                sample_db + (sample_db - threshold + half_knee).powi(2) * knee_partial
421            } else {
422                threshold + (sample_db - threshold) / ratio
423            };
424            // variable xL in paper
425            let sample_attenuation = sample_db - sample_attenuated;
426
427            // Level Detector stage
428            // ------------------------------------------------
429            // Branching peak detector - eq. 16 in paper - var yL
430            // attack branch
431            let detector_value = if sample_attenuation > prev_detector_value {
432                attack_tau * prev_detector_value + (1. - attack_tau) * sample_attenuation
433            // release branch
434            } else {
435                release_tau * prev_detector_value + (1. - release_tau) * sample_attenuation
436            };
437
438            detector_values[i] = detector_value;
439            // cdB = -yL + make up gain
440            reduction_gain = -1. * detector_value + makeup_gain;
441            // convert to lin now, so we just to multiply samples later
442            reduction_gains[i] = db_to_lin(reduction_gain);
443            // update prev_detector_value for next sample
444            prev_detector_value = detector_value;
445        }
446
447        // update prev_detector_value for next block
448        self.prev_detector_value = prev_detector_value;
449        // update reduction shared w/ main thread
450        self.reduction.store(reduction_gain, Ordering::Relaxed);
451
452        // store input in delay line
453        self.ring_buffer[self.ring_index] = input;
454
455        // apply compression to delayed signal
456        let read_index = (self.ring_index + 1) % ring_size;
457        let delayed = &self.ring_buffer[read_index];
458
459        self.ring_index = read_index;
460
461        *output = delayed.clone();
462
463        // if delayed signal is silent, there is no compression to apply
464        // thus we can consider the node has reach is tail time. (TBC)
465        if output.is_silent() {
466            output.make_silent(); // truncate to 1 channel if needed
467            return false;
468        }
469
470        output.channels_mut().iter_mut().for_each(|channel| {
471            channel
472                .iter_mut()
473                .zip(reduction_gains.iter())
474                .for_each(|(o, g)| *o *= g);
475        });
476
477        true
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use float_eq::assert_float_eq;
484
485    use crate::context::OfflineAudioContext;
486    use crate::node::AudioScheduledSourceNode;
487
488    use super::*;
489
490    #[test]
491    fn test_constructor_default() {
492        let context = OfflineAudioContext::new(1, 1, 44_100.);
493        let compressor = DynamicsCompressorNode::new(&context, Default::default());
494
495        assert_float_eq!(compressor.attack().value(), 0.003, abs <= 0.);
496        assert_float_eq!(compressor.knee().value(), 30., abs <= 0.);
497        assert_float_eq!(compressor.ratio().value(), 12., abs <= 0.);
498        assert_float_eq!(compressor.release().value(), 0.25, abs <= 0.);
499        assert_float_eq!(compressor.threshold().value(), -24., abs <= 0.);
500    }
501
502    #[test]
503    fn test_constructor_non_default() {
504        let context = OfflineAudioContext::new(1, 1, 44_100.);
505        let compressor = DynamicsCompressorNode::new(
506            &context,
507            DynamicsCompressorOptions {
508                attack: 0.5,
509                knee: 12.,
510                ratio: 1.,
511                release: 0.75,
512                threshold: -60.,
513                ..DynamicsCompressorOptions::default()
514            },
515        );
516
517        assert_float_eq!(compressor.attack().value(), 0.5, abs <= 0.);
518        assert_float_eq!(compressor.knee().value(), 12., abs <= 0.);
519        assert_float_eq!(compressor.ratio().value(), 1., abs <= 0.);
520        assert_float_eq!(compressor.release().value(), 0.75, abs <= 0.);
521        assert_float_eq!(compressor.threshold().value(), -60., abs <= 0.);
522    }
523
524    #[test]
525    fn test_inner_delay() {
526        let sample_rate = 44_100.;
527        let compressor_delay = 0.006;
528        // index of the first non zero sample, rounded at next block after
529        // compressor theoretical delay, i.e. 3 blocks at this sample_rate
530        let non_zero_index = (compressor_delay * sample_rate / RENDER_QUANTUM_SIZE as f32).ceil()
531            as usize
532            * RENDER_QUANTUM_SIZE;
533
534        let mut context = OfflineAudioContext::new(1, 128 * 8, sample_rate);
535
536        let compressor = DynamicsCompressorNode::new(&context, Default::default());
537        compressor.connect(&context.destination());
538
539        let mut buffer = context.create_buffer(1, 128 * 5, sample_rate);
540        let signal = [1.; 128 * 5];
541        buffer.copy_to_channel(&signal, 0);
542
543        let mut src = context.create_buffer_source();
544        src.set_buffer(buffer);
545        src.connect(&compressor);
546        src.start();
547
548        let res = context.start_rendering_sync();
549        let chan = res.channel_data(0).as_slice();
550
551        // this is the delay
552        assert_float_eq!(
553            chan[0..non_zero_index],
554            vec![0.; non_zero_index][..],
555            abs_all <= 0.
556        );
557
558        // as some compression is applied, we just check the remaining is non zero
559        for sample in chan.iter().take(128 * 8).skip(non_zero_index) {
560            assert!(*sample != 0.);
561        }
562    }
563
564    #[test]
565    fn test_db_to_lin() {
566        assert_float_eq!(db_to_lin(0.), 1., abs <= 0.);
567        assert_float_eq!(db_to_lin(-20.), 0.1, abs <= 1e-8);
568        assert_float_eq!(db_to_lin(-40.), 0.01, abs <= 1e-8);
569        assert_float_eq!(db_to_lin(-60.), 0.001, abs <= 1e-8);
570    }
571
572    #[test]
573    fn test_lin_to_db() {
574        assert_float_eq!(lin_to_db(1.), 0., abs <= 0.);
575        assert_float_eq!(lin_to_db(0.1), -20., abs <= 0.);
576        assert_float_eq!(lin_to_db(0.01), -40., abs <= 0.);
577        assert_float_eq!(lin_to_db(0.001), -60., abs <= 0.);
578        // special case
579        assert_float_eq!(lin_to_db(0.), -1000., abs <= 0.);
580    }
581
582    // @note: keep this, is useful to grab some internal value to be plotted
583    // #[test]
584    // fn test_attenuated_values() {
585    //     // threshold: -40.
586    //     // knee: 0.
587    //     // ratio: 12.
588    //     let sample_rate = 1_000.;
589    //     let mut context = OfflineAudioContext::new(1, 128, sample_rate);
590
591    //     let compressor = DynamicsCompressorNode::new(&context, Default::default());
592    //     compressor.knee().set_value(0.);
593    //     compressor.threshold().set_value(-30.);
594    //     compressor.attack().set_value(0.05);
595    //     compressor.release().set_value(0.1);
596    //     compressor.connect(&context.destination());
597
598    //     let mut buffer = context.create_buffer(1, 128 * 8, sample_rate);
599    //     let mut signal = [0.; 128 * 8];
600
601    //     for (i, s) in signal.iter_mut().enumerate() {
602    //         *s = if i < 300 { 1. } else { 0.3 };
603    //     }
604
605    //     // println!("{:?}", signal);
606    //     buffer.copy_to_channel(&signal, 0);
607
608    //     let mut src = context.create_buffer_source();
609    //     src.set_buffer(buffer);
610    //     src.connect(&compressor);
611    //     src.start();
612
613    //     let _res = context.start_rendering_sync();
614    // }
615}