Files
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

245 lines
6.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use std::f32::consts::TAU;
use temper_core::{Curve, Engine, Settings, Source};
const RATE: f32 = 48000.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
let l = e.latency_samples() as usize;
(e, l)
}
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
let mut s = Saw::new(RATE, hz);
(0..(seconds * RATE) as usize)
.map(|_| s.sample() * amp)
.collect()
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
}
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
let mut m = HarmonicMeter::new(RATE, 85.);
for &v in x {
m.push(v, hz);
}
*m.levels()
}
/// Harmonics 1–n relative to the fundamental.
fn profile(x: &[f32], hz: f32, n: usize) -> Vec<f32> {
let h = harmonics(x, hz);
(0..n).map(|k| h[k] - h[0]).collect()
}
fn rms_db(x: &[f32]) -> f32 {
10. * (x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).log10()
}
#[test]
fn quiet_and_loud_patches_distort_the_same() {
let s = Settings {
drive: 18.,
auto_gain: false,
..Settings::default()
};
let mut profiles = vec![];
for amp in [0.5, 0.06] {
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(55., 1.2, amp));
profiles.push(profile(&y[y.len() / 2..], 55., 8));
}
for k in 1..8 {
let d = profiles[0][k] - profiles[1][k];
assert!(d.abs() < 1., "harmonic {}: {profiles:?}", k + 1);
}
// Without normalisation the quiet patch is barely distorted.
let s = Settings { normalize: 0., ..s };
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(55., 1.2, 0.06));
let quiet = profile(&y[y.len() / 2..], 55., 8);
assert!(
(quiet[2] - profiles[0][2]).abs() > 2.,
"{quiet:?} vs {profiles:?}"
);
}
#[test]
fn bright_follows_the_note() {
let s = Settings {
drive: 24.,
curve: Curve::Hard,
bright: 8.,
..Settings::default()
};
let mut profiles = vec![];
for hz in [43.65, 87.31] {
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(hz, 1.2, 0.4));
profiles.push(profile(&y[y.len() / 2..], hz, 14));
}
for k in 1..14 {
let d = profiles[0][k] - profiles[1][k];
assert!(d.abs() < 1.5, "harmonic {}: {profiles:?}", k + 1);
}
// Above the cutoff the harmonics fall away on both notes.
assert!(profiles[0][12] < profiles[0][4] - 10.);
}
/// Energy away from the harmonics of `hz`, relative to the harmonics, in dB.
fn alias_ratio(curve: Curve, drive: f32, hz: f32) -> f32 {
let s = Settings {
drive,
curve,
auto_gain: false,
..Settings::default()
};
let (mut e, _) = engine(s);
let x: Vec<f32> = (0..36000)
.map(|i| 0.3 * (TAU * hz * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let tail = &y[12000..];
let n = tail.len();
let window: Vec<f32> = (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos())
.collect();
let (mut harmonic, mut other) = (0f64, 0f64);
for f in (20..23000).step_by(10) {
// Goertzel power at f.
let c = 2. * (TAU as f64 * f as f64 / RATE as f64).cos();
let (mut s1, mut s2) = (0f64, 0f64);
for (v, w) in tail.iter().zip(&window) {
let s0 = (v * w) as f64 + c * s1 - s2;
s2 = s1;
s1 = s0;
}
let p = s1 * s1 + s2 * s2 - c * s1 * s2;
let near = (f as f32 / hz - (f as f32 / hz).round()).abs() * hz < 30.;
if near {
harmonic += p;
} else {
other += p;
}
}
(10. * (other / harmonic).log10()) as f32
}
#[test]
fn oversampling_keeps_aliasing_down_in_the_bass_range() {
// 2 kHz stands for the upper harmonics of a growl, 440 Hz for a high bass fundamental.
for curve in Curve::ALL {
let r = alias_ratio(curve, 24., 2000.);
assert!(r < -60., "{} 2 kHz: {r}", curve.name());
let r = alias_ratio(curve, 36., 440.);
assert!(r < -55., "{} 440 Hz: {r}", curve.name());
}
}
#[test]
#[ignore]
fn print_alias_ratios() {
for curve in Curve::ALL {
for hz in [440., 2000., 5000.] {
for drive in [12., 24., 36.] {
println!(
"{} {hz} {drive}: {:.1}",
curve.name(),
alias_ratio(curve, drive, hz)
);
}
}
}
}
#[test]
fn auto_gain_matches_input_loudness() {
for drive in [6., 24., 36.] {
let s = Settings {
drive,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2., 0.3);
let y = render(&mut e, &x);
let d = rms_db(&y[y.len() / 2..]) - rms_db(&x[x.len() / 2 - l..x.len() - l]);
assert!(d.abs() < 1., "{drive}: {d}");
}
}
#[test]
fn zero_mix_is_the_delayed_dry_signal_and_latency_is_exact() {
let s = Settings {
mix: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1., 0.5);
let y = render(&mut e, &x);
for i in RATE as usize / 2..x.len() {
assert_eq!(y[i], x[i - l], "{i}");
}
// With the shaper nearly linear the wet path lines up with the dry one.
let s = Settings {
drive: 0.,
normalize: 0.,
curve: Curve::Soft,
auto_gain: false,
..Settings::default()
};
let (mut e, l) = engine(s);
let mut x = vec![0f32; 9600];
x[100] = 0.01;
let y = render(&mut e, &x);
let at = y
.iter()
.enumerate()
.max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
.unwrap()
.0;
assert_eq!(at, 100 + l);
}
#[test]
fn midi_source_tunes_filters_at_the_delayed_note_time() {
let s = Settings {
source: Source::Midi,
bright: 4.,
drive: 18.,
curve: Curve::Hard,
..Settings::default()
};
let (mut e, _) = engine(s);
e.note_on(29);
let y = render(&mut e, &saw(43.65, 1., 0.4));
let p = profile(&y[y.len() / 2..], 43.65, 12);
assert!(p[10] < p[2] - 12., "{p:?}");
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
}
#[test]
fn settings_are_validated_and_serialise() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
for bad in [
Settings { floor: 0.1, ..s },
Settings { bright: 2., ..s },
Settings {
push_harmonic: 0,
..s
},
Settings {
drive: f32::NAN,
..s
},
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
for c in Curve::ALL {
assert_eq!(c.shape(0.), 0.);
assert!(c.shape(100.).abs() <= 1.0001);
}
}