diff --git a/Cargo.lock b/Cargo.lock index c33c362..54aa11c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,11 +108,15 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/Cargo.toml b/Cargo.toml index 681e48d..1aeb954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/quench/core", "plugins/quench/ui", "plugins/ballast/core", "plugins/ballast/ui", "plugins/forge/core", "plugins/forge/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"] -exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"] +exclude = ["plugins/common/params", "plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"] [workspace.package] edition = "2021" diff --git a/plugins/anvil/core/src/lib.rs b/plugins/anvil/core/src/lib.rs index 0b0b8f5..aae32cb 100644 --- a/plugins/anvil/core/src/lib.rs +++ b/plugins/anvil/core/src/lib.rs @@ -10,6 +10,10 @@ //! only the harmonics the patch lacks are filled: a harmonic the patch already has would //! meet the added one at an unrelated phase and could cancel. //! +//! The sine only plays while the note is sure. While tracking is unsure, the original sub +//! comes back in its place (Replace) or the added sine fades out (Enhance), so a wrong guess +//! never plays a wrong sub note. +//! //! Kick lock reads the kick from the sidechain. Hits are found on the undelayed sidechain, //! so with the lookahead each hit ducks the sub exactly when the delayed kick plays, and //! restarts the sub's phase at the kick plus an offset, also before the kick. The audition @@ -18,9 +22,9 @@ mod settings; use common_dsp::meter::HarmonicMeter; use common_dsp::svf::{Svf, SvfCoef}; -use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::tracker::{Fallback, NoteEvent, Tracker, TrackerConfig}; use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover}; -pub use settings::{Settings, Source, KICK_OFFSET_MAX}; +pub use settings::{Mode, Settings, Source, KICK_OFFSET_MAX}; use std::f32::consts::TAU; /// Detectable fundamental range. The lowest sets the lookahead (two periods). @@ -30,6 +34,8 @@ pub const SCOPE: usize = 256; const ATTACK_MS: f32 = 2.; const RELEASE_MS: f32 = 15.; const CROSSFADE_MS: f32 = 3.; +/// Crossfade between the sine and the original sub when tracking turns unsure or sure. +const TRUST_MS: f32 = 15.; /// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz. pub const GHOST_TOP: usize = 10; /// Ghost harmonics shown and generated: 2..=GHOST_TOP. @@ -76,6 +82,8 @@ pub struct AnalyzerFrame { pub hz: f32, /// MIDI note of the current sub, rounded. pub note: Option, + /// Tracking is unsure: the original sub plays instead of the sine. + pub neutral: bool, pub low_db: f32, pub sub_db: f32, /// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the @@ -106,6 +114,7 @@ impl Default for AnalyzerFrame { Self { hz: 0., note: None, + neutral: false, low_db: -120., sub_db: -120., patch_harmonics: [-120.; GHOST_COUNT], @@ -308,6 +317,11 @@ pub struct Engine { keep: f32, output: f32, bypass: f32, + fallback: Fallback, + neutral: bool, + /// 1 while the sine plays, 0 while the original sub stands in for it. + trust: f32, + trust_coef: f32, meter_coef: f32, low_power: f32, sub_power: f32, @@ -355,6 +369,10 @@ impl Engine { keep: 0., output: 1., bypass: 0., + fallback: Fallback::default(), + neutral: false, + trust: 1., + trust_coef: coefficient(rate, TRUST_MS / 3.), meter_coef: coefficient(rate, 150.), low_power: 0., sub_power: 0., @@ -397,7 +415,7 @@ impl Engine { } if self .tracker - .set(s.source == Source::Midi, s.gate, s.glide > 0.) + .set(s.pitch.source(s.source.mode(), s.gate, s.glide > 0.)) { self.synth.voice.releasing = true; } @@ -416,6 +434,9 @@ impl Engine { } self.tracker.reset(); self.synth = Synth::default(); + self.fallback.reset(); + self.neutral = false; + self.trust = 1.; self.low_env = 0.; self.low_power = 0.; self.sub_power = 0.; @@ -638,6 +659,7 @@ impl Engine { }; let (synth, settings) = (&mut self.synth, &self.settings); self.tracker.process(mono, |e| synth.event(settings, e)); + self.neutral = self.fallback.neutral(self.tracker.state()); let len = self.delay.len(); self.delay[self.delay_at] = x; self.delay_at = (self.delay_at + 1) % len; @@ -683,8 +705,10 @@ impl Engine { let duck = self.kick_events(); self.update_fill(); let (sub, ghost) = self.oscillator(); - let sub = sub * self.level * shape * duck; - let generated = sub + ghost * self.level * shape * duck; + self.trust += (f32::from(!self.neutral) - self.trust) * self.trust_coef; + let gain = self.level * shape * duck * self.trust; + let sub = sub * gain; + let generated = sub + ghost * gain; if self.settings.kick_lock { let kick_low = self.kick.low.process(kick_late); self.measure_kick(kick_low, sub); @@ -699,11 +723,16 @@ impl Engine { self.scope_sub[self.scope_at] = generated; self.scope_at = (self.scope_at + 1) % (2 * SCOPE); } + // Replace keeps `keep` of the original, and all of it while the sine is out. + let keep = match s.mode { + Mode::Replace => self.keep + (1. - self.keep) * (1. - self.trust), + Mode::Enhance => 1., + }; let wet = |l: f32, h: f32| { if monitor.solo { generated } else { - h + self.keep * l + generated + h + keep * l + generated } }; let w = [wet(l0, h0), wet(l1, h1)]; @@ -736,6 +765,7 @@ impl Engine { kick_sum_db: self.kick.sum_db, kick_offset: self.kick.offset, audition_sums: self.kick.sums, + neutral: self.neutral, ..AnalyzerFrame::default() }; let sub = self.level.max(1e-6); @@ -751,7 +781,7 @@ impl Engine { f.ghost_harmonics[i] = rel(self.fill[i] * sub); } let v = self.synth.voice; - if v.amp > 0. && !v.releasing { + if v.amp > 0. && !v.releasing && !self.neutral { f.hz = v.hz; f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8); } diff --git a/plugins/anvil/core/src/settings.rs b/plugins/anvil/core/src/settings.rs index ae1f3c9..17e6b81 100644 --- a/plugins/anvil/core/src/settings.rs +++ b/plugins/anvil/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -8,12 +9,45 @@ pub enum Source { Audio, /// Notes come from MIDI note input; the audio only supplies the band above the split. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } +} + +/// What happens to the original sub. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum Mode { + /// The band below the split is replaced by the clean sine (plus Keep of the original). + #[default] + Replace, + /// The original sub stays, and the sine is added under it for weight. + Enhance, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, + #[serde(default)] + pub mode: Mode, /// Peak level of the new sub in dBFS, the same for every note. pub level: f32, /// Crossover frequency: below it the original sub is replaced. @@ -75,6 +109,8 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), + mode: Mode::Replace, level: -9., split: 90., dynamics: 0., @@ -99,6 +135,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let finite = [ self.level, self.split, diff --git a/plugins/anvil/core/tests/dsp.rs b/plugins/anvil/core/tests/dsp.rs index 17645cf..2844fb1 100644 --- a/plugins/anvil/core/tests/dsp.rs +++ b/plugins/anvil/core/tests/dsp.rs @@ -1,4 +1,4 @@ -use anvil_core::{Engine, Monitor, Settings, Source}; +use anvil_core::{Engine, Mode, Monitor, Settings, Source}; use common_dsp::Saw; const RATE: f32 = 48000.; @@ -488,3 +488,73 @@ fn the_audition_finds_where_kick_and_sub_sum() { ); } } + +#[test] +fn unsure_tracking_brings_back_the_original_sub() { + let (mut e, l) = engine(Settings::default()); + // A clear note, then low noise the tracker cannot read. + let mut x = notes(&[(0.1, 0.6, 55., 0.5)], 0.6); + let mut seed = 3u32; + let mut lp = 0f32; + for _ in 0..seconds(1.5) { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + lp += 0.05 * ((seed as f32 / u32::MAX as f32 - 0.5) - lp); + x.push(4. * lp); + } + let solo = render(&mut e, &x, true); + let sine = rms(&solo[seconds(0.3) + l..seconds(0.5) + l]); + let unsure = rms(&solo[seconds(1.5) + l..seconds(2.0) + l]); + assert!( + db(unsure) < db(sine) - 40., + "{} vs {}", + db(unsure), + db(sine) + ); + // The full output carries the original low band again. + let (mut e, l) = engine(Settings::default()); + let y = render(&mut e, &x, false); + let a = rms(&x[seconds(1.5)..seconds(2.0)]); + let b = rms(&y[seconds(1.5) + l..seconds(2.0) + l]); + assert!((db(a) - db(b)).abs() < 1., "{} vs {}", db(a), db(b)); +} + +#[test] +fn enhance_keeps_the_original_and_adds_the_sine() { + let x = tones(&[(55., 0.3)], 1.2); + for (mode, keeps) in [(Mode::Replace, false), (Mode::Enhance, true)] { + let (mut e, l) = engine(Settings { + mode, + octave: -1, + ..Settings::default() + }); + let y = render(&mut e, &x, false); + // Whole cycles of 27.5 Hz: 0.4 s. + let w = &y[seconds(0.5) + l..seconds(0.9) + l]; + let original = amplitude(w, 55.); + let added = amplitude(w, 27.5); + assert!((db(added) + 9.).abs() < 1., "{mode:?}: {}", db(added)); + if keeps { + assert!((db(original) - db(0.3)).abs() < 1., "{}", db(original)); + } else { + assert!(db(original) < db(0.3) - 12., "{}", db(original)); + } + } +} + +#[test] +fn note_mode_plays_the_set_note_whatever_the_audio() { + let (mut e, l) = engine(Settings { + source: Source::Note, + pitch: common_dsp::tracker::Pitch { + note: 29, + ..Default::default() + }, + ..Settings::default() + }); + let x = notes(&[(0.1, 1.1, 61.74, 0.5)], 1.3); + let y = render(&mut e, &x, true); + let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]); + assert!((f / 43.654 - 1.).abs() < 0.005, "{f}"); +} diff --git a/plugins/anvil/plugin/Cargo.lock b/plugins/anvil/plugin/Cargo.lock index 5412cd2..2edb8c3 100644 --- a/plugins/anvil/plugin/Cargo.lock +++ b/plugins/anvil/plugin/Cargo.lock @@ -77,6 +77,7 @@ dependencies = [ "anvil-core", "anvil-ui", "clap-sys", + "common-params", "crossbeam-queue", "nih_plug", "nih_plug_egui", @@ -340,11 +341,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/anvil/plugin/Cargo.toml b/plugins/anvil/plugin/Cargo.toml index b0fdda4..8b0441b 100644 --- a/plugins/anvil/plugin/Cargo.toml +++ b/plugins/anvil/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } anvil-core = { path = "../core" } +common-params = { path = "../../common/params" } anvil-ui = { path = "../ui" } crossbeam-queue = "0.3" diff --git a/plugins/anvil/plugin/src/params.rs b/plugins/anvil/plugin/src/params.rs index db72d11..7d5c3e8 100644 --- a/plugins/anvil/plugin/src/params.rs +++ b/plugins/anvil/plugin/src/params.rs @@ -1,4 +1,5 @@ -use anvil_core::{Settings, Source, KICK_OFFSET_MAX}; +use anvil_core::{Mode, Settings, Source, KICK_OFFSET_MAX}; +use common_params::PitchParams; use nih_plug::prelude::*; use nih_plug_egui::EguiState; use std::sync::Arc; @@ -10,6 +11,49 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } +} +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ModeParam { + #[id = "replace"] + Replace, + #[id = "enhance"] + Enhance, +} +impl From for Mode { + fn from(m: ModeParam) -> Self { + match m { + ModeParam::Replace => Mode::Replace, + ModeParam::Enhance => Mode::Enhance, + } + } +} +impl From for ModeParam { + fn from(m: Mode) -> Self { + match m { + Mode::Replace => ModeParam::Replace, + Mode::Enhance => ModeParam::Enhance, + } + } } fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam { FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit) @@ -20,6 +64,10 @@ pub struct AnvilParams { pub editor: Arc, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, + #[id = "mode"] + pub mode: EnumParam, #[id = "level"] pub level: FloatParam, #[id = "split"] @@ -65,6 +113,8 @@ impl Default for AnvilParams { Self { editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), + mode: EnumParam::new("Mode", ModeParam::Replace), level: linear("Sub level", d.level, -30., 0., " dBFS"), split: FloatParam::new( "Split", @@ -114,10 +164,9 @@ impl Default for AnvilParams { impl AnvilParams { pub fn settings(&self) -> Settings { Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), + mode: self.mode.value().into(), level: self.level.value(), split: self.split.value(), dynamics: self.dynamics.value(), @@ -206,14 +255,16 @@ impl AnvilParams { }); } once(new.source != old.source, &|| { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; setter.begin_set_parameter(&self.source); - setter.set_parameter(&self.source, v); + setter.set_parameter(&self.source, new.source.into()); setter.end_set_parameter(&self.source); }); + once(new.mode != old.mode, &|| { + setter.begin_set_parameter(&self.mode); + setter.set_parameter(&self.mode, new.mode.into()); + setter.end_set_parameter(&self.mode); + }); + self.pitch.apply(setter, &old.pitch, &new.pitch); } fn continuous(&self, i: usize) -> &FloatParam { [ diff --git a/plugins/anvil/ui/src/lib.rs b/plugins/anvil/ui/src/lib.rs index 8541b42..55a4f10 100644 --- a/plugins/anvil/ui/src/lib.rs +++ b/plugins/anvil/ui/src/lib.rs @@ -228,13 +228,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { state.phone = !state.phone; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); ui.add_space(6.); let frame = state.analyzer; @@ -284,10 +280,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) { RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., - match settings.source { - Source::Audio => "detecting pitch", - Source::Midi => "following MIDI notes", - } + pitch_status(settings.source.mode(), &settings.pitch) )) .size(12.) .color(MUTED), diff --git a/plugins/ballast/core/src/lib.rs b/plugins/ballast/core/src/lib.rs index 3066863..eadcd4b 100644 --- a/plugins/ballast/core/src/lib.rs +++ b/plugins/ballast/core/src/lib.rs @@ -11,7 +11,7 @@ mod settings; use common_dsp::loudness::{lufs, KWeighting}; use common_dsp::svf::{Svf, SvfCoef}; -use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::tracker::{NoteEvent, PitchMode, PitchSource, Tracker, TrackerConfig}; use common_dsp::{coefficient, db_to_gain, gain_to_db}; pub use settings::{ Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN, @@ -216,7 +216,15 @@ 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.tracker.set(PitchSource { + mode: if s.source == Source::Midi { + PitchMode::Midi + } else { + PitchMode::Audio + }, + gate_db: s.gate, + ..PitchSource::default() + }); if s.from != self.settings.from || self.fresh { self.from_coef = if s.from > FROM_MIN { SvfCoef::highpass(self.rate, s.from, Q) diff --git a/plugins/ballast/core/src/settings.rs b/plugins/ballast/core/src/settings.rs index 89c74d2..28a781c 100644 --- a/plugins/ballast/core/src/settings.rs +++ b/plugins/ballast/core/src/settings.rs @@ -22,9 +22,10 @@ pub enum Source { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum Segment { /// Every note start. - #[default] Notes, /// Every line of the host's beat grid, so a patch change on the same note counts too. + /// The default: a switch-up lands on the grid, and the grid needs no note tracking. + #[default] Grid, } @@ -91,7 +92,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, - segment: Segment::Notes, + segment: Segment::Grid, grid: Grid::Quarter, target: -14., amount: 100., diff --git a/plugins/ballast/core/tests/dsp.rs b/plugins/ballast/core/tests/dsp.rs index 19a59bb..d178a68 100644 --- a/plugins/ballast/core/tests/dsp.rs +++ b/plugins/ballast/core/tests/dsp.rs @@ -117,6 +117,7 @@ fn note_starts_from_the_audio_cut_the_parts() { let mut e = Engine::new(RATE, 2).unwrap(); e.set_settings(Settings { target, + segment: Segment::Notes, ..Settings::default() }) .unwrap(); @@ -151,6 +152,7 @@ fn midi_note_starts_cut_legato_parts() { e.set_settings(Settings { target, source: Source::Midi, + segment: Segment::Notes, ..Settings::default() }) .unwrap(); diff --git a/plugins/ballast/plugin/Cargo.lock b/plugins/ballast/plugin/Cargo.lock index 1100ec2..30f613d 100644 --- a/plugins/ballast/plugin/Cargo.lock +++ b/plugins/ballast/plugin/Cargo.lock @@ -340,11 +340,15 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/ballast/plugin/src/params.rs b/plugins/ballast/plugin/src/params.rs index e999979..256f89a 100644 --- a/plugins/ballast/plugin/src/params.rs +++ b/plugins/ballast/plugin/src/params.rs @@ -88,7 +88,7 @@ impl Default for BallastParams { Self { editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT), source: EnumParam::new("Note source", SourceParam::Audio), - segment: EnumParam::new("Parts from", SegmentParam::Notes), + segment: EnumParam::new("Parts from", SegmentParam::Grid), grid: EnumParam::new("Grid", grid_param(d.grid)), target: linear("Target", d.target, TARGET_MIN, TARGET_MAX, " LUFS"), amount: linear("Amount", d.amount, 0., 100., " %"), diff --git a/plugins/braze/core/src/lib.rs b/plugins/braze/core/src/lib.rs index bb0c29e..42ceb07 100644 --- a/plugins/braze/core/src/lib.rs +++ b/plugins/braze/core/src/lib.rs @@ -181,7 +181,9 @@ 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); + // Unsure tracking sends no events, so the beating holds its last sure note. + self.tracker + .set(s.pitch.source(s.source.mode(), s.gate, true)); if s.low_cut != self.settings.low_cut || self.fresh { let q = std::f32::consts::FRAC_1_SQRT_2; let hz = s.low_cut.min(0.45 * self.rate); @@ -340,7 +342,7 @@ impl Engine { let beat_hz = self.tempo / 60. / s.rate.quarters(); f.ratio = 1. + (beat_hz / (s.harmonic as f64 * hz as f64)) as f32; } - if self.tracker.sounding() && hz > 0. { + if self.tracker.active() && hz > 0. { f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8); } f diff --git a/plugins/braze/core/src/settings.rs b/plugins/braze/core/src/settings.rs index ee12c0d..a07db91 100644 --- a/plugins/braze/core/src/settings.rs +++ b/plugins/braze/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Levels in dB, amounts in percent, angles in degrees. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; /// Highest harmonic the beat rate can be set on. @@ -14,6 +15,24 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } /// One beat cycle lasts this note value. @@ -75,6 +94,9 @@ impl Rate { #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// How long one beat cycle of `harmonic` lasts. pub rate: Rate, /// The harmonic that beats exactly at `rate`. Harmonic n beats n/harmonic times as fast. @@ -100,6 +122,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), rate: Rate::Eighth, // Harmonic 4 sits above the low cut on every bass note, and on F1 it asks for // about 45 cents of detune, a usual reese spread. @@ -117,6 +140,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let finite = [ self.depth, self.low_cut, diff --git a/plugins/braze/plugin/Cargo.lock b/plugins/braze/plugin/Cargo.lock index 347c844..1b15f99 100644 --- a/plugins/braze/plugin/Cargo.lock +++ b/plugins/braze/plugin/Cargo.lock @@ -189,6 +189,7 @@ dependencies = [ "braze-core", "braze-ui", "clap-sys", + "common-params", "crossbeam-queue", "nih_plug", "nih_plug_egui", @@ -340,11 +341,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/braze/plugin/Cargo.toml b/plugins/braze/plugin/Cargo.toml index b95c642..22985be 100644 --- a/plugins/braze/plugin/Cargo.toml +++ b/plugins/braze/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } braze-core = { path = "../core" } +common-params = { path = "../../common/params" } braze-ui = { path = "../ui" } crossbeam-queue = "0.3" diff --git a/plugins/braze/plugin/src/params.rs b/plugins/braze/plugin/src/params.rs index f340aa9..d900ab9 100644 --- a/plugins/braze/plugin/src/params.rs +++ b/plugins/braze/plugin/src/params.rs @@ -1,4 +1,5 @@ use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX}; +use common_params::PitchParams; use nih_plug::prelude::*; use nih_plug_egui::EguiState; use std::sync::Arc; @@ -10,6 +11,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } #[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] pub enum RateParam { @@ -70,6 +91,8 @@ pub struct BrazeParams { pub editor: Arc, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "rate"] pub rate: EnumParam, #[id = "harmonic"] @@ -97,6 +120,7 @@ impl Default for BrazeParams { Self { editor: EguiState::from_size(braze_ui::WIDTH, braze_ui::HEIGHT), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), rate: EnumParam::new("Rate", to_param(d.rate)), harmonic: IntParam::new( "Harmonic", @@ -130,10 +154,8 @@ impl BrazeParams { pub fn settings(&self) -> Settings { let rate = self.rate.value(); Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), rate: RATES .iter() .find(|(p, _)| *p == rate) @@ -207,13 +229,10 @@ impl BrazeParams { } } if new.source != old.source { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; setter.begin_set_parameter(&self.source); - setter.set_parameter(&self.source, v); + setter.set_parameter(&self.source, new.source.into()); setter.end_set_parameter(&self.source); } + self.pitch.apply(setter, &old.pitch, &new.pitch); } } diff --git a/plugins/braze/ui/src/lib.rs b/plugins/braze/ui/src/lib.rs index 3f2b271..e5c3fe5 100644 --- a/plugins/braze/ui/src/lib.rs +++ b/plugins/braze/ui/src/lib.rs @@ -202,13 +202,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { settings.bypass = !settings.bypass; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); ui.add_space(6.); let frame = state.analyzer; @@ -279,10 +275,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) { RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., - match settings.source { - Source::Audio => "detecting pitch", - Source::Midi => "following MIDI notes", - } + pitch_status(settings.source.mode(), &settings.pitch) )) .size(12.) .color(MUTED), diff --git a/plugins/common/dsp/Cargo.toml b/plugins/common/dsp/Cargo.toml index 80c1434..8fc5526 100644 --- a/plugins/common/dsp/Cargo.toml +++ b/plugins/common/dsp/Cargo.toml @@ -5,3 +5,4 @@ edition.workspace = true license.workspace = true [dependencies] +serde = { workspace = true } diff --git a/plugins/common/dsp/examples/tracker_report.rs b/plugins/common/dsp/examples/tracker_report.rs new file mode 100644 index 0000000..c2061e2 --- /dev/null +++ b/plugins/common/dsp/examples/tracker_report.rs @@ -0,0 +1,118 @@ +//! Scores the note tracker on bass stems. +//! +//! ```text +//! cargo run -p common-dsp --release --example tracker_report +//! cargo run -p common-dsp --release --example tracker_report -- [--sub] [--bends] +//! [--note F1] [--key free|minor|major|root] stem.wav ... +//! ``` +//! Without files it scores the synthetic corpus in `common_dsp::stems`, once with the stable +//! default and once with Follow bends. With files, `--note` names the one note each file +//! plays (for a wrong-note share); without it only note starts and unsure time are reported. +use common_dsp::parse_note; +use common_dsp::stems::{self, Score, Truth}; +use common_dsp::tracker::{PitchSource, Scale, TrackerConfig}; +use std::{env, fs, process}; + +fn row(name: &str, s: Score) { + println!( + "{name:<14} {:>6.1}s {:>8.2} {:>8.2} {:>7.1}% {:>7.1}%", + s.seconds, + s.starts, + s.extra_starts, + s.wrong * 100., + s.unsure * 100. + ); +} +fn header() { + println!( + "{:<14} {:>7} {:>8} {:>8} {:>8} {:>8}", + "stem", "length", "starts/s", "extra/s", "wrong", "unsure" + ); +} + +fn main() { + let mut cfg = TrackerConfig::BASS; + let mut source = PitchSource::default(); + let mut note = None; + let mut files = vec![]; + let mut args = env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--sub" => cfg = TrackerConfig::SUB, + "--bends" => source.bends = true, + "--note" => { + let n = args.next().and_then(|n| parse_note(&n)); + let Some(n) = n else { + eprintln!("--note needs a note name such as F1"); + process::exit(2); + }; + note = Some(n); + source.note = n; + } + "--key" => { + source.scale = match args.next().as_deref() { + Some("free") => Scale::Free, + Some("minor") => Scale::Minor, + Some("major") => Scale::Major, + Some("root") => Scale::Root, + _ => { + eprintln!("--key needs free, minor, major or root"); + process::exit(2); + } + }; + } + _ => files.push(a), + } + } + if files.is_empty() { + let rate = 48000.; + for bends in [false, true] { + println!( + "\n{}", + if bends { + "Follow bends" + } else { + "Stable (default)" + } + ); + header(); + let s = PitchSource { bends, ..source }; + for stem in stems::all(rate) { + row( + stem.name, + stems::score(rate, cfg, s, &stem.samples, &stem.truth), + ); + } + } + return; + } + header(); + for path in files { + let wave = match fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| common_dsp::wav::decode(&b).map_err(str::to_string)) + { + Ok(w) => w, + Err(e) => { + eprintln!("{path}: {e}"); + continue; + } + }; + let channels = wave.channels as usize; + let mono: Vec = wave + .samples + .chunks(channels) + .map(|c| c.iter().sum::() / channels as f32) + .collect(); + // With a known note, quiet samples are skipped rather than counted as silence. + let truth = match note { + Some(n) => vec![Truth::Note(n as f32); mono.len()], + None => vec![], + }; + let name = path.rsplit('/').next().unwrap_or(&path); + row( + name, + stems::score(wave.rate as f32, cfg, source, &mono, &truth), + ); + } +} diff --git a/plugins/common/dsp/src/lib.rs b/plugins/common/dsp/src/lib.rs index 9c73ddc..c5e909d 100644 --- a/plugins/common/dsp/src/lib.rs +++ b/plugins/common/dsp/src/lib.rs @@ -1,7 +1,9 @@ -//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files. +//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files, +//! and synthetic test stems. pub mod loudness; pub mod meter; pub mod oversample; +pub mod stems; pub mod svf; pub mod tracker; pub mod wav; @@ -221,6 +223,20 @@ pub fn note_name(note: u8) -> String { ]; format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1) } +/// Reads a scientific note name such as "F1" or "c#2", the inverse of [`note_name`]. +pub fn parse_note(text: &str) -> Option { + const NAMES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let text = text.trim(); + let split = text.find(|c: char| c.is_ascii_digit() || c == '-')?; + let (pitch, octave) = text.split_at(split); + let class = NAMES.iter().position(|n| n.eq_ignore_ascii_case(pitch))? as i32; + let octave: i32 = octave.parse().ok()?; + u8::try_from((octave + 1) * 12 + class) + .ok() + .filter(|n| *n <= 127) +} pub fn db_to_gain(db: f32) -> f32 { 10f32.powf(db / 20.) } diff --git a/plugins/common/dsp/src/stems.rs b/plugins/common/dsp/src/stems.rs new file mode 100644 index 0000000..658c866 --- /dev/null +++ b/plugins/common/dsp/src/stems.rs @@ -0,0 +1,414 @@ +//! Synthetic bass stems with known notes, and a score for how well the tracker follows them. +//! +//! The stems imitate what makes real bass hard to track: FM growls, detuned reeses, moving +//! formants over a weak fundamental, 808 slides, layered octaves and noise. They are a stand-in +//! until real stems are collected; `examples/tracker_report.rs` scores real WAV files too. +use crate::tracker::{NoteEvent, PitchSource, Tracker, TrackerConfig}; +use crate::{midi_to_hz, Biquad, Saw}; +use std::f32::consts::TAU; + +/// What the tracker should report for one input sample. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Truth { + /// One note sounds at this fractional MIDI pitch. + Note(f32), + /// No note sounds; reporting one is wrong. + Silent, + /// A transition (attack, slide): not scored. + Skip, +} + +pub struct Stem { + pub name: &'static str, + pub samples: Vec, + pub truth: Vec, +} + +/// Deterministic white noise in -1..1. +struct Noise(u32); +impl Noise { + fn sample(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 as f32 / u32::MAX as f32 * 2. - 1. + } +} + +/// Notes as (start s, end s, MIDI note). Samples outside every note are silent; the first +/// `SKIP_S` of each note is not scored. +const SKIP_S: f32 = 0.03; +fn truth(rate: f32, len: usize, notes: &[(f32, f32, f32)]) -> Vec { + (0..len) + .map(|i| { + let t = i as f32 / rate; + match notes.iter().find(|(a, b, _)| t >= *a && t < *b) { + Some((a, _, n)) if t - a >= SKIP_S => Truth::Note(*n), + Some(_) => Truth::Skip, + None => Truth::Silent, + } + }) + .collect() +} +/// Short fade at note edges so onsets are clean. +fn envelope(t: f32, a: f32, b: f32) -> f32 { + ((t - a) / 0.005).clamp(0., 1.) * ((b - t) / 0.005).clamp(0., 1.) +} + +fn growl(rate: f32) -> Stem { + // FM: modulator at twice the carrier, index wobbling at 3 Hz, then driven. + let notes = [(0.1, 1.9, 29.), (2.0, 3.9, 32.)]; + let len = (4. * rate) as usize; + let (mut pc, mut pm) = (0f32, 0f32); + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let Some(&(a, b, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else { + return 0.; + }; + let hz = midi_to_hz(n); + pc = (pc + hz / rate).fract(); + pm = (pm + 2. * hz / rate).fract(); + let index = 3. + 3. * (TAU * 3. * t).sin(); + let x = (TAU * pc + index * (TAU * pm).sin()).sin(); + 0.5 * (2.5 * x).tanh() * envelope(t, a, b) + }) + .collect(); + Stem { + name: "growl", + samples, + truth: truth(rate, len, ¬es), + } +} + +fn hard_growl(rate: f32) -> Stem { + // FM at a 1:1 ratio, index sweeping 2–12 at 6 Hz, hard drive, then a sweeping resonance. + let notes = [(0.1, 1.0, 31.), (1.0, 2.0, 34.), (2.1, 3.9, 29.)]; + let len = (4. * rate) as usize; + let (mut pc, mut pm) = (0f32, 0f32); + let mut band = Biquad::default(); + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let Some(&(a, b, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else { + return 0.; + }; + if i % 32 == 0 { + let sweep = 0.5 + 0.5 * (TAU * 6. * t).sin(); + band = retuned(band, Biquad::peak(rate, 200. + 1800. * sweep, 3., 12.)); + } + let hz = midi_to_hz(n); + pc = (pc + hz / rate).fract(); + pm = (pm + hz / rate).fract(); + let index = 7. + 5. * (TAU * 6. * t).sin(); + let x = (4. * (TAU * pc + index * (TAU * pm).sin()).sin()).tanh(); + 0.15 * band.process(x) * envelope(t, a, b) + }) + .collect(); + Stem { + name: "hard growl", + samples, + truth: truth(rate, len, ¬es), + } +} + +fn wobble(rate: f32) -> Stem { + // A saw on G1 through a resonant low-pass wobbling 80–1500 Hz at 1/8 notes of 140 BPM. + let notes = [(0.1, 3.9, 31.)]; + let len = (4. * rate) as usize; + let mut saw = Saw::new(rate, midi_to_hz(31.)); + let mut lp = [Biquad::default(); 2]; + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let (a, b, _) = notes[0]; + if t < a || t >= b { + return 0.; + } + if i % 32 == 0 { + let lfo = 0.5 - 0.5 * (TAU * (140. / 60. * 2.) * t).cos(); + let hz = 80. * (1500f32 / 80.).powf(lfo); + lp[0] = retuned(lp[0], Biquad::lowpass(rate, hz, 6.)); + lp[1] = retuned(lp[1], Biquad::lowpass(rate, hz, 0.7)); + } + let y = lp[0].process(0.3 * saw.sample()); + lp[1].process(y) * envelope(t, a, b) + }) + .collect(); + Stem { + name: "wobble", + samples, + truth: truth(rate, len, ¬es), + } +} + +fn reese(rate: f32) -> Stem { + // Two saws 12 cents apart, lowpassed; A1 then D2 legato. + let notes = [(0.1, 2.0, 33.), (2.0, 3.9, 38.)]; + let len = (4. * rate) as usize; + let mut lp = [Biquad::lowpass(rate, 900., 0.7); 2]; + let mut saws: Option<(f32, Saw, Saw)> = None; + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let Some(&(_, _, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else { + return 0.; + }; + if saws.as_ref().map(|s| s.0) != Some(n) { + let hz = midi_to_hz(n); + saws = Some(( + n, + Saw::new(rate, hz * 2f32.powf(0.06 / 12.)), + Saw::new(rate, hz * 2f32.powf(-0.06 / 12.)), + )); + } + let (_, a, b) = saws.as_mut().unwrap(); + let x = 0.3 * (a.sample() + b.sample()); + let y = lp[0].process(x); + let y = lp[1].process(y); + y * envelope(t, notes[0].0, notes[1].1) + }) + .collect(); + Stem { + name: "reese", + samples, + truth: truth(rate, len, ¬es), + } +} + +fn formant(rate: f32) -> Stem { + // A saw on E1 through two sweeping band-passes; the fundamental is 18 dB down. + let notes = [(0.1, 3.9, 28.)]; + let len = (4. * rate) as usize; + let hz = midi_to_hz(28.); + let mut saw = Saw::new(rate, hz); + let mut phase = 0f32; + let mut bands = [Biquad::default(); 2]; + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let (a, b, _) = notes[0]; + if t < a || t >= b { + return 0.; + } + if i % 32 == 0 { + let sweep = 0.5 + 0.5 * (TAU * 1.5 * t).sin(); + bands[0] = retuned(bands[0], Biquad::peak(rate, 300. + 500. * sweep, 4., 18.)); + bands[1] = retuned(bands[1], Biquad::peak(rate, 900. + 900. * sweep, 4., 18.)); + } + phase = (phase + hz / rate).fract(); + let x = 0.05 * saw.sample(); + let y = bands[0].process(x); + let y = bands[1].process(y); + (0.4 * y + 0.06 * (TAU * phase).sin()) * envelope(t, a, b) + }) + .collect(); + Stem { + name: "formant", + samples, + truth: truth(rate, len, ¬es), + } +} +fn retuned(mut old: Biquad, new: Biquad) -> Biquad { + old.retune(new); + old +} + +fn slide808(rate: f32) -> Stem { + // A saturated sine on C2 that slides down to G1 over 60 ms, decaying slowly. + let notes = [(0.1, 1.0, 36.), (1.0, 3.0, 31.)]; + let len = (3.2 * rate) as usize; + let mut phase = 0f32; + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + if !(0.1..3.0).contains(&t) { + return 0.; + } + let n = if t < 1. { + 36. + } else { + 36. - 5. * ((t - 1.) / 0.06).min(1.) + }; + phase = (phase + midi_to_hz(n) / rate).fract(); + let decay = (-(t - 0.1) / 2.5).exp(); + 0.6 * decay * (2. * (TAU * phase).sin()).tanh() * envelope(t, 0.1, 3.) + }) + .collect(); + let mut truth = truth(rate, len, ¬es); + // The slide itself is not scored. + for (i, v) in truth.iter_mut().enumerate() { + let t = i as f32 / rate; + if (1.0..1.1).contains(&t) { + *v = Truth::Skip; + } + } + Stem { + name: "808 slide", + samples, + truth, + } +} + +fn layered(rate: f32) -> Stem { + // Sub sine on F1 under an FM growl an octave up, plus noise: the drop's low end. + let notes = [(0.1, 3.9, 29.)]; + let len = (4. * rate) as usize; + let hz = midi_to_hz(29.); + let (mut sub, mut pc, mut pm) = (0f32, 0f32, 0f32); + let mut noise = Noise(0x9e37_79b9); + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let (a, b, _) = notes[0]; + if t < a || t >= b { + return 0.; + } + sub = (sub + hz / rate).fract(); + pc = (pc + 2. * hz / rate).fract(); + pm = (pm + 3. * hz / rate).fract(); + let index = 2. + 2. * (TAU * 2. * t).sin(); + let growl = (2. * (TAU * pc + index * (TAU * pm).sin()).sin()).tanh(); + (0.3 * (TAU * sub).sin() + 0.35 * growl + 0.05 * noise.sample()) * envelope(t, a, b) + }) + .collect(); + Stem { + name: "layered", + samples, + truth: truth(rate, len, ¬es), + } +} + +fn riser(rate: f32) -> Stem { + // Low-passed noise swelling up: no note at all. + let len = (3. * rate) as usize; + let mut noise = Noise(0x1234_5678); + let mut lp = [Biquad::lowpass(rate, 600., 0.7); 2]; + let samples = (0..len) + .map(|i| { + let t = i as f32 / rate; + let x = noise.sample() * 0.6 * (t / 3.); + let y = lp[0].process(x); + lp[1].process(y) + }) + .collect(); + Stem { + name: "noise riser", + samples, + truth: vec![Truth::Silent; len], + } +} + +/// The corpus at sample rate `rate`. +pub fn all(rate: f32) -> Vec { + vec![ + growl(rate), + hard_growl(rate), + wobble(rate), + reese(rate), + formant(rate), + slide808(rate), + layered(rate), + riser(rate), + ] +} + +/// How the tracker did on one stem. Shares are of the scored samples. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Score { + pub seconds: f32, + /// Note starts per second beyond the stem's real notes (0 when no truth is known). + pub extra_starts: f32, + /// Note starts per second. + pub starts: f32, + /// Share of note samples where the tracker reports a sure note that is wrong by more + /// than half a semitone, plus silent samples where it reports a sure note. + pub wrong: f32, + /// Share of note samples where the tracker is unsure or reports no note. + pub unsure: f32, +} + +/// Runs `samples` through a tracker and scores its output against `truth` (input-aligned; +/// empty when unknown). +pub fn score( + rate: f32, + cfg: TrackerConfig, + source: PitchSource, + samples: &[f32], + truth: &[Truth], +) -> Score { + let mut t = Tracker::new(rate, cfg); + t.set(source); + let latency = t.latency(); + let mut pitch = 0f32; + let mut starts = 0usize; + let (mut wrong, mut unsure, mut scored) = (0usize, 0usize, 0usize); + for i in 0..samples.len() + latency { + let x = samples.get(i).copied().unwrap_or(0.); + t.process(x, |e| match e { + NoteEvent::Start { pitch: p, .. } => { + pitch = p; + starts += 1; + } + NoteEvent::Move { pitch: p } => pitch = p, + NoteEvent::Stop => {} + }); + let Some(at) = i.checked_sub(latency) else { + continue; + }; + let sure = t.active(); + match truth.get(at) { + Some(Truth::Note(n)) => { + scored += 1; + if !sure { + unsure += 1; + } else if (pitch - n).abs() > 0.5 { + wrong += 1; + } + } + Some(Truth::Silent) => { + scored += 1; + if sure { + wrong += 1; + } + } + _ => {} + } + } + let seconds = samples.len() as f32 / rate; + let notes = count_notes(truth); + let share = |n: usize| { + if scored > 0 { + n as f32 / scored as f32 + } else { + 0. + } + }; + Score { + seconds, + extra_starts: if truth.is_empty() { + 0. + } else { + starts.saturating_sub(notes) as f32 / seconds + }, + starts: starts as f32 / seconds, + wrong: share(wrong), + unsure: share(unsure), + } +} +/// Number of distinct notes in a truth track (a slide counts as a new note). +fn count_notes(truth: &[Truth]) -> usize { + let mut last = None; + let mut n = 0; + for v in truth { + match v { + Truth::Note(p) if last != Some(*p) => { + n += 1; + last = Some(*p); + } + Truth::Silent => last = None, + _ => {} + } + } + n +} diff --git a/plugins/common/dsp/src/tracker.rs b/plugins/common/dsp/src/tracker.rs index 15e4e44..fda54ee 100644 --- a/plugins/common/dsp/src/tracker.rs +++ b/plugins/common/dsp/src/tracker.rs @@ -3,7 +3,14 @@ //! Detection runs on the undelayed input. Events are reported at *output* time, `latency()` //! samples later, so a host that delays its audio path by the same amount sees each note start //! exactly on its attack, with the pitch already known. -use crate::{coefficient, gain_to_db, hz_to_midi, Biquad, Yin}; +//! +//! Detection is built to stay put. A new note needs a confident pitch held for `NOTE_MS` +//! (`OCTAVE_MS` for an octave jump), and inside a note the tuning only moves when the pitch +//! drifts past `DEADBAND` for good. When the pitch reads keep disagreeing (FM growls, noise, +//! formant sweeps), the tracker reports itself unsure: [`Tracker::active`] turns false, and +//! tracked processing should fall back to something neutral rather than act on a guess. +use crate::{coefficient, db_to_gain, gain_to_db, hz_to_midi, Biquad, Yin}; +use serde::{Deserialize, Serialize}; const EVENTS: usize = 64; const HOP_MS: f32 = 5.; @@ -12,6 +19,162 @@ const GATE_HYSTERESIS_DB: f32 = 6.; /// A pitch move this large (semitones) counts as a new note. const JUMP: f32 = 0.7; const CLARITY: f32 = 0.5; +/// A different pitch must hold this long before it becomes the new note. +const NOTE_MS: f32 = 40.; +/// A jump down an octave must hold this long: octave errors are the commonest misread. +const OCTAVE_MS: f32 = 80.; +/// A jump up an octave must hold this long. A signal one octave up still repeats at the +/// note's period, and detuned or FM basses read an octave up whenever their odd harmonics +/// cancel, so these reads count as agreeing with the note. +const OCTAVE_UP_MS: f32 = 200.; +/// A new note's reads must stay within this spread (semitones): a swept resonance drags the +/// reads along continuously, a real note change settles. +const STEADY: f32 = 0.5; +/// A note ends when the start of the analysis window (output time) stays this long below the +/// gate's release level. Faster than the gate, whose envelope needs over 100 ms to fall. +const QUIET_MS: f32 = 20.; +/// Inside a note, the tuning moves only when the settled pitch is this far (semitones) off. +const DEADBAND: f32 = 0.25; +/// Per-hop step of the settled pitch towards each agreeing read. +const SETTLE: f32 = 0.2; +/// Trust kept per disagreeing read: about 40 ms of them make the note unsure. +const TRUST_DISAGREE: f32 = 0.85; +/// Trust kept per hop with no read: about 400 ms without one make the note unsure. Growls +/// read only now and then, between stretches YIN cannot read at all. +const TRUST_SILENT: f32 = 0.985; +/// Share of the missing trust one agreeing read restores. +const TRUST_AGREE: f32 = 0.5; +const TRUST_LOST: f32 = 0.3; +const TRUST_REGAINED: f32 = 0.6; + +/// Where notes come from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PitchMode { + /// Pitch and note starts are detected from the audio. + #[default] + Audio, + /// Notes come from MIDI note input. + Midi, + /// Note starts come from the audio, the pitch is the fixed `note`: no detection at all. + Note, +} + +/// Key lock for detected pitches. The root is the pitch class of [`PitchSource::note`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scale { + /// Any pitch, including between notes. + #[default] + Free, + /// Natural minor. + Minor, + Major, + /// Only the root, in any octave: detection picks the octave. + Root, +} +impl Scale { + fn steps(self) -> &'static [i32] { + match self { + Scale::Free => &[], + Scale::Minor => &[0, 2, 3, 5, 7, 8, 10], + Scale::Major => &[0, 2, 4, 5, 7, 9, 11], + Scale::Root => &[0], + } + } + /// The in-key note nearest `pitch`, or `pitch` itself when the scale is free. + pub fn snap(self, root: u8, pitch: f32) -> f32 { + let steps = self.steps(); + if steps.is_empty() { + return pitch; + } + let root = (root % 12) as i32; + let base = pitch.round() as i32; + let mut best = base as f32; + let mut distance = f32::MAX; + for n in base - 7..=base + 7 { + if steps.contains(&(n - root).rem_euclid(12)) { + let d = (n as f32 - pitch).abs(); + if d < distance { + distance = d; + best = n as f32; + } + } + } + best + } +} + +/// The user's pitch-source choices, shared by every tracked plugin. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PitchSource { + pub mode: PitchMode, + /// Detection threshold in dBFS for note starts (Audio and Note modes). + pub gate_db: f32, + /// Legato notes slide instead of restarting. + pub glide: bool, + /// The note played in Note mode (MIDI number); its pitch class is the key root. + pub note: u8, + pub scale: Scale, + /// Follow bends and vibrato on every hop and switch notes after 10 ms, as before 2026-11. + /// Fast, but it flickers on growls. + pub bends: bool, +} +impl Default for PitchSource { + fn default() -> Self { + Self { + mode: PitchMode::Audio, + gate_db: -42., + glide: false, + note: DEFAULT_NOTE, + scale: Scale::Free, + bends: false, + } + } +} +/// F1, the commonest riddim root. +pub const DEFAULT_NOTE: u8 = 29; +/// Range of the Note control: C0 to C5. +pub const NOTE_MIN: u8 = 12; +pub const NOTE_MAX: u8 = 72; + +/// The stored part of a plugin's pitch source beside its mode and gate: Note mode's note, the +/// key lock and Follow bends. Plugins keep it as one `pitch` field in their settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Pitch { + pub note: u8, + pub scale: Scale, + pub bends: bool, +} +impl Default for Pitch { + fn default() -> Self { + Self { + note: DEFAULT_NOTE, + scale: Scale::Free, + bends: false, + } + } +} +impl Pitch { + pub fn validate(&self) -> Result<(), &'static str> { + if (NOTE_MIN..=NOTE_MAX).contains(&self.note) { + Ok(()) + } else { + Err("Note out of range") + } + } + /// The full tracker settings for `mode`, a gate threshold in dBFS and legato slides. + pub fn source(self, mode: PitchMode, gate_db: f32, glide: bool) -> PitchSource { + PitchSource { + mode, + gate_db, + glide, + note: self.note, + scale: self.scale, + bends: self.bends, + } + } +} #[derive(Clone, Copy, Debug, PartialEq)] pub struct TrackerConfig { @@ -40,6 +203,40 @@ impl TrackerConfig { }; } +/// The tracker's note state at one output sample. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct NoteState { + /// A note sounds. + pub sounding: bool, + /// The sounding note's pitch is trusted ([`Tracker::active`]); false while none sounds. + pub sure: bool, +} +impl NoteState { + /// A note sounds but its pitch is not trusted: tracked processing should step back. + pub fn unsure(self) -> bool { + self.sounding && !self.sure + } +} + +/// Latches "step back" across note ends: a module goes neutral while its note is unsure and +/// returns only on a sure note, so a tail after an unsure stretch stays neutral too. +#[derive(Clone, Copy, Debug, Default)] +pub struct Fallback { + neutral: bool, +} +impl Fallback { + /// Updates from this sample's note state; true while the module should sound neutral. + pub fn neutral(&mut self, note: NoteState) -> bool { + if note.sounding { + self.neutral = !note.sure; + } + self.neutral + } + pub fn reset(&mut self) { + self.neutral = false; + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub enum NoteEvent { /// A new note. `glide` asks to slide from the previous pitch instead of restarting. @@ -67,14 +264,50 @@ struct Event { kind: Kind, } +/// A pitch that may become the next note. +#[derive(Clone, Copy, Debug)] +struct Candidate { + mean: f32, + low: f32, + high: f32, + reads: u32, +} +impl Candidate { + fn new(p: f32) -> Self { + Self { + mean: p, + low: p, + high: p, + reads: 1, + } + } + /// Adds a read, or returns None when it does not fit this candidate. + fn add(self, p: f32) -> Option { + let (low, high) = (self.low.min(p), self.high.max(p)); + (high - low < STEADY).then(|| Self { + mean: self.mean + (p - self.mean) / (self.reads + 1) as f32, + low, + high, + reads: self.reads + 1, + }) + } +} + +/// One analysis window's result. +#[derive(Clone, Copy, Debug)] +enum Read { + Pitch(f32), + /// Sound YIN cannot read: no evidence either way. + Unclear, + Silent, +} + /// Allocation-free after `new`. Pitches are fractional MIDI notes. pub struct Tracker { rate: f32, cfg: TrackerConfig, latency: usize, - midi: bool, - gate_db: f32, - glide: bool, + source: PitchSource, detect: [Biquad; 2], decimate: usize, decimate_count: usize, @@ -85,6 +318,9 @@ pub struct Tracker { yin: Yin, hop: usize, hop_count: usize, + note_hops: u32, + octave_hops: u32, + octave_up_hops: u32, gate_env: f32, gate_release: f32, gate_open: bool, @@ -96,8 +332,17 @@ pub struct Tracker { /// Output-time note state. sounding: bool, pending: bool, + /// The note ended on silence while the gate stayed open: the next sound starts a note. + rest: bool, + quiet_hops: u32, + quiet_need: u32, tracked: f32, - candidate: Option<(f32, u8)>, + /// Running pitch of the agreeing reads inside the note. + settled: f32, + candidate: Option, + /// 1 while reads agree with the note, falling towards 0 while they do not. + trust: f32, + sure: bool, held: [u8; 16], held_len: usize, } @@ -110,13 +355,13 @@ impl Tracker { // YIN needs one comparison window of at least the longest period after the lags. let analysis = 2 * max_lag + 3; let q = std::f32::consts::FRAC_1_SQRT_2; + let hop = ((detect_rate * HOP_MS * 0.001) as usize).max(1); + let hop_ms = hop as f32 / detect_rate * 1000.; Self { rate, cfg, latency: analysis * decimate, - midi: false, - gate_db: -42., - glide: false, + source: PitchSource::default(), detect: [Biquad::lowpass(rate, cfg.detect_lowpass, q); 2], decimate, decimate_count: 0, @@ -125,8 +370,11 @@ impl Tracker { ring_at: 0, scratch: vec![0.; analysis], yin: Yin::new(max_lag + 2), - hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1), + hop, hop_count: 0, + note_hops: (NOTE_MS / hop_ms).round().max(2.) as u32, + octave_hops: (OCTAVE_MS / hop_ms).round().max(2.) as u32, + octave_up_hops: (OCTAVE_UP_MS / hop_ms).round().max(2.) as u32, gate_env: 0., gate_release: 1. - coefficient(rate, 30.), gate_open: false, @@ -140,8 +388,14 @@ impl Tracker { event_len: 0, sounding: false, pending: false, + rest: false, + quiet_hops: 0, + quiet_need: (QUIET_MS / hop_ms).round().max(1.) as u32, tracked: 0., + settled: 0., candidate: None, + trust: 0., + sure: false, held: [0; 16], held_len: 0, } @@ -153,22 +407,28 @@ impl Tracker { pub fn config(&self) -> TrackerConfig { self.cfg } - /// `midi` selects note input over detection; `gate_db` is the detection threshold; - /// `glide` lets legato notes slide. Returns true when the source changed, which drops - /// every pending and held note: the caller should release its sound. - pub fn set(&mut self, midi: bool, gate_db: f32, glide: bool) -> bool { - let changed = midi != self.midi; - self.midi = midi; - self.gate_db = gate_db; - self.glide = glide; + pub fn source(&self) -> PitchSource { + self.source + } + /// Applies the pitch-source choices. Returns true when the mode changed, which drops every + /// pending and held note: the caller should release its sound. + pub fn set(&mut self, source: PitchSource) -> bool { + let changed = source.mode != self.source.mode; + self.source = source; if changed { self.release_all(); } changed } + fn midi(&self) -> bool { + self.source.mode == PitchMode::Midi + } fn release_all(&mut self) { self.sounding = false; self.pending = false; + self.rest = false; + self.quiet_hops = 0; + self.sure = false; self.held_len = 0; self.event_len = 0; self.candidate = None; @@ -192,10 +452,22 @@ impl Tracker { pub fn note_off(&mut self, note: u8) { self.push(Kind::NoteOff(note.min(127))); } - /// True while a note sounds at output time. + /// True while a note sounds at output time, sure or not. pub fn sounding(&self) -> bool { self.sounding } + /// True while tracked processing should act: a note sounds and its pitch is trusted. + /// MIDI and Note modes are always sure; detection is unsure from an onset until a pitch + /// reads, and while reads keep disagreeing with the note. + pub fn active(&self) -> bool { + self.sounding && (self.source.mode != PitchMode::Audio || self.sure) + } + pub fn state(&self) -> NoteState { + NoteState { + sounding: self.sounding, + sure: self.active(), + } + } fn push(&mut self, kind: Kind) { if self.event_len == EVENTS { // Dropping the oldest keeps the newest note state; overflow only happens on floods. @@ -209,45 +481,81 @@ impl Tracker { }; self.event_len += 1; } - /// Detected pitch of the most recent analysis window, in fractional MIDI notes. - fn estimate(&mut self) -> Option { + /// Detected pitch of the most recent analysis window, in fractional MIDI notes, snapped to + /// the key when one is set. + fn estimate(&mut self) -> Read { let n = self.ring.len(); for i in 0..n { self.scratch[i] = self.ring[(self.ring_at + i) % n]; } - let (hz, clarity) = self.yin.estimate( + // The window starts at output time: a quiet head means the note has ended there. + let head = self.hop.min(n); + let power = self.scratch[..head].iter().map(|v| v * v).sum::() / head as f32; + let quiet = db_to_gain(self.source.gate_db - GATE_HYSTERESIS_DB); + if power < quiet * quiet { + return Read::Silent; + } + let s = self.source; + match self.yin.estimate( &self.scratch, self.detect_rate, self.cfg.min_hz, self.cfg.max_hz, - )?; - (clarity >= CLARITY).then(|| hz_to_midi(hz)) + ) { + Some((hz, clarity)) if clarity >= CLARITY => { + Read::Pitch(s.scale.snap(s.note, hz_to_midi(hz))) + } + _ => Read::Unclear, + } } fn start(&mut self, pitch: f32, glide: bool, emit: &mut impl FnMut(NoteEvent)) { self.tracked = pitch; + self.settled = pitch; self.candidate = None; self.pending = false; self.sounding = true; + self.trust = 1.; + self.sure = true; emit(NoteEvent::Start { pitch, glide }); } fn stop(&mut self, emit: &mut impl FnMut(NoteEvent)) { + let was = self.sounding || self.pending; self.sounding = false; self.pending = false; + self.sure = false; + self.quiet_hops = 0; self.candidate = None; - emit(NoteEvent::Stop); + if was { + emit(NoteEvent::Stop); + } + } + /// A note starts at output time from `read`: detected (or pending until a pitch reads) in + /// Audio mode, the set note in Note mode. + fn onset(&mut self, read: Read, emit: &mut impl FnMut(NoteEvent)) { + self.rest = false; + self.quiet_hops = 0; + match (self.source.mode, read) { + (PitchMode::Note, _) => self.start(self.source.note as f32, false, emit), + (_, Read::Pitch(p)) => self.start(p, false, emit), + _ => { + self.pending = true; + self.sounding = true; + self.sure = false; + self.trust = 0.; + } + } } fn handle(&mut self, kind: Kind, emit: &mut impl FnMut(NoteEvent)) { - let midi = self.midi; - match kind { - Kind::Onset if !midi => match self.estimate() { - Some(p) => self.start(p, false, emit), - None => { - self.pending = true; - self.sounding = true; - } - }, - Kind::Release if !midi => self.stop(emit), - Kind::NoteOn(n) if midi => { + match (self.source.mode, kind) { + (PitchMode::Audio | PitchMode::Note, Kind::Onset) => { + let read = self.estimate(); + self.onset(read, emit); + } + (PitchMode::Audio | PitchMode::Note, Kind::Release) => { + self.rest = false; + self.stop(emit); + } + (PitchMode::Midi, Kind::NoteOn(n)) => { let len = self.held_len; if let Some(i) = self.held[..len].iter().position(|h| *h == n) { self.held.copy_within(i + 1..len, i); @@ -260,9 +568,9 @@ impl Tracker { let legato = self.held_len > 0; self.held[self.held_len] = n; self.held_len += 1; - self.start(n as f32, legato && self.glide, emit); + self.start(n as f32, legato && self.source.glide, emit); } - Kind::NoteOff(n) if midi => { + (PitchMode::Midi, Kind::NoteOff(n)) => { let len = self.held_len; let Some(i) = self.held[..len].iter().position(|h| *h == n) else { return; @@ -274,48 +582,119 @@ impl Tracker { self.stop(emit); } else if top { let next = self.held[self.held_len - 1]; - self.start(next as f32, self.glide, emit); + self.start(next as f32, self.source.glide, emit); } } _ => {} } } + /// Moves the trust score one hop: `Some(true)` for a read that agrees with the note, + /// `Some(false)` for one that does not, `None` for no read. Hysteresis on `sure`. + fn trust(&mut self, agree: Option) { + match agree { + Some(true) => self.trust += (1. - self.trust) * TRUST_AGREE, + Some(false) => self.trust *= TRUST_DISAGREE, + None => self.trust *= TRUST_SILENT, + } + if self.sure && self.trust < TRUST_LOST { + self.sure = false; + } else if !self.sure && self.trust > TRUST_REGAINED { + self.sure = true; + } + } /// Regular analysis; its window starts at the current output time. fn hop_analysis(&mut self, emit: &mut impl FnMut(NoteEvent)) { - if self.midi || !self.sounding { + if self.midi() || !(self.sounding || self.rest) { return; } - let Some(p) = self.estimate() else { - return; - }; - if self.pending { - self.start(p, false, emit); - return; - } - if (p - self.tracked).abs() >= JUMP { - match self.candidate { - Some((c, count)) if (c - p).abs() < 0.5 => { - if count + 1 >= 2 { - self.start(p, self.glide, emit); - } else { - self.candidate = Some((p, count + 1)); - } + let read = self.estimate(); + if let Read::Silent = read { + if self.sounding && !self.pending { + self.quiet_hops += 1; + if self.quiet_hops >= self.quiet_need { + self.stop(emit); + self.rest = true; + } else { + self.trust(Some(false)); } - _ => self.candidate = Some((p, 1)), + } + return; + } + self.quiet_hops = 0; + if self.rest { + return self.onset(read, emit); + } + if self.source.mode == PitchMode::Note { + return; + } + if self.pending { + if let Read::Pitch(p) = read { + self.start(p, false, emit); + } + return; + } + let p = match read { + Read::Pitch(p) => p, + Read::Unclear => return self.trust(None), + Read::Silent => unreachable!(), + }; + let offset = p - self.tracked; + if offset.abs() < JUMP { + self.candidate = None; + self.trust(Some(true)); + if self.source.bends { + self.tracked += 0.3 * offset; + emit(NoteEvent::Move { + pitch: self.tracked, + }); + } else { + self.settled += SETTLE * (p - self.settled); + if (self.settled - self.tracked).abs() >= DEADBAND { + self.tracked = self.settled; + emit(NoteEvent::Move { + pitch: self.tracked, + }); + } + } + return; + } + let octaves = (offset / 12.).round(); + let octave = octaves != 0. && (offset - 12. * octaves).abs() < JUMP; + let up = octave && octaves > 0.; + let candidate = if self.source.bends { + // The 0.1 rule: two reads within half a semitone. + match self.candidate { + Some(c) if (c.mean - p).abs() < 0.5 => Candidate { + mean: p, + reads: c.reads + 1, + ..c + }, + _ => Candidate::new(p), } } else { - self.candidate = None; - self.tracked += 0.3 * (p - self.tracked); - emit(NoteEvent::Move { - pitch: self.tracked, - }); + self.candidate + .and_then(|c| c.add(p)) + .unwrap_or(Candidate::new(p)) + }; + let need = match (self.source.bends, octave, up) { + (true, _, _) => 2, + (false, false, _) => self.note_hops, + (false, true, false) => self.octave_hops, + (false, true, true) => self.octave_up_hops, + }; + if candidate.reads >= need { + let pitch = if self.source.bends { p } else { candidate.mean }; + self.start(pitch, self.source.glide, emit); + } else { + self.candidate = Some(candidate); + self.trust(Some(up && !self.source.bends)); } } /// Feeds one undelayed mono input sample. Events due at output time go to `emit`, in order. pub fn process(&mut self, mono: f32, mut emit: impl FnMut(NoteEvent)) { let d = self.detect[0].process(mono); let d = self.detect[1].process(d); - if !self.midi { + if !self.midi() { let a = d.abs(); self.gate_env = if a > self.gate_env { a @@ -323,13 +702,14 @@ impl Tracker { self.gate_env * self.gate_release }; let level = gain_to_db(self.gate_env); - if level > self.gate_db { + let gate = self.source.gate_db; + if level > gate { self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize; if !self.gate_open { self.gate_open = true; self.push(Kind::Onset); } - } else if self.gate_open && level < self.gate_db - GATE_HYSTERESIS_DB { + } else if self.gate_open && level < gate - GATE_HYSTERESIS_DB { if self.gate_hold == 0 { self.gate_open = false; self.push(Kind::Release); @@ -409,8 +789,9 @@ pub trait Tracked { 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]; + /// One sample of audio and the tracker's note state at it. While the note is unsure + /// (see [`Fallback`]) the module should step back to something neutral. + fn process(&mut self, x: [f32; 2], note: NoteState) -> [f32; 2]; } /// A tracker and the lookahead delay in front of one [`Tracked`] module: the standalone form. @@ -446,6 +827,6 @@ impl Lookahead { self.delay[self.at] = x; self.at = (self.at + 1) % len; let y = self.delay[self.at]; - module.process(y, self.tracker.sounding()) + module.process(y, self.tracker.state()) } } diff --git a/plugins/common/dsp/tests/blocks.rs b/plugins/common/dsp/tests/blocks.rs index f687a29..7b28d8f 100644 --- a/plugins/common/dsp/tests/blocks.rs +++ b/plugins/common/dsp/tests/blocks.rs @@ -1,7 +1,7 @@ use common_dsp::meter::HarmonicMeter; use common_dsp::oversample::Oversampler; use common_dsp::svf::{Svf, SvfCoef}; -use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::tracker::{NoteEvent, PitchMode, PitchSource, Scale, Tracker, TrackerConfig}; use common_dsp::{hz_to_midi, Saw}; use std::f32::consts::TAU; @@ -174,7 +174,7 @@ fn harmonic_meter_reads_saw_harmonics() { #[test] fn tracker_reports_notes_at_output_time() { let mut t = Tracker::new(RATE, TrackerConfig::BASS); - t.set(false, -42., false); + t.set(PitchSource::default()); let l = t.latency(); assert!(l < (0.08 * RATE) as usize, "{l}"); let onset = 4800; @@ -210,7 +210,11 @@ fn tracker_reports_notes_at_output_time() { #[test] fn tracker_delays_midi_by_the_latency() { let mut t = Tracker::new(RATE, TrackerConfig::BASS); - assert!(t.set(true, -42., true)); + assert!(t.set(PitchSource { + mode: PitchMode::Midi, + glide: true, + ..PitchSource::default() + })); let l = t.latency(); let mut events = vec![]; for i in 0..l + 2000 { @@ -248,3 +252,127 @@ fn tracker_delays_midi_by_the_latency() { ] ); } + +#[test] +fn stable_tracker_holds_its_notes_on_the_stem_corpus() { + let stable = PitchSource::default(); + let bends = PitchSource { + bends: true, + ..stable + }; + let (mut extra_stable, mut extra_bends) = (0., 0.); + for stem in common_dsp::stems::all(RATE) { + let s = common_dsp::stems::score( + RATE, + TrackerConfig::BASS, + stable, + &stem.samples, + &stem.truth, + ); + let b = + common_dsp::stems::score(RATE, TrackerConfig::BASS, bends, &stem.samples, &stem.truth); + assert!(s.extra_starts <= 0.25, "{}: {s:?}", stem.name); + assert!(s.wrong <= 0.05, "{}: {s:?}", stem.name); + assert!(s.wrong <= b.wrong + 0.005, "{}: {s:?} vs {b:?}", stem.name); + extra_stable += s.extra_starts; + extra_bends += b.extra_starts; + } + // Follow bends keeps the fast 0.1 behaviour, which flickers on formant basses. + assert!( + extra_bends > 10. * extra_stable.max(1.), + "{extra_stable} vs {extra_bends}" + ); +} + +#[test] +fn tracker_turns_inactive_when_reads_stop_agreeing() { + let mut t = Tracker::new(RATE, TrackerConfig::BASS); + t.set(PitchSource::default()); + let mut saw = Saw::new(RATE, 55.); + let mut seed = 1u32; + let mut active = vec![]; + for i in 0..(RATE * 1.5) as usize { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let noise = seed as f32 / u32::MAX as f32 - 0.5; + let x = if i < 24000 { 0.5 * saw.sample() } else { noise }; + t.process(x, |_| {}); + active.push(t.active()); + } + let l = t.latency(); + assert!(active[l + 12000..l + 23000].iter().all(|a| *a)); + // Unreadable sound is no evidence against the note, so trust fades slowly: growls read + // only now and then. Within half a second of noise, tracked processing stops acting. + assert!(active[l + 48000..].iter().all(|a| !*a)); +} + +#[test] +fn note_mode_plays_the_set_note_on_each_onset() { + let mut t = Tracker::new(RATE, TrackerConfig::BASS); + t.set(PitchSource { + mode: PitchMode::Note, + note: 29, + ..PitchSource::default() + }); + let mut saw = Saw::new(RATE, 220.); + let mut events = vec![]; + for i in 0..48000 { + let x = if (4800..20000).contains(&i) || i >= 30000 { + 0.5 * saw.sample() + } else { + 0. + }; + t.process(x, |e| events.push(e)); + if i == 15000 { + assert!(t.active()); + } + } + let starts: Vec<_> = events + .iter() + .filter_map(|e| match e { + NoteEvent::Start { pitch, .. } => Some(*pitch), + _ => None, + }) + .collect(); + assert_eq!(starts, vec![29., 29.]); + assert!(!events.iter().any(|e| matches!(e, NoteEvent::Move { .. }))); +} + +#[test] +fn key_lock_snaps_reads_to_the_scale() { + // F minor: F G Ab Bb C Db Eb. F#1 (30) leans to G1 when read sharp. + assert_eq!(Scale::Minor.snap(29, 30.4), 31.); + assert_eq!(Scale::Minor.snap(29, 29.6), 29.); + assert_eq!(Scale::Major.snap(0, 61.), 60.); + assert_eq!(Scale::Root.snap(29, 40.6), 41.); + assert_eq!(Scale::Root.snap(29, 35.4), 41.); + assert_eq!(Scale::Free.snap(29, 30.37), 30.37); +} + +#[test] +fn key_lock_keeps_a_drifting_wobble_on_its_root() { + let stem = common_dsp::stems::all(RATE) + .into_iter() + .find(|s| s.name == "wobble") + .unwrap(); + let free = PitchSource::default(); + let root = PitchSource { + note: 31, + scale: Scale::Root, + ..free + }; + let f = common_dsp::stems::score(RATE, TrackerConfig::BASS, free, &stem.samples, &stem.truth); + let r = common_dsp::stems::score(RATE, TrackerConfig::BASS, root, &stem.samples, &stem.truth); + assert!(r.unsure + r.wrong < f.unsure + f.wrong, "{f:?} vs {r:?}"); + assert!(r.wrong <= 0.02, "{r:?}"); +} + +#[test] +fn note_names_parse_back() { + for n in 0..=127u8 { + assert_eq!(common_dsp::parse_note(&common_dsp::note_name(n)), Some(n)); + } + assert_eq!(common_dsp::parse_note(" f1 "), Some(29)); + assert_eq!(common_dsp::parse_note("H2"), None); +} diff --git a/plugins/common/params/Cargo.toml b/plugins/common/params/Cargo.toml new file mode 100644 index 0000000..0ce3abf --- /dev/null +++ b/plugins/common/params/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "common-params" +version = "0.1.0" +edition = "2021" +license = "ISC" + +[dependencies] +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } +common-dsp = { path = "../dsp" } diff --git a/plugins/common/params/src/lib.rs b/plugins/common/params/src/lib.rs new file mode 100644 index 0000000..5429816 --- /dev/null +++ b/plugins/common/params/src/lib.rs @@ -0,0 +1,121 @@ +//! Host parameters shared by every tracked plugin: Note mode's note, the key lock and +//! Follow bends. Plugins nest [`PitchParams`] in their own parameter struct. +use common_dsp::tracker::{Pitch, Scale, NOTE_MAX, NOTE_MIN}; +use common_dsp::note_name; +use nih_plug::prelude::*; +use std::sync::Arc; + +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ScaleParam { + #[id = "free"] + #[name = "Off"] + Free, + #[id = "minor"] + Minor, + #[id = "major"] + Major, + #[id = "root"] + #[name = "Root only"] + Root, +} +impl From for Scale { + fn from(s: ScaleParam) -> Self { + match s { + ScaleParam::Free => Scale::Free, + ScaleParam::Minor => Scale::Minor, + ScaleParam::Major => Scale::Major, + ScaleParam::Root => Scale::Root, + } + } +} +impl From for ScaleParam { + fn from(s: Scale) -> Self { + match s { + Scale::Free => ScaleParam::Free, + Scale::Minor => ScaleParam::Minor, + Scale::Major => ScaleParam::Major, + Scale::Root => ScaleParam::Root, + } + } +} + +#[derive(Params)] +pub struct PitchParams { + /// The note played in Note mode; its pitch class is the key root. + #[id = "note"] + pub note: IntParam, + #[id = "scale"] + pub scale: EnumParam, + #[id = "bends"] + pub bends: BoolParam, +} +impl Default for PitchParams { + fn default() -> Self { + let d = Pitch::default(); + Self { + note: IntParam::new( + "Note", + d.note as i32, + IntRange::Linear { + min: NOTE_MIN as i32, + max: NOTE_MAX as i32, + }, + ) + .with_value_to_string(Arc::new(|v| note_name(v.clamp(0, 127) as u8))) + .with_string_to_value(Arc::new(|s| parse_note(s).map(i32::from))), + scale: EnumParam::new("Key lock", ScaleParam::Free), + bends: BoolParam::new("Follow bends", d.bends), + } + } +} +impl PitchParams { + pub fn pitch(&self) -> Pitch { + Pitch { + note: (self.note.value().clamp(NOTE_MIN as i32, NOTE_MAX as i32)) as u8, + scale: self.scale.value().into(), + bends: self.bends.value(), + } + } + /// Pushes editor changes to the host, one gesture per change. + pub fn apply(&self, setter: &ParamSetter, old: &Pitch, new: &Pitch) { + if old.note != new.note { + setter.begin_set_parameter(&self.note); + setter.set_parameter(&self.note, new.note as i32); + setter.end_set_parameter(&self.note); + } + if old.scale != new.scale { + setter.begin_set_parameter(&self.scale); + setter.set_parameter(&self.scale, new.scale.into()); + setter.end_set_parameter(&self.scale); + } + if old.bends != new.bends { + setter.begin_set_parameter(&self.bends); + setter.set_parameter(&self.bends, new.bends); + setter.end_set_parameter(&self.bends); + } + } +} + +/// Reads a note name such as "F1" within the Note range. +pub fn parse_note(text: &str) -> Option { + common_dsp::parse_note(text).filter(|n| (NOTE_MIN..=NOTE_MAX).contains(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn note_names_round_trip() { + for n in NOTE_MIN..=NOTE_MAX { + assert_eq!(parse_note(¬e_name(n)), Some(n)); + } + assert_eq!(parse_note("f1"), Some(29)); + assert_eq!(parse_note("H2"), None); + } + #[test] + fn scale_maps_both_ways() { + for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] { + assert_eq!(Scale::from(ScaleParam::from(s)), s); + } + } +} diff --git a/plugins/common/ui/Cargo.toml b/plugins/common/ui/Cargo.toml index 28da044..3149815 100644 --- a/plugins/common/ui/Cargo.toml +++ b/plugins/common/ui/Cargo.toml @@ -6,3 +6,4 @@ license.workspace = true [dependencies] egui = "0.31.1" +common-dsp = { path = "../dsp" } diff --git a/plugins/common/ui/src/lib.rs b/plugins/common/ui/src/lib.rs index 6f8d752..2e90e9e 100644 --- a/plugins/common/ui/src/lib.rs +++ b/plugins/common/ui/src/lib.rs @@ -1,4 +1,6 @@ //! Shared hardware look for every plugin editor: palette, fonts, knobs, panels. +use common_dsp::note_name; +use common_dsp::tracker::{Pitch, PitchMode, Scale, NOTE_MAX, NOTE_MIN}; use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; // Dark anodized hardware: graphite surfaces, one signal colour, steel data traces. @@ -359,3 +361,168 @@ pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32 ); }); } + +const PITCH_CLASSES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", +]; +fn scale_name(scale: Scale) -> &'static str { + match scale { + Scale::Free => "off", + Scale::Minor => "minor", + Scale::Major => "major", + Scale::Root => "root only", + } +} +/// Short status of the pitch source for readout lines: "detecting pitch in F minor". +pub fn pitch_status(mode: PitchMode, pitch: &Pitch) -> String { + match (mode, pitch.scale) { + (PitchMode::Audio, Scale::Free) => "detecting pitch".into(), + (PitchMode::Audio, s) => format!( + "detecting pitch, key {} {}", + PITCH_CLASSES[pitch.note as usize % 12], + scale_name(s) + ), + (PitchMode::Midi, _) => "following MIDI".into(), + (PitchMode::Note, _) => format!("fixed on {}", note_name(pitch.note)), + } +} +/// Pitch source switch for a header's right-to-left row: AUDIO, MIDI and NOTE, then a button +/// that opens Note mode's note, or the key lock and Follow bends for detection. +pub fn pitch_from(ui: &mut egui::Ui, mode: &mut PitchMode, pitch: &mut Pitch) { + let id = ui.make_persistent_id("pitch-detail"); + let detail = match *mode { + PitchMode::Note => Some(note_name(pitch.note)), + PitchMode::Audio => Some(match pitch.scale { + Scale::Free if pitch.bends => "BENDS".into(), + Scale::Free => "KEY OFF".into(), + s => format!( + "{} {}", + PITCH_CLASSES[pitch.note as usize % 12], + match s { + Scale::Minor => "MIN", + Scale::Major => "MAJ", + _ => "ROOT", + } + ), + }), + PitchMode::Midi => None, + }; + if let Some(detail) = detail { + let help = if *mode == PitchMode::Note { + "The note this plugin follows. Nothing is detected: set the drop's root." + } else { + "Key lock and Follow bends for detection." + }; + let r = ui + .button(RichText::new(detail).size(13.).color(ACCENT)) + .on_hover_text(help); + if r.clicked() { + ui.memory_mut(|m| m.toggle_popup(id)); + } + let note_mode = *mode == PitchMode::Note; + egui::popup_below_widget( + ui, + id, + &r, + egui::PopupCloseBehavior::CloseOnClickOutside, + |ui| { + ui.set_min_width(220.); + if note_mode { + pitch_note(ui, pitch, true); + } else { + caption(ui, "KEY LOCK"); + ui.horizontal(|ui| { + for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] { + let help = match s { + Scale::Free => "Any pitch, also between notes.", + Scale::Minor => "Only notes of the natural minor scale.", + Scale::Major => "Only notes of the major scale.", + Scale::Root => "Only the root: detection picks the octave.", + }; + let label = RichText::new(scale_name(s).to_uppercase()).size(12.); + if ui + .selectable_label(pitch.scale == s, label) + .on_hover_text(help) + .clicked() + { + pitch.scale = s; + } + } + }); + if pitch.scale != Scale::Free { + ui.add_space(4.); + caption(ui, "ROOT"); + pitch_note(ui, pitch, false); + } + ui.add_space(6.); + ui.checkbox(&mut pitch.bends, "Follow bends").on_hover_text( + "Retune on every bend and switch notes within 10 ms. Fast, but it \ + flickers on growls. Off holds each note until a new one is clear.", + ); + } + }, + ); + } + for (m, label, help) in [ + ( + PitchMode::Note, + "NOTE", + "You set the note; the audio only starts and stops it. Nothing is detected.", + ), + ( + PitchMode::Midi, + "MIDI", + "Notes from MIDI input. In FL, set the channel's MIDI output port and this \ + effect's input port to the same number.", + ), + ( + PitchMode::Audio, + "AUDIO", + "Detect the pitch from the audio. When detection is unsure, tracked processing \ + steps back instead of guessing.", + ), + ] { + let r = ui + .selectable_label(*mode == m, RichText::new(label).size(13.)) + .on_hover_text(help); + if r.clicked() { + *mode = m; + } + } + caption(ui, "PITCH FROM"); +} +/// Twelve pitch-class buttons, and octave steps when `octave` is set. +fn pitch_note(ui: &mut egui::Ui, pitch: &mut Pitch, octave: bool) { + let class = pitch.note % 12; + let base = pitch.note - class; + for row in [0..6, 6..12] { + ui.horizontal(|ui| { + for c in row { + let r = ui.add_sized( + [30., 22.], + egui::SelectableLabel::new(class == c as u8, PITCH_CLASSES[c]), + ); + if r.clicked() { + pitch.note = (base + c as u8).clamp(NOTE_MIN, NOTE_MAX); + } + } + }); + } + if octave { + ui.horizontal(|ui| { + caption(ui, "OCTAVE"); + if ui.button("−").clicked() && pitch.note >= NOTE_MIN + 12 { + pitch.note -= 12; + } + ui.label( + RichText::new(format!("{}", pitch.note as i32 / 12 - 1)) + .monospace() + .color(TEXT), + ); + if ui.button("+").clicked() && pitch.note + 12 <= NOTE_MAX { + pitch.note += 12; + } + ui.label(RichText::new(note_name(pitch.note)).color(ACCENT)); + }); + } +} diff --git a/plugins/crucible/plugin/Cargo.lock b/plugins/crucible/plugin/Cargo.lock index 6935776..4b1a21d 100644 --- a/plugins/crucible/plugin/Cargo.lock +++ b/plugins/crucible/plugin/Cargo.lock @@ -309,11 +309,15 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/forge/core/src/lib.rs b/plugins/forge/core/src/lib.rs index bee8bc3..7f913bf 100644 --- a/plugins/forge/core/src/lib.rs +++ b/plugins/forge/core/src/lib.rs @@ -7,7 +7,7 @@ //! its audio by its own latency, so the strip's latency never changes. Allocation-free after //! `new`. mod settings; -use common_dsp::tracker::{NoteEvent, Tracked, Tracker, TrackerConfig}; +use common_dsp::tracker::{NoteEvent, NoteState, Tracked, Tracker, TrackerConfig}; use common_dsp::{coefficient, hz_to_midi}; pub use settings::{Settings, Source, Stage, ORDERS, STAGES}; @@ -44,7 +44,7 @@ impl Default for AnalyzerFrame { struct Slot { events: [NoteEvent; EVENTS], len: usize, - sounding: bool, + note: NoteState, /// The undelayed input, one lookahead ahead of the audio this slot belongs to. ahead: f32, } @@ -53,7 +53,7 @@ impl Default for Slot { Self { events: [NoteEvent::Stop; EVENTS], len: 0, - sounding: false, + note: NoteState::default(), ahead: 0., } } @@ -155,7 +155,8 @@ impl Engine { /// Tracker settings and how far back each position reads the note events. fn route(&mut self, s: &Settings) { // Each module decides itself whether a legato note slides. - self.tracker.set(s.source == Source::Midi, s.gate, true); + self.tracker + .set(s.pitch.source(s.source.mode(), s.gate, true)); let mut at = 0; for (i, stage) in s.order.into_iter().enumerate() { self.offsets[i] = at; @@ -198,7 +199,7 @@ impl Engine { ..Slot::default() }; self.tracker.process(mono, |e| slot.push(e)); - slot.sounding = self.tracker.sounding(); + slot.note = self.tracker.state(); let len = self.line.len(); self.line_at = (self.line_at + 1) % len; self.line[self.line_at] = slot; @@ -221,7 +222,7 @@ impl Engine { for &e in &slot.events[..slot.len] { m.event(e); } - v = m.process(v, slot.sounding); + v = m.process(v, slot.note); } // The pitch at the output: the oldest slot is the last module's input time minus its @@ -231,7 +232,7 @@ impl Engine { self.pitch.event(false, e); } self.pitch.step(1.); - self.sounding = last.sounding; + self.sounding = last.note.sure; let len = self.dry.len(); self.dry_at = (self.dry_at + 1) % len; diff --git a/plugins/forge/core/src/settings.rs b/plugins/forge/core/src/settings.rs index b296a90..32810d0 100644 --- a/plugins/forge/core/src/settings.rs +++ b/plugins/forge/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls of the strip and its modules. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -8,6 +9,24 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } /// One module of the strip. @@ -45,6 +64,9 @@ pub const ORDERS: [[Stage; STAGES]; 6] = [ pub struct Settings { /// Pitch source of the one tracker every module follows. pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// Detection threshold in dBFS for note starts (Audio source). pub gate: f32, /// Modules in processing order. @@ -60,6 +82,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), gate: lathe_core::Settings::default().gate, order: ORDERS[0], lathe: lathe_core::Settings::default(), @@ -71,6 +94,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let ok = (-60.0..=-20.).contains(&self.gate) && ORDERS.contains(&self.order); if !ok { return Err("invalid settings"); @@ -94,26 +118,17 @@ impl Settings { Stage::Knurl => self.knurl.bypass = !on, } } - /// Copies the strip's pitch source and gate into every module. + /// Copies the strip's pitch source, pitch details and gate into every module. pub fn share(&mut self) { - let midi = self.source == Source::Midi; + let mode = self.source.mode(); self.lathe.gate = self.gate; self.temper.gate = self.gate; self.knurl.gate = self.gate; - self.lathe.source = if midi { - lathe_core::Source::Midi - } else { - lathe_core::Source::Audio - }; - self.temper.source = if midi { - temper_core::Source::Midi - } else { - temper_core::Source::Audio - }; - self.knurl.source = if midi { - knurl_core::Source::Midi - } else { - knurl_core::Source::Audio - }; + self.lathe.pitch = self.pitch; + self.temper.pitch = self.pitch; + self.knurl.pitch = self.pitch; + self.lathe.source = lathe_core::Source::from_mode(mode); + self.temper.source = temper_core::Source::from_mode(mode); + self.knurl.source = knurl_core::Source::from_mode(mode); } } diff --git a/plugins/forge/plugin/Cargo.lock b/plugins/forge/plugin/Cargo.lock index 6c28d9e..7b2997d 100644 --- a/plugins/forge/plugin/Cargo.lock +++ b/plugins/forge/plugin/Cargo.lock @@ -309,11 +309,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] @@ -646,6 +658,7 @@ name = "forge" version = "0.1.0" dependencies = [ "clap-sys", + "common-params", "crossbeam-queue", "forge-core", "forge-ui", diff --git a/plugins/forge/plugin/Cargo.toml b/plugins/forge/plugin/Cargo.toml index 7be2ee4..443041b 100644 --- a/plugins/forge/plugin/Cargo.toml +++ b/plugins/forge/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } forge-core = { path = "../core" } +common-params = { path = "../../common/params" } lathe-core = { path = "../../lathe/core" } temper-core = { path = "../../temper/core" } knurl-core = { path = "../../knurl/core" } diff --git a/plugins/forge/plugin/src/params.rs b/plugins/forge/plugin/src/params.rs index 0c6687d..84601f4 100644 --- a/plugins/forge/plugin/src/params.rs +++ b/plugins/forge/plugin/src/params.rs @@ -1,3 +1,4 @@ +use common_params::PitchParams; use forge_core::{Settings, Source, ORDERS}; use knurl_core::{Seed, GRIT_OFF, STEPS, STEP_MIN}; use lathe_core::HARMONICS; @@ -13,6 +14,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } /// The six orders of `ORDERS`, in the same order. #[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] @@ -281,6 +302,8 @@ pub struct ForgeParams { pub editor: Arc, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "gate"] pub gate: FloatParam, #[id = "order"] @@ -300,6 +323,7 @@ impl Default for ForgeParams { Self { editor: EguiState::from_size(forge_ui::WIDTH, forge_ui::HEIGHT), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), gate: linear("Gate", d.gate, -60., -20., " dB"), order: EnumParam::new("Order", OrderParam::LatheTemperKnurl), bypass: BoolParam::new("Bypass", false).make_bypass(), @@ -401,10 +425,8 @@ impl ForgeParams { .unwrap_or(0); let curve = self.temper.curve.value(); let mut s = Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), gate: self.gate.value(), order: ORDERS[order], lathe: lathe_core::Settings { @@ -576,11 +598,13 @@ impl ForgeParams { old.knurl.steps as i32, new.knurl.steps as i32, ); - let source = |s: Source| match s { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; - switch(setter, &self.source, source(old.source), source(new.source)); + switch( + setter, + &self.source, + SourceParam::from(old.source), + SourceParam::from(new.source), + ); + self.pitch.apply(setter, &old.pitch, &new.pitch); let order = |s: &Settings| ORDER_PARAMS[ORDERS.iter().position(|o| *o == s.order).unwrap_or(0)]; switch(setter, &self.order, order(old), order(new)); diff --git a/plugins/forge/ui/src/lib.rs b/plugins/forge/ui/src/lib.rs index 5f88313..e4a8f04 100644 --- a/plugins/forge/ui/src/lib.rs +++ b/plugins/forge/ui/src/lib.rs @@ -129,13 +129,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { settings.bypass = !settings.bypass; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); ui.add_space(6.); panel(ui, |ui| { diff --git a/plugins/knurl/core/src/lib.rs b/plugins/knurl/core/src/lib.rs index a34f180..8c8d597 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::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig}; +use common_dsp::tracker::{Lookahead, NoteEvent, NoteState, 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}; @@ -402,8 +402,10 @@ impl Tracked for Module { }; self.pitch.event(self.settings.snap, e); } - fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] { - self.run(x, sounding) + fn process(&mut self, x: [f32; 2], note: NoteState) -> [f32; 2] { + // The texture needs a trusted cycle clock: it fades out while tracking is unsure, as + // it does between notes. + self.run(x, note.sure) } } @@ -444,7 +446,7 @@ impl Engine { fn set_tracker(&mut self, s: &Settings) { self.lookahead .tracker - .set(s.source == Source::Midi, s.gate, false); + .set(s.pitch.source(s.source.mode(), s.gate, false)); } pub fn reset(&mut self) { self.lookahead.reset(); diff --git a/plugins/knurl/core/src/settings.rs b/plugins/knurl/core/src/settings.rs index ff5c5a3..33cbd93 100644 --- a/plugins/knurl/core/src/settings.rs +++ b/plugins/knurl/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Gains in dB; amounts relative to one cycle in percent. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; /// Pattern steps: one per cycle, repeating. @@ -14,6 +15,24 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -29,6 +48,9 @@ pub enum Seed { #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// Gain per pattern step, 0 down to `STEP_MIN`. pub pattern: [f32; STEPS], /// Steps that get a grit burst. @@ -104,6 +126,7 @@ impl Default for Settings { fn default() -> Self { let mut s = Self { source: Source::Audio, + pitch: Pitch::default(), pattern: [0.; STEPS], grit_steps: [true; STEPS], steps: 1, @@ -127,6 +150,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let finite = [ self.edge, self.level_jitter, diff --git a/plugins/knurl/plugin/Cargo.lock b/plugins/knurl/plugin/Cargo.lock index ccdde2a..a87b512 100644 --- a/plugins/knurl/plugin/Cargo.lock +++ b/plugins/knurl/plugin/Cargo.lock @@ -309,11 +309,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] @@ -864,6 +876,7 @@ name = "knurl" version = "0.2.0" dependencies = [ "clap-sys", + "common-params", "crossbeam-queue", "knurl-core", "knurl-ui", diff --git a/plugins/knurl/plugin/Cargo.toml b/plugins/knurl/plugin/Cargo.toml index 895e0b3..1091e80 100644 --- a/plugins/knurl/plugin/Cargo.toml +++ b/plugins/knurl/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } knurl-core = { path = "../core" } +common-params = { path = "../../common/params" } knurl-ui = { path = "../ui" } crossbeam-queue = "0.3" serde = { version = "1", features = ["derive"] } diff --git a/plugins/knurl/plugin/src/params.rs b/plugins/knurl/plugin/src/params.rs index 0a2edab..736973e 100644 --- a/plugins/knurl/plugin/src/params.rs +++ b/plugins/knurl/plugin/src/params.rs @@ -1,3 +1,4 @@ +use common_params::PitchParams; use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN}; use nih_plug::prelude::*; use nih_plug_egui::EguiState; @@ -10,6 +11,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } #[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] pub enum SeedParam { @@ -50,6 +71,8 @@ pub struct KnurlParams { pub pattern: RwLock, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "seed"] pub seed: EnumParam, #[id = "steps"] @@ -86,6 +109,7 @@ impl Default for KnurlParams { editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT), pattern: RwLock::new(Pattern::default()), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), seed: EnumParam::new("Random", SeedParam::Same), steps: IntParam::new( "Steps", @@ -131,10 +155,8 @@ impl KnurlParams { pub fn settings(&self, last: &Pattern) -> Settings { let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last); Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), pattern: pattern.gains.map(|g| { if g.is_finite() { g.clamp(STEP_MIN, 0.) @@ -238,12 +260,9 @@ impl KnurlParams { switch(setter, &self.bypass, new.bypass); } if new.source != old.source { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; - switch(setter, &self.source, v); + switch(setter, &self.source, new.source.into()); } + self.pitch.apply(setter, &old.pitch, &new.pitch); if new.seed != old.seed { let v = match new.seed { Seed::Same => SeedParam::Same, diff --git a/plugins/knurl/ui/src/lib.rs b/plugins/knurl/ui/src/lib.rs index 9faa99d..46848d9 100644 --- a/plugins/knurl/ui/src/lib.rs +++ b/plugins/knurl/ui/src/lib.rs @@ -78,13 +78,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { settings.bypass = !settings.bypass; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); body(ui, settings, state); ui.separator(); diff --git a/plugins/lathe/core/src/lib.rs b/plugins/lathe/core/src/lib.rs index 08a171c..ab66863 100644 --- a/plugins/lathe/core/src/lib.rs +++ b/plugins/lathe/core/src/lib.rs @@ -6,7 +6,9 @@ mod settings; use common_dsp::meter::HarmonicMeter; use common_dsp::svf::{Svf, SvfCoef}; -use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig}; +use common_dsp::tracker::{ + Fallback, Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig, +}; use common_dsp::{coefficient, db_to_gain, hz_to_midi}; pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS}; @@ -22,8 +24,10 @@ const GAIN_MS: f32 = 20.; pub struct AnalyzerFrame { /// Fundamental the filters are tuned to, 0 before the first note. pub hz: f32, - /// Sounding note, rounded; None between notes. + /// Sounding note, rounded; None between notes and while tracking is unsure. pub note: Option, + /// Tracking is unsure, so the audio passes unchanged. + pub neutral: bool, /// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental). pub input: [f32; HARMONICS], pub output: [f32; HARMONICS], @@ -35,6 +39,7 @@ impl Default for AnalyzerFrame { Self { hz: 0., note: None, + neutral: false, input: [-120.; HARMONICS], output: [-120.; HARMONICS], latency: 0, @@ -64,7 +69,11 @@ pub struct Module { bypass: f32, meter_in: HarmonicMeter, meter_out: HarmonicMeter, + /// A sure note sounds: the readout shows it. sounding: bool, + fallback: Fallback, + /// Unsure tracking: every band fades to 0 dB and the audio passes unchanged. + neutral: bool, /// Smoothed controls start at the next settings instead of gliding from defaults. fresh: bool, } @@ -103,6 +112,8 @@ impl Module { meter_in: HarmonicMeter::new(rate, 85.), meter_out: HarmonicMeter::new(rate, 85.), sounding: false, + fallback: Fallback::default(), + neutral: false, fresh: true, }; m.set_settings(settings)?; @@ -129,6 +140,8 @@ impl Module { self.pitch = PitchFollower::default(); self.hz = 0.; self.sounding = false; + self.fallback.reset(); + self.neutral = false; for ch in &mut self.state { for f in ch.iter_mut() { f.reset(); @@ -143,6 +156,9 @@ impl Module { fn targets(&self) -> [f32; BANDS] { let s = &self.settings; let mut t = [0.; BANDS]; + if self.neutral { + return t; + } for k in 1..=s.harmonics as usize { t[k - 1] = s.effective(k); t[HARMONICS + k - 1] = s.between; @@ -187,6 +203,7 @@ impl Module { sample_rate: self.rate, input: *self.meter_in.levels(), output: *self.meter_out.levels(), + neutral: self.neutral, ..AnalyzerFrame::default() }; if self.sounding && self.hz > 0. { @@ -211,8 +228,9 @@ impl Tracked for Module { }; self.pitch.event(self.settings.snap, e); } - fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] { - self.sounding = sounding; + fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] { + self.sounding = note.sure; + self.neutral = self.fallback.neutral(note); let first = self.hz == 0.; let (hz, jump) = self.pitch.step(self.hz_coef); let first = first && jump; @@ -238,7 +256,7 @@ impl Tracked for Module { 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 sounding { self.hz } else { 0. }; + let hz = if note.sure { self.hz } else { 0. }; let (dry, wet) = if self.channels == 2 { (0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1])) } else { @@ -286,7 +304,7 @@ impl Engine { fn set_tracker(&mut self, s: &Settings) { self.lookahead .tracker - .set(s.source == Source::Midi, s.gate, s.smooth > 0.); + .set(s.pitch.source(s.source.mode(), s.gate, s.smooth > 0.)); } pub fn reset(&mut self) { self.lookahead.reset(); diff --git a/plugins/lathe/core/src/settings.rs b/plugins/lathe/core/src/settings.rs index c738cd1..b8177ac 100644 --- a/plugins/lathe/core/src/settings.rs +++ b/plugins/lathe/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Gains in dB, times in milliseconds. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; /// Editable harmonics: the fundamental is harmonic 1. @@ -13,12 +14,33 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// Gain per harmonic, index 0 = fundamental. pub gains: [f32; HARMONICS], /// dB per octave of harmonic number, added on top of `gains`. Harmonic 1 is the pivot. @@ -45,6 +67,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), gains: [0.; HARMONICS], tilt: 0., odd_even: 0., @@ -78,6 +101,7 @@ impl Settings { if !finite { return Err("non-finite setting"); } + self.pitch.validate()?; let ok = self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g)) && (-12.0..=12.).contains(&self.tilt) && (-12.0..=12.).contains(&self.odd_even) diff --git a/plugins/lathe/core/tests/dsp.rs b/plugins/lathe/core/tests/dsp.rs index ad8e3a8..1a46188 100644 --- a/plugins/lathe/core/tests/dsp.rs +++ b/plugins/lathe/core/tests/dsp.rs @@ -137,3 +137,44 @@ fn settings_are_validated_and_serialise() { assert!(e.set_settings(bad).is_err()); assert_eq!(e.settings(), s); } + +#[test] +fn note_mode_cuts_the_set_notes_harmonics() { + let mut s = Settings::default(); + s.gains[2] = -12.; + s.source = Source::Note; + s.pitch.note = 29; + let hz = 43.65; + let x = saw(hz, 1., 0.5); + let (mut e, l) = engine(s); + let y = render(&mut e, &x); + let a = harmonics(&x[..x.len() - l], hz); + let b = harmonics(&y[l..], hz); + assert!((b[2] - a[2] + 12.).abs() < 0.7, "{a:?} {b:?}"); + assert!((b[1] - a[1]).abs() < 1.); +} + +#[test] +fn unsure_tracking_passes_the_audio_through() { + let mut s = Settings::default(); + s.gains[0] = -12.; + s.gains[1] = -12.; + s.gains[2] = -12.; + let (mut e, l) = engine(s); + // A clear note, then noise detection cannot read. + let mut x = saw(55., 0.5, 0.5); + let mut seed = 7u32; + for _ in 0..RATE as usize { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + x.push((seed as f32 / u32::MAX as f32 - 0.5) * 0.5); + } + let y = render(&mut e, &x); + let tail = (RATE * 1.0) as usize; + let mut worst = 0f32; + for i in tail..x.len() { + worst = worst.max((y[i] - x[i - l]).abs()); + } + assert!(worst < 1e-3, "{worst}"); +} diff --git a/plugins/lathe/plugin/Cargo.lock b/plugins/lathe/plugin/Cargo.lock index a181418..30594ff 100644 --- a/plugins/lathe/plugin/Cargo.lock +++ b/plugins/lathe/plugin/Cargo.lock @@ -309,11 +309,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] @@ -864,6 +876,7 @@ name = "lathe" version = "0.2.0" dependencies = [ "clap-sys", + "common-params", "crossbeam-queue", "lathe-core", "lathe-ui", diff --git a/plugins/lathe/plugin/Cargo.toml b/plugins/lathe/plugin/Cargo.toml index 46e76ba..06b8027 100644 --- a/plugins/lathe/plugin/Cargo.toml +++ b/plugins/lathe/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } lathe-core = { path = "../core" } +common-params = { path = "../../common/params" } lathe-ui = { path = "../ui" } crossbeam-queue = "0.3" diff --git a/plugins/lathe/plugin/src/params.rs b/plugins/lathe/plugin/src/params.rs index db97a95..a78df70 100644 --- a/plugins/lathe/plugin/src/params.rs +++ b/plugins/lathe/plugin/src/params.rs @@ -1,3 +1,4 @@ +use common_params::PitchParams; use lathe_core::{Settings, Source, HARMONICS}; use nih_plug::prelude::*; use nih_plug_egui::EguiState; @@ -10,6 +11,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam { FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit) @@ -26,6 +47,8 @@ pub struct LatheParams { pub gains: RwLock<[f32; HARMONICS]>, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "tilt"] pub tilt: FloatParam, #[id = "odd-even"] @@ -56,6 +79,7 @@ impl Default for LatheParams { editor: EguiState::from_size(lathe_ui::WIDTH, lathe_ui::HEIGHT), gains: RwLock::new(d.gains), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), tilt: linear("Tilt", d.tilt, -12., 12., " dB/oct"), odd_even: linear("Odd/Even", d.odd_even, -12., 12., " dB"), between: linear("Between", d.between, -24., 12., " dB"), @@ -83,10 +107,8 @@ impl LatheParams { pub fn settings(&self, last: &[f32; HARMONICS]) -> Settings { let gains = self.gains.try_read().map(|g| *g).unwrap_or(*last); Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), gains: gains.map(|g| { if g.is_finite() { g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX) @@ -174,13 +196,10 @@ impl LatheParams { } } if new.source != old.source { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; setter.begin_set_parameter(&self.source); - setter.set_parameter(&self.source, v); + setter.set_parameter(&self.source, new.source.into()); setter.end_set_parameter(&self.source); } + self.pitch.apply(setter, &old.pitch, &new.pitch); } } diff --git a/plugins/lathe/ui/src/lib.rs b/plugins/lathe/ui/src/lib.rs index bd4f516..103e67f 100644 --- a/plugins/lathe/ui/src/lib.rs +++ b/plugins/lathe/ui/src/lib.rs @@ -277,13 +277,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { settings.bypass = !settings.bypass; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); body(ui, settings, state); ui.separator(); @@ -326,6 +322,7 @@ fn note_panel( caption(ui, "NOTE"); let (name, hz) = match frame.note { Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)), + _ if frame.neutral => ("?".into(), "unsure: passing through".into()), _ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)), _ => ("—".into(), "no note yet".into()), }; @@ -340,10 +337,7 @@ fn note_panel( RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., - match settings.source { - Source::Audio => "detecting pitch", - Source::Midi => "following MIDI", - } + pitch_status(settings.source.mode(), &settings.pitch) )) .size(12.) .color(MUTED), diff --git a/plugins/patina/plugin/Cargo.lock b/plugins/patina/plugin/Cargo.lock index 24734e6..2684768 100644 --- a/plugins/patina/plugin/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -309,11 +309,15 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/planish/core/src/lib.rs b/plugins/planish/core/src/lib.rs index ed0b7dd..1c72331 100644 --- a/plugins/planish/core/src/lib.rs +++ b/plugins/planish/core/src/lib.rs @@ -34,6 +34,8 @@ pub const ATTACK_MS: [f32; BANDS] = [30., 15., 8., 4.]; pub const RELEASE_MS: [f32; BANDS] = [200., 160., 120., 90.]; /// Filter coefficients are recomputed this often, in samples. const RETUNE: usize = 16; +/// A new note slides the band edges over about this long instead of snapping them. +const EDGE_MS: f32 = 6.; const Q: f32 = std::f32::consts::FRAC_1_SQRT_2; const SILENT: f32 = -120.; @@ -176,6 +178,9 @@ pub struct Engine { pitch: PitchFollower, hz: f32, hz_coef: f32, + edge_coef: f32, + /// The edges are sliding to a new note. + sliding: bool, countdown: usize, edges: [f32; BANDS - 1], coefs: Coefs, @@ -226,6 +231,8 @@ impl Engine { pitch: PitchFollower::default(), hz: 0., hz_coef: 1., + edge_coef: coefficient(rate, EDGE_MS / 3.), + sliding: false, countdown: 0, edges: [0.; BANDS - 1], coefs: Coefs { @@ -264,7 +271,7 @@ impl Engine { 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.); + .set(s.pitch.source(s.source.mode(), s.gate, s.smooth > 0.)); self.hz_coef = coefficient(self.rate, s.smooth / 3.); if s.time != self.settings.time || self.fresh { let scale = s.time * 0.01; @@ -296,6 +303,7 @@ impl Engine { self.levels = [SILENT; BANDS]; self.gains = [0.; BANDS]; self.countdown = 0; + self.sliding = false; self.fresh = true; } pub fn note_on(&mut self, note: u8) { @@ -346,9 +354,23 @@ impl Engine { self.delay_at = (self.delay_at + 1) % len; let y = self.delay[self.delay_at]; + // Unsure tracking sends no events, so the edges hold the last sure note. A new note + // slides them instead of snapping, so the bands do not pump on note changes. let (hz, jump) = self.pitch.step(self.hz_coef); - self.hz = hz; - if jump || self.countdown == 0 { + let first = self.hz == 0.; + if jump && !first { + self.sliding = true; + } + if self.sliding { + self.hz += (hz - self.hz) * self.edge_coef; + if (hz - self.hz).abs() < 1e-3 * hz { + self.hz = hz; + self.sliding = false; + } + } else { + self.hz = hz; + } + if (jump && first) || self.countdown == 0 { self.retune(); self.countdown = RETUNE; } @@ -404,7 +426,7 @@ impl Engine { sample_rate: self.rate, ..AnalyzerFrame::default() }; - if self.tracker.sounding() && self.hz > 0. { + if self.tracker.active() && self.hz > 0. { f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8); } f diff --git a/plugins/planish/core/src/settings.rs b/plugins/planish/core/src/settings.rs index f075847..1ff315f 100644 --- a/plugins/planish/core/src/settings.rs +++ b/plugins/planish/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; /// Bands: the fundamental, low harmonics, mid harmonics and the top. @@ -15,12 +16,33 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// Highest harmonic in the bottom band. 1 keeps the fundamental on its own. pub low: u32, /// Highest harmonic in the second band. @@ -54,6 +76,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), low: 1, mid: 4, high: 16, @@ -73,6 +96,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let finite = [ self.depth, self.time, diff --git a/plugins/planish/plugin/Cargo.lock b/plugins/planish/plugin/Cargo.lock index 13d4e70..e9f42b8 100644 --- a/plugins/planish/plugin/Cargo.lock +++ b/plugins/planish/plugin/Cargo.lock @@ -309,11 +309,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] @@ -1443,6 +1455,7 @@ name = "planish" version = "0.1.0" dependencies = [ "clap-sys", + "common-params", "crossbeam-queue", "nih_plug", "nih_plug_egui", diff --git a/plugins/planish/plugin/Cargo.toml b/plugins/planish/plugin/Cargo.toml index e7177cb..0a064ad 100644 --- a/plugins/planish/plugin/Cargo.toml +++ b/plugins/planish/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } planish-core = { path = "../core" } +common-params = { path = "../../common/params" } planish-ui = { path = "../ui" } crossbeam-queue = "0.3" diff --git a/plugins/planish/plugin/src/params.rs b/plugins/planish/plugin/src/params.rs index 64abbd9..14db16e 100644 --- a/plugins/planish/plugin/src/params.rs +++ b/plugins/planish/plugin/src/params.rs @@ -1,3 +1,4 @@ +use common_params::PitchParams; use nih_plug::prelude::*; use nih_plug_egui::EguiState; use planish_core::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX}; @@ -10,6 +11,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam { FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit) @@ -26,6 +47,8 @@ pub struct PlanishParams { pub editor: Arc, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "depth"] pub depth: FloatParam, #[id = "time"] @@ -68,6 +91,7 @@ impl Default for PlanishParams { Self { editor: EguiState::from_size(planish_ui::WIDTH, planish_ui::HEIGHT), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), depth: linear("Depth", d.depth, 0., 100., " %"), time: linear("Time", d.time, 10., 1000., " %"), upward: linear("Upward", d.upward, 0., 100., " %"), @@ -92,10 +116,8 @@ impl PlanishParams { pub fn settings(&self) -> Settings { let gains = [&self.gain_1, &self.gain_2, &self.gain_3, &self.gain_4]; Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), low: self.low.value() as u32, mid: self.mid.value() as u32, high: self.high.value() as u32, @@ -180,13 +202,10 @@ impl PlanishParams { } } if new.source != old.source { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; setter.begin_set_parameter(&self.source); - setter.set_parameter(&self.source, v); + setter.set_parameter(&self.source, new.source.into()); setter.end_set_parameter(&self.source); } + self.pitch.apply(setter, &old.pitch, &new.pitch); } } diff --git a/plugins/planish/ui/src/lib.rs b/plugins/planish/ui/src/lib.rs index 7e60570..e91337c 100644 --- a/plugins/planish/ui/src/lib.rs +++ b/plugins/planish/ui/src/lib.rs @@ -358,13 +358,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { settings.bypass = !settings.bypass; } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); ui.add_space(6.); let frame = state.analyzer; @@ -418,10 +414,7 @@ fn note_panel( RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., - match settings.source { - Source::Audio => "detecting pitch", - Source::Midi => "following MIDI", - } + pitch_status(settings.source.mode(), &settings.pitch) )) .size(12.) .color(MUTED), diff --git a/plugins/quench/plugin/Cargo.lock b/plugins/quench/plugin/Cargo.lock index 0c34360..13999eb 100644 --- a/plugins/quench/plugin/Cargo.lock +++ b/plugins/quench/plugin/Cargo.lock @@ -309,11 +309,15 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/plugins/temper/core/src/lib.rs b/plugins/temper/core/src/lib.rs index b1bf5c0..73c71e3 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::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig}; +use common_dsp::tracker::{Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig}; use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi}; pub use settings::{Curve, Settings, Source}; @@ -381,8 +381,10 @@ impl Tracked for Module { self.pitch.event(false, e); self.level.event(e); } - fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] { - self.sounding = sounding; + fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] { + // Unsure tracking sends no retune, so Floor, Push and Bright hold their last sure + // tuning; only the readout drops the note. + self.sounding = note.sure; self.run(y) } } @@ -424,7 +426,7 @@ impl Engine { fn set_tracker(&mut self, s: &Settings) { self.lookahead .tracker - .set(s.source == Source::Midi, s.gate, true); + .set(s.pitch.source(s.source.mode(), s.gate, true)); } pub fn reset(&mut self) { self.lookahead.reset(); diff --git a/plugins/temper/core/src/settings.rs b/plugins/temper/core/src/settings.rs index da0220c..245709f 100644 --- a/plugins/temper/core/src/settings.rs +++ b/plugins/temper/core/src/settings.rs @@ -1,4 +1,5 @@ //! Host-independent controls. Levels in dB, harmonic positions as multiples of the fundamental. +use common_dsp::tracker::{Pitch, PitchMode}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -8,6 +9,24 @@ pub enum Source { Audio, /// Notes come from MIDI note input. Midi, + /// Note starts are detected, the pitch is the set note: nothing is guessed. + Note, +} +impl Source { + pub fn mode(self) -> PitchMode { + match self { + Source::Audio => PitchMode::Audio, + Source::Midi => PitchMode::Midi, + Source::Note => PitchMode::Note, + } + } + pub fn from_mode(mode: PitchMode) -> Self { + match mode { + PitchMode::Audio => Source::Audio, + PitchMode::Midi => Source::Midi, + PitchMode::Note => Source::Note, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -58,6 +77,9 @@ impl Curve { #[serde(deny_unknown_fields)] pub struct Settings { pub source: Source, + /// Note mode's note, the key lock and Follow bends. + #[serde(default)] + pub pitch: Pitch, /// Gain into the shaper after level normalisation. pub drive: f32, pub curve: Curve, @@ -85,6 +107,7 @@ impl Default for Settings { fn default() -> Self { Self { source: Source::Audio, + pitch: Pitch::default(), drive: 12., curve: Curve::Soft, bias: 0., @@ -103,6 +126,7 @@ impl Default for Settings { } impl Settings { pub fn validate(&self) -> Result<(), &'static str> { + self.pitch.validate()?; let finite = [ self.drive, self.bias, diff --git a/plugins/temper/plugin/Cargo.lock b/plugins/temper/plugin/Cargo.lock index b742d82..3cf5456 100644 --- a/plugins/temper/plugin/Cargo.lock +++ b/plugins/temper/plugin/Cargo.lock @@ -309,11 +309,23 @@ dependencies = [ [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "common-params" +version = "0.1.0" +dependencies = [ + "common-dsp", + "nih_plug", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] @@ -1737,6 +1749,7 @@ name = "temper" version = "0.2.0" dependencies = [ "clap-sys", + "common-params", "crossbeam-queue", "nih_plug", "nih_plug_egui", diff --git a/plugins/temper/plugin/Cargo.toml b/plugins/temper/plugin/Cargo.toml index 4c588f0..51a4410 100644 --- a/plugins/temper/plugin/Cargo.toml +++ b/plugins/temper/plugin/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } temper-core = { path = "../core" } +common-params = { path = "../../common/params" } temper-ui = { path = "../ui" } crossbeam-queue = "0.3" diff --git a/plugins/temper/plugin/src/params.rs b/plugins/temper/plugin/src/params.rs index 245a955..efbff80 100644 --- a/plugins/temper/plugin/src/params.rs +++ b/plugins/temper/plugin/src/params.rs @@ -1,3 +1,4 @@ +use common_params::PitchParams; use nih_plug::prelude::*; use nih_plug_egui::EguiState; use std::sync::Arc; @@ -10,6 +11,26 @@ pub enum SourceParam { #[id = "midi"] #[name = "MIDI"] Midi, + #[id = "note"] + Note, +} +impl From for Source { + fn from(s: SourceParam) -> Self { + match s { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + SourceParam::Note => Source::Note, + } + } +} +impl From for SourceParam { + fn from(s: Source) -> Self { + match s { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + Source::Note => SourceParam::Note, + } + } } #[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] pub enum CurveParam { @@ -33,6 +54,8 @@ pub struct TemperParams { pub editor: Arc, #[id = "source"] pub source: EnumParam, + #[nested(group = "Pitch")] + pub pitch: PitchParams, #[id = "curve"] pub curve: EnumParam, #[id = "drive"] @@ -66,6 +89,7 @@ impl Default for TemperParams { Self { editor: EguiState::from_size(temper_ui::WIDTH, temper_ui::HEIGHT), source: EnumParam::new("Pitch source", SourceParam::Audio), + pitch: PitchParams::default(), curve: EnumParam::new("Curve", CurveParam::Soft), drive: linear("Drive", d.drive, 0., 36., " dB"), bias: linear("Bias", d.bias, 0., 100., " %"), @@ -108,10 +132,8 @@ impl TemperParams { let floor = self.floor.value(); let bright = self.bright.value(); Settings { - source: match self.source.value() { - SourceParam::Audio => Source::Audio, - SourceParam::Midi => Source::Midi, - }, + source: self.source.value().into(), + pitch: self.pitch.pitch(), curve: curve_to(self.curve.value()), drive: self.drive.value(), bias: self.bias.value(), @@ -195,13 +217,10 @@ impl TemperParams { setter.end_set_parameter(&self.curve); } if new.source != old.source { - let v = match new.source { - Source::Audio => SourceParam::Audio, - Source::Midi => SourceParam::Midi, - }; setter.begin_set_parameter(&self.source); - setter.set_parameter(&self.source, v); + setter.set_parameter(&self.source, new.source.into()); setter.end_set_parameter(&self.source); } + self.pitch.apply(setter, &old.pitch, &new.pitch); } } diff --git a/plugins/temper/ui/src/lib.rs b/plugins/temper/ui/src/lib.rs index 7f2428d..0530c36 100644 --- a/plugins/temper/ui/src/lib.rs +++ b/plugins/temper/ui/src/lib.rs @@ -235,13 +235,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } 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.)); - if r.clicked() { - settings.source = source; - } - } - caption(ui, "PITCH FROM"); + let mut mode = settings.source.mode(); + pitch_from(ui, &mut mode, &mut settings.pitch); + settings.source = Source::from_mode(mode); }); body(ui, settings, state); ui.separator(); @@ -315,10 +311,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) { RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., - match settings.source { - Source::Audio => "detecting pitch", - Source::Midi => "following MIDI", - } + pitch_status(settings.source.mode(), &settings.pitch) )) .size(12.) .color(MUTED), diff --git a/plugins/weld/plugin/Cargo.lock b/plugins/weld/plugin/Cargo.lock index 754073a..720c87b 100644 --- a/plugins/weld/plugin/Cargo.lock +++ b/plugins/weld/plugin/Cargo.lock @@ -306,10 +306,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "common-dsp" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ] diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index f3f2d26..b666a20 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -123,11 +123,15 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "common-dsp" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "common-ui" version = "0.1.0" dependencies = [ + "common-dsp", "egui", ]