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

+37 -7
View File
@@ -10,6 +10,10 @@
//! only the harmonics the patch lacks are filled: a harmonic the patch already has would
//! meet the added one at an unrelated phase and could cancel.
//!
//! The sine only plays while the note is sure. While tracking is unsure, the original sub
//! comes back in its place (Replace) or the added sine fades out (Enhance), so a wrong guess
//! never plays a wrong sub note.
//!
//! Kick lock reads the kick from the sidechain. Hits are found on the undelayed sidechain,
//! so with the lookahead each hit ducks the sub exactly when the delayed kick plays, and
//! restarts the sub's phase at the kick plus an offset, also before the kick. The audition
@@ -18,9 +22,9 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::tracker::{Fallback, NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover};
pub use settings::{Settings, Source, KICK_OFFSET_MAX};
pub use settings::{Mode, Settings, Source, KICK_OFFSET_MAX};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
@@ -30,6 +34,8 @@ pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
/// Crossfade between the sine and the original sub when tracking turns unsure or sure.
const TRUST_MS: f32 = 15.;
/// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz.
pub const GHOST_TOP: usize = 10;
/// Ghost harmonics shown and generated: 2..=GHOST_TOP.
@@ -76,6 +82,8 @@ pub struct AnalyzerFrame {
pub hz: f32,
/// MIDI note of the current sub, rounded.
pub note: Option<u8>,
/// Tracking is unsure: the original sub plays instead of the sine.
pub neutral: bool,
pub low_db: f32,
pub sub_db: f32,
/// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the
@@ -106,6 +114,7 @@ impl Default for AnalyzerFrame {
Self {
hz: 0.,
note: None,
neutral: false,
low_db: -120.,
sub_db: -120.,
patch_harmonics: [-120.; GHOST_COUNT],
@@ -308,6 +317,11 @@ pub struct Engine {
keep: f32,
output: f32,
bypass: f32,
fallback: Fallback,
neutral: bool,
/// 1 while the sine plays, 0 while the original sub stands in for it.
trust: f32,
trust_coef: f32,
meter_coef: f32,
low_power: f32,
sub_power: f32,
@@ -355,6 +369,10 @@ impl Engine {
keep: 0.,
output: 1.,
bypass: 0.,
fallback: Fallback::default(),
neutral: false,
trust: 1.,
trust_coef: coefficient(rate, TRUST_MS / 3.),
meter_coef: coefficient(rate, 150.),
low_power: 0.,
sub_power: 0.,
@@ -397,7 +415,7 @@ impl Engine {
}
if self
.tracker
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
.set(s.pitch.source(s.source.mode(), s.gate, s.glide > 0.))
{
self.synth.voice.releasing = true;
}
@@ -416,6 +434,9 @@ impl Engine {
}
self.tracker.reset();
self.synth = Synth::default();
self.fallback.reset();
self.neutral = false;
self.trust = 1.;
self.low_env = 0.;
self.low_power = 0.;
self.sub_power = 0.;
@@ -638,6 +659,7 @@ impl Engine {
};
let (synth, settings) = (&mut self.synth, &self.settings);
self.tracker.process(mono, |e| synth.event(settings, e));
self.neutral = self.fallback.neutral(self.tracker.state());
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
@@ -683,8 +705,10 @@ impl Engine {
let duck = self.kick_events();
self.update_fill();
let (sub, ghost) = self.oscillator();
let sub = sub * self.level * shape * duck;
let generated = sub + ghost * self.level * shape * duck;
self.trust += (f32::from(!self.neutral) - self.trust) * self.trust_coef;
let gain = self.level * shape * duck * self.trust;
let sub = sub * gain;
let generated = sub + ghost * gain;
if self.settings.kick_lock {
let kick_low = self.kick.low.process(kick_late);
self.measure_kick(kick_low, sub);
@@ -699,11 +723,16 @@ impl Engine {
self.scope_sub[self.scope_at] = generated;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
// Replace keeps `keep` of the original, and all of it while the sine is out.
let keep = match s.mode {
Mode::Replace => self.keep + (1. - self.keep) * (1. - self.trust),
Mode::Enhance => 1.,
};
let wet = |l: f32, h: f32| {
if monitor.solo {
generated
} else {
h + self.keep * l + generated
h + keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
@@ -736,6 +765,7 @@ impl Engine {
kick_sum_db: self.kick.sum_db,
kick_offset: self.kick.offset,
audition_sums: self.kick.sums,
neutral: self.neutral,
..AnalyzerFrame::default()
};
let sub = self.level.max(1e-6);
@@ -751,7 +781,7 @@ impl Engine {
f.ghost_harmonics[i] = rel(self.fill[i] * sub);
}
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
if v.amp > 0. && !v.releasing && !self.neutral {
f.hz = v.hz;
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
}
+37
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -8,12 +9,45 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input; the audio only supplies the band above the split.
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,
}
}
}
/// What happens to the original sub.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Mode {
/// The band below the split is replaced by the clean sine (plus Keep of the original).
#[default]
Replace,
/// The original sub stays, and the sine is added under it for weight.
Enhance,
}
#[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,
#[serde(default)]
pub mode: Mode,
/// Peak level of the new sub in dBFS, the same for every note.
pub level: f32,
/// Crossover frequency: below it the original sub is replaced.
@@ -75,6 +109,8 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
mode: Mode::Replace,
level: -9.,
split: 90.,
dynamics: 0.,
@@ -99,6 +135,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.level,
self.split,