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

+61 -10
View File
@@ -1,4 +1,5 @@
use anvil_core::{Settings, Source, KICK_OFFSET_MAX};
use anvil_core::{Mode, Settings, Source, KICK_OFFSET_MAX};
use common_params::PitchParams;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
@@ -10,6 +11,49 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum ModeParam {
#[id = "replace"]
Replace,
#[id = "enhance"]
Enhance,
}
impl From<ModeParam> for Mode {
fn from(m: ModeParam) -> Self {
match m {
ModeParam::Replace => Mode::Replace,
ModeParam::Enhance => Mode::Enhance,
}
}
}
impl From<Mode> for ModeParam {
fn from(m: Mode) -> Self {
match m {
Mode::Replace => ModeParam::Replace,
Mode::Enhance => ModeParam::Enhance,
}
}
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
@@ -20,6 +64,10 @@ pub struct AnvilParams {
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "mode"]
pub mode: EnumParam<ModeParam>,
#[id = "level"]
pub level: FloatParam,
#[id = "split"]
@@ -65,6 +113,8 @@ impl Default for AnvilParams {
Self {
editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
mode: EnumParam::new("Mode", ModeParam::Replace),
level: linear("Sub level", d.level, -30., 0., " dBFS"),
split: FloatParam::new(
"Split",
@@ -114,10 +164,9 @@ impl Default for AnvilParams {
impl AnvilParams {
pub fn settings(&self) -> Settings {
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
mode: self.mode.value().into(),
level: self.level.value(),
split: self.split.value(),
dynamics: self.dynamics.value(),
@@ -206,14 +255,16 @@ impl AnvilParams {
});
}
once(new.source != old.source, &|| {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
});
once(new.mode != old.mode, &|| {
setter.begin_set_parameter(&self.mode);
setter.set_parameter(&self.mode, new.mode.into());
setter.end_set_parameter(&self.mode);
});
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
fn continuous(&self, i: usize) -> &FloatParam {
[