Files
bass-foundry/plugins/anvil/core/src/lib.rs
T
nvrl b3ebe4d952
Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
feat: add Lathe harmonic EQ and Temper tracked distortion
Lathe places EQ bands on harmonics 1-32 of the played note, so a cut on
harmonic 3 stays on harmonic 3 on every note. Temper normalises each
note's level before an oversampled shaper and tunes its floor, push and
bright filters to the note, so every note distorts the same.

The note tracker moves from Anvil into common-dsp, together with a
state-variable filter, 4x oversampler and harmonic meter. Anvil's
behaviour and editor render are unchanged.

docs/ideas.md collects all plugin ideas and their status.
2026-11-06 06:08:51 +01:00

365 lines
12 KiB
Rust

//! Sub anchor: replaces the band below the split with a clean mono sine that follows the
//! bass pitch, holds one level for every note and restarts at the same phase on each note.
//!
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
mod settings;
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
pub use settings::{Settings, Source};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz;
pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
/// Hear only the generated sub.
pub solo: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Current sub frequency in Hz, 0 when silent.
pub hz: f32,
/// MIDI note of the current sub, rounded.
pub note: Option<u8>,
pub low_db: f32,
pub sub_db: f32,
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
pub scope_low: [f32; SCOPE],
pub scope_sub: [f32; SCOPE],
/// Duration of the scope window in milliseconds.
pub scope_ms: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
low_db: -120.,
sub_db: -120.,
scope_low: [0.; SCOPE],
scope_sub: [0.; SCOPE],
scope_ms: 60.,
latency: 0,
sample_rate: 48000.,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Voice {
phase: f32,
hz: f32,
target: f32,
amp: f32,
releasing: bool,
}
/// Sine voices and per-note state, driven by tracker events at output time.
#[derive(Clone, Copy, Debug, Default)]
struct Synth {
voice: Voice,
old: Voice,
old_gain: f32,
note_peak: f32,
}
impl Synth {
fn event(&mut self, s: &Settings, e: NoteEvent) {
match e {
NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide),
NoteEvent::Move { pitch } => {
if !s.snap {
self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32);
}
}
NoteEvent::Stop => self.voice.releasing = true,
}
}
fn start(&mut self, s: &Settings, pitch: f32, glide: bool) {
let note = if s.snap { pitch.round() } else { pitch };
let hz = midi_to_hz(note + 12. * s.octave as f32);
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
if glide && audible {
self.voice.target = hz;
return;
}
if s.retrigger || !audible {
if self.voice.amp > 1e-4 {
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
self.old = self.voice;
self.old_gain = 1.;
}
self.voice = Voice {
phase: s.phase / 360.,
hz,
target: hz,
amp: 0.,
releasing: false,
};
} else {
self.voice.target = hz;
self.voice.hz = hz;
}
self.note_peak = 0.;
}
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
crossover: [Crossover; 2],
tracker: Tracker,
synth: Synth,
glide: f32,
low_env: f32,
env_coef: f32,
smooth: f32,
level: f32,
keep: f32,
output: f32,
bypass: f32,
meter_coef: f32,
low_power: f32,
sub_power: f32,
scope_low: [f32; 2 * SCOPE],
scope_sub: [f32; 2 * SCOPE],
scope_at: usize,
scope_stride: usize,
scope_count: usize,
}
impl Engine {
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 tracker = Tracker::new(rate, TrackerConfig::SUB);
let latency = tracker.latency();
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
crossover: [Crossover::new(rate, settings.split); 2],
tracker,
synth: Synth::default(),
glide: 1.,
low_env: 0.,
env_coef: coefficient(rate, 10.),
smooth: coefficient(rate, 10.),
level: db_to_gain(settings.level),
keep: 0.,
output: 1.,
bypass: 0.,
meter_coef: coefficient(rate, 150.),
low_power: 0.,
sub_power: 0.,
scope_low: [0.; 2 * SCOPE],
scope_sub: [0.; 2 * SCOPE],
scope_at: 0,
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
scope_count: 0,
};
e.set_settings(settings)?;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency 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()?;
let old = self.settings;
if (s.split - old.split).abs() > 1e-3 {
for x in &mut self.crossover {
x.retune(self.rate, s.split);
}
}
if self
.tracker
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
{
self.synth.voice.releasing = true;
}
self.glide = if s.glide <= 0. {
1.
} else {
coefficient(self.rate, s.glide / 3.)
};
self.settings = s;
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
for x in &mut self.crossover {
x.reset();
}
self.tracker.reset();
self.synth = Synth::default();
self.low_env = 0.;
self.low_power = 0.;
self.sub_power = 0.;
self.scope_low = [0.; 2 * SCOPE];
self.scope_sub = [0.; 2 * SCOPE];
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
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);
}
#[inline]
fn shape(&self, x: f32) -> f32 {
let drive = self.settings.drive * 0.01;
if drive <= 0. {
return x;
}
let k = 1. + 6. * drive;
(k * x).tanh() / k.tanh()
}
fn oscillator(&mut self) -> f32 {
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
let release = 1. / (self.rate * RELEASE_MS * 0.001);
let v = &mut self.synth.voice;
v.amp = if v.releasing {
(v.amp - release).max(0.)
} else {
(v.amp + attack).min(1.)
};
v.hz += (v.target - v.hz) * self.glide;
let mut out = 0.;
if v.amp > 0. {
out += (TAU * v.phase).sin() * v.amp;
v.phase = (v.phase + v.hz / self.rate).fract();
}
let s = &mut self.synth;
if s.old_gain > 0. {
let o = &mut s.old;
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
o.phase = (o.phase + o.hz / self.rate).fract();
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
}
self.shape(out)
}
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (synth, settings) = (&mut self.synth, &self.settings);
self.tracker.process(mono, |e| synth.event(settings, e));
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
// The slot after the newest write is the oldest: exactly `latency` samples ago.
let y = self.delay[self.delay_at];
let s = self.settings;
let (l0, h0) = self.crossover[0].process(y[0]);
let (l1, h1) = if self.channels == 2 {
self.crossover[1].process(y[1])
} else {
(l0, h0)
};
let low = 0.5 * (l0 + l1);
self.low_env += (low * low - self.low_env) * self.env_coef;
let env = self.low_env.sqrt();
if !self.synth.voice.releasing {
self.synth.note_peak = self.synth.note_peak.max(env);
}
let follow = if self.synth.note_peak > 1e-6 {
(env / self.synth.note_peak).min(1.)
} else {
1.
};
let dynamics = s.dynamics * 0.01;
let shape = 1. - dynamics + dynamics * follow;
self.level += (db_to_gain(s.level) - self.level) * self.smooth;
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let sub = self.oscillator() * self.level * shape;
self.low_power += (low * low - self.low_power) * self.meter_coef;
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
self.scope_count += 1;
if self.scope_count >= self.scope_stride {
self.scope_count = 0;
self.scope_low[self.scope_at] = low;
self.scope_sub[self.scope_at] = sub;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
let wet = |l: f32, h: f32| {
if monitor.solo {
sub
} else {
h + self.keep * l + sub
}
};
let w = [wet(l0, h0), wet(l1, h1)];
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
latency: self.latency as u32,
sample_rate: self.rate,
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
..AnalyzerFrame::default()
};
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
f.hz = v.hz;
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
}
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
let n = 2 * SCOPE;
let oldest = self.scope_at;
let mut start = oldest;
for back in 0..SCOPE {
let i = (oldest + SCOPE - back) % n;
let prev = (i + n - 1) % n;
if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. {
start = i;
break;
}
}
for k in 0..SCOPE {
let i = (start + k) % n;
f.scope_low[k] = self.scope_low[i];
f.scope_sub[k] = self.scope_sub[i];
}
f
}
}