//! Planish 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 planish_core::{ AnalyzerFrame, Settings, Source, BANDS, DOWN_THRESHOLD, FALLBACK_HZ, FLOOR, GAIN_MAX, GAIN_MIN, TOP_MAX, UP_MAX, UP_THRESHOLD, }; 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, /// Band edge being dragged on the harmonic ruler. dragging: Option, } impl Default for State { fn default() -> Self { Self { analyzer: AnalyzerFrame::default(), gesture_active: false, help: DEFAULT_HELP.into(), dragging: None, } } } const DEFAULT_HELP: &str = "Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note, wherever they are in Hz."; 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 frames. for b in 0..BANDS { self.analyzer.levels[b] = frame.levels[b].max(old.levels[b] - 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); /// One colour per band, bottom band first. const BAND_COLORS: [Color32; BANDS] = [ Color32::from_rgb(255, 1, 96), Color32::from_rgb(226, 120, 64), Color32::from_rgb(126, 167, 216), Color32::from_rgb(184, 192, 204), ]; /// Harmonics shown on the ruler: 1 to 64 on a log scale. const RULER_MAX: f32 = 64.; const LEVEL_TOP: f32 = 0.; const TOP: f32 = 330.; fn ruler_x(rect: Rect, harmonic: f32) -> f32 { rect.left() + rect.width() * (harmonic.max(1.).log2() / RULER_MAX.log2()) } fn x_ruler(rect: Rect, x: f32) -> f32 { 2f32.powf((x - rect.left()) / rect.width() * RULER_MAX.log2()) } fn level_y(rect: Rect, db: f32) -> f32 { let t = (db - FLOOR) / (LEVEL_TOP - FLOOR); rect.bottom() - rect.height() * t.clamp(0., 1.) } 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 edge_mut(settings: &mut Settings, edge: usize) -> &mut u32 { match edge { 0 => &mut settings.low, 1 => &mut settings.mid, _ => &mut settings.high, } } fn edge_range(edge: usize) -> (u32, u32) { match edge { 0 => (1, 8), 1 => (2, 16), _ => (3, TOP_MAX), } } /// Harmonic ruler with draggable band edges, then one column per band with its level, /// thresholds and compressor gain. fn band_display(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 ruler = Rect::from_min_max( outer.left_top() + Vec2::new(16., 26.), Pos2::new(outer.right() - 16., outer.top() + 62.), ); let columns = Rect::from_min_max( Pos2::new(outer.left() + 40., ruler.bottom() + 34.), Pos2::new(outer.right() - 40., outer.bottom() - 34.), ); let tops = settings.tops(); let f = state.analyzer; let root = if f.hz > 0. { f.hz } else { FALLBACK_HZ }; // Ruler: band spans, harmonic ticks, and edges between harmonics. let edge_at = |k: u32| ((k * (k + 1)) as f32).sqrt(); let mut from = 1f32; for b in 0..BANDS { let to = if b < BANDS - 1 { edge_at(tops[b]) } else { RULER_MAX }; let span = Rect::from_min_max( Pos2::new(ruler_x(ruler, from), ruler.top() + 14.), Pos2::new(ruler_x(ruler, to), ruler.bottom()), ); p.rect_filled(span, 1., BAND_COLORS[b].gamma_multiply(0.22)); p.text( span.center_top() + Vec2::new(0., -2.), egui::Align2::CENTER_BOTTOM, format!("BAND {} · {}", b + 1, settings.band_label(b)), FontId::proportional(10.), BAND_COLORS[b], ); from = to; } for k in 1..=RULER_MAX as u32 { let x = ruler_x(ruler, k as f32); let major = k.is_power_of_two() || k <= 8; let tick = if major { 10. } else { 5. }; p.line_segment( [ Pos2::new(x, ruler.bottom() - tick), Pos2::new(x, ruler.bottom()), ], Stroke::new(1.0_f32, if major { MUTED } else { DIM }), ); if major { p.text( Pos2::new(x, ruler.bottom() + 3.), egui::Align2::CENTER_TOP, format!("{k}"), FontId::proportional(10.), MUTED, ); } } let nearest = |x: f32| { (0..BANDS - 1) .min_by(|&a, &b| { let da = (ruler_x(ruler, edge_at(tops[a])) - x).abs(); let db = (ruler_x(ruler, edge_at(tops[b])) - x).abs(); da.total_cmp(&db) }) .unwrap_or(0) }; let hover_edge = response .hover_pos() .filter(|p| p.y < ruler.bottom() + 16.) .map(|p| nearest(p.x)); if response.drag_started() { state.dragging = response .interact_pointer_pos() .filter(|p| p.y < ruler.bottom() + 16.) .map(|p| nearest(p.x)); } if let (Some(edge), Some(pos)) = (state.dragging, response.interact_pointer_pos()) { let (lo, hi) = edge_range(edge); // The edge above harmonic k sits at √(k(k+1)), so k is the harmonic below the pointer. let k = (x_ruler(ruler, pos.x) - 0.5) .round() .clamp(lo as f32, hi as f32) as u32; *edge_mut(settings, edge) = k; // Keep the order visible while dragging: the other edges make room. let t = settings.tops(); settings.low = t[0]; settings.mid = t[1].min(16); settings.high = t[2]; } if !response.dragged() { state.dragging = None; } for (e, &top) in settings.tops().iter().enumerate() { let x = ruler_x(ruler, edge_at(top)); let active = state.dragging == Some(e) || hover_edge == Some(e); p.line_segment( [ Pos2::new(x, ruler.top() + 10.), Pos2::new(x, ruler.bottom() + 2.), ], Stroke::new( if active { 2.5_f32 } else { 1.5 }, if active { TEXT } else { STEEL }, ), ); } // Columns: level (steel) against the thresholds, gain (accent cut, blue lift). for db in [0., -12., -24., -36., -48., -60., -72.] { let y = level_y(columns, db); p.line_segment( [Pos2::new(columns.left(), y), Pos2::new(columns.right(), y)], Stroke::new(1.0_f32, GRID), ); p.text( Pos2::new(outer.left() + 8., y), egui::Align2::LEFT_CENTER, format!("{db:.0}"), FontId::proportional(10.), DIM, ); } // Gain scale on the right, for the bars. for db in [24., 12., 0., -12., -24.] { p.text( Pos2::new(outer.right() - 8., gain_y(columns, db)), egui::Align2::RIGHT_CENTER, if db == 0. { "0".into() } else { format!("{db:+.0}") }, FontId::proportional(10.), if db == 0. { MUTED } else { DIM }, ); } let col = columns.width() / BANDS as f32; let depth = settings.depth * 0.01; for b in 0..BANDS { let x0 = columns.left() + col * b as f32; let level = Rect::from_min_max( Pos2::new(x0 + col * 0.14, level_y(columns, f.levels[b])), Pos2::new(x0 + col * 0.44, columns.bottom()), ); if level.height() > 0.5 { p.rect_filled(level, 1., STEEL.gamma_multiply(0.8)); } for (db, amount, color) in [ (DOWN_THRESHOLD, settings.downward, ACCENT), (UP_THRESHOLD, settings.upward, FOCUS), ] { if amount > 0. { let y = level_y(columns, db); p.line_segment( [Pos2::new(x0 + col * 0.08, y), Pos2::new(x0 + col * 0.5, y)], Stroke::new(1.0_f32, color.gamma_multiply(0.8)), ); } } // The same blend as the engine: Depth mixes the band with its compressed copy. let wet = 10f32.powf((f.gains[b] + settings.gains[b]) / 20.); let gain = 20. * (1. + depth * (wet - 1.)).max(1e-6).log10(); let zero = gain_y(columns, 0.); let y = gain_y(columns, gain); let bar = Rect::from_min_max( Pos2::new(x0 + col * 0.56, zero.min(y)), Pos2::new(x0 + col * 0.86, zero.max(y)), ); p.line_segment( [ Pos2::new(x0 + col * 0.52, zero), Pos2::new(x0 + col * 0.9, zero), ], Stroke::new(1.0_f32, EDGE), ); if bar.height() >= 1. { p.rect_filled(bar, 1., if gain < 0. { ACCENT } else { FOCUS }); } let hz = |k: f32| k * root; let low = if b == 0 { 1. } else { edge_at(tops[b - 1]) }; let range = if b < BANDS - 1 { format!("{:.0}–{:.0} Hz", hz(low), hz(edge_at(tops[b]))) } else { format!("{:.0} Hz +", hz(low)) }; p.text( Pos2::new(x0 + col * 0.5, columns.bottom() + 6.), egui::Align2::CENTER_TOP, format!("{} · {gain:+.1} dB", settings.band_label(b)), FontId::proportional(11.), BAND_COLORS[b], ); p.text( Pos2::new(x0 + col * 0.5, columns.bottom() + 20.), egui::Align2::CENTER_TOP, range, FontId::proportional(10.), MUTED, ); } p.text( Pos2::new(outer.right() - 8., ruler.bottom() + 18.), egui::Align2::RIGHT_TOP, "LEVEL dB · GAIN dB", FontId::proportional(11.), MUTED, ); p.text( outer.left_top() + Vec2::new(8., 6.), egui::Align2::LEFT_TOP, "HARMONIC", FontId::proportional(11.), MUTED, ); p.rect_stroke( outer, 2., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); if let Some(e) = state.dragging.or(hover_edge) { let k = tops[e]; state.help = format!( "Edge between harmonics {k} and {}: {:.0} Hz on this note. Drag to move it.", k + 1, edge_at(k) * root, ); } else if response.hovered() { state.help = format!( "Level plus Drive (steel) against the upward (blue) and downward (pink) thresholds; bar: band gain. Lift is at most {UP_MAX:.0} dB." ); } } 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, "PLANISH", "HARMONIC OTT", |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()); band_display(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)); } /// Label, help text and band edges (highest harmonic of bands 1–3). type Split = (&'static str, &'static str, [u32; 3]); 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(), format!("no note yet · edges on {FALLBACK_HZ:.0} Hz"), ), }; 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, "SPLITS"); let splits: [Split; 4] = [ ( "CLASSIC", "Fundamental alone, body 2–4, mids 5–16, top 17 and up.", [1, 4, 16], ), ( "SUB + BODY", "Harmonics 1–2 together, so the octave stays locked to the sub.", [2, 6, 24], ), ( "TIGHT", "Narrow low bands for close control of the first harmonics.", [1, 2, 8], ), ( "WIDE TOP", "One big mid band, a top that starts high: fizz only.", [1, 3, 32], ), ]; ui.horizontal_wrapped(|ui| { for (label, help, [low, mid, high]) in splits { let on = settings.tops() == [low, mid, high]; let r = ui.selectable_label(on, RichText::new(label).size(12.)); if r.clicked() { settings.low = low; settings.mid = mid; settings.high = high; } 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. Off keeps the level window on the real period, which is steadier on detuned patches.", ); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); ui.columns(12, |c| { knob(&mut c[0], state, "Drive", &mut settings.drive, -24. ..=24., d.drive, " dB", 64., "Pushes the band levels against the thresholds: more drive, more squash. It does not change the level by itself."); knob(&mut c[1], state, "Depth", &mut settings.depth, 0. ..=100., d.depth, " %", 64., "How much of the compression is applied. 0 % is a clean crossover sum."); knob(&mut c[2], state, "Upward", &mut settings.upward, 0. ..=100., d.upward, " %", 64., "Lift for bands below the upward threshold: brings up quiet harmonics."); knob(&mut c[3], state, "Downward", &mut settings.downward, 0. ..=100., d.downward, " %", 64., "Cut for bands above the downward threshold: holds loud harmonics down."); knob(&mut c[4], state, "Time", &mut settings.time, 10. ..=1000., d.time, " %", 64., "Scales every attack and release. Lower bands are slower than higher ones."); for b in 0..BANDS { let name = format!("Band {}", b + 1); let help = format!( "Gain of band {} (harmonics {}) after its compressor, scaled by Depth.", b + 1, settings.band_label(b) ); knob(&mut c[5 + b], state, &name, &mut settings.gains[b], GAIN_MIN..=GAIN_MAX, d.gains[b], " dB", 54., &help); } knob(&mut c[9], 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[10], 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[11], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54., "Output trim."); }); }