feat(anvil): add Kick lock with offset audition
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Anvil gets a sidechain input named Kick. With Kick lock on, kicks are found on the undelayed sidechain, so with the lookahead each hit ducks the sub (Duck, Release) when the delayed kick plays and restarts the sub at Phase, shifted by Offset (-30 to +30 ms, also before the kick). The Audition steps the offset from -30 to +30 ms, 4 kicks each, and measures how kick and sub sum below 150 Hz after each hit; Keep takes the offset heard. On a 55 Hz kick burst against a 55 Hz sub the sum peaks at 0 ms (+2.4 dB) and nearly again one period away (+/-20 ms), so the bars check the choice by ear rather than make it.
This commit is contained in:
1 parent
330ec54150
commit
fadea4c471
15 files changed
+798
-39
No files matched your search
@@ -9,12 +9,18 @@
|
||||
//! reads on speakers without sub. The band above the split is measured per harmonic and
|
||||
//! 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.
|
||||
//!
|
||||
//! 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
|
||||
//! plays each offset of a sweep for a few kicks and measures how kick and sub sum in the
|
||||
//! low band for each one; what sums loudest is not always what sounds like one hit.
|
||||
mod settings;
|
||||
use common_dsp::meter::HarmonicMeter;
|
||||
use common_dsp::svf::{Svf, SvfCoef};
|
||||
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
|
||||
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
|
||||
pub use settings::{Settings, Source};
|
||||
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover};
|
||||
pub use settings::{Settings, Source, KICK_OFFSET_MAX};
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
|
||||
@@ -33,6 +39,23 @@ const GHOST_WINDOW_MS: f32 = 60.;
|
||||
/// Harmonics fade in over this many octaves above the Ghost edge, so a gliding note
|
||||
/// does not switch them on with a step.
|
||||
const GHOST_RAMP_OCT: f32 = 0.5;
|
||||
/// Offsets the audition steps through, in ms.
|
||||
pub const AUDITION: [f32; 13] = [
|
||||
-30., -25., -20., -15., -10., -5., 0., 5., 10., 15., 20., 25., 30.,
|
||||
];
|
||||
/// Kicks each audition offset plays for.
|
||||
pub const AUDITION_HITS: u32 = 4;
|
||||
/// A kick is a rise of the fast sidechain envelope this far above the slow one...
|
||||
const KICK_RISE: f32 = 2.8;
|
||||
/// ...above this level (−45 dBFS)...
|
||||
const KICK_FLOOR: f32 = 0.0056;
|
||||
/// ...at least this long after the previous kick.
|
||||
const KICK_HOLD_MS: f32 = 60.;
|
||||
/// Kick and sub are compared below this frequency, over this long after each hit.
|
||||
const KICK_LOW_HZ: f32 = 150.;
|
||||
const SUM_MS: f32 = 60.;
|
||||
const DUCK_ATTACK_MS: f32 = 1.;
|
||||
const PENDING: usize = 16;
|
||||
/// Small speaker listen: a phone-like band, summed to mono.
|
||||
pub const PHONE_LOW: f32 = 400.;
|
||||
pub const PHONE_HIGH: f32 = 7000.;
|
||||
@@ -43,6 +66,8 @@ pub struct Monitor {
|
||||
pub solo: bool,
|
||||
/// Hear the output through a phone-like speaker band, in mono.
|
||||
pub phone: bool,
|
||||
/// Step the kick offset through `AUDITION`, `AUDITION_HITS` kicks each.
|
||||
pub audition: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -59,6 +84,15 @@ pub struct AnalyzerFrame {
|
||||
pub ghost_harmonics: [f32; GHOST_COUNT],
|
||||
/// Lowest frequency Ghost fills, after the split.
|
||||
pub ghost_edge: f32,
|
||||
/// Kicks found in the sidechain since the plugin started.
|
||||
pub kick_hits: u32,
|
||||
/// Kick plus sub against kick and sub apart, low band, dB, after the last hit. +3 is a
|
||||
/// full sum of equal levels, 0 unrelated, below 0 they cancel. NaN before a hit.
|
||||
pub kick_sum_db: f32,
|
||||
/// Offset the sub restarted with on the latest kick, in ms.
|
||||
pub kick_offset: f32,
|
||||
/// Measured sum per audition offset, dB; NaN where not measured yet.
|
||||
pub audition_sums: [f32; AUDITION.len()],
|
||||
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
|
||||
pub scope_low: [f32; SCOPE],
|
||||
pub scope_sub: [f32; SCOPE],
|
||||
@@ -77,6 +111,10 @@ impl Default for AnalyzerFrame {
|
||||
patch_harmonics: [-120.; GHOST_COUNT],
|
||||
ghost_harmonics: [-120.; GHOST_COUNT],
|
||||
ghost_edge: 300.,
|
||||
kick_hits: 0,
|
||||
kick_sum_db: f32::NAN,
|
||||
kick_offset: 0.,
|
||||
audition_sums: [f32::NAN; AUDITION.len()],
|
||||
scope_low: [0.; SCOPE],
|
||||
scope_sub: [0.; SCOPE],
|
||||
scope_ms: 60.,
|
||||
@@ -145,6 +183,111 @@ impl Synth {
|
||||
self.note_peak = 0.;
|
||||
self.fresh = true;
|
||||
}
|
||||
/// Restarts a sounding sub at the start phase, keeping its pitch.
|
||||
fn kick(&mut self, s: &Settings) {
|
||||
let v = self.voice;
|
||||
if v.amp <= 1e-4 || v.releasing {
|
||||
return;
|
||||
}
|
||||
self.old = v;
|
||||
self.old_gain = 1.;
|
||||
self.voice = Voice {
|
||||
phase: s.phase / 360.,
|
||||
amp: 0.,
|
||||
..v
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Pending {
|
||||
/// Output-time sample.
|
||||
at: u64,
|
||||
/// Restart the sub (true) or duck it and start measuring (false).
|
||||
restart: bool,
|
||||
offset: f32,
|
||||
/// Audition offset index this hit belongs to.
|
||||
slot: Option<usize>,
|
||||
}
|
||||
|
||||
/// Kick detection, scheduling, ducking and the sum measurement.
|
||||
struct Kick {
|
||||
clock: u64,
|
||||
fast: f32,
|
||||
slow: f32,
|
||||
fast_coef: f32,
|
||||
slow_coef: f32,
|
||||
hold: usize,
|
||||
pending: [Option<Pending>; PENDING],
|
||||
delay: Vec<f32>,
|
||||
delay_at: usize,
|
||||
low: Biquad,
|
||||
duck: f32,
|
||||
rising: bool,
|
||||
attack: f32,
|
||||
measure: usize,
|
||||
measure_slot: Option<usize>,
|
||||
energy: [f32; 3],
|
||||
hits: u32,
|
||||
sum_db: f32,
|
||||
offset: f32,
|
||||
auditioning: bool,
|
||||
slot: usize,
|
||||
slot_hits: u32,
|
||||
sums: [f32; AUDITION.len()],
|
||||
counts: [u32; AUDITION.len()],
|
||||
}
|
||||
impl Kick {
|
||||
fn new(rate: f32, latency: usize) -> Self {
|
||||
Self {
|
||||
clock: 0,
|
||||
fast: 0.,
|
||||
slow: 0.,
|
||||
fast_coef: coefficient(rate, 0.5),
|
||||
slow_coef: coefficient(rate, 30.),
|
||||
hold: 0,
|
||||
pending: [None; PENDING],
|
||||
delay: vec![0.; latency + 1],
|
||||
delay_at: 0,
|
||||
low: Biquad::lowpass(rate, KICK_LOW_HZ, std::f32::consts::FRAC_1_SQRT_2),
|
||||
duck: 0.,
|
||||
rising: false,
|
||||
attack: coefficient(rate, DUCK_ATTACK_MS / 3.),
|
||||
measure: 0,
|
||||
measure_slot: None,
|
||||
energy: [0.; 3],
|
||||
hits: 0,
|
||||
sum_db: f32::NAN,
|
||||
offset: 0.,
|
||||
auditioning: false,
|
||||
slot: 0,
|
||||
slot_hits: 0,
|
||||
sums: [f32::NAN; AUDITION.len()],
|
||||
counts: [0; AUDITION.len()],
|
||||
}
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.fast = 0.;
|
||||
self.slow = 0.;
|
||||
self.hold = 0;
|
||||
self.pending = [None; PENDING];
|
||||
self.delay.fill(0.);
|
||||
self.low.reset();
|
||||
self.duck = 0.;
|
||||
self.rising = false;
|
||||
self.measure = 0;
|
||||
self.measure_slot = None;
|
||||
self.sum_db = f32::NAN;
|
||||
self.slot = 0;
|
||||
self.slot_hits = 0;
|
||||
self.sums = [f32::NAN; AUDITION.len()];
|
||||
self.counts = [0; AUDITION.len()];
|
||||
}
|
||||
fn schedule(&mut self, p: Pending) {
|
||||
if let Some(free) = self.pending.iter_mut().find(|p| p.is_none()) {
|
||||
*free = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
@@ -180,6 +323,7 @@ pub struct Engine {
|
||||
fill: [f32; GHOST_COUNT],
|
||||
phone_coefs: [SvfCoef; 3],
|
||||
phone: [Svf; 3],
|
||||
kick: Kick,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
@@ -229,6 +373,7 @@ impl Engine {
|
||||
SvfCoef::lowpass(rate, PHONE_HIGH.min(0.45 * rate), 0.707),
|
||||
],
|
||||
phone: [Svf::default(); 3],
|
||||
kick: Kick::new(rate, latency),
|
||||
};
|
||||
e.set_settings(settings)?;
|
||||
Ok(e)
|
||||
@@ -282,6 +427,7 @@ impl Engine {
|
||||
for f in &mut self.phone {
|
||||
f.reset();
|
||||
}
|
||||
self.kick.reset();
|
||||
}
|
||||
/// Lowest frequency Ghost fills.
|
||||
fn ghost_edge(&self) -> f32 {
|
||||
@@ -364,7 +510,127 @@ impl Engine {
|
||||
}
|
||||
(self.shape(out), ghost)
|
||||
}
|
||||
/// One sample without a sidechain.
|
||||
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
|
||||
self.process_with_kick(x, 0., monitor)
|
||||
}
|
||||
/// Finds kicks in the undelayed sidechain and schedules their duck and restart.
|
||||
fn detect_kick(&mut self, side: f32, audition: bool) {
|
||||
let rate = self.rate;
|
||||
let s = self.settings;
|
||||
let k = &mut self.kick;
|
||||
if audition != k.auditioning {
|
||||
k.auditioning = audition;
|
||||
k.slot = 0;
|
||||
k.slot_hits = 0;
|
||||
k.sums = [f32::NAN; AUDITION.len()];
|
||||
k.counts = [0; AUDITION.len()];
|
||||
}
|
||||
let level = side.abs();
|
||||
k.fast += (level - k.fast) * k.fast_coef;
|
||||
k.slow += (level - k.slow) * k.slow_coef;
|
||||
k.hold = k.hold.saturating_sub(1);
|
||||
if !s.kick_lock || k.hold > 0 || k.fast < KICK_FLOOR || k.fast < KICK_RISE * k.slow {
|
||||
return;
|
||||
}
|
||||
k.hold = (rate * KICK_HOLD_MS * 0.001) as usize;
|
||||
let (offset, slot) = if audition {
|
||||
let slot = k.slot;
|
||||
k.slot_hits += 1;
|
||||
if k.slot_hits >= AUDITION_HITS {
|
||||
k.slot_hits = 0;
|
||||
k.slot = (k.slot + 1) % AUDITION.len();
|
||||
}
|
||||
(AUDITION[slot], Some(slot))
|
||||
} else {
|
||||
(s.kick_offset, None)
|
||||
};
|
||||
// The delayed kick leaves the plugin `latency` samples from now.
|
||||
let at = k.clock + self.latency as u64;
|
||||
let shift = (offset * 0.001 * rate).round() as i64;
|
||||
let duck = Pending {
|
||||
at,
|
||||
restart: false,
|
||||
offset,
|
||||
slot,
|
||||
};
|
||||
k.schedule(duck);
|
||||
k.schedule(Pending {
|
||||
at: (at as i64 + shift).max(0) as u64,
|
||||
restart: true,
|
||||
..duck
|
||||
});
|
||||
}
|
||||
/// Runs the kick events due at this output sample. Returns the duck gain for the sub.
|
||||
fn kick_events(&mut self) -> f32 {
|
||||
let s = self.settings;
|
||||
let clock = self.kick.clock;
|
||||
for i in 0..PENDING {
|
||||
let Some(p) = self.kick.pending[i] else {
|
||||
continue;
|
||||
};
|
||||
if p.at > clock {
|
||||
continue;
|
||||
}
|
||||
self.kick.pending[i] = None;
|
||||
if p.restart {
|
||||
self.synth.kick(&s);
|
||||
self.kick.offset = p.offset;
|
||||
} else {
|
||||
let k = &mut self.kick;
|
||||
k.hits += 1;
|
||||
k.rising = true;
|
||||
k.measure = (self.rate * SUM_MS * 0.001) as usize;
|
||||
k.measure_slot = p.slot;
|
||||
k.energy = [0.; 3];
|
||||
}
|
||||
}
|
||||
let k = &mut self.kick;
|
||||
if k.rising {
|
||||
k.duck += (1. - k.duck) * k.attack;
|
||||
if k.duck > 0.98 {
|
||||
k.rising = false;
|
||||
}
|
||||
} else {
|
||||
k.duck -= k.duck * coefficient(self.rate, s.kick_release / 3.);
|
||||
}
|
||||
if !s.kick_lock {
|
||||
k.duck = 0.;
|
||||
}
|
||||
1. - (1. - db_to_gain(-s.kick_duck)) * k.duck
|
||||
}
|
||||
/// Adds one sample of the delayed kick's and the sub's low band to the sum measurement.
|
||||
fn measure_kick(&mut self, kick: f32, sub: f32) {
|
||||
let k = &mut self.kick;
|
||||
if k.measure == 0 {
|
||||
return;
|
||||
}
|
||||
k.energy[0] += (kick + sub) * (kick + sub);
|
||||
k.energy[1] += kick * kick;
|
||||
k.energy[2] += sub * sub;
|
||||
k.measure -= 1;
|
||||
if k.measure > 0 {
|
||||
return;
|
||||
}
|
||||
let apart = k.energy[1] + k.energy[2];
|
||||
if apart < 1e-9 {
|
||||
return;
|
||||
}
|
||||
k.sum_db = 10. * (k.energy[0] / apart).max(1e-6).log10();
|
||||
if let Some(slot) = k.measure_slot {
|
||||
let n = k.counts[slot] as f32;
|
||||
let old = if n > 0. { k.sums[slot] } else { 0. };
|
||||
k.sums[slot] = (old * n + k.sum_db) / (n + 1.);
|
||||
k.counts[slot] += 1;
|
||||
}
|
||||
}
|
||||
/// One sample with the kick's sidechain (mono) for Kick lock.
|
||||
pub fn process_with_kick(&mut self, x: [f32; 2], kick: f32, monitor: Monitor) -> [f32; 2] {
|
||||
self.detect_kick(kick, monitor.audition);
|
||||
let len = self.kick.delay.len();
|
||||
self.kick.delay[self.kick.delay_at] = kick;
|
||||
self.kick.delay_at = (self.kick.delay_at + 1) % len;
|
||||
let kick_late = self.kick.delay[self.kick.delay_at];
|
||||
let mono = if self.channels == 2 {
|
||||
0.5 * (x[0] + x[1])
|
||||
} else {
|
||||
@@ -414,10 +680,16 @@ impl Engine {
|
||||
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
|
||||
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
|
||||
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
|
||||
let duck = self.kick_events();
|
||||
self.update_fill();
|
||||
let (sub, ghost) = self.oscillator();
|
||||
let sub = sub * self.level * shape;
|
||||
let generated = sub + ghost * self.level * shape;
|
||||
let sub = sub * self.level * shape * duck;
|
||||
let generated = sub + ghost * self.level * shape * duck;
|
||||
if self.settings.kick_lock {
|
||||
let kick_low = self.kick.low.process(kick_late);
|
||||
self.measure_kick(kick_low, sub);
|
||||
}
|
||||
self.kick.clock += 1;
|
||||
self.low_power += (low * low - self.low_power) * self.meter_coef;
|
||||
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
|
||||
self.scope_count += 1;
|
||||
@@ -460,6 +732,10 @@ impl Engine {
|
||||
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
|
||||
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
|
||||
ghost_edge: self.ghost_edge(),
|
||||
kick_hits: self.kick.hits,
|
||||
kick_sum_db: self.kick.sum_db,
|
||||
kick_offset: self.kick.offset,
|
||||
audition_sums: self.kick.sums,
|
||||
..AnalyzerFrame::default()
|
||||
};
|
||||
let sub = self.level.max(1e-6);
|
||||
|
||||
@@ -44,8 +44,30 @@ pub struct Settings {
|
||||
/// 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
|
||||
}
|
||||
@@ -68,6 +90,10 @@ impl Default for Settings {
|
||||
retrigger: true,
|
||||
snap: true,
|
||||
bypass: false,
|
||||
kick_lock: false,
|
||||
kick_duck: KICK_DUCK,
|
||||
kick_release: KICK_RELEASE,
|
||||
kick_offset: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +111,9 @@ impl Settings {
|
||||
self.gate,
|
||||
self.keep,
|
||||
self.output,
|
||||
self.kick_duck,
|
||||
self.kick_release,
|
||||
self.kick_offset,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite());
|
||||
@@ -102,7 +131,10 @@ impl Settings {
|
||||
&& (0.0..=360.).contains(&self.phase)
|
||||
&& (-60.0..=-20.).contains(&self.gate)
|
||||
&& (0.0..=100.).contains(&self.keep)
|
||||
&& (-12.0..=12.).contains(&self.output);
|
||||
&& (-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 {
|
||||
|
||||
Reference in new issue
Block a user