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
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:
1 parent
0a0917ac56
commit
9b3ddeaf56
26 files changed
+2898
-144
No files matched your search
+480
-57
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user