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

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:
nvrl committed 2026-11-06 14:09:59 +01:00
1 parent 330ec54150
commit fadea4c471
15 files changed
+798 -39

No files matched your search

+155 -2
View File
@@ -19,7 +19,15 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
}
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
x.iter()
.map(|&s| e.process_sample([s, s], Monitor { solo, phone: false })[0])
.map(|&s| {
e.process_sample(
[s, s],
Monitor {
solo,
..Monitor::default()
},
)[0]
})
.collect()
}
fn rms(x: &[f32]) -> f32 {
@@ -140,7 +148,7 @@ fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
[0.; 2],
Monitor {
solo: true,
phone: false,
..Monitor::default()
},
)[0],
);
@@ -319,6 +327,7 @@ fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() {
Monitor {
solo: true,
phone: true,
audition: false,
},
)[0]
})
@@ -335,3 +344,147 @@ fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() {
db(plain)
);
}
/// A kick-like burst: a 55 Hz sine starting at phase 0 with a 60 ms decay, at each time.
fn kicks(times: &[f32], total: f32) -> Vec<f32> {
let mut k = vec![0.; seconds(total)];
for &t in times {
for i in 0..seconds(0.2) {
let n = seconds(t) + i;
if n < k.len() {
let t = i as f32 / RATE;
k[n] += 0.8 * (std::f32::consts::TAU * 55. * t).sin() * (-t / 0.06).exp();
}
}
}
k
}
fn render_kick(e: &mut Engine, x: &[f32], k: &[f32], monitor: Monitor) -> Vec<f32> {
x.iter()
.zip(k)
.map(|(&s, &k)| e.process_with_kick([s, s], k, monitor)[0])
.collect()
}
/// Normalised correlation of `y[a..b]` with a sine of `hz` at phase 0 at sample `zero`.
fn sine_match(y: &[f32], a: usize, b: usize, hz: f32, zero: f32) -> f32 {
let w = std::f32::consts::TAU * hz / RATE;
let (mut xy, mut xx, mut yy) = (0., 0., 0.);
for (i, &v) in y.iter().enumerate().take(b).skip(a) {
let r = (w * (i as f32 - zero)).sin();
xy += v * r;
xx += r * r;
yy += v * v;
}
xy / (xx * yy).sqrt()
}
const SOLO: Monitor = Monitor {
solo: true,
phone: false,
audition: false,
};
fn kick_lock(offset: f32) -> Settings {
Settings {
kick_lock: true,
kick_offset: offset,
..Settings::default()
}
}
#[test]
fn a_kick_restarts_the_sub_at_the_start_phase_and_ducks_it() {
// A bass note that started off the kick: before the hit, the sub runs at its own phase.
let x = tones(&[(55., 0.5)], 1.4);
let k = kicks(&[0.613], 1.4);
let (mut e, l) = engine(kick_lock(0.));
let y = render_kick(&mut e, &x, &k, SOLO);
let hit = seconds(0.613) + l;
let after = sine_match(
&y,
hit + seconds(0.004),
hit + seconds(0.04),
55.,
hit as f32,
);
assert!(after > 0.99, "{after}");
let before = rms(&y[hit - seconds(0.1)..hit - seconds(0.01)]);
let ducked = rms(&y[hit + seconds(0.004)..hit + seconds(0.022)]);
let back = rms(&y[hit + seconds(0.4)..hit + seconds(0.5)]);
assert!(
db(before) - db(ducked) > 2.,
"{} vs {}",
db(before),
db(ducked)
);
assert!(
(db(back) - db(before)).abs() < 0.3,
"{} vs {}",
db(back),
db(before)
);
assert_eq!(e.take_analyzer().kick_hits, 1);
}
#[test]
fn the_offset_restarts_the_sub_before_or_after_the_kick() {
let x = tones(&[(55., 0.5)], 1.2);
let k = kicks(&[0.613], 1.2);
for offset in [-10., 7.] {
// No duck: only the phase is checked here.
let (mut e, l) = engine(Settings {
kick_duck: 0.,
..kick_lock(offset)
});
let y = render_kick(&mut e, &x, &k, SOLO);
let start = (seconds(0.613) + l) as f32 + offset * 0.001 * RATE;
let a = start as usize + seconds(0.004);
let m = sine_match(&y, a, a + seconds(0.03), 55., start);
assert!(m > 0.99, "{offset}: {m}");
}
}
#[test]
fn without_kick_lock_the_sidechain_changes_nothing() {
let x = tones(&[(55., 0.5)], 1.);
let k = kicks(&[0.3, 0.6], 1.);
let (mut a, _) = engine(Settings::default());
let (mut b, _) = engine(Settings::default());
let with = render_kick(&mut a, &x, &k, SOLO);
let without = render(&mut b, &x, true);
assert_eq!(with, without);
}
#[test]
fn the_audition_finds_where_kick_and_sub_sum() {
use anvil_core::{AUDITION, AUDITION_HITS};
let hits = AUDITION.len() * AUDITION_HITS as usize;
let total = 0.5 + hits as f32 * 0.2 + 0.4;
let x = tones(&[(55., 0.5)], total);
let times: Vec<f32> = (0..hits).map(|i| 0.5 + i as f32 * 0.2).collect();
let k = kicks(&times, total);
let (mut e, _) = engine(kick_lock(0.));
let monitor = Monitor {
audition: true,
..SOLO
};
render_kick(&mut e, &x, &k, monitor);
let f = e.take_analyzer();
assert_eq!(f.kick_hits as usize, hits);
let sums = f.audition_sums;
assert!(sums.iter().all(|v| v.is_finite()), "{sums:?}");
// The kick is a 55 Hz burst at phase 0, like the sub: they sum fully at 0 ms and
// cancel half a period (9 ms) away.
let best = (0..sums.len())
.max_by(|&a, &b| sums[a].total_cmp(&sums[b]))
.unwrap();
assert_eq!(AUDITION[best], 0., "{sums:?}");
let zero = sums[6];
assert!(zero > 2., "{zero}");
for i in [4, 8] {
assert!(
zero - sums[i] > 4.,
"{} ms: {} vs {zero}",
AUDITION[i],
sums[i]
);
}
}
+6 -1
View File
@@ -36,6 +36,8 @@ fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings {
ghost: 80.,
kick_lock: true,
kick_offset: -12.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
@@ -61,11 +63,14 @@ fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
} else {
0.
};
std::hint::black_box(engine.process_sample(
let kick = if i % 12000 < 400 { 0.8 } else { 0. };
std::hint::black_box(engine.process_with_kick(
[x, x],
kick,
Monitor {
solo: i % 3 == 0,
phone: i % 5 == 0,
audition: i > 30000,
},
));
if i % 1600 == 0 {