refactor: run Lathe, Temper and Knurl as tracked modules

Add a Tracked trait and a Lookahead harness to common-dsp. Each engine is
now its tracker plus a module that follows note events aligned with its
input, so the same module can run behind a shared tracker.

Lathe and Temper output is bit-exact against the old engines. Knurl's
interpolation now reads one newer sample inside the module, so its
latency is one sample longer; the output matches within float rounding.

Each editor exposes body() and toggles() besides show(), so another
editor can draw a module's page.
This commit is contained in:
nvrl committed 2026-11-06 16:30:22 +01:00
1 parent 4b1a89626e
commit 340376eee2
8 files changed
+418 -163

No files matched your search

+105 -38
View File
@@ -8,7 +8,7 @@
//! 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::tracker::{Lookahead, NoteEvent, 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};
@@ -95,15 +95,15 @@ struct Pitch {
sounding: bool,
}
pub struct Engine {
/// 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,
/// Tracker lookahead.
lookahead: usize,
sounding: bool,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
@@ -137,7 +137,7 @@ pub struct Engine {
meter_count: usize,
}
impl Engine {
impl Module {
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");
@@ -145,22 +145,19 @@ impl Engine {
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,
sounding: false,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_line: vec![Pitch::default(); headroom + 2],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
// Reads reach `2·headroom + 1` back, plus the interpolation taps.
audio: vec![[0.; 2]; 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
@@ -189,9 +186,6 @@ impl Engine {
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
@@ -200,7 +194,6 @@ impl Engine {
}
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;
@@ -213,7 +206,7 @@ impl Engine {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.sounding = false;
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
@@ -226,12 +219,6 @@ impl Engine {
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] {
@@ -252,21 +239,15 @@ impl Engine {
}
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));
fn run(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
let (hz, jump) = self.pitch.step(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
// 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.tracker.sounding(),
sounding,
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
@@ -274,7 +255,7 @@ impl Engine {
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 centre = (self.headroom + 1) as f32;
let dry = self.read(centre);
// Cycle clock.
@@ -380,7 +361,8 @@ impl Engine {
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
/// 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;
@@ -392,10 +374,10 @@ impl Engine {
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
latency: 0,
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
if self.sounding && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
@@ -404,3 +386,88 @@ impl Engine {
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], sounding: bool) -> [f32; 2] {
self.run(x, sounding)
}
}
/// 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<Self, &'static str> {
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.source == Source::Midi, 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()
}
}
}