Files
bass-foundry/plugins/weld/ui/src/lib.rs
T
nvrl d536e3127a feat(weld): show the alignment's delay spread
Weld's allpass sections add group delay between 500 Hz and 4 kHz, where
listeners hear it best. Liski et al. (2021) measured a threshold of about
0.64 ms for a positive group-delay peak on clicks. Weld now reports how much
its delay varies over that range and, above the threshold, suggests
comparing attacks in SUM and SUM DRY.

Common fits from Weld's own tests spread 0.82-1.03 ms, so the line is a
neutral note, not a warning: sustained bass hides much more than a click.

- Fit::group_delay (analytic, per section) and Fit::smear_ms in weld-core.
- Report::smear_ms, filled by the analyzer from the applied fit.
- Tests against the numeric phase slope and the analog 4Q/w0 peak.
- Display only; the audio path does not change.
2026-11-06 13:17:29 +01:00

561 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f32>,
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<String>, 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),
);
});
});
}