feat(knurl): add Knurl cycle texture
Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m31s
Check and Windows preview / check (knurl) (push) Successful in 2m4s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m54s
Check and Windows preview / check (rivet) (push) Successful in 1m30s
Check and Windows preview / check (temper) (push) Successful in 1m31s
Check and Windows preview / check (weld) (push) Successful in 2m31s
Check and Windows preview / previews (push) Successful in 1m26s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m26s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 8s
Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m31s
Check and Windows preview / check (knurl) (push) Successful in 2m4s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m54s
Check and Windows preview / check (rivet) (push) Successful in 1m30s
Check and Windows preview / check (temper) (push) Successful in 1m31s
Check and Windows preview / check (weld) (push) Successful in 2m31s
Check and Windows preview / previews (push) Successful in 1m26s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m26s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 8s
Knurl follows the played note and treats every cycle of the wave on its own: a repeating level pattern over 1-16 cycles makes subharmonics in tune with every note, random level and timing per cycle add rattle, and a noise burst per cycle, highpassed at a multiple of f0, adds grit. Amounts are counted in cycles, so the texture is the same on F1 and on C2. - Cycle clock from the shared note tracker, restarted at each note; the audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom. - Per-cycle randomness from a hash of the cycle number: Same every note or Free. - Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn; grit switchable per step. Pattern persisted as knurl-pattern. - Editor: last eight cycles overlaid, pattern bars with the playing step. - Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3 at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off, no allocation on the audio thread.
This commit is contained in:
1 parent
f4c0a833e6
commit
5cc3770737
27 files changed
+4699
-33
No files matched your search
@@ -0,0 +1,195 @@
|
||||
//! CLAP audio effect. All DSP state is owned by the processing thread.
|
||||
mod params;
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use knurl_core::{AnalyzerFrame, Engine};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::{KnurlParams, Pattern, GESTURES};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct EditorData {
|
||||
ui: knurl_ui::State,
|
||||
gestures: [bool; GESTURES],
|
||||
}
|
||||
pub struct Knurl {
|
||||
params: Arc<KnurlParams>,
|
||||
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
|
||||
engine: Option<Engine>,
|
||||
pattern: Pattern,
|
||||
publish_count: usize,
|
||||
publish_interval: usize,
|
||||
}
|
||||
impl Default for Knurl {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(KnurlParams::default()),
|
||||
analyzer: Arc::new(ArrayQueue::new(2)),
|
||||
engine: None,
|
||||
pattern: Pattern::default(),
|
||||
publish_count: 0,
|
||||
publish_interval: 1600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Knurl {
|
||||
const NAME: &'static str = "Knurl";
|
||||
const VENDOR: &'static str = "Bass Foundry";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
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),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let analyzer = self.analyzer.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: knurl_ui::State::default(),
|
||||
gestures: [false; GESTURES],
|
||||
},
|
||||
|ctx, _| knurl_ui::style(ctx),
|
||||
move |ctx, setter, data| {
|
||||
while let Some(frame) = analyzer.pop() {
|
||||
data.ui.accept_frame(frame);
|
||||
}
|
||||
let current = params.pattern();
|
||||
let before = params.settings(¤t);
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("knurl-editor")
|
||||
.min_size(egui::vec2(
|
||||
knurl_ui::WIDTH as f32 - 80.,
|
||||
knurl_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
knurl_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// The scope follows the audio.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(40));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
let Ok(mut engine) = Engine::new(
|
||||
config.sample_rate,
|
||||
layout.main_input_channels.map_or(0, |v| v.get() as usize),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let settings = self.params.settings(&self.pattern);
|
||||
self.pattern = Pattern {
|
||||
gains: settings.pattern,
|
||||
grit: settings.grit_steps,
|
||||
};
|
||||
if engine.set_settings(settings).is_err() {
|
||||
return false;
|
||||
}
|
||||
context.set_latency_samples(engine.latency_samples());
|
||||
self.publish_interval = (config.sample_rate / 30.) as usize;
|
||||
self.engine = Some(engine);
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if let Some(e) = self.engine.as_mut() {
|
||||
e.reset();
|
||||
}
|
||||
self.publish_count = 0;
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(engine) = self.engine.as_mut() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let settings = self.params.settings(&self.pattern);
|
||||
self.pattern = Pattern {
|
||||
gains: settings.pattern,
|
||||
grit: settings.grit_steps,
|
||||
};
|
||||
let _ = engine.set_settings(settings);
|
||||
let mut next = context.next_event();
|
||||
for (index, mut sample) in buffer.iter_samples().enumerate() {
|
||||
while let Some(event) = next {
|
||||
if event.timing() as usize > index {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
|
||||
engine.note_on(note)
|
||||
}
|
||||
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
|
||||
engine.note_off(note)
|
||||
}
|
||||
NoteEvent::Choke { note, .. } => engine.note_off(note),
|
||||
_ => {}
|
||||
}
|
||||
next = context.next_event();
|
||||
}
|
||||
let x = [
|
||||
*sample.get_mut(0).unwrap(),
|
||||
if engine.channels() == 2 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
];
|
||||
let out = engine.process_sample(x);
|
||||
for (c, value) in sample.into_iter().enumerate() {
|
||||
*value = out[c];
|
||||
}
|
||||
self.publish_count += 1;
|
||||
if self.publish_count >= self.publish_interval {
|
||||
self.analyzer.force_push(engine.take_analyzer());
|
||||
self.publish_count = 0;
|
||||
}
|
||||
}
|
||||
// The lookahead delay and filter tails must drain after input stops.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Knurl {
|
||||
const CLAP_ID: &'static str = "io.storyboard.cycle-texture";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Texture per cycle of the played note: patterns, jitter and grit that fit every note");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Distortion,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Knurl);
|
||||
@@ -0,0 +1,255 @@
|
||||
use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SourceParam {
|
||||
#[id = "audio"]
|
||||
Audio,
|
||||
#[id = "midi"]
|
||||
#[name = "MIDI"]
|
||||
Midi,
|
||||
}
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SeedParam {
|
||||
#[id = "same"]
|
||||
#[name = "Same every note"]
|
||||
Same,
|
||||
#[id = "free"]
|
||||
Free,
|
||||
}
|
||||
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
|
||||
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
|
||||
}
|
||||
/// Continuous controls, in the order of the gesture slots.
|
||||
const FLOATS: usize = 10;
|
||||
pub const GESTURES: usize = FLOATS;
|
||||
|
||||
/// The drawn pattern: step gains and grit switches. Saved with the project, not automation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Pattern {
|
||||
pub gains: [f32; STEPS],
|
||||
pub grit: [bool; STEPS],
|
||||
}
|
||||
impl Default for Pattern {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
gains: d.pattern,
|
||||
grit: d.grit_steps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct KnurlParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[persist = "knurl-pattern"]
|
||||
pub pattern: RwLock<Pattern>,
|
||||
#[id = "source"]
|
||||
pub source: EnumParam<SourceParam>,
|
||||
#[id = "seed"]
|
||||
pub seed: EnumParam<SeedParam>,
|
||||
#[id = "steps"]
|
||||
pub steps: IntParam,
|
||||
#[id = "edge"]
|
||||
pub edge: FloatParam,
|
||||
#[id = "level-jitter"]
|
||||
pub level_jitter: FloatParam,
|
||||
#[id = "time-jitter"]
|
||||
pub time_jitter: FloatParam,
|
||||
#[id = "grit"]
|
||||
pub grit: FloatParam,
|
||||
#[id = "tone"]
|
||||
pub tone: FloatParam,
|
||||
#[id = "position"]
|
||||
pub position: FloatParam,
|
||||
#[id = "length"]
|
||||
pub length: FloatParam,
|
||||
#[id = "gate"]
|
||||
pub gate: FloatParam,
|
||||
#[id = "mix"]
|
||||
pub mix: FloatParam,
|
||||
#[id = "output"]
|
||||
pub output: FloatParam,
|
||||
#[id = "snap"]
|
||||
pub snap: BoolParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for KnurlParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT),
|
||||
pattern: RwLock::new(Pattern::default()),
|
||||
source: EnumParam::new("Pitch source", SourceParam::Audio),
|
||||
seed: EnumParam::new("Random", SeedParam::Same),
|
||||
steps: IntParam::new(
|
||||
"Steps",
|
||||
d.steps as i32,
|
||||
IntRange::Linear {
|
||||
min: 1,
|
||||
max: STEPS as i32,
|
||||
},
|
||||
),
|
||||
edge: linear("Edge", d.edge, 5., 50., " %"),
|
||||
level_jitter: linear("Level jitter", d.level_jitter, 0., 6., " dB"),
|
||||
time_jitter: linear("Time jitter", d.time_jitter, 0., 10., " %"),
|
||||
grit: linear("Grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(|v| {
|
||||
if v <= GRIT_OFF {
|
||||
"Off".into()
|
||||
} else {
|
||||
format!("{v:.1}")
|
||||
}
|
||||
})),
|
||||
tone: FloatParam::new(
|
||||
"Tone",
|
||||
d.tone,
|
||||
FloatRange::Skewed {
|
||||
min: 4.,
|
||||
max: 64.,
|
||||
factor: FloatRange::skew_factor(-1.),
|
||||
},
|
||||
)
|
||||
.with_unit(" ×f0"),
|
||||
position: linear("Position", d.position, 0., 100., " %"),
|
||||
length: linear("Length", d.length, 2., 50., " %"),
|
||||
gate: linear("Gate", d.gate, -60., -20., " dB"),
|
||||
mix: linear("Mix", d.mix, 0., 100., " %"),
|
||||
output: linear("Output", d.output, -12., 12., " dB"),
|
||||
snap: BoolParam::new("Snap to semitone", d.snap),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl KnurlParams {
|
||||
/// Current settings. The pattern falls back to `last` when the editor holds the lock,
|
||||
/// so the audio thread never waits.
|
||||
pub fn settings(&self, last: &Pattern) -> Settings {
|
||||
let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last);
|
||||
Settings {
|
||||
source: match self.source.value() {
|
||||
SourceParam::Audio => Source::Audio,
|
||||
SourceParam::Midi => Source::Midi,
|
||||
},
|
||||
pattern: pattern.gains.map(|g| {
|
||||
if g.is_finite() {
|
||||
g.clamp(STEP_MIN, 0.)
|
||||
} else {
|
||||
0.
|
||||
}
|
||||
}),
|
||||
grit_steps: pattern.grit,
|
||||
steps: self.steps.value().clamp(1, STEPS as i32) as usize,
|
||||
edge: self.edge.value(),
|
||||
level_jitter: self.level_jitter.value(),
|
||||
time_jitter: self.time_jitter.value(),
|
||||
grit: self.grit.value(),
|
||||
tone: self.tone.value(),
|
||||
position: self.position.value(),
|
||||
length: self.length.value(),
|
||||
seed: match self.seed.value() {
|
||||
SeedParam::Same => Seed::Same,
|
||||
SeedParam::Free => Seed::Free,
|
||||
},
|
||||
gate: self.gate.value(),
|
||||
snap: self.snap.value(),
|
||||
mix: self.mix.value(),
|
||||
output: self.output.value(),
|
||||
bypass: self.bypass.value(),
|
||||
}
|
||||
}
|
||||
pub fn pattern(&self) -> Pattern {
|
||||
self.pattern.read().map(|p| *p).unwrap_or_default()
|
||||
}
|
||||
fn floats(&self) -> [&FloatParam; FLOATS] {
|
||||
[
|
||||
&self.edge,
|
||||
&self.level_jitter,
|
||||
&self.time_jitter,
|
||||
&self.grit,
|
||||
&self.tone,
|
||||
&self.position,
|
||||
&self.length,
|
||||
&self.gate,
|
||||
&self.mix,
|
||||
&self.output,
|
||||
]
|
||||
}
|
||||
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
|
||||
/// while the pointer is down; switches are set in a single gesture. The pattern is
|
||||
/// stored directly.
|
||||
pub fn apply(
|
||||
&self,
|
||||
setter: &ParamSetter,
|
||||
old: &Settings,
|
||||
new: &Settings,
|
||||
dragging: bool,
|
||||
open: &mut [bool; FLOATS],
|
||||
) {
|
||||
if new.pattern != old.pattern || new.grit_steps != old.grit_steps {
|
||||
if let Ok(mut p) = self.pattern.write() {
|
||||
*p = Pattern {
|
||||
gains: new.pattern,
|
||||
grit: new.grit_steps,
|
||||
};
|
||||
}
|
||||
}
|
||||
let values = [
|
||||
(old.edge, new.edge),
|
||||
(old.level_jitter, new.level_jitter),
|
||||
(old.time_jitter, new.time_jitter),
|
||||
(old.grit, new.grit),
|
||||
(old.tone, new.tone),
|
||||
(old.position, new.position),
|
||||
(old.length, new.length),
|
||||
(old.gate, new.gate),
|
||||
(old.mix, new.mix),
|
||||
(old.output, new.output),
|
||||
];
|
||||
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
|
||||
if a != b {
|
||||
if !open[i] {
|
||||
open[i] = true;
|
||||
setter.begin_set_parameter(p);
|
||||
}
|
||||
setter.set_parameter(p, b);
|
||||
}
|
||||
if !dragging && open[i] {
|
||||
setter.end_set_parameter(p);
|
||||
open[i] = false;
|
||||
}
|
||||
}
|
||||
fn switch<P: Param>(setter: &ParamSetter, p: &P, v: P::Plain) {
|
||||
setter.begin_set_parameter(p);
|
||||
setter.set_parameter(p, v);
|
||||
setter.end_set_parameter(p);
|
||||
}
|
||||
if new.steps != old.steps {
|
||||
switch(setter, &self.steps, new.steps as i32);
|
||||
}
|
||||
if new.snap != old.snap {
|
||||
switch(setter, &self.snap, new.snap);
|
||||
}
|
||||
if new.bypass != old.bypass {
|
||||
switch(setter, &self.bypass, new.bypass);
|
||||
}
|
||||
if new.source != old.source {
|
||||
let v = match new.source {
|
||||
Source::Audio => SourceParam::Audio,
|
||||
Source::Midi => SourceParam::Midi,
|
||||
};
|
||||
switch(setter, &self.source, v);
|
||||
}
|
||||
if new.seed != old.seed {
|
||||
let v = match new.seed {
|
||||
Seed::Same => SeedParam::Same,
|
||||
Seed::Free => SeedParam::Free,
|
||||
};
|
||||
switch(setter, &self.seed, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user