Files
bass-foundry/plugins/ballast/core/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

475 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Levels each part of a bassline, a note or a step of the beat grid, to one loudness with a
//! single gain per part. No compression: the gain does not move inside a part, so a patch
//! keeps its own envelope and punch.
//!
//! A part's loudness is the K-weighted (ITU-R BS.1770) mean square of its first 100 ms, or
//! of all of it when it is shorter, counted in 10 ms blocks; blocks 20 dB under the loudest
//! one are left out, so a gap at the start does not pull the reading down. Each part is
//! weighted from rest, as if it played alone. The audio path is delayed by the note tracker's lookahead
//! plus that window plus a 5 ms ramp, so every part's gain is known before its attack leaves
//! the plugin, and the ramp to it ends exactly on the attack.
mod settings;
use common_dsp::loudness::{lufs, KWeighting};
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db};
pub use settings::{
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
};
/// Each part is measured over this much of its start.
pub const WINDOW_MS: f32 = 100.;
/// The gain glides to a new part's value over this long, ending on its first sample.
pub const RAMP_MS: f32 = 5.;
/// A boundary closer than this to the last one is ignored: the parts are merged.
const MIN_PART_MS: f32 = 10.;
/// The window is measured in blocks of this length.
const BLOCK_MS: f32 = 10.;
const BLOCKS: usize = (WINDOW_MS / BLOCK_MS) as usize;
/// Blocks this far under the part's loudest block do not count: a note start found by a
/// pitch change can come early, inside the gap before the note.
const RELATIVE_GATE_DB: f64 = 20.;
/// Parts shown in the editor.
pub const HISTORY: usize = 32;
/// Parts used for the spread readout.
const SPREAD: usize = 16;
const PENDING: usize = 32;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Tempo the grid runs at before the host reports one.
const FALLBACK_BPM: f64 = 120.;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Part {
/// Loudness of the part's measured start, in LUFS.
pub loudness: f32,
/// Gain given to the part, in dB.
pub gain: f32,
/// Under the gate: the part kept the previous gain.
pub gated: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Latest parts, oldest first; the first `count` entries are valid.
pub history: [Part; HISTORY],
pub count: usize,
/// Gain applied at the output right now, in dB.
pub gain: f32,
/// Standard deviation of the loudness of the last 16 parts over the gate, before and after
/// Ballast. NaN with fewer than two.
pub spread_in: f32,
pub spread_out: f32,
/// Waiting for the next part to take as the target.
pub learning: bool,
/// The loudness last taken by Learn, and a number that changes each time one is taken.
pub learned: f32,
pub learn_id: u32,
pub tempo: f64,
pub synced: bool,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
history: [Part::default(); HISTORY],
count: 0,
gain: 0.,
spread_in: f32::NAN,
spread_out: f32::NAN,
learning: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
synced: false,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Gain in dB that brings `loudness` to the target, at `amount` (0–1), within ±`range`.
pub fn part_gain(loudness: f32, target: f32, amount: f32, range: f32) -> f32 {
((target - loudness) * amount).clamp(-range, range)
}
#[derive(Clone, Copy, Default)]
struct Pending {
start: u64,
gain: f32,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
tracker: Tracker,
lookahead: usize,
window: usize,
ramp: usize,
min_part: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
weighting: [KWeighting; 2],
from: [[Svf; 2]; 2],
from_coef: SvfCoef,
/// Samples of the aligned stream: the input delayed by the tracker's lookahead.
clock: u64,
open: bool,
part_start: u64,
block: usize,
blocks: [(f64, usize); BLOCKS],
measured: usize,
last_gain: f32,
queue: [Pending; PENDING],
queue_head: usize,
queue_len: usize,
gain: f32,
ramp_from: f32,
ramp_to: f32,
ramp_left: usize,
history: [Part; HISTORY],
history_at: usize,
history_len: usize,
learn: bool,
learned: f32,
learn_id: u32,
tempo: f64,
/// Beat position of the aligned stream.
beats: f64,
synced: bool,
cell: i64,
output: f32,
bypass: f32,
smooth: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=384000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let samples = |ms: f32| ((rate * ms * 0.001).round() as usize).max(1);
let (window, ramp) = (samples(WINDOW_MS), samples(RAMP_MS));
let mut e = Self {
rate,
channels,
settings: Settings::default(),
tracker,
lookahead,
window,
ramp,
min_part: samples(MIN_PART_MS),
delay: vec![[0.; 2]; lookahead + window + ramp + 1],
delay_at: 0,
weighting: [KWeighting::new(rate); 2],
from: [[Svf::default(); 2]; 2],
from_coef: SvfCoef::IDENTITY,
clock: 0,
open: false,
part_start: 0,
block: samples(BLOCK_MS),
blocks: [(0., 0); BLOCKS],
measured: 0,
last_gain: 0.,
queue: [Pending::default(); PENDING],
queue_head: 0,
queue_len: 0,
gain: 1.,
ramp_from: 1.,
ramp_to: 1.,
ramp_left: 0,
history: [Part::default(); HISTORY],
history_at: 0,
history_len: 0,
learn: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
beats: 0.,
synced: false,
cell: 0,
output: 1.,
bypass: 0.,
smooth: coefficient(rate, 10.),
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
/// Lookahead of the tracker, the measuring window and the ramp.
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.window + self.ramp) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
if s.from != self.settings.from || self.fresh {
self.from_coef = if s.from > FROM_MIN {
SvfCoef::highpass(self.rate, s.from, Q)
} else {
SvfCoef::IDENTITY
};
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Host tempo in BPM and, while playing, the beat position at the block's first sample.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
if let Some(t) = tempo.filter(|t| t.is_finite() && *t > 0.) {
self.tempo = t;
}
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The parts are cut on the aligned stream, `lookahead` samples behind the input.
self.beats = p - self.lookahead as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Takes the loudness of the next part over the gate as the new target.
pub fn learn(&mut self) {
self.learn = true;
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
self.delay_at = 0;
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
self.clock = 0;
self.open = false;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
self.last_gain = 0.;
self.queue_len = 0;
self.gain = 1.;
self.ramp_left = 0;
self.history_len = 0;
self.learn = false;
self.fresh = true;
let s = self.settings;
let _ = self.set_settings(s);
}
/// Mean square of the measured blocks, without those far under the loudest.
fn gated_mean(&self) -> f64 {
let used = self.measured.div_ceil(self.block).min(BLOCKS);
let blocks = &self.blocks[..used];
let loudest = blocks
.iter()
.map(|(s, n)| s / (*n).max(1) as f64)
.fold(0., f64::max);
let floor = loudest * 10f64.powf(-RELATIVE_GATE_DB / 10.);
let (sum, n) = blocks
.iter()
.filter(|(s, n)| s / (*n).max(1) as f64 >= floor)
.fold((0., 0), |(a, b), (s, n)| (a + s, b + n));
sum / n.max(1) as f64
}
fn close(&mut self) {
self.open = false;
let s = self.settings;
let loudness = lufs(self.gated_mean());
let gated = loudness < s.gate;
let gain = if gated {
self.last_gain
} else {
part_gain(loudness, s.target, s.amount * 0.01, s.range)
};
if !gated {
self.last_gain = gain;
if std::mem::take(&mut self.learn) {
self.learned = loudness;
self.learn_id = self.learn_id.wrapping_add(1);
}
}
if self.queue_len < PENDING {
let at = (self.queue_head + self.queue_len) % PENDING;
self.queue[at] = Pending {
start: self.part_start,
gain,
};
self.queue_len += 1;
}
self.history[self.history_at] = Part {
loudness,
gain,
gated,
};
self.history_at = (self.history_at + 1) % HISTORY;
self.history_len = (self.history_len + 1).min(HISTORY);
}
// Channels index the delayed frame, the filters and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let mut started = false;
self.tracker.process(mono, |e| {
if matches!(e, NoteEvent::Start { .. }) {
started = true;
}
});
let len = self.delay.len();
self.delay[self.delay_at] = x;
let aligned = self.delay[(self.delay_at + len - self.lookahead) % len];
self.delay_at = (self.delay_at + 1) % len;
let delayed = self.delay[self.delay_at];
// Grid lines are counted on the aligned stream even in Notes mode, so switching modes
// does not cut a part.
let cell = (self.beats / self.settings.grid.quarters()).floor() as i64;
self.beats += self.tempo / 60. / self.rate as f64;
let crossed = cell != self.cell;
self.cell = cell;
let boundary = match self.settings.segment {
Segment::Notes => started,
Segment::Grid => crossed,
};
if boundary && !(self.open && self.measured < self.min_part) {
if self.open {
self.close();
}
self.open = true;
self.part_start = self.clock;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
// Each part is weighted from rest, as if it played alone: the filters' memory of
// the last part would otherwise ring into this one.
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
}
let mut power = 0f64;
for c in 0..self.channels {
let f = &mut self.from[c];
let v = f[0].process(&self.from_coef, aligned[c]);
let v = f[1].process(&self.from_coef, v);
let w = self.weighting[c].process(v) as f64;
power += w * w;
}
if self.open {
let b = &mut self.blocks[(self.measured / self.block).min(BLOCKS - 1)];
b.0 += power;
b.1 += 1;
self.measured += 1;
if self.measured >= self.window {
self.close();
}
}
// A part's ramp starts `ramp` samples before its first sample reaches the output.
if self.queue_len > 0 {
let next = self.queue[self.queue_head];
if self.clock >= next.start + self.window as u64 {
self.queue_head = (self.queue_head + 1) % PENDING;
self.queue_len -= 1;
self.ramp_from = self.gain;
self.ramp_to = db_to_gain(next.gain);
self.ramp_left = self.ramp;
}
}
if self.ramp_left > 0 {
self.ramp_left -= 1;
let t = 1. - self.ramp_left as f32 / self.ramp as f32;
let t = t * t * (3. - 2. * t);
self.gain = self.ramp_from + (self.ramp_to - self.ramp_from) * t;
}
self.clock += 1;
let s = self.settings;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
if (f32::from(s.bypass) - self.bypass).abs() < 1e-6 {
self.bypass = f32::from(s.bypass);
}
let mut out = [0.; 2];
for c in 0..self.channels {
let wet = delayed[c] * self.gain * self.output;
out[c] = if self.bypass >= 1. {
delayed[c]
} else {
wet + (delayed[c] - wet) * self.bypass
};
}
out
}
fn spread(&self, after: bool) -> f32 {
let (mut n, mut sum, mut sq) = (0usize, 0f64, 0f64);
for i in 0..self.history_len {
if n == SPREAD {
break;
}
let p = self.history[(self.history_at + HISTORY - 1 - i) % HISTORY];
if p.gated {
continue;
}
let v = (p.loudness + if after { p.gain } else { 0. }) as f64;
n += 1;
sum += v;
sq += v * v;
}
if n < 2 {
return f32::NAN;
}
let mean = sum / n as f64;
((sq / n as f64 - mean * mean).max(0.)).sqrt() as f32
}
/// Readings for the editor. Never allocates.
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
count: self.history_len,
gain: gain_to_db(self.gain),
spread_in: self.spread(false),
spread_out: self.spread(true),
learning: self.learn,
learned: self.learned,
learn_id: self.learn_id,
tempo: self.tempo,
synced: self.synced,
latency: self.latency_samples(),
sample_rate: self.rate,
..AnalyzerFrame::default()
};
let first = (self.history_at + HISTORY - self.history_len) % HISTORY;
for i in 0..self.history_len {
f.history[i] = self.history[(first + i) % HISTORY];
}
f
}
}