/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use baseview::dpi::LogicalSize; use iced_audio::{Gesture, Knob, param::nice_to_iced}; use nice_plug::prelude::*; use nice_plug_iced::{ IcedEditor, IcedEditorState, IcedNiceContext, PersistentState, application, create_iced_editor, iced::{ self, Alignment, Element, Font, Length, PollSubNotifier, Theme, font, widget::{Text, button, checkbox, column, container, row, rule, space, text}, }, }; use std::{f32::consts::TAU, sync::Arc}; const DEFAULT_FONT: Font = Font::with_name("Open Sans"); pub struct Ring6Plugin { editor_state: Arc, params: Arc, notifier: PollSubNotifier, sample_rate: f32, channel_state: [LadderEffectState; 2], } #[derive(Params)] struct Ring6PluginParams { #[id = "pre-gain"] pre_gain: FloatParam, #[id = "post-gain"] post_gain: FloatParam, #[id = "effect-gain"] effect_gain: FloatParam, #[id = "pz-offs"] pz_offs: FloatParam, #[id = "neg-offs"] neg_offs: FloatParam, #[id = "threshold"] threshold: FloatParam, #[id = "effect-lp-cutoff"] effect_lp_cutoff: FloatParam, #[id = "post-lp"] post_lp: BoolParam, #[id = "post-lp-cutoff"] post_lp_cutoff: FloatParam, #[id = "effect-adaa"] effect_adaa: BoolParam, #[id = "release-ms"] release_ms: FloatParam, #[id = "effect-balance"] effect_balance: FloatParam, } fn gain_param(name: &str) -> FloatParam { FloatParam::new( name, util::db_to_gain(0.0), FloatRange::Skewed { min: util::db_to_gain(-30.0), max: util::db_to_gain(12.0), factor: FloatRange::gain_skew_factor(-30.0, 12.0), }, ) .with_smoother(SmoothingStyle::Logarithmic(50.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()) } fn offset_param(name: &str, ticks: f32, bound: f32) -> FloatParam { FloatParam::new( name, ticks / bound, FloatRange::Linear { min: 0.0, max: 16.0 / bound, }, ) .with_smoother(SmoothingStyle::Linear(50.0)) // TODO: use formatters here? .with_value_to_string(Arc::new(move |v| format!("{:.2}", v * bound))) .with_string_to_value(Arc::new(move |s| s.parse::().ok().map(|v| v / bound))) } fn cutoff_param(name: &str, default: f32, min: f32, max: f32) -> FloatParam { FloatParam::new( name, default, FloatRange::Skewed { min, max, factor: FloatRange::skew_factor(-2.0), }, ) .with_smoother(SmoothingStyle::Logarithmic(50.0)) .with_value_to_string(formatters::v2s_f32_hz_then_khz(2)) .with_string_to_value(formatters::s2v_f32_hz_then_khz()) } impl Default for Ring6PluginParams { fn default() -> Self { Self { pre_gain: gain_param("Pre-Gain"), post_gain: gain_param("Post-Gain"), effect_gain: gain_param("Effect Gain"), pz_offs: offset_param("Positive/0 Offset", 4.0, 256.0), neg_offs: offset_param("Negative Offset", 3.0, 256.0), threshold: offset_param("Effect Threshold", 1.0, 256.0), effect_lp_cutoff: cutoff_param("Effect Low-Pass", 3390.0, 100.0, 20000.0), effect_adaa: BoolParam::new("Anti-Alias", false), post_lp_cutoff: cutoff_param("Post Low-Pass", 20000.0, 100.0, 20000.0), post_lp: BoolParam::new("Post Low-Pass Enabled", false), release_ms: FloatParam::new( "Release", 15.0, FloatRange::Linear { min: 0.0, max: 100.0, }, ) .with_smoother(SmoothingStyle::Linear(50.0)) .with_unit(" ms") .with_value_to_string(formatters::v2s_f32_rounded(2)) .with_string_to_value(Arc::new(|s| { s.strip_suffix(" ms").unwrap_or(s).parse::().ok() })), effect_balance: FloatParam::new( "Balance", 0.0, FloatRange::Linear { min: -1.0, max: 1.0, }, ) .with_smoother(SmoothingStyle::Linear(50.0)) .with_value_to_string(formatters::v2s_f32_panning()) .with_string_to_value(formatters::s2v_f32_panning()), } } } #[derive(Debug, Clone, Copy)] enum BoolParamKey { EffectADAA, PostLp, } #[derive(Debug, Clone, Copy)] enum FloatParamKey { PreGain, PostGain, EffectGain, PzOffs, NegOffs, Threshold, EffectLpCutoff, PostLpCutoff, // TODO: should this actually be customizable? // ReleaseMs, EffectBalance, } impl Ring6PluginParams { fn get_bool(&self, key: BoolParamKey) -> &BoolParam { match key { BoolParamKey::EffectADAA => &self.effect_adaa, BoolParamKey::PostLp => &self.post_lp, } } fn get_float(&self, key: FloatParamKey) -> &FloatParam { match key { FloatParamKey::PreGain => &self.pre_gain, FloatParamKey::PostGain => &self.post_gain, FloatParamKey::EffectGain => &self.effect_gain, FloatParamKey::PzOffs => &self.pz_offs, FloatParamKey::NegOffs => &self.neg_offs, FloatParamKey::Threshold => &self.threshold, FloatParamKey::EffectLpCutoff => &self.effect_lp_cutoff, FloatParamKey::PostLpCutoff => &self.post_lp_cutoff, // FloatParamKey::ReleaseMs => &self.release_ms, FloatParamKey::EffectBalance => &self.effect_balance, } } } impl Default for Ring6Plugin { fn default() -> Self { Self { editor_state: IcedEditorState::from_size(LogicalSize::new(540.0, 360.0), 1.0), params: Arc::new(Ring6PluginParams::default()), notifier: PollSubNotifier::new(), sample_rate: 0.0, channel_state: Default::default(), } } } #[derive(Debug, Default)] struct LadderEffectState { base_sample: f32, effect_lp: f32, post_lp: f32, pz_release_level: f32, } #[derive(Debug)] struct LadderEffectParams<'s> { pre_gain: f32, post_gain: f32, effect_gain: f32, pz_offs: f32, neg_offs: f32, threshold: f32, effect_adaa: bool, effect_lp_cutoff: f32, post_lp_cutoff: Option, pz_release_ms: f32, sample_rate: f32, state: &'s mut LadderEffectState, } #[inline(always)] fn ladder_effect(samples: &mut [f32], params: &mut LadderEffectParams<'_>) { let prev_sample = &mut params.state.base_sample; let lp_offs = &mut params.state.effect_lp; let lp_post = &mut params.state.post_lp; let lp_offs_a = 1.0 - (-TAU * params.effect_lp_cutoff / params.sample_rate).exp(); let lp_post_a = params .post_lp_cutoff .map(|cutoff| 1.0 - (-TAU * cutoff / params.sample_rate).exp()); let pz_release_level = &mut params.state.pz_release_level; let pz_release_factor = (-1.0 / (params.pz_release_ms / 1000.0 * params.sample_rate)).exp(); for sample in samples { // Apply a slow release during quiet periods to clean up used headroom. // TODO: should this fall back if threshold==0? if sample.abs() >= params.threshold { *pz_release_level = 1.0; } else if *pz_release_level <= 1e-6 { *pz_release_level = 0.0; } else { *pz_release_level *= pz_release_factor; } let pz_offs = params.pz_offs * *pz_release_level; let offs = if params.effect_adaa && (*sample - *prev_sample).abs() > 1e-8 { // ADAA let f = |s: f32| { if s < -params.threshold { -params.neg_offs * s } else { // Use C to make the lines for the negative & positive sides // continuous. let adaa_c = (-params.neg_offs - pz_offs) * -params.threshold; pz_offs * s + adaa_c } }; (f(*sample) - f(*prev_sample)) / (*sample - *prev_sample) } else if *sample < -params.threshold { -params.neg_offs } else { pz_offs }; *prev_sample = *sample; // LP on the effect offset. *lp_offs += lp_offs_a * (offs - *lp_offs); *sample *= params.pre_gain; *sample += *lp_offs * params.effect_gain; // LP on the final sample. *lp_post = if let Some(a) = lp_post_a { *lp_post + (a * (*sample - *lp_post)) } else { *sample }; *sample = *lp_post; *sample *= params.post_gain; } } impl Plugin for Ring6Plugin { const NAME: &'static str = "Ring 6"; const VENDOR: &'static str = "TODO"; const URL: &'static str = "TODO"; const EMAIL: &'static str = "info@example.com"; const VERSION: &'static str = env!("CARGO_PKG_VERSION"); const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[ AudioIOLayout { main_input_channels: NonZeroU32::new(2), main_output_channels: NonZeroU32::new(2), aux_input_ports: &[], aux_output_ports: &[], names: PortNames::const_default(), }, AudioIOLayout { main_input_channels: NonZeroU32::new(1), main_output_channels: NonZeroU32::new(1), ..AudioIOLayout::const_default() }, ]; const MIDI_INPUT: MidiConfig = MidiConfig::None; const SAMPLE_ACCURATE_AUTOMATION: bool = true; type Editor = IcedEditor; type SysExMessage = (); type BackgroundTask = (); fn activate( &mut self, _audio_io_layout: &AudioIOLayout, buffer_config: &BufferConfig, _context: &mut impl ActivateContext, ) -> bool { self.sample_rate = buffer_config.sample_rate; true } fn reset(&mut self) { self.channel_state = Default::default(); } fn params(&self) -> Arc { self.params.clone() } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option { let params = self.params.clone(); create_iced_editor( self.editor_state.clone(), Ring6EditorState { params }, self.notifier.clone(), Default::default(), |editor_state, nice_ctx| { Ok(application( editor_state, nice_ctx, Ring6Editor::new, Ring6Editor::update, Ring6Editor::view, ) .theme(Ring6Editor::theme) .default_font(DEFAULT_FONT) .font(include_bytes!("../fonts/OpenSans-Regular.subset.ttf")) .font(include_bytes!("../fonts/OpenSans-Bold.subset.ttf")) .subscription(|_| iced::poll_events().map(|_| Message::Poll)) .run()) }, ) } fn process( &mut self, buffer: &mut Buffer, _aux: &mut AuxiliaryBuffers, _context: &mut impl ProcessContext, ) -> ProcessStatus { for (i, channel) in buffer.as_slice().iter_mut().enumerate() { let balance = self.params.effect_balance.value(); let mut effect_params = LadderEffectParams { pre_gain: self.params.pre_gain.value(), post_gain: self.params.post_gain.value(), effect_gain: self.params.effect_gain.value() * if (balance > 0.0 && i == 0) || (balance < 0.0 && i == 1) { (1.0 - balance.abs()).powf(2.0) } else { 1.0 }, pz_offs: self.params.pz_offs.value(), neg_offs: self.params.neg_offs.value(), threshold: self.params.threshold.value(), effect_lp_cutoff: self.params.effect_lp_cutoff.value(), effect_adaa: self.params.effect_adaa.value(), post_lp_cutoff: if self.params.post_lp.value() { Some(self.params.post_lp_cutoff.value()) } else { None }, pz_release_ms: self.params.release_ms.value(), sample_rate: self.sample_rate, state: &mut self.channel_state[i], }; ladder_effect(channel, &mut effect_params); } ProcessStatus::Normal } fn deactivate(&mut self) {} } #[derive(Debug, Clone)] enum Message { Poll, FloatParamGesture(FloatParamKey, Gesture), FloatParamReset(FloatParamKey), BoolParam(BoolParamKey, bool), } struct Ring6EditorState { params: Arc, } struct Ring6Editor { state: PersistentState, nice_ctx: IcedNiceContext, } impl Ring6Editor { fn new(state: PersistentState, nice_ctx: IcedNiceContext) -> Self { Self { state, nice_ctx } } fn theme(&self) -> Option { Some(Theme::Oxocarbon) } fn update(&mut self, message: Message) { let params = &self.state.params; let setter = self.nice_ctx.nice_context.param_setter(); match message { Message::Poll => {} Message::FloatParamGesture(key, gesture) => { iced_audio::param::set_nice_param(params.get_float(key), gesture, &setter); } Message::FloatParamReset(key) => { let p = params.get_float(key); setter.begin_set_parameter(p); setter.set_parameter(p, p.default_plain_value()); setter.end_set_parameter(p); } Message::BoolParam(key, value) => { let p = params.get_bool(key); setter.begin_set_parameter(p); setter.set_parameter(p, value); setter.end_set_parameter(p); } } } fn view<'a>(&'a self) -> Element<'a, Message> { let params = &self.state.params; let bold = Font { weight: font::Weight::Bold, ..DEFAULT_FONT }; let heading = |title: &'static str, size: u32| -> Text<'a, _, _> { text(title).font(bold).size(size) }; let param_control_custom_hdr = |header: Element<'a, Message>, key: FloatParamKey| -> Element<'_, Message> { let p = params.get_float(key); column![ header, Knob::new(nice_to_iced(p)).on_gesture(move |g| Message::FloatParamGesture(key, g)), text(p.normalized_value_to_string(p.modulated_normalized_value(), true)).size(10), button(text("Reset").size(12)) .style(button::secondary) .on_press(Message::FloatParamReset(key)), ] .padding(8) .spacing(4) .height(Length::Fill) .width(Length::Fill) .align_x(Alignment::Center) .into() }; let param_control_text_hdr = |title: &'static str, key: FloatParamKey| -> Element<'a, Message> { param_control_custom_hdr(text(title).size(12).font(bold).into(), key) }; let param_control_toggle_hdr = |title: &'static str, toggle_key: BoolParamKey, float_key: FloatParamKey| -> Element<'a, Message> { let toggle_p = params.get_bool(toggle_key); param_control_custom_hdr( checkbox(toggle_p.value()) .on_toggle(move |b| Message::BoolParam(toggle_key, b)) .size(12) .label(title) .text_size(12) .font(bold) .into(), float_key, ) }; column![ row![heading("ring ", 18), heading("6", 18).style(text::primary)], row![ column![ heading("Pre", 16), column![ param_control_text_hdr("Gain", FloatParamKey::PreGain), space().height(Length::Fixed(16.0)) ], ] .spacing(8) .height(Length::Fill) .width(Length::Fill) .align_x(Alignment::Center), rule::vertical(2), column![ heading("Effect", 16), column![ row![ column![ param_control_text_hdr("Offset A", FloatParamKey::PzOffs), param_control_text_hdr("Offset B", FloatParamKey::NegOffs), ], column![ param_control_text_hdr("Threshold", FloatParamKey::Threshold), param_control_text_hdr("Balance", FloatParamKey::EffectBalance), // param_control_text_hdr("Decay", FloatParamKey::DecayMs), ], column![ param_control_text_hdr("Gain", FloatParamKey::EffectGain), param_control_text_hdr("Low-Pass", FloatParamKey::EffectLpCutoff), ], ], container( checkbox(params.effect_adaa.value()) .size(12) .label("Anti-Alias") .text_size(12) .font(bold) .on_toggle(|b| Message::BoolParam(BoolParamKey::EffectADAA, b)) ) .center_x(Length::Fill), ], ] .spacing(8) .height(Length::Fill) .width(Length::FillPortion(3)) .align_x(Alignment::Center), rule::vertical(2), column![ heading("Post", 16), column![ param_control_text_hdr("Gain", FloatParamKey::PostGain), param_control_toggle_hdr( "Low-Pass", BoolParamKey::PostLp, FloatParamKey::PostLpCutoff ), space().height(Length::Fixed(16.0)) ], ] .spacing(8) .height(Length::Fill) .width(Length::Fill) .align_x(Alignment::Center), ] .spacing(8) .height(Length::Fill) .width(Length::Fill), ] .height(Length::Fill) .width(Length::Fill) .padding(16) .spacing(16) .align_x(Alignment::Center) .into() } } impl ClapPlugin for Ring6Plugin { const CLAP_ID: &'static str = "dev.refi64.ring6"; const CLAP_DESCRIPTION: Option<&'static str> = Some("TODO"); const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL); const CLAP_SUPPORT_URL: Option<&'static str> = None; const CLAP_FEATURES: &'static [ClapFeature] = &[ClapFeature::AudioEffect, ClapFeature::Stereo]; } nice_export_clap!(Ring6Plugin);