//! Shared native editor, also exercised by the headless renderer. 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 std::time::{Duration, Instant}; pub struct State { pub delta: bool, pub selected: Option, pub preset: usize, pub snapshots: [Settings; 2], pub active: usize, pub analyzer: AnalyzerFrame, pub gesture_active: bool, pub gestures: [bool; 30], last_audio: Instant, pub help: String, drag: Option, } impl Default for State { fn default() -> Self { Self { delta: false, selected: None, preset: 0, snapshots: [Settings::default(); 2], active: 0, analyzer: AnalyzerFrame::default(), 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(), drag: None, } } } impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { // 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; self.analyzer.input_peak = frame.input_peak; self.analyzer.output_peak = frame.output_peak; self.analyzer.guard = frame.guard; self.analyzer.sample_rate = frame.sample_rate; self.analyzer.latency = frame.latency; self.analyzer.channels = frame.channels; if frame.input_peak > 1e-6 || frame.output_peak > 1e-6 || frame.reduction.iter().any(|v| *v > 0.001) { self.last_audio = Instant::now(); } } } fn db(x: f32) -> f32 { 20. * x.max(1e-6).log10() } /// 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)); } #[allow(clippy::too_many_arguments)] // Explicit widget metadata keeps the control table readable. fn control( ui: &mut egui::Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive, unit: &str, log: bool, help: &str, state: &mut State, ) { ui.vertical(|ui| { caption(ui, &name.to_uppercase()); let previous = *value; let response = ui.add( egui::Slider::new(value, range) .logarithmic(log) .suffix(unit) .clamping(egui::SliderClamping::Always) .smart_aim(false), ); if response.dragged() && ui.input(|i| i.modifiers.shift) { *value = previous + (*value - previous) * 0.1; } if response.double_clicked() { let defaults = Settings::default(); *value = match name { "Transient guard" => defaults.transient_guard, "Attack" => defaults.attack, "Release" => defaults.release, "Low frequency" => defaults.low, "High frequency" => defaults.high, "Mix" => defaults.mix, _ => *value, }; } if response.hovered() || response.has_focus() { state.help = help.into(); } }); } 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| { 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| { if ui .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.", ) .clicked() { state.delta = !state.delta; } ui.separator(); 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 { *settings = state.snapshots[1]; } } for i in (0..2).rev() { if ui .selectable_label( state.active == i, RichText::new(if i == 0 { "A" } else { "B" }).size(13.), ) .clicked() && state.active != i { state.snapshots[state.active] = *settings; *settings = state.snapshots[i]; 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", |i| Settings::PRESETS[i]), ) .width(155.) .show_ui(ui, |ui| { for (i, name) in Settings::PRESETS.iter().enumerate() { if ui.selectable_value(&mut state.preset, i, *name).clicked() { *settings = Settings::preset(i); state.delta = false; } } }); }); }); ui.add_space(6.); ui.horizontal(|ui| { let input = db(state.analyzer.input_peak); let output = db(state.analyzer.output_peak); let max = state .analyzer .reduction .iter() .copied() .fold(0f32, f32::max); 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| { caption( ui, &format!( "{:.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() - 40.).max(480.); ui.horizontal_top(|ui| { 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(4.); ui.vertical(|ui| { ui.set_width(ui.available_width()); ui.spacing_mut().slider_width = ((ui.available_width() - 48.) / 4. - 103.).max(72.); spectrum(ui, settings, state); ui.add_space(4.); ui.horizontal_wrapped(|ui| { ui.label(RichText::new("FOCUS / PROTECT").size(11.).color(FOCUS)); for i in 0..4 { let f = settings.focus[i]; if ui .selectable_label( state.selected == Some(i), format!("{} {}", i + 1, if f.enabled { "●" } else { "○" }), ) .clicked() { state.selected = Some(i); } } if let Some(i) = state.selected { let f = &mut settings.focus[i]; ui.checkbox(&mut f.enabled, "Enabled"); ui.label("Hz"); ui.add( egui::DragValue::new(&mut f.frequency) .range(20. ..=20000.) .speed(5.), ); ui.label("Cut more (+) / protect (−)"); ui.add( egui::DragValue::new(&mut f.sensitivity) .range(-18. ..=18.) .speed(0.1) .suffix(" dB"), ); ui.label("Width"); ui.add( egui::DragValue::new(&mut f.width) .range(0.05..=2.) .speed(0.01) .suffix(" oct"), ); if ui.small_button("Reset").clicked() { f.sensitivity = 0.; } if ui.small_button("Remove").clicked() { *f = Focus::default(); state.selected = None; } } else { note( ui, "Double-click the spectrum to place a point. Drag up to cut more there, down to protect.", ); } }); ui.add_space(8.); panel(ui, |ui| { ui.set_width(ui.available_width()); egui::Grid::new("engine-controls") .num_columns(3) .spacing([16., 12.]) .min_col_width((ui.available_width() - 32.) / 3.) .show(ui, |ui| { control( ui, "Attack", &mut settings.attack, 1. ..=200., " ms", true, "How fast cuts grow. Short catches brief whistles.", state, ); control( ui, "Release", &mut settings.release, 10. ..=1000., " ms", true, "How fast cuts recover after a resonance stops.", state, ); control( ui, "Transient guard", &mut settings.transient_guard, 0. ..=100., " %", false, "Relaxes cuts on attacks to keep punch. Turn down to catch short rings.", state, ); ui.end_row(); }); ui.add_space(6.); egui::Grid::new("range-controls") .num_columns(2) .spacing([22., 8.]) .show(ui, |ui| { control( ui, "Low frequency", &mut settings.low, 20. ..=19999., " Hz", true, "Lower processing boundary. Protect the source's body.", state, ); control( ui, "High frequency", &mut settings.high, 21. ..=20000., " Hz", true, "Upper processing boundary, also limited by Nyquist.", state, ); }); }); }); }); ui.separator(); note(ui, &state.help); if settings.low >= settings.high { if settings.low != before.low { settings.low = (settings.high - 1.).max(20.); } else { settings.high = (settings.low + 1.).min(20000.); } } state.gesture_active = ui.input(|i| i.pointer.any_down()); if state.last_audio.elapsed() < Duration::from_millis(300) { ui.ctx().request_repaint_after(Duration::from_millis(33)); } } fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { 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, 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 / 20. * r.height() * 0.75; for (xx, level, color) in [ (outer.left() + 5., state.analyzer.input_peak, STEEL), ( outer.right() - 9., state.analyzer.output_peak, if state.analyzer.output_peak >= 1. { ACCENT } else { TEXT }, ), ] { let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom())); 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.75), ); } let color_grid = Color32::from_rgb(30, 32, 36); for f in [ 20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000., ] { let xx = x(f); p.line_segment( [Pos2::new(xx, r.top()), Pos2::new(xx, r.bottom())], Stroke::new(1.0_f32, color_grid), ); let label = if f >= 1000. { format!("{}k", f / 1000.) } else { format!("{f:.0}") }; p.text( Pos2::new(xx, r.bottom() + 7.), egui::Align2::CENTER_TOP, label, FontId::proportional(12.), MUTED, ); } for d in [-96., -72., -48., -24., 0.] { p.line_segment( [Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))], Stroke::new(1.0_f32, color_grid), ); p.text( Pos2::new(r.left() - 7., y(d)), egui::Align2::RIGHT_CENTER, format!("{d:.0}"), FontId::proportional(12.), MUTED, ); } 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.), ACCENT, ); } p.text( r.left_top() - Vec2::new(30., 19.), egui::Align2::LEFT_TOP, "dBFS / bin", FontId::proportional(12.), MUTED, ); p.text( r.right_top() + Vec2::new(30., -19.), egui::Align2::RIGHT_TOP, "Cut dB", FontId::proportional(12.), ACCENT, ); p.rect_filled( Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())), 0., 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(120), ); let signal: Vec<_> = (0..GRID) .map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i]))) .collect(); let mut source_fill = egui::Mesh::default(); for (i, point) in signal.iter().enumerate() { source_fill.colored_vertex( Pos2::new(point.x, r.bottom()), Color32::from_rgba_unmultiplied(184, 192, 204, 4), ); 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); source_fill.add_triangle(j - 1, j, j + 1); } } p.add(egui::Shape::mesh(source_fill)); 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 { fill.push(Pos2::new( x(frequency(i)), red_y(state.analyzer.reduction[i]), )); } fill.push(r.right_top()); // A triangle strip makes non-convex reduction envelopes render accurately. let mut mesh = egui::Mesh::default(); for i in 0..GRID { let xx = x(frequency(i)); let yy = red_y(state.analyzer.reduction[i]); mesh.colored_vertex( Pos2::new(xx, r.top()), Color32::from_rgba_unmultiplied(255, 1, 96, 6), ); mesh.colored_vertex( Pos2::new(xx, yy), Color32::from_rgba_unmultiplied(255, 1, 96, 100), ); if i > 0 { let j = (i * 2) as u32; mesh.add_triangle(j - 2, j - 1, j); mesh.add_triangle(j - 1, j, j + 1); } } p.add(egui::Shape::mesh(mesh)); p.add(egui::Shape::line( fill[1..=GRID].to_vec(), Stroke::new(1.6_f32, ACCENT), )); let center = r.center().y; let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65; let points: Vec<_> = (0..GRID) .map(|i| { Pos2::new( x(frequency(i)), focus_y(focus_offset(frequency(i), &settings.focus)), ) }) .collect(); p.line_segment( [Pos2::new(r.left(), center), Pos2::new(r.right(), center)], Stroke::new(0.7_f32, FOCUS.gamma_multiply(0.3)), ); 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. }, 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, format!("{}", i + 1), FontId::proportional(12.), TEXT, ); } if state.analyzer.input_peak < 1e-6 { p.text( r.center() + Vec2::new(0., 42.), egui::Align2::CENTER_CENTER, "Play audio to inspect its resonances", FontId::proportional(18.), MUTED, ); } if let Some(pos) = response.hover_pos() { if r.contains(pos) { let f = 20. * 1000f32.powf((pos.x - r.left()) / r.width()); p.line_segment( [Pos2::new(pos.x, r.top()), Pos2::new(pos.x, r.bottom())], Stroke::new(0.7_f32, TEXT.gamma_multiply(0.3)), ); response.clone().on_hover_text(format!("{f:.0} Hz • Double-click to add focus; drag a node to tame/protect; wheel sets width")); if response.double_clicked() { if let Some(i) = settings.focus.iter().position(|p| !p.enabled) { settings.focus[i] = Focus { enabled: true, frequency: f, sensitivity: 0., ..Default::default() }; state.selected = Some(i); } } } } if response.drag_started() { if let Some(pos) = response.interact_pointer_pos() { state.drag = settings .focus .iter() .enumerate() .filter(|(_, f)| f.enabled) .find(|(_, f)| { Pos2::new(x(f.frequency), focus_y(f.sensitivity)).distance(pos) < 18. }) .map(|(i, _)| i); if state.drag.is_some() { state.selected = state.drag; } else if (pos.x - x(settings.low)).abs() < 12. { state.drag = Some(4); } else if (pos.x - x(settings.high)).abs() < 12. { state.drag = Some(5); } } } if response.dragged() { if let (Some(i), Some(pos)) = (state.drag, response.interact_pointer_pos()) { let fine = ui.input(|i| i.modifiers.shift); let f = (20. * 1000f32.powf((pos.x - r.left()) / r.width())).clamp(20., 20000.); if i < 4 { let node = &mut settings.focus[i]; let next = ((center - pos.y) / (r.height() * 0.65) * 36.).clamp(-18., 18.); if fine { node.frequency += (f - node.frequency) * 0.1; node.sensitivity += (next - node.sensitivity) * 0.1; } else { node.frequency = f; node.sensitivity = next; } } else if i == 4 { settings.low = f.min(settings.high - 1.); } else { settings.high = f.max(settings.low + 1.); } } } if response.drag_stopped() { state.drag = None; } if response.hovered() { if let Some(i) = state.selected { let scroll = ui.input(|i| i.smooth_scroll_delta.y); if scroll != 0. { settings.focus[i].width = (settings.focus[i].width + scroll * 0.002).clamp(0.05, 2.); } } } for f in [settings.low, settings.high] { p.line_segment( [Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())], 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, 2., Color32::from_rgb(46, 49, 55)); p.rect_stroke( handle, 6., Stroke::new(1.0_f32, STEEL.gamma_multiply(0.5)), egui::StrokeKind::Inside, ); } }