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

119 lines
3.8 KiB
Rust

//! Scores the note tracker on bass stems.
//!
//! ```text
//! cargo run -p common-dsp --release --example tracker_report
//! cargo run -p common-dsp --release --example tracker_report -- [--sub] [--bends]
//! [--note F1] [--key free|minor|major|root] stem.wav ...
//! ```
//! Without files it scores the synthetic corpus in `common_dsp::stems`, once with the stable
//! default and once with Follow bends. With files, `--note` names the one note each file
//! plays (for a wrong-note share); without it only note starts and unsure time are reported.
use common_dsp::parse_note;
use common_dsp::stems::{self, Score, Truth};
use common_dsp::tracker::{PitchSource, Scale, TrackerConfig};
use std::{env, fs, process};
fn row(name: &str, s: Score) {
println!(
"{name:<14} {:>6.1}s {:>8.2} {:>8.2} {:>7.1}% {:>7.1}%",
s.seconds,
s.starts,
s.extra_starts,
s.wrong * 100.,
s.unsure * 100.
);
}
fn header() {
println!(
"{:<14} {:>7} {:>8} {:>8} {:>8} {:>8}",
"stem", "length", "starts/s", "extra/s", "wrong", "unsure"
);
}
fn main() {
let mut cfg = TrackerConfig::BASS;
let mut source = PitchSource::default();
let mut note = None;
let mut files = vec![];
let mut args = env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--sub" => cfg = TrackerConfig::SUB,
"--bends" => source.bends = true,
"--note" => {
let n = args.next().and_then(|n| parse_note(&n));
let Some(n) = n else {
eprintln!("--note needs a note name such as F1");
process::exit(2);
};
note = Some(n);
source.note = n;
}
"--key" => {
source.scale = match args.next().as_deref() {
Some("free") => Scale::Free,
Some("minor") => Scale::Minor,
Some("major") => Scale::Major,
Some("root") => Scale::Root,
_ => {
eprintln!("--key needs free, minor, major or root");
process::exit(2);
}
};
}
_ => files.push(a),
}
}
if files.is_empty() {
let rate = 48000.;
for bends in [false, true] {
println!(
"\n{}",
if bends {
"Follow bends"
} else {
"Stable (default)"
}
);
header();
let s = PitchSource { bends, ..source };
for stem in stems::all(rate) {
row(
stem.name,
stems::score(rate, cfg, s, &stem.samples, &stem.truth),
);
}
}
return;
}
header();
for path in files {
let wave = match fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|b| common_dsp::wav::decode(&b).map_err(str::to_string))
{
Ok(w) => w,
Err(e) => {
eprintln!("{path}: {e}");
continue;
}
};
let channels = wave.channels as usize;
let mono: Vec<f32> = wave
.samples
.chunks(channels)
.map(|c| c.iter().sum::<f32>() / channels as f32)
.collect();
// With a known note, quiet samples are skipped rather than counted as silence.
let truth = match note {
Some(n) => vec![Truth::Note(n as f32); mono.len()],
None => vec![],
};
let name = path.rsplit('/').next().unwrap_or(&path);
row(
name,
stems::score(wave.rate as f32, cfg, source, &mono, &truth),
);
}
}