//! Temper editor, shared by the CLAP wrapper and the headless renderer. use common_dsp::db_to_gain; use common_ui::*; use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; use temper_core::{AnalyzerFrame, Curve, Settings, Source, SHOWN}; 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, } impl Default for State { fn default() -> Self { Self { analyzer: AnalyzerFrame::default(), gesture_active: false, help: DEFAULT_HELP.into(), } } } const DEFAULT_HELP: &str = "Every note is driven the same: the input level is normalised per note and the tone filters follow the pitch."; impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { let old = self.analyzer; self.analyzer = frame; for k in 0..SHOWN { self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.); self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.); } self.analyzer.shaper_db = frame.shaper_db.max(old.shaper_db - 1.5); } } 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); /// Input peak at which a normalised note reaches the shaper before Drive. const NOMINAL: f32 = 0.35; fn transfer(ui: &mut egui::Ui, settings: &Settings, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); let p = ui.painter_at(rect); p.rect_filled(rect, 2., SCREEN); let c = rect.center(); let half = rect.width() * 0.45; for t in [-1., -0.5, 0.5, 1.] { p.line_segment( [ Pos2::new(c.x + t * half, rect.top()), Pos2::new(c.x + t * half, rect.bottom()), ], Stroke::new(1.0_f32, GRID), ); p.line_segment( [ Pos2::new(rect.left(), c.y - t * half), Pos2::new(rect.right(), c.y - t * half), ], Stroke::new(1.0_f32, GRID), ); } p.line_segment( [Pos2::new(rect.left(), c.y), Pos2::new(rect.right(), c.y)], Stroke::new(1.0_f32, EDGE), ); p.line_segment( [Pos2::new(c.x, rect.top()), Pos2::new(c.x, rect.bottom())], Stroke::new(1.0_f32, EDGE), ); p.line_segment( [ Pos2::new(c.x - half, c.y + half), Pos2::new(c.x + half, c.y - half), ], Stroke::new(1.0_f32, DIM), ); // Input axis: a normalised note's peak at ±1; output: the shaper's ±1. let gain = db_to_gain(settings.drive) * NOMINAL; let bias = settings.bias * 0.008; let offset = settings.curve.shape(bias); let points: Vec = (0..=160) .map(|i| { let x = -1.1 + 2.2 * i as f32 / 160.; let y = settings.curve.shape(x * gain + bias) - offset; Pos2::new(c.x + x * half, c.y - y.clamp(-1.1, 1.1) * half) }) .collect(); p.add(egui::Shape::line(points, Stroke::new(2.0_f32, ACCENT))); p.text( rect.left_top() + Vec2::new(8., 6.), egui::Align2::LEFT_TOP, "TRANSFER", FontId::proportional(11.), MUTED, ); p.rect_stroke( rect, 2., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); } fn spectrum(ui: &mut egui::Ui, frame: &AnalyzerFrame, settings: &Settings, height: f32) { let size = Vec2::new(ui.available_width(), height); let (outer, response) = ui.allocate_exact_size(size, Sense::hover()); let p = ui.painter_at(outer); p.rect_filled(outer, 2., SCREEN); let area = Rect::from_min_max( outer.left_top() + Vec2::new(34., 24.), outer.right_bottom() - Vec2::new(10., 24.), ); let floor = -72.; let y = |db: f32| area.bottom() - area.height() * ((db - floor) / -floor).clamp(0., 1.); for db in [0., -12., -24., -36., -48., -60.] { let yy = y(db); p.line_segment( [Pos2::new(area.left(), yy), Pos2::new(area.right(), yy)], Stroke::new(1.0_f32, GRID), ); p.text( Pos2::new(outer.left() + 6., yy), egui::Align2::LEFT_CENTER, format!("{db:.0}"), FontId::proportional(10.), DIM, ); } let col = area.width() / SHOWN as f32; for k in 0..SHOWN { let x0 = area.left() + col * k as f32; for (i, (db, color)) in [(frame.input[k], STEEL), (frame.output[k], ACCENT)] .into_iter() .enumerate() { if db <= floor { continue; } let w = col * 0.32; let x = x0 + col * 0.16 + i as f32 * (w + col * 0.04); p.rect_filled( Rect::from_min_max(Pos2::new(x, y(db)), Pos2::new(x + w, area.bottom())), 0., color, ); } let marked = (k + 1) as u32 == settings.push_harmonic && settings.push_gain != 0.; p.text( Pos2::new(x0 + col * 0.5, area.bottom() + 4.), egui::Align2::CENTER_TOP, format!("{}", k + 1), FontId::proportional(10.), if marked { ACCENT } else { MUTED }, ); } // Tracked filter positions, in harmonic numbers. let mark = |h: f32, label: &str| { if h > 0. && h <= SHOWN as f32 + 0.5 { let x = area.left() + col * (h - 0.5); p.line_segment( [Pos2::new(x, area.top()), Pos2::new(x, area.bottom())], Stroke::new(1.0_f32, FOCUS), ); p.text( Pos2::new(x + 4., area.top()), egui::Align2::LEFT_TOP, label, FontId::proportional(10.), FOCUS, ); } }; mark(settings.floor, "FLOOR"); mark(settings.bright, "BRIGHT"); p.text( outer.left_top() + Vec2::new(34., 6.), egui::Align2::LEFT_TOP, "HARMONICS", FontId::proportional(11.), MUTED, ); p.text( outer.right_top() + Vec2::new(-10., 6.), egui::Align2::RIGHT_TOP, "IN · OUT", FontId::proportional(11.), MUTED, ); p.rect_stroke( outer, 2., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); let _ = response; } 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, "TEMPER", "TRACKED DISTORTION", |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; } let r = ui .selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.)) .on_hover_text( "Match output loudness to the input, so Drive changes character, not level.", ); if r.clicked() { settings.auto_gain = !settings.auto_gain; } 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, &frame)); }); ui.add_space(4.); panel(ui, |ui| { ui.set_min_height(TOP); ui.vertical(|ui| { transfer(ui, settings, 236.); ui.add_space(4.); ui.horizontal(|ui| { for curve in Curve::ALL { let r = ui.selectable_label( settings.curve == curve, RichText::new(curve.name().to_uppercase()).size(12.), ); if r.clicked() { settings.curve = curve; } hint(&r, state, match curve { Curve::Soft => "Soft: tanh. Smooth, odd harmonics, rounded top.", Curve::Hard => "Hard: clipping with a narrow knee. Dense, square-like.", Curve::Fold => "Fold: the wave folds back on itself. Drive moves the upper harmonics.", Curve::Tube => "Tube: the negative half clips earlier. Adds even harmonics.", }); } }); }); }); ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); spectrum(ui, &frame, settings, 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 = 290.; fn note_panel(ui: &mut egui::Ui, settings: &Settings, 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, "INTO SHAPER"); let (rect, _) = ui.allocate_exact_size(Vec2::new(180., 10.), Sense::hover()); let p = ui.painter(); p.rect_filled(rect, 1., SCREEN); // −24..+24 dB around the curve's knee at 0 dB. let t = ((frame.shaper_db + 24.) / 48.).clamp(0., 1.); let mut fill = rect; fill.set_width(rect.width() * t); p.rect_filled(fill, 1., ACCENT); let knee = rect.left() + rect.width() * 0.5; p.line_segment( [ Pos2::new(knee, rect.top() - 2.), Pos2::new(knee, rect.bottom() + 2.), ], Stroke::new(1.0_f32, TEXT), ); p.rect_stroke( rect, 1., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); ui.label( RichText::new(if frame.shaper_db < -60. { "—".to_string() } else { format!("{:+.1} dB over the knee", frame.shaper_db) }) .monospace() .size(12.) .color(TEXT), ); ui.add_space(8.); readout( ui, "NORMALIZE GAIN", format!("{:+.1} dB", frame.normalize_db), STEEL, ); readout( ui, "AUTO GAIN", if settings.auto_gain { format!("{:+.1} dB", frame.auto_db) } else { "off".into() }, STEEL, ); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); ui.columns(10, |c| { knob(&mut c[0], state, "Drive", &mut settings.drive, 0. ..=36., d.drive, " dB", 80., "Gain into the curve. With Normalize at 100 % every note starts from the same level."); knob(&mut c[1], state, "Bias", &mut settings.bias, 0. ..=100., d.bias, " %", 80., "Shift the curve's operating point: asymmetric clipping, even harmonics."); knob(&mut c[2], state, "Push", &mut settings.push_gain, -12. ..=12., d.push_gain, " dB", 80., "Boost or cut one harmonic before the curve, so the distortion bites there on every note."); knob(&mut c[3], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 80., "Blend with the delayed dry signal."); let mut push = settings.push_harmonic as f32; knob(&mut c[4], state, "Push at", &mut push, 1. ..=16., d.push_harmonic as f32, "", 54., "Harmonic number that Push boosts or cuts."); settings.push_harmonic = push.round().clamp(1., 16.) as u32; knob(&mut c[5], state, "Floor", &mut settings.floor, 0. ..=2., d.floor, " ×f0", 54., "Highpass before the curve at this multiple of the note. 0 is off. Keeps sub mud out of the distortion."); if settings.floor < 0.25 { settings.floor = 0.; } knob(&mut c[6], state, "Bright", &mut settings.bright, 0. ..=64., d.bright, " ×f0", 54., "Lowpass after the curve at this harmonic. 0 is off. The top end stops at the same harmonic on every note."); if settings.bright < 4. { settings.bright = 0.; } knob(&mut c[7], state, "Normalize", &mut settings.normalize, 0. ..=100., d.normalize, " %", 54., "How far each note's level is evened out before Drive. 100 %: quiet and loud patches distort the same."); knob(&mut c[8], 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[9], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., "Output trim."); }); }