Files
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

181 lines
5.2 KiB
Rust

use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use lathe_core::{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()
}
/// Harmonic levels of the last complete window of `x`.
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()
}
#[test]
fn a_cut_on_harmonic_three_stays_on_harmonic_three_on_every_note() {
let mut s = Settings::default();
s.gains[2] = -12.;
for hz in [43.65, 65.41, 130.81] {
let x = saw(hz, 1., 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
let d: Vec<f32> = (0..6).map(|k| b[k] - a[k]).collect();
assert!((d[2] + 12.).abs() < 0.7, "{hz}: {d:?}");
for k in [0, 1, 3, 4] {
assert!(d[k].abs() < 1., "{hz} harmonic {}: {d:?}", k + 1);
}
}
}
#[test]
fn a_flat_map_passes_the_delayed_input_exactly() {
let x = saw(55., 0.5, 0.5);
let (mut e, l) = engine(Settings::default());
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i], x[i - l]);
}
}
#[test]
fn odd_even_balances_the_harmonic_series() {
let s = Settings {
odd_even: 6.,
..Settings::default()
};
let hz = 55.;
let x = saw(hz, 1., 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
for k in 1..8 {
let want = if k % 2 == 1 { 3. } else { -3. };
let got = b[k - 1] - a[k - 1];
assert!((got - want).abs() < 0.8, "harmonic {k}: {got}");
}
}
#[test]
fn note_changes_do_not_click() {
let mut s = Settings::default();
s.gains[1] = 12.;
s.gains[3] = -24.;
s.tilt = -3.;
let mut x = saw(43.65, 0.5, 0.5);
x.extend(saw(58.27, 0.5, 0.5));
x.extend(saw(87.31, 0.5, 0.5));
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let step = y
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
let in_step = x
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
assert!(y.iter().all(|v| v.is_finite()));
// A saw has its own jumps; the filtered output must not jump much further than that.
assert!(step < 2. * in_step, "{step} vs {in_step}");
}
#[test]
fn midi_source_tunes_the_bands_at_the_delayed_note_time() {
let mut s = Settings {
source: Source::Midi,
..Settings::default()
};
s.gains[0] = -24.;
let (mut e, l) = engine(s);
// 43.65 Hz is MIDI 29. Notes arrive at sample 0; the bands must be tuned by sample l.
e.note_on(29);
let x = saw(43.65, 1., 0.5);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], 43.65);
let b = harmonics(&y[l..], 43.65);
assert!(b[0] - a[0] < -20., "{}", b[0] - a[0]);
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
assert!((f.hz - 43.65).abs() < 0.05);
}
#[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);
let mut bad = s;
bad.gains[5] = 40.;
assert!(bad.validate().is_err());
bad = s;
bad.harmonics = 4;
assert!(bad.validate().is_err());
bad = s;
bad.width = f32::NAN;
assert!(bad.validate().is_err());
let mut e = Engine::new(RATE, 2).unwrap();
assert!(e.set_settings(bad).is_err());
assert_eq!(e.settings(), s);
}
#[test]
fn note_mode_cuts_the_set_notes_harmonics() {
let mut s = Settings::default();
s.gains[2] = -12.;
s.source = Source::Note;
s.pitch.note = 29;
let hz = 43.65;
let x = saw(hz, 1., 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
assert!((b[2] - a[2] + 12.).abs() < 0.7, "{a:?} {b:?}");
assert!((b[1] - a[1]).abs() < 1.);
}
#[test]
fn unsure_tracking_passes_the_audio_through() {
let mut s = Settings::default();
s.gains[0] = -12.;
s.gains[1] = -12.;
s.gains[2] = -12.;
let (mut e, l) = engine(s);
// A clear note, then noise detection cannot read.
let mut x = saw(55., 0.5, 0.5);
let mut seed = 7u32;
for _ in 0..RATE as usize {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
x.push((seed as f32 / u32::MAX as f32 - 0.5) * 0.5);
}
let y = render(&mut e, &x);
let tail = (RATE * 1.0) as usize;
let mut worst = 0f32;
for i in tail..x.len() {
worst = worst.max((y[i] - x[i - l]).abs());
}
assert!(worst < 1e-3, "{worst}");
}