feat(patina): rebuild resonance detector, simplify controls, new look
Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s
Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s
Patina 0.2.0. The old detector compared raw FFT bins with a one-octave dB mean, so every harmonic of a tonal sound looked like a resonance and real harsh bumps were barely touched. The new detector: - averages power over each log band and smooths it over time - joins harmonic tops into an envelope, smoothed by Sharpness - compares it with a robust, tilt-aware local line fit that ignores empty regions and cannot be lifted by the bump itself Six overlapping controls become Depth, Sharpness and Selectivity; focus points now read "up cuts more, down protects". The analysis window is halved to 2048 samples (about 43 ms delay). Behaviour tests cover plain harmonic tones, resonant bumps, narrow rings, level independence, flicker on noise and high sample rates. Both editors get a dark metallic look with #FF0160 as the single accent colour; screenshots and docs are updated. Projects saved with Patina 0.1 load the new controls at their defaults.
This commit is contained in:
1 parent
15853e61ac
commit
17c6fe3ab9
25 files changed
+1074
-539
No files matched your search
+356
-251
@@ -3,13 +3,21 @@ use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BG: Color32 = Color32::from_rgb(23, 20, 31);
|
||||
const PANEL: Color32 = Color32::from_rgb(37, 33, 49);
|
||||
const TEXT: Color32 = Color32::from_rgb(238, 234, 245);
|
||||
const MUTED: Color32 = Color32::from_rgb(161, 151, 179);
|
||||
const CYAN: Color32 = Color32::from_rgb(102, 215, 230);
|
||||
const CORAL: Color32 = Color32::from_rgb(255, 136, 125);
|
||||
const VIOLET: Color32 = Color32::from_rgb(174, 154, 247);
|
||||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||||
const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||||
const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||||
const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||||
const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||||
const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||||
const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||||
const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||||
const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||||
/// Signal colour: cuts, active states, value arcs.
|
||||
const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||||
/// Measured input and neutral data.
|
||||
const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||||
const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
@@ -36,14 +44,36 @@ pub fn style(ctx: &egui::Context) {
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
s.visuals = egui::Visuals::dark();
|
||||
s.visuals.panel_fill = BG;
|
||||
s.visuals.window_fill = PANEL;
|
||||
s.visuals.override_text_color = Some(TEXT);
|
||||
s.visuals.selection.bg_fill = Color32::from_rgb(80, 64, 112);
|
||||
s.visuals.widgets.inactive.bg_fill = PANEL;
|
||||
s.visuals.widgets.hovered.bg_fill = Color32::from_rgb(62, 50, 80);
|
||||
s.visuals.widgets.active.bg_fill = Color32::from_rgb(81, 58, 102);
|
||||
let v = &mut s.visuals;
|
||||
*v = egui::Visuals::dark();
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = PANEL;
|
||||
v.extreme_bg_color = SCREEN;
|
||||
v.faint_bg_color = PANEL;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.hyperlink_color = ACCENT;
|
||||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
(
|
||||
&mut v.widgets.hovered,
|
||||
Color32::from_rgb(45, 48, 54),
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
),
|
||||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||||
w.corner_radius = 3.into();
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.slider_trailing_fill = true;
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
@@ -77,7 +107,7 @@ impl Default for State {
|
||||
gesture_active: false,
|
||||
gestures: [false; 30],
|
||||
last_audio: Instant::now(),
|
||||
help: "Focus a troublesome region, then compare in the full mix.".into(),
|
||||
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
||||
drag: None,
|
||||
}
|
||||
}
|
||||
@@ -87,6 +117,8 @@ impl State {
|
||||
// Display-only smoothing; it never feeds the detector.
|
||||
for i in 0..GRID {
|
||||
self.analyzer.input[i] += 0.45 * (frame.input[i] - self.analyzer.input[i]);
|
||||
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.focus = frame.focus;
|
||||
@@ -107,6 +139,35 @@ impl State {
|
||||
fn db(x: f32) -> f32 {
|
||||
20. * x.max(1e-6).log10()
|
||||
}
|
||||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||||
fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mix = |t: f32| {
|
||||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color32::from_rgb(
|
||||
l(top.r(), bottom.r()),
|
||||
l(top.g(), bottom.g()),
|
||||
l(top.b(), bottom.b()),
|
||||
)
|
||||
};
|
||||
mesh.colored_vertex(center, mix(0.5));
|
||||
let n = 48;
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||||
let point = center + Vec2::angled(a) * radius;
|
||||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||||
if i > 0 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
/// Shows a help line in the status bar while the knob is hovered or focused.
|
||||
fn hint(response: egui::Response, state: &mut State, text: &str) {
|
||||
if response.hovered() || response.has_focus() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
fn note(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(MUTED));
|
||||
}
|
||||
@@ -122,7 +183,7 @@ fn control(
|
||||
state: &mut State,
|
||||
) {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(name).color(TEXT));
|
||||
caption(ui, &name.to_uppercase());
|
||||
let previous = *value;
|
||||
let response = ui.add(
|
||||
egui::Slider::new(value, range)
|
||||
@@ -137,15 +198,9 @@ fn control(
|
||||
if response.double_clicked() {
|
||||
let defaults = Settings::default();
|
||||
*value = match name {
|
||||
"Amount" => defaults.amount,
|
||||
"Sensitivity" => defaults.sensitivity,
|
||||
"Max cut" => defaults.max_cut,
|
||||
"Transient guard" => defaults.transient_guard,
|
||||
"Attack" => defaults.attack,
|
||||
"Release" => defaults.release,
|
||||
"Neighborhood" => defaults.neighborhood,
|
||||
"Cut width" => defaults.cut_width,
|
||||
"Knee" => defaults.knee,
|
||||
"Low frequency" => defaults.low,
|
||||
"High frequency" => defaults.high,
|
||||
"Mix" => defaults.mix,
|
||||
@@ -167,7 +222,7 @@ fn rotary(
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) {
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
@@ -201,62 +256,71 @@ fn rotary(
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.45;
|
||||
let radius = size * 0.46;
|
||||
let p = ui.painter();
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 3.),
|
||||
radius,
|
||||
Color32::from_black_alpha(90),
|
||||
);
|
||||
p.circle_filled(center, radius, Color32::from_rgb(20, 18, 29));
|
||||
p.circle_stroke(
|
||||
center,
|
||||
radius,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(74, 65, 90)),
|
||||
);
|
||||
p.circle_filled(center, radius - 6., Color32::from_rgb(43, 37, 56));
|
||||
p.circle_stroke(
|
||||
center,
|
||||
radius - 7.,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(60, 51, 77)),
|
||||
);
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = (*value - low) / (high - low);
|
||||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||||
// Scale track and value arc around the knob.
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(3.0_f32, Color32::from_rgb(59, 49, 76))),
|
||||
(normalized, Stroke::new(3.0_f32, color)),
|
||||
(1., Stroke::new(2.5_f32, DIM)),
|
||||
(normalized, Stroke::new(2.5_f32, color)),
|
||||
] {
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 3.)
|
||||
center + Vec2::angled(a) * (radius - 1.5)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||||
let body = radius - 7.;
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 2.),
|
||||
body + 1.,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
body,
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
Color32::from_rgb(28, 30, 34),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
body,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||||
);
|
||||
let cap = body * 0.74;
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
cap,
|
||||
Color32::from_rgb(44, 47, 52),
|
||||
Color32::from_rgb(33, 35, 40),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
cap,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||||
);
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (radius - 14.),
|
||||
center + Vec2::angled(angle) * (radius - 8.),
|
||||
center + Vec2::angled(angle) * (cap * 0.35),
|
||||
center + Vec2::angled(angle) * (cap - 2.),
|
||||
],
|
||||
Stroke::new(2.0_f32, color),
|
||||
);
|
||||
p.text(
|
||||
center,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{:.0}{}", *value, unit),
|
||||
FontId::proportional(if size > 100. { 25. } else { 18. }),
|
||||
color,
|
||||
Stroke::new(2.0_f32, TEXT),
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, TEXT));
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.size(if size > 100. { 20. } else { 17. })
|
||||
.color(TEXT),
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
@@ -264,32 +328,85 @@ fn rotary(
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (pos, color) in [
|
||||
(rect.left_top(), top),
|
||||
(rect.right_top(), top),
|
||||
(rect.right_bottom(), bottom),
|
||||
(rect.left_bottom(), bottom),
|
||||
] {
|
||||
mesh.colored_vertex(pos, color);
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||||
fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let face = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = egui::Frame::new()
|
||||
.inner_margin(14.)
|
||||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||||
.show(ui, add);
|
||||
let r = inner.response.rect;
|
||||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
r.left_top() + Vec2::new(1., 1.),
|
||||
r.right_top() + Vec2::new(-1., 1.),
|
||||
],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||||
);
|
||||
inner.inner
|
||||
}
|
||||
fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||||
}
|
||||
/// Small labelled hardware-style readout.
|
||||
fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||||
ui.add_space(10.);
|
||||
}
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
|
||||
let before = *settings;
|
||||
let full = ui.max_rect();
|
||||
ui.painter()
|
||||
.add(gradient(full, Color32::from_rgb(20, 21, 24), BG));
|
||||
ui.add_space(2.);
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Patina")
|
||||
.font(FontId::new(30., egui::FontFamily::Name("medium".into()))),
|
||||
);
|
||||
note(ui, "Keep the character. Control the ring.");
|
||||
});
|
||||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||||
ui.label(
|
||||
RichText::new("PATINA")
|
||||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(RichText::new("RESONANCE SUPPRESSOR").size(12.).color(MUTED));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut settings.bypass, "Bypass");
|
||||
if ui
|
||||
.selectable_label(
|
||||
state.delta,
|
||||
RichText::new("Delta").color(if state.delta { CORAL } else { TEXT }),
|
||||
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
|
||||
.on_hover_text("Crossfade to the delayed dry signal.")
|
||||
.clicked()
|
||||
{
|
||||
settings.bypass = !settings.bypass;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(state.delta, RichText::new("DELTA").size(13.))
|
||||
.on_hover_text(
|
||||
"Hear only what is removed: delayed dry minus wet, before output trim.",
|
||||
)
|
||||
.on_hover_text("Hear delayed dry minus wet, before output trim.")
|
||||
.clicked()
|
||||
{
|
||||
state.delta = !state.delta;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Copy A to B").clicked() {
|
||||
if ui.button(RichText::new("COPY A TO B").size(13.)).clicked() {
|
||||
state.snapshots[state.active] = *settings;
|
||||
state.snapshots[1] = state.snapshots[0];
|
||||
if state.active == 1 {
|
||||
@@ -298,7 +415,10 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
for i in (0..2).rev() {
|
||||
if ui
|
||||
.selectable_label(state.active == i, if i == 0 { "A" } else { "B" })
|
||||
.selectable_label(
|
||||
state.active == i,
|
||||
RichText::new(if i == 0 { "A" } else { "B" }).size(13.),
|
||||
)
|
||||
.clicked()
|
||||
&& state.active != i
|
||||
{
|
||||
@@ -307,11 +427,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
state.active = i;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
egui::ComboBox::from_id_salt("resonance-preset")
|
||||
.selected_text(
|
||||
(0..4)
|
||||
.find(|i| *settings == Settings::preset(*i))
|
||||
.map_or("Custom settings", |i| Settings::PRESETS[i]),
|
||||
.map_or("Custom", |i| Settings::PRESETS[i]),
|
||||
)
|
||||
.width(155.)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -324,117 +445,128 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.);
|
||||
ui.add_space(6.);
|
||||
ui.horizontal(|ui| {
|
||||
let input = db(state.analyzer.input_peak);
|
||||
let output = db(state.analyzer.output_peak);
|
||||
ui.label(RichText::new(format!("Input {input:>5.1} dBFS")).color(CYAN));
|
||||
ui.label(
|
||||
RichText::new(format!("Output {output:>5.1} dBFS")).color(if output >= 0. {
|
||||
CORAL
|
||||
} else {
|
||||
TEXT
|
||||
}),
|
||||
);
|
||||
let max = state
|
||||
.analyzer
|
||||
.reduction
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0f32, f32::max);
|
||||
ui.label(RichText::new(format!("Reduction {max:.1} dB")).color(CORAL));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Transient guard {:.0}%",
|
||||
state.analyzer.guard * 100.
|
||||
))
|
||||
.color(MUTED),
|
||||
readout(ui, "IN", format!("{input:>6.1} dBFS"), STEEL);
|
||||
readout(
|
||||
ui,
|
||||
"OUT",
|
||||
format!("{output:>6.1} dBFS"),
|
||||
if output >= 0. { ACCENT } else { TEXT },
|
||||
);
|
||||
readout(ui, "CUT", format!("{max:>4.1} dB"), ACCENT);
|
||||
readout(
|
||||
ui,
|
||||
"GUARD",
|
||||
format!("{:>3.0} %", state.analyzer.guard * 100.),
|
||||
MUTED,
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
note(
|
||||
caption(
|
||||
ui,
|
||||
&format!(
|
||||
"{:.1} ms latency • {}",
|
||||
"{:.1} ms latency · {}",
|
||||
state.analyzer.latency as f32 / state.analyzer.sample_rate * 1000.,
|
||||
if state.analyzer.channels == 1 {
|
||||
"Mono"
|
||||
} else {
|
||||
"Stereo linked"
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let body_height = (ui.available_height() - 35.).max(500.);
|
||||
let body_height = (ui.available_height() - 40.).max(480.);
|
||||
ui.horizontal_top(|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL)
|
||||
.corner_radius(8.)
|
||||
.inner_margin(14.)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(156.);
|
||||
ui.set_min_height(body_height - 28.);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(4.);
|
||||
rotary(
|
||||
ui,
|
||||
"Amount",
|
||||
&mut settings.amount,
|
||||
0. ..=100.,
|
||||
35.,
|
||||
" %",
|
||||
126.,
|
||||
CYAN,
|
||||
);
|
||||
ui.add_space(8.);
|
||||
ui.separator();
|
||||
ui.add_space(12.);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_width(72.);
|
||||
rotary(
|
||||
ui,
|
||||
"Mix",
|
||||
&mut settings.mix,
|
||||
0. ..=100.,
|
||||
100.,
|
||||
" %",
|
||||
64.,
|
||||
VIOLET,
|
||||
);
|
||||
});
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_width(72.);
|
||||
rotary(
|
||||
ui,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
0.,
|
||||
" dB",
|
||||
64.,
|
||||
CORAL,
|
||||
);
|
||||
});
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(176.);
|
||||
ui.set_min_height(body_height - 28.);
|
||||
ui.vertical_centered(|ui| {
|
||||
caption(ui, "DETECTION");
|
||||
ui.add_space(4.);
|
||||
let defaults = Settings::default();
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Depth",
|
||||
&mut settings.depth,
|
||||
0. ..=20.,
|
||||
defaults.depth,
|
||||
" dB",
|
||||
108.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Largest cut. 0 dB does nothing.");
|
||||
ui.add_space(8.);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Sharpness",
|
||||
&mut settings.sharpness,
|
||||
0. ..=100.,
|
||||
defaults.sharpness,
|
||||
" %",
|
||||
58.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Low: smooth broad bumps. High: narrow rings.");
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Selectivity",
|
||||
&mut settings.selectivity,
|
||||
0. ..=100.,
|
||||
defaults.selectivity,
|
||||
" %",
|
||||
58.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Low: treat any bump. High: only the worst.");
|
||||
});
|
||||
});
|
||||
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.add_space(10.);
|
||||
note(ui, "Peak reduction");
|
||||
let max = state
|
||||
.analyzer
|
||||
.reduction
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0f32, f32::max);
|
||||
ui.label(RichText::new(format!("{max:.1} dB")).size(25.).color(CORAL));
|
||||
ui.add_space(12.);
|
||||
ui.label(
|
||||
RichText::new("Drag to adjust. Shift for fine steps. Double-click resets.")
|
||||
.size(13.)
|
||||
.color(MUTED),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(4.);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
@@ -442,7 +574,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
spectrum(ui, settings, state);
|
||||
ui.add_space(4.);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new("Focus / protect").color(VIOLET));
|
||||
ui.label(RichText::new("FOCUS / PROTECT").size(11.).color(FOCUS));
|
||||
for i in 0..4 {
|
||||
let f = settings.focus[i];
|
||||
if ui
|
||||
@@ -464,7 +596,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
.range(20. ..=20000.)
|
||||
.speed(5.),
|
||||
);
|
||||
ui.label("Sensitivity");
|
||||
ui.label("Cut more (+) / protect (−)");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut f.sensitivity)
|
||||
.range(-18. ..=18.)
|
||||
@@ -488,59 +620,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
} else {
|
||||
note(
|
||||
ui,
|
||||
"Double-click the spectrum to place a point. Drag to tame or protect.",
|
||||
"Double-click the spectrum to place a point. Drag up to cut more there, down to protect.",
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.);
|
||||
ui.separator();
|
||||
ui.add_space(4.);
|
||||
ui.add_space(8.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
egui::Grid::new("engine-controls")
|
||||
.num_columns(4)
|
||||
.num_columns(3)
|
||||
.spacing([16., 12.])
|
||||
.min_col_width((ui.available_width() - 48.) / 4.)
|
||||
.min_col_width((ui.available_width() - 32.) / 3.)
|
||||
.show(ui, |ui| {
|
||||
control(
|
||||
ui,
|
||||
"Sensitivity",
|
||||
&mut settings.sensitivity,
|
||||
0. ..=24.,
|
||||
" dB",
|
||||
false,
|
||||
"Lower catches more peaks. Measured relative to the local spectral reference.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Max cut",
|
||||
&mut settings.max_cut,
|
||||
0. ..=18.,
|
||||
" dB",
|
||||
false,
|
||||
"Upper bound on spectral attenuation. This is not a limiter.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Transient guard",
|
||||
&mut settings.transient_guard,
|
||||
0. ..=100.,
|
||||
" %",
|
||||
false,
|
||||
"Relaxes reduction during attacks. Turn down to catch short resonances.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Knee",
|
||||
&mut settings.knee,
|
||||
0. ..=12.,
|
||||
" dB",
|
||||
false,
|
||||
"Softness of the transition into prominence reduction.",
|
||||
state,
|
||||
);
|
||||
ui.end_row();
|
||||
control(
|
||||
ui,
|
||||
"Attack",
|
||||
@@ -548,7 +639,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
1. ..=200.,
|
||||
" ms",
|
||||
true,
|
||||
"Attenuation rise time, in addition to window and detection delay.",
|
||||
"How fast cuts grow. Short catches brief whistles.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
@@ -558,27 +649,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
10. ..=1000.,
|
||||
" ms",
|
||||
true,
|
||||
"Recovery time after a resonance subsides.",
|
||||
"How fast cuts recover after a resonance stops.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Neighborhood",
|
||||
&mut settings.neighborhood,
|
||||
0.25..=2.,
|
||||
" oct",
|
||||
"Transient guard",
|
||||
&mut settings.transient_guard,
|
||||
0. ..=100.,
|
||||
" %",
|
||||
false,
|
||||
"Width of the surrounding spectral reference. Wider includes more context.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Cut width",
|
||||
&mut settings.cut_width,
|
||||
0.03..=0.5,
|
||||
" oct",
|
||||
false,
|
||||
"Frequency smoothing width of the attenuation surface.",
|
||||
"Relaxes cuts on attacks to keep punch. Turn down to catch short rings.",
|
||||
state,
|
||||
);
|
||||
ui.end_row();
|
||||
@@ -609,6 +690,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
state,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
@@ -626,42 +708,48 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
}
|
||||
fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let height = (ui.available_height() - 360.).clamp(180., 480.);
|
||||
let height = (ui.available_height() - 290.).clamp(180., 540.);
|
||||
let (outer, response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), height),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(outer, 6., Color32::from_rgb(15, 13, 22));
|
||||
p.rect_filled(outer, 3., SCREEN);
|
||||
p.rect_stroke(
|
||||
outer,
|
||||
3.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let r = Rect::from_min_max(
|
||||
outer.min + Vec2::new(46., 26.),
|
||||
outer.max - Vec2::new(42., 26.),
|
||||
);
|
||||
let x = |f: f32| r.left() + ((f.max(20.) / 20.).ln() / 1000f32.ln()).clamp(0., 1.) * r.width();
|
||||
let y = |d: f32| r.bottom() - (d + 100.).clamp(0., 100.) / 100. * r.height();
|
||||
let red_y = |d: f32| r.top() + d / 18. * r.height() * 0.75;
|
||||
let red_y = |d: f32| r.top() + d / 20. * r.height() * 0.75;
|
||||
for (xx, level, color) in [
|
||||
(outer.left() + 5., state.analyzer.input_peak, CYAN),
|
||||
(outer.left() + 5., state.analyzer.input_peak, STEEL),
|
||||
(
|
||||
outer.right() - 9.,
|
||||
state.analyzer.output_peak,
|
||||
if state.analyzer.output_peak >= 1. {
|
||||
CORAL
|
||||
ACCENT
|
||||
} else {
|
||||
VIOLET
|
||||
TEXT
|
||||
},
|
||||
),
|
||||
] {
|
||||
let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom()));
|
||||
p.rect_filled(rail, 2., Color32::from_rgb(38, 33, 48));
|
||||
p.rect_filled(rail, 1., Color32::from_rgb(26, 28, 32));
|
||||
let height = ((db(level) + 72.) / 72.).clamp(0., 1.) * rail.height();
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(Pos2::new(xx, rail.bottom() - height), rail.max),
|
||||
2.,
|
||||
color.gamma_multiply(0.65),
|
||||
color.gamma_multiply(0.75),
|
||||
);
|
||||
}
|
||||
let color_grid = Color32::from_rgb(58, 51, 70);
|
||||
let color_grid = Color32::from_rgb(30, 32, 36);
|
||||
for f in [
|
||||
20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000.,
|
||||
] {
|
||||
@@ -696,13 +784,13 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
for d in [0., 6., 12., 18.] {
|
||||
for d in [0., 5., 10., 15., 20.] {
|
||||
p.text(
|
||||
Pos2::new(r.right() + 7., red_y(d)),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("−{d:.0}"),
|
||||
FontId::proportional(12.),
|
||||
CORAL,
|
||||
ACCENT,
|
||||
);
|
||||
}
|
||||
p.text(
|
||||
@@ -717,17 +805,17 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
egui::Align2::RIGHT_TOP,
|
||||
"Cut dB",
|
||||
FontId::proportional(12.),
|
||||
CORAL,
|
||||
ACCENT,
|
||||
);
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())),
|
||||
0.,
|
||||
Color32::from_black_alpha(90),
|
||||
Color32::from_black_alpha(120),
|
||||
);
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(Pos2::new(x(settings.high), r.top()), r.max),
|
||||
0.,
|
||||
Color32::from_black_alpha(90),
|
||||
Color32::from_black_alpha(120),
|
||||
);
|
||||
let signal: Vec<_> = (0..GRID)
|
||||
.map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i])))
|
||||
@@ -736,9 +824,9 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
for (i, point) in signal.iter().enumerate() {
|
||||
source_fill.colored_vertex(
|
||||
Pos2::new(point.x, r.bottom()),
|
||||
Color32::from_rgba_unmultiplied(102, 215, 230, 7),
|
||||
Color32::from_rgba_unmultiplied(184, 192, 204, 4),
|
||||
);
|
||||
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(102, 215, 230, 60));
|
||||
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(184, 192, 204, 34));
|
||||
if i > 0 {
|
||||
let j = (i * 2) as u32;
|
||||
source_fill.add_triangle(j - 2, j - 1, j);
|
||||
@@ -746,7 +834,21 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(source_fill));
|
||||
p.add(egui::Shape::line(signal, Stroke::new(1.5_f32, CYAN)));
|
||||
p.add(egui::Shape::line(
|
||||
signal,
|
||||
Stroke::new(1.2_f32, STEEL.gamma_multiply(0.7)),
|
||||
));
|
||||
// What the detector hears (envelope) and what it expects (reference).
|
||||
// Accent cuts appear where the envelope rises above the reference.
|
||||
for (values, color, width) in [
|
||||
(&state.analyzer.reference, DIM, 1.0_f32),
|
||||
(&state.analyzer.envelope, TEXT.gamma_multiply(0.8), 1.2_f32),
|
||||
] {
|
||||
let line: Vec<_> = (0..GRID)
|
||||
.map(|i| Pos2::new(x(frequency(i)), y(values[i])))
|
||||
.collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(width, color)));
|
||||
}
|
||||
let mut fill = Vec::with_capacity(GRID + 2);
|
||||
fill.push(r.left_top());
|
||||
for i in 0..GRID {
|
||||
@@ -763,11 +865,11 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let yy = red_y(state.analyzer.reduction[i]);
|
||||
mesh.colored_vertex(
|
||||
Pos2::new(xx, r.top()),
|
||||
Color32::from_rgba_unmultiplied(255, 136, 125, 8),
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 6),
|
||||
);
|
||||
mesh.colored_vertex(
|
||||
Pos2::new(xx, yy),
|
||||
Color32::from_rgba_unmultiplied(255, 136, 125, 90),
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 100),
|
||||
);
|
||||
if i > 0 {
|
||||
let j = (i * 2) as u32;
|
||||
@@ -778,7 +880,7 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
p.add(egui::Shape::line(
|
||||
fill[1..=GRID].to_vec(),
|
||||
Stroke::new(1.6_f32, CORAL),
|
||||
Stroke::new(1.6_f32, ACCENT),
|
||||
));
|
||||
let center = r.center().y;
|
||||
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
|
||||
@@ -792,12 +894,15 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
.collect();
|
||||
p.line_segment(
|
||||
[Pos2::new(r.left(), center), Pos2::new(r.right(), center)],
|
||||
Stroke::new(0.7_f32, VIOLET.gamma_multiply(0.35)),
|
||||
Stroke::new(0.7_f32, FOCUS.gamma_multiply(0.3)),
|
||||
);
|
||||
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, VIOLET)));
|
||||
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, FOCUS)));
|
||||
for (i, f) in settings.focus.iter().enumerate().filter(|(_, f)| f.enabled) {
|
||||
let pos = Pos2::new(x(f.frequency), focus_y(f.sensitivity));
|
||||
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, VIOLET);
|
||||
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, FOCUS);
|
||||
if state.selected == Some(i) {
|
||||
p.circle_stroke(pos, 10., Stroke::new(1.0_f32, FOCUS.gamma_multiply(0.6)));
|
||||
}
|
||||
p.text(
|
||||
pos - Vec2::new(0., 12.),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
@@ -892,14 +997,14 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
for f in [settings.low, settings.high] {
|
||||
p.line_segment(
|
||||
[Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())],
|
||||
Stroke::new(2.0_f32, MUTED),
|
||||
Stroke::new(1.0_f32, MUTED.gamma_multiply(0.7)),
|
||||
);
|
||||
let handle = Rect::from_center_size(Pos2::new(x(f), r.center().y), Vec2::new(12., 30.));
|
||||
p.rect_filled(handle, 6., Color32::from_rgb(65, 77, 96));
|
||||
p.rect_filled(handle, 2., Color32::from_rgb(46, 49, 55));
|
||||
p.rect_stroke(
|
||||
handle,
|
||||
6.,
|
||||
Stroke::new(1.0_f32, CYAN.gamma_multiply(0.65)),
|
||||
Stroke::new(1.0_f32, STEEL.gamma_multiply(0.5)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user