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.
379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
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);
|
||
}
|