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.
181 lines
5.2 KiB
Rust
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}");
|
|
}
|