From fc8eaa4057f01cf416684ab8cced8dd727deb232 Mon Sep 17 00:00:00 2001 From: Irene Date: Fri, 4 Sep 2026 19:12:36 +0900 Subject: [PATCH 1/2] feat: implement basic stereo delay plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add time (0-2000ms), feedback (0-1), and mix (0-1 wet/dry) parameters with a stereo delay line DSP. No extra features — just time, feedback and mix. --- src/lib.rs | 195 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 161 insertions(+), 34 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 23c6e59..fb6472e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,43 +5,125 @@ use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState}; use std::sync::Arc; // ────────────────────────────────────────────── -// Params — add your plugin parameters here. -// See musiikki/codename-206/src/params.rs for a real example. +// Params — time, feedback, mix // ────────────────────────────────────────────── +const MAX_DELAY_MS: f32 = 2_000.0; + #[derive(Params)] struct KolmekiviParams { /// Editor window size, persisted with the plugin state. #[persist = "editor-state"] editor_state: Arc, - /// Example parameter — replace with your own. The `id` is the stable key the host - /// stores automation against, so never change it once you've shipped. - #[id = "gain"] - gain: FloatParam, + /// Delay time in milliseconds. + #[id = "time"] + time: FloatParam, + + /// Feedback amount (0 = no feedback, 1 = oscillation). + #[id = "feedback"] + feedback: FloatParam, + + /// Wet/dry mix (0 = dry only, 1 = wet only). + #[id = "mix"] + mix: FloatParam, } impl Default for KolmekiviParams { fn default() -> Self { Self { editor_state: EguiState::from_size(320, 160), - gain: FloatParam::new( - "Gain", - util::db_to_gain(0.0), - FloatRange::Skewed { - min: util::db_to_gain(-30.0), - max: util::db_to_gain(30.0), - factor: FloatRange::gain_skew_factor(-30.0, 30.0), - }, + time: FloatParam::new( + "Time", + 500.0, + FloatRange::Linear { min: 0.0, max: MAX_DELAY_MS }, ) - .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()), + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(0)), + + feedback: FloatParam::new( + "Feedback", + 0.5, + FloatRange::Linear { min: 0.0, max: 1.0 }, + ) + .with_smoother(SmoothingStyle::Logarithmic(32.0)), + + mix: FloatParam::new( + "Mix", + 0.5, + FloatRange::Linear { min: 0.0, max: 1.0 }, + ) + .with_smoother(SmoothingStyle::Logarithmic(32.0)), } } } +// ────────────────────────────────────────────── +// Stereo delay line +// ────────────────────────────────────────────── + +struct StereoDelayLine { + left: Vec, + right: Vec, + write_pos: usize, +} + +impl StereoDelayLine { + fn new(max_samples: usize) -> Self { + Self { + left: vec![0.0; max_samples], + right: vec![0.0; max_samples], + write_pos: 0, + } + } + + fn reset(&mut self) { + for sample in &mut self.left { + *sample = 0.0; + } + for sample in &mut self.right { + *sample = 0.0; + } + self.write_pos = 0; + } + + /// Process one sample frame for both channels. + /// Returns (output_left, output_right) as dry + wet mix. + fn process_frame( + &mut self, + input_left: f32, + input_right: f32, + delay_samples: usize, + feedback: f32, + mix: f32, + ) -> (f32, f32) { + let read_pos = if delay_samples >= self.left.len() { + 0 + } else { + self.write_pos.saturating_sub(delay_samples) + }; + + let delayed_left = self.left[read_pos]; + let delayed_right = self.right[read_pos]; + + // Feedback-mixed input goes into the delay line + let fb_input_left = (input_left + delayed_left * feedback).clamp(-1.0, 1.0); + let fb_input_right = (input_right + delayed_right * feedback).clamp(-1.0, 1.0); + + // Store into delay line + self.left[self.write_pos] = fb_input_left; + self.right[self.write_pos] = fb_input_right; + + // Output is dry signal blended with delayed (wet) signal + let out_left = input_left * (1.0 - mix) + delayed_left * mix; + let out_right = input_right * (1.0 - mix) + delayed_right * mix; + + self.write_pos = (self.write_pos + 1) % self.left.len(); + + (out_left, out_right) + } +} + // ────────────────────────────────────────────── // Plugin // ────────────────────────────────────────────── @@ -49,10 +131,11 @@ impl Default for KolmekiviParams { #[derive(Default)] struct Kolmekivi { params: Arc, + sample_rate: f32, + delay_line: Option, } impl Plugin for Kolmekivi { - // TODO: Replace these placeholder values with your plugin's metadata. const NAME: &'static str = "Kolmekivi"; const VENDOR: &'static str = "Novoyuuparosk"; const URL: &'static str = env!("CARGO_PKG_HOMEPAGE"); @@ -79,7 +162,6 @@ impl Plugin for Kolmekivi { } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { - // TODO: Build your own egui layout here. let params = self.params.clone(); create_egui_editor( self.params.editor_state.clone(), @@ -87,8 +169,18 @@ impl Plugin for Kolmekivi { |_, _| {}, move |egui_ctx, setter, _state| { egui::CentralPanel::default().show(egui_ctx, |ui| { - ui.label("Gain"); - ui.add(widgets::ParamSlider::for_param(¶ms.gain, setter)); + ui.horizontal(|ui| { + ui.label("Time"); + ui.add(widgets::ParamSlider::for_param(¶ms.time, setter)); + }); + ui.horizontal(|ui| { + ui.label("Feedback"); + ui.add(widgets::ParamSlider::for_param(¶ms.feedback, setter)); + }); + ui.horizontal(|ui| { + ui.label("Mix"); + ui.add(widgets::ParamSlider::for_param(¶ms.mix, setter)); + }); }); }, ) @@ -97,13 +189,23 @@ impl Plugin for Kolmekivi { fn initialize( &mut self, _audio_io_layout: &AudioIOLayout, - _buffer_config: &BufferConfig, + buffer_config: &BufferConfig, _context: &mut impl InitContext, ) -> bool { + let sample_rate = buffer_config.sample_rate; + self.sample_rate = sample_rate; + // Allocate for max delay (2 seconds at max sample rate) + let max_samples = (MAX_DELAY_MS * sample_rate / 1000.0) as usize + 1; + + self.delay_line = Some(StereoDelayLine::new(max_samples)); true } - fn reset(&mut self) {} + fn reset(&mut self) { + if let Some(ref mut delay_line) = self.delay_line { + delay_line.reset(); + } + } fn process( &mut self, @@ -111,12 +213,35 @@ impl Plugin for Kolmekivi { _aux: &mut AuxiliaryBuffers, _context: &mut impl ProcessContext, ) -> ProcessStatus { - // TODO: Implement your DSP here. This applies the example gain parameter; note that - // the smoother is advanced once per frame so automation doesn't zipper. + let delay_line = match &mut self.delay_line { + Some(dl) => dl, + None => return ProcessStatus::Normal, + }; + + let sample_rate = self.sample_rate; + let time_val = self.params.time.smoothed.next(); + let feedback = self.params.feedback.smoothed.next(); + let mix = self.params.mix.smoothed.next(); + let delay_samples = (time_val * sample_rate / 1000.0) as usize; + + // Process each sample in the buffer for mut frame in buffer.iter_samples() { - let gain = self.params.gain.smoothed.next(); - for sample in frame.iter_mut() { - *sample *= gain; + let n = frame.len().min(2); + // Read input samples + let input_left = *frame.get_mut(0).unwrap(); + let input_right = if n > 1 { + *frame.get_mut(1).unwrap() + } else { + 0.0 + }; + + let (output_left, output_right) = + delay_line.process_frame(input_left, input_right, delay_samples, feedback, mix); + + // Write output back — left channel always exists, right may not + *frame.get_mut(0).unwrap() = output_left; + if n > 1 { + *frame.get_mut(1).unwrap() = output_right; } } @@ -125,18 +250,20 @@ impl Plugin for Kolmekivi { } impl ClapPlugin for Kolmekivi { - // Frozen identity: hosts key saved projects and presets off this. Never change it. const CLAP_ID: &'static str = "org.novoyuuparosk.kolmekivi"; - const CLAP_DESCRIPTION: Option<&'static str> = Some("Stereo delay with feedback and mix control"); + const CLAP_DESCRIPTION: Option<&'static str> = + Some("Stereo delay with feedback and mix control"); 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::Delay, ClapFeature::Stereo]; + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::AudioEffect, + ClapFeature::Delay, + ClapFeature::Stereo, + ]; } #[cfg(feature = "vst3")] impl Vst3Plugin for Kolmekivi { - // Frozen identity, exactly 16 bytes. Never change it once distributed. const VST3_CLASS_ID: [u8; 16] = *b"KolmekiviDelay!!"; const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[Vst3SubCategory::Fx, Vst3SubCategory::Delay]; From 1348ac59e08a4e43305f09dfe94d3db1cc891897 Mon Sep 17 00:00:00 2001 From: Irene Date: Fri, 4 Sep 2026 19:14:08 +0900 Subject: [PATCH 2/2] feat: add tempo-sync for delay time Add a Sync checkbox parameter. When enabled the Time parameter is interpreted as a beat subdivision (1.0 = quarter note, 0.5 = eighth, 2.0 = half note) derived from the host transport tempo. Falls back to 120 BPM when no tempo is available. The Time label shows 'Time (1/n)' in the UI when sync is on for visual clarity. --- src/lib.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fb6472e..0cd8bcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState}; use std::sync::Arc; // ────────────────────────────────────────────── -// Params — time, feedback, mix +// Params — time, feedback, mix, sync // ────────────────────────────────────────────── const MAX_DELAY_MS: f32 = 2_000.0; @@ -16,7 +16,7 @@ struct KolmekiviParams { #[persist = "editor-state"] editor_state: Arc, - /// Delay time in milliseconds. + /// Delay time in milliseconds (sync off) or beat subdivision (sync on). #[id = "time"] time: FloatParam, @@ -27,12 +27,17 @@ struct KolmekiviParams { /// Wet/dry mix (0 = dry only, 1 = wet only). #[id = "mix"] mix: FloatParam, + + /// When true, Time is interpreted as a beat subdivision (1.0 = quarter note) + /// derived from the host tempo. When false, Time is raw milliseconds. + #[id = "sync"] + sync: BoolParam, } impl Default for KolmekiviParams { fn default() -> Self { Self { - editor_state: EguiState::from_size(320, 160), + editor_state: EguiState::from_size(320, 180), time: FloatParam::new( "Time", 500.0, @@ -54,6 +59,8 @@ impl Default for KolmekiviParams { FloatRange::Linear { min: 0.0, max: 1.0 }, ) .with_smoother(SmoothingStyle::Logarithmic(32.0)), + + sync: BoolParam::new("Sync", false), } } } @@ -168,9 +175,14 @@ impl Plugin for Kolmekivi { (), |_, _| {}, move |egui_ctx, setter, _state| { + let sync = params.sync.value(); egui::CentralPanel::default().show(egui_ctx, |ui| { ui.horizontal(|ui| { - ui.label("Time"); + if sync { + ui.label("Time (1/n)"); + } else { + ui.label("Time"); + } ui.add(widgets::ParamSlider::for_param(¶ms.time, setter)); }); ui.horizontal(|ui| { @@ -181,6 +193,14 @@ impl Plugin for Kolmekivi { ui.label("Mix"); ui.add(widgets::ParamSlider::for_param(¶ms.mix, setter)); }); + ui.horizontal(|ui| { + let mut sync_val = params.sync.value(); + let changed = ui.checkbox(&mut sync_val as &mut bool, "Sync to tempo"); + if changed.changed() { + setter.begin_set_parameter(¶ms.sync); + setter.set_parameter(¶ms.sync, sync_val); + } + }); }); }, ) @@ -222,7 +242,20 @@ impl Plugin for Kolmekivi { let time_val = self.params.time.smoothed.next(); let feedback = self.params.feedback.smoothed.next(); let mix = self.params.mix.smoothed.next(); - let delay_samples = (time_val * sample_rate / 1000.0) as usize; + let sync = self.params.sync.value(); + + let delay_samples = if sync { + // Time is a beat subdivision multiplier: 1.0 = quarter note, + // 0.5 = eighth, 2.0 = half note, etc. + let tempo = _context.transport().tempo.unwrap_or(120.0) as f32; + let beat_duration_s = 60.0_f32 / tempo; // quarter note in seconds + let subdivisions = time_val.clamp(0.015625, 8.0); // 32nd to 4x whole + let delay_s = subdivisions * beat_duration_s; + (delay_s * sample_rate) as usize + } else { + // Time is raw milliseconds + (time_val * sample_rate / 1000.0) as usize + }; // Process each sample in the buffer for mut frame in buffer.iter_samples() {