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.
377 lines
12 KiB
Rust
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.",
|
|
);
|
|
});
|
|
}
|