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

Generated
+1
View File
@@ -304,6 +304,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
"serde_json",
+10
View File
@@ -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
+23 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 420 KiB

+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)
+1
View File
@@ -416,6 +416,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
]