diff --git a/Cargo.lock b/Cargo.lock index 121bd91..80a45a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", "serde_json", diff --git a/docs/patina/CHANGELOG.md b/docs/patina/CHANGELOG.md index 9146d59..ba7032d 100644 --- a/docs/patina/CHANGELOG.md +++ b/docs/patina/CHANGELOG.md @@ -1,5 +1,15 @@ # Patina changelog +## Unreleased + +- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and + remembers, over about four seconds, which bumps stay in Hz and which follow the + note. Bumps that follow the note (a harmonic the bass is built on) are cut + less; resonances fixed in Hz (formant, comb and filter rings) are still cut. + At 0 % Patina works exactly as before. The display shows the note and marks + the held-back region with a blue strip. +- Add the **Bass line** preset (Tone guard 80 %, Low 150 Hz). + ## 0.3.0 - Change the CLAP ID to `io.narl.bass-foundry.patina`. Hosts see this as a new diff --git a/docs/patina/README.md b/docs/patina/README.md index 7724db9..df03ff8 100644 --- a/docs/patina/README.md +++ b/docs/patina/README.md @@ -33,6 +33,27 @@ cuts, then use Delta to hear what is removed. Restrict Low/High frequency to kee the source’s body out of reach. Attack and Release set how quickly cuts grow and recover; Transient guard briefly relaxes cuts on attacks to keep punch. +### Tone guard for bass lines + +On a bass line, a bump can mean two things. A resonance fixed in Hz (a formant, +a comb or filter ring) stays at the same frequency on every note: that is what +Patina should cut. A bump that follows the note (a harmonic the patch is built +on, or a Lathe boost) moves with every note: cutting it changes the patch +differently on each note. + +**Tone guard** tells them apart. Patina detects the played note (30–500 Hz) +and remembers, over about four seconds, how high each bump was in Hz and how +high it was relative to the note. A bump that has stayed with the note more +than in Hz gets less cut; at 100 % none. A resonance fixed in Hz is cut as +before. It needs a few different notes before it can decide: on one held note +both look the same and Patina cuts as normal. The **NOTE** readout shows what +Patina follows, and a blue strip on top of the plot shows where cuts are held +back. Start from the **Bass line** preset. At 0 % Tone guard is off. + +Measured on a saw line F1–F2 with Low at 150 Hz: a +12 dB boost on harmonic 6 +is cut by 5.8 dB without Tone guard and by 0.4 dB at 100 %; a formant at 1.5 kHz +is cut by 5.8 dB either way. + Double-click the plot to add one of four focus points. Drag a point up to cut more in that region, down to protect it. Wheel changes its width. Range handles on the plot drag the processing limits. @@ -76,7 +97,8 @@ The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstructi the envelope/reference detector above, bounded soft-knee cuts, and temporal smoothing. Original bin phases are retained. This is not a minimum-phase filter bank, a live monitoring effect, a limiter, or an automatic separation of tone and -unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1. +unwanted resonance. No external sidechain, Learn or M/S. The pitch detector +serves Tone guard only. ## Build and offline evaluation diff --git a/docs/patina/assets/editor.png b/docs/patina/assets/editor.png index 3a0ea4c..f240349 100644 Binary files a/docs/patina/assets/editor.png and b/docs/patina/assets/editor.png differ diff --git a/plugins/catalog.json b/plugins/catalog.json index a07541b..4c53977 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -26,6 +26,7 @@ }, "packages": [ "common-ui", + "common-dsp", "patina-core", "patina-ui", "patina-render" diff --git a/plugins/patina/core/Cargo.toml b/plugins/patina/core/Cargo.toml index 21a25bf..e63a5dd 100644 --- a/plugins/patina/core/Cargo.toml +++ b/plugins/patina/core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" license = "ISC" [dependencies] +common-dsp = { path = "../../common/dsp" } rustfft = "6.4" serde = { version = "1", features = ["derive"] } diff --git a/plugins/patina/core/src/lib.rs b/plugins/patina/core/src/lib.rs index 3e3f292..08f6ad1 100644 --- a/plugins/patina/core/src/lib.rs +++ b/plugins/patina/core/src/lib.rs @@ -1,5 +1,7 @@ //! Independently designed spectral attenuation. No host or GUI dependencies. +mod pitch; mod settings; +use pitch::PitchProbe; use rustfft::{num_complex::Complex32, Fft, FftPlanner}; pub use settings::{focus_offset, knee, Focus, Settings}; use std::{f32::consts::PI, sync::Arc}; @@ -22,6 +24,14 @@ const CLIP_DB: f32 = 2.; const EMPTY_DB: f32 = 24.; /// Steepest spectral tilt the reference follows, in dB per octave. const MAX_TILT: f32 = 12.; +/// Tone guard memory: bump heights are averaged over about this many seconds. +const TONE_MEMORY: f32 = 4.; +/// Tone guard starts to protect a bump this much (as a share of its height) more consistent +/// relative to the note than in Hz, and protects it fully at `TONE_FULL`. +const TONE_START: f32 = 0.2; +const TONE_FULL: f32 = 0.6; +/// Bumps lower than this (dB) do not count for the tone guard comparison. +const TONE_MIN_DB: f32 = 1.; fn grid_step() -> f32 { 1000f32.log2() / (GRID - 1) as f32 } @@ -112,6 +122,10 @@ pub struct AnalyzerFrame { pub reference: [f32; GRID], pub reduction: [f32; GRID], pub focus: [f32; GRID], + /// Share of the cut that Tone guard holds back, 0–1. + pub tone: [f32; GRID], + /// Fundamental the Tone guard follows, Hz; 0 when none. + pub pitch: f32, pub input_peak: f32, pub output_peak: f32, pub guard: f32, @@ -127,6 +141,8 @@ impl Default for AnalyzerFrame { reference: [-120.; GRID], reduction: [0.; GRID], focus: [0.; GRID], + tone: [0.; GRID], + pitch: 0., input_peak: 0., output_peak: 0., guard: 0., @@ -139,6 +155,10 @@ impl Default for AnalyzerFrame { pub fn frequency(index: usize) -> f32 { 20. * 1000f32.powf(index as f32 / (GRID - 1) as f32) } +/// Grid position of a frequency, fractional. +pub fn cell(hz: f32) -> f32 { + (hz.max(20.) / 20.).log2() / 1000f32.log2() * (GRID - 1) as f32 +} #[derive(Clone, Copy, Default)] pub struct Monitor { pub delta: bool, @@ -173,6 +193,12 @@ pub struct Engine { envelope_radius: usize, reference_radius: usize, settings: Settings, + /// Tone guard: pitch of the analysis window and two slow maps of bump height, one fixed + /// in Hz and one relative to the fundamental (index = cell + GRID − fundamental cell). + probe: PitchProbe, + fixed: [f32; GRID], + relative: [f32; 2 * GRID], + tone: [f32; GRID], frame: AnalyzerFrame, fresh: bool, fast: f32, @@ -276,6 +302,10 @@ impl Engine { envelope_radius: 0, reference_radius: 0, settings: Settings::default(), + probe: PitchProbe::new(rate), + fixed: [0.; GRID], + relative: [0.; 2 * GRID], + tone: [0.; GRID], frame: AnalyzerFrame::default(), fresh: false, fast: 0., @@ -344,6 +374,10 @@ impl Engine { } self.reduction.fill(0.); self.power = [[0.; GRID]; 2]; + self.probe.reset(); + self.fixed.fill(0.); + self.relative.fill(0.); + self.tone.fill(0.); self.pos = 0; self.hop_pos = 0; self.fast = 0.; @@ -403,6 +437,14 @@ impl Engine { ((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.); self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp()); self.sample_power += energy; + if self.settings.tone_guard > 0. { + let mono = if self.channels == 2 { + 0.5 * (self.input[0][self.pos] + self.input[1][self.pos]) + } else { + self.input[0][self.pos] + }; + self.probe.push(mono); + } self.pos = (self.pos + 1) % self.n; self.hop_pos += 1; if self.hop_pos == self.hop { @@ -433,6 +475,26 @@ impl Engine { let mut clipped = [0f32; GRID]; let mut weight = [0f32; GRID]; let mut content = [false; GRID]; + let mut bumps = [0f32; GRID]; + // Tone guard: protect bumps that have followed the note more than they stayed in Hz. + let guard = self.settings.tone_guard / 100.; + let pitch = if guard > 0. && awake { + self.probe.estimate() + } else { + 0. + }; + let shift = cell(pitch).round() as usize; + for i in 0..GRID { + self.tone[i] = if pitch > 0. { + let rel = self.relative[i + GRID - shift]; + let fixed = self.fixed[i]; + let score = (rel - fixed) / rel.max(fixed).max(TONE_MIN_DB); + let t = ((score - TONE_START) / (TONE_FULL - TONE_START)).clamp(0., 1.); + t * t * (3. - 2. * t) * guard + } else { + 0. + }; + } for c in 0..self.channels { for i in 0..self.n { self.spectra[c][i] = @@ -524,15 +586,27 @@ impl Engine { let excess = level - reference[i] + self.curve[i] - threshold; // Only real content can ring: leakage far below nearby // material is never cut. - let target = if awake && content[i] && level > -130. { - (knee(excess, KNEE) * SLOPE).min(self.settings.depth) + let live = awake && content[i] && level > -130.; + let target = if live { + (knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i]) } else { 0. }; + if live { + bumps[i] = bumps[i].max(level - reference[i]); + } // Linked stereo: the stronger channel decides. self.target[i] = self.target[i].max(target); } } + if pitch > 0. { + let alpha = 1. - (-frame_time / TONE_MEMORY).exp(); + for i in 0..GRID { + self.fixed[i] += (bumps[i] - self.fixed[i]) * alpha; + let r = &mut self.relative[i + GRID - shift]; + *r += (bumps[i] - *r) * alpha; + } + } let cut = self.target; smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1); let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp(); @@ -553,6 +627,7 @@ impl Engine { * (1. - self.protect * self.settings.transient_guard / 100.)) .clamp(0., self.settings.depth); self.frame.focus[i] = self.curve[i]; + self.frame.tone[i] = self.tone[i]; } for c in 0..self.channels { for k in 0..=self.n / 2 { @@ -581,6 +656,7 @@ impl Engine { self.frame.input_peak = self.in_peak; self.frame.output_peak = self.out_peak; self.frame.guard = self.protect; + self.frame.pitch = pitch; self.frame.sample_rate = self.rate; self.frame.latency = self.n as u32; self.frame.channels = self.channels; diff --git a/plugins/patina/core/src/pitch.rs b/plugins/patina/core/src/pitch.rs new file mode 100644 index 0000000..aeac5a3 --- /dev/null +++ b/plugins/patina/core/src/pitch.rs @@ -0,0 +1,80 @@ +//! Fundamental of the newest input, for Tone guard. Allocation-free after `new`. +use common_dsp::{hz_to_midi, Biquad, Yin}; + +const MIN_HZ: f32 = 30.; +const MAX_HZ: f32 = 500.; +/// Lowpass in front of detection, removing upper harmonics that confuse YIN. +const DETECT_LOWPASS: f32 = 1000.; +const DETECT_RATE: f32 = 6000.; +const CLARITY: f32 = 0.6; +/// Two estimates in a row must agree this closely (semitones) to count. +const AGREE: f32 = 0.5; + +pub(crate) struct PitchProbe { + lowpass: [Biquad; 2], + decimate: usize, + count: usize, + rate: f32, + ring: Vec, + at: usize, + scratch: Vec, + yin: Yin, + last: f32, +} +impl PitchProbe { + pub fn new(rate: f32) -> Self { + let decimate = (rate / DETECT_RATE).round().max(1.) as usize; + let detect_rate = rate / decimate as f32; + let max_lag = (detect_rate / MIN_HZ).ceil() as usize; + let window = 2 * max_lag + 3; + let q = std::f32::consts::FRAC_1_SQRT_2; + Self { + lowpass: [Biquad::lowpass(rate, DETECT_LOWPASS, q); 2], + decimate, + count: 0, + rate: detect_rate, + ring: vec![0.; window], + at: 0, + scratch: vec![0.; window], + yin: Yin::new(max_lag + 2), + last: 0., + } + } + pub fn push(&mut self, x: f32) { + let d = self.lowpass[0].process(x); + let d = self.lowpass[1].process(d); + self.count += 1; + if self.count >= self.decimate { + self.count = 0; + self.ring[self.at] = d; + self.at = (self.at + 1) % self.ring.len(); + } + } + /// Fundamental in Hz of the newest input, or 0 when it is unclear or still changing. + pub fn estimate(&mut self) -> f32 { + let n = self.ring.len(); + for i in 0..n { + self.scratch[i] = self.ring[(self.at + i) % n]; + } + let hz = match self.yin.estimate(&self.scratch, self.rate, MIN_HZ, MAX_HZ) { + Some((hz, clarity)) if clarity >= CLARITY => hz, + _ => 0., + }; + let stable = + hz > 0. && self.last > 0. && (hz_to_midi(hz) - hz_to_midi(self.last)).abs() < AGREE; + self.last = hz; + if stable { + hz + } else { + 0. + } + } + pub fn reset(&mut self) { + for f in &mut self.lowpass { + f.reset(); + } + self.ring.fill(0.); + self.count = 0; + self.last = 0.; + } +} diff --git a/plugins/patina/core/src/settings.rs b/plugins/patina/core/src/settings.rs index 72a457f..1058b3c 100644 --- a/plugins/patina/core/src/settings.rs +++ b/plugins/patina/core/src/settings.rs @@ -37,6 +37,10 @@ pub struct Settings { pub output: f32, pub bypass: bool, pub focus: [Focus; 4], + /// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note + /// (a harmonic the sound is built on) while bumps fixed in Hz are still cut. + #[serde(default)] + pub tone_guard: f32, } impl Default for Settings { fn default() -> Self { @@ -53,6 +57,7 @@ impl Default for Settings { output: 0., bypass: false, focus: [Focus::default(); 4], + tone_guard: 0., } } } @@ -69,6 +74,7 @@ impl Settings { (self.high, 20., 20000.), (self.mix, 0., 100.), (self.output, -12., 12.), + (self.tone_guard, 0., 100.), ] { if !v.is_finite() || !(low..=high).contains(&v) { return Err("Invalid resonance control value"); @@ -102,11 +108,12 @@ impl Settings { pub fn threshold(&self) -> f32 { 0.5 + self.selectivity * 0.05 } - pub const PRESETS: [&'static str; 4] = [ + pub const PRESETS: [&'static str; 5] = [ "Gentle cleanup", "Vocal harshness", "Snare ring", "Bright synth", + "Bass line", ]; pub fn preset(index: usize) -> Self { let mut s = Self::default(); @@ -136,6 +143,13 @@ impl Settings { s.sharpness = 25.; s.selectivity = 45.; } + 4 => { + s.low = 150.; + s.high = 12000.; + s.sharpness = 50.; + s.selectivity = 45.; + s.tone_guard = 80.; + } _ => {} } s diff --git a/plugins/patina/core/tests/dsp.rs b/plugins/patina/core/tests/dsp.rs index 720b82a..f1e7a31 100644 --- a/plugins/patina/core/tests/dsp.rs +++ b/plugins/patina/core/tests/dsp.rs @@ -359,3 +359,113 @@ fn high_sample_rates_detect_the_same_bump() { assert!(near >= 4. && away < 1., "{rate}: {near} / {away}"); } } + +/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2). +const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31]; +/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`. +fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec { + let rate = 48000.; + let n = (rate * seconds) as usize; + let mut x = vec![0f32; n]; + for k in 1..=(16000. / f0) as usize { + let f = k as f32 * f0; + let a = 0.08 * shape(k, f) / k as f32; + let w = TAU * f / rate; + for (i, v) in x.iter_mut().enumerate() { + *v += a * (w * i as f32 + k as f32).sin(); + } + } + x +} +/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz` +/// (largest within ±3 grid cells) over the second half of the last note. +fn tone_cut( + guard: f32, + notes: &[f32], + rounds: usize, + shape: &dyn Fn(usize, f32) -> f32, + hz: &dyn Fn(f32) -> f32, +) -> f32 { + let mut e = Engine::new(48000., 1).unwrap(); + e.set_settings(Settings { + tone_guard: guard, + low: 150., + ..Default::default() + }) + .unwrap(); + let last = *notes.last().unwrap(); + let at = patina_core::cell(hz(last)).round() as usize; + let (mut sum, mut count) = (0., 0); + for round in 0..rounds { + for &f0 in notes { + let x = bass(f0, 0.4, shape); + let measure = round + 1 == rounds && f0 == last; + for (i, v) in x.iter().enumerate() { + e.process_sample([*v, 0.], Monitor::default()); + if let Some(frame) = e.take_analyzer() { + if measure && i > x.len() / 2 { + sum += frame.reduction[at - 3..=at + 3] + .iter() + .fold(0f32, |m, v| m.max(*v)); + count += 1; + } + } + } + } + } + sum / count as f32 +} +fn harmonic_six(k: usize, _: f32) -> f32 { + if k == 6 { + 4. + } else { + 1. + } +} +fn formant(_: usize, f: f32) -> f32 { + let z = (f / 1500.).log2() / 0.15; + 1. + 3. * (-z * z).exp() +} +#[test] +fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() { + let h6 = |f0: f32| 6. * f0; + let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6); + let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6); + assert!( + plain > 4., + "without guard the boosted harmonic is cut: {plain}" + ); + assert!( + guarded < 0.25 * plain, + "guard keeps the harmonic: {guarded} vs {plain}" + ); +} +#[test] +fn tone_guard_still_cuts_resonances_fixed_in_hz() { + let at = |_: f32| 1500.; + let plain = tone_cut(0., &LINE, 3, &formant, &at); + let guarded = tone_cut(100., &LINE, 3, &formant, &at); + assert!(plain > 4., "the formant is cut: {plain}"); + assert!( + guarded > 0.85 * plain, + "guard still cuts it: {guarded} vs {plain}" + ); +} +#[test] +fn tone_guard_needs_different_notes_to_tell_bumps_apart() { + let h6 = |f0: f32| 6. * f0; + let one = [LINE[2]]; + let plain = tone_cut(0., &one, 10, &harmonic_six, &h6); + let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6); + assert!( + (guarded - plain).abs() < 0.5, + "one held note gives no evidence: {guarded} vs {plain}" + ); +} +#[test] +fn old_settings_without_tone_guard_still_load() { + let mut json = serde_json::to_value(Settings::default()).unwrap(); + json.as_object_mut().unwrap().remove("tone_guard"); + let s: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(s.tone_guard, 0.); +} diff --git a/plugins/patina/core/tests/realtime.rs b/plugins/patina/core/tests/realtime.rs index c71d456..8a016d3 100644 --- a/plugins/patina/core/tests/realtime.rs +++ b/plugins/patina/core/tests/realtime.rs @@ -35,6 +35,7 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator; fn processing_automation_and_analysis_do_not_allocate_or_free() { let mut engine = Engine::new(48000., 2).unwrap(); let mut settings = Settings::preset(1); + settings.tone_guard = 80.; engine.set_settings(settings).unwrap(); CHECK.with(|v| v.set(true)); for i in 0..48000 { @@ -45,7 +46,12 @@ fn processing_automation_and_analysis_do_not_allocate_or_free() { settings.low = 120. + (i % 100) as f32; engine.set_settings(settings).unwrap(); } - std::hint::black_box(engine.process_sample([0.3, -0.2], Monitor { delta: i > 24000 })); + // A pitched input, so Tone guard estimates the pitch and updates its maps. + let x = 0.3 + * (i as f32 * std::f32::consts::TAU * 55. / 48000.) + .sin() + .signum(); + std::hint::black_box(engine.process_sample([x, -x], Monitor { delta: i > 24000 })); std::hint::black_box(engine.take_analyzer()); } engine.reset(); diff --git a/plugins/patina/plugin/Cargo.lock b/plugins/patina/plugin/Cargo.lock index 5f9aa23..7dc696a 100644 --- a/plugins/patina/plugin/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -306,6 +306,10 @@ dependencies = [ "memchr", ] +[[package]] +name = "common-dsp" +version = "0.1.0" + [[package]] name = "common-ui" version = "0.1.0" @@ -1434,6 +1438,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", ] diff --git a/plugins/patina/plugin/src/params.rs b/plugins/patina/plugin/src/params.rs index 94c432e..e8f3dad 100644 --- a/plugins/patina/plugin/src/params.rs +++ b/plugins/patina/plugin/src/params.rs @@ -62,6 +62,8 @@ pub struct PatinaParams { pub mix: FloatParam, #[id = "output"] pub output: FloatParam, + #[id = "tone-guard"] + pub tone_guard: FloatParam, #[id = "bypass"] pub bypass: BoolParam, #[nested(array, group = "Focus")] @@ -82,6 +84,7 @@ impl Default for PatinaParams { high: log("High frequency", d.high, 21., 20000., " Hz"), mix: linear("Mix", 100., 0., 100., " %"), output: linear("Output", 0., -12., 12., " dB"), + tone_guard: linear("Tone guard", d.tone_guard, 0., 100., " %"), bypass: BoolParam::new("Bypass", false).make_bypass(), focus: std::array::from_fn(|_| FocusParams::default()), } @@ -116,6 +119,7 @@ impl PatinaParams { &self.focus[3].frequency, &self.focus[3].width, &self.focus[3].sensitivity, + &self.tone_guard, ] } pub fn settings(&self) -> Settings { @@ -137,6 +141,7 @@ impl PatinaParams { width: self.focus[i].width.value(), sensitivity: self.focus[i].sensitivity.value(), }), + tone_guard: self.tone_guard.value(), }; // Independent automation of bounds may cross them; preserve a valid effective range. if s.low >= s.high { @@ -145,8 +150,8 @@ impl PatinaParams { s } } -/// Host float parameters: ten main controls, then four per focus point. -pub const COUNT: usize = 26; +/// Host float parameters: ten main controls, four per focus point, then Tone guard. +pub const COUNT: usize = 27; pub fn values(s: &Settings) -> [f32; COUNT] { let mut v = [0.; COUNT]; v[..10].copy_from_slice(&[ @@ -169,5 +174,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] { s.focus[i].sensitivity, ]); } + v[26] = s.tone_guard; v } diff --git a/plugins/patina/ui/src/lib.rs b/plugins/patina/ui/src/lib.rs index 554ac50..91e1d78 100644 --- a/plugins/patina/ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -43,7 +43,9 @@ impl State { self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]); self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]); self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]); + self.analyzer.tone[i] += 0.5 * (frame.tone[i] - self.analyzer.tone[i]); } + self.analyzer.pitch = frame.pitch; self.analyzer.focus = frame.focus; self.analyzer.input_peak = frame.input_peak; self.analyzer.output_peak = frame.output_peak; @@ -59,6 +61,21 @@ impl State { } } } +/// Note name of a frequency, or a dash when there is none. +fn note_name(hz: f32) -> String { + if hz <= 0. { + return "—".into(); + } + const NAMES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let n = (69. + 12. * (hz / 440.).log2()).round() as i32; + format!( + "{}{}", + NAMES[n.rem_euclid(12) as usize], + n.div_euclid(12) - 1 + ) +} fn db(x: f32) -> f32 { 20. * x.max(1e-6).log10() } @@ -99,6 +116,7 @@ fn control( let defaults = Settings::default(); *value = match name { "Transient guard" => defaults.transient_guard, + "Tone guard" => defaults.tone_guard, "Attack" => defaults.attack, "Release" => defaults.release, "Low frequency" => defaults.low, @@ -170,7 +188,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { ui.separator(); egui::ComboBox::from_id_salt("resonance-preset") .selected_text( - (0..4) + (0..Settings::PRESETS.len()) .find(|i| *settings == Settings::preset(*i)) .map_or("Custom", |i| Settings::PRESETS[i]), ) @@ -209,6 +227,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { format!("{:>3.0} %", state.analyzer.guard * 100.), MUTED, ); + if settings.tone_guard > 0. { + readout(ui, "NOTE", note_name(state.analyzer.pitch), FOCUS); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { caption( ui, @@ -368,9 +389,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { panel(ui, |ui| { ui.set_width(ui.available_width()); egui::Grid::new("engine-controls") - .num_columns(3) + .num_columns(4) .spacing([16., 12.]) - .min_col_width((ui.available_width() - 32.) / 3.) + .min_col_width((ui.available_width() - 48.) / 4.) .show(ui, |ui| { control( ui, @@ -402,6 +423,16 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { "Relaxes cuts on attacks to keep punch. Turn down to catch short rings.", state, ); + control( + ui, + "Tone guard", + &mut settings.tone_guard, + 0. ..=100., + " %", + false, + "Keeps bumps that follow the played note (the harmonics a bass is built on) and still cuts rings fixed in Hz. Needs a few different notes to tell them apart.", + state, + ); ui.end_row(); }); ui.add_space(6.); @@ -622,6 +653,23 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { fill[1..=GRID].to_vec(), Stroke::new(1.6_f32, ACCENT), )); + // Tone guard: a strip along the top where cuts are held back because the bump follows the note. + if settings.tone_guard > 0. { + let mut strip = egui::Mesh::default(); + for i in 0..GRID { + let xx = x(frequency(i)); + let alpha = (state.analyzer.tone[i].clamp(0., 1.) * 170.) as u8; + let color = Color32::from_rgba_unmultiplied(FOCUS.r(), FOCUS.g(), FOCUS.b(), alpha); + strip.colored_vertex(Pos2::new(xx, r.top()), color); + strip.colored_vertex(Pos2::new(xx, r.top() + 8.), color); + if i > 0 { + let j = (i * 2) as u32; + strip.add_triangle(j - 2, j - 1, j); + strip.add_triangle(j - 1, j, j + 1); + } + } + p.add(egui::Shape::mesh(strip)); + } let center = r.center().y; let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65; let points: Vec<_> = (0..GRID) diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index 0877146..7610c80 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -416,6 +416,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", ]