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

+1
View File
@@ -6,3 +6,4 @@ license.workspace = true
[dependencies]
egui = "0.31.1"
common-dsp = { path = "../dsp" }
+167
View File
@@ -1,4 +1,6 @@
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
use common_dsp::note_name;
use common_dsp::tracker::{Pitch, PitchMode, Scale, NOTE_MAX, NOTE_MIN};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
@@ -359,3 +361,168 @@ pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32
);
});
}
const PITCH_CLASSES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
fn scale_name(scale: Scale) -> &'static str {
match scale {
Scale::Free => "off",
Scale::Minor => "minor",
Scale::Major => "major",
Scale::Root => "root only",
}
}
/// Short status of the pitch source for readout lines: "detecting pitch in F minor".
pub fn pitch_status(mode: PitchMode, pitch: &Pitch) -> String {
match (mode, pitch.scale) {
(PitchMode::Audio, Scale::Free) => "detecting pitch".into(),
(PitchMode::Audio, s) => format!(
"detecting pitch, key {} {}",
PITCH_CLASSES[pitch.note as usize % 12],
scale_name(s)
),
(PitchMode::Midi, _) => "following MIDI".into(),
(PitchMode::Note, _) => format!("fixed on {}", note_name(pitch.note)),
}
}
/// Pitch source switch for a header's right-to-left row: AUDIO, MIDI and NOTE, then a button
/// that opens Note mode's note, or the key lock and Follow bends for detection.
pub fn pitch_from(ui: &mut egui::Ui, mode: &mut PitchMode, pitch: &mut Pitch) {
let id = ui.make_persistent_id("pitch-detail");
let detail = match *mode {
PitchMode::Note => Some(note_name(pitch.note)),
PitchMode::Audio => Some(match pitch.scale {
Scale::Free if pitch.bends => "BENDS".into(),
Scale::Free => "KEY OFF".into(),
s => format!(
"{} {}",
PITCH_CLASSES[pitch.note as usize % 12],
match s {
Scale::Minor => "MIN",
Scale::Major => "MAJ",
_ => "ROOT",
}
),
}),
PitchMode::Midi => None,
};
if let Some(detail) = detail {
let help = if *mode == PitchMode::Note {
"The note this plugin follows. Nothing is detected: set the drop's root."
} else {
"Key lock and Follow bends for detection."
};
let r = ui
.button(RichText::new(detail).size(13.).color(ACCENT))
.on_hover_text(help);
if r.clicked() {
ui.memory_mut(|m| m.toggle_popup(id));
}
let note_mode = *mode == PitchMode::Note;
egui::popup_below_widget(
ui,
id,
&r,
egui::PopupCloseBehavior::CloseOnClickOutside,
|ui| {
ui.set_min_width(220.);
if note_mode {
pitch_note(ui, pitch, true);
} else {
caption(ui, "KEY LOCK");
ui.horizontal(|ui| {
for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] {
let help = match s {
Scale::Free => "Any pitch, also between notes.",
Scale::Minor => "Only notes of the natural minor scale.",
Scale::Major => "Only notes of the major scale.",
Scale::Root => "Only the root: detection picks the octave.",
};
let label = RichText::new(scale_name(s).to_uppercase()).size(12.);
if ui
.selectable_label(pitch.scale == s, label)
.on_hover_text(help)
.clicked()
{
pitch.scale = s;
}
}
});
if pitch.scale != Scale::Free {
ui.add_space(4.);
caption(ui, "ROOT");
pitch_note(ui, pitch, false);
}
ui.add_space(6.);
ui.checkbox(&mut pitch.bends, "Follow bends").on_hover_text(
"Retune on every bend and switch notes within 10 ms. Fast, but it \
flickers on growls. Off holds each note until a new one is clear.",
);
}
},
);
}
for (m, label, help) in [
(
PitchMode::Note,
"NOTE",
"You set the note; the audio only starts and stops it. Nothing is detected.",
),
(
PitchMode::Midi,
"MIDI",
"Notes from MIDI input. In FL, set the channel's MIDI output port and this \
effect's input port to the same number.",
),
(
PitchMode::Audio,
"AUDIO",
"Detect the pitch from the audio. When detection is unsure, tracked processing \
steps back instead of guessing.",
),
] {
let r = ui
.selectable_label(*mode == m, RichText::new(label).size(13.))
.on_hover_text(help);
if r.clicked() {
*mode = m;
}
}
caption(ui, "PITCH FROM");
}
/// Twelve pitch-class buttons, and octave steps when `octave` is set.
fn pitch_note(ui: &mut egui::Ui, pitch: &mut Pitch, octave: bool) {
let class = pitch.note % 12;
let base = pitch.note - class;
for row in [0..6, 6..12] {
ui.horizontal(|ui| {
for c in row {
let r = ui.add_sized(
[30., 22.],
egui::SelectableLabel::new(class == c as u8, PITCH_CLASSES[c]),
);
if r.clicked() {
pitch.note = (base + c as u8).clamp(NOTE_MIN, NOTE_MAX);
}
}
});
}
if octave {
ui.horizontal(|ui| {
caption(ui, "OCTAVE");
if ui.button("−").clicked() && pitch.note >= NOTE_MIN + 12 {
pitch.note -= 12;
}
ui.label(
RichText::new(format!("{}", pitch.note as i32 / 12 - 1))
.monospace()
.color(TEXT),
);
if ui.button("+").clicked() && pitch.note + 12 <= NOTE_MAX {
pitch.note += 12;
}
ui.label(RichText::new(note_name(pitch.note)).color(ACCENT));
});
}
}