//! 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::{Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, 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, /// 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, } /// The cycle texture without its tracker: driven by note events aligned with its input. Its /// latency is the timing-jitter headroom and one sample for the interpolation. pub struct Module { rate: f32, channels: usize, settings: Settings, sounding: bool, /// Largest timing offset in samples, either way. headroom: usize, pitch: PitchFollower, pitch_line: Vec, 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 Module { pub fn new(rate: f32, channels: usize) -> Result { 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 headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize; let settings = Settings::default(); let mut e = Self { rate, channels, settings, sounding: false, headroom, pitch: PitchFollower::default(), pitch_line: vec![Pitch::default(); headroom + 2], pitch_at: 0, // Reads reach `2·headroom + 1` back, plus the interpolation taps. audio: vec![[0.; 2]; 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 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.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.sounding = false; 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; } /// 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 } fn run(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] { self.sounding = sounding; let (hz, jump) = self.pitch.step(1.); // Note events are aligned with the input; the audio leaves `headroom + 1` later. let len = self.pitch_line.len(); self.pitch_line[self.pitch_at] = Pitch { hz, start: jump, 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.headroom + 1) 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. `latency` is left at 0 for /// the owner to fill in. 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: 0, sample_rate: self.rate, }; if self.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 } } impl Tracked for Module { fn latency(&self) -> usize { self.headroom + 1 } fn event(&mut self, e: NoteEvent) { // Every note restarts the clock, legato or not. let e = match e { NoteEvent::Start { pitch, .. } => NoteEvent::Start { pitch, glide: false, }, e => e, }; self.pitch.event(self.settings.snap, e); } fn process(&mut self, x: [f32; 2], note: NoteState) -> [f32; 2] { // The texture needs a trusted cycle clock: it fades out while tracking is unsure, as // it does between notes. self.run(x, note.sure) } } /// Standalone Knurl: its own tracker in front of the [`Module`]. pub struct Engine { channels: usize, lookahead: Lookahead, module: Module, } impl Engine { pub fn new(rate: f32, channels: usize) -> Result { let module = Module::new(rate, channels)?; let mut e = Self { channels, lookahead: Lookahead::new(rate, TrackerConfig::BASS), module, }; // The module already holds the defaults; only the tracker still needs them. e.set_tracker(&Settings::default()); Ok(e) } /// Lookahead, jitter headroom and one interpolation sample. pub fn latency_samples(&self) -> u32 { (self.lookahead.latency() + self.module.latency()) as u32 } pub fn channels(&self) -> usize { self.channels } pub fn settings(&self) -> Settings { self.module.settings() } pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> { self.module.set_settings(s)?; self.set_tracker(&s); Ok(()) } fn set_tracker(&mut self, s: &Settings) { self.lookahead .tracker .set(s.pitch.source(s.source.mode(), s.gate, false)); } pub fn reset(&mut self) { self.lookahead.reset(); self.module.reset(); } pub fn note_on(&mut self, note: u8) { self.lookahead.tracker.note_on(note); } pub fn note_off(&mut self, note: u8) { self.lookahead.tracker.note_off(note); } 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] }; self.lookahead.process(x, mono, &mut self.module) } pub fn take_analyzer(&mut self) -> AnalyzerFrame { AnalyzerFrame { latency: self.latency_samples(), ..self.module.take_analyzer() } } }