feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
Tester feedback asked for a threshold and a ratio. Each band now cuts like a small compressor: Threshold (dB above the local floor) replaces Selectivity, Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut out. Mix and Output move to the slider panel so the detection panel fits. common-ui gains rotary_text for knobs with custom value text.
This commit is contained in:
1 parent
7b989f7685
commit
9ca4e4f9dd
13 files changed
+568
-102
No files matched your search
+13
-1
@@ -237,7 +237,19 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
|
|||||||
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
|
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
|
||||||
only. Drift (planned for 0.3).
|
only. Drift (planned for 0.3).
|
||||||
- **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S
|
- **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S
|
||||||
processing.
|
processing. Threshold, Ratio and Soft/Hard knee from tester feedback (done,
|
||||||
|
unreleased 0.5.0). Next, from the same feedback round:
|
||||||
|
- An absolute level gate (dBFS) so quiet passages are never cut, if the
|
||||||
|
tester meant a loudness threshold rather than a prominence threshold.
|
||||||
|
- Narrow rings on dense or noisy material are under-read: a +12 dB ring
|
||||||
|
(Q 25) on noise reads only 3–6 dB above the floor, so it is cut by 1–3 dB.
|
||||||
|
Two causes, measured: the joined-tops envelope lifts the floor to the noise
|
||||||
|
peaks (about 5 dB above the noise mean), and envelope smoothing loses about
|
||||||
|
3 dB on a narrow ring. A floor that follows Sharpness (1/3–2 octaves) was
|
||||||
|
tried and rejected: it lost a resonant lowpass, absorbed broad bumps and cut
|
||||||
|
plain harmonic tones. Better candidates: join tops only across deep gaps
|
||||||
|
(harmonic spacing), or a mean-based floor for noise-like regions.
|
||||||
|
- A finer analysis grid (1024 cells) for narrow rings at high frequencies.
|
||||||
- **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option
|
- **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option
|
||||||
that keeps the delay spread under a limit, for plucky layers. Alignment slots
|
that keeps the delay spread under a limit, for plucky layers. Alignment slots
|
||||||
per note or per switch-up. The External
|
per note or per switch-up. The External
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Patina changelog
|
# Patina changelog
|
||||||
|
|
||||||
|
## Unreleased (0.5.0)
|
||||||
|
|
||||||
|
- Each band now works like a small compressor. Add **Ratio** (1:1 to ∞:1,
|
||||||
|
default ∞:1): low ratios leave small bumps nearly alone and still cut big ones
|
||||||
|
hard. ∞:1 is the old behaviour.
|
||||||
|
- Replace Selectivity (%) with **Threshold** (0–18 dB above the local floor,
|
||||||
|
default 3 dB, which equals the old default of 50 %).
|
||||||
|
- Add a **Soft/Hard knee** switch. Soft widens the knee from 3 dB to 6 dB;
|
||||||
|
Hard uses a 1 dB knee and half the Attack time.
|
||||||
|
- Show the gain curve (bump height in, cut out) under the knobs.
|
||||||
|
- Mix and Output move to the slider panel under the plot.
|
||||||
|
- Retune the Vocal harshness, Snare ring, Bright synth and Bass line presets
|
||||||
|
with Ratio and more Depth.
|
||||||
|
- Breaking: FL Studio projects saved earlier load Threshold, Ratio and Knee at
|
||||||
|
their defaults. Settings files for `patina-render` with `selectivity` still
|
||||||
|
load and convert.
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and
|
- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and
|
||||||
|
|||||||
+28
-13
@@ -1,7 +1,7 @@
|
|||||||
# Patina
|
# Patina
|
||||||
|
|
||||||
Patina smooths a sound’s rough surface without erasing its character. Formerly
|
Patina smooths a sound’s rough surface without erasing its character. Formerly
|
||||||
**Resonance Compressor**; current version **0.4.0**.
|
**Resonance Compressor**; current version **0.4.0** (0.5.0 unreleased).
|
||||||
|
|
||||||
A mixing effect that finds harsh, ringing regions as they happen and pulls them
|
A mixing effect that finds harsh, ringing regions as they happen and pulls them
|
||||||
down, while plain harmonic tone passes. Independently designed in Rust; no claim
|
down, while plain harmonic tone passes. Independently designed in Rust; no claim
|
||||||
@@ -20,18 +20,30 @@ redistributed packages.
|
|||||||
|
|
||||||
## Work with it
|
## Work with it
|
||||||
|
|
||||||
Three controls decide what happens:
|
Each band works like a small compressor. It compares its level with the local floor
|
||||||
|
around it and cuts only the part that sticks out:
|
||||||
|
|
||||||
- **Depth** is the largest cut, in dB. 0 dB does nothing.
|
- **Depth** is the largest cut per band, in dB. 0 dB does nothing.
|
||||||
- **Sharpness** sets what counts as a resonance. Low values follow broad,
|
- **Sharpness** sets what counts as a resonance. Low values follow broad,
|
||||||
smooth bumps (honk, boxiness). High values target narrow rings and whistles.
|
smooth bumps (honk, boxiness). High values target narrow rings and whistles.
|
||||||
- **Selectivity** sets how far a bump must stand out before it is cut. Low
|
- **Threshold** is how far (dB) a band must rise above its neighbours before it
|
||||||
values treat any bump; high values treat only the worst.
|
is cut. Low values treat any bump; high values treat only the worst.
|
||||||
|
- **Ratio** is how hard a bump above the threshold is cut. At n:1 a bump ends
|
||||||
|
1/n as far above the threshold as it started. Low ratios leave small bumps
|
||||||
|
nearly alone and still cut big ones; ∞:1 flattens every bump to the
|
||||||
|
threshold (this is how Patina worked before 0.5).
|
||||||
|
- **Knee**: Soft fades cuts in around the threshold. Hard starts them right at
|
||||||
|
the threshold and uses half the Attack time.
|
||||||
|
|
||||||
Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show
|
The small graph under the knobs shows the result: bump height above the floor
|
||||||
cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep
|
in, cut out, with the threshold and the Depth limit.
|
||||||
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.
|
Start with Depth around 6 dB. Raise Threshold until only the harsh parts show
|
||||||
|
cuts, then use Delta to hear what is removed. To hit big resonances hard and
|
||||||
|
leave small ones alone, raise Depth to 12–20 dB and set Ratio to 2:1–4:1.
|
||||||
|
Restrict Low/High frequency to keep 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
|
### Tone guard for bass lines
|
||||||
|
|
||||||
@@ -61,7 +73,7 @@ on the plot drag the processing limits.
|
|||||||
On the plot, the grey fill is the measured input. The bright line is what the
|
On the plot, the grey fill is the measured input. The bright line is what the
|
||||||
detector hears (its smoothed spectral envelope); the dim line is what it expects
|
detector hears (its smoothed spectral envelope); the dim line is what it expects
|
||||||
there. Accent-coloured cuts appear where the bright line rises above the dim one
|
there. Accent-coloured cuts appear where the bright line rises above the dim one
|
||||||
by more than Selectivity allows. The blue curve is the focus/protect shape.
|
by more than Threshold allows. The blue curve is the focus/protect shape.
|
||||||
|
|
||||||
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
|
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
|
||||||
then return to normal monitoring to decide whether the music improved.
|
then return to normal monitoring to decide whether the music improved.
|
||||||
@@ -83,8 +95,9 @@ window size persist; Delta does not.
|
|||||||
4. The reference is a robust local straight-line fit over 2–3 octaves: spectral
|
4. The reference is a robust local straight-line fit over 2–3 octaves: spectral
|
||||||
tilt is expected, bumps cannot raise their own reference, and empty regions
|
tilt is expected, bumps cannot raise their own reference, and empty regions
|
||||||
(below a fundamental, beyond a filter cutoff) are ignored.
|
(below a fundamental, beyond a filter cutoff) are ignored.
|
||||||
5. Envelope minus reference, above the Selectivity threshold, becomes the cut,
|
5. Envelope minus reference is the band's height above its floor. Above
|
||||||
limited by Depth, smoothed with Attack/Release, and linked across stereo.
|
Threshold it becomes a cut through the Knee and Ratio curve, limited by
|
||||||
|
Depth, smoothed with Attack/Release, and linked across stereo.
|
||||||
|
|
||||||
## Timing and limits
|
## Timing and limits
|
||||||
|
|
||||||
@@ -142,7 +155,9 @@ The CLAP ID is now `io.narl.bass-foundry.patina` (earlier builds used
|
|||||||
`io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so
|
`io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so
|
||||||
add it again in projects saved with an earlier build. Version 0.2 replaced
|
add it again in projects saved with an earlier build. Version 0.2 replaced
|
||||||
Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth,
|
Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth,
|
||||||
Sharpness, and Selectivity. Delete the old plugin file, place `Patina.clap`, and
|
Sharpness, and Selectivity; version 0.5 replaced Selectivity with Threshold
|
||||||
|
(dB), Ratio and Knee, so projects saved earlier load Threshold at its default.
|
||||||
|
Delete the old plugin file, place `Patina.clap`, and
|
||||||
rescan FL Studio.
|
rescan FL Studio.
|
||||||
|
|
||||||
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
|
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 417 KiB |
@@ -113,6 +113,58 @@ pub fn rotary(
|
|||||||
unit: &str,
|
unit: &str,
|
||||||
size: f32,
|
size: f32,
|
||||||
color: Color32,
|
color: Color32,
|
||||||
|
) -> egui::Response {
|
||||||
|
let response = dial(ui, value, range.clone(), default, size, color);
|
||||||
|
dial_caption(ui, name, size);
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(value)
|
||||||
|
.range(range)
|
||||||
|
.speed(0.1)
|
||||||
|
.suffix(unit),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
/// A rotary whose value field shows custom text, for values that are not a plain number
|
||||||
|
/// with a unit (a ratio, a mapped knob position).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn rotary_text(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
name: &str,
|
||||||
|
value: &mut f32,
|
||||||
|
range: std::ops::RangeInclusive<f32>,
|
||||||
|
default: f32,
|
||||||
|
size: f32,
|
||||||
|
color: Color32,
|
||||||
|
format: impl Fn(f32) -> String,
|
||||||
|
parse: impl Fn(&str) -> Option<f32>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let response = dial(ui, value, range.clone(), default, size, color);
|
||||||
|
dial_caption(ui, name, size);
|
||||||
|
let speed = (*range.end() - *range.start()) as f64 / 200.;
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(value)
|
||||||
|
.range(range)
|
||||||
|
.speed(speed)
|
||||||
|
.custom_formatter(|v, _| format(v as f32))
|
||||||
|
.custom_parser(|s| parse(s).map(f64::from)),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
fn dial_caption(ui: &mut egui::Ui, name: &str, size: f32) {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(name.to_uppercase())
|
||||||
|
.size(if size > 100. { 14. } else { 12. })
|
||||||
|
.color(MUTED),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/// The knob itself: drag, keys, double-click reset, and drawing.
|
||||||
|
fn dial(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
value: &mut f32,
|
||||||
|
range: std::ops::RangeInclusive<f32>,
|
||||||
|
default: f32,
|
||||||
|
size: f32,
|
||||||
|
color: Color32,
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
let low = *range.start();
|
let low = *range.start();
|
||||||
let high = *range.end();
|
let high = *range.end();
|
||||||
@@ -212,17 +264,6 @@ pub fn rotary(
|
|||||||
if response.has_focus() {
|
if response.has_focus() {
|
||||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||||
}
|
}
|
||||||
ui.label(
|
|
||||||
RichText::new(name.to_uppercase())
|
|
||||||
.size(if size > 100. { 14. } else { 12. })
|
|
||||||
.color(MUTED),
|
|
||||||
);
|
|
||||||
ui.add(
|
|
||||||
egui::DragValue::new(value)
|
|
||||||
.range(range)
|
|
||||||
.speed(0.1)
|
|
||||||
.suffix(unit),
|
|
||||||
);
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
/// Vertical gradient rectangle, used for panel faces.
|
/// Vertical gradient rectangle, used for panel faces.
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ mod pitch;
|
|||||||
mod settings;
|
mod settings;
|
||||||
use pitch::PitchProbe;
|
use pitch::PitchProbe;
|
||||||
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
|
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
|
||||||
pub use settings::{focus_offset, knee, Focus, Settings};
|
pub use settings::{focus_offset, knee, Focus, Knee, Settings, RATIO_INFINITE};
|
||||||
use std::{f32::consts::PI, sync::Arc};
|
use std::{f32::consts::PI, sync::Arc};
|
||||||
|
|
||||||
pub const GRID: usize = 512;
|
pub const GRID: usize = 512;
|
||||||
/// dB of cut per dB of prominence above the threshold.
|
|
||||||
const SLOPE: f32 = 1.5;
|
|
||||||
/// Fixed soft-knee width, in dB of prominence.
|
|
||||||
const KNEE: f32 = 3.;
|
|
||||||
/// Neighboring peaks closer than this (Hz, or one octave) are joined by a
|
/// Neighboring peaks closer than this (Hz, or one octave) are joined by a
|
||||||
/// straight line in dB, so harmonic gaps do not count as expected level.
|
/// straight line in dB, so harmonic gaps do not count as expected level.
|
||||||
const JOIN_HZ: f32 = 800.;
|
const JOIN_HZ: f32 = 800.;
|
||||||
@@ -463,7 +459,9 @@ impl Engine {
|
|||||||
let fall = 1. - (-frame_time / 0.04).exp();
|
let fall = 1. - (-frame_time / 0.04).exp();
|
||||||
let awake = self.sample_power / self.hop as f32 > 1e-9;
|
let awake = self.sample_power / self.hop as f32 > 1e-9;
|
||||||
self.sample_power = 0.;
|
self.sample_power = 0.;
|
||||||
let threshold = self.settings.threshold();
|
let threshold = self.settings.threshold;
|
||||||
|
let slope = self.settings.slope();
|
||||||
|
let knee_width = self.settings.knee_width();
|
||||||
self.target.fill(0.);
|
self.target.fill(0.);
|
||||||
self.frame.input.fill(-200.);
|
self.frame.input.fill(-200.);
|
||||||
self.frame.envelope.fill(-200.);
|
self.frame.envelope.fill(-200.);
|
||||||
@@ -588,7 +586,8 @@ impl Engine {
|
|||||||
// material is never cut.
|
// material is never cut.
|
||||||
let live = awake && content[i] && level > -130.;
|
let live = awake && content[i] && level > -130.;
|
||||||
let target = if live {
|
let target = if live {
|
||||||
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
|
(knee(excess, knee_width) * slope).min(self.settings.depth)
|
||||||
|
* (1. - self.tone[i])
|
||||||
} else {
|
} else {
|
||||||
0.
|
0.
|
||||||
};
|
};
|
||||||
@@ -609,7 +608,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
let cut = self.target;
|
let cut = self.target;
|
||||||
smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1);
|
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();
|
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack_time() * 0.001)).exp();
|
||||||
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
|
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
|
||||||
for i in 0..GRID {
|
for i in 0..GRID {
|
||||||
let target = self.target[i];
|
let target = self.target[i];
|
||||||
|
|||||||
@@ -19,15 +19,34 @@ impl Default for Focus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Shape of each band's gain curve around the threshold.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Knee {
|
||||||
|
/// Wide knee: cuts fade in gradually around the threshold.
|
||||||
|
#[default]
|
||||||
|
Soft,
|
||||||
|
/// Narrow knee and a faster attack: cuts start right at the threshold.
|
||||||
|
Hard,
|
||||||
|
}
|
||||||
|
/// Ratio at or above this value is treated as ∞:1.
|
||||||
|
pub const RATIO_INFINITE: f32 = 20.;
|
||||||
|
/// dB of cut per dB of detected prominence at ∞:1. The detector smooths a bump across
|
||||||
|
/// frequency and reads it lower than it is; this restores the true height.
|
||||||
|
const CALIBRATION: f32 = 1.5;
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(from = "Stored")]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper.
|
/// Largest cut in dB per band.
|
||||||
pub depth: f32,
|
pub depth: f32,
|
||||||
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
|
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
|
||||||
pub sharpness: f32,
|
pub sharpness: f32,
|
||||||
/// 0 % treats any bump; 100 % treats only the most prominent bumps.
|
/// How far (dB) a band must rise above its local floor before it is cut.
|
||||||
pub selectivity: f32,
|
pub threshold: f32,
|
||||||
|
/// Cut per dB above the threshold: at n:1 a bump ends 1/n as far above the threshold.
|
||||||
|
/// `RATIO_INFINITE` and above flatten bumps to the threshold.
|
||||||
|
pub ratio: f32,
|
||||||
|
pub knee: Knee,
|
||||||
pub attack: f32,
|
pub attack: f32,
|
||||||
pub release: f32,
|
pub release: f32,
|
||||||
pub transient_guard: f32,
|
pub transient_guard: f32,
|
||||||
@@ -39,15 +58,68 @@ pub struct Settings {
|
|||||||
pub focus: [Focus; 4],
|
pub focus: [Focus; 4],
|
||||||
/// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note
|
/// 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.
|
/// (a harmonic the sound is built on) while bumps fixed in Hz are still cut.
|
||||||
#[serde(default)]
|
|
||||||
pub tone_guard: f32,
|
pub tone_guard: f32,
|
||||||
}
|
}
|
||||||
|
/// Stored form. Settings saved before 0.5 have Selectivity (%) instead of Threshold, and no
|
||||||
|
/// Ratio, Knee or Tone guard.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Stored {
|
||||||
|
depth: f32,
|
||||||
|
sharpness: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
threshold: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
selectivity: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
ratio: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
knee: Knee,
|
||||||
|
attack: f32,
|
||||||
|
release: f32,
|
||||||
|
transient_guard: f32,
|
||||||
|
low: f32,
|
||||||
|
high: f32,
|
||||||
|
mix: f32,
|
||||||
|
output: f32,
|
||||||
|
bypass: bool,
|
||||||
|
focus: [Focus; 4],
|
||||||
|
#[serde(default)]
|
||||||
|
tone_guard: f32,
|
||||||
|
}
|
||||||
|
impl From<Stored> for Settings {
|
||||||
|
fn from(s: Stored) -> Self {
|
||||||
|
let d = Settings::default();
|
||||||
|
Self {
|
||||||
|
depth: s.depth,
|
||||||
|
sharpness: s.sharpness,
|
||||||
|
threshold: s
|
||||||
|
.threshold
|
||||||
|
.or(s.selectivity.map(|v| 0.5 + v * 0.05))
|
||||||
|
.unwrap_or(d.threshold),
|
||||||
|
ratio: s.ratio.unwrap_or(d.ratio),
|
||||||
|
knee: s.knee,
|
||||||
|
attack: s.attack,
|
||||||
|
release: s.release,
|
||||||
|
transient_guard: s.transient_guard,
|
||||||
|
low: s.low,
|
||||||
|
high: s.high,
|
||||||
|
mix: s.mix,
|
||||||
|
output: s.output,
|
||||||
|
bypass: s.bypass,
|
||||||
|
focus: s.focus,
|
||||||
|
tone_guard: s.tone_guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
depth: 6.,
|
depth: 6.,
|
||||||
sharpness: 40.,
|
sharpness: 40.,
|
||||||
selectivity: 50.,
|
threshold: 3.,
|
||||||
|
ratio: RATIO_INFINITE,
|
||||||
|
knee: Knee::Soft,
|
||||||
attack: 5.,
|
attack: 5.,
|
||||||
release: 80.,
|
release: 80.,
|
||||||
transient_guard: 30.,
|
transient_guard: 30.,
|
||||||
@@ -66,7 +138,8 @@ impl Settings {
|
|||||||
for (v, low, high) in [
|
for (v, low, high) in [
|
||||||
(self.depth, 0., 20.),
|
(self.depth, 0., 20.),
|
||||||
(self.sharpness, 0., 100.),
|
(self.sharpness, 0., 100.),
|
||||||
(self.selectivity, 0., 100.),
|
(self.threshold, 0., 18.),
|
||||||
|
(self.ratio, 1., RATIO_INFINITE),
|
||||||
(self.attack, 1., 200.),
|
(self.attack, 1., 200.),
|
||||||
(self.release, 10., 1000.),
|
(self.release, 10., 1000.),
|
||||||
(self.transient_guard, 0., 100.),
|
(self.transient_guard, 0., 100.),
|
||||||
@@ -104,9 +177,27 @@ impl Settings {
|
|||||||
pub fn reference_width(&self) -> f32 {
|
pub fn reference_width(&self) -> f32 {
|
||||||
(self.envelope_width() * 16.).clamp(2., 3.)
|
(self.envelope_width() * 16.).clamp(2., 3.)
|
||||||
}
|
}
|
||||||
/// Prominence threshold in dB above the local reference, from Selectivity.
|
/// dB of cut per dB of detected prominence above the threshold.
|
||||||
pub fn threshold(&self) -> f32 {
|
pub fn slope(&self) -> f32 {
|
||||||
0.5 + self.selectivity * 0.05
|
if self.ratio >= RATIO_INFINITE {
|
||||||
|
CALIBRATION
|
||||||
|
} else {
|
||||||
|
CALIBRATION * (1. - 1. / self.ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Soft-knee width in dB of prominence.
|
||||||
|
pub fn knee_width(&self) -> f32 {
|
||||||
|
match self.knee {
|
||||||
|
Knee::Soft => 6.,
|
||||||
|
Knee::Hard => 1.,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Attack time actually used: the hard knee grips twice as fast.
|
||||||
|
pub fn attack_time(&self) -> f32 {
|
||||||
|
match self.knee {
|
||||||
|
Knee::Soft => self.attack,
|
||||||
|
Knee::Hard => (self.attack * 0.5).max(0.5),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub const PRESETS: [&'static str; 5] = [
|
pub const PRESETS: [&'static str; 5] = [
|
||||||
"Gentle cleanup",
|
"Gentle cleanup",
|
||||||
@@ -121,18 +212,21 @@ impl Settings {
|
|||||||
1 => {
|
1 => {
|
||||||
s.low = 1500.;
|
s.low = 1500.;
|
||||||
s.high = 12000.;
|
s.high = 12000.;
|
||||||
s.depth = 8.;
|
s.depth = 10.;
|
||||||
s.sharpness = 55.;
|
s.sharpness = 55.;
|
||||||
s.selectivity = 40.;
|
s.threshold = 2.5;
|
||||||
|
s.ratio = 3.;
|
||||||
s.attack = 3.;
|
s.attack = 3.;
|
||||||
s.release = 60.;
|
s.release = 60.;
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
s.low = 250.;
|
s.low = 250.;
|
||||||
s.high = 6000.;
|
s.high = 6000.;
|
||||||
s.depth = 8.;
|
s.depth = 12.;
|
||||||
s.sharpness = 75.;
|
s.sharpness = 75.;
|
||||||
s.selectivity = 35.;
|
s.threshold = 2.5;
|
||||||
|
s.ratio = 6.;
|
||||||
|
s.knee = Knee::Hard;
|
||||||
s.attack = 10.;
|
s.attack = 10.;
|
||||||
s.release = 150.;
|
s.release = 150.;
|
||||||
s.transient_guard = 80.;
|
s.transient_guard = 80.;
|
||||||
@@ -140,14 +234,18 @@ impl Settings {
|
|||||||
3 => {
|
3 => {
|
||||||
s.low = 2000.;
|
s.low = 2000.;
|
||||||
s.high = 16000.;
|
s.high = 16000.;
|
||||||
|
s.depth = 9.;
|
||||||
s.sharpness = 25.;
|
s.sharpness = 25.;
|
||||||
s.selectivity = 45.;
|
s.threshold = 2.5;
|
||||||
|
s.ratio = 2.5;
|
||||||
}
|
}
|
||||||
4 => {
|
4 => {
|
||||||
s.low = 150.;
|
s.low = 150.;
|
||||||
s.high = 12000.;
|
s.high = 12000.;
|
||||||
|
s.depth = 8.;
|
||||||
s.sharpness = 50.;
|
s.sharpness = 50.;
|
||||||
s.selectivity = 45.;
|
s.threshold = 2.5;
|
||||||
|
s.ratio = 3.;
|
||||||
s.tone_guard = 80.;
|
s.tone_guard = 80.;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn depth_selectivity_and_focus_each_do_one_thing() {
|
fn depth_threshold_and_focus_each_do_one_thing() {
|
||||||
let run = |s: Settings| {
|
let run = |s: Settings| {
|
||||||
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
||||||
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
|
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
|
||||||
@@ -160,7 +160,7 @@ fn depth_selectivity_and_focus_each_do_one_thing() {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let picky = run(Settings {
|
let picky = run(Settings {
|
||||||
selectivity: 100.,
|
threshold: 5.5,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let mut protect = Settings::default();
|
let mut protect = Settings::default();
|
||||||
@@ -279,7 +279,7 @@ fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn soft_knee_and_presets_are_valid() {
|
fn soft_knee_and_presets_are_valid() {
|
||||||
for i in 0..4 {
|
for i in 0..Settings::PRESETS.len() {
|
||||||
Settings::preset(i).validate().unwrap();
|
Settings::preset(i).validate().unwrap();
|
||||||
}
|
}
|
||||||
assert_eq!(knee(-2., 3.), 0.);
|
assert_eq!(knee(-2., 3.), 0.);
|
||||||
@@ -469,3 +469,103 @@ fn old_settings_without_tone_guard_still_load() {
|
|||||||
let s: Settings = serde_json::from_value(json).unwrap();
|
let s: Settings = serde_json::from_value(json).unwrap();
|
||||||
assert_eq!(s.tone_guard, 0.);
|
assert_eq!(s.tone_guard, 0.);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cut at the bump for a resonance of `gain` dB (Q 4 at 3 kHz) on a 220 Hz saw.
|
||||||
|
fn bump_cut(s: Settings, gain: f32) -> f32 {
|
||||||
|
let mut bump = Peak::new(3000., 4., gain, 48000.);
|
||||||
|
let frame = settle(
|
||||||
|
Settings {
|
||||||
|
transient_guard: 0.,
|
||||||
|
..s
|
||||||
|
},
|
||||||
|
|i| bump.tick(saw(220., i, 48000.)),
|
||||||
|
);
|
||||||
|
peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ratio_spares_small_peaks_and_still_cuts_big_ones() {
|
||||||
|
let at = |ratio: f32, gain: f32| {
|
||||||
|
bump_cut(
|
||||||
|
Settings {
|
||||||
|
depth: 20.,
|
||||||
|
ratio,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
gain,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let (small_hard, big_hard) = (at(RATIO_INFINITE, 6.), at(RATIO_INFINITE, 18.));
|
||||||
|
let (small, big) = (at(2., 6.), at(2., 18.));
|
||||||
|
// At 2:1 each cut is about half the ∞:1 cut.
|
||||||
|
for (half, full) in [(small, small_hard), (big, big_hard)] {
|
||||||
|
assert!((half / full - 0.5).abs() < 0.15, "{half} vs {full}");
|
||||||
|
}
|
||||||
|
assert!(small < 1., "small bump cut by {small} dB at 2:1");
|
||||||
|
assert!(big > 4., "big bump cut only {big} dB at 2:1");
|
||||||
|
// Ratio between the two ends gives a cut between them.
|
||||||
|
let mid = at(4., 18.);
|
||||||
|
assert!(big < mid && mid < big_hard, "{big} < {mid} < {big_hard}");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn cut_grows_with_the_bump_until_depth() {
|
||||||
|
let s = Settings {
|
||||||
|
depth: 20.,
|
||||||
|
ratio: 3.,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cuts: Vec<f32> = [4., 8., 12., 18., 24.]
|
||||||
|
.iter()
|
||||||
|
.map(|g| bump_cut(s, *g))
|
||||||
|
.collect();
|
||||||
|
for w in cuts.windows(2) {
|
||||||
|
assert!(w[1] > w[0] + 0.5, "{cuts:?}");
|
||||||
|
}
|
||||||
|
let capped = bump_cut(Settings { depth: 4., ..s }, 24.);
|
||||||
|
assert!(capped <= 4.001 && capped > 3., "{capped}");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn threshold_moves_where_cuts_start() {
|
||||||
|
let low = bump_cut(Settings::default(), 9.);
|
||||||
|
let high = bump_cut(
|
||||||
|
Settings {
|
||||||
|
threshold: 9.,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
9.,
|
||||||
|
);
|
||||||
|
assert!(low > 2. && high < 0.5, "{low} / {high}");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn hard_knee_starts_cutting_closer_to_the_threshold() {
|
||||||
|
// A bump just under the threshold: the soft knee already cuts a little, the hard knee not.
|
||||||
|
let s = Settings {
|
||||||
|
threshold: 4.,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let soft = bump_cut(s, 6.);
|
||||||
|
let hard = bump_cut(
|
||||||
|
Settings {
|
||||||
|
knee: Knee::Hard,
|
||||||
|
..s
|
||||||
|
},
|
||||||
|
6.,
|
||||||
|
);
|
||||||
|
assert!(soft > hard + 0.2, "soft {soft} vs hard {hard}");
|
||||||
|
assert_eq!(knee(0.25, 1.), 0.28125);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn settings_from_before_threshold_still_load() {
|
||||||
|
let mut json = serde_json::to_value(Settings::default()).unwrap();
|
||||||
|
let map = json.as_object_mut().unwrap();
|
||||||
|
for key in ["threshold", "ratio", "knee", "tone_guard"] {
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
map.insert("selectivity".into(), 100.into());
|
||||||
|
let s: Settings = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.threshold, 5.5);
|
||||||
|
assert_eq!(s.ratio, RATIO_INFINITE);
|
||||||
|
assert_eq!(s.knee, Knee::Soft);
|
||||||
|
assert!(serde_json::from_str::<Settings>(r#"{"oops":1}"#).is_err());
|
||||||
|
let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
}
|
||||||
@@ -105,6 +105,15 @@ impl Plugin for Patina {
|
|||||||
state.gestures[i] = false;
|
state.gestures[i] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if edited.knee != before.knee {
|
||||||
|
let knee = match edited.knee {
|
||||||
|
patina_core::Knee::Soft => params::KneeParam::Soft,
|
||||||
|
patina_core::Knee::Hard => params::KneeParam::Hard,
|
||||||
|
};
|
||||||
|
setter.begin_set_parameter(¶ms.knee);
|
||||||
|
setter.set_parameter(¶ms.knee, knee);
|
||||||
|
setter.end_set_parameter(¶ms.knee);
|
||||||
|
}
|
||||||
if edited.bypass != before.bypass {
|
if edited.bypass != before.bypass {
|
||||||
setter.begin_set_parameter(¶ms.bypass);
|
setter.begin_set_parameter(¶ms.bypass);
|
||||||
setter.set_parameter(¶ms.bypass, edited.bypass);
|
setter.set_parameter(¶ms.bypass, edited.bypass);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use nih_plug::prelude::*;
|
use nih_plug::prelude::*;
|
||||||
use nih_plug_egui::EguiState;
|
use nih_plug_egui::EguiState;
|
||||||
use patina_core::{Focus, Settings};
|
use patina_core::{Focus, Knee, Settings, RATIO_INFINITE};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[derive(Params)]
|
#[derive(Params)]
|
||||||
pub struct FocusParams {
|
pub struct FocusParams {
|
||||||
@@ -38,6 +38,13 @@ impl Default for FocusParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum KneeParam {
|
||||||
|
#[id = "soft"]
|
||||||
|
Soft,
|
||||||
|
#[id = "hard"]
|
||||||
|
Hard,
|
||||||
|
}
|
||||||
#[derive(Params)]
|
#[derive(Params)]
|
||||||
pub struct PatinaParams {
|
pub struct PatinaParams {
|
||||||
#[persist = "editor"]
|
#[persist = "editor"]
|
||||||
@@ -46,8 +53,12 @@ pub struct PatinaParams {
|
|||||||
pub depth: FloatParam,
|
pub depth: FloatParam,
|
||||||
#[id = "sharpness"]
|
#[id = "sharpness"]
|
||||||
pub sharpness: FloatParam,
|
pub sharpness: FloatParam,
|
||||||
#[id = "selectivity"]
|
#[id = "threshold"]
|
||||||
pub selectivity: FloatParam,
|
pub threshold: FloatParam,
|
||||||
|
#[id = "ratio"]
|
||||||
|
pub ratio: FloatParam,
|
||||||
|
#[id = "knee"]
|
||||||
|
pub knee: EnumParam<KneeParam>,
|
||||||
#[id = "attack"]
|
#[id = "attack"]
|
||||||
pub attack: FloatParam,
|
pub attack: FloatParam,
|
||||||
#[id = "release"]
|
#[id = "release"]
|
||||||
@@ -76,7 +87,36 @@ impl Default for PatinaParams {
|
|||||||
editor: EguiState::from_size(1180, 760),
|
editor: EguiState::from_size(1180, 760),
|
||||||
depth: linear("Depth", d.depth, 0., 20., " dB"),
|
depth: linear("Depth", d.depth, 0., 20., " dB"),
|
||||||
sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"),
|
sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"),
|
||||||
selectivity: linear("Selectivity", d.selectivity, 0., 100., " %"),
|
threshold: linear("Threshold", d.threshold, 0., 18., " dB"),
|
||||||
|
ratio: FloatParam::new(
|
||||||
|
"Ratio",
|
||||||
|
d.ratio,
|
||||||
|
FloatRange::Skewed {
|
||||||
|
min: 1.,
|
||||||
|
max: RATIO_INFINITE,
|
||||||
|
factor: FloatRange::skew_factor(-1.5),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_value_to_string(Arc::new(ratio_text))
|
||||||
|
.with_string_to_value(Arc::new(|s| {
|
||||||
|
let s = s
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(":1")
|
||||||
|
.trim_end_matches(": 1")
|
||||||
|
.trim();
|
||||||
|
if s == "∞" || s.eq_ignore_ascii_case("inf") {
|
||||||
|
Some(RATIO_INFINITE)
|
||||||
|
} else {
|
||||||
|
s.parse().ok()
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
knee: EnumParam::new(
|
||||||
|
"Knee",
|
||||||
|
match d.knee {
|
||||||
|
Knee::Soft => KneeParam::Soft,
|
||||||
|
Knee::Hard => KneeParam::Hard,
|
||||||
|
},
|
||||||
|
),
|
||||||
attack: log("Attack", d.attack, 1., 200., " ms"),
|
attack: log("Attack", d.attack, 1., 200., " ms"),
|
||||||
release: log("Release", d.release, 10., 1000., " ms"),
|
release: log("Release", d.release, 10., 1000., " ms"),
|
||||||
transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"),
|
transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"),
|
||||||
@@ -95,7 +135,7 @@ impl PatinaParams {
|
|||||||
[
|
[
|
||||||
&self.depth,
|
&self.depth,
|
||||||
&self.sharpness,
|
&self.sharpness,
|
||||||
&self.selectivity,
|
&self.threshold,
|
||||||
&self.attack,
|
&self.attack,
|
||||||
&self.release,
|
&self.release,
|
||||||
&self.transient_guard,
|
&self.transient_guard,
|
||||||
@@ -120,13 +160,19 @@ impl PatinaParams {
|
|||||||
&self.focus[3].width,
|
&self.focus[3].width,
|
||||||
&self.focus[3].sensitivity,
|
&self.focus[3].sensitivity,
|
||||||
&self.tone_guard,
|
&self.tone_guard,
|
||||||
|
&self.ratio,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
pub fn settings(&self) -> Settings {
|
pub fn settings(&self) -> Settings {
|
||||||
let mut s = Settings {
|
let mut s = Settings {
|
||||||
depth: self.depth.value(),
|
depth: self.depth.value(),
|
||||||
sharpness: self.sharpness.value(),
|
sharpness: self.sharpness.value(),
|
||||||
selectivity: self.selectivity.value(),
|
threshold: self.threshold.value(),
|
||||||
|
ratio: self.ratio.value(),
|
||||||
|
knee: match self.knee.value() {
|
||||||
|
KneeParam::Soft => Knee::Soft,
|
||||||
|
KneeParam::Hard => Knee::Hard,
|
||||||
|
},
|
||||||
attack: self.attack.value(),
|
attack: self.attack.value(),
|
||||||
release: self.release.value(),
|
release: self.release.value(),
|
||||||
transient_guard: self.transient_guard.value(),
|
transient_guard: self.transient_guard.value(),
|
||||||
@@ -150,14 +196,22 @@ impl PatinaParams {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Host float parameters: ten main controls, four per focus point, then Tone guard.
|
/// Ratio as the host shows it: "4.0:1", and "∞:1" at the top of the range.
|
||||||
pub const COUNT: usize = 27;
|
fn ratio_text(v: f32) -> String {
|
||||||
|
if v >= RATIO_INFINITE {
|
||||||
|
"∞:1".into()
|
||||||
|
} else {
|
||||||
|
format!("{v:.1}:1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Host float parameters: ten main controls, four per focus point, Tone guard, then Ratio.
|
||||||
|
pub const COUNT: usize = 28;
|
||||||
pub fn values(s: &Settings) -> [f32; COUNT] {
|
pub fn values(s: &Settings) -> [f32; COUNT] {
|
||||||
let mut v = [0.; COUNT];
|
let mut v = [0.; COUNT];
|
||||||
v[..10].copy_from_slice(&[
|
v[..10].copy_from_slice(&[
|
||||||
s.depth,
|
s.depth,
|
||||||
s.sharpness,
|
s.sharpness,
|
||||||
s.selectivity,
|
s.threshold,
|
||||||
s.attack,
|
s.attack,
|
||||||
s.release,
|
s.release,
|
||||||
s.transient_guard,
|
s.transient_guard,
|
||||||
@@ -175,5 +229,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
v[26] = s.tone_guard;
|
v[26] = s.tone_guard;
|
||||||
|
v[27] = s.ratio;
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
|||||||
envelope
|
envelope
|
||||||
.params
|
.params
|
||||||
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
|
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
|
||||||
|
envelope
|
||||||
|
.params
|
||||||
|
.insert("threshold".into(), ParamValue::F32(6.));
|
||||||
|
envelope.params.insert("ratio".into(), ParamValue::F32(3.));
|
||||||
|
envelope
|
||||||
|
.params
|
||||||
|
.insert("knee".into(), ParamValue::String("hard".into()));
|
||||||
let encoded = serde_json::to_vec(&envelope).unwrap();
|
let encoded = serde_json::to_vec(&envelope).unwrap();
|
||||||
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
|
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
|
||||||
framed.extend_from_slice(&encoded);
|
framed.extend_from_slice(&encoded);
|
||||||
|
|||||||
+153
-40
@@ -2,7 +2,9 @@
|
|||||||
pub use common_ui::style;
|
pub use common_ui::style;
|
||||||
use common_ui::*;
|
use common_ui::*;
|
||||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||||
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
|
use patina_core::{
|
||||||
|
focus_offset, frequency, knee, AnalyzerFrame, Focus, Knee, Settings, GRID, RATIO_INFINITE,
|
||||||
|
};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub struct State {
|
pub struct State {
|
||||||
@@ -30,7 +32,7 @@ impl Default for State {
|
|||||||
gesture_active: false,
|
gesture_active: false,
|
||||||
gestures: [false; 30],
|
gestures: [false; 30],
|
||||||
last_audio: Instant::now(),
|
last_audio: Instant::now(),
|
||||||
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
help: "Set Depth, then raise Threshold until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
||||||
drag: None,
|
drag: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,66 @@ fn hint(response: egui::Response, state: &mut State, text: &str) {
|
|||||||
state.help = text.into();
|
state.help = text.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// The Ratio knob is square-law, so 1:1 to 6:1 gets half its travel.
|
||||||
|
fn ratio_position(ratio: f32) -> f32 {
|
||||||
|
((ratio - 1.) / (RATIO_INFINITE - 1.)).clamp(0., 1.).sqrt() * 100.
|
||||||
|
}
|
||||||
|
fn ratio_from_position(position: f32) -> f32 {
|
||||||
|
let p = (position / 100.).clamp(0., 1.);
|
||||||
|
1. + (RATIO_INFINITE - 1.) * p * p
|
||||||
|
}
|
||||||
|
fn ratio_text(ratio: f32) -> String {
|
||||||
|
if ratio >= RATIO_INFINITE {
|
||||||
|
"∞:1".into()
|
||||||
|
} else {
|
||||||
|
format!("{ratio:.1}:1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn parse_ratio(text: &str) -> Option<f32> {
|
||||||
|
let t = text.trim().trim_end_matches(":1").trim();
|
||||||
|
if t == "∞" || t.eq_ignore_ascii_case("inf") {
|
||||||
|
Some(RATIO_INFINITE)
|
||||||
|
} else {
|
||||||
|
t.parse::<f32>().ok().map(|r| r.clamp(1., RATIO_INFINITE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Gain curve of one band: prominence above the floor in, cut out. Shows Threshold, Knee,
|
||||||
|
/// Ratio and the Depth limit together.
|
||||||
|
fn transfer(ui: &mut egui::Ui, settings: &Settings) {
|
||||||
|
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 62.), Sense::hover());
|
||||||
|
let p = ui.painter();
|
||||||
|
p.rect_filled(rect, 3., SCREEN);
|
||||||
|
let r = rect.shrink2(Vec2::new(6., 6.));
|
||||||
|
const IN: f32 = 18.;
|
||||||
|
const OUT: f32 = 20.;
|
||||||
|
let x = |e: f32| r.left() + e / IN * r.width();
|
||||||
|
let y = |c: f32| r.top() + c.min(OUT) / OUT * r.height();
|
||||||
|
let t = settings.threshold;
|
||||||
|
p.line_segment(
|
||||||
|
[Pos2::new(x(t), r.top()), Pos2::new(x(t), r.bottom())],
|
||||||
|
Stroke::new(1.0_f32, DIM),
|
||||||
|
);
|
||||||
|
let d = settings.depth;
|
||||||
|
p.line_segment(
|
||||||
|
[Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))],
|
||||||
|
Stroke::new(1.0_f32, Color32::from_rgb(48, 50, 56)),
|
||||||
|
);
|
||||||
|
let points: Vec<Pos2> = (0..=90)
|
||||||
|
.map(|i| {
|
||||||
|
let e = IN * i as f32 / 90.;
|
||||||
|
let cut = (knee(e - t, settings.knee_width()) * settings.slope()).min(d);
|
||||||
|
Pos2::new(x(e), y(cut))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
p.add(egui::Shape::line(points, Stroke::new(1.5_f32, ACCENT)));
|
||||||
|
p.text(
|
||||||
|
r.right_top(),
|
||||||
|
egui::Align2::RIGHT_TOP,
|
||||||
|
"CUT PER BUMP HEIGHT",
|
||||||
|
FontId::proportional(10.),
|
||||||
|
MUTED,
|
||||||
|
);
|
||||||
|
}
|
||||||
fn note(ui: &mut egui::Ui, text: &str) {
|
fn note(ui: &mut egui::Ui, text: &str) {
|
||||||
ui.label(RichText::new(text).color(MUTED));
|
ui.label(RichText::new(text).color(MUTED));
|
||||||
}
|
}
|
||||||
@@ -122,6 +184,7 @@ fn control(
|
|||||||
"Low frequency" => defaults.low,
|
"Low frequency" => defaults.low,
|
||||||
"High frequency" => defaults.high,
|
"High frequency" => defaults.high,
|
||||||
"Mix" => defaults.mix,
|
"Mix" => defaults.mix,
|
||||||
|
"Output" => defaults.output,
|
||||||
_ => *value,
|
_ => *value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -249,7 +312,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
|||||||
let body_height = (ui.available_height() - 40.).max(480.);
|
let body_height = (ui.available_height() - 40.).max(480.);
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
panel(ui, |ui| {
|
panel(ui, |ui| {
|
||||||
ui.set_width(176.);
|
ui.set_width(236.);
|
||||||
ui.set_min_height(body_height - 28.);
|
ui.set_min_height(body_height - 28.);
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
caption(ui, "DETECTION");
|
caption(ui, "DETECTION");
|
||||||
@@ -262,12 +325,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
|||||||
0. ..=20.,
|
0. ..=20.,
|
||||||
defaults.depth,
|
defaults.depth,
|
||||||
" dB",
|
" dB",
|
||||||
108.,
|
88.,
|
||||||
ACCENT,
|
ACCENT,
|
||||||
);
|
);
|
||||||
hint(knob, state, "Largest cut. 0 dB does nothing.");
|
hint(knob, state, "Largest cut. 0 dB does nothing.");
|
||||||
ui.add_space(8.);
|
ui.add_space(8.);
|
||||||
ui.columns(2, |columns| {
|
ui.columns(3, |columns| {
|
||||||
columns[0].vertical_centered(|ui| {
|
columns[0].vertical_centered(|ui| {
|
||||||
let knob = rotary(
|
let knob = rotary(
|
||||||
ui,
|
ui,
|
||||||
@@ -284,47 +347,76 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
|||||||
columns[1].vertical_centered(|ui| {
|
columns[1].vertical_centered(|ui| {
|
||||||
let knob = rotary(
|
let knob = rotary(
|
||||||
ui,
|
ui,
|
||||||
"Selectivity",
|
"Threshold",
|
||||||
&mut settings.selectivity,
|
&mut settings.threshold,
|
||||||
0. ..=100.,
|
0. ..=18.,
|
||||||
defaults.selectivity,
|
defaults.threshold,
|
||||||
" %",
|
" dB",
|
||||||
58.,
|
58.,
|
||||||
ACCENT,
|
ACCENT,
|
||||||
);
|
);
|
||||||
hint(knob, state, "Low: treat any bump. High: only the worst.");
|
hint(
|
||||||
|
knob,
|
||||||
|
state,
|
||||||
|
"How far a band must rise above its neighbours before it is cut. \
|
||||||
|
Raise it until only the harsh parts are cut.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
columns[2].vertical_centered(|ui| {
|
||||||
|
let mut position = ratio_position(settings.ratio);
|
||||||
|
let knob = rotary_text(
|
||||||
|
ui,
|
||||||
|
"Ratio",
|
||||||
|
&mut position,
|
||||||
|
0. ..=100.,
|
||||||
|
ratio_position(defaults.ratio),
|
||||||
|
58.,
|
||||||
|
ACCENT,
|
||||||
|
|p| ratio_text(ratio_from_position(p)),
|
||||||
|
|s| parse_ratio(s).map(ratio_position),
|
||||||
|
);
|
||||||
|
if position != ratio_position(settings.ratio) {
|
||||||
|
settings.ratio = ratio_from_position(position);
|
||||||
|
}
|
||||||
|
hint(
|
||||||
|
knob,
|
||||||
|
state,
|
||||||
|
"Cut per dB above the threshold. Low ratios leave small bumps \
|
||||||
|
nearly alone and still cut big ones; ∞:1 flattens every bump to \
|
||||||
|
the threshold.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(10.);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(4.);
|
ui.add_space(4.);
|
||||||
caption(ui, "OUTPUT");
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(4.);
|
ui.vertical(|ui| {
|
||||||
ui.columns(2, |columns| {
|
caption(ui, "KNEE");
|
||||||
columns[0].vertical_centered(|ui| {
|
for (knee, label, help) in [
|
||||||
rotary(
|
(
|
||||||
ui,
|
Knee::Soft,
|
||||||
"Mix",
|
"SOFT",
|
||||||
&mut settings.mix,
|
"Soft knee: cuts fade in gradually around the threshold.",
|
||||||
0. ..=100.,
|
),
|
||||||
100.,
|
(
|
||||||
" %",
|
Knee::Hard,
|
||||||
58.,
|
"HARD",
|
||||||
STEEL,
|
"Hard knee: cuts start right at the threshold and grip twice \
|
||||||
);
|
as fast.",
|
||||||
});
|
),
|
||||||
columns[1].vertical_centered(|ui| {
|
] {
|
||||||
rotary(
|
let r = ui.selectable_label(
|
||||||
ui,
|
settings.knee == knee,
|
||||||
"Output",
|
RichText::new(label).size(13.),
|
||||||
&mut settings.output,
|
|
||||||
-12. ..=12.,
|
|
||||||
0.,
|
|
||||||
" dB",
|
|
||||||
58.,
|
|
||||||
STEEL,
|
|
||||||
);
|
);
|
||||||
|
if r.clicked() {
|
||||||
|
settings.knee = knee;
|
||||||
|
}
|
||||||
|
if r.hovered() {
|
||||||
|
state.help = help.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
transfer(ui, settings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -437,8 +529,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
|||||||
});
|
});
|
||||||
ui.add_space(6.);
|
ui.add_space(6.);
|
||||||
egui::Grid::new("range-controls")
|
egui::Grid::new("range-controls")
|
||||||
.num_columns(2)
|
.num_columns(4)
|
||||||
.spacing([22., 8.])
|
.spacing([16., 12.])
|
||||||
|
.min_col_width((ui.available_width() - 48.) / 4.)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
control(
|
control(
|
||||||
ui,
|
ui,
|
||||||
@@ -460,6 +553,26 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
|||||||
"Upper processing boundary, also limited by Nyquist.",
|
"Upper processing boundary, also limited by Nyquist.",
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
control(
|
||||||
|
ui,
|
||||||
|
"Mix",
|
||||||
|
&mut settings.mix,
|
||||||
|
0. ..=100.,
|
||||||
|
" %",
|
||||||
|
false,
|
||||||
|
"Blend of processed and delayed dry signal.",
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
control(
|
||||||
|
ui,
|
||||||
|
"Output",
|
||||||
|
&mut settings.output,
|
||||||
|
-12. ..=12.,
|
||||||
|
" dB",
|
||||||
|
false,
|
||||||
|
"Output trim, for level-matched comparison.",
|
||||||
|
state,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const COPY: Record<string, Copy> = {
|
|||||||
'Distortion chains build up harsh, ringing resonances. A static EQ cut takes out the tone along with the ring.',
|
'Distortion chains build up harsh, ringing resonances. A static EQ cut takes out the tone along with the ring.',
|
||||||
points: [
|
points: [
|
||||||
'Finds ringing regions as they happen and pulls only those down.',
|
'Finds ringing regions as they happen and pulls only those down.',
|
||||||
'Plain harmonic tone and transients pass; Selectivity sets what counts as harsh.',
|
'Each band works like a small compressor: Threshold sets what counts as harsh, Ratio how hard it is cut.',
|
||||||
'Four focus points on the plot to cut more or protect a region.',
|
'Four focus points on the plot to cut more or protect a region.',
|
||||||
'Delta monitoring to hear exactly what is removed.',
|
'Delta monitoring to hear exactly what is removed.',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user