Producers found the panels crowded: every control had the same weight. The header now carries a MAIN / ADVANCED switch (common-ui header_paged). The main page keeps the controls a first result needs; the advanced page shows everything, as before. - Anvil: Mode (Replace or Enhance), Split and Sub level, plus Octave, Ghost, Keep and Output. Kick lock, Dynamics, Drive, Glide, Phase, Gate and the pitch switches move to Advanced. The note readout says when the original sub plays because tracking is unsure. - Lathe, Temper, Knurl, Planish, Braze: tracking and fine-tuning controls (Gate, Smooth, Snap, Harmonics, Push at, Floor, Bright, Normalize, band gains, Phase) move to Advanced. Lathe labels the octaves of the note in Hz on the harmonic axis. - Forge passes the page to its module pages. - Weld: the main page shows the level won back at full height, Mode, Response and Output; the phase plot, Sections, Low, High and Gate move to Advanced. - editor-check renders the advanced pages with --advanced.
579 lines
20 KiB
Rust
579 lines
20 KiB
Rust
//! 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,
|
||
/// The advanced page shows its extra controls. Not saved.
|
||
pub advanced: bool,
|
||
}
|
||
impl Default for State {
|
||
fn default() -> Self {
|
||
Self {
|
||
analyzer: AnalyzerFrame::default(),
|
||
report: Report::default(),
|
||
gesture_active: false,
|
||
advanced: 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.);
|
||
let mut advanced = state.advanced;
|
||
header_paged(ui, "WELD", "LAYER ALIGN", Some(&mut advanced), |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");
|
||
});
|
||
state.advanced = advanced;
|
||
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;
|
||
// The phase plot is for fine tuning: the main page shows the level won back.
|
||
if state.advanced {
|
||
band_plot(ui, &report, 190.);
|
||
ui.add_space(4.);
|
||
phase_plot(ui, &report, TOP - 194.);
|
||
} else {
|
||
band_plot(ui, &report, 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));
|
||
}
|
||
|
||
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);
|
||
}
|
||
if state.advanced {
|
||
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.);
|
||
let advanced = state.advanced;
|
||
ui.columns(if advanced { 6 } else { 3 }, |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.");
|
||
let at = if advanced {
|
||
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.");
|
||
4
|
||
} else {
|
||
1
|
||
};
|
||
knob(&mut c[at], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB",
|
||
"Output trim.");
|
||
let report = state.report;
|
||
c[at + 1].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),
|
||
);
|
||
});
|
||
});
|
||
}
|