//! Knurl 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 knurl_core::{ AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS, STEP_MIN, }; pub use common_ui::style; pub const WIDTH: u32 = 1040; pub const HEIGHT: u32 = 650; const GRID: Color32 = Color32::from_rgb(24, 26, 30); const TOP: f32 = 316.; 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, } impl Default for State { fn default() -> Self { Self { analyzer: AnalyzerFrame::default(), gesture_active: false, help: DEFAULT_HELP.into(), } } } const DEFAULT_HELP: &str = "Every cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note."; impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { let old = self.analyzer; self.analyzer = frame; self.analyzer.input_db = frame.input_db.max(old.input_db - 3.); self.analyzer.output_db = frame.output_db.max(old.output_db - 3.); // Keep the last scope between notes. if !frame.scope_valid && old.scope_valid { self.analyzer.scope = old.scope; self.analyzer.scope_valid = true; } } } 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, help: &str, ) { ui.vertical_centered(|ui| { let r = rotary(ui, name, value, range, default, unit, 58., ACCENT); hint(&r, state, help); }); } 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, "KNURL", "CYCLE TEXTURE", |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.); ui.horizontal_top(|ui| { panel(ui, |ui| { ui.set_width(210.); ui.set_min_height(TOP); ui.vertical(|ui| note_panel(ui, settings, state)); }); ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { let frame = state.analyzer; scope(ui, &frame, 140.); ui.add_space(4.); pattern_editor(ui, settings, state, TOP - 144.); }); }); }); 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)); } fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let frame = state.analyzer; 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).size(13.).color(MUTED)); if frame.hz > 0. { ui.label( RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz)) .size(12.) .color(DIM), ); } ui.add_space(10.); meter(ui, "IN", frame.input_db, STEEL, 110.); meter(ui, "OUT", frame.output_db, ACCENT, 110.); ui.add_space(10.); caption(ui, "RANDOM"); ui.horizontal(|ui| { for (seed, label, help) in [ ( Seed::Same, "SAME EVERY NOTE", "Every note gets the same random sequence: identical notes sound identical.", ), ( Seed::Free, "FREE", "The random sequences keep running, so every note differs.", ), ] { let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.)); if r.clicked() { settings.seed = seed; } hint(&r, state, help); } }); ui.add_space(6.); let r = ui.checkbox( &mut settings.snap, RichText::new("Snap to semitone").size(12.), ); hint( &r, state, "Round the detected pitch. Turn it off for slides.", ); } /// The last cycles of the output, overlaid; the newest is brightest. fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) { let (outer, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover()); let p = ui.painter_at(outer); p.rect_filled(outer, 2., SCREEN); p.text( outer.left_top() + Vec2::new(8., 5.), egui::Align2::LEFT_TOP, format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"), FontId::proportional(10.), MUTED, ); let plot = Rect::from_min_max( outer.left_top() + Vec2::new(10., 20.), outer.right_bottom() - Vec2::new(10., 8.), ); for t in [0.25f32, 0.5, 0.75] { let x = plot.left() + plot.width() * t; p.line_segment( [Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())], Stroke::new(1.0_f32, GRID), ); } p.line_segment( [ Pos2::new(plot.left(), plot.center().y), Pos2::new(plot.right(), plot.center().y), ], Stroke::new(1.0_f32, EDGE), ); if !frame.scope_valid { p.text( plot.center(), egui::Align2::CENTER_CENTER, "play a note", FontId::proportional(12.), DIM, ); return; } let peak = frame .scope .iter() .flatten() .fold(1e-6f32, |m, v| m.max(v.abs())); // The newest cycle is still being written; draw the complete ones. for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() { let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32; let color = if age == SCOPE_CYCLES - 2 { ACCENT } else { STEEL.gamma_multiply(alpha * 0.6) }; let points: Vec = cycle .iter() .enumerate() .map(|(i, v)| { Pos2::new( plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32, plot.center().y - plot.height() * 0.48 * v / peak, ) }) .collect(); p.add(egui::Shape::line( points, Stroke::new( if age == SCOPE_CYCLES - 2 { 2.0_f32 } else { 1.0 }, color, ), )); } } fn step_y(rect: Rect, db: f32) -> f32 { rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.) } /// One bar per step: drag to set its level, double-click to reset; the square below a bar /// switches its grit burst. fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) { let (outer, response) = ui.allocate_exact_size( Vec2::new(ui.available_width(), height), Sense::click_and_drag(), ); let p = ui.painter_at(outer); p.rect_filled(outer, 2., SCREEN); p.text( outer.left_top() + Vec2::new(8., 5.), egui::Align2::LEFT_TOP, "PATTERN: LEVEL PER CYCLE", FontId::proportional(10.), MUTED, ); let bars = Rect::from_min_max( outer.left_top() + Vec2::new(34., 22.), Pos2::new(outer.right() - 10., outer.bottom() - 26.), ); let toggles_y = outer.bottom() - 16.; let col = bars.width() / STEPS as f32; let column = |x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize; if response.hovered() { state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into(); } if let Some(pos) = response.interact_pointer_pos() { let k = column(pos.x); if pos.y > bars.bottom() + 2. { if response.clicked() { settings.grit_steps[k] = !settings.grit_steps[k]; } } else if response.double_clicked() { settings.pattern[k] = 0.; } else if response.dragged() || response.clicked() { let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.); let db = t * STEP_MIN; settings.pattern[k] = if db > -0.5 { 0. } else { db.round() }; if k >= settings.steps { settings.steps = k + 1; } } } for db in [0f32, -6., -12., -18., -24.] { let y = step_y(bars, db); p.line_segment( [Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)], Stroke::new(1.0_f32, GRID), ); p.text( Pos2::new(outer.left() + 6., y), egui::Align2::LEFT_CENTER, if db == 0. { "0".into() } else { format!("{db:.0}") }, FontId::proportional(10.), DIM, ); } let current = state.analyzer.step; let playing = state.analyzer.note.is_some(); for k in 0..STEPS { let on = k < settings.steps; let x0 = bars.left() + k as f32 * col + 3.; let x1 = x0 + col - 6.; let top = step_y(bars, settings.pattern[k]); let color = if !on { DIM.gamma_multiply(0.5) } else if playing && k == current { TEXT } else { ACCENT }; p.rect_filled( Rect::from_min_max( Pos2::new(x0, top.min(bars.bottom() - 2.)), Pos2::new(x1, bars.bottom()), ), 1., color.gamma_multiply(if on { 0.85 } else { 0.4 }), ); let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF; let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.)); if settings.grit_steps[k] { p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM }); } else { p.rect_stroke( square, 1., Stroke::new(1.0_f32, DIM), egui::StrokeKind::Inside, ); } } p.text( Pos2::new(outer.left() + 6., toggles_y), egui::Align2::LEFT_CENTER, "grit", FontId::proportional(10.), DIM, ); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); ui.horizontal(|ui| { caption(ui, "PATTERN"); for (name, help, gains) in PATTERNS { let selected = settings.steps == gains.len() && settings.pattern[..gains.len()] .iter() .zip(gains) .all(|(a, b)| a == b); let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.)); if r.clicked() { settings.apply_pattern(gains); } hint(&r, state, help); } ui.add_space(24.); caption(ui, "STEPS"); let minus = ui.button(RichText::new("−").size(14.)); if minus.clicked() && settings.steps > 1 { settings.steps -= 1; } ui.label( RichText::new(format!("{:>2}", settings.steps)) .monospace() .size(14.) .color(TEXT), ); let plus = ui.button(RichText::new("+").size(14.)); if plus.clicked() && settings.steps < STEPS { settings.steps += 1; } let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down."; hint(&minus, state, help); hint(&plus, state, help); }); ui.add_space(4.); ui.columns(10, |c| { knob( &mut c[0], state, "Edge", &mut settings.edge, 5. ..=50., d.edge, " %", "How soft each step change is, as a share of a cycle. Low is sharper and buzzier.", ); knob( &mut c[1], state, "Level", &mut settings.level_jitter, 0. ..=6., d.level_jitter, " dB", "Random level per cycle: a rough, living surface.", ); knob( &mut c[2], state, "Time", &mut settings.time_jitter, 0. ..=10., d.time_jitter, " %", "Random timing per cycle, in percent of a period: rattle and scrape.", ); knob( &mut c[3], state, "Grit", &mut settings.grit, GRIT_OFF..=0., d.grit, " dB", "Level of the noise burst in each cycle, relative to the input. Fully left is off.", ); knob( &mut c[4], state, "Tone", &mut settings.tone, 4. ..=64., d.tone, " ×f0", "Lowest frequency of the grit, in harmonics of the note.", ); knob( &mut c[5], state, "Position", &mut settings.position, 0. ..=100., d.position, " %", "Where in the cycle the grit burst sits.", ); knob( &mut c[6], state, "Length", &mut settings.length, 2. ..=50., d.length, " %", "Length of the grit burst, as a share of a cycle.", ); knob( &mut c[7], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", "Input level that counts as a note (AUDIO). Raise it if tails retrigger.", ); knob( &mut c[8], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", "Blend with the delayed dry signal.", ); knob( &mut c[9], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", "Output trim.", ); }); }