Files
bass-foundry/plugins/temper/core/src/lib.rs
T
nvrl adfb2c0287 feat: stable note tracking, NOTE pitch source and unsure fallback
Producers found the tracked plugins jumpy: the shared tracker switched notes
after 10 ms and retuned every 5 ms, so growls and formant basses flickered
and every tracked plugin acted on wrong notes.

Shared tracker (common-dsp):
- A new note needs a steady read for 40 ms; an octave down 80 ms, an octave
  up 200 ms (an octave-up read still fits the note's period and counts as
  agreeing). Inside a note the tuning only moves past a 0.25 semitone
  deadband. Follow bends keeps the old fast behaviour as an option.
- A trust score turns the note unsure while reads disagree; Tracker::active
  and NoteState report it, and Fallback latches "step back" across note ends.
- Notes end when the window start stays below the gate's release level for
  20 ms, so short gaps between notes are seen without waiting for the gate.
- PitchSource replaces the old (midi, gate, glide) arguments: Audio, MIDI or
  Note mode (a set note, no detection), plus a key lock (minor, major, root
  only) that snaps reads to the key.
- stems.rs: synthetic stems (growls, wobble, reese, formant, 808 slide,
  layered drop, noise riser) and a score; examples/tracker_report scores the
  corpus or real WAV stems. On the corpus the old behaviour makes up to 28
  extra note starts per second; the stable default makes none.

Plugins:
- Anvil, Lathe, Temper, Knurl, Planish, Braze and Forge gain the NOTE pitch
  source, the key lock and Follow bends (shared common-params crate and a
  shared header switch in common-ui).
- Unsure tracking: Anvil brings the original sub back in place of the sine,
  Lathe passes the audio through, Knurl fades its texture out; Temper,
  Planish and Braze hold their last sure tuning. Readouts show the note only
  when it is sure.
- Anvil gains Mode: Replace or Enhance (keeps the original sub and adds the
  sine under it).
- Planish slides its band edges over 6 ms on a new note instead of snapping.
- Ballast defaults to Grid parts, which need no tracking.
2026-11-06 21:02:52 +01:00

456 lines
14 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.
//! Note-tracked distortion: the filters around the shaper follow the played note and the
//! input level is normalised per note, so every note distorts with the same character.
//!
//! The audio path is delayed by the tracker's lookahead, so filters and drive are set for a
//! note before its attack reaches the shaper. The shaper runs at 4× the sample rate.
mod settings;
use common_dsp::meter::{HarmonicMeter, HARMONICS};
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi};
pub use settings::{Curve, Settings, Source};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Harmonics shown by the editor.
pub const SHOWN: usize = 16;
/// RMS level that Normalize brings every note to before Drive.
const TARGET_DB: f32 = -12.;
const RETUNE: usize = 16;
const DRY: usize = 64;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
pub hz: f32,
pub note: Option<u8>,
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
pub input: [f32; SHOWN],
pub output: [f32; SHOWN],
/// Peak level reaching the shaper, after normalisation and drive, in dB.
pub shaper_db: f32,
/// Gain applied by Normalize and by Auto gain, in dB.
pub normalize_db: f32,
pub auto_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
input: [-120.; SHOWN],
output: [-120.; SHOWN],
shaper_db: -120.,
normalize_db: 0.,
auto_db: 0.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Per-note input level, measured on the undelayed input so it is known at the attack.
#[derive(Clone, Copy, Debug, Default)]
struct Level {
/// RMS of the undelayed input.
ahead: f32,
/// Loudest `ahead` since the note started, slowly released.
note: f32,
}
impl Level {
fn event(&mut self, e: NoteEvent) {
if let NoteEvent::Start { glide: false, .. } = e {
// `ahead` already looks one lookahead into the new note.
self.note = self.ahead;
}
}
}
#[derive(Clone, Copy, Debug)]
struct Filters {
floor: SvfCoef,
push: SvfCoef,
bright: SvfCoef,
}
struct Channel {
floor: Svf,
push: Svf,
bright: Svf,
over: Oversampler,
dc_x: f32,
dc_y: f32,
dry: [f32; DRY],
}
impl Channel {
fn new() -> Self {
Self {
floor: Svf::default(),
push: Svf::default(),
bright: Svf::default(),
over: Oversampler::new(),
dc_x: 0.,
dc_y: 0.,
dry: [0.; DRY],
}
}
fn reset(&mut self) {
self.floor.reset();
self.push.reset();
self.bright.reset();
self.over.reset();
self.dc_x = 0.;
self.dc_y = 0.;
self.dry = [0.; DRY];
}
}
struct Smoothed {
value: f32,
coef: f32,
}
impl Smoothed {
fn new(value: f32, coef: f32) -> Self {
Self { value, coef }
}
#[inline]
fn next(&mut self, target: f32) -> f32 {
let d = target - self.value;
self.value = if d.abs() < 1e-6 {
target
} else {
self.value + d * self.coef
};
self.value
}
}
/// The tracked distortion without its tracker: driven by note events aligned with its input.
/// Its latency is the oversampling filters'.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
dry_at: usize,
sounding: bool,
pitch: PitchFollower,
hz_coef: f32,
countdown: usize,
filters: Filters,
channel: [Channel; 2],
level: Level,
rms_coef: f32,
release: f32,
norm_db: Smoothed,
drive: Smoothed,
bias: Smoothed,
mix: Smoothed,
output: Smoothed,
bypass: Smoothed,
auto_db: Smoothed,
power_in: f32,
power_out: f32,
power_coef: f32,
dc_r: f32,
shaper_peak: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
impl Module {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let smooth = coefficient(rate, 10.);
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
dry_at: 0,
sounding: false,
pitch: PitchFollower::default(),
hz_coef: coefficient(rate, 5.),
countdown: 0,
filters: Filters {
floor: SvfCoef::IDENTITY,
push: SvfCoef::IDENTITY,
bright: SvfCoef::IDENTITY,
},
channel: [Channel::new(), Channel::new()],
level: Level::default(),
rms_coef: coefficient(rate, 10.),
release: 1. - coefficient(rate, 1500.),
norm_db: Smoothed::new(0., coefficient(rate, 5.)),
drive: Smoothed::new(db_to_gain(settings.drive), smooth),
bias: Smoothed::new(0., smooth),
mix: Smoothed::new(1., smooth),
output: Smoothed::new(1., smooth),
bypass: Smoothed::new(0., smooth),
auto_db: Smoothed::new(0., coefficient(rate, 50.)),
power_in: 0.,
power_out: 0.,
power_coef: coefficient(rate, 300.),
dc_r: 1. - std::f32::consts::TAU * 10. / rate,
shaper_peak: 0.,
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
fresh: true,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
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()?;
if std::mem::take(&mut self.fresh) {
self.drive.value = db_to_gain(s.drive);
self.bias.value = s.bias * 0.008;
self.mix.value = s.mix * 0.01;
self.output.value = db_to_gain(s.output);
self.bypass.value = f32::from(s.bypass);
}
self.settings = s;
Ok(())
}
pub fn reset(&mut self) {
self.sounding = false;
for c in &mut self.channel {
c.reset();
}
self.level = Level::default();
self.power_in = 0.;
self.power_out = 0.;
self.shaper_peak = 0.;
self.meter_in.reset();
self.meter_out.reset();
self.countdown = 0;
self.fresh = true;
}
fn retune(&mut self, hz: f32) {
let s = &self.settings;
let limit = 0.45 * self.rate;
let tuned = |mult: f32| hz > 0. && mult > 0. && hz * mult < limit;
self.filters.floor = if tuned(s.floor) {
SvfCoef::highpass(self.rate, hz * s.floor, std::f32::consts::FRAC_1_SQRT_2)
} else {
SvfCoef::IDENTITY
};
let k = s.push_harmonic as f32;
self.filters.push = if tuned(k) && s.push_gain != 0. {
SvfCoef::bell(self.rate, hz * k, k / 0.6, s.push_gain)
} else {
SvfCoef::IDENTITY
};
self.filters.bright = if tuned(s.bright) {
SvfCoef::lowpass(self.rate, hz * s.bright, std::f32::consts::FRAC_1_SQRT_2)
} else {
SvfCoef::IDENTITY
};
}
fn run(&mut self, y: [f32; 2]) -> [f32; 2] {
let (hz, jump) = self.pitch.step(self.hz_coef);
if jump || self.countdown == 0 {
self.retune(hz);
self.countdown = RETUNE;
}
self.countdown -= 1;
let s = self.settings;
let rms_db = 10. * self.level.note.max(1e-12).log10();
// Below −60 dBFS there is nothing to normalise; hold the last gain.
let norm_target = if rms_db > -60. {
(s.normalize * 0.01 * (TARGET_DB - rms_db)).clamp(-24., 36.)
} else {
self.norm_db.value
};
let norm = db_to_gain(self.norm_db.next(norm_target));
let drive = self.drive.next(db_to_gain(s.drive));
let bias = self.bias.next(s.bias * 0.008);
let mix = self.mix.next(s.mix * 0.01);
let output = self.output.next(db_to_gain(s.output));
let bypass = self.bypass.next(f32::from(s.bypass));
let curve = s.curve;
let offset = curve.shape(bias);
let f = self.filters;
let dry_at = self.dry_at;
let back = (dry_at + DRY - Oversampler::latency()) % DRY;
let mut wet = [0.; 2];
let mut dry = [0.; 2];
let mut peak = self.shaper_peak;
for c in 0..self.channels {
let ch = &mut self.channel[c];
ch.dry[dry_at] = y[c];
dry[c] = ch.dry[back];
let v = ch.floor.process(&f.floor, y[c]);
let v = ch.push.process(&f.push, v) * norm * drive;
let up = ch.over.up(v);
let shaped = up.map(|u| {
peak = peak.max(u.abs());
curve.shape(u + bias) - offset
});
let v = ch.over.down(shaped);
let v = ch.bright.process(&f.bright, v);
// DC blocker: bias and asymmetric curves leave an offset.
let d = v - ch.dc_x + self.dc_r * ch.dc_y;
ch.dc_x = v;
ch.dc_y = d;
wet[c] = d;
}
self.shaper_peak = peak;
self.dry_at = (dry_at + 1) % DRY;
let (dry_mono, wet_mono) = if self.channels == 2 {
(0.5 * (dry[0] + dry[1]), 0.5 * (wet[0] + wet[1]))
} else {
(dry[0], wet[0])
};
self.power_in += (dry_mono * dry_mono - self.power_in) * self.power_coef;
self.power_out += (wet_mono * wet_mono - self.power_out) * self.power_coef;
let auto_target = if !s.auto_gain {
0.
} else if self.power_in > 1e-7 && self.power_out > 1e-9 {
(10. * (self.power_in / self.power_out).log10()).clamp(-36., 24.)
} else {
self.auto_db.value
};
let auto = db_to_gain(self.auto_db.next(auto_target));
let mut out = [0.; 2];
for c in 0..self.channels {
let v = (mix * wet[c] * auto + (1. - mix) * dry[c]) * output;
out[c] = v * (1. - bypass) + dry[c] * bypass;
}
let tone = if self.sounding { hz } else { 0. };
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
self.meter_in.push(dry_mono, tone);
self.meter_out.push(o, tone);
out
}
/// Editor data. `latency` is left at 0 for the owner to fill in.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let hz = self.pitch.hz();
let mut f = AnalyzerFrame {
hz,
sample_rate: self.rate,
shaper_db: gain_to_db(self.shaper_peak),
normalize_db: self.norm_db.value,
auto_db: self.auto_db.value,
..AnalyzerFrame::default()
};
self.shaper_peak = 0.;
f.input
.copy_from_slice(&self.meter_in.levels()[..SHOWN.min(HARMONICS)]);
f.output
.copy_from_slice(&self.meter_out.levels()[..SHOWN.min(HARMONICS)]);
if self.sounding && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
Oversampler::latency()
}
fn ahead(&mut self, mono: f32) {
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
}
fn event(&mut self, e: NoteEvent) {
self.pitch.event(false, e);
self.level.event(e);
}
fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] {
// Unsure tracking sends no retune, so Floor, Push and Bright hold their last sure
// tuning; only the readout drops the note.
self.sounding = note.sure;
self.run(y)
}
}
/// Standalone Temper: its own tracker in front of the [`Module`].
pub struct Engine {
channels: usize,
lookahead: Lookahead,
module: Module,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
let module = Module::new(rate, channels)?;
let mut e = Self {
channels,
lookahead: Lookahead::new(rate, TrackerConfig::BASS),
module,
};
// The module already holds the defaults; only the tracker still needs them.
e.set_tracker(&Settings::default());
Ok(e)
}
/// Lookahead plus the oversampling filters.
pub fn latency_samples(&self) -> u32 {
(self.lookahead.latency() + self.module.latency()) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.module.settings()
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
self.module.set_settings(s)?;
self.set_tracker(&s);
Ok(())
}
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.pitch.source(s.source.mode(), s.gate, true));
}
pub fn reset(&mut self) {
self.lookahead.reset();
self.module.reset();
}
pub fn note_on(&mut self, note: u8) {
self.lookahead.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.lookahead.tracker.note_off(note);
}
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]
};
self.lookahead.process(x, mono, &mut self.module)
}
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
AnalyzerFrame {
latency: self.latency_samples(),
..self.module.take_analyzer()
}
}
}