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.
182 lines
5.9 KiB
Rust
182 lines
5.9 KiB
Rust
//! 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)]
|
||
pub enum Source {
|
||
/// Pitch and note starts are detected from the incoming bass.
|
||
#[default]
|
||
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.
|
||
pub split: f32,
|
||
/// 0 % holds a constant level per note; 100 % follows the original low band's shape.
|
||
pub dynamics: f32,
|
||
/// Soft saturation of the sine, adding upper harmonics for small speakers.
|
||
pub drive: f32,
|
||
/// Ghost harmonics: adds the sub's harmonics 2–10 above `ghost_from`, so small
|
||
/// speakers hear the note. Only harmonics the patch lacks are filled. 0 is off.
|
||
#[serde(default)]
|
||
pub ghost: f32,
|
||
/// Lowest frequency Ghost harmonics reach down to, in Hz (never below `split`).
|
||
#[serde(default = "default_ghost_from")]
|
||
pub ghost_from: f32,
|
||
pub octave: i32,
|
||
/// Pitch slide time between overlapping notes.
|
||
pub glide: f32,
|
||
/// Start phase in degrees on every note start.
|
||
pub phase: f32,
|
||
/// Detection threshold in dBFS for note starts (Audio source).
|
||
pub gate: f32,
|
||
/// Amount of the original low band that is kept under the new sub.
|
||
pub keep: f32,
|
||
pub output: f32,
|
||
/// Restart the sine at `phase` on every note, so each note and kick hit lines up the same.
|
||
pub retrigger: bool,
|
||
/// Round detected pitch to the nearest semitone.
|
||
pub snap: bool,
|
||
pub bypass: bool,
|
||
/// Kick lock: on each kick in the sidechain, duck the sub and restart its phase.
|
||
#[serde(default)]
|
||
pub kick_lock: bool,
|
||
/// How far the sub dips on each kick, in dB.
|
||
#[serde(default = "default_kick_duck")]
|
||
pub kick_duck: f32,
|
||
/// Time for the sub to come back after a kick, in ms.
|
||
#[serde(default = "default_kick_release")]
|
||
pub kick_release: f32,
|
||
/// When the sub restarts against the kick's start, in ms. Negative: before the kick.
|
||
#[serde(default)]
|
||
pub kick_offset: f32,
|
||
}
|
||
const GHOST_FROM: f32 = 300.;
|
||
const KICK_DUCK: f32 = 6.;
|
||
const KICK_RELEASE: f32 = 80.;
|
||
/// Range of the kick offset, in ms either side of the kick.
|
||
pub const KICK_OFFSET_MAX: f32 = 30.;
|
||
fn default_kick_duck() -> f32 {
|
||
KICK_DUCK
|
||
}
|
||
fn default_kick_release() -> f32 {
|
||
KICK_RELEASE
|
||
}
|
||
fn default_ghost_from() -> f32 {
|
||
GHOST_FROM
|
||
}
|
||
impl Default for Settings {
|
||
fn default() -> Self {
|
||
Self {
|
||
source: Source::Audio,
|
||
pitch: Pitch::default(),
|
||
mode: Mode::Replace,
|
||
level: -9.,
|
||
split: 90.,
|
||
dynamics: 0.,
|
||
drive: 0.,
|
||
ghost: 0.,
|
||
ghost_from: GHOST_FROM,
|
||
octave: 0,
|
||
glide: 0.,
|
||
phase: 0.,
|
||
gate: -42.,
|
||
keep: 0.,
|
||
output: 0.,
|
||
retrigger: true,
|
||
snap: true,
|
||
bypass: false,
|
||
kick_lock: false,
|
||
kick_duck: KICK_DUCK,
|
||
kick_release: KICK_RELEASE,
|
||
kick_offset: 0.,
|
||
}
|
||
}
|
||
}
|
||
impl Settings {
|
||
pub fn validate(&self) -> Result<(), &'static str> {
|
||
self.pitch.validate()?;
|
||
let finite = [
|
||
self.level,
|
||
self.split,
|
||
self.dynamics,
|
||
self.drive,
|
||
self.ghost,
|
||
self.ghost_from,
|
||
self.glide,
|
||
self.phase,
|
||
self.gate,
|
||
self.keep,
|
||
self.output,
|
||
self.kick_duck,
|
||
self.kick_release,
|
||
self.kick_offset,
|
||
]
|
||
.iter()
|
||
.all(|v| v.is_finite());
|
||
if !finite {
|
||
return Err("non-finite setting");
|
||
}
|
||
let ok = (-30.0..=0.).contains(&self.level)
|
||
&& (40.0..=200.).contains(&self.split)
|
||
&& (0.0..=100.).contains(&self.dynamics)
|
||
&& (0.0..=100.).contains(&self.drive)
|
||
&& (0.0..=100.).contains(&self.ghost)
|
||
&& (150.0..=600.).contains(&self.ghost_from)
|
||
&& (-2..=1).contains(&self.octave)
|
||
&& (0.0..=200.).contains(&self.glide)
|
||
&& (0.0..=360.).contains(&self.phase)
|
||
&& (-60.0..=-20.).contains(&self.gate)
|
||
&& (0.0..=100.).contains(&self.keep)
|
||
&& (-12.0..=12.).contains(&self.output)
|
||
&& (0.0..=24.).contains(&self.kick_duck)
|
||
&& (10.0..=300.).contains(&self.kick_release)
|
||
&& (-KICK_OFFSET_MAX..=KICK_OFFSET_MAX).contains(&self.kick_offset);
|
||
if ok {
|
||
Ok(())
|
||
} else {
|
||
Err("setting out of range")
|
||
}
|
||
}
|
||
}
|