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
+153
-40
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user