//! Weld 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 weld_core::{ band_edge, curve_frequency, AnalyzerFrame, Listen, Mode, Report, Settings, Status, CURVE, DISPLAY_HIGH, DISPLAY_LOW, MAX_SECTIONS, SMEAR_AUDIBLE_MS, SMEAR_HIGH, SMEAR_LOW, }; 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 LOSS: Color32 = Color32::from_rgb(170, 40, 52); const LOSS_FLOOR: f32 = -24.; const TOP: f32 = 318.; pub struct State { pub analyzer: AnalyzerFrame, pub report: Report, /// 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(), report: Report::default(), gesture_active: false, help: DEFAULT_HELP.into(), } } } const DEFAULT_HELP: &str = "Put Weld on one bass layer and send the other layer to its sidechain input."; impl State { pub fn accept_frame(&mut self, frame: AnalyzerFrame) { let old = self.analyzer; self.analyzer = frame; // Meters fall smoothly between frames. self.analyzer.main_db = frame.main_db.max(old.main_db - 3.); self.analyzer.side_db = frame.side_db.max(old.side_db - 3.); self.analyzer.side_peak_db = frame.side_peak_db.max(old.side_peak_db - 3.); } pub fn accept_report(&mut self, report: Report) { self.report = report; } } 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); }); } fn big(text: impl Into, size: f32, color: Color32) -> RichText { RichText::new(text) .font(FontId::new(size, egui::FontFamily::Name("medium".into()))) .color(color) } fn hz(f: f32) -> String { if f >= 1000. { format!("{:.1} kHz", f / 1000.) } else { format!("{f:.0} Hz") } } 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, "WELD", "LAYER ALIGN", |ui| { let r = ui .selectable_label(settings.bypass, RichText::new("BYPASS").size(13.)) .on_hover_text("Pass the main layer through unchanged."); if r.clicked() { settings.bypass = !settings.bypass; } ui.separator(); for (listen, label, help) in [ (Listen::SumDry, "SUM DRY", "Output this layer unprocessed plus the sidechain layer, to compare with SUM."), (Listen::Sum, "SUM", "Output this layer aligned plus the sidechain layer. Mute the other layer's own send while you listen."), (Listen::Layer, "LAYER", "Output this layer only, aligned. Use this in the mix."), ] { let r = ui.selectable_label(settings.listen == listen, RichText::new(label).size(13.)); if r.clicked() { settings.listen = listen; } hint(&r, state, help); } caption(ui, "LISTEN"); }); ui.add_space(6.); ui.horizontal_top(|ui| { panel(ui, |ui| { ui.set_width(250.); ui.set_min_height(TOP); ui.vertical(|ui| summary(ui, settings, state)); }); ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { let report = state.report; band_plot(ui, &report, 190.); ui.add_space(4.); phase_plot(ui, &report, TOP - 194.); }); }); }); 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 status_text(report: &Report, frame: &AnalyzerFrame, mode: Mode) -> (String, Color32) { if !frame.side_seen && frame.side_db < -90. { return ( "No sidechain signal. Route the other layer to Weld's sidechain input.".into(), DIM, ); } match report.status { Status::NoSidechain => ("The sidechain is silent.".into(), MUTED), Status::Waiting => ("Listening…".into(), MUTED), Status::Quiet => ("One layer is under the gate.".into(), MUTED), Status::Loose => ( "Layers unrelated: free-running oscillators or different notes. Enable retrigger.".into(), FOCUS, ), Status::Late => ( format!( "This layer is {:.2} ms late. Weld cannot move it earlier: put Weld on the other layer.", report.lag_ms ), ACCENT, ), Status::Aligned => match mode { Mode::Follow => ("Following.".into(), STEEL), Mode::Learn => (format!("Learning… {} frames", report.frames), ACCENT), Mode::Hold => ("Holding the alignment.".into(), STEEL), }, } } fn summary(ui: &mut egui::Ui, settings: &Settings, state: &mut State) { let report = state.report; let frame = state.analyzer; caption(ui, "RECOVERED"); let measured = matches!( report.status, Status::Aligned | Status::Late | Status::Loose ); ui.label(big( if measured { format!("{:+.1} dB", report.recovered_db) } else { "—".into() }, 44., if measured { TEXT } else { DIM }, )); ui.label( RichText::new(if measured { format!( "Cancellation cost {:.1} dB, {}–{}", report.cost_db, hz(report.low), hz(report.high) ) } else { "Average per band, between Low and High".into() }) .size(12.) .color(MUTED), ); ui.add_space(10.); caption(ui, "ALIGNMENT"); let fit = report.fit; ui.label( RichText::new(if fit.flip { "Polarity flipped" } else { "Polarity normal" }) .size(14.) .color(if fit.flip { ACCENT } else { STEEL }), ); if fit.count == 0 { ui.label(RichText::new("No allpass sections").size(13.).color(MUTED)); } for s in fit.active() { ui.label( RichText::new(format!("{:>9} Q {:.2}", hz(s.fc), s.q)) .monospace() .size(13.) .color(STEEL), ); } if fit.count > 0 { // Typical fits vary 0.8–1 ms here, above the click threshold, so this stays a // neutral note rather than a warning: sustained bass hides much more. let r = ui.label( RichText::new(format!( "Delay spread {:.2} ms, {}–{}", report.smear_ms, hz(SMEAR_LOW), hz(SMEAR_HIGH) )) .size(12.) .color(MUTED), ); if report.smear_ms > SMEAR_AUDIBLE_MS { ui.label( RichText::new("Clicks may soften: compare SUM, SUM DRY") .size(12.) .color(DIM), ); } hint(&r, state, "How much the alignment's delay changes between 500 Hz and 4 kHz. On clicks, listeners hear changes from about 0.6 ms; sustained bass hides much more. On a plucky layer, compare SUM with SUM DRY on the attacks. Fewer sections usually vary less."); } ui.add_space(10.); let (text, color) = status_text(&report, &frame, settings.mode); ui.label(RichText::new(text).size(13.).color(color)); ui.add_space(10.); meter(ui, "MAIN", frame.main_db, STEEL, 150.); meter(ui, "SIDECHAIN", frame.side_db, ACCENT, 150.); if frame.dropped > 0 { ui.label( RichText::new(format!("{} blocks skipped (CPU busy)", frame.dropped)) .size(11.) .color(DIM), ); } } fn freq_x(rect: Rect, f: f32) -> f32 { let t = (f / DISPLAY_LOW).ln() / (DISPLAY_HIGH / DISPLAY_LOW).ln(); rect.left() + rect.width() * t.clamp(0., 1.) } fn frequency_grid(p: &egui::Painter, plot: Rect, labels: bool) { for f in [50f32, 100., 200., 500., 1000., 2000., 5000., 10000.] { let x = freq_x(plot, f); p.line_segment( [Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())], Stroke::new(1.0_f32, GRID), ); if labels { p.text( Pos2::new(x, plot.bottom() + 3.), egui::Align2::CENTER_TOP, if f >= 1000. { format!("{}k", f / 1000.) } else { format!("{f}") }, FontId::proportional(10.), DIM, ); } } } fn fit_range(p: &egui::Painter, plot: Rect, report: &Report) { let (a, b) = (freq_x(plot, report.low), freq_x(plot, report.high)); p.rect_filled( Rect::from_min_max(Pos2::new(a, plot.top()), Pos2::new(b, plot.bottom())), 0., Color32::from_rgba_unmultiplied(255, 255, 255, 5), ); } /// Per band: what the unprocessed sum loses against a perfect alignment (red), and the /// part of it Weld wins back (accent). fn band_plot(ui: &mut egui::Ui, report: &Report, 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); let plot = Rect::from_min_max( outer.left_top() + Vec2::new(34., 22.), outer.right_bottom() - Vec2::new(10., 16.), ); p.text( outer.left_top() + Vec2::new(8., 5.), egui::Align2::LEFT_TOP, "SUM LEVEL AGAINST A PERFECT ALIGNMENT", FontId::proportional(10.), MUTED, ); let legend = [(LOSS, "lost to cancellation"), (ACCENT, "won back by Weld")]; let mut x = outer.right() - 10.; for (color, label) in legend.iter().rev() { let galley = p.layout_no_wrap(label.to_string(), FontId::proportional(10.), MUTED); x -= galley.size().x; p.galley(Pos2::new(x, outer.top() + 5.), galley, MUTED); x -= 14.; p.rect_filled( Rect::from_min_size(Pos2::new(x, outer.top() + 7.), Vec2::splat(8.)), 1., *color, ); x -= 14.; } fit_range(&p, plot, report); let y = |db: f32| plot.top() + plot.height() * (db / LOSS_FLOOR).clamp(0., 1.); for db in [0., -6., -12., -18., -24.] { let yy = y(db); p.line_segment( [Pos2::new(plot.left(), yy), Pos2::new(plot.right(), yy)], Stroke::new(1.0_f32, if db == 0. { EDGE } else { GRID }), ); p.text( Pos2::new(outer.left() + 6., yy), egui::Align2::LEFT_CENTER, if db == 0. { "0".into() } else { format!("{db:.0}") }, FontId::proportional(10.), if db == 0. { MUTED } else { DIM }, ); } frequency_grid(&p, plot, true); for (i, band) in report.bands.iter().enumerate() { if !band.valid { continue; } let (a, b) = ( freq_x(plot, band_edge(i)) + 2., freq_x(plot, band_edge(i + 1)) - 2., ); let top = y(0.); let before = y(band.before); let after = y(band.after.max(band.before)); let loose = band.coherence < 0.3; // The whole loss in red, then the recovered part over it in accent. p.rect_filled( Rect::from_min_max(Pos2::new(a, top), Pos2::new(b, before.max(top + 1.))), 1., if loose { LOSS.gamma_multiply(0.35) } else { LOSS }, ); if after > top + 0.5 { p.rect_filled( Rect::from_min_max(Pos2::new(a, after), Pos2::new(b, before)), 0., if loose { ACCENT.gamma_multiply(0.35) } else { ACCENT }, ); } else if before > top + 0.5 { p.rect_filled( Rect::from_min_max(Pos2::new(a, top), Pos2::new(b, before)), 1., if loose { ACCENT.gamma_multiply(0.35) } else { ACCENT }, ); } p.line_segment( [Pos2::new(a, after), Pos2::new(b, after)], Stroke::new(1.5_f32, TEXT), ); if loose { // Hatching marks bands where the layers do not hold a stable phase. let mut x = a; while x < b { p.line_segment( [ Pos2::new(x, plot.bottom()), Pos2::new((x + 8.).min(b), plot.bottom() - 8.), ], Stroke::new(1.0_f32, DIM), ); x += 5.; } p.text( Pos2::new((a + b) / 2., plot.bottom() - 12.), egui::Align2::CENTER_BOTTOM, "loose", FontId::proportional(10.), DIM, ); } } } /// Phase of this layer against the sidechain layer, before and after, plus the chain's own /// phase. fn phase_plot(ui: &mut egui::Ui, report: &Report, 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); let plot = Rect::from_min_max( outer.left_top() + Vec2::new(34., 20.), outer.right_bottom() - Vec2::new(10., 6.), ); p.text( outer.left_top() + Vec2::new(8., 4.), egui::Align2::LEFT_TOP, "PHASE AGAINST THE SIDECHAIN LAYER", FontId::proportional(10.), MUTED, ); let legend = [(DIM, "before"), (ACCENT, "after"), (FOCUS, "Weld's chain")]; let mut x = outer.right() - 10.; for (color, label) in legend.iter().rev() { let galley = p.layout_no_wrap(label.to_string(), FontId::proportional(10.), MUTED); x -= galley.size().x; p.galley(Pos2::new(x, outer.top() + 4.), galley, MUTED); x -= 16.; p.line_segment( [ Pos2::new(x, outer.top() + 10.), Pos2::new(x + 10., outer.top() + 10.), ], Stroke::new(2.0_f32, *color), ); x -= 12.; } fit_range(&p, plot, report); let y = |deg: f32| plot.center().y - plot.height() / 2. * (deg / 180.).clamp(-1., 1.); for deg in [-180f32, -90., 0., 90., 180.] { let yy = y(deg); p.line_segment( [Pos2::new(plot.left(), yy), Pos2::new(plot.right(), yy)], Stroke::new(1.0_f32, if deg == 0. { EDGE } else { GRID }), ); if deg.abs() < 180. { p.text( Pos2::new(outer.left() + 6., yy), egui::Align2::LEFT_CENTER, format!("{deg:.0}°"), FontId::proportional(10.), if deg == 0. { MUTED } else { DIM }, ); } } frequency_grid(&p, plot, false); let curve = |pick: fn(&weld_core::CurvePoint) -> f32, color: Color32, width: f32, fade: bool| { let mut previous: Option<(Pos2, f32)> = None; for i in 0..CURVE { let c = report.curve[i]; if !c.valid { previous = None; continue; } let v = pick(&c); let pos = Pos2::new(freq_x(plot, curve_frequency(i)), y(v)); if let Some((last, last_v)) = previous { // Do not draw the jump where the phase wraps. if (v - last_v).abs() < 180. { let alpha = if fade { 0.25 + 0.75 * c.coherence } else { 1. }; p.line_segment([last, pos], Stroke::new(width, color.gamma_multiply(alpha))); } } previous = Some((pos, v)); } }; curve(|c| c.chain, FOCUS, 1.5, false); curve(|c| c.before, MUTED, 1.5, true); curve(|c| c.after, ACCENT, 2., true); } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let d = Settings::default(); ui.horizontal(|ui| { caption(ui, "MODE"); for (mode, label, help) in [ (Mode::Follow, "FOLLOW", "Keep measuring and refit when a clearly better alignment shows up."), (Mode::Learn, "LEARN", "Measure from now with equal weight, for example over one drop. Switch to HOLD to keep the result."), (Mode::Hold, "HOLD", "Keep the current alignment. The display keeps measuring. The alignment is saved with the project."), ] { let r = ui.selectable_label(settings.mode == mode, RichText::new(label).size(14.)); if r.clicked() { settings.mode = if mode == Mode::Learn && settings.mode == Mode::Learn { Mode::Hold } else { mode }; } hint(&r, state, help); } ui.add_space(30.); caption(ui, "SECTIONS"); let minus = ui.button(RichText::new("−").size(14.)); if minus.clicked() && settings.sections > 1 { settings.sections -= 1; } ui.label(RichText::new(format!("{}", settings.sections)).monospace().size(14.).color(TEXT)); let plus = ui.button(RichText::new("+").size(14.)); if plus.clicked() && settings.sections < MAX_SECTIONS { settings.sections += 1; } let help = "Most allpass sections the fit may use. Fewer is gentler; Weld uses fewer when they are enough."; hint(&minus, state, help); hint(&plus, state, help); }); ui.add_space(4.); ui.columns(6, |c| { knob(&mut c[0], state, "Response", &mut settings.response, 0.3..=5., d.response, " s", "Averaging time in FOLLOW. Longer is steadier; shorter follows switch-ups faster."); knob(&mut c[1], state, "Low", &mut settings.low, 30. ..=400., d.low, " Hz", "Lowest frequency the fit listens to. Leave the sub to Anvil."); knob(&mut c[2], state, "High", &mut settings.high, 1000. ..=12000., d.high, " Hz", "Highest frequency the fit listens to."); knob(&mut c[3], state, "Gate", &mut settings.gate, -70. ..=-30., d.gate, " dB", "Moments where either layer is quieter than this are not measured."); knob(&mut c[4], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", "Output trim."); let report = state.report; c[5].vertical(|ui| { caption(ui, "TIME OFFSET"); let clear = report.lag_strength > 0.3; ui.label(big( if clear { format!("{:+.2} ms", report.lag_ms) } else { "—".into() }, 22., if clear { TEXT } else { DIM }, )); ui.label( RichText::new("Positive: this layer is late. Weld can only delay it, so a late layer needs Weld on the other one.") .size(11.) .color(DIM), ); }); }); }