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:
1 parent
6d868a63c4
commit
53bc26d14d
15 files changed
+391
-10
No files matched your search
Generated
+1
@@ -304,6 +304,7 @@ dependencies = [
|
||||
name = "patina-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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 |
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"common-dsp",
|
||||
"patina-core",
|
||||
"patina-ui",
|
||||
"patina-render"
|
||||
|
||||
@@ -5,6 +5,7 @@ edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[dependencies]
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
rustfft = "6.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Generated
+5
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Generated
+1
@@ -416,6 +416,7 @@ dependencies = [
|
||||
name = "patina-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"rustfft",
|
||||
"serde",
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user