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

+119 -62
View File
@@ -6,7 +6,7 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
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::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
@@ -43,14 +43,12 @@ impl Default for AnalyzerFrame {
}
}
pub struct Engine {
/// The harmonic EQ without its tracker: tuned by note events aligned with its input. No
/// latency of its own.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
@@ -66,29 +64,29 @@ pub struct Engine {
bypass: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
sounding: bool,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
impl Engine {
fn check(rate: f32, channels: usize) -> Result<(), &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
Ok(())
}
impl Module {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(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 latency = tracker.latency();
check(rate, channels)?;
let settings = Settings::default();
let mut e = Self {
let mut m = Self {
rate,
channels,
settings,
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
tracker,
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
@@ -104,25 +102,19 @@ impl Engine {
bypass: 0.,
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
sounding: false,
fresh: true,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
m.set_settings(settings)?;
m.fresh = true;
Ok(m)
}
pub fn settings(&self) -> Settings {
self.settings
}
/// `source` and `gate` belong to the tracker and are ignored here.
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
self.settings = s;
if std::mem::take(&mut self.fresh) {
@@ -134,8 +126,9 @@ impl Engine {
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.pitch = PitchFollower::default();
self.hz = 0.;
self.sounding = false;
for ch in &mut self.state {
for f in ch.iter_mut() {
f.reset();
@@ -146,12 +139,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);
}
/// Gain targets in dB of every band for the current settings.
fn targets(&self) -> [f32; BANDS] {
let s = &self.settings;
@@ -193,19 +180,39 @@ impl Engine {
}
}
}
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]
/// Editor data. `latency` is left at 0 for the owner to fill in.
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
..AnalyzerFrame::default()
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, 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];
if self.sounding && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
0
}
fn event(&mut self, e: NoteEvent) {
// Legato notes slide only when Smooth is on.
let e = match e {
NoteEvent::Start { pitch, glide } => NoteEvent::Start {
pitch,
glide: glide && self.settings.smooth > 0.,
},
e => e,
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
let first = self.hz == 0.;
let (hz, jump) = self.pitch.step(self.hz_coef);
let first = first && jump;
@@ -231,7 +238,7 @@ impl Engine {
let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output;
out[c] = wet * (1. - self.bypass) + y[c] * self.bypass;
}
let hz = if self.tracker.sounding() { self.hz } else { 0. };
let hz = if sounding { self.hz } else { 0. };
let (dry, wet) = if self.channels == 2 {
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
} else {
@@ -241,18 +248,68 @@ impl Engine {
self.meter_out.push(wet, hz);
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
latency: self.latency as u32,
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
..AnalyzerFrame::default()
}
/// Standalone Lathe: 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,
};
if self.tracker.sounding() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
// The module already holds the defaults; only the tracker still needs them.
e.set_tracker(&Settings::default());
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.lookahead.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, s.smooth > 0.);
}
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(&self) -> AnalyzerFrame {
AnalyzerFrame {
latency: self.latency_samples(),
..self.module.take_analyzer()
}
f
}
}
+10 -2
View File
@@ -285,6 +285,13 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
caption(ui, "PITCH FROM");
});
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Everything under the header: the standalone editor and Forge both draw it.
pub fn body(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -304,8 +311,6 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 330.;
@@ -429,3 +434,6 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Output trim.");
});
}
/// Header switches besides Bypass and the pitch source: none here.
pub fn toggles(_: &mut egui::Ui, _: &mut Settings) {}