Files
bass-foundry/plugins/ballast/ui/src/lib.rs
T
nvrl d93dd942a8 feat(ballast): add Ballast switch-up leveller
Ballast gives every part of a bassline one gain that brings it to the target
loudness, with no compression inside a part. Parts start at note starts,
detected from the audio or taken from MIDI, or on the host's beat grid, so a
patch change on the same note counts too.

- Loudness is K-weighted over each part's first 100 ms, in 10 ms blocks with
  a relative gate, and every part is weighted from rest so the last part does
  not ring into it.
- The audio path is delayed by the tracker's lookahead, the window and a
  5 ms glide, so each gain is in place on its part's first sample.
- Learn takes the next part's loudness as the target. Amount, Range, Measure
  from (leave the sub out), Gate, Output, Bypass.
- Editor shows the last 32 parts in and out, the target, and the spread of
  the last 16 parts.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 16:05:19 +01:00

377 lines
12 KiB
Rust

//! Ballast editor, shared by the CLAP wrapper and the headless renderer.
use ballast_core::{
AnalyzerFrame, Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, HISTORY, RANGE_MAX,
TARGET_MAX, TARGET_MIN,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 660;
pub struct State {
pub analyzer: AnalyzerFrame,
/// Set by the Learn button; the wrapper passes it to the engine and clears it.
pub learn_request: bool,
/// The last Learn result already applied. None until the first frame arrives, so an old
/// result is not applied again when the editor opens.
learn_seen: Option<u32>,
learned: Option<f32>,
/// 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(),
learn_request: false,
learn_seen: None,
learned: None,
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str = "Put Ballast on the bass after its sound design. Press LEARN on the \
switch-up that sits right, or set Target.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
match self.learn_seen {
Some(id) if id != frame.learn_id && frame.learned.is_finite() => {
self.learned = Some(frame.learned);
}
_ => {}
}
self.learn_seen = Some(frame.learn_id);
self.analyzer = frame;
}
}
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, 72., ACCENT);
hint(&r, state, help);
});
}
const LOW: f32 = -42.;
const HIGH: f32 = 0.;
/// The last parts: their loudness in (bars), after Ballast (marks) and the target.
fn parts(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
format!("LAST {HISTORY} PARTS · LOUDNESS OF THE FIRST 100 MS"),
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
"bar: in · mark: out",
FontId::proportional(11.),
MUTED,
);
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(44., 30.),
rect.right_bottom() - Vec2::new(12., 12.),
);
let y = |lufs: f32| {
let t = ((lufs - LOW) / (HIGH - LOW)).clamp(0., 1.);
area.bottom() - t * area.height()
};
for db in [-36., -24., -12., 0.] {
p.line_segment(
[
Pos2::new(area.left(), y(db)),
Pos2::new(area.right(), y(db)),
],
Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)),
);
p.text(
Pos2::new(area.left() - 6., y(db)),
egui::Align2::RIGHT_CENTER,
format!("{db:.0}"),
FontId::monospace(10.),
DIM,
);
}
let column = area.width() / HISTORY as f32;
// Newest part on the right.
let offset = HISTORY - frame.count;
for i in 0..frame.count {
let part = frame.history[i];
let x = area.left() + column * (offset + i) as f32;
let bar = Rect::from_min_max(
Pos2::new(x + column * 0.2, y(part.loudness)),
Pos2::new(x + column * 0.8, area.bottom()),
);
let color = if part.gated {
DIM.gamma_multiply(0.6)
} else {
STEEL.gamma_multiply(0.45)
};
p.rect_filled(bar, 1., color);
if !part.gated {
let out = y(part.loudness + part.gain);
p.line_segment(
[
Pos2::new(x + column * 0.1, out),
Pos2::new(x + column * 0.9, out),
],
Stroke::new(3.0_f32, ACCENT),
);
}
}
let t = y(settings.target);
p.line_segment(
[Pos2::new(area.left(), t), Pos2::new(area.right(), t)],
Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.5)),
);
p.text(
Pos2::new(area.left() + 4., t - 3.),
egui::Align2::LEFT_BOTTOM,
format!("TARGET {:.1} LUFS", settings.target),
FontId::proportional(10.),
ACCENT,
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
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());
if let Some(l) = state.learned.take() {
settings.target = l.clamp(TARGET_MIN, TARGET_MAX);
}
ui.add_space(2.);
header(ui, "BALLAST", "SWITCH-UP LEVELLER", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
if settings.segment == Segment::Notes {
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r =
ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "NOTES FROM");
ui.separator();
}
for (segment, label) in [(Segment::Grid, "GRID"), (Segment::Notes, "NOTES")] {
let r =
ui.selectable_label(settings.segment == segment, RichText::new(label).size(13.));
if r.clicked() {
settings.segment = segment;
}
}
caption(ui, "PARTS FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(250.);
ui.set_min_height(TOP);
ui.vertical(|ui| readings(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
parts(ui, settings, &frame, 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));
}
const TOP: f32 = 300.;
fn readings(ui: &mut egui::Ui, settings: &Settings, state: &mut State, frame: &AnalyzerFrame) {
caption(ui, "SPREAD OF THE LAST PARTS");
let known = frame.spread_in.is_finite();
ui.label(
RichText::new(if known {
format!("{:.1} · {:.1} dB", frame.spread_in, frame.spread_out)
} else {
"—".into()
})
.font(FontId::new(34., egui::FontFamily::Name("medium".into())))
.color(if known { TEXT } else { DIM }),
);
ui.label(
RichText::new("in · out, standard deviation")
.size(12.)
.color(MUTED),
);
ui.add_space(12.);
readout(ui, "GAIN NOW", format!("{:+.1} dB", frame.gain), ACCENT);
readout(ui, "TARGET", format!("{:.1} LUFS", settings.target), TEXT);
let label = if frame.learning || state.learn_request {
"LISTENING"
} else {
"LEARN"
};
let r = ui.selectable_label(
frame.learning || state.learn_request,
RichText::new(label).size(13.),
);
if r.clicked() {
state.learn_request = true;
}
hint(
&r,
state,
"Takes the loudness of the next part over the gate as the target. Press it just \
before the switch-up that sits right in the mix.",
);
ui.add_space(10.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate.max(1.) * 1000.,
match settings.segment {
Segment::Notes => match settings.source {
Source::Audio => "parts at detected notes".to_string(),
Source::Midi => "parts at MIDI notes".to_string(),
},
Segment::Grid => format!(
"grid at {:.1} BPM, {}",
frame.tempo,
if frame.synced {
"host transport"
} else {
"free running"
}
),
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
if settings.segment == Segment::Grid {
ui.horizontal(|ui| {
caption(ui, "ONE PART EVERY");
ui.add_space(6.);
for grid in Grid::ALL {
let r = ui
.selectable_label(settings.grid == grid, RichText::new(grid.label()).size(13.));
if r.clicked() {
settings.grid = grid;
}
hint(
&r,
state,
"A new part starts on every line of this grid, also when the note does not \
change.",
);
}
});
ui.add_space(8.);
ui.separator();
}
ui.columns(6, |c| {
knob(
&mut c[0],
state,
"Target",
&mut settings.target,
TARGET_MIN..=TARGET_MAX,
d.target,
" LUFS",
"Loudness every part lands at, over its first 100 ms.",
);
knob(
&mut c[1],
state,
"Amount",
&mut settings.amount,
0. ..=100.,
d.amount,
" %",
"Share of the correction. 50 % halves the differences between parts.",
);
knob(
&mut c[2],
state,
"Range",
&mut settings.range,
0. ..=RANGE_MAX,
d.range,
" dB",
"Most boost or cut a part gets.",
);
knob(
&mut c[3],
state,
"Measure from",
&mut settings.from,
FROM_MIN..=FROM_MAX,
d.from,
" Hz",
"The measurement hears only what is above this. Set it to Anvil's Split when \
Anvil makes the sub; the gain still applies to the whole signal.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Note-start threshold, and the loudness under which a part keeps the last gain.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}