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:
nvrl committed 2026-11-06 17:29:54 +01:00
1 parent 7b989f7685
commit 9ca4e4f9dd
13 files changed
+568 -102

No files matched your search

+153 -40
View File
@@ -2,7 +2,9 @@
pub use common_ui::style;
use common_ui::*;
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};
pub struct State {
@@ -30,7 +32,7 @@ impl Default for State {
gesture_active: false,
gestures: [false; 30],
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,
}
}
@@ -85,6 +87,66 @@ fn hint(response: egui::Response, state: &mut State, text: &str) {
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) {
ui.label(RichText::new(text).color(MUTED));
}
@@ -122,6 +184,7 @@ fn control(
"Low frequency" => defaults.low,
"High frequency" => defaults.high,
"Mix" => defaults.mix,
"Output" => defaults.output,
_ => *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.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(176.);
ui.set_width(236.);
ui.set_min_height(body_height - 28.);
ui.vertical_centered(|ui| {
caption(ui, "DETECTION");
@@ -262,12 +325,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
0. ..=20.,
defaults.depth,
" dB",
108.,
88.,
ACCENT,
);
hint(knob, state, "Largest cut. 0 dB does nothing.");
ui.add_space(8.);
ui.columns(2, |columns| {
ui.columns(3, |columns| {
columns[0].vertical_centered(|ui| {
let knob = rotary(
ui,
@@ -284,47 +347,76 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
columns[1].vertical_centered(|ui| {
let knob = rotary(
ui,
"Selectivity",
&mut settings.selectivity,
0. ..=100.,
defaults.selectivity,
" %",
"Threshold",
&mut settings.threshold,
0. ..=18.,
defaults.threshold,
" dB",
58.,
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.);
caption(ui, "OUTPUT");
ui.add_space(4.);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
rotary(
ui,
"Mix",
&mut settings.mix,
0. ..=100.,
100.,
" %",
58.,
STEEL,
);
});
columns[1].vertical_centered(|ui| {
rotary(
ui,
"Output",
&mut settings.output,
-12. ..=12.,
0.,
" dB",
58.,
STEEL,
ui.horizontal(|ui| {
ui.vertical(|ui| {
caption(ui, "KNEE");
for (knee, label, help) in [
(
Knee::Soft,
"SOFT",
"Soft knee: cuts fade in gradually around the threshold.",
),
(
Knee::Hard,
"HARD",
"Hard knee: cuts start right at the threshold and grip twice \
as fast.",
),
] {
let r = ui.selectable_label(
settings.knee == knee,
RichText::new(label).size(13.),
);
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.);
egui::Grid::new("range-controls")
.num_columns(2)
.spacing([22., 8.])
.num_columns(4)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 48.) / 4.)
.show(ui, |ui| {
control(
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.",
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,
);
});
});
});