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

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:
nvrl committed 2026-11-06 08:49:11 +01:00
1 parent f4c0a833e6
commit 5cc3770737
27 files changed
+4699 -33

No files matched your search

+16
View File
@@ -114,5 +114,21 @@
"weld-core",
"weld-ui"
]
},
"knurl": {
"name": "Knurl",
"description": "Give every cycle of the note its own level, timing and grit",
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.storyboard.cycle-texture",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"knurl-core",
"knurl-ui"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "knurl-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+406
View File
@@ -0,0 +1,406 @@
//! Knurl: texture per cycle of the played note. A cycle clock follows the tracked pitch and
//! restarts at every note; each cycle gets its own step gain (a repeating pattern that
//! makes subharmonics), a random level and timing, and a noise burst tuned to the note.
//! Every amount is counted in cycles, so the texture has the same grain on every note.
//!
//! The audio path is delayed by the tracker lookahead plus the timing-jitter headroom, so
//! the clock already knows each note when its attack leaves the plugin. Allocation-free
//! after `new`; mono-safe (both channels get the same treatment).
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
/// Cycles kept for the editor's scope, and points per cycle.
pub const SCOPE_CYCLES: usize = 8;
pub const SCOPE_POINTS: usize = 64;
/// Timing jitter can move a cycle by at most this share of the lowest period.
const JITTER_HEADROOM: f32 = 0.125;
/// Effects fade in and out with the note over this time.
const FADE_MS: f32 = 20.;
/// The grit filter is retuned this often, in samples.
const RETUNE: usize = 16;
/// Level follower for scaling the grit to the input.
const LEVEL_MS: f32 = 30.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the clock follows, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Pattern step of the newest cycle.
pub step: usize,
/// Output of the last `SCOPE_CYCLES` cycles, oldest first, each resampled to
/// `SCOPE_POINTS` points.
pub scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
/// True when the scope holds cycles of a sounding note.
pub scope_valid: bool,
pub input_db: f32,
pub output_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
step: 0,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
input_db: -120.,
output_db: -120.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Deterministic per-cycle randomness: a hash of (stream, note seed, cycle).
fn hash(stream: u64, seed: u64, cycle: u64) -> u64 {
let mut z = stream
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(seed.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add(cycle.wrapping_mul(0x94D0_49BB_1331_11EB));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn uniform(stream: u64, seed: u64, cycle: u64) -> f32 {
(hash(stream, seed, cycle) >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately normal, clamped to ±3 (sum of four uniforms, unit variance).
fn normal(stream: u64, seed: u64, cycle: u64) -> f32 {
let s: f32 = (0..4).map(|i| uniform(stream, seed, cycle * 4 + i)).sum();
((s - 2.) * 3f32.sqrt()).clamp(-3., 3.)
}
/// Raised-cosine weight of the next cycle's value: 0 until `1 − width` of the cycle, then
/// rising to 1 at the boundary.
fn edge(frac: f32, width: f32) -> f32 {
let t = ((frac - (1. - width)) / width).clamp(0., 1.);
0.5 - 0.5 * (std::f32::consts::PI * t).cos()
}
const LEVEL: u64 = 1;
const TIME: u64 = 2;
const NOISE: u64 = 3;
/// What the clock needs, delayed by the jitter headroom so it lines up with the audio.
#[derive(Clone, Copy, Default)]
struct Pitch {
hz: f32,
start: bool,
sounding: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
/// Tracker lookahead.
lookahead: usize,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
// Cycle clock.
phase: f64,
cycle: u64,
note_seed: u64,
notes: u64,
noise_at: u64,
amount: f32,
fade: f32,
// Grit.
grit_coef: SvfCoef,
grit_filter: Svf,
countdown: usize,
level: f32,
level_coef: f32,
// Smoothed controls.
smooth: f32,
mix: f32,
output: f32,
bypass: f32,
fresh: bool,
// Editor.
scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: bool,
meter_in: f64,
meter_out: f64,
meter_count: usize,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize;
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
lookahead,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
note_seed: 0,
notes: 0,
noise_at: 0,
amount: 0.,
fade: coefficient(rate, FADE_MS),
grit_coef: SvfCoef::IDENTITY,
grit_filter: Svf::default(),
countdown: 0,
level: 0.,
level_coef: coefficient(rate, LEVEL_MS),
smooth: coefficient(rate, 10.),
mix: 1.,
output: 1.,
bypass: 0.,
fresh: true,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
meter_in: 0.,
meter_out: 0.,
meter_count: 0,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
self.notes = 0;
self.note_seed = 0;
self.noise_at = 0;
self.amount = 0.;
self.level = 0.;
self.countdown = 0;
self.scope_valid = false;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32) -> [f32; 2] {
let len = self.audio.len();
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back % len) % len];
// y0 is one sample newer than y1; y2 and y3 are older.
let (newer, p1, p2, p3) = (at(base.max(1) - 1), at(base), at(base + 1), at(base + 2));
let mut out = [0.; 2];
for c in 0..2 {
let (y0, y1, y2, y3) = (newer[c], p1[c], p2[c], p3[c]);
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
out[c] = ((c3 * t + c2) * t + c1) * t + y1;
}
out
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
let len = self.pitch_line.len();
self.pitch_line[self.pitch_at] = Pitch {
hz,
start: jump,
sounding: self.tracker.sounding(),
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let centre = (self.lookahead + self.headroom) as f32;
let dry = self.read(centre);
// Cycle clock.
if p.start {
self.phase = 0.;
self.cycle = 0;
self.notes += 1;
self.noise_at = 0;
self.note_seed = match self.settings.seed {
Seed::Same => 0,
Seed::Free => self.notes,
};
self.countdown = 0;
}
let active = p.sounding && p.hz > 0.;
self.amount += (f32::from(active) - self.amount) * self.fade;
let s = self.settings;
let frac = self.phase.fract() as f32;
let c = self.cycle;
let seed = self.note_seed;
let step = |k: u64| (k % s.steps as u64) as usize;
// Pattern and level jitter, crossfaded into the next cycle.
let w = edge(frac, s.edge * 0.01);
let jitter_w = edge(frac, 0.5);
let pattern = |k: u64| db_to_gain(s.pattern[step(k)]);
let wobble = |k: u64| db_to_gain(s.level_jitter * normal(LEVEL, seed, k));
let gain = (pattern(c) * (1. - w) + pattern(c + 1) * w)
* (wobble(c) * (1. - jitter_w) + wobble(c + 1) * jitter_w);
let gain = 1. + self.amount * (gain - 1.);
// Timing jitter: an offset per cycle, blended across the whole cycle.
let period = if p.hz > 0. { self.rate / p.hz } else { 0. };
let shift = |k: u64| normal(TIME, seed, k) * s.time_jitter * 0.01 * period;
let tw = edge(frac, 1.);
let offset = (shift(c) * (1. - tw) + shift(c + 1) * tw) * self.amount;
let offset = offset.clamp(-(self.headroom as f32), self.headroom as f32);
let moved = if offset == 0. {
dry
} else {
self.read(centre + offset)
};
// Grit: noise above `tone` harmonics, in a sin² window at `position` of each cycle.
let mut grit = 0.;
if s.grit > GRIT_OFF && p.hz > 0. && s.grit_steps[step(c)] {
if self.countdown == 0 {
let f = (s.tone * p.hz).min(0.45 * self.rate);
self.grit_coef = SvfCoef::highpass(self.rate, f, std::f32::consts::FRAC_1_SQRT_2);
self.countdown = RETUNE;
}
self.countdown -= 1;
self.noise_at = self.noise_at.wrapping_add(1);
let n = 2. * uniform(NOISE, seed, self.noise_at) - 1.;
let hp = self.grit_filter.process(&self.grit_coef, n * 3f32.sqrt());
let d = (frac - s.position * 0.01).rem_euclid(1.);
let l = s.length * 0.01;
if d < l {
let window = (std::f32::consts::PI * d / l).sin().powi(2);
grit = hp * window * self.level.sqrt() * db_to_gain(s.grit) * self.amount;
}
}
let m = if self.channels == 2 {
0.5 * (dry[0] + dry[1])
} else {
dry[0]
};
self.level += (m * m - self.level) * self.level_coef;
// Advance the clock.
if p.hz > 0. {
self.phase += (p.hz / self.rate) as f64;
let whole = self.phase.floor() as u64;
if whole > 0 {
self.phase -= whole as f64;
self.cycle += whole;
self.scope
.rotate_left(whole.min(SCOPE_CYCLES as u64) as usize);
self.scope[SCOPE_CYCLES - 1] = [0.; SCOPE_POINTS];
}
}
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for ch in 0..self.channels {
let wet = moved[ch] * gain + grit;
let mixed = (self.mix * wet + (1. - self.mix) * dry[ch]) * self.output;
out[ch] = mixed * (1. - self.bypass) + dry[ch] * self.bypass;
}
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
if active {
let i = ((frac * SCOPE_POINTS as f32) as usize).min(SCOPE_POINTS - 1);
self.scope[SCOPE_CYCLES - 1][i] = o;
self.scope_valid = true;
}
self.meter_in += (m * m) as f64;
self.meter_out += (o * o) as f64;
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.meter_count.max(1) as f64;
let db = |p: f64| (10. * (p / n).max(1e-12).log10()).max(-120.) as f32;
let mut f = AnalyzerFrame {
hz: self.pitch.hz(),
note: None,
step: (self.cycle % self.settings.steps as u64) as usize,
scope: self.scope,
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
self.meter_out = 0.;
self.meter_count = 0;
f
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Host-independent controls. Gains in dB; amounts relative to one cycle in percent.
use serde::{Deserialize, Serialize};
/// Pattern steps: one per cycle, repeating.
pub const STEPS: usize = 16;
pub const STEP_MIN: f32 = -24.;
/// Grit at or below this level is off.
pub const GRIT_OFF: f32 = -60.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Seed {
/// Every note gets the same random sequence, so identical notes sound identical.
#[default]
Same,
/// The random sequences keep running from note to note.
Free,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain per pattern step, 0 down to `STEP_MIN`.
pub pattern: [f32; STEPS],
/// Steps that get a grit burst.
pub grit_steps: [bool; STEPS],
/// Pattern length in cycles, 1..=STEPS.
pub steps: usize,
/// Crossfade into the next step, as a share of a cycle.
pub edge: f32,
/// Random level per cycle, standard deviation in dB.
pub level_jitter: f32,
/// Random timing per cycle, standard deviation in percent of a period.
pub time_jitter: f32,
/// Grit burst level relative to the input, dB; `GRIT_OFF` or lower is off.
pub grit: f32,
/// Grit highpass, in harmonics of the note.
pub tone: f32,
/// Where in the cycle the grit burst sits, percent.
pub position: f32,
/// Grit burst length, percent of a cycle.
pub length: f32,
pub seed: Seed,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
/// Named step patterns: (name, help, gains). Missing steps are 0 dB.
pub const PATTERNS: [(&str, &str, &[f32]); 6] = [
("Off", "Every cycle at full level.", &[0.]),
(
"Double",
"Every second cycle quieter: a sub-octave growl.",
&[0., -9.],
),
(
"Triple",
"One loud cycle, two quiet: a growl an octave and a fifth down.",
&[0., -12., -12.],
),
(
"Fry",
"Uneven four-cycle pattern: vocal-fry rattle.",
&[0., -14., -5., -14.],
),
(
"Gallop",
"Loud, quiet, loud, then three quiet: a rolling gallop.",
&[0., -18., 0., -18., -18., -18.],
),
(
"Stutter",
"Bursts of cycles with gaps: a gated stutter.",
&[0., 0., -24., -24., 0., -24., -24., -24.],
),
];
impl Settings {
/// Applies a named pattern: its gains, its length, grit on every step.
pub fn apply_pattern(&mut self, gains: &[f32]) {
self.pattern = [0.; STEPS];
for (p, g) in self.pattern.iter_mut().zip(gains) {
*p = *g;
}
self.steps = gains.len().clamp(1, STEPS);
}
}
impl Default for Settings {
fn default() -> Self {
let mut s = Self {
source: Source::Audio,
pattern: [0.; STEPS],
grit_steps: [true; STEPS],
steps: 1,
edge: 15.,
level_jitter: 1.,
time_jitter: 3.,
grit: -9.,
tone: 12.,
position: 10.,
length: 12.,
seed: Seed::Same,
gate: -42.,
snap: true,
mix: 100.,
output: 0.,
bypass: false,
};
s.apply_pattern(PATTERNS[3].2);
s
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.edge,
self.level_jitter,
self.time_jitter,
self.grit,
self.tone,
self.position,
self.length,
self.gate,
self.mix,
self.output,
]
.iter()
.chain(self.pattern.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = self.pattern.iter().all(|g| (STEP_MIN..=0.).contains(g))
&& (1..=STEPS).contains(&self.steps)
&& (5.0..=50.).contains(&self.edge)
&& (0.0..=6.).contains(&self.level_jitter)
&& (0.0..=10.).contains(&self.time_jitter)
&& (GRIT_OFF..=0.).contains(&self.grit)
&& (4.0..=64.).contains(&self.tone)
&& (0.0..=100.).contains(&self.position)
&& (2.0..=50.).contains(&self.length)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+232
View File
@@ -0,0 +1,232 @@
use common_dsp::{midi_to_hz, Saw};
use knurl_core::{Engine, Seed, Settings, Source, GRIT_OFF, PATTERNS};
const RATE: f32 = 48000.;
/// A tanh saw bass at `note`, as the prototype used.
fn bass(note: u8, n: usize) -> Vec<f32> {
let mut saw = Saw::new(RATE, midi_to_hz(note as f32));
(0..n).map(|_| (2.2 * saw.sample()).tanh() * 0.5).collect()
}
fn plain() -> Settings {
let mut s = Settings {
source: Source::Midi,
level_jitter: 0.,
time_jitter: 0.,
grit: GRIT_OFF,
..Settings::default()
};
s.apply_pattern(PATTERNS[0].2);
s
}
/// Runs one held note from sample 0 and returns the output, aligned to the input.
fn run(settings: Settings, note: u8, input: &[f32]) -> Vec<f32> {
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(settings).unwrap();
let latency = engine.latency_samples() as usize;
engine.note_on(note);
let mut out: Vec<f32> = input
.iter()
.chain(std::iter::repeat_n(&0., latency))
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
out
}
/// Level of the component at `hz` (single-bin DFT with a Hann window), dB.
fn level(x: &[f32], hz: f32) -> f64 {
let n = x.len();
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / n as f64).cos();
let a = std::f64::consts::TAU * hz as f64 * i as f64 / RATE as f64;
re += w * v as f64 * a.cos();
im -= w * v as f64 * a.sin();
}
10. * (re * re + im * im).max(1e-30).log10()
}
fn subharmonic(out: &[f32], note: u8, divisor: f32) -> f64 {
let f = midi_to_hz(note as f32);
let seg = &out[4800..out.len() - 4800];
level(seg, f / divisor) - level(seg, f)
}
fn max_step(x: &[f32]) -> f32 {
x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0., f32::max)
}
#[test]
fn everything_off_is_the_delayed_input() {
let input = bass(29, 24000);
let out = run(plain(), 29, &input);
assert_eq!(out, input);
}
#[test]
fn patterns_make_tuned_subharmonics_without_clicks() {
let input = bass(29, 48000);
let base = run(plain(), 29, &input);
assert!(subharmonic(&base, 29, 2.) < -50.);
for (pattern, divisor, expected) in [(1, 2., -8.2), (2, 3., -10.7)] {
let mut s = plain();
s.apply_pattern(PATTERNS[pattern].2);
let out = run(s, 29, &input);
let sub = subharmonic(&out, 29, divisor);
assert!(
(sub - expected).abs() < 0.5,
"{}: {sub}",
PATTERNS[pattern].0
);
assert!(
max_step(&out) <= 1.05 * max_step(&input),
"{}",
PATTERNS[pattern].0
);
}
}
#[test]
fn the_texture_is_the_same_on_every_note() {
let mut s = plain();
s.apply_pattern(PATTERNS[1].2);
let low = run(s, 29, &bass(29, 48000));
let high = run(s, 36, &bass(36, 48000));
let (a, b) = (subharmonic(&low, 29, 2.), subharmonic(&high, 36, 2.));
assert!((a - b).abs() < 0.3, "F1 {a} C2 {b}");
}
#[test]
fn same_seed_repeats_every_note_and_free_does_not() {
for (seed, identical) in [(Seed::Same, true), (Seed::Free, false)] {
let s = Settings {
source: Source::Midi,
seed,
..Settings::default()
};
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
let note = bass(29, 12000);
let mut out = Vec::new();
let starts = [0, 36000];
for i in 0..72000 + latency {
if starts.contains(&i) {
engine.note_on(29);
}
if i == starts[0] + 12000 || i == starts[1] + 12000 {
engine.note_off(29);
}
let k = starts
.iter()
.filter(|&&s| i >= s)
.map(|&s| i - s)
.min()
.unwrap();
let x = note.get(k).copied().unwrap_or(0.);
out.push(engine.process_sample([x, x])[0]);
}
let first = &out[latency..latency + 12000];
let second = &out[starts[1] + latency..starts[1] + latency + 12000];
let largest = first
.iter()
.zip(second)
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
if identical {
assert!(largest < 1e-4, "{seed:?}: {largest}");
} else {
assert!(largest > 1e-2, "{seed:?}: {largest}");
}
assert!(out.iter().all(|v| v.is_finite()));
}
}
#[test]
fn grit_follows_the_input_level_and_stops_with_the_note() {
let mut s = plain();
s.grit = -6.;
let input = bass(29, 24000);
let out = run(s, 29, &input);
let noise: f64 = out
.iter()
.zip(&input)
.skip(4800)
.map(|(o, i)| ((o - i) as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let signal: f64 = input
.iter()
.skip(4800)
.map(|v| (*v as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let ratio = 10. * (noise / signal).log10();
// −6 dB bursts over 12 % of each cycle; the sin² window keeps 3/8 of the power:
// −6 + 10·log10(0.12 · 0.375) = −19.5 dB.
assert!((ratio + 19.5).abs() < 1.5, "{ratio}");
// Silence in, silence out: no grit without a note, and no NaN.
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for _ in 0..48000 {
let out = engine.process_sample([0., 0.]);
assert_eq!(out, [0., 0.]);
}
}
#[test]
fn audio_source_finds_the_note_and_reports_latency() {
let mut s = plain();
s.source = Source::Audio;
s.apply_pattern(PATTERNS[1].2);
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
// About 67 ms of lookahead plus 4.2 ms of jitter headroom.
assert!((latency as f32 / RATE - 0.071).abs() < 0.005, "{latency}");
let input = bass(29, 48000);
let mut out: Vec<f32> = input
.iter()
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
let frame = engine.take_analyzer();
assert_eq!(frame.note, Some(29));
assert!(frame.scope_valid);
let sub = subharmonic(&out[..out.len() - 4800], 29, 2.);
assert!(sub > -14., "{sub}");
}
#[test]
fn invalid_settings_are_rejected() {
let mut engine = Engine::new(RATE, 2).unwrap();
for bad in [
Settings {
steps: 0,
..Settings::default()
},
Settings {
edge: 60.,
..Settings::default()
},
Settings {
grit: f32::NAN,
..Settings::default()
},
Settings {
time_jitter: 11.,
..Settings::default()
},
] {
assert!(engine.set_settings(bad).is_err());
}
let mut s = Settings::default();
s.pattern[3] = -30.;
assert!(engine.set_settings(s).is_err());
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+70
View File
@@ -0,0 +1,70 @@
use knurl_core::{Engine, Seed, Settings, Source, PATTERNS};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn notes_patterns_grit_and_jitter_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.apply_pattern(PATTERNS[(i / 4800) % PATTERNS.len()].2);
settings.seed = if i > 48000 { Seed::Free } else { Seed::Same };
settings.source = if (i / 4800) % 3 == 0 {
Source::Midi
} else {
Source::Audio
};
settings.time_jitter = ((i / 4800) % 10) as f32;
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2382
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "knurl"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
knurl-core = { path = "../core" }
knurl-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+195
View File
@@ -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(&current);
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, &params.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);
+255
View File
@@ -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);
}
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &knurl::clap_entry;
assert!(entry.init.unwrap()(c"knurl.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.cycle-texture"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("edge".into(), ParamValue::F32(30.));
envelope.params.insert("steps".into(), ParamValue::I32(3));
envelope
.params
.insert("seed".into(), ParamValue::String("free".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
// Pitch from MIDI and no note arrives, so the audio check below sees the dry input
// even with a pattern set.
envelope
.params
.insert("level-jitter".into(), ParamValue::F32(0.));
envelope
.params
.insert("time-jitter".into(), ParamValue::F32(0.));
envelope.params.insert("grit".into(), ParamValue::F32(-60.));
let (gains, grit) = ([-6f32; 16], [true; 16]);
let mut pattern = serde_json::json!({ "gains": gains, "grit": grit });
pattern["grit"][5] = false.into();
envelope
.fields
.insert("knurl-pattern".into(), pattern.to_string());
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: serde_json::Value =
serde_json::from_str(&restored.fields["knurl-pattern"]).unwrap();
assert_eq!(saved, pattern);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead plus 4.2 ms of timing-jitter headroom.
assert!((delay as f64 / rate - 0.071).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// With no MIDI note and every effect off, a tone passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "knurl-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
knurl-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+501
View File
@@ -0,0 +1,501 @@
//! Knurl editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use knurl_core::{
AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS,
STEP_MIN,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
const TOP: f32 = 316.;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Every cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
self.analyzer.input_db = frame.input_db.max(old.input_db - 3.);
self.analyzer.output_db = frame.output_db.max(old.output_db - 3.);
// Keep the last scope between notes.
if !frame.scope_valid && old.scope_valid {
self.analyzer.scope = old.scope;
self.analyzer.scope_valid = true;
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 58., ACCENT);
hint(&r, state, help);
});
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "KNURL", "CYCLE TEXTURE", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
let frame = state.analyzer;
scope(ui, &frame, 140.);
ui.add_space(4.);
pattern_editor(ui, settings, state, TOP - 144.);
});
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).size(13.).color(MUTED));
if frame.hz > 0. {
ui.label(
RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz))
.size(12.)
.color(DIM),
);
}
ui.add_space(10.);
meter(ui, "IN", frame.input_db, STEEL, 110.);
meter(ui, "OUT", frame.output_db, ACCENT, 110.);
ui.add_space(10.);
caption(ui, "RANDOM");
ui.horizontal(|ui| {
for (seed, label, help) in [
(
Seed::Same,
"SAME EVERY NOTE",
"Every note gets the same random sequence: identical notes sound identical.",
),
(
Seed::Free,
"FREE",
"The random sequences keep running, so every note differs.",
),
] {
let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.));
if r.clicked() {
settings.seed = seed;
}
hint(&r, state, help);
}
});
ui.add_space(6.);
let r = ui.checkbox(
&mut settings.snap,
RichText::new("Snap to semitone").size(12.),
);
hint(
&r,
state,
"Round the detected pitch. Turn it off for slides.",
);
}
/// The last cycles of the output, overlaid; the newest is brightest.
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
let (outer, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"),
FontId::proportional(10.),
MUTED,
);
let plot = Rect::from_min_max(
outer.left_top() + Vec2::new(10., 20.),
outer.right_bottom() - Vec2::new(10., 8.),
);
for t in [0.25f32, 0.5, 0.75] {
let x = plot.left() + plot.width() * t;
p.line_segment(
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
Stroke::new(1.0_f32, GRID),
);
}
p.line_segment(
[
Pos2::new(plot.left(), plot.center().y),
Pos2::new(plot.right(), plot.center().y),
],
Stroke::new(1.0_f32, EDGE),
);
if !frame.scope_valid {
p.text(
plot.center(),
egui::Align2::CENTER_CENTER,
"play a note",
FontId::proportional(12.),
DIM,
);
return;
}
let peak = frame
.scope
.iter()
.flatten()
.fold(1e-6f32, |m, v| m.max(v.abs()));
// The newest cycle is still being written; draw the complete ones.
for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() {
let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32;
let color = if age == SCOPE_CYCLES - 2 {
ACCENT
} else {
STEEL.gamma_multiply(alpha * 0.6)
};
let points: Vec<Pos2> = cycle
.iter()
.enumerate()
.map(|(i, v)| {
Pos2::new(
plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32,
plot.center().y - plot.height() * 0.48 * v / peak,
)
})
.collect();
p.add(egui::Shape::line(
points,
Stroke::new(
if age == SCOPE_CYCLES - 2 {
2.0_f32
} else {
1.0
},
color,
),
));
}
}
fn step_y(rect: Rect, db: f32) -> f32 {
rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.)
}
/// One bar per step: drag to set its level, double-click to reset; the square below a bar
/// switches its grit burst.
fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
"PATTERN: LEVEL PER CYCLE",
FontId::proportional(10.),
MUTED,
);
let bars = Rect::from_min_max(
outer.left_top() + Vec2::new(34., 22.),
Pos2::new(outer.right() - 10., outer.bottom() - 26.),
);
let toggles_y = outer.bottom() - 16.;
let col = bars.width() / STEPS as f32;
let column =
|x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize;
if response.hovered() {
state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into();
}
if let Some(pos) = response.interact_pointer_pos() {
let k = column(pos.x);
if pos.y > bars.bottom() + 2. {
if response.clicked() {
settings.grit_steps[k] = !settings.grit_steps[k];
}
} else if response.double_clicked() {
settings.pattern[k] = 0.;
} else if response.dragged() || response.clicked() {
let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.);
let db = t * STEP_MIN;
settings.pattern[k] = if db > -0.5 { 0. } else { db.round() };
if k >= settings.steps {
settings.steps = k + 1;
}
}
}
for db in [0f32, -6., -12., -18., -24.] {
let y = step_y(bars, db);
p.line_segment(
[Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 6., y),
egui::Align2::LEFT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:.0}")
},
FontId::proportional(10.),
DIM,
);
}
let current = state.analyzer.step;
let playing = state.analyzer.note.is_some();
for k in 0..STEPS {
let on = k < settings.steps;
let x0 = bars.left() + k as f32 * col + 3.;
let x1 = x0 + col - 6.;
let top = step_y(bars, settings.pattern[k]);
let color = if !on {
DIM.gamma_multiply(0.5)
} else if playing && k == current {
TEXT
} else {
ACCENT
};
p.rect_filled(
Rect::from_min_max(
Pos2::new(x0, top.min(bars.bottom() - 2.)),
Pos2::new(x1, bars.bottom()),
),
1.,
color.gamma_multiply(if on { 0.85 } else { 0.4 }),
);
let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF;
let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.));
if settings.grit_steps[k] {
p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM });
} else {
p.rect_stroke(
square,
1.,
Stroke::new(1.0_f32, DIM),
egui::StrokeKind::Inside,
);
}
}
p.text(
Pos2::new(outer.left() + 6., toggles_y),
egui::Align2::LEFT_CENTER,
"grit",
FontId::proportional(10.),
DIM,
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "PATTERN");
for (name, help, gains) in PATTERNS {
let selected = settings.steps == gains.len()
&& settings.pattern[..gains.len()]
.iter()
.zip(gains)
.all(|(a, b)| a == b);
let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.));
if r.clicked() {
settings.apply_pattern(gains);
}
hint(&r, state, help);
}
ui.add_space(24.);
caption(ui, "STEPS");
let minus = ui.button(RichText::new("−").size(14.));
if minus.clicked() && settings.steps > 1 {
settings.steps -= 1;
}
ui.label(
RichText::new(format!("{:>2}", settings.steps))
.monospace()
.size(14.)
.color(TEXT),
);
let plus = ui.button(RichText::new("+").size(14.));
if plus.clicked() && settings.steps < STEPS {
settings.steps += 1;
}
let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down.";
hint(&minus, state, help);
hint(&plus, state, help);
});
ui.add_space(4.);
ui.columns(10, |c| {
knob(
&mut c[0],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[1],
state,
"Level",
&mut settings.level_jitter,
0. ..=6.,
d.level_jitter,
" dB",
"Random level per cycle: a rough, living surface.",
);
knob(
&mut c[2],
state,
"Time",
&mut settings.time_jitter,
0. ..=10.,
d.time_jitter,
" %",
"Random timing per cycle, in percent of a period: rattle and scrape.",
);
knob(
&mut c[3],
state,
"Grit",
&mut settings.grit,
GRIT_OFF..=0.,
d.grit,
" dB",
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
);
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
knob(
&mut c[8],
state,
"Mix",
&mut settings.mix,
0. ..=100.,
d.mix,
" %",
"Blend with the delayed dry signal.",
);
knob(
&mut c[9],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}