Compare commits

..

6 Commits

Author SHA1 Message Date
mikkeli c3775f4548 Merge branch 'ci/validate-workflow-changes'
CI / Build (push) Successful in 35s
Include the workflow file in both path filters so CI changes are self-validating.
2026-09-04 19:33:12 +09:00
mikkeli 9675905401 ci: let a workflow change validate itself
Adds .github/workflows/** to both path filters. A broken step, a moved
script path or a wrong runner label now fails on the commit that caused
it, instead of surfacing later against an unrelated source change and
looking like that change's fault.

This session produced exactly that bug: after the branding tools moved
under tools/, the workflow still ran `sh check-branding.sh` at the old
path, and it was caught by reading the file rather than by CI.

The limit is worth stating: this validates the job body, not the trigger
block. Break `on:` and the symptom is silence, which is indistinguishable
from success.

Workflow edits are rare enough that the extra builds cost nothing, and CI
config is the definition of how the project is built and verified — it
belongs on the source side of "source code edits only", not with the docs.

kolmekivi additionally gains the pull_request path filter the other two
already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-09-04 19:33:12 +09:00
irene 552b82bbaf docs: update README to reflect completed DSP and fix stale references
- Replace 'DSP not written yet' description with actual stereo delay
  DSP summary.
- Update src/lib.rs description in project structure tree.
- Replace stale 192.168.2.199 IP with pi5-16.local hostname in the
  clone URL (both in README body and Quick Start example).
2026-09-04 19:23:30 +09:00
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
3 changed files with 215 additions and 39 deletions
+16
View File
@@ -10,8 +10,24 @@ on:
- 'Cargo.lock'
- 'bundler.toml'
- 'tools/**'
# This workflow validates itself: a broken step, a moved script path or a
# wrong runner label then fails loudly on the commit that caused it, rather
# than surfacing later against an unrelated source change. Note the limit -
# it checks the job body, not the trigger block above. Break `on:` and the
# symptom is silence, which looks like success.
- '.github/workflows/**'
# Mirrors the push filter. Without it a PR touching only the README would run a
# full build - the branch filter alone constrains where, not what.
pull_request:
branches: [ master ]
paths:
- 'src/**'
- 'xtask/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'bundler.toml'
- 'tools/**'
- '.github/workflows/**'
workflow_dispatch:
# Target the self-hosted Rust/audio runner registered to the `musiikki` org.
+4 -4
View File
@@ -2,13 +2,13 @@
A stereo delay/echo plugin — Rust, CLAP/VST3, egui.
Scaffolded from [`musiikki/nih-plug-template`](http://192.168.2.199:3005/musiikki/nih-plug-template);
the DSP is not written yet, the plugin currently passes audio through an example gain stage.
Scaffolded from [`musiikki/nih-plug-template`](http://pi5-16.local:3005/musiikki/nih-plug-template);
the DSP is a simple stereo delay with independent left/right delay lines, feedback, and a wet/dry mix control.
## Quick Start
```bash
git clone http://192.168.2.199:3005/musiikki/kolmekivi
git clone http://pi5-16.local:3005/musiikki/kolmekivi
cd kolmekivi
cargo xtask bundle kolmekivi --release --features vst3
```
@@ -81,7 +81,7 @@ cp -r "target/bundled/Kolmekivi.vst3" ~/.vst3/
```
Cargo.toml # Package deps + the opt-in `vst3` feature — NIH-plug pinned to codename-206's rev
bundler.toml # Bundle name for `cargo xtask bundle`
src/lib.rs # Plugin skeleton — params, DSP, and egui editor, with one example gain param
src/lib.rs # Plugin — params, stereo delay DSP, and egui editor
xtask/ # Bundler xtask (cargo xtask bundle)
.cargo/config.toml # xtask alias
tools/ # Branding toolkit — see Renaming
+195 -35
View File
@@ -5,43 +5,132 @@ 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, sync
// ──────────────────────────────────────────────
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<EguiState>,
/// 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 (sync off) or beat subdivision (sync on).
#[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,
/// 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),
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),
},
editor_state: EguiState::from_size(320, 180),
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)),
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
// ──────────────────────────────────────────────
@@ -49,10 +138,11 @@ impl Default for KolmekiviParams {
#[derive(Default)]
struct Kolmekivi {
params: Arc<KolmekiviParams>,
sample_rate: f32,
delay_line: Option<StereoDelayLine>,
}
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,16 +169,38 @@ impl Plugin for Kolmekivi {
}
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
// TODO: Build your own egui layout here.
let params = self.params.clone();
create_egui_editor(
self.params.editor_state.clone(),
(),
|_, _| {},
move |egui_ctx, setter, _state| {
let sync = params.sync.value();
egui::CentralPanel::default().show(egui_ctx, |ui| {
ui.label("Gain");
ui.add(widgets::ParamSlider::for_param(&params.gain, setter));
ui.horizontal(|ui| {
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(
&mut self,
_audio_io_layout: &AudioIOLayout,
_buffer_config: &BufferConfig,
buffer_config: &BufferConfig,
_context: &mut impl InitContext<Self>,
) -> 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 +233,48 @@ impl Plugin for Kolmekivi {
_aux: &mut AuxiliaryBuffers,
_context: &mut impl ProcessContext<Self>,
) -> 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 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() {
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 +283,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];