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.
This commit is contained in:
nvrl committed 2026-11-06 21:02:52 +01:00
1 parent 7c873f2bd0
commit adfb2c0287
66 files changed
+2246 -261

No files matched your search

+24 -6
View File
@@ -6,7 +6,9 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::tracker::{
Fallback, Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig,
};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
@@ -22,8 +24,10 @@ const GAIN_MS: f32 = 20.;
pub struct AnalyzerFrame {
/// Fundamental the filters are tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
/// Sounding note, rounded; None between notes and while tracking is unsure.
pub note: Option<u8>,
/// Tracking is unsure, so the audio passes unchanged.
pub neutral: bool,
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
pub input: [f32; HARMONICS],
pub output: [f32; HARMONICS],
@@ -35,6 +39,7 @@ impl Default for AnalyzerFrame {
Self {
hz: 0.,
note: None,
neutral: false,
input: [-120.; HARMONICS],
output: [-120.; HARMONICS],
latency: 0,
@@ -64,7 +69,11 @@ pub struct Module {
bypass: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
/// A sure note sounds: the readout shows it.
sounding: bool,
fallback: Fallback,
/// Unsure tracking: every band fades to 0 dB and the audio passes unchanged.
neutral: bool,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
@@ -103,6 +112,8 @@ impl Module {
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
sounding: false,
fallback: Fallback::default(),
neutral: false,
fresh: true,
};
m.set_settings(settings)?;
@@ -129,6 +140,8 @@ impl Module {
self.pitch = PitchFollower::default();
self.hz = 0.;
self.sounding = false;
self.fallback.reset();
self.neutral = false;
for ch in &mut self.state {
for f in ch.iter_mut() {
f.reset();
@@ -143,6 +156,9 @@ impl Module {
fn targets(&self) -> [f32; BANDS] {
let s = &self.settings;
let mut t = [0.; BANDS];
if self.neutral {
return t;
}
for k in 1..=s.harmonics as usize {
t[k - 1] = s.effective(k);
t[HARMONICS + k - 1] = s.between;
@@ -187,6 +203,7 @@ impl Module {
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
neutral: self.neutral,
..AnalyzerFrame::default()
};
if self.sounding && self.hz > 0. {
@@ -211,8 +228,9 @@ impl Tracked for Module {
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] {
self.sounding = note.sure;
self.neutral = self.fallback.neutral(note);
let first = self.hz == 0.;
let (hz, jump) = self.pitch.step(self.hz_coef);
let first = first && jump;
@@ -238,7 +256,7 @@ impl Tracked for Module {
let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output;
out[c] = wet * (1. - self.bypass) + y[c] * self.bypass;
}
let hz = if sounding { self.hz } else { 0. };
let hz = if note.sure { self.hz } else { 0. };
let (dry, wet) = if self.channels == 2 {
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
} else {
@@ -286,7 +304,7 @@ impl Engine {
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
.set(s.pitch.source(s.source.mode(), s.gate, s.smooth > 0.));
}
pub fn reset(&mut self) {
self.lookahead.reset();
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Gains in dB, times in milliseconds.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
/// Editable harmonics: the fundamental is harmonic 1.
@@ -13,12 +14,33 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// Gain per harmonic, index 0 = fundamental.
pub gains: [f32; HARMONICS],
/// dB per octave of harmonic number, added on top of `gains`. Harmonic 1 is the pivot.
@@ -45,6 +67,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
gains: [0.; HARMONICS],
tilt: 0.,
odd_even: 0.,
@@ -78,6 +101,7 @@ impl Settings {
if !finite {
return Err("non-finite setting");
}
self.pitch.validate()?;
let ok = self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
&& (-12.0..=12.).contains(&self.tilt)
&& (-12.0..=12.).contains(&self.odd_even)
+41
View File
@@ -137,3 +137,44 @@ fn settings_are_validated_and_serialise() {
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}");
}