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,399 @@
|
||||
//! Note tracker with lookahead: pitch and note starts of a monophonic bass, from audio or MIDI.
|
||||
//!
|
||||
//! Detection runs on the undelayed input. Events are reported at *output* time, `latency()`
|
||||
//! samples later, so a host that delays its audio path by the same amount sees each note start
|
||||
//! exactly on its attack, with the pitch already known.
|
||||
use crate::{coefficient, gain_to_db, hz_to_midi, Biquad, Yin};
|
||||
|
||||
const EVENTS: usize = 64;
|
||||
const HOP_MS: f32 = 5.;
|
||||
const GATE_HOLD_MS: f32 = 20.;
|
||||
const GATE_HYSTERESIS_DB: f32 = 6.;
|
||||
/// A pitch move this large (semitones) counts as a new note.
|
||||
const JUMP: f32 = 0.7;
|
||||
const CLARITY: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TrackerConfig {
|
||||
/// Lowest detectable fundamental. Sets the lookahead (two periods plus a margin).
|
||||
pub min_hz: f32,
|
||||
pub max_hz: f32,
|
||||
/// Lowpass in front of detection, removing upper harmonics that confuse YIN.
|
||||
pub detect_lowpass: f32,
|
||||
/// Approximate decimated analysis rate.
|
||||
pub detect_rate: f32,
|
||||
}
|
||||
impl TrackerConfig {
|
||||
/// Sub range: 25–250 Hz.
|
||||
pub const SUB: Self = Self {
|
||||
min_hz: 25.,
|
||||
max_hz: 250.,
|
||||
detect_lowpass: 400.,
|
||||
detect_rate: 3000.,
|
||||
};
|
||||
/// Bass range for harmonic tools: 30–500 Hz.
|
||||
pub const BASS: Self = Self {
|
||||
min_hz: 30.,
|
||||
max_hz: 500.,
|
||||
detect_lowpass: 1000.,
|
||||
detect_rate: 6000.,
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum NoteEvent {
|
||||
/// A new note. `glide` asks to slide from the previous pitch instead of restarting.
|
||||
Start {
|
||||
pitch: f32,
|
||||
glide: bool,
|
||||
},
|
||||
/// The sounding note's smoothed pitch moved within a semitone (bends, vibrato).
|
||||
Move {
|
||||
pitch: f32,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Onset,
|
||||
Release,
|
||||
NoteOn(u8),
|
||||
NoteOff(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Event {
|
||||
time: u64,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// Allocation-free after `new`. Pitches are fractional MIDI notes.
|
||||
pub struct Tracker {
|
||||
rate: f32,
|
||||
cfg: TrackerConfig,
|
||||
latency: usize,
|
||||
midi: bool,
|
||||
gate_db: f32,
|
||||
glide: bool,
|
||||
detect: [Biquad; 2],
|
||||
decimate: usize,
|
||||
decimate_count: usize,
|
||||
detect_rate: f32,
|
||||
ring: Vec<f32>,
|
||||
ring_at: usize,
|
||||
scratch: Vec<f32>,
|
||||
yin: Yin,
|
||||
hop: usize,
|
||||
hop_count: usize,
|
||||
gate_env: f32,
|
||||
gate_release: f32,
|
||||
gate_open: bool,
|
||||
gate_hold: usize,
|
||||
clock: u64,
|
||||
events: [Event; EVENTS],
|
||||
event_head: usize,
|
||||
event_len: usize,
|
||||
/// Output-time note state.
|
||||
sounding: bool,
|
||||
pending: bool,
|
||||
tracked: f32,
|
||||
candidate: Option<(f32, u8)>,
|
||||
held: [u8; 16],
|
||||
held_len: usize,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
pub fn new(rate: f32, cfg: TrackerConfig) -> Self {
|
||||
let decimate = (rate / cfg.detect_rate).round().max(1.) as usize;
|
||||
let detect_rate = rate / decimate as f32;
|
||||
let max_lag = (detect_rate / cfg.min_hz).ceil() as usize;
|
||||
// YIN needs one comparison window of at least the longest period after the lags.
|
||||
let analysis = 2 * max_lag + 3;
|
||||
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||||
Self {
|
||||
rate,
|
||||
cfg,
|
||||
latency: analysis * decimate,
|
||||
midi: false,
|
||||
gate_db: -42.,
|
||||
glide: false,
|
||||
detect: [Biquad::lowpass(rate, cfg.detect_lowpass, q); 2],
|
||||
decimate,
|
||||
decimate_count: 0,
|
||||
detect_rate,
|
||||
ring: vec![0.; analysis],
|
||||
ring_at: 0,
|
||||
scratch: vec![0.; analysis],
|
||||
yin: Yin::new(max_lag + 2),
|
||||
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
|
||||
hop_count: 0,
|
||||
gate_env: 0.,
|
||||
gate_release: 1. - coefficient(rate, 30.),
|
||||
gate_open: false,
|
||||
gate_hold: 0,
|
||||
clock: 0,
|
||||
events: [Event {
|
||||
time: 0,
|
||||
kind: Kind::Release,
|
||||
}; EVENTS],
|
||||
event_head: 0,
|
||||
event_len: 0,
|
||||
sounding: false,
|
||||
pending: false,
|
||||
tracked: 0.,
|
||||
candidate: None,
|
||||
held: [0; 16],
|
||||
held_len: 0,
|
||||
}
|
||||
}
|
||||
/// Lookahead in samples. Delay the audio path by exactly this much.
|
||||
pub fn latency(&self) -> usize {
|
||||
self.latency
|
||||
}
|
||||
pub fn config(&self) -> TrackerConfig {
|
||||
self.cfg
|
||||
}
|
||||
/// `midi` selects note input over detection; `gate_db` is the detection threshold;
|
||||
/// `glide` lets legato notes slide. Returns true when the source changed, which drops
|
||||
/// every pending and held note: the caller should release its sound.
|
||||
pub fn set(&mut self, midi: bool, gate_db: f32, glide: bool) -> bool {
|
||||
let changed = midi != self.midi;
|
||||
self.midi = midi;
|
||||
self.gate_db = gate_db;
|
||||
self.glide = glide;
|
||||
if changed {
|
||||
self.release_all();
|
||||
}
|
||||
changed
|
||||
}
|
||||
fn release_all(&mut self) {
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.held_len = 0;
|
||||
self.event_len = 0;
|
||||
self.candidate = None;
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
for f in &mut self.detect {
|
||||
f.reset();
|
||||
}
|
||||
self.ring.fill(0.);
|
||||
self.decimate_count = 0;
|
||||
self.hop_count = 0;
|
||||
self.gate_env = 0.;
|
||||
self.gate_open = false;
|
||||
self.gate_hold = 0;
|
||||
self.release_all();
|
||||
}
|
||||
/// MIDI note start at the current input sample. Used when the source is MIDI.
|
||||
pub fn note_on(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOn(note.min(127)));
|
||||
}
|
||||
pub fn note_off(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOff(note.min(127)));
|
||||
}
|
||||
/// True while a note sounds at output time.
|
||||
pub fn sounding(&self) -> bool {
|
||||
self.sounding
|
||||
}
|
||||
fn push(&mut self, kind: Kind) {
|
||||
if self.event_len == EVENTS {
|
||||
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
}
|
||||
let at = (self.event_head + self.event_len) % EVENTS;
|
||||
self.events[at] = Event {
|
||||
time: self.clock,
|
||||
kind,
|
||||
};
|
||||
self.event_len += 1;
|
||||
}
|
||||
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
|
||||
fn estimate(&mut self) -> Option<f32> {
|
||||
let n = self.ring.len();
|
||||
for i in 0..n {
|
||||
self.scratch[i] = self.ring[(self.ring_at + i) % n];
|
||||
}
|
||||
let (hz, clarity) = self.yin.estimate(
|
||||
&self.scratch,
|
||||
self.detect_rate,
|
||||
self.cfg.min_hz,
|
||||
self.cfg.max_hz,
|
||||
)?;
|
||||
(clarity >= CLARITY).then(|| hz_to_midi(hz))
|
||||
}
|
||||
fn start(&mut self, pitch: f32, glide: bool, emit: &mut impl FnMut(NoteEvent)) {
|
||||
self.tracked = pitch;
|
||||
self.candidate = None;
|
||||
self.pending = false;
|
||||
self.sounding = true;
|
||||
emit(NoteEvent::Start { pitch, glide });
|
||||
}
|
||||
fn stop(&mut self, emit: &mut impl FnMut(NoteEvent)) {
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.candidate = None;
|
||||
emit(NoteEvent::Stop);
|
||||
}
|
||||
fn handle(&mut self, kind: Kind, emit: &mut impl FnMut(NoteEvent)) {
|
||||
let midi = self.midi;
|
||||
match kind {
|
||||
Kind::Onset if !midi => match self.estimate() {
|
||||
Some(p) => self.start(p, false, emit),
|
||||
None => {
|
||||
self.pending = true;
|
||||
self.sounding = true;
|
||||
}
|
||||
},
|
||||
Kind::Release if !midi => self.stop(emit),
|
||||
Kind::NoteOn(n) if midi => {
|
||||
let len = self.held_len;
|
||||
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
if self.held_len == self.held.len() {
|
||||
self.held.copy_within(1.., 0);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
let legato = self.held_len > 0;
|
||||
self.held[self.held_len] = n;
|
||||
self.held_len += 1;
|
||||
self.start(n as f32, legato && self.glide, emit);
|
||||
}
|
||||
Kind::NoteOff(n) if midi => {
|
||||
let len = self.held_len;
|
||||
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
|
||||
return;
|
||||
};
|
||||
let top = i + 1 == len;
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
if self.held_len == 0 {
|
||||
self.stop(emit);
|
||||
} else if top {
|
||||
let next = self.held[self.held_len - 1];
|
||||
self.start(next as f32, self.glide, emit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Regular analysis; its window starts at the current output time.
|
||||
fn hop_analysis(&mut self, emit: &mut impl FnMut(NoteEvent)) {
|
||||
if self.midi || !self.sounding {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.estimate() else {
|
||||
return;
|
||||
};
|
||||
if self.pending {
|
||||
self.start(p, false, emit);
|
||||
return;
|
||||
}
|
||||
if (p - self.tracked).abs() >= JUMP {
|
||||
match self.candidate {
|
||||
Some((c, count)) if (c - p).abs() < 0.5 => {
|
||||
if count + 1 >= 2 {
|
||||
self.start(p, self.glide, emit);
|
||||
} else {
|
||||
self.candidate = Some((p, count + 1));
|
||||
}
|
||||
}
|
||||
_ => self.candidate = Some((p, 1)),
|
||||
}
|
||||
} else {
|
||||
self.candidate = None;
|
||||
self.tracked += 0.3 * (p - self.tracked);
|
||||
emit(NoteEvent::Move {
|
||||
pitch: self.tracked,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Feeds one undelayed mono input sample. Events due at output time go to `emit`, in order.
|
||||
pub fn process(&mut self, mono: f32, mut emit: impl FnMut(NoteEvent)) {
|
||||
let d = self.detect[0].process(mono);
|
||||
let d = self.detect[1].process(d);
|
||||
if !self.midi {
|
||||
let a = d.abs();
|
||||
self.gate_env = if a > self.gate_env {
|
||||
a
|
||||
} else {
|
||||
self.gate_env * self.gate_release
|
||||
};
|
||||
let level = gain_to_db(self.gate_env);
|
||||
if level > self.gate_db {
|
||||
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
|
||||
if !self.gate_open {
|
||||
self.gate_open = true;
|
||||
self.push(Kind::Onset);
|
||||
}
|
||||
} else if self.gate_open && level < self.gate_db - GATE_HYSTERESIS_DB {
|
||||
if self.gate_hold == 0 {
|
||||
self.gate_open = false;
|
||||
self.push(Kind::Release);
|
||||
} else {
|
||||
self.gate_hold -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.decimate_count += 1;
|
||||
if self.decimate_count >= self.decimate {
|
||||
self.decimate_count = 0;
|
||||
self.ring[self.ring_at] = d;
|
||||
self.ring_at = (self.ring_at + 1) % self.ring.len();
|
||||
self.hop_count += 1;
|
||||
if self.hop_count >= self.hop {
|
||||
self.hop_count = 0;
|
||||
self.hop_analysis(&mut emit);
|
||||
}
|
||||
}
|
||||
while self.event_len > 0 {
|
||||
let e = self.events[self.event_head];
|
||||
if e.time + self.latency as u64 > self.clock {
|
||||
break;
|
||||
}
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
self.handle(e.kind, &mut emit);
|
||||
}
|
||||
self.clock += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fundamental that follows tracker events: jumps on new notes, slides on bends and legato.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PitchFollower {
|
||||
target: f32,
|
||||
hz: f32,
|
||||
jump: bool,
|
||||
}
|
||||
impl PitchFollower {
|
||||
pub fn event(&mut self, snap: bool, e: NoteEvent) {
|
||||
match e {
|
||||
NoteEvent::Start { pitch, glide } => {
|
||||
let p = if snap { pitch.round() } else { pitch };
|
||||
self.target = crate::midi_to_hz(p);
|
||||
self.jump |= !glide;
|
||||
}
|
||||
NoteEvent::Move { pitch } if !snap => self.target = crate::midi_to_hz(pitch),
|
||||
// The last pitch is held so tails keep their tuning.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Advances one sample with one-pole coefficient `coef`. Returns the frequency, 0 before the
|
||||
/// first note, and whether it jumped this sample (a new note, or the first one).
|
||||
pub fn step(&mut self, coef: f32) -> (f32, bool) {
|
||||
let jump = std::mem::take(&mut self.jump) || (self.hz == 0. && self.target > 0.);
|
||||
if jump {
|
||||
self.hz = self.target;
|
||||
} else {
|
||||
self.hz += (self.target - self.hz) * coef;
|
||||
}
|
||||
(self.hz, jump)
|
||||
}
|
||||
pub fn hz(&self) -> f32 {
|
||||
self.hz
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user