feat(weld): phase alignment for layered bass, release 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m0s
Check and Windows preview / check (crucible) (push) Successful in 1m29s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m53s
Check and Windows preview / check (rivet) (push) Successful in 1m29s
Check and Windows preview / check (temper) (push) Successful in 1m29s
Check and Windows preview / check (weld) (push) Successful in 2m38s
Check and Windows preview / previews (push) Successful in 1m28s
Independent plugin release / plan (push) Successful in 8s
Check and Windows preview / windows (push) Successful in 3m14s
Independent plugin release / build (push) Successful in 3m50s
Independent plugin release / publish (push) Successful in 9s

Weld hears the other bass layer through the sidechain and fits polarity
plus up to four RBJ allpass sections to the measured cross-spectrum, so
the layers sum instead of cancelling. No latency; the layer on its own
keeps its sound.

- Worker thread: Welch cross-spectrum and coherence, Nelder-Mead fit with
  1/f weighting, a per-band loss penalty and model selection by share of
  the achievable gain; GCC-PHAT lag detection flags a late layer.
- Audio thread: allocation-free chain with a 40 ms warm-up and 40 ms
  crossfade per new fit; mono blocks go to the worker through a lock-free
  queue.
- Follow / Learn / Hold; the fit is persisted and restored without a
  crossfade.
- Editor: per-band loss and recovery against a perfect alignment, phase
  before and after, recovered dB, time offset and status.
- Tests for flat magnitude, crossfade, fit targets, Follow hysteresis,
  Learn, silence, realtime allocation and the persisted fit in the host.
This commit is contained in:
nvrl committed 2026-11-06 08:31:41 +01:00
1 parent 0a0917ac56
commit 9b3ddeaf56
26 files changed
+2898 -144

No files matched your search

+480 -57
View File
@@ -1,14 +1,23 @@
//! Weld editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, FontId, RichText};
use weld_core::{AnalyzerFrame, Listen, Settings};
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,
};
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,
@@ -17,6 +26,7 @@ impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
report: Report::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
@@ -33,12 +43,43 @@ impl State {
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);
@@ -52,64 +93,446 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
if r.clicked() {
settings.bypass = !settings.bypass;
}
});
ui.add_space(6.);
let frame = state.analyzer;
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(380.);
ui.vertical(|ui| {
caption(ui, "SIDECHAIN");
let (text, color) = if frame.side_db > -90. {
(format!("sidechain: {:.0} dBFS", frame.side_db), TEXT)
} else if frame.side_seen {
("sidechain: silent".to_string(), MUTED)
} else {
("no sidechain signal".to_string(), DIM)
};
ui.label(
RichText::new(text)
.font(FontId::new(40., egui::FontFamily::Name("medium".into())))
.color(color),
);
ui.add_space(12.);
meter(ui, "MAIN", frame.main_db, STEEL, 520.);
meter(ui, "SIDECHAIN", frame.side_db, ACCENT, 520.);
meter(ui, "SIDE PEAK", frame.side_peak_db, FOCUS, 520.);
ui.add_space(18.);
ui.label(
RichText::new(
"FL Studio: on the other layer's mixer track, right-click the route arrow \
to this track and choose \"Sidechain to this track\". Then open Weld's \
wrapper settings and set the sidechain input to that track.",
)
.size(13.)
.color(MUTED),
);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
caption(ui, "LISTEN");
for (listen, label, help) in [
(Listen::Layer, "LAYER", "Output the main layer only. Use this in the mix."),
(Listen::Sum, "SUM", "Output main + sidechain layer. Mute the other layer's own send while listening."),
(Listen::SumDry, "SUM DRY", "Output the unprocessed main + sidechain layer, to compare with SUM."),
] {
let r = ui.selectable_label(settings.listen == listen, RichText::new(label).size(13.));
if r.clicked() {
settings.listen = listen;
}
hint(&r, state, help);
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;
}
ui.add_space(40.);
let d = Settings::default();
let r = rotary(ui, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., ACCENT);
hint(&r, state, "Output trim.");
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),
);
}
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),
);
});
});
}