diff --git a/docs/knurl/CHANGELOG.md b/docs/knurl/CHANGELOG.md index 5275339..6a9f8e2 100644 --- a/docs/knurl/CHANGELOG.md +++ b/docs/knurl/CHANGELOG.md @@ -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 diff --git a/plugins/common/dsp/src/tracker.rs b/plugins/common/dsp/src/tracker.rs index c2e2d59..15e4e44 100644 --- a/plugins/common/dsp/src/tracker.rs +++ b/plugins/common/dsp/src/tracker.rs @@ -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()) + } +} diff --git a/plugins/knurl/core/src/lib.rs b/plugins/knurl/core/src/lib.rs index 5fb3d3f..a34f180 100644 --- a/plugins/knurl/core/src/lib.rs +++ b/plugins/knurl/core/src/lib.rs @@ -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_at: usize, @@ -137,7 +137,7 @@ pub struct Engine { meter_count: usize, } -impl Engine { +impl Module { pub fn new(rate: f32, channels: usize) -> Result { 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 { + 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() + } + } +} diff --git a/plugins/knurl/ui/src/lib.rs b/plugins/knurl/ui/src/lib.rs index 880a25e..9faa99d 100644 --- a/plugins/knurl/ui/src/lib.rs +++ b/plugins/knurl/ui/src/lib.rs @@ -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) {} diff --git a/plugins/lathe/core/src/lib.rs b/plugins/lathe/core/src/lib.rs index a21260c..08a171c 100644 --- a/plugins/lathe/core/src/lib.rs +++ b/plugins/lathe/core/src/lib.rs @@ -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 { - 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 { + 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 } } diff --git a/plugins/lathe/ui/src/lib.rs b/plugins/lathe/ui/src/lib.rs index 7ac276f..bd4f516 100644 --- a/plugins/lathe/ui/src/lib.rs +++ b/plugins/lathe/ui/src/lib.rs @@ -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) {} diff --git a/plugins/temper/core/src/lib.rs b/plugins/temper/core/src/lib.rs index 78c19fd..b1bf5c0 100644 --- a/plugins/temper/core/src/lib.rs +++ b/plugins/temper/core/src/lib.rs @@ -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 { 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 { + 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() + } + } +} diff --git a/plugins/temper/ui/src/lib.rs b/plugins/temper/ui/src/lib.rs index f13f2b0..7f2428d 100644 --- a/plugins/temper/ui/src/lib.rs +++ b/plugins/temper/ui/src/lib.rs @@ -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; + } +}