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.
456 lines
14 KiB
Rust
456 lines
14 KiB
Rust
//! 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()
|
||
}
|
||
}
|
||
}
|