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

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:
nvrl committed 2026-11-06 06:08:51 +01:00
1 parent f36ff0780c
commit b3ebe4d952
48 files changed
+9862 -339

No files matched your search

+4
View File
@@ -1,4 +1,8 @@
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
pub mod meter;
pub mod oversample;
pub mod svf;
pub mod tracker;
pub mod wav;
use std::f32::consts::TAU;
+91
View File
@@ -0,0 +1,91 @@
//! Levels of the harmonics of a known fundamental, measured with Hann-windowed Goertzel
//! filters. Work is spread evenly over the samples, so there is no per-window spike.
use std::f32::consts::TAU;
pub const HARMONICS: usize = 32;
pub struct HarmonicMeter {
rate: f32,
window: Vec<f32>,
count: usize,
hz: f32,
coef: [f32; HARMONICS],
s1: [f32; HARMONICS],
s2: [f32; HARMONICS],
levels: [f32; HARMONICS],
}
impl HarmonicMeter {
/// Window of about `ms`; 85 ms resolves harmonics of fundamentals down to 30 Hz.
pub fn new(rate: f32, ms: f32) -> Self {
let n = ((rate * ms * 0.001) as usize).max(64);
Self {
rate,
window: (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos())
.collect(),
count: 0,
hz: 0.,
coef: [0.; HARMONICS],
s1: [0.; HARMONICS],
s2: [0.; HARMONICS],
levels: [-120.; HARMONICS],
}
}
/// Peak level in dBFS of each harmonic (index 0 is the fundamental) from the last
/// complete window. Harmonics above 0.45 × the sample rate read −120.
pub fn levels(&self) -> &[f32; HARMONICS] {
&self.levels
}
/// Fundamental of the last complete window, 0 if none.
pub fn hz(&self) -> f32 {
self.hz
}
/// Feeds one sample. `hz` is read at the start of each window (0 = no note: skip it).
/// Returns true when a window completed and `levels` changed.
#[inline]
pub fn push(&mut self, x: f32, hz: f32) -> bool {
if self.count == 0 {
self.hz = hz;
for k in 0..HARMONICS {
let f = hz * (k + 1) as f32;
self.coef[k] = if hz > 0. && f < 0.45 * self.rate {
2. * (TAU * f / self.rate).cos()
} else {
0.
};
}
self.s1 = [0.; HARMONICS];
self.s2 = [0.; HARMONICS];
}
let w = x * self.window[self.count];
for k in 0..HARMONICS {
let s = w + self.coef[k] * self.s1[k] - self.s2[k];
self.s2[k] = self.s1[k];
self.s1[k] = s;
}
self.count += 1;
if self.count < self.window.len() {
return false;
}
self.count = 0;
let n = self.window.len() as f32;
for k in 0..HARMONICS {
let f = self.hz * (k + 1) as f32;
self.levels[k] = if self.hz > 0. && f < 0.45 * self.rate {
let c = self.coef[k];
let p =
self.s1[k] * self.s1[k] + self.s2[k] * self.s2[k] - c * self.s1[k] * self.s2[k];
// Hann coherent gain is 0.5: a sine of peak A gives |X| = A·N/4.
let amp = 4. * p.max(0.).sqrt() / n;
20. * amp.max(1e-6).log10()
} else {
-120.
};
}
true
}
pub fn reset(&mut self) {
self.count = 0;
self.levels = [-120.; HARMONICS];
}
}
+131
View File
@@ -0,0 +1,131 @@
//! 4× oversampling with two linear-phase half-band FIR stages. The round trip (up, then
//! down) delays the signal by a whole number of base-rate samples, `latency()`.
const STAGE1_HALF: usize = 31; // 63 taps at 2×: steep, carries the audio band edge
const STAGE2_HALF: usize = 11; // 23 taps at 4×: the band edge is far from its images
const BETA: f32 = 9.;
/// Kaiser-windowed half-band lowpass with gain `gain`, `2 * half + 1` taps.
fn halfband(half: usize, gain: f32) -> Vec<f32> {
fn i0(x: f32) -> f32 {
let (mut sum, mut term) = (1f64, 1f64);
let y = (x as f64 / 2.).powi(2);
for k in 1..50 {
term *= y / (k * k) as f64;
sum += term;
}
sum as f32
}
let n = 2 * half + 1;
let norm = i0(BETA);
let mut h: Vec<f32> = (0..n)
.map(|i| {
let m = i as f32 - half as f32;
let sinc = if m == 0. {
0.5
} else {
(std::f32::consts::FRAC_PI_2 * m).sin() / (std::f32::consts::PI * m)
};
let r = m / half as f32;
sinc * i0(BETA * (1. - r * r).max(0.).sqrt()) / norm
})
.collect();
let sum: f32 = h.iter().sum();
for v in &mut h {
*v *= gain / sum;
}
h
}
/// FIR with a doubled circular buffer so every convolution reads one contiguous slice.
struct Fir {
taps: Vec<f32>,
buf: Vec<f32>,
at: usize,
}
impl Fir {
fn new(taps: Vec<f32>) -> Self {
let n = taps.len();
Self {
taps,
buf: vec![0.; 2 * n],
at: 0,
}
}
#[inline]
fn process(&mut self, x: f32) -> f32 {
let n = self.taps.len();
self.at = if self.at == 0 { n - 1 } else { self.at - 1 };
self.buf[self.at] = x;
self.buf[self.at + n] = x;
let window = &self.buf[self.at..self.at + n];
window.iter().zip(&self.taps).map(|(a, b)| a * b).sum()
}
fn reset(&mut self) {
self.buf.fill(0.);
self.at = 0;
}
}
/// One channel of 4× oversampling. Allocation-free after `new`.
pub struct Oversampler {
up1: Fir,
up2: Fir,
down2: Fir,
down1: Fir,
/// Two samples at 4× make the round trip a whole number of base samples.
pad: [f32; 2],
}
impl Default for Oversampler {
fn default() -> Self {
Self::new()
}
}
impl Oversampler {
pub const FACTOR: usize = 4;
pub fn new() -> Self {
Self {
up1: Fir::new(halfband(STAGE1_HALF, 2.)),
up2: Fir::new(halfband(STAGE2_HALF, 2.)),
down2: Fir::new(halfband(STAGE2_HALF, 1.)),
down1: Fir::new(halfband(STAGE1_HALF, 1.)),
pad: [0.; 2],
}
}
/// Round-trip delay in base-rate samples.
pub const fn latency() -> usize {
STAGE1_HALF + STAGE2_HALF.div_ceil(2)
}
#[inline]
pub fn up(&mut self, x: f32) -> [f32; 4] {
let a = self.up1.process(x);
let b = self.up1.process(0.);
let mut out = [
self.up2.process(a),
self.up2.process(0.),
self.up2.process(b),
self.up2.process(0.),
];
// Delay by two 4× samples.
let [p0, p1] = self.pad;
self.pad = [out[2], out[3]];
out = [p0, p1, out[0], out[1]];
out
}
#[inline]
pub fn down(&mut self, y: [f32; 4]) -> f32 {
let a = self.down2.process(y[0]);
self.down2.process(y[1]);
let b = self.down2.process(y[2]);
self.down2.process(y[3]);
let out = self.down1.process(a);
self.down1.process(b);
out
}
pub fn reset(&mut self) {
self.up1.reset();
self.up2.reset();
self.down2.reset();
self.down1.reset();
self.pad = [0.; 2];
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Trapezoidal state-variable filter (Simper/Cytomic). Stable and click-free under fast
//! retuning, so it can follow a gliding note. Coefficients are separate from state, so one
//! set can drive both channels.
use std::f32::consts::PI;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SvfCoef {
a1: f32,
a2: f32,
a3: f32,
m0: f32,
m1: f32,
m2: f32,
}
impl Default for SvfCoef {
/// Identity: passes the input unchanged.
fn default() -> Self {
Self::IDENTITY
}
}
impl SvfCoef {
pub const IDENTITY: Self = Self {
a1: 1.,
a2: 0.,
a3: 0.,
m0: 1.,
m1: 0.,
m2: 0.,
};
fn base(rate: f32, hz: f32, k: f32, warp: f32) -> (f32, f32, f32) {
let g = (PI * (hz / rate).clamp(1e-6, 0.49)).tan() * warp;
let a1 = 1. / (1. + g * (g + k));
let a2 = g * a1;
(a1, a2, g * a2)
}
/// Peaking filter. At 0 dB it is an exact identity.
pub fn bell(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / (q.max(1e-3) * a);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: k * (a * a - 1.),
m2: 0.,
}
}
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
let (a1, a2, a3) = Self::base(rate, hz, 1. / q.max(1e-3), 1.);
Self {
a1,
a2,
a3,
m0: 0.,
m1: 0.,
m2: 1.,
}
}
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: -k,
m2: -1.,
}
}
pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, a.sqrt());
Self {
a1,
a2,
a3,
m0: a * a,
m1: k * (1. - a) * a,
m2: 1. - a * a,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Svf {
ic1: f32,
ic2: f32,
}
impl Svf {
#[inline]
pub fn process(&mut self, c: &SvfCoef, v0: f32) -> f32 {
let v3 = v0 - self.ic2;
let v1 = c.a1 * self.ic1 + c.a2 * v3;
let v2 = self.ic2 + c.a2 * self.ic1 + c.a3 * v3;
self.ic1 = 2. * v1 - self.ic1;
self.ic2 = 2. * v2 - self.ic2;
c.m0 * v0 + c.m1 * v1 + c.m2 * v2
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
+399
View File
@@ -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
}
}
+225
View File
@@ -0,0 +1,225 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{hz_to_midi, Saw};
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
/// Steady-state gain in dB of a sine at `hz` through `f`.
fn sine_gain(mut f: impl FnMut(f32) -> f32, hz: f32) -> f32 {
let n = (RATE * 0.5) as usize;
let (mut pin, mut pout) = (0., 0.);
for i in 0..n {
let x = (TAU * hz * i as f32 / RATE).sin();
let y = f(x);
if i > n / 2 {
pin += x * x;
pout += y * y;
}
}
10. * (pout / pin).log10()
}
#[test]
fn svf_bell_hits_its_gain_and_leaves_the_rest() {
for (hz, db) in [(130., -12.), (400., 6.), (2000., -24.)] {
let c = SvfCoef::bell(RATE, hz, 4., db);
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz);
assert!((g - db).abs() < 0.1, "{hz}: {g}");
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz * 4.);
assert!(g.abs() < 0.3, "{hz} away: {g}");
}
}
#[test]
fn svf_bell_at_zero_db_is_an_exact_identity() {
let c = SvfCoef::bell(RATE, 100., 2., 0.);
let mut s = Svf::default();
let mut saw = Saw::new(RATE, 55.);
for _ in 0..4800 {
let x = saw.sample();
assert_eq!(s.process(&c, x), x);
}
}
#[test]
fn svf_survives_retuning_every_sample() {
let mut s = Svf::default();
let mut saw = Saw::new(RATE, 55.);
let mut peak: f32 = 0.;
for i in 0..48000 {
let hz = 40. + 4000. * (0.5 + 0.5 * (i as f32 * 0.003).sin());
let c = SvfCoef::bell(RATE, hz, 8., 12.);
let y = s.process(&c, saw.sample());
assert!(y.is_finite());
peak = peak.max(y.abs());
}
assert!(peak < 8., "{peak}");
let c = SvfCoef::lowpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 2000.) < -35.);
let c = SvfCoef::highpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 20.) < -35.);
}
#[test]
fn oversampler_round_trip_is_a_whole_sample_delay() {
let mut o = Oversampler::new();
let l = Oversampler::latency();
let mut saw = Saw::new(RATE, 97.);
let x: Vec<f32> = (0..9600).map(|_| saw.sample() * 0.5).collect();
let y: Vec<f32> = x
.iter()
.map(|&v| {
let u = o.up(v);
o.down(u)
})
.collect();
// Band-limited content passes; compare after the delay, away from the start.
let mut err: f32 = 0.;
let mut sig: f32 = 0.;
for i in 2000..9600 {
let d = y[i] - x[i - l];
err += d * d;
sig += x[i - l] * x[i - l];
}
let null = 10. * (err / sig).log10();
// The saw has energy above the passband edge, so the null is limited by that, not by timing.
assert!(null < -40., "{null}");
}
#[test]
fn oversampler_passband_is_flat_and_images_are_removed() {
for hz in [50., 1000., 10000., 18000.] {
let mut o = Oversampler::new();
let g = sine_gain(
|x| {
let u = o.up(x);
o.down(u)
},
hz,
);
assert!(g.abs() < 0.1, "{hz}: {g}");
}
// An image of a 1 kHz tone at 4× would sit at 47 kHz; check the 4× signal there.
let mut o = Oversampler::new();
let n = 48000;
let (mut a, mut b) = (0f64, 0f64);
let w = TAU as f64 * 47000. / (4. * RATE as f64);
for i in 0..n {
let x = (TAU * 1000. * i as f32 / RATE).sin();
for (k, v) in o.up(x).into_iter().enumerate() {
let t = (4 * i + k) as f64;
a += v as f64 * (w * t).cos();
b += v as f64 * (w * t).sin();
}
}
let image = 20.
* ((a * a + b * b).sqrt() / (2. * n as f64))
.max(1e-12)
.log10();
assert!(image < -85., "{image}");
}
#[test]
fn harmonic_meter_reads_saw_harmonics() {
let hz = 55.;
let mut m = HarmonicMeter::new(RATE, 85.);
let mut saw = Saw::new(RATE, hz);
let mut done = 0;
for _ in 0..48000 {
if m.push(saw.sample(), hz) {
done += 1;
}
}
assert!(done >= 5);
// A saw's k-th harmonic has peak 2/(πk).
for k in 1..=16 {
let want = 20. * (2. / (std::f32::consts::PI * k as f32)).log10();
let got = m.levels()[k - 1];
assert!((got - want).abs() < 0.5, "harmonic {k}: {got} vs {want}");
}
}
#[test]
fn tracker_reports_notes_at_output_time() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(false, -42., false);
let l = t.latency();
assert!(l < (0.08 * RATE) as usize, "{l}");
let onset = 4800;
let mut events = vec![];
let mut saw_a = Saw::new(RATE, 87.31);
let mut saw_b = Saw::new(RATE, 220.);
for i in 0..onset + 48000 {
let x = if i < onset {
0.
} else if i < onset + 24000 {
0.5 * saw_a.sample()
} else {
0.5 * saw_b.sample()
};
t.process(x, |e| events.push((i, e)));
}
let starts: Vec<_> = events
.iter()
.filter_map(|(i, e)| match e {
NoteEvent::Start { pitch, .. } => Some((*i, *pitch)),
_ => None,
})
.collect();
assert_eq!(starts.len(), 2, "{starts:?}");
assert!(
(starts[0].0 as i64 - (onset + l) as i64).abs() <= 24,
"{starts:?}"
);
assert!((starts[0].1 - hz_to_midi(87.31)).abs() < 0.2);
assert!((starts[1].1 - hz_to_midi(220.)).abs() < 0.2);
}
#[test]
fn tracker_delays_midi_by_the_latency() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
assert!(t.set(true, -42., true));
let l = t.latency();
let mut events = vec![];
for i in 0..l + 2000 {
if i == 100 {
t.note_on(41);
}
if i == 600 {
t.note_on(43);
}
if i == 900 {
// Releasing the lower held note first leaves the top note; then the top ends it.
t.note_off(41);
t.note_off(43);
}
t.process(0., |e| events.push((i, e)));
}
assert_eq!(
events,
vec![
(
100 + l,
NoteEvent::Start {
pitch: 41.,
glide: false
}
),
(
600 + l,
NoteEvent::Start {
pitch: 43.,
glide: true
}
),
(900 + l, NoteEvent::Stop),
]
);
}