feat(patina): Tone guard for bass lines

Patina detects the played note (30-500 Hz) and remembers, over about
4 s, how high each spectral bump is at a fixed frequency and relative
to the note. Bumps that follow the note (a boosted harmonic the patch
is built on) get less cut; bumps fixed in Hz (formants, room modes)
are cut as before. On a saw line F1-F2 with harmonic 6 boosted, the
cut on that harmonic drops from 5.78 dB to 0.44 dB at 100 %, while a
fixed formant at 1.5 kHz stays at 5.78 dB. One held note is cut as
normal, since the two cases cannot be told apart.

- New parameter tone-guard (0-100 %, default 0), appended at the end;
  0 % is the old behaviour and old settings load unchanged.
- New Bass line preset.
- Editor: Tone guard control, NOTE readout and a strip over the
  spectrum where cuts are held back.
- No added latency, no allocation on the audio thread; patina-core now
  depends on common-dsp.
This commit is contained in:
nvrl committed 2026-11-06 12:24:11 +01:00
1 parent 6d868a63c4
commit 53bc26d14d
15 files changed
+391 -10

No files matched your search

+1
View File
@@ -26,6 +26,7 @@
},
"packages": [
"common-ui",
"common-dsp",
"patina-core",
"patina-ui",
"patina-render"
+1
View File
@@ -5,6 +5,7 @@ edition = "2021"
license = "ISC"
[dependencies]
common-dsp = { path = "../../common/dsp" }
rustfft = "6.4"
serde = { version = "1", features = ["derive"] }
+78 -2
View File
@@ -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;
+80
View File
@@ -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<f32>,
at: usize,
scratch: Vec<f32>,
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.;
}
}
+15 -1
View File
@@ -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
+110
View File
@@ -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<f32> {
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.);
}
+7 -1
View File
@@ -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();
+5
View File
@@ -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",
]
+8 -2
View File
@@ -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
}
+51 -3
View File
@@ -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)