Files
bass-foundry/plugins/anvil/plugin/src/params.rs
T
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

294 lines
9.7 KiB
Rust

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;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[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)
}
#[derive(Params)]
pub struct AnvilParams {
#[persist = "editor"]
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"]
pub split: FloatParam,
#[id = "dynamics"]
pub dynamics: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "ghost"]
pub ghost: FloatParam,
#[id = "ghost_from"]
pub ghost_from: FloatParam,
#[id = "octave"]
pub octave: IntParam,
#[id = "glide"]
pub glide: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "keep"]
pub keep: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "retrigger"]
pub retrigger: BoolParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[id = "kick_lock"]
pub kick_lock: BoolParam,
#[id = "kick_duck"]
pub kick_duck: FloatParam,
#[id = "kick_release"]
pub kick_release: FloatParam,
#[id = "kick_offset"]
pub kick_offset: FloatParam,
}
impl Default for AnvilParams {
fn default() -> Self {
let d = Settings::default();
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",
d.split,
FloatRange::Skewed {
min: 40.,
max: 200.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
drive: linear("Drive", d.drive, 0., 100., " %"),
ghost: linear("Ghost", d.ghost, 0., 100., " %"),
ghost_from: FloatParam::new(
"Ghost from",
d.ghost_from,
FloatRange::Skewed {
min: 150.,
max: 600.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
glide: linear("Glide", d.glide, 0., 200., " ms"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
keep: linear("Keep", d.keep, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
retrigger: BoolParam::new("Retrigger", d.retrigger),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
kick_lock: BoolParam::new("Kick lock", d.kick_lock),
kick_duck: linear("Kick duck", d.kick_duck, 0., 24., " dB"),
kick_release: linear("Kick release", d.kick_release, 10., 300., " ms"),
kick_offset: linear(
"Kick offset",
d.kick_offset,
-KICK_OFFSET_MAX,
KICK_OFFSET_MAX,
" ms",
),
}
}
}
impl AnvilParams {
pub fn settings(&self) -> Settings {
Settings {
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(),
drive: self.drive.value(),
ghost: self.ghost.value(),
ghost_from: self.ghost_from.value(),
octave: self.octave.value(),
glide: self.glide.value(),
phase: self.phase.value(),
gate: self.gate.value(),
keep: self.keep.value(),
output: self.output.value(),
retrigger: self.retrigger.value(),
snap: self.snap.value(),
bypass: self.bypass.value(),
kick_lock: self.kick_lock.value(),
kick_duck: self.kick_duck.value(),
kick_release: self.kick_release.value(),
kick_offset: self.kick_offset.value(),
}
}
/// Pushes every changed editor value to the host. Continuous controls hold one
/// gesture while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; 14],
) {
let floats: [(&FloatParam, f32, f32); 13] = [
(&self.level, old.level, new.level),
(&self.split, old.split, new.split),
(&self.dynamics, old.dynamics, new.dynamics),
(&self.drive, old.drive, new.drive),
(&self.glide, old.glide, new.glide),
(&self.phase, old.phase, new.phase),
(&self.gate, old.gate, new.gate),
(&self.keep, old.keep, new.keep),
(&self.ghost, old.ghost, new.ghost),
(&self.ghost_from, old.ghost_from, new.ghost_from),
(&self.kick_duck, old.kick_duck, new.kick_duck),
(&self.kick_release, old.kick_release, new.kick_release),
(&self.kick_offset, old.kick_offset, new.kick_offset),
];
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
if changed {
if !open[i] {
open[i] = true;
set_begin(setter, self, i);
}
set();
}
if !dragging && open[i] {
set_end(setter, self, i);
open[i] = false;
}
};
for (i, (p, a, b)) in floats.into_iter().enumerate() {
continuous(i, a != b, &|| setter.set_parameter(p, b));
}
continuous(13, old.output != new.output, &|| {
setter.set_parameter(&self.output, new.output)
});
let once = |changed: bool, f: &dyn Fn()| {
if changed {
f();
}
};
once(new.octave != old.octave, &|| {
setter.begin_set_parameter(&self.octave);
setter.set_parameter(&self.octave, new.octave);
setter.end_set_parameter(&self.octave);
});
for (p, a, b) in [
(&self.retrigger, old.retrigger, new.retrigger),
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
(&self.kick_lock, old.kick_lock, new.kick_lock),
] {
once(a != b, &|| {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
});
}
once(new.source != old.source, &|| {
setter.begin_set_parameter(&self.source);
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 {
[
&self.level,
&self.split,
&self.dynamics,
&self.drive,
&self.glide,
&self.phase,
&self.gate,
&self.keep,
&self.ghost,
&self.ghost_from,
&self.kick_duck,
&self.kick_release,
&self.kick_offset,
&self.output,
][i]
}
}
fn set_begin(setter: &ParamSetter, p: &AnvilParams, i: usize) {
setter.begin_set_parameter(p.continuous(i));
}
fn set_end(setter: &ParamSetter, p: &AnvilParams, i: usize) {
setter.end_set_parameter(p.continuous(i));
}