feat: add Lathe harmonic EQ and Temper tracked distortion
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
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
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.
This commit is contained in:
1 parent
f36ff0780c
commit
b3ebe4d952
48 files changed
+9862
-339
No files matched your search
@@ -0,0 +1,258 @@
|
||||
//! Harmonic EQ: bands sit on the harmonics of the played note instead of fixed frequencies,
|
||||
//! so a cut on harmonic 3 stays on harmonic 3 on every note.
|
||||
//!
|
||||
//! The audio path is delayed by the tracker's lookahead, so the filters are already tuned to
|
||||
//! a note when its attack leaves the plugin.
|
||||
mod settings;
|
||||
use common_dsp::meter::HarmonicMeter;
|
||||
use common_dsp::svf::{Svf, SvfCoef};
|
||||
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
|
||||
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
|
||||
pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
|
||||
|
||||
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
|
||||
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
|
||||
/// Harmonic bands plus the bands halfway between them.
|
||||
const BANDS: usize = 2 * HARMONICS;
|
||||
/// Filter coefficients are recomputed this often, in samples.
|
||||
const RETUNE: usize = 16;
|
||||
const GAIN_MS: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AnalyzerFrame {
|
||||
/// Fundamental the filters are tuned to, 0 before the first note.
|
||||
pub hz: f32,
|
||||
/// Sounding note, rounded; None between notes.
|
||||
pub note: Option<u8>,
|
||||
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
|
||||
pub input: [f32; HARMONICS],
|
||||
pub output: [f32; HARMONICS],
|
||||
pub latency: u32,
|
||||
pub sample_rate: f32,
|
||||
}
|
||||
impl Default for AnalyzerFrame {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hz: 0.,
|
||||
note: None,
|
||||
input: [-120.; HARMONICS],
|
||||
output: [-120.; HARMONICS],
|
||||
latency: 0,
|
||||
sample_rate: 48000.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
rate: f32,
|
||||
channels: usize,
|
||||
settings: Settings,
|
||||
latency: usize,
|
||||
delay: Vec<[f32; 2]>,
|
||||
delay_at: usize,
|
||||
tracker: Tracker,
|
||||
pitch: PitchFollower,
|
||||
hz: f32,
|
||||
hz_coef: f32,
|
||||
countdown: usize,
|
||||
coefs: [SvfCoef; BANDS],
|
||||
active: [bool; BANDS],
|
||||
gains: [f32; BANDS],
|
||||
state: [[Svf; BANDS]; 2],
|
||||
gain_coef: f32,
|
||||
smooth: f32,
|
||||
mix: f32,
|
||||
output: f32,
|
||||
bypass: f32,
|
||||
meter_in: HarmonicMeter,
|
||||
meter_out: HarmonicMeter,
|
||||
/// Smoothed controls start at the next settings instead of gliding from defaults.
|
||||
fresh: bool,
|
||||
}
|
||||
|
||||
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::BASS);
|
||||
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,
|
||||
tracker,
|
||||
pitch: PitchFollower::default(),
|
||||
hz: 0.,
|
||||
hz_coef: 1.,
|
||||
countdown: 0,
|
||||
coefs: [SvfCoef::IDENTITY; BANDS],
|
||||
active: [false; BANDS],
|
||||
gains: [0.; BANDS],
|
||||
state: [[Svf::default(); BANDS]; 2],
|
||||
gain_coef: coefficient(rate / RETUNE as f32, GAIN_MS),
|
||||
smooth: coefficient(rate, 10.),
|
||||
mix: 1.,
|
||||
output: 1.,
|
||||
bypass: 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 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()?;
|
||||
self.tracker
|
||||
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
|
||||
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
|
||||
self.settings = s;
|
||||
if std::mem::take(&mut self.fresh) {
|
||||
self.mix = s.mix * 0.01;
|
||||
self.output = db_to_gain(s.output);
|
||||
self.bypass = f32::from(s.bypass);
|
||||
self.gains = self.targets();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.delay.fill([0.; 2]);
|
||||
self.tracker.reset();
|
||||
for ch in &mut self.state {
|
||||
for f in ch.iter_mut() {
|
||||
f.reset();
|
||||
}
|
||||
}
|
||||
self.meter_in.reset();
|
||||
self.meter_out.reset();
|
||||
self.countdown = 0;
|
||||
self.fresh = true;
|
||||
}
|
||||
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);
|
||||
}
|
||||
/// Gain targets in dB of every band for the current settings.
|
||||
fn targets(&self) -> [f32; BANDS] {
|
||||
let s = &self.settings;
|
||||
let mut t = [0.; BANDS];
|
||||
for k in 1..=s.harmonics as usize {
|
||||
t[k - 1] = s.effective(k);
|
||||
t[HARMONICS + k - 1] = s.between;
|
||||
}
|
||||
t
|
||||
}
|
||||
fn retune(&mut self, jump: bool) {
|
||||
let s = self.settings;
|
||||
let targets = self.targets();
|
||||
let width = s.width * 0.01;
|
||||
for b in 0..BANDS {
|
||||
self.gains[b] = if jump {
|
||||
targets[b]
|
||||
} else {
|
||||
self.gains[b] + (targets[b] - self.gains[b]) * self.gain_coef
|
||||
};
|
||||
if (self.gains[b] - targets[b]).abs() < 1e-4 {
|
||||
self.gains[b] = targets[b];
|
||||
}
|
||||
let order = if b < HARMONICS {
|
||||
(b + 1) as f32
|
||||
} else {
|
||||
(b - HARMONICS + 1) as f32 + 0.5
|
||||
};
|
||||
let centre = order * self.hz;
|
||||
let audible = self.hz > 0. && centre < 0.45 * self.rate;
|
||||
if audible && !self.active[b] {
|
||||
for ch in &mut self.state {
|
||||
ch[b].reset();
|
||||
}
|
||||
}
|
||||
self.active[b] = audible && self.gains[b] != 0.;
|
||||
if self.active[b] {
|
||||
self.coefs[b] = SvfCoef::bell(self.rate, centre, order / width, self.gains[b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (pitch, snap) = (&mut self.pitch, self.settings.snap);
|
||||
self.tracker.process(mono, |e| pitch.event(snap, e));
|
||||
let len = self.delay.len();
|
||||
self.delay[self.delay_at] = x;
|
||||
self.delay_at = (self.delay_at + 1) % len;
|
||||
let y = self.delay[self.delay_at];
|
||||
|
||||
let first = self.hz == 0.;
|
||||
let (hz, jump) = self.pitch.step(self.hz_coef);
|
||||
let first = first && jump;
|
||||
self.hz = hz;
|
||||
if jump || self.countdown == 0 {
|
||||
self.retune(first);
|
||||
self.countdown = RETUNE;
|
||||
}
|
||||
self.countdown -= 1;
|
||||
|
||||
let s = self.settings;
|
||||
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
|
||||
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
|
||||
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
|
||||
let mut out = [0.; 2];
|
||||
for c in 0..self.channels {
|
||||
let mut v = y[c];
|
||||
for b in 0..BANDS {
|
||||
if self.active[b] {
|
||||
v = self.state[c][b].process(&self.coefs[b], v);
|
||||
}
|
||||
}
|
||||
let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output;
|
||||
out[c] = wet * (1. - self.bypass) + y[c] * self.bypass;
|
||||
}
|
||||
let hz = if self.tracker.sounding() { self.hz } else { 0. };
|
||||
let (dry, wet) = if self.channels == 2 {
|
||||
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
|
||||
} else {
|
||||
(y[0], out[0])
|
||||
};
|
||||
self.meter_in.push(dry, hz);
|
||||
self.meter_out.push(wet, hz);
|
||||
out
|
||||
}
|
||||
pub fn take_analyzer(&self) -> AnalyzerFrame {
|
||||
let mut f = AnalyzerFrame {
|
||||
hz: self.hz,
|
||||
latency: self.latency as u32,
|
||||
sample_rate: self.rate,
|
||||
input: *self.meter_in.levels(),
|
||||
output: *self.meter_out.levels(),
|
||||
..AnalyzerFrame::default()
|
||||
};
|
||||
if self.tracker.sounding() && self.hz > 0. {
|
||||
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Host-independent controls. Gains in dB, times in milliseconds.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Editable harmonics: the fundamental is harmonic 1.
|
||||
pub const HARMONICS: usize = 32;
|
||||
pub const GAIN_MIN: f32 = -24.;
|
||||
pub const GAIN_MAX: f32 = 12.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Source {
|
||||
/// Pitch and note starts are detected from the incoming bass.
|
||||
#[default]
|
||||
Audio,
|
||||
/// Notes come from MIDI note input.
|
||||
Midi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub source: Source,
|
||||
/// Gain per harmonic, index 0 = fundamental.
|
||||
pub gains: [f32; HARMONICS],
|
||||
/// dB per octave of harmonic number, added on top of `gains`. Harmonic 1 is the pivot.
|
||||
pub tilt: f32,
|
||||
/// Positive lifts odd harmonics and lowers even ones by half this amount each.
|
||||
pub odd_even: f32,
|
||||
/// Gain of the regions halfway between harmonics: noise, beating, inharmonic grit.
|
||||
pub between: f32,
|
||||
/// Bandwidth of every band as a percentage of the fundamental.
|
||||
pub width: f32,
|
||||
/// Number of harmonics that are shaped; higher ones pass unchanged.
|
||||
pub harmonics: u32,
|
||||
/// Retune time when the pitch slides or bends.
|
||||
pub smooth: f32,
|
||||
/// Detection threshold in dBFS for note starts (Audio source).
|
||||
pub gate: f32,
|
||||
/// Round detected pitch to the nearest semitone.
|
||||
pub snap: bool,
|
||||
pub mix: f32,
|
||||
pub output: f32,
|
||||
pub bypass: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: Source::Audio,
|
||||
gains: [0.; HARMONICS],
|
||||
tilt: 0.,
|
||||
odd_even: 0.,
|
||||
between: 0.,
|
||||
width: 20.,
|
||||
harmonics: 24,
|
||||
smooth: 10.,
|
||||
gate: -42.,
|
||||
snap: true,
|
||||
mix: 100.,
|
||||
output: 0.,
|
||||
bypass: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
let finite = [
|
||||
self.tilt,
|
||||
self.odd_even,
|
||||
self.between,
|
||||
self.width,
|
||||
self.smooth,
|
||||
self.gate,
|
||||
self.mix,
|
||||
self.output,
|
||||
]
|
||||
.iter()
|
||||
.chain(self.gains.iter())
|
||||
.all(|v| v.is_finite());
|
||||
if !finite {
|
||||
return Err("non-finite setting");
|
||||
}
|
||||
let ok = self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
|
||||
&& (-12.0..=12.).contains(&self.tilt)
|
||||
&& (-12.0..=12.).contains(&self.odd_even)
|
||||
&& (-24.0..=12.).contains(&self.between)
|
||||
&& (5.0..=50.).contains(&self.width)
|
||||
&& (8..=HARMONICS as u32).contains(&self.harmonics)
|
||||
&& (0.0..=100.).contains(&self.smooth)
|
||||
&& (-60.0..=-20.).contains(&self.gate)
|
||||
&& (0.0..=100.).contains(&self.mix)
|
||||
&& (-12.0..=12.).contains(&self.output);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("setting out of range")
|
||||
}
|
||||
}
|
||||
/// Gain applied to harmonic `k` (1-based), with tilt and odd/even included.
|
||||
pub fn effective(&self, k: usize) -> f32 {
|
||||
if k == 0 || k > self.harmonics as usize || k > HARMONICS {
|
||||
return 0.;
|
||||
}
|
||||
let parity = if k % 2 == 1 { 0.5 } else { -0.5 };
|
||||
(self.gains[k - 1] + self.tilt * (k as f32).log2() + self.odd_even * parity)
|
||||
.clamp(-30., 18.)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user