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

+97 -49
View File
@@ -7,7 +7,7 @@ mod settings;
use common_dsp::meter::{HarmonicMeter, HARMONICS};
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, PitchFollower, Tracker, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi};
pub use settings::{Curve, Settings, Source};
@@ -127,15 +127,14 @@ impl Smoothed {
}
}
pub struct Engine {
/// The tracked distortion without its tracker: driven by note events aligned with its input.
/// Its latency is the oversampling filters'.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
dry_at: usize,
tracker: Tracker,
sounding: bool,
pitch: PitchFollower,
hz_coef: f32,
countdown: usize,
@@ -162,7 +161,7 @@ pub struct Engine {
fresh: bool,
}
impl Engine {
impl Module {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
@@ -170,19 +169,14 @@ impl Engine {
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let smooth = coefficient(rate, 10.);
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency: lookahead + Oversampler::latency(),
delay: vec![[0.; 2]; lookahead + 1],
delay_at: 0,
dry_at: 0,
tracker,
sounding: false,
pitch: PitchFollower::default(),
hz_coef: coefficient(rate, 5.),
countdown: 0,
@@ -215,10 +209,6 @@ impl Engine {
e.fresh = true;
Ok(e)
}
/// Lookahead plus the oversampling filters.
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
@@ -227,7 +217,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, true);
if std::mem::take(&mut self.fresh) {
self.drive.value = db_to_gain(s.drive);
self.bias.value = s.bias * 0.008;
@@ -239,8 +228,7 @@ impl Engine {
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.sounding = false;
for c in &mut self.channel {
c.reset();
}
@@ -253,12 +241,6 @@ impl Engine {
self.countdown = 0;
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);
}
fn retune(&mut self, hz: f32) {
let s = &self.settings;
let limit = 0.45 * self.rate;
@@ -280,26 +262,7 @@ impl Engine {
SvfCoef::IDENTITY
};
}
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]
};
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
let (pitch, level) = (&mut self.pitch, &mut self.level);
self.tracker.process(mono, |e| {
pitch.event(false, e);
level.event(e);
});
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
fn run(&mut self, y: [f32; 2]) -> [f32; 2] {
let (hz, jump) = self.pitch.step(self.hz_coef);
if jump || self.countdown == 0 {
self.retune(hz);
@@ -371,7 +334,7 @@ impl Engine {
let v = (mix * wet[c] * auto + (1. - mix) * dry[c]) * output;
out[c] = v * (1. - bypass) + dry[c] * bypass;
}
let tone = if self.tracker.sounding() { hz } else { 0. };
let tone = if self.sounding { hz } else { 0. };
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
@@ -381,11 +344,11 @@ impl Engine {
self.meter_out.push(o, tone);
out
}
/// Editor data. `latency` is left at 0 for the owner to fill in.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let hz = self.pitch.hz();
let mut f = AnalyzerFrame {
hz,
latency: self.latency as u32,
sample_rate: self.rate,
shaper_db: gain_to_db(self.shaper_peak),
normalize_db: self.norm_db.value,
@@ -397,9 +360,94 @@ impl Engine {
.copy_from_slice(&self.meter_in.levels()[..SHOWN.min(HARMONICS)]);
f.output
.copy_from_slice(&self.meter_out.levels()[..SHOWN.min(HARMONICS)]);
if self.tracker.sounding() && hz > 0. {
if self.sounding && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
Oversampler::latency()
}
fn ahead(&mut self, mono: f32) {
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
}
fn event(&mut self, e: NoteEvent) {
self.pitch.event(false, e);
self.level.event(e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
self.run(y)
}
}
/// Standalone Temper: 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 plus the oversampling filters.
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, true);
}
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()
}
}
}