// Spectral Compressor: an FFT based compressor // Copyright (C) 2021-2022 Robbert van der Helm // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use nih_plug::prelude::*; use realfft::num_complex::Complex32; /// Type alias for the compressor parameters. These two are split up so the parameter list/tree /// looks a bit nicer. pub type CompressorParams<'a> = (&'a ThresholdParams, &'a CompressorBankParams); /// A bank of compressors so each FFT bin can be compressed individually. The vectors in this struct /// will have a capacity of `MAX_WINDOW_SIZE / 2 + 1` and a size that matches the current complex /// FFT buffer size. This is stored as a struct of arrays to make SIMD-ing easier in the future. pub struct CompressorBank { /// If set, then the downwards thresholds should be updated on the next processing cycle. Can be /// set from a parameter value change listener, and is also set when calling `.reset_for_size`. pub should_update_downwards_thresholds: Arc, /// The same as `should_update_downwards_thresholds`, but for upwards thresholds. pub should_update_upwards_thresholds: Arc, /// If set, then the downwards ratios should be updated on the next processing cycle. Can be set /// from a parameter value change listener, and is also set when calling `.reset_for_size`. pub should_update_downwards_ratios: Arc, /// The same as `should_update_downwards_ratios`, but for upwards ratios. pub should_update_upwards_ratios: Arc, /// For each compressor bin, `log2(freq)` where `freq` is the frequency associated with that /// compressor. This is precomputed since all update functions need it. log2_freqs: Vec, /// Downwards compressor thresholds, in linear space. downwards_thresholds: Vec, /// The reciprocals of the downwards compressor ratios. At 1.0 the cmopressor won't do anything. /// If [`CompressorBankParams::high_freq_ratio_rolloff`] is set to 1.0, then this will be the /// same for each compressor. We're doing the compression in linear space to avoid a logarithm, /// so the division by the ratio becomes an nth-root, or exponentation by the reciprocal of the /// ratio. downwards_ratio_recips: Vec, /// Upwards compressor thresholds, in linear space. upwards_thresholds: Vec, /// The same as `downwards_ratio_recipss`, but for the upwards compression. upwards_ratio_recips: Vec, /// The current envelope value for this bin, in linear space. Indexed by /// `[channel_idx][compressor_idx]`. envelopes: Vec>, /// The window size this compressor bank was configured for. This is used to compute the /// coefficients for the envelope followers in the process function. window_size: usize, /// The sample rate this compressor bank was configured for. This is used to compute the /// coefficients for the envelope followers in the process function. sample_rate: f32, } #[derive(Params)] pub struct ThresholdParams { // TODO: Sidechaining /// The compressor threshold at the center frequency. When sidechaining is enabled, the input /// signal is gained by the inverse of this value. This replaces the input gain in the original /// Spectral Compressor. In the polynomial above, this is the intercept. #[id = "input_db"] threshold_db: FloatParam, /// The center frqeuency for the target curve when sidechaining is not enabled. The curve is a /// polynomial `threshold_db + curve_slope*x + curve_curve*(x^2)` that evaluates to a decibel /// value, where `x = log2(center_frequency) - log2(bin_frequency)`. In other words, this is /// evaluated in the log/log domain for decibels and octaves. #[id = "thresh_center_freq"] center_frequency: FloatParam, /// The slope for the curve, in the log/log domain. See the polynomial above. #[id = "thresh_curve_slope"] curve_slope: FloatParam, /// The, uh, 'curve' for the curve, in the logarithmic domain. This is the third coefficient in /// the quadratic polynomial and controls the parabolic behavior. Positive values turn the curve /// into a v-shaped curve, while negative values attenuate everything outside of the center /// frequency. See the polynomial above. #[id = "thresh_curve_curve"] curve_curve: FloatParam, } #[derive(Params)] pub struct CompressorBankParams { // TODO: Target curve options /// The downwards compression threshold relative to the target curve. #[id = "thresh_down_off"] downwards_threshold_offset_db: FloatParam, /// The downwards compression ratio. At 1.0 the downwards compressor is disengaged. #[id = "ratio_down"] downwards_ratio: FloatParam, /// The downwards compression knee width, in decibels. #[id = "knee_down"] downwards_knee_width_db: FloatParam, /// The upwards compression threshold relative to the target curve. #[id = "thresh_up_off"] upwards_threshold_offset_db: FloatParam, /// The upwards compression ratio. At 1.0 the upwards compressor is disengaged. #[id = "ratio_up"] upwards_ratio: FloatParam, /// The upwards compression knee width, in decibels. #[id = "knee_up"] upwards_knee_width_db: FloatParam, /// A `[0, 1]` scaling factor that causes the compressors for the higher registers to have lower /// ratios than the compressors for the lower registers. The scaling is applied logarithmically /// rather than linearly over the compressors. If this is set to 1.0, then the ratios will be /// the same for every compressor. /// /// TODO: Decide on whether or not this should only apply on upwards ratios, or if we may need /// separate controls for both #[id = "ratio_hi_freq_rolloff"] high_freq_ratio_rolloff: FloatParam, /// The compressor's attack time in milliseconds. Controls both upwards and downwards /// compression. #[id = "attack"] compressor_attack_ms: FloatParam, /// The compressor's release time in milliseconds. Controls both upwards and downwards /// compression. #[id = "release"] compressor_release_ms: FloatParam, } impl ThresholdParams { /// Create a new [`ThresholdParams`] object. Changing any of the threshold parameters causes the /// passed compressor bank's thresholds to be updated. pub fn new(compressor_bank: &CompressorBank) -> Self { let should_update_downwards_thresholds = compressor_bank.should_update_downwards_thresholds.clone(); let should_update_upwards_thresholds = compressor_bank.should_update_upwards_thresholds.clone(); let set_update_both_thresholds = Arc::new(move |_| { should_update_downwards_thresholds.store(true, Ordering::SeqCst); should_update_upwards_thresholds.store(true, Ordering::SeqCst); }); ThresholdParams { // These are polynomial coefficients that are evaluated in the log/log domain // (octaves/decibels). The threshold is the intercept. threshold_db: FloatParam::new( "Global Threshold", 0.0, FloatRange::Linear { min: -50.0, max: 50.0, }, ) .with_callback(set_update_both_thresholds.clone()) .with_unit(" dB") .with_step_size(0.1), center_frequency: FloatParam::new( "Threshold Center", 500.0, FloatRange::Skewed { min: 20.0, max: 20_000.0, factor: FloatRange::skew_factor(-2.0), }, ) .with_callback(set_update_both_thresholds.clone()) // This includes the unit .with_value_to_string(formatters::v2s_f32_hz_then_khz(0)) .with_string_to_value(formatters::s2v_f32_hz_then_khz()), curve_slope: FloatParam::new( "Threshold Slope", 0.0, FloatRange::Linear { min: -36.0, max: 36.0, }, ) .with_callback(set_update_both_thresholds.clone()) .with_unit(" dB/oct") .with_step_size(0.1), curve_curve: FloatParam::new( "Threshold Curve", 0.0, FloatRange::Linear { min: -24.0, max: 24.0, }, ) .with_callback(set_update_both_thresholds) .with_unit(" dB/oct²") .with_step_size(0.1), } } } impl CompressorBankParams { /// Create a new [`CompressorBankParams`] object. Changing any of the threshold or ratio /// parameters causes the passed compressor bank's parameters to be updated. pub fn new(compressor_bank: &CompressorBank) -> Self { let should_update_downwards_thresholds = compressor_bank.should_update_downwards_thresholds.clone(); let set_update_downwards_thresholds = Arc::new(move |_| should_update_downwards_thresholds.store(true, Ordering::SeqCst)); let should_update_upwards_thresholds = compressor_bank.should_update_upwards_thresholds.clone(); let set_update_upwards_thresholds = Arc::new(move |_| should_update_upwards_thresholds.store(true, Ordering::SeqCst)); let should_update_downwards_ratios = compressor_bank.should_update_downwards_ratios.clone(); let set_update_downwards_ratios = Arc::new(move |_| should_update_downwards_ratios.store(true, Ordering::SeqCst)); let should_update_upwards_ratios = compressor_bank.should_update_upwards_ratios.clone(); let set_update_upwards_ratios = Arc::new(move |_| should_update_upwards_ratios.store(true, Ordering::SeqCst)); let should_update_downwards_ratios = compressor_bank.should_update_downwards_ratios.clone(); let should_update_upwards_ratios = compressor_bank.should_update_upwards_ratios.clone(); let set_update_both_ratios = Arc::new(move |_| { should_update_downwards_ratios.store(true, Ordering::SeqCst); should_update_upwards_ratios.store(true, Ordering::SeqCst); }); CompressorBankParams { // TODO: Set nicer default values for these things // As explained above, these offsets are relative to the target curve downwards_threshold_offset_db: FloatParam::new( "Downwards Offset", 0.0, FloatRange::Linear { min: -50.0, max: 50.0, }, ) .with_callback(set_update_downwards_thresholds) .with_unit(" dB") .with_step_size(0.1), downwards_ratio: FloatParam::new( "Downwards Ratio", 1.0, FloatRange::Skewed { min: 1.0, max: 300.0, factor: FloatRange::skew_factor(-2.0), }, ) .with_callback(set_update_downwards_ratios) .with_step_size(0.01) .with_value_to_string(formatters::v2s_compression_ratio(2)) .with_string_to_value(formatters::s2v_compression_ratio()), downwards_knee_width_db: FloatParam::new( "Downwards Knee", 0.0, FloatRange::Skewed { min: 0.0, max: 36.0, factor: FloatRange::skew_factor(-1.0), }, ) .with_unit(" dB") .with_step_size(0.1), upwards_threshold_offset_db: FloatParam::new( "Upwards Offset", 0.0, FloatRange::Linear { min: -50.0, max: 50.0, }, ) .with_callback(set_update_upwards_thresholds) .with_unit(" dB") .with_step_size(0.1), upwards_ratio: FloatParam::new( "Upwards Ratio", 1.0, FloatRange::Skewed { min: 1.0, max: 300.0, factor: FloatRange::skew_factor(-2.0), }, ) .with_callback(set_update_upwards_ratios) .with_step_size(0.01) .with_value_to_string(formatters::v2s_compression_ratio(2)) .with_string_to_value(formatters::s2v_compression_ratio()), upwards_knee_width_db: FloatParam::new( "Upwards Knee", 0.0, FloatRange::Skewed { min: 0.0, max: 36.0, factor: FloatRange::skew_factor(-1.0), }, ) .with_unit(" dB") .with_step_size(0.1), high_freq_ratio_rolloff: FloatParam::new( "High-freq Ratio Rolloff", 0.5, FloatRange::Linear { min: 0.0, max: 1.0 }, ) .with_callback(set_update_both_ratios) .with_unit("%") .with_value_to_string(formatters::v2s_f32_percentage(0)) .with_string_to_value(formatters::s2v_f32_percentage()), compressor_attack_ms: FloatParam::new( "Attack", 150.0, FloatRange::Skewed { min: 0.0, max: 10_000.0, factor: FloatRange::skew_factor(-2.0), }, ) .with_unit(" ms") .with_step_size(0.1), compressor_release_ms: FloatParam::new( "Release", 300.0, FloatRange::Skewed { min: 0.0, max: 10_000.0, factor: FloatRange::skew_factor(-2.0), }, ) .with_unit(" ms") .with_step_size(0.1), } } } impl CompressorBank { /// Set up the compressor for the given channel count and maximum FFT window size. The /// compressors won't be initialized yet. pub fn new(num_channels: usize, max_window_size: usize) -> Self { let complex_buffer_len = max_window_size / 2 + 1; CompressorBank { should_update_downwards_thresholds: Arc::new(AtomicBool::new(true)), should_update_upwards_thresholds: Arc::new(AtomicBool::new(true)), should_update_downwards_ratios: Arc::new(AtomicBool::new(true)), should_update_upwards_ratios: Arc::new(AtomicBool::new(true)), log2_freqs: Vec::with_capacity(complex_buffer_len), downwards_thresholds: Vec::with_capacity(complex_buffer_len), downwards_ratio_recips: Vec::with_capacity(complex_buffer_len), upwards_thresholds: Vec::with_capacity(complex_buffer_len), upwards_ratio_recips: Vec::with_capacity(complex_buffer_len), envelopes: vec![Vec::with_capacity(complex_buffer_len); num_channels], window_size: 0, sample_rate: 1.0, } } /// Change the capacities of the internal buffers to fit new parameters. Use the /// `.reset_for_size()` method to clear the buffers and set the current window size. pub fn update_capacity(&mut self, num_channels: usize, max_window_size: usize) { let complex_buffer_len = max_window_size / 2 + 1; self.log2_freqs .reserve_exact(complex_buffer_len.saturating_sub(self.log2_freqs.len())); self.downwards_thresholds .reserve_exact(complex_buffer_len.saturating_sub(self.downwards_thresholds.len())); self.downwards_ratio_recips .reserve_exact(complex_buffer_len.saturating_sub(self.downwards_ratio_recips.len())); self.upwards_thresholds .reserve_exact(complex_buffer_len.saturating_sub(self.upwards_thresholds.len())); self.upwards_ratio_recips .reserve_exact(complex_buffer_len.saturating_sub(self.upwards_ratio_recips.len())); self.envelopes.resize_with(num_channels, Vec::new); for envelopes in self.envelopes.iter_mut() { envelopes.reserve_exact(complex_buffer_len.saturating_sub(envelopes.len())); } } /// Resize the number of compressors to match the current window size. Also precomputes the /// 2-log frequencies for each bin. /// /// If the window size is larger than the maximum window size, then this will allocate. pub fn resize(&mut self, buffer_config: &BufferConfig, window_size: usize) { let complex_buffer_len = window_size / 2 + 1; // These 2-log frequencies are needed when updating the compressor parameters, so we'll just // precompute them to avoid having to repeat the same expensive computations all the time self.log2_freqs.resize(complex_buffer_len, 0.0); for (i, log2_freq) in self.log2_freqs.iter_mut().enumerate() { let freq = (i as f32 / window_size as f32) * buffer_config.sample_rate; *log2_freq = freq.log2(); } self.downwards_thresholds.resize(complex_buffer_len, 1.0); self.downwards_ratio_recips.resize(complex_buffer_len, 1.0); self.upwards_thresholds.resize(complex_buffer_len, 1.0); self.upwards_ratio_recips.resize(complex_buffer_len, 1.0); for envelopes in self.envelopes.iter_mut() { envelopes.resize(complex_buffer_len, 0.0); } self.window_size = window_size; self.sample_rate = buffer_config.sample_rate; // The compressors need to be updated on the next processing cycle self.should_update_downwards_thresholds .store(true, Ordering::SeqCst); self.should_update_upwards_thresholds .store(true, Ordering::SeqCst); self.should_update_downwards_ratios .store(true, Ordering::SeqCst); self.should_update_upwards_ratios .store(true, Ordering::SeqCst); } /// Clear out the envelope followers. pub fn reset(&mut self) { for envelopes in self.envelopes.iter_mut() { envelopes.fill(0.0); } } /// Apply the magnitude compression to a buffer of FFT bins. The compressors are first updated /// if needed. The overlap amount is needed to compute the effective sample rate. The /// `skip_bins_below` argument is used to avoid compressing DC bins, or the neighbouring bins /// the DC signal may have been convolved into because of the Hann window function. pub fn process( &mut self, buffer: &mut [Complex32], channel_idx: usize, params: CompressorParams, overlap_times: usize, skip_bins_below: usize, ) { assert_eq!(buffer.len(), self.log2_freqs.len()); self.update_if_needed(params); self.update_envelopes(buffer, channel_idx, params, overlap_times, skip_bins_below); self.compress(buffer, channel_idx, params, skip_bins_below); } /// Update the envelope followers based on the bin magnetudes. fn update_envelopes( &mut self, buffer: &mut [Complex32], channel_idx: usize, (_, compressor): CompressorParams, overlap_times: usize, skip_bins_below: usize, ) { // The coefficient the old envelope value is multiplied by when the current rectified sample // value is above the envelope's value. The 0 to 1 step response retains 36.8% of the old // value after the attack time has elapsed, and current value is 63.2% of the way towards 1. // The effective sample rate needs to compensate for the periodic nature of the STFT // operation. Since with a 2048 sample window and 4x overlap, you'd run this function once // for every 512 samples. let effective_sample_rate = self.sample_rate / (self.window_size as f32 / overlap_times as f32); let attack_old_t = if compressor.compressor_attack_ms.value == 0.0 { 0.0 } else { (-1.0 / (compressor.compressor_attack_ms.value / 1000.0 * effective_sample_rate)).exp() }; let attack_new_t = 1.0 - attack_old_t; // The same as `attack_old_t`, but for the release phase of the envelope follower let release_old_t = if compressor.compressor_release_ms.value == 0.0 { 0.0 } else { (-1.0 / (compressor.compressor_release_ms.value / 1000.0 * effective_sample_rate)).exp() }; let release_new_t = 1.0 - release_old_t; for (bin, envelope) in buffer .iter() .zip(self.envelopes[channel_idx].iter_mut()) .skip(skip_bins_below) { let magnitude = bin.norm(); if *envelope > magnitude { // Release stage *envelope = (release_old_t * *envelope) + (release_new_t * magnitude); } else { // Attack stage *envelope = (attack_old_t * *envelope) + (attack_new_t * magnitude); } } } /// Actually do the thing. [`Self::update_envelopes()`] must have been called before calling /// this. fn compress( &self, buffer: &mut [Complex32], channel_idx: usize, (_, compressor): CompressorParams, skip_bins_below: usize, ) { // Well I'm not sure at all why this scaling works, but it does. With higher knee // bandwidths, the middle values needs to be pushed more towards the post-knee threshold // than with lower knee values. let downwards_knee_scaling_factor = ((compressor.downwards_knee_width_db.value * 2.0) + 2.0).log2() - 1.0; let upwards_knee_scaling_factor = ((compressor.upwards_knee_width_db.value * 2.0) + 2.0).log2() - 1.0; // Is this what they mean by zip and and ship it? let downwards_values = self .downwards_thresholds .iter() .zip(self.downwards_ratio_recips.iter()); let upwards_values = self .upwards_thresholds .iter() .zip(self.upwards_ratio_recips.iter()); for ( ((bin, envelope), (downwards_threshold, downwards_ratio_recip)), (upwards_threshold, upwards_ratio_recip), ) in buffer .iter_mut() .zip(self.envelopes[channel_idx].iter()) .zip(downwards_values) .zip(upwards_values) .skip(skip_bins_below) { // This works by computing a scaling factor, and then scaling the bin magnitudes by that. let mut scale = 1.0; // All compression happens in the linear domain to save a logarithm if *downwards_ratio_recip != 1.0 { // TODO: We need the knee starts and ends on this struct // TODO: As mentioned above, soft knee, replace the threshold if envelope > downwards_threshold { // Because we're working in the linear domain, we care about the ratio between // the threshold and the envelope's current value. And log-space division // becomes linear-space exponentiation by the reciprocal, or taking the nth // root. let threshold_ratio = *envelope / *downwards_threshold; scale /= threshold_ratio / threshold_ratio.powf(*downwards_ratio_recip); } } // TODO: More stuff // TODO: Upwards compression *bin *= scale; } } /// Update the compressors if needed. This is called just before processing, and the compressors /// are updated in accordance to the atomic flags set on this struct. fn update_if_needed(&mut self, (threshold, compressor): CompressorParams) { // The threshold curve is a polynomial in log-log (decibels-octaves) space. The reuslt from // evaluating this needs to be converted to linear gain for the compressors. let intercept = threshold.threshold_db.value; // The cheeky 3 additional dB/octave attenuation is to match pink noise with the default // settings let slope = threshold.curve_slope.value - 3.0; let curve = threshold.curve_curve.value; let log2_center_freq = threshold.center_frequency.value.log2(); let high_freq_ratio_rolloff = compressor.high_freq_ratio_rolloff.value; let log2_nyquist_freq = self .log2_freqs .last() .expect("The CompressorBank has not yet been resized"); if self .should_update_downwards_thresholds .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { let intercept = intercept + compressor.downwards_threshold_offset_db.value; for (log2_freq, threshold) in self .log2_freqs .iter() .zip(self.downwards_thresholds.iter_mut()) { let offset = log2_freq - log2_center_freq; let threshold_db = intercept + (slope * offset) + (curve * offset * offset); // This threshold may never reach zero as it's used in divisions to get a gain ratio // above the threshold *threshold = util::db_to_gain(threshold_db).max(f32::EPSILON); } } if self .should_update_upwards_thresholds .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { let intercept = intercept + compressor.upwards_threshold_offset_db.value; for (log2_freq, threshold) in self .log2_freqs .iter() .zip(self.upwards_thresholds.iter_mut()) { let offset = log2_freq - log2_center_freq; let threshold_db = intercept + (slope * offset) + (curve * offset * offset); *threshold = util::db_to_gain(threshold_db).max(f32::EPSILON); } } if self .should_update_downwards_ratios .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { // If the high-frequency rolloff is enabled then higher frequency bins will have their // ratios reduced to reduce harshness. This follows the octave scale. let target_ratio_recip = compressor.downwards_ratio.value.recip(); if high_freq_ratio_rolloff == 0.0 { self.downwards_ratio_recips.fill(target_ratio_recip); } else { for (log2_freq, ratio) in self .log2_freqs .iter() .zip(self.downwards_ratio_recips.iter_mut()) { let octave_fraction = log2_freq / log2_nyquist_freq; let rolloff_t = octave_fraction * high_freq_ratio_rolloff; // If the octave fraction times the rolloff amount is high, then this should get // closer to `high_freq_ratio_rolloff` (which is in [0, 1]). *ratio = (target_ratio_recip * (1.0 - rolloff_t)) + rolloff_t; } } } if self .should_update_upwards_ratios .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { let target_ratio_recip = compressor.upwards_ratio.value.recip(); if high_freq_ratio_rolloff == 0.0 { self.upwards_ratio_recips.fill(target_ratio_recip); } else { for (log2_freq, ratio) in self .log2_freqs .iter() .zip(self.upwards_ratio_recips.iter_mut()) { let octave_fraction = log2_freq / log2_nyquist_freq; let rolloff_t = octave_fraction * high_freq_ratio_rolloff; *ratio = (target_ratio_recip * (1.0 - rolloff_t)) + rolloff_t; } } } } }