//! Ballast editor, shared by the CLAP wrapper and the headless renderer. use ballast_core::{ AnalyzerFrame, Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, HISTORY, RANGE_MAX, TARGET_MAX, TARGET_MIN, }; use common_ui::*; use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; pub use common_ui::style; pub const WIDTH: u32 = 980; pub const HEIGHT: u32 = 660; pub struct State { pub analyzer: AnalyzerFrame, /// Set by the Learn button; the wrapper passes it to the engine and clears it. pub learn_request: bool, /// The last Learn result already applied. None until the first frame arrives, so an old /// result is not applied again when the editor opens. learn_seen: Option, learned: Option, /// 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(), learn_request: false, learn_seen: None, learned: None, gesture_active: false, help: DEFAULT_HELP.into(), } } } const DEFAULT_HELP: &str = "Put Ballast on the bass after its sound design. Press LEARN on the \ switch-up that sits right, or set Target."; impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { match self.learn_seen { Some(id) if id != frame.learn_id && frame.learned.is_finite() => { self.learned = Some(frame.learned); } _ => {} } self.learn_seen = Some(frame.learn_id); self.analyzer = frame; } } 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, 72., ACCENT); hint(&r, state, help); }); } const LOW: f32 = -42.; const HIGH: f32 = 0.; /// The last parts: their loudness in (bars), after Ballast (marks) and the target. fn parts(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover()); let p = ui.painter_at(rect); p.rect_filled(rect, 2., SCREEN); p.text( rect.left_top() + Vec2::new(10., 8.), egui::Align2::LEFT_TOP, format!("LAST {HISTORY} PARTS · LOUDNESS OF THE FIRST 100 MS"), FontId::proportional(11.), MUTED, ); p.text( rect.right_top() + Vec2::new(-12., 8.), egui::Align2::RIGHT_TOP, "bar: in · mark: out", FontId::proportional(11.), MUTED, ); let area = Rect::from_min_max( rect.left_top() + Vec2::new(44., 30.), rect.right_bottom() - Vec2::new(12., 12.), ); let y = |lufs: f32| { let t = ((lufs - LOW) / (HIGH - LOW)).clamp(0., 1.); area.bottom() - t * area.height() }; for db in [-36., -24., -12., 0.] { p.line_segment( [ Pos2::new(area.left(), y(db)), Pos2::new(area.right(), y(db)), ], Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)), ); p.text( Pos2::new(area.left() - 6., y(db)), egui::Align2::RIGHT_CENTER, format!("{db:.0}"), FontId::monospace(10.), DIM, ); } let column = area.width() / HISTORY as f32; // Newest part on the right. let offset = HISTORY - frame.count; for i in 0..frame.count { let part = frame.history[i]; let x = area.left() + column * (offset + i) as f32; let bar = Rect::from_min_max( Pos2::new(x + column * 0.2, y(part.loudness)), Pos2::new(x + column * 0.8, area.bottom()), ); let color = if part.gated { DIM.gamma_multiply(0.6) } else { STEEL.gamma_multiply(0.45) }; p.rect_filled(bar, 1., color); if !part.gated { let out = y(part.loudness + part.gain); p.line_segment( [ Pos2::new(x + column * 0.1, out), Pos2::new(x + column * 0.9, out), ], Stroke::new(3.0_f32, ACCENT), ); } } let t = y(settings.target); p.line_segment( [Pos2::new(area.left(), t), Pos2::new(area.right(), t)], Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.5)), ); p.text( Pos2::new(area.left() + 4., t - 3.), egui::Align2::LEFT_BOTTOM, format!("TARGET {:.1} LUFS", settings.target), FontId::proportional(10.), ACCENT, ); p.rect_stroke( rect, 2., Stroke::new(1.0_f32, EDGE), egui::StrokeKind::Inside, ); } 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()); if let Some(l) = state.learned.take() { settings.target = l.clamp(TARGET_MIN, TARGET_MAX); } ui.add_space(2.); header(ui, "BALLAST", "SWITCH-UP LEVELLER", |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(); if settings.segment == Segment::Notes { 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, "NOTES FROM"); ui.separator(); } for (segment, label) in [(Segment::Grid, "GRID"), (Segment::Notes, "NOTES")] { let r = ui.selectable_label(settings.segment == segment, RichText::new(label).size(13.)); if r.clicked() { settings.segment = segment; } } caption(ui, "PARTS FROM"); }); ui.add_space(6.); let frame = state.analyzer; ui.horizontal_top(|ui| { panel(ui, |ui| { ui.set_width(250.); ui.set_min_height(TOP); ui.vertical(|ui| readings(ui, settings, state, &frame)); }); ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); parts(ui, settings, &frame, 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 = 300.; fn readings(ui: &mut egui::Ui, settings: &Settings, state: &mut State, frame: &AnalyzerFrame) { caption(ui, "SPREAD OF THE LAST PARTS"); let known = frame.spread_in.is_finite(); ui.label( RichText::new(if known { format!("{:.1} · {:.1} dB", frame.spread_in, frame.spread_out) } else { "—".into() }) .font(FontId::new(34., egui::FontFamily::Name("medium".into()))) .color(if known { TEXT } else { DIM }), ); ui.label( RichText::new("in · out, standard deviation") .size(12.) .color(MUTED), ); ui.add_space(12.); readout(ui, "GAIN NOW", format!("{:+.1} dB", frame.gain), ACCENT); readout(ui, "TARGET", format!("{:.1} LUFS", settings.target), TEXT); let label = if frame.learning || state.learn_request { "LISTENING" } else { "LEARN" }; let r = ui.selectable_label( frame.learning || state.learn_request, RichText::new(label).size(13.), ); if r.clicked() { state.learn_request = true; } hint( &r, state, "Takes the loudness of the next part over the gate as the target. Press it just \ before the switch-up that sits right in the mix.", ); ui.add_space(10.); ui.label( RichText::new(format!( "{:.0} ms lookahead · {}", frame.latency as f32 / frame.sample_rate.max(1.) * 1000., match settings.segment { Segment::Notes => match settings.source { Source::Audio => "parts at detected notes".to_string(), Source::Midi => "parts at MIDI notes".to_string(), }, Segment::Grid => format!( "grid at {:.1} BPM, {}", frame.tempo, if frame.synced { "host transport" } else { "free running" } ), } )) .size(12.) .color(MUTED), ); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); if settings.segment == Segment::Grid { ui.horizontal(|ui| { caption(ui, "ONE PART EVERY"); ui.add_space(6.); for grid in Grid::ALL { let r = ui .selectable_label(settings.grid == grid, RichText::new(grid.label()).size(13.)); if r.clicked() { settings.grid = grid; } hint( &r, state, "A new part starts on every line of this grid, also when the note does not \ change.", ); } }); ui.add_space(8.); ui.separator(); } ui.columns(6, |c| { knob( &mut c[0], state, "Target", &mut settings.target, TARGET_MIN..=TARGET_MAX, d.target, " LUFS", "Loudness every part lands at, over its first 100 ms.", ); knob( &mut c[1], state, "Amount", &mut settings.amount, 0. ..=100., d.amount, " %", "Share of the correction. 50 % halves the differences between parts.", ); knob( &mut c[2], state, "Range", &mut settings.range, 0. ..=RANGE_MAX, d.range, " dB", "Most boost or cut a part gets.", ); knob( &mut c[3], state, "Measure from", &mut settings.from, FROM_MIN..=FROM_MAX, d.from, " Hz", "The measurement hears only what is above this. Set it to Anvil's Split when \ Anvil makes the sub; the gain still applies to the whole signal.", ); knob( &mut c[4], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", "Note-start threshold, and the loudness under which a part keeps the last gain.", ); knob( &mut c[5], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", "Output trim.", ); }); }