//! Lathe editor, shared by the CLAP wrapper and the headless renderer. use common_ui::*; use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; use lathe_core::{AnalyzerFrame, Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS}; pub use common_ui::style; pub const WIDTH: u32 = 1040; pub const HEIGHT: u32 = 650; pub struct State { pub analyzer: AnalyzerFrame, /// True while a pointer button is held, so knob drags become one host gesture. pub gesture_active: bool, pub help: String, /// Last point of a draw stroke in the harmonic editor: (harmonic index, dB). stroke: Option<(usize, f32)>, } impl Default for State { fn default() -> Self { Self { analyzer: AnalyzerFrame::default(), gesture_active: false, help: DEFAULT_HELP.into(), stroke: None, } } } const DEFAULT_HELP: &str = "Draw gains on the harmonics. They follow the note: harmonic 3 stays harmonic 3 on every pitch."; impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { let old = self.analyzer; self.analyzer = frame; // Levels fall smoothly so bars stay readable between windows. for k in 0..HARMONICS { self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.); self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.); } } } fn hint(r: &egui::Response, state: &mut State, text: &str) { if r.hovered() || r.has_focus() { state.help = text.into(); } } #[allow(clippy::too_many_arguments)] fn knob( ui: &mut egui::Ui, state: &mut State, name: &str, value: &mut f32, range: std::ops::RangeInclusive, default: f32, unit: &str, size: f32, help: &str, ) { ui.vertical_centered(|ui| { let r = rotary(ui, name, value, range, default, unit, size, ACCENT); hint(&r, state, help); }); } const GRID: Color32 = Color32::from_rgb(24, 26, 30); const LEVEL_FLOOR: f32 = -72.; fn gain_y(rect: Rect, db: f32) -> f32 { let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN); rect.bottom() - rect.height() * t.clamp(0., 1.) } fn y_gain(rect: Rect, y: f32) -> f32 { let t = (rect.bottom() - y) / rect.height(); // Snap near 0 dB so drawing a flat line is easy. let db = GAIN_MIN + t * (GAIN_MAX - GAIN_MIN); let db = if db.abs() < 0.4 { 0. } else { db }; db.clamp(GAIN_MIN, GAIN_MAX) } /// Bar editor of the per-harmonic gains, with the effective curve and measured levels. fn harmonic_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) { let size = Vec2::new(ui.available_width(), height); let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag()); let p = ui.painter_at(outer); p.rect_filled(outer, 2., SCREEN); let levels_h = 64.; let labels_h = 18.; let left = 34.; let gains = Rect::from_min_max( outer.left_top() + Vec2::new(left, 12.), Pos2::new( outer.right() - 10., outer.bottom() - levels_h - labels_h - 8., ), ); let levels = Rect::from_min_max( Pos2::new(gains.left(), gains.bottom() + labels_h + 4.), Pos2::new(gains.right(), outer.bottom() - 6.), ); let col = gains.width() / HARMONICS as f32; let column = |x: f32| { (((x - gains.left()) / col).floor() as isize).clamp(0, HARMONICS as isize - 1) as usize }; // Interaction: drag draws, Shift-drag nudges, double-click resets a bar. if let Some(pos) = response.interact_pointer_pos() { let k = column(pos.x); if response.double_clicked() { settings.gains[k] = 0.; state.stroke = None; } else if response.dragged() || response.drag_started() || response.clicked() { if ui.input(|i| i.modifiers.shift) { let dy = ui.input(|i| i.pointer.delta().y); let step = dy * (GAIN_MAX - GAIN_MIN) / gains.height() * 0.15; settings.gains[k] = (settings.gains[k] - step).clamp(GAIN_MIN, GAIN_MAX); } else { let db = y_gain(gains, pos.y); // Fill every column the pointer skipped since the last frame. let (from, from_db) = state.stroke.unwrap_or((k, db)); let (a, b) = (from.min(k), from.max(k)); for i in a..=b { let t = if b == a { 1. } else { (i as f32 - from as f32) / (k as f32 - from as f32) }; settings.gains[i] = from_db + (db - from_db) * t.clamp(0., 1.); } state.stroke = Some((k, db)); } } } if !response.dragged() { state.stroke = None; } // Grid: dB lines with labels. for db in [12., 6., 0., -6., -12., -18., -24.] { let y = gain_y(gains, db); let zero = db == 0.; p.line_segment( [Pos2::new(gains.left(), y), Pos2::new(gains.right(), y)], Stroke::new(1.0_f32, if zero { EDGE } else { GRID }), ); p.text( Pos2::new(outer.left() + 6., y), egui::Align2::LEFT_CENTER, if zero { "0".into() } else { format!("{db:+.0}") }, FontId::proportional(10.), if zero { MUTED } else { DIM }, ); } let zero = gain_y(gains, 0.); let active = settings.harmonics as usize; let hover = response.hover_pos().map(|pos| column(pos.x)); let mut curve = Vec::with_capacity(HARMONICS); for k in 0..HARMONICS { let x0 = gains.left() + col * k as f32; let bar = Rect::from_min_max( Pos2::new(x0 + col * 0.18, zero.min(gain_y(gains, settings.gains[k]))), Pos2::new(x0 + col * 0.82, zero.max(gain_y(gains, settings.gains[k]))), ); let on = k < active; let fill = if !on { Color32::from_rgb(40, 42, 47) } else if hover == Some(k) { Color32::from_rgb(255, 60, 130) } else { ACCENT }; if bar.height() >= 1. { p.rect_filled(bar, 1., fill); } else { p.line_segment( [Pos2::new(bar.left(), zero), Pos2::new(bar.right(), zero)], Stroke::new(2.0_f32, if on { DIM } else { GRID }), ); } if on { curve.push(Pos2::new( x0 + col * 0.5, gain_y(gains, settings.effective(k + 1)), )); } let label_color = if hover == Some(k) { TEXT } else if on { MUTED } else { DIM }; p.text( Pos2::new(x0 + col * 0.5, gains.bottom() + 4.), egui::Align2::CENTER_TOP, format!("{}", k + 1), FontId::proportional(10.), label_color, ); // Measured levels: input (steel) and output (accent), side by side. let f = &state.analyzer; for (i, (db, color)) in [(f.input[k], STEEL), (f.output[k], ACCENT)] .into_iter() .enumerate() { let t = ((db - LEVEL_FLOOR) / -LEVEL_FLOOR).clamp(0., 1.); if t <= 0. { continue; } let w = col * 0.3; let x = x0 + col * 0.18 + i as f32 * (w + col * 0.04); let r = Rect::from_min_max( Pos2::new(x, levels.bottom() - levels.height() * t), Pos2::new(x + w, levels.bottom()), ); p.rect_filled(r, 0., color.gamma_multiply(if on { 0.9 } else { 0.4 })); } } if curve.len() > 1 { p.add(egui::Shape::line(curve, Stroke::new(1.5_f32, FOCUS))); } p.line_segment( [ Pos2::new(levels.left(), levels.top() - 2.), Pos2::new(levels.right(), levels.top() - 2.), ], Stroke::new(1.0_f32, GRID), ); p.text( gains.right_top() + Vec2::new(-4., 2.), egui::Align2::RIGHT_TOP, "GAIN · EFFECTIVE", FontId::proportional(11.), MUTED, ); p.text( Pos2::new(levels.right() - 4., levels.top() + 2.), egui::Align2::RIGHT_TOP, "LEVEL IN · OUT", FontId::proportional(11.), MUTED, ); p.rect_stroke( outer, 2., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); if let Some(k) = hover { let f = &state.analyzer; let at = if f.hz > 0. { format!(" at {:.0} Hz", f.hz * (k + 1) as f32) } else { String::new() }; state.help = format!( "Harmonic {}{at}: drawn {:+.1} dB, effective {:+.1} dB. Drag to draw, Shift-drag fine, double-click resets.", k + 1, settings.gains[k], settings.effective(k + 1), ); } } pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { backdrop(ui); state.help = DEFAULT_HELP.into(); state.gesture_active = ui.input(|i| i.pointer.any_down()); ui.add_space(2.); header(ui, "LATHE", "HARMONIC EQ", |ui| { let r = ui .selectable_label(settings.bypass, RichText::new("BYPASS").size(13.)) .on_hover_text("Crossfade to the delayed dry signal."); if r.clicked() { settings.bypass = !settings.bypass; } ui.separator(); for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] { let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.)); if r.clicked() { settings.source = source; } } caption(ui, "PITCH FROM"); }); ui.add_space(6.); let frame = state.analyzer; ui.horizontal_top(|ui| { panel(ui, |ui| { ui.set_width(210.); ui.set_min_height(TOP); ui.vertical(|ui| note_panel(ui, settings, state, &frame)); }); ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); harmonic_editor(ui, settings, state, TOP); }); }); ui.add_space(6.); panel(ui, |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| controls(ui, settings, state)); }); ui.separator(); ui.label(RichText::new(&state.help).color(MUTED)); } const TOP: f32 = 330.; /// Label, help text, and gain in dB for 1-based harmonic `k`. type Preset = (&'static str, &'static str, fn(usize) -> f32); fn note_panel( ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, frame: &AnalyzerFrame, ) { caption(ui, "NOTE"); let (name, hz) = match frame.note { Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)), _ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)), _ => ("—".into(), "no note yet".into()), }; ui.label( RichText::new(name) .font(FontId::new(54., egui::FontFamily::Name("medium".into()))) .color(if frame.note.is_some() { TEXT } else { DIM }), ); ui.label(RichText::new(hz).monospace().size(14.).color(MUTED)); ui.add_space(6.); ui.label( RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate * 1000., match settings.source { Source::Audio => "detecting pitch", Source::Midi => "following MIDI", } )) .size(12.) .color(MUTED), ); ui.add_space(14.); caption(ui, "SHAPES"); let presets: [Preset; 4] = [ ("FLAT", "Reset every drawn gain to 0 dB.", |_| 0.), ( "ODD ONLY", "Remove even harmonics: hollow, square-like.", |k| { if k % 2 == 0 { -24. } else { 0. } }, ), ( "EVEN ONLY", "Keep the fundamental and even harmonics: octave-like, smooth.", |k| { if k > 1 && k % 2 == 1 { -24. } else { 0. } }, ), ( "HOLLOW", "Cut harmonics 2–4: scooped, vocal growl body.", |k| { if (2..=4).contains(&k) { -12. } else { 0. } }, ), ]; ui.horizontal_wrapped(|ui| { for (label, help, f) in presets { let r = ui.button(RichText::new(label).size(12.)); if r.clicked() { for k in 0..HARMONICS { settings.gains[k] = f(k + 1); } } hint(&r, state, help); } }); ui.add_space(10.); let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.)); if r.clicked() { settings.snap = !settings.snap; } hint( &r, state, "Round the detected pitch. Turn off for slides and pitch bends.", ); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); ui.columns(9, |c| { knob(&mut c[0], state, "Tilt", &mut settings.tilt, -12. ..=12., d.tilt, " dB/oct", 80., "Brighter or darker by harmonic number: dB per octave of harmonics, pivot on the fundamental."); knob(&mut c[1], state, "Odd/Even", &mut settings.odd_even, -12. ..=12., d.odd_even, " dB", 80., "Positive: odd harmonics up, even down (square-like). Negative: the reverse."); knob(&mut c[2], state, "Between", &mut settings.between, -24. ..=12., d.between, " dB", 80., "Gain between the harmonics. Cut to clean noise and beating, lift for grit."); knob(&mut c[3], state, "Width", &mut settings.width, 5. ..=50., d.width, " %", 80., "Bandwidth of every band as a share of the harmonic spacing."); let mut harmonics = settings.harmonics as f32; knob(&mut c[4], state, "Harmonics", &mut harmonics, 8. ..=32., d.harmonics as f32, "", 54., "How many harmonics are shaped. Higher harmonics pass unchanged."); settings.harmonics = harmonics.round() as u32; knob(&mut c[5], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54., "Retune time for slides and bends. New notes always retune at once."); knob(&mut c[6], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54., "Input level that counts as a note. Raise it if tails retrigger."); knob(&mut c[7], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 54., "Blend with the delayed dry signal."); knob(&mut c[8], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., "Output trim."); }); }