From c24d4062e48467a702213e0f313181f077cba03d Mon Sep 17 00:00:00 2001 From: Robbert van der Helm Date: Wed, 6 Jul 2022 20:45:05 +0200 Subject: [PATCH] Add an attack-release amp envelope to PolyModSynth --- plugins/examples/poly_mod_synth/src/lib.rs | 168 ++++++++++++++++----- 1 file changed, 134 insertions(+), 34 deletions(-) diff --git a/plugins/examples/poly_mod_synth/src/lib.rs b/plugins/examples/poly_mod_synth/src/lib.rs index 22a6ded6..9f6e1199 100644 --- a/plugins/examples/poly_mod_synth/src/lib.rs +++ b/plugins/examples/poly_mod_synth/src/lib.rs @@ -34,6 +34,12 @@ struct PolyModSynthParams { /// A voice's gain. This can be polyphonically modulated. #[id = "gain"] gain: FloatParam, + /// The amplitude envelope attack time. This is the same for every voice. + #[id = "amp_atk"] + amp_attack_ms: FloatParam, + /// The amplitude envelope release time. This is the same for every voice. + #[id = "amp_rel"] + amp_release_ms: FloatParam, } /// Data for a single synth voice. In a real synth where performance matter, you may want to use a @@ -52,6 +58,8 @@ struct Voice { /// The voices internal ID. Each voice has an internal voice ID one higher than the previous /// voice. This is used to steal the last voice in case all 16 voices are in use. internal_voice_id: u64, + /// The square root of the note's velocity. This is used as a gain multiplier. + velocity_sqrt: f32, /// The voice's current phase. This is randomized at the start of the voice phase: f32, @@ -59,8 +67,11 @@ struct Voice { /// Since we don't support pitch expressions or pitch bend, this value stays constant for the /// duration of the voice. phase_delta: f32, - /// The square root of the note's velocity. This is used as a gain multiplier. - velocity_sqrt: f32, + /// Whether the key has been released and the voice is in its release stage. The voice will be + /// terminated when the amplitude envelope hits 0 while the note is releasing. + releasing: bool, + /// Fades between 0 and 1 with timings based on the global attack and release settings. + amp_envelope: Smoother, /// If this voice has polyphonic gain modulation applied, then this contains the normalized /// offset and a smoother. @@ -100,6 +111,31 @@ impl Default for PolyModSynthParams { .with_unit(" dB") .with_value_to_string(formatters::v2s_f32_gain_to_db(2)) .with_string_to_value(formatters::s2v_f32_gain_to_db()), + amp_attack_ms: FloatParam::new( + "Attack", + 200.0, + FloatRange::Skewed { + min: 0.0, + max: 2000.0, + factor: FloatRange::skew_factor(-1.0), + }, + ) + // These parameters are global (and they cannot be changed once the voice has started). + // They also don't need any smoothing themselves because they affect smoothing + // coefficients. + .with_step_size(0.1) + .with_unit(" ms"), + amp_release_ms: FloatParam::new( + "Release", + 100.0, + FloatRange::Skewed { + min: 0.0, + max: 2000.0, + factor: FloatRange::skew_factor(-1.0), + }, + ) + .with_step_size(0.1) + .with_unit(" ms"), } } } @@ -175,24 +211,28 @@ impl Plugin for PolyModSynth { velocity, } => { let initial_phase: f32 = self.prng.gen(); + // This starts with the attack portion of the amplitude envelope + let mut amp_envelope = Smoother::new(SmoothingStyle::Exponential( + self.params.amp_attack_ms.value, + )); + amp_envelope.reset(0.0); + amp_envelope.set_target(sample_rate, 1.0); + let voice = self.start_voice(context, timing, voice_id, channel, note); - - // TODO: Add and set the other fields + voice.velocity_sqrt = velocity.sqrt(); voice.phase = initial_phase; voice.phase_delta = util::midi_note_to_freq(note) / sample_rate; - voice.velocity_sqrt = velocity.sqrt(); + voice.amp_envelope = amp_envelope; } NoteEvent::NoteOff { - timing, + timing: _, voice_id, channel, note, velocity: _, } => { - // TODO: This should not immediately terminate the voice. For - // obvious reasons. - self.terminate_voice(context, timing, voice_id, channel, note); + self.start_release_for_voices(sample_rate, voice_id, channel, note) } NoteEvent::Choke { timing, @@ -200,7 +240,7 @@ impl Plugin for PolyModSynth { channel, note, } => { - self.terminate_voice(context, timing, voice_id, channel, note); + self.choke_voices(context, timing, voice_id, channel, note); } NoteEvent::PolyModulation { timing: _, @@ -326,27 +366,33 @@ impl Plugin for PolyModSynth { let block_len = block_end - block_start; let mut gain = [0.0; MAX_BLOCK_SIZE]; let mut voice_gain = [0.0; MAX_BLOCK_SIZE]; + let mut voice_amp_envelope = [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 (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, - }; + // 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 amp = voice.velocity_sqrt * gain[value_idx]; + // This is an exponential smoother repurposed as an AR envelope with values between + // 0 and 1. When a note off event is received, this envelope will start fading out + // again. When it reaches 0, we will terminate the voice. + voice + .amp_envelope + .next_block(&mut voice_amp_envelope, block_len); + + for (value_idx, sample_idx) in (block_start..block_end).enumerate() { + let amp = voice.velocity_sqrt * gain[value_idx] * voice_amp_envelope[value_idx]; let sample = (voice.phase * 2.0 - 1.0) * amp; voice.phase += voice.phase_delta; @@ -359,6 +405,25 @@ impl Plugin for PolyModSynth { } } + // Terminate voices whose release period has fully ended. This could be done as part of + // the previous loop but this is simpler. + for voice in self.voices.iter_mut() { + match voice { + Some(v) if v.releasing && v.amp_envelope.previous_value() == 0.0 => { + // This event is very important, as it allows the host to manage its own modulation + // voices + context.send_event(NoteEvent::VoiceTerminated { + timing: block_end as u32, + voice_id: Some(v.voice_id), + channel: v.channel, + note: v.note, + }); + *voice = None; + } + _ => (), + } + } + // And then just keep processing blocks until we've run out of buffer to fill block_start = block_end; block_end = (block_start + MAX_BLOCK_SIZE).min(num_samples); @@ -392,10 +457,12 @@ impl PolyModSynth { internal_voice_id: self.next_internal_voice_id, channel, note, - velocity_sqrt: 1.0, + phase: 0.0, phase_delta: 0.0, + releasing: false, + amp_envelope: Smoother::none(), voice_gain: None, }; @@ -437,9 +504,48 @@ impl PolyModSynth { } } - /// Terminate one or more voice, removing it from the pool and informing the host that the voice - /// has ended. If `voice_id` is not provided, then this will terminate all matching voices. - fn terminate_voice( + /// Start the release process for one or more voice by changing their amplitude envelope. If + /// `voice_id` is not provided, then this will terminate all matching voices. + fn start_release_for_voices( + &mut self, + sample_rate: f32, + voice_id: Option, + channel: u8, + note: u8, + ) { + for voice in self.voices.iter_mut() { + match voice { + Some(Voice { + voice_id: candidate_voice_id, + channel: candidate_channel, + note: candidate_note, + releasing, + amp_envelope, + .. + }) if voice_id == Some(*candidate_voice_id) + || (channel == *candidate_channel && note == *candidate_note) => + { + *releasing = true; + amp_envelope.style = + SmoothingStyle::Exponential(self.params.amp_release_ms.value); + amp_envelope.set_target(sample_rate, 0.0); + + // If this targetted a single voice ID, we're done here. Otherwise there may be + // multiple overlapping voices as we enabled support for that in the + // `PolyModulationConfig`. + if voice_id.is_some() { + return; + } + } + _ => (), + } + } + } + + /// Immediately terminate one or more voice, removing it from the pool and informing the host + /// that the voice has ended. If `voice_id` is not provided, then this will terminate all + /// matching voices. + fn choke_voices( &mut self, context: &mut impl ProcessContext, sample_offset: u32, @@ -447,7 +553,6 @@ impl PolyModSynth { channel: u8, note: u8, ) { - // TODO: If voice ID = none, terminate all matching voices for voice in self.voices.iter_mut() { match voice { Some(Voice { @@ -458,8 +563,6 @@ impl PolyModSynth { }) if voice_id == Some(*candidate_voice_id) || (channel == *candidate_channel && note == *candidate_note) => { - // This event is very important, as it allows the host to manage its own modulation - // voices context.send_event(NoteEvent::VoiceTerminated { timing: sample_offset, // Notice how we always send the terminated voice ID here @@ -469,9 +572,6 @@ impl PolyModSynth { }); *voice = None; - // If this targetted a single voice ID, we're done here. Otherwise there may be - // multiple overlapping voices as we enabled support for that in the - // `PolyModulationConfig`. if voice_id.is_some() { return; }