Compare commits

...

3 Commits

Author SHA1 Message Date
irene 360c18ee0a feat: implement stereo delay plugin
CI / Build (push) Successful in 34s
Add time (0-2000ms), feedback (0-1), and mix (0-1 wet/dry) parameters
with a stereo delay line DSP.

When sync is enabled the Time parameter is interpreted as a beat
subdivision (1.0 = quarter note) derived from the host transport tempo,
falling back to 120 BPM when no tempo is available.
2026-09-04 19:21:08 +09:00
irene 1348ac59e0 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.
2026-09-04 19:14:08 +09:00
irene fc8eaa4057 feat: implement basic stereo delay plugin
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.
2026-09-04 19:12:36 +09:00
+195 -35
View File
@@ -5,43 +5,132 @@ use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState};
use std::sync::Arc; use std::sync::Arc;
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Params — add your plugin parameters here. // Params — time, feedback, mix, sync
// See musiikki/codename-206/src/params.rs for a real example.
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
const MAX_DELAY_MS: f32 = 2_000.0;
#[derive(Params)] #[derive(Params)]
struct KolmekiviParams { struct KolmekiviParams {
/// Editor window size, persisted with the plugin state. /// Editor window size, persisted with the plugin state.
#[persist = "editor-state"] #[persist = "editor-state"]
editor_state: Arc<EguiState>, editor_state: Arc<EguiState>,
/// Example parameter — replace with your own. The `id` is the stable key the host /// Delay time in milliseconds (sync off) or beat subdivision (sync on).
/// stores automation against, so never change it once you've shipped. #[id = "time"]
#[id = "gain"] time: FloatParam,
gain: 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,
/// 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 { impl Default for KolmekiviParams {
fn default() -> Self { fn default() -> Self {
Self { Self {
editor_state: EguiState::from_size(320, 160), editor_state: EguiState::from_size(320, 180),
gain: FloatParam::new( time: FloatParam::new(
"Gain", "Time",
util::db_to_gain(0.0), 500.0,
FloatRange::Skewed { FloatRange::Linear { min: 0.0, max: MAX_DELAY_MS },
min: util::db_to_gain(-30.0),
max: util::db_to_gain(30.0),
factor: FloatRange::gain_skew_factor(-30.0, 30.0),
},
) )
.with_smoother(SmoothingStyle::Logarithmic(50.0)) .with_unit(" ms")
.with_unit(" dB") .with_value_to_string(formatters::v2s_f32_rounded(0)),
.with_value_to_string(formatters::v2s_f32_gain_to_db(2))
.with_string_to_value(formatters::s2v_f32_gain_to_db()), 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)),
sync: BoolParam::new("Sync", false),
} }
} }
} }
// ──────────────────────────────────────────────
// Stereo delay line
// ──────────────────────────────────────────────
struct StereoDelayLine {
left: Vec<f32>,
right: Vec<f32>,
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 // Plugin
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -49,10 +138,11 @@ impl Default for KolmekiviParams {
#[derive(Default)] #[derive(Default)]
struct Kolmekivi { struct Kolmekivi {
params: Arc<KolmekiviParams>, params: Arc<KolmekiviParams>,
sample_rate: f32,
delay_line: Option<StereoDelayLine>,
} }
impl Plugin for Kolmekivi { impl Plugin for Kolmekivi {
// TODO: Replace these placeholder values with your plugin's metadata.
const NAME: &'static str = "Kolmekivi"; const NAME: &'static str = "Kolmekivi";
const VENDOR: &'static str = "Novoyuuparosk"; const VENDOR: &'static str = "Novoyuuparosk";
const URL: &'static str = env!("CARGO_PKG_HOMEPAGE"); const URL: &'static str = env!("CARGO_PKG_HOMEPAGE");
@@ -79,16 +169,38 @@ impl Plugin for Kolmekivi {
} }
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> { fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
// TODO: Build your own egui layout here.
let params = self.params.clone(); let params = self.params.clone();
create_egui_editor( create_egui_editor(
self.params.editor_state.clone(), self.params.editor_state.clone(),
(), (),
|_, _| {}, |_, _| {},
move |egui_ctx, setter, _state| { move |egui_ctx, setter, _state| {
let sync = params.sync.value();
egui::CentralPanel::default().show(egui_ctx, |ui| { egui::CentralPanel::default().show(egui_ctx, |ui| {
ui.label("Gain"); ui.horizontal(|ui| {
ui.add(widgets::ParamSlider::for_param(&params.gain, setter)); if sync {
ui.label("Time (1/n)");
} else {
ui.label("Time");
}
ui.add(widgets::ParamSlider::for_param(&params.time, setter));
});
ui.horizontal(|ui| {
ui.label("Feedback");
ui.add(widgets::ParamSlider::for_param(&params.feedback, setter));
});
ui.horizontal(|ui| {
ui.label("Mix");
ui.add(widgets::ParamSlider::for_param(&params.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(&params.sync);
setter.set_parameter(&params.sync, sync_val);
}
});
}); });
}, },
) )
@@ -97,13 +209,23 @@ impl Plugin for Kolmekivi {
fn initialize( fn initialize(
&mut self, &mut self,
_audio_io_layout: &AudioIOLayout, _audio_io_layout: &AudioIOLayout,
_buffer_config: &BufferConfig, buffer_config: &BufferConfig,
_context: &mut impl InitContext<Self>, _context: &mut impl InitContext<Self>,
) -> bool { ) -> 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 true
} }
fn reset(&mut self) {} fn reset(&mut self) {
if let Some(ref mut delay_line) = self.delay_line {
delay_line.reset();
}
}
fn process( fn process(
&mut self, &mut self,
@@ -111,12 +233,48 @@ impl Plugin for Kolmekivi {
_aux: &mut AuxiliaryBuffers, _aux: &mut AuxiliaryBuffers,
_context: &mut impl ProcessContext<Self>, _context: &mut impl ProcessContext<Self>,
) -> ProcessStatus { ) -> ProcessStatus {
// TODO: Implement your DSP here. This applies the example gain parameter; note that let delay_line = match &mut self.delay_line {
// the smoother is advanced once per frame so automation doesn't zipper. 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 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() { for mut frame in buffer.iter_samples() {
let gain = self.params.gain.smoothed.next(); let n = frame.len().min(2);
for sample in frame.iter_mut() { // Read input samples
*sample *= gain; 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 +283,20 @@ impl Plugin for Kolmekivi {
} }
impl ClapPlugin 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_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_MANUAL_URL: Option<&'static str> = Some(Self::URL);
const CLAP_SUPPORT_URL: Option<&'static str> = None; const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = const CLAP_FEATURES: &'static [ClapFeature] = &[
&[ClapFeature::AudioEffect, ClapFeature::Delay, ClapFeature::Stereo]; ClapFeature::AudioEffect,
ClapFeature::Delay,
ClapFeature::Stereo,
];
} }
#[cfg(feature = "vst3")] #[cfg(feature = "vst3")]
impl Vst3Plugin for Kolmekivi { impl Vst3Plugin for Kolmekivi {
// Frozen identity, exactly 16 bytes. Never change it once distributed.
const VST3_CLASS_ID: [u8; 16] = *b"KolmekiviDelay!!"; const VST3_CLASS_ID: [u8; 16] = *b"KolmekiviDelay!!";
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
&[Vst3SubCategory::Fx, Vst3SubCategory::Delay]; &[Vst3SubCategory::Fx, Vst3SubCategory::Delay];