From 35e584b3c8fbdfb505bf4d1027d0ea98b1fee3df Mon Sep 17 00:00:00 2001 From: Robbert van der Helm Date: Wed, 6 Jul 2022 18:55:42 +0200 Subject: [PATCH] Add a poly mod gain parameter to PolyModSynth --- plugins/examples/poly_mod_synth/src/lib.rs | 191 ++++++++++++++++++--- src/param.rs | 8 + src/param/smoothing.rs | 4 +- 3 files changed, 180 insertions(+), 23 deletions(-) diff --git a/plugins/examples/poly_mod_synth/src/lib.rs b/plugins/examples/poly_mod_synth/src/lib.rs index 701ddf6e..449fea12 100644 --- a/plugins/examples/poly_mod_synth/src/lib.rs +++ b/plugins/examples/poly_mod_synth/src/lib.rs @@ -5,11 +5,15 @@ use std::sync::Arc; /// The number of simultaneous voices for this synth. const NUM_VOICES: u32 = 16; - /// The maximum size of an audio block. We'll split up the audio in blocks and render smoothed /// values to buffers since these values may need to be reused for multiple voices. const MAX_BLOCK_SIZE: usize = 64; +// Polyphonic modulation works by assigning integer IDs to parameters. Pattern matching on these in +// `PolyModulation` and `MonoAutomation` events makes it possible to easily link these events to the +// correct parameter. +const GAIN_POLY_MOD_ID: u32 = 0; + /// A simple polyphonic synthesizer with support for CLAP's polyphonic modulation. See /// `NoteEvent::PolyModulation` for another source of information on how to use this. struct PolyModSynth { @@ -25,8 +29,12 @@ struct PolyModSynth { next_internal_voice_id: u64, } -#[derive(Default, Params)] -struct PolyModSynthParams {} +#[derive(Params)] +struct PolyModSynthParams { + /// A voice's gain. This can be polyphonically modulated. + #[id = "gain"] + gain: FloatParam, +} /// Data for a single synth voice. In a real synth where performance matter, you may want to use a /// struct of arrays instead of having a struct for each voice. @@ -53,6 +61,10 @@ struct Voice { phase_delta: f32, /// The square root of the note's velocity. This is used as a gain multiplier. velocity_sqrt: f32, + + /// If this voice has polyphonic gain modulation applied, then this contains the normalized + /// offset and a smoother. + voice_gain: Option<(f32, Smoother)>, } impl Default for PolyModSynth { @@ -68,6 +80,30 @@ impl Default for PolyModSynth { } } +impl Default for PolyModSynthParams { + fn default() -> Self { + Self { + gain: FloatParam::new( + "Gain", + util::db_to_gain(-12.0), + // Because we're representing gain as decibels the range is already logarithmic + FloatRange::Linear { + min: util::db_to_gain(-36.0), + max: util::db_to_gain(0.0), + }, + ) + // This enables polyphonic mdoulation for this parameter by representing all related + // events with this ID. After enabling this, the plugin **must** start sending + // `VoiceTerminated` events to the host whenever a voice has ended. + .with_poly_modulation_id(GAIN_POLY_MOD_ID) + .with_smoother(SmoothingStyle::Logarithmic(5.0)) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_gain_to_db(2)) + .with_string_to_value(formatters::s2v_f32_gain_to_db()), + } + } +} + impl Plugin for PolyModSynth { const NAME: &'static str = "Poly Mod Synth"; const VENDOR: &'static str = "Moist Plugins GmbH"; @@ -110,6 +146,7 @@ impl Plugin for PolyModSynth { // split on note events, it's easier to work with raw audio here and to do the splitting by // hand. let num_samples = buffer.len(); + let sample_rate = context.transport().sample_rate; let output = buffer.as_slice(); let mut next_event = context.next_event(); @@ -117,7 +154,12 @@ impl Plugin for PolyModSynth { let mut block_end: usize = MAX_BLOCK_SIZE.min(num_samples); while block_start < num_samples { // First of all, handle all note events that happen at the start of the block, and cut - // the block short if another event happens before the end of it + // the block short if another event happens before the end of it. To handle polyphonic + // modulation for new notes properly, we'll keep track of the next internal note index + // at the block's start. If we receive polyphonic modulation that matches a voice that + // has an internal note ID that's great than or equal to this one, then we should start + // the note's smoother at the new value instead of fading in from the global value. + let this_sample_internal_voice_id_start = self.next_internal_voice_id; 'events: loop { match next_event { // If the event happens now, then we'll keep processing events @@ -138,8 +180,7 @@ impl Plugin for PolyModSynth { // TODO: Add and set the other fields voice.phase = initial_phase; - voice.phase_delta = - util::midi_note_to_freq(note) / context.transport().sample_rate; + voice.phase_delta = util::midi_note_to_freq(note) / sample_rate; voice.velocity_sqrt = velocity.sqrt(); } NoteEvent::NoteOff { @@ -161,18 +202,104 @@ impl Plugin for PolyModSynth { } => { self.terminate_voice(context, timing, voice_id, channel, note); } - // TODO: Handle poly modulation NoteEvent::PolyModulation { - timing, + timing: _, voice_id, poly_modulation_id, normalized_offset, - } => todo!(), + } => { + // Polyphonic modulation events are matched to voices using the + // voice ID, and to parameters using the poly modulation ID + match self.get_voice_idx(voice_id) { + Some(voice_idx) => { + let voice = self.voices[voice_idx].as_mut().unwrap(); + + match poly_modulation_id { + GAIN_POLY_MOD_ID => { + // This should either create a smoother for this + // modulated parameter or update the existing one. + // Notice how this uses the parameter's unmodulated + // normalized value in combination with the normalized + // offset to create the target plain value + let target_plain_value = self + .params + .gain + .preview_modulated(normalized_offset); + let (_, smoother) = + voice.voice_gain.get_or_insert_with(|| { + ( + normalized_offset, + self.params.gain.smoothed.clone(), + ) + }); + + // If this `PolyModulation` events happens on the + // same sample as a voice's `NoteOn` event, then it + // should immediately use the modulated value + // instead of slowly fading in + if voice.internal_voice_id + >= this_sample_internal_voice_id_start + { + smoother.reset(target_plain_value); + } else { + smoother.set_target( + sample_rate, + target_plain_value, + ); + } + } + n => nih_debug_assert_failure!( + "Polyphonic modulation sent for unknown poly \ + modulation ID {}", + n + ), + } + } + // TODO: Bitwig sends the polyphonic modulation event before the + // NoteOn, and there will also be some more events after + // the voice has been terminated + None => nih_debug_assert_failure!( + "Polyphonic modulation sent for unknown voice {}", + voice_id + ), + } + } NoteEvent::MonoAutomation { - timing, + timing: _, poly_modulation_id, normalized_value, - } => todo!(), + } => { + // Modulation always acts as an offset to the parameter's current + // automated value. So if the host sends a new automation value for + // a modulated parameter, the modulated values/smoothing targets + // need to be updated for all polyphonically modulated voices. + for voice in self.voices.iter_mut().filter_map(|v| v.as_mut()) { + match poly_modulation_id { + GAIN_POLY_MOD_ID => { + let (normalized_offset, smoother) = + match voice.voice_gain.as_mut() { + Some((o, s)) => (o, s), + // If the voice does not have existing + // polyphonic modulation, then there's nothing + // to do here. The global automation/monophonic + // modulation has already been taken care of by + // the framework. + None => continue, + }; + let target_plain_value = + self.params.gain.preview_plain( + normalized_value + *normalized_offset, + ); + smoother.set_target(sample_rate, target_plain_value); + } + n => nih_debug_assert_failure!( + "Automation event sent for unknown poly modulation ID \ + {}", + n + ), + } + } + } _ => (), }; @@ -192,16 +319,36 @@ impl Plugin for PolyModSynth { output[0][block_start..block_end].fill(0.0); output[1][block_start..block_end].fill(0.0); - // TODO: Poly modulation + // These are the smoothed global parameter values. These are used for voices that do not + // have polyphonic modulation applied to them. With a plugin as simple as this it would + // be possible to avoid this completely by simply always copying the smoother into the + // voice's struct, but that may not be realistic when the plugin has hundreds of + // parameters. The `voice_*` arrays are scratch arrays that an individual voice can use. + let block_len = block_end - block_start; + let mut gain = [0.0; MAX_BLOCK_SIZE]; + let mut voice_gain = [0.0; MAX_BLOCK_SIZE]; + self.params.gain.smoothed.next_block(&mut gain, block_len); + // TODO: Amp envelope // TODO: Some form of band limiting // TODO: Filter for voice in self.voices.iter_mut().filter_map(|v| v.as_mut()) { - for sample_idx in block_start..block_end { + for (value_idx, sample_idx) in (block_start..block_end).enumerate() { + // Depending on whether the voice has polyphonic modulation applied to it, + // either the global parameter values are used, or the voice's smoother is used + // to generate unique modulated values for that voice + let gain = match &voice.voice_gain { + Some((_, smoother)) => { + smoother.next_block(&mut voice_gain, block_len); + &voice_gain + } + None => &gain, + }; + // TODO: This should of course take the envelope and probably a poly mod param into account // TODO: And as mentioned above, basic PolyBLEP or something - let gain = voice.velocity_sqrt; - let sample = (voice.phase * 2.0 - 1.0) * gain; + let amp = voice.velocity_sqrt * gain[value_idx]; + let sample = (voice.phase * 2.0 - 1.0) * amp; voice.phase += voice.phase_delta; if voice.phase >= 1.0 { @@ -223,12 +370,12 @@ impl Plugin for PolyModSynth { } impl PolyModSynth { - /// Get an active voice by its voice ID, if the voice exists - fn get_voice_mut(&mut self, voice_id: i32) -> Option<&mut Voice> { - self.voices.iter_mut().find_map(|voice| match voice { - Some(voice) if voice.voice_id == voice_id => Some(voice), - _ => None, - }) + /// Get the index of a voice by its voice ID, if the voice exists. This does not immediately + /// reutnr a reference to the voice to avoid lifetime issues. + fn get_voice_idx(&mut self, voice_id: i32) -> Option { + self.voices + .iter_mut() + .position(|voice| matches!(voice, Some(voice) if voice.voice_id == voice_id)) } /// Start a new voice with the given voice ID. If all voices are currently in use, the oldest @@ -250,6 +397,8 @@ impl PolyModSynth { velocity_sqrt: 1.0, phase: 0.0, phase_delta: 0.0, + + voice_gain: None, }; self.next_internal_voice_id = self.next_internal_voice_id.wrapping_add(1); diff --git a/src/param.rs b/src/param.rs index 777011de..b0262feb 100644 --- a/src/param.rs +++ b/src/param.rs @@ -147,6 +147,14 @@ pub trait Param: Display { /// wrappers. This **does** snap to step sizes for continuous parameters (i.e. [`FloatParam`]). fn preview_plain(&self, normalized: f32) -> Self::Plain; + /// Get the plain, unnormalized value for this parameter after polyphonic modulation has been + /// applied. This is a convenience method for calling [`preview_plain()`][Self::preview_plain()] + /// with `unmodulated_normalized_value() + normalized_offset`.` + #[inline] + fn preview_modulated(&self, normalized_offset: f32) -> Self::Plain { + self.preview_plain(self.unmodulated_normalized_value() + normalized_offset) + } + /// Flags to control the parameter's behavior. See [`ParamFlags`]. fn flags(&self) -> ParamFlags; diff --git a/src/param/smoothing.rs b/src/param/smoothing.rs index 9906ad2c..f2c4d483 100644 --- a/src/param/smoothing.rs +++ b/src/param/smoothing.rs @@ -96,9 +96,9 @@ impl Clone for Smoother { // We can't derive clone because of the atomics, but these atomics are only here to allow // Send+Sync interior mutability Self { - style: self.style.clone(), + style: self.style, steps_left: AtomicI32::new(self.steps_left.load(Ordering::Relaxed)), - step_size: self.step_size.clone(), + step_size: self.step_size, current: AtomicF32::new(self.current.load(Ordering::Relaxed)), target: self.target.clone(), }