From 1711efa11e4f3ff0b856ecf77e3b42e9e6749457 Mon Sep 17 00:00:00 2001 From: Robbert van der Helm Date: Wed, 5 Apr 2023 15:39:33 +0200 Subject: [PATCH] Add a basic 4x oversampled version of Hard Vacuum The oversampling amount should be configurable, and it would work better if the slew signal was oversampled independently instead of doing this compensation thing. --- plugins/aw_soft_vacuum/src/hard_vacuum.rs | 8 +- plugins/aw_soft_vacuum/src/lib.rs | 145 ++++++++++++++++++---- 2 files changed, 128 insertions(+), 25 deletions(-) diff --git a/plugins/aw_soft_vacuum/src/hard_vacuum.rs b/plugins/aw_soft_vacuum/src/hard_vacuum.rs index f18f6705..b2544cc8 100644 --- a/plugins/aw_soft_vacuum/src/hard_vacuum.rs +++ b/plugins/aw_soft_vacuum/src/hard_vacuum.rs @@ -40,6 +40,10 @@ pub struct Params { pub warmth: f32, /// The 'aura' parameter, should be in the range `[0, pi]`. pub aura: f32, + + /// A factor slews should be multiplied with. Set above 1.0 when oversampling to compensate for + /// the waveform becoming smoother. + pub slew_compensation_factor: f32, } impl HardVacuum { @@ -68,8 +72,10 @@ impl HardVacuum { // AW: We're doing all this here so skew isn't incremented by each stage let skew = { + // NOTE: The `slew_compensation_factor` is an addition to make the algorithm behave more + // consistently when oversampling // AW: skew will be direction/angle - let skew = input - self.last_sample; + let skew = (input - self.last_sample) * params.slew_compensation_factor; // AW: for skew we want it to go to zero effect again, so we use full range of the sine let bridge_rectifier = skew.abs().min(PI).sin(); diff --git a/plugins/aw_soft_vacuum/src/lib.rs b/plugins/aw_soft_vacuum/src/lib.rs index 0cc58001..9a610cfb 100644 --- a/plugins/aw_soft_vacuum/src/lib.rs +++ b/plugins/aw_soft_vacuum/src/lib.rs @@ -22,12 +22,25 @@ use nih_plug::prelude::*; mod hard_vacuum; mod oversampling; +/// The maximum number of samples to process at a time. Used to create scratch buffers for the +/// oversampling. +const MAX_BLOCK_SIZE: usize = 32; + +/// The 2-logarithm of the oversampling amount to use. 4x oversampling corresponds to factor 2. +// FIXME: Set this back to 2 +const OVERSAMPLING_FACTOR: usize = 2; +const OVERSAMPLING_TIMES: usize = 2usize.pow(OVERSAMPLING_FACTOR as u32); + +const MAX_OVERSAMPLED_BLOCK_SIZE: usize = MAX_BLOCK_SIZE * OVERSAMPLING_TIMES; + struct SoftVacuum { params: Arc, /// Stores implementations of the Hard Vacuum algorithm for each channel, since each channel /// needs to maintain its own state. hard_vacuum_processors: Vec, + /// Oversampling for each channel. + oversamplers: Vec, } // The parameters are the same as in the original plugin, except that they have different value @@ -60,17 +73,26 @@ impl Default for SoftVacuumParams { // Goes up to 200%, with the second half being nonlinear drive: FloatParam::new("Drive", 0.0, FloatRange::Linear { min: 0.0, max: 2.0 }) .with_unit("%") - .with_smoother(SmoothingStyle::Linear(20.0)) + .with_smoother( + SmoothingStyle::Linear(20.0) + .for_oversampling_factor(OVERSAMPLING_FACTOR as f32), + ) .with_value_to_string(formatters::v2s_f32_percentage(0)) .with_string_to_value(formatters::s2v_f32_percentage()), warmth: FloatParam::new("Warmth", 0.0, FloatRange::Linear { min: 0.0, max: 1.0 }) .with_unit("%") - .with_smoother(SmoothingStyle::Linear(10.0)) + .with_smoother( + SmoothingStyle::Linear(10.0) + .for_oversampling_factor(OVERSAMPLING_FACTOR as f32), + ) .with_value_to_string(formatters::v2s_f32_percentage(0)) .with_string_to_value(formatters::s2v_f32_percentage()), aura: FloatParam::new("Aura", 0.0, FloatRange::Linear { min: 0.0, max: PI }) .with_unit("%") - .with_smoother(SmoothingStyle::Linear(10.0)) + .with_smoother( + SmoothingStyle::Linear(10.0) + .for_oversampling_factor(OVERSAMPLING_FACTOR as f32), + ) // We're displaying the value as a percentage even though it goes from `[0, pi]` .with_value_to_string({ let formatter = formatters::v2s_f32_percentage(0); @@ -93,12 +115,18 @@ impl Default for SoftVacuumParams { ) .with_unit(" dB") // The value does not go down to 0 so we can do logarithmic here - .with_smoother(SmoothingStyle::Logarithmic(10.0)) + .with_smoother( + SmoothingStyle::Logarithmic(10.0) + .for_oversampling_factor(OVERSAMPLING_FACTOR as f32), + ) .with_value_to_string(formatters::v2s_f32_gain_to_db(2)) .with_string_to_value(formatters::s2v_f32_gain_to_db()), dry_wet_ratio: FloatParam::new("Mix", 1.0, FloatRange::Linear { min: 0.0, max: 1.0 }) .with_unit("%") - .with_smoother(SmoothingStyle::Linear(10.0)) + .with_smoother( + SmoothingStyle::Linear(10.0) + .for_oversampling_factor(OVERSAMPLING_FACTOR as f32), + ) .with_value_to_string(formatters::v2s_f32_percentage(0)) .with_string_to_value(formatters::s2v_f32_percentage()), } @@ -111,6 +139,7 @@ impl Default for SoftVacuum { params: Arc::new(SoftVacuumParams::default()), hard_vacuum_processors: Vec::new(), + oversamplers: Vec::new(), } } } @@ -147,14 +176,24 @@ impl Plugin for SoftVacuum { &mut self, audio_io_layout: &AudioIOLayout, _buffer_config: &BufferConfig, - _context: &mut impl InitContext, + context: &mut impl InitContext, ) -> bool { let num_channels = audio_io_layout .main_output_channels .expect("Plugin was initialized without any outputs") .get() as usize; + self.hard_vacuum_processors .resize_with(num_channels, hard_vacuum::HardVacuum::default); + // If the number of stages ever becomes configurable, then this needs to also change the + // existinginstances + self.oversamplers.resize_with(num_channels, || { + oversampling::Lanczos3Oversampler::new(MAX_BLOCK_SIZE, OVERSAMPLING_FACTOR) + }); + + if let Some(oversampler) = self.oversamplers.first() { + context.set_latency_samples(oversampler.latency() as u32); + } true } @@ -163,6 +202,10 @@ impl Plugin for SoftVacuum { for hard_vacuum in &mut self.hard_vacuum_processors { hard_vacuum.reset(); } + + for oversampler in &mut self.oversamplers { + oversampler.reset(); + } } fn process( @@ -171,25 +214,79 @@ impl Plugin for SoftVacuum { _aux: &mut AuxiliaryBuffers, _context: &mut impl ProcessContext, ) -> ProcessStatus { - // TODO: Parameters - // TODO: Dry/wet mixing and output scaling - // TODO: Oversampling - for channel_samples in buffer.iter_samples() { - let output_gain = self.params.output_gain.smoothed.next(); - let dry_wet_ratio = self.params.dry_wet_ratio.smoothed.next(); - let hard_vacuum_params = hard_vacuum::Params { - drive: self.params.drive.smoothed.next(), - warmth: self.params.warmth.smoothed.next(), - aura: self.params.aura.smoothed.next(), - }; + // TODO: When the oversampling amount becomes dynamic, then we should update the latency here: + // context.set_latency_samples(self.oversampler.latency() as u32); - for (sample, hard_vacuum) in channel_samples - .into_iter() - .zip(self.hard_vacuum_processors.iter_mut()) - { - let distorted = hard_vacuum.process(*sample, &hard_vacuum_params); - *sample = - (distorted * output_gain * dry_wet_ratio) + (*sample * (1.0 - dry_wet_ratio)); + // The Hard Vacuum algorithm makes use of slews, and the aura control amplifies this part. + // The oversampling rounds out the waveform and reduces those slews. This is a rough + // compensation to get the distortion to sound like it normally would. The alternative would + // be to upsample the slews independently. + // FIXME: Maybe just upsample the slew signal instead, that should be more accurate + let slew_oversampling_compensation_factor = (OVERSAMPLING_TIMES - 1) as f32 * 0.7; + + for (_, block) in buffer.iter_blocks(MAX_BLOCK_SIZE) { + let block_len = block.samples(); + let upsampled_block_len = block_len * OVERSAMPLING_TIMES; + + // These are the parameters for the distortion algorithm + // TODO: When the oversampling amount becomes dynamic, then the block size here needs to + // change depending on the oversampling amount + let mut drive = [0.0; MAX_OVERSAMPLED_BLOCK_SIZE]; + self.params + .drive + .smoothed + .next_block(&mut drive, upsampled_block_len); + let mut warmth = [0.0; MAX_OVERSAMPLED_BLOCK_SIZE]; + self.params + .warmth + .smoothed + .next_block(&mut warmth, upsampled_block_len); + let mut aura = [0.0; MAX_OVERSAMPLED_BLOCK_SIZE]; + self.params + .aura + .smoothed + .next_block(&mut aura, upsampled_block_len); + + // And the general output mixing + let mut output_gain = [0.0; MAX_OVERSAMPLED_BLOCK_SIZE]; + self.params + .output_gain + .smoothed + .next_block(&mut output_gain, upsampled_block_len); + let mut dry_wet_ratio = [0.0; MAX_OVERSAMPLED_BLOCK_SIZE]; + self.params + .dry_wet_ratio + .smoothed + .next_block(&mut dry_wet_ratio, upsampled_block_len); + + for (block_channel, (oversampler, hard_vacuum)) in block.into_iter().zip( + self.oversamplers + .iter_mut() + .zip(self.hard_vacuum_processors.iter_mut()), + ) { + oversampler.process(block_channel, |upsampled| { + assert!(upsampled.len() == upsampled_block_len); + + for (sample_idx, sample) in upsampled.iter_mut().enumerate() { + // SAFETY: We already made sure that the blocks are equal in size. We could + // zip iterators instead but with six iterators that's already a bit + // too much without a first class way to zip more than two iterators + // together into a single tuple of iterators. + let hard_vacuum_params = hard_vacuum::Params { + drive: unsafe { *drive.get_unchecked(sample_idx) }, + warmth: unsafe { *warmth.get_unchecked(sample_idx) }, + aura: unsafe { *aura.get_unchecked(sample_idx) }, + + slew_compensation_factor: slew_oversampling_compensation_factor, + }; + let output_gain = unsafe { *output_gain.get_unchecked(sample_idx) }; + let dry_wet_ratio = unsafe { *dry_wet_ratio.get_unchecked(sample_idx) }; + + let distorted = hard_vacuum.process(*sample, &hard_vacuum_params); + *sample = (distorted * output_gain * dry_wet_ratio) + + (*sample * (1.0 - dry_wet_ratio)); + } + }); } }