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:
1 parent
4b1a89626e
commit
340376eee2
8 files changed
+418
-163
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Knurl changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The engine is now a module that also runs inside Forge. The sound is
|
||||
unchanged; the latency is one sample longer.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Change the CLAP ID to `io.narl.bass-foundry.knurl`. Hosts see this as a new
|
||||
|
||||
@@ -397,3 +397,55 @@ impl PitchFollower {
|
||||
self.hz
|
||||
}
|
||||
}
|
||||
|
||||
/// A processor driven by note events that are aligned with its input audio. Lathe, Temper and
|
||||
/// Knurl are built from one each, so the same code runs standalone (after its own tracker) and
|
||||
/// inside a strip that shares one tracker between several of them.
|
||||
pub trait Tracked {
|
||||
/// Delay of the output behind the input, in samples.
|
||||
fn latency(&self) -> usize;
|
||||
/// The undelayed input, one tracker lookahead ahead of the audio passed to `process`.
|
||||
/// Called before the sample's events.
|
||||
fn ahead(&mut self, _mono: f32) {}
|
||||
/// A note event due at the next `process` sample.
|
||||
fn event(&mut self, e: NoteEvent);
|
||||
/// One sample of audio; `sounding` is the tracker's note state at this sample.
|
||||
fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2];
|
||||
}
|
||||
|
||||
/// A tracker and the lookahead delay in front of one [`Tracked`] module: the standalone form.
|
||||
pub struct Lookahead {
|
||||
pub tracker: Tracker,
|
||||
delay: Vec<[f32; 2]>,
|
||||
at: usize,
|
||||
}
|
||||
impl Lookahead {
|
||||
pub fn new(rate: f32, cfg: TrackerConfig) -> Self {
|
||||
let tracker = Tracker::new(rate, cfg);
|
||||
let len = tracker.latency() + 1;
|
||||
Self {
|
||||
tracker,
|
||||
delay: vec![[0.; 2]; len],
|
||||
at: 0,
|
||||
}
|
||||
}
|
||||
/// The tracker's lookahead, in samples.
|
||||
pub fn latency(&self) -> usize {
|
||||
self.tracker.latency()
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.tracker.reset();
|
||||
self.delay.fill([0.; 2]);
|
||||
}
|
||||
/// Feeds one input sample (`mono` is its channel average) and returns `module`'s output.
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: [f32; 2], mono: f32, module: &mut impl Tracked) -> [f32; 2] {
|
||||
module.ahead(mono);
|
||||
self.tracker.process(mono, |e| module.event(e));
|
||||
let len = self.delay.len();
|
||||
self.delay[self.at] = x;
|
||||
self.at = (self.at + 1) % len;
|
||||
let y = self.delay[self.at];
|
||||
module.process(y, self.tracker.sounding())
|
||||
}
|
||||
}
|
||||
+105
-38
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,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.);
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
@@ -109,8 +116,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));
|
||||
}
|
||||
|
||||
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
@@ -499,3 +504,6 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Header switches besides Bypass and the pitch source: none here.
|
||||
pub fn toggles(_: &mut egui::Ui, _: &mut Settings) {}
|
||||
+119
-62
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,14 +233,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
if r.clicked() {
|
||||
settings.bypass = !settings.bypass;
|
||||
}
|
||||
let r = ui
|
||||
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
|
||||
.on_hover_text(
|
||||
"Match output loudness to the input, so Drive changes character, not level.",
|
||||
);
|
||||
if r.clicked() {
|
||||
settings.auto_gain = !settings.auto_gain;
|
||||
}
|
||||
toggles(ui, settings);
|
||||
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.));
|
||||
@@ -250,6 +243,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| {
|
||||
@@ -294,8 +294,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 = 290.;
|
||||
|
||||
@@ -411,3 +409,15 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
"Output trim.");
|
||||
});
|
||||
}
|
||||
|
||||
/// Header switches besides Bypass and the pitch source.
|
||||
pub fn toggles(ui: &mut egui::Ui, settings: &mut Settings) {
|
||||
let r = ui
|
||||
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
|
||||
.on_hover_text(
|
||||
"Match output loudness to the input, so Drive changes character, not level.",
|
||||
);
|
||||
if r.clicked() {
|
||||
settings.auto_gain = !settings.auto_gain;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user