Files
bass-foundry/plugins/common/dsp/tests/blocks.rs
T
nvrl adfb2c0287 feat: stable note tracking, NOTE pitch source and unsure fallback
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.
2026-11-06 21:02:52 +01:00

379 lines
11 KiB
Rust
Raw 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::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, PitchMode, PitchSource, Scale, 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_allpass_matches_a_linkwitz_riley_split() {
let q = std::f32::consts::FRAC_1_SQRT_2;
let (low, high, all) = (
SvfCoef::lowpass(RATE, 300., q),
SvfCoef::highpass(RATE, 300., q),
SvfCoef::allpass(RATE, 300., q),
);
let mut s = [Svf::default(); 5];
let mut saw = Saw::new(RATE, 55.);
for _ in 0..9600 {
let x = saw.sample();
let l = s[0].process(&low, x);
let l = s[1].process(&low, l);
let h = s[2].process(&high, x);
let h = s[3].process(&high, h);
let a = s[4].process(&all, x);
assert!((l + h - a).abs() < 1e-4, "{} vs {a}", l + h);
}
let mut s = Svf::default();
for hz in [30., 300., 3000.] {
assert!(sine_gain(|x| s.process(&all, x), hz).abs() < 0.01);
}
}
#[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(PitchSource::default());
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(PitchSource {
mode: PitchMode::Midi,
glide: true,
..PitchSource::default()
}));
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),
]
);
}
#[test]
fn stable_tracker_holds_its_notes_on_the_stem_corpus() {
let stable = PitchSource::default();
let bends = PitchSource {
bends: true,
..stable
};
let (mut extra_stable, mut extra_bends) = (0., 0.);
for stem in common_dsp::stems::all(RATE) {
let s = common_dsp::stems::score(
RATE,
TrackerConfig::BASS,
stable,
&stem.samples,
&stem.truth,
);
let b =
common_dsp::stems::score(RATE, TrackerConfig::BASS, bends, &stem.samples, &stem.truth);
assert!(s.extra_starts <= 0.25, "{}: {s:?}", stem.name);
assert!(s.wrong <= 0.05, "{}: {s:?}", stem.name);
assert!(s.wrong <= b.wrong + 0.005, "{}: {s:?} vs {b:?}", stem.name);
extra_stable += s.extra_starts;
extra_bends += b.extra_starts;
}
// Follow bends keeps the fast 0.1 behaviour, which flickers on formant basses.
assert!(
extra_bends > 10. * extra_stable.max(1.),
"{extra_stable} vs {extra_bends}"
);
}
#[test]
fn tracker_turns_inactive_when_reads_stop_agreeing() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(PitchSource::default());
let mut saw = Saw::new(RATE, 55.);
let mut seed = 1u32;
let mut active = vec![];
for i in 0..(RATE * 1.5) as usize {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let noise = seed as f32 / u32::MAX as f32 - 0.5;
let x = if i < 24000 { 0.5 * saw.sample() } else { noise };
t.process(x, |_| {});
active.push(t.active());
}
let l = t.latency();
assert!(active[l + 12000..l + 23000].iter().all(|a| *a));
// Unreadable sound is no evidence against the note, so trust fades slowly: growls read
// only now and then. Within half a second of noise, tracked processing stops acting.
assert!(active[l + 48000..].iter().all(|a| !*a));
}
#[test]
fn note_mode_plays_the_set_note_on_each_onset() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(PitchSource {
mode: PitchMode::Note,
note: 29,
..PitchSource::default()
});
let mut saw = Saw::new(RATE, 220.);
let mut events = vec![];
for i in 0..48000 {
let x = if (4800..20000).contains(&i) || i >= 30000 {
0.5 * saw.sample()
} else {
0.
};
t.process(x, |e| events.push(e));
if i == 15000 {
assert!(t.active());
}
}
let starts: Vec<_> = events
.iter()
.filter_map(|e| match e {
NoteEvent::Start { pitch, .. } => Some(*pitch),
_ => None,
})
.collect();
assert_eq!(starts, vec![29., 29.]);
assert!(!events.iter().any(|e| matches!(e, NoteEvent::Move { .. })));
}
#[test]
fn key_lock_snaps_reads_to_the_scale() {
// F minor: F G Ab Bb C Db Eb. F#1 (30) leans to G1 when read sharp.
assert_eq!(Scale::Minor.snap(29, 30.4), 31.);
assert_eq!(Scale::Minor.snap(29, 29.6), 29.);
assert_eq!(Scale::Major.snap(0, 61.), 60.);
assert_eq!(Scale::Root.snap(29, 40.6), 41.);
assert_eq!(Scale::Root.snap(29, 35.4), 41.);
assert_eq!(Scale::Free.snap(29, 30.37), 30.37);
}
#[test]
fn key_lock_keeps_a_drifting_wobble_on_its_root() {
let stem = common_dsp::stems::all(RATE)
.into_iter()
.find(|s| s.name == "wobble")
.unwrap();
let free = PitchSource::default();
let root = PitchSource {
note: 31,
scale: Scale::Root,
..free
};
let f = common_dsp::stems::score(RATE, TrackerConfig::BASS, free, &stem.samples, &stem.truth);
let r = common_dsp::stems::score(RATE, TrackerConfig::BASS, root, &stem.samples, &stem.truth);
assert!(r.unsure + r.wrong < f.unsure + f.wrong, "{f:?} vs {r:?}");
assert!(r.wrong <= 0.02, "{r:?}");
}
#[test]
fn note_names_parse_back() {
for n in 0..=127u8 {
assert_eq!(common_dsp::parse_note(&common_dsp::note_name(n)), Some(n));
}
assert_eq!(common_dsp::parse_note(" f1 "), Some(29));
assert_eq!(common_dsp::parse_note("H2"), None);
}