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.
491 lines
15 KiB
Rust
491 lines
15 KiB
Rust
use anvil_core::{Engine, Monitor, Settings, Source};
|
||
use common_dsp::Saw;
|
||
|
||
const RATE: f32 = 48000.;
|
||
|
||
/// Saw notes (start s, end s, Hz, peak) with 1 ms fades.
|
||
fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
|
||
let mut x = vec![0.; (total * RATE) as usize];
|
||
for &(a, b, hz, amp) in list {
|
||
let (a, b) = ((a * RATE) as usize, (b * RATE) as usize);
|
||
let mut saw = Saw::new(RATE, hz);
|
||
let fade = (0.001 * RATE) as usize;
|
||
for i in a..b.min(x.len()) {
|
||
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
|
||
x[i] += saw.sample() * amp * edge;
|
||
}
|
||
}
|
||
x
|
||
}
|
||
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
|
||
x.iter()
|
||
.map(|&s| {
|
||
e.process_sample(
|
||
[s, s],
|
||
Monitor {
|
||
solo,
|
||
..Monitor::default()
|
||
},
|
||
)[0]
|
||
})
|
||
.collect()
|
||
}
|
||
fn rms(x: &[f32]) -> f32 {
|
||
(x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt()
|
||
}
|
||
fn db(x: f32) -> f32 {
|
||
20. * x.max(1e-9).log10()
|
||
}
|
||
/// Frequency from interpolated rising zero crossings.
|
||
fn frequency(x: &[f32]) -> f32 {
|
||
let mut first = None;
|
||
let mut last = 0.;
|
||
let mut count = 0;
|
||
for i in 1..x.len() {
|
||
if x[i - 1] < 0. && x[i] >= 0. {
|
||
let t = (i - 1) as f32 + x[i - 1] / (x[i - 1] - x[i]);
|
||
if first.is_none() {
|
||
first = Some(t);
|
||
} else {
|
||
count += 1;
|
||
}
|
||
last = t;
|
||
}
|
||
}
|
||
count as f32 * RATE / (last - first.unwrap())
|
||
}
|
||
fn seconds(t: f32) -> usize {
|
||
(t * RATE) as usize
|
||
}
|
||
fn engine(s: Settings) -> (Engine, usize) {
|
||
let mut e = Engine::new(RATE, 2).unwrap();
|
||
e.set_settings(s).unwrap();
|
||
let l = e.latency_samples() as usize;
|
||
(e, l)
|
||
}
|
||
|
||
#[test]
|
||
fn every_note_gets_the_same_sub_level() {
|
||
let (mut e, l) = engine(Settings::default());
|
||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.8, 1.3, 55., 0.1)], 1.6);
|
||
let y = render(&mut e, &x, true);
|
||
let loud = rms(&y[seconds(0.3) + l..seconds(0.5) + l]);
|
||
let quiet = rms(&y[seconds(1.0) + l..seconds(1.2) + l]);
|
||
assert!(
|
||
(db(loud) - db(quiet)).abs() < 0.5,
|
||
"{} vs {}",
|
||
db(loud),
|
||
db(quiet)
|
||
);
|
||
// −9 dBFS peak sine.
|
||
assert!((db(loud * 2f32.sqrt()) + 9.).abs() < 0.5, "{}", db(loud));
|
||
}
|
||
|
||
#[test]
|
||
fn sub_follows_the_detected_pitch_and_octave() {
|
||
for (octave, expect) in [(0, 43.654), (-1, 21.827)] {
|
||
let (mut e, l) = engine(Settings {
|
||
octave,
|
||
..Settings::default()
|
||
});
|
||
let x = notes(&[(0.1, 1.1, 43.65, 0.5)], 1.3);
|
||
let y = render(&mut e, &x, true);
|
||
let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]);
|
||
assert!((f / expect - 1.).abs() < 0.005, "octave {octave}: {f}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_pitch_change_without_a_gap_is_a_new_note() {
|
||
let (mut e, l) = engine(Settings::default());
|
||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.6, 1.1, 82.41, 0.5)], 1.3);
|
||
let y = render(&mut e, &x, true);
|
||
let f = frequency(&y[seconds(0.75) + l..seconds(1.05) + l]);
|
||
assert!((f / 82.41 - 1.).abs() < 0.005, "{f}");
|
||
}
|
||
|
||
#[test]
|
||
fn every_note_restarts_at_the_start_phase() {
|
||
let (mut e, l) = engine(Settings {
|
||
phase: 90.,
|
||
..Settings::default()
|
||
});
|
||
let x = notes(&[(0.1, 0.4, 55., 0.5), (0.6, 0.9, 55., 0.3)], 1.1);
|
||
let y = render(&mut e, &x, true);
|
||
let period = (RATE / 55.) as usize;
|
||
let a = &y[seconds(0.1) + l..seconds(0.1) + l + 2 * period];
|
||
let b = &y[seconds(0.6) + l..seconds(0.6) + l + 2 * period];
|
||
let peak = y.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||
let worst = a.iter().zip(b).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
|
||
assert!(worst < 0.1 * peak, "{worst} of {peak}");
|
||
// Fit the phase over the second period, after the 2 ms attack.
|
||
let w = std::f32::consts::TAU * 55. / RATE;
|
||
let (mut s, mut c) = (0., 0.);
|
||
for (k, v) in a.iter().enumerate().skip(period) {
|
||
s += v * (w * k as f32).sin();
|
||
c += v * (w * k as f32).cos();
|
||
}
|
||
let phase = c.atan2(s).to_degrees();
|
||
assert!((phase - 90.).abs() < 20., "{phase}");
|
||
}
|
||
|
||
#[test]
|
||
fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
|
||
let (mut e, l) = engine(Settings {
|
||
source: Source::Midi,
|
||
..Settings::default()
|
||
});
|
||
let mut y = Vec::new();
|
||
for i in 0..seconds(0.8) {
|
||
if i == 1000 {
|
||
e.note_on(33);
|
||
}
|
||
if i == seconds(0.6) {
|
||
e.note_off(33);
|
||
}
|
||
y.push(
|
||
e.process_sample(
|
||
[0.; 2],
|
||
Monitor {
|
||
solo: true,
|
||
..Monitor::default()
|
||
},
|
||
)[0],
|
||
);
|
||
}
|
||
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
|
||
assert!(y[1000 + l + 50].abs() > 0.);
|
||
let f = frequency(&y[seconds(0.2) + l..seconds(0.5)]);
|
||
assert!((f / 55. - 1.).abs() < 0.001, "{f}");
|
||
assert!(y[seconds(0.6) + l + seconds(0.05)..]
|
||
.iter()
|
||
.all(|v| v.abs() < 1e-6));
|
||
}
|
||
|
||
#[test]
|
||
fn note_changes_do_not_click() {
|
||
let (mut e, _) = engine(Settings::default());
|
||
let x = notes(
|
||
&[
|
||
(0.1, 0.4, 55., 0.5),
|
||
(0.4, 0.7, 73.42, 0.5),
|
||
(0.7, 1.0, 41.2, 0.5),
|
||
],
|
||
1.3,
|
||
);
|
||
let y = render(&mut e, &x, true);
|
||
let step = y.windows(2).fold(0f32, |m, w| m.max((w[1] - w[0]).abs()));
|
||
// A −9 dBFS sine at 73 Hz moves at most 0.0034 per sample.
|
||
assert!(step < 0.012, "{step}");
|
||
}
|
||
|
||
#[test]
|
||
fn keep_without_notes_is_an_allpass() {
|
||
let (mut e, _) = engine(Settings {
|
||
source: Source::Midi,
|
||
keep: 100.,
|
||
..Settings::default()
|
||
});
|
||
for hz in [40., 90., 300., 3000.] {
|
||
e.reset();
|
||
let x: Vec<f32> = (0..seconds(1.))
|
||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / RATE).sin())
|
||
.collect();
|
||
let y = render(&mut e, &x, false);
|
||
let d = db(rms(&y[seconds(0.5)..])) - db(rms(&x[seconds(0.5)..]));
|
||
assert!(d.abs() < 0.1, "{hz}: {d}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn split_removes_original_sub_and_keeps_mids() {
|
||
let (mut e, l) = engine(Settings {
|
||
source: Source::Midi,
|
||
..Settings::default()
|
||
});
|
||
let x: Vec<f32> = (0..seconds(1.))
|
||
.map(|i| 0.3 * (std::f32::consts::TAU * 40. * i as f32 / RATE).sin())
|
||
.collect();
|
||
let y = render(&mut e, &x, false);
|
||
assert!(db(rms(&y[seconds(0.5) + l..])) < db(0.3) - 18.);
|
||
}
|
||
|
||
#[test]
|
||
fn settings_are_validated_and_serialise() {
|
||
let mut e = Engine::new(RATE, 1).unwrap();
|
||
assert!(e
|
||
.set_settings(Settings {
|
||
level: 3.,
|
||
..Settings::default()
|
||
})
|
||
.is_err());
|
||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||
assert_eq!(
|
||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||
Settings::default()
|
||
);
|
||
assert!(Engine::new(1000., 2).is_err());
|
||
assert!(Engine::new(48000., 3).is_err());
|
||
}
|
||
|
||
/// Peak amplitude of the component at `hz`, over a window of whole cycles.
|
||
fn amplitude(x: &[f32], hz: f32) -> f32 {
|
||
let w = std::f32::consts::TAU * hz / RATE;
|
||
let (re, im) = x.iter().enumerate().fold((0., 0.), |(re, im), (n, v)| {
|
||
(re + v * (w * n as f32).cos(), im - v * (w * n as f32).sin())
|
||
});
|
||
2. * (re * re + im * im).sqrt() / x.len() as f32
|
||
}
|
||
/// Sines (Hz, peak) from 0.1 s to `total` − 0.1 s, with 1 ms fades.
|
||
fn tones(list: &[(f32, f32)], total: f32) -> Vec<f32> {
|
||
let n = (total * RATE) as usize;
|
||
let (a, b, fade) = (seconds(0.1), n - seconds(0.1), seconds(0.001));
|
||
(0..n)
|
||
.map(|i| {
|
||
if i < a || i >= b {
|
||
return 0.;
|
||
}
|
||
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
|
||
let t = (i - a) as f32 / RATE;
|
||
edge * list
|
||
.iter()
|
||
.map(|&(hz, amp)| amp * (std::f32::consts::TAU * hz * t).sin())
|
||
.sum::<f32>()
|
||
})
|
||
.collect()
|
||
}
|
||
fn ghost(amount: f32) -> Settings {
|
||
Settings {
|
||
ghost: amount,
|
||
ghost_from: 300.,
|
||
..Settings::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ghost_fills_the_harmonics_a_clean_sub_lacks() {
|
||
let (mut e, l) = engine(ghost(100.));
|
||
let x = tones(&[(55., 0.5)], 1.);
|
||
let y = render(&mut e, &x, true);
|
||
// 0.2 s holds exactly 11 cycles of 55 Hz.
|
||
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
|
||
let sub = amplitude(w, 55.);
|
||
assert!((db(sub) + 9.).abs() < 0.5, "{}", db(sub));
|
||
for k in 2..=5 {
|
||
// Below Ghost from: nothing added, so the sub stays a clean sine there.
|
||
let a = amplitude(w, 55. * k as f32);
|
||
assert!(db(a / sub) < -60., "harmonic {k}: {}", db(a / sub));
|
||
}
|
||
for k in 8..=10 {
|
||
let a = amplitude(w, 55. * k as f32);
|
||
let expect = 20. * (2. / k as f32).log10();
|
||
assert!(
|
||
(db(a / sub) - expect).abs() < 1.,
|
||
"harmonic {k}: {}",
|
||
db(a / sub)
|
||
);
|
||
}
|
||
// Harmonic 6 (330 Hz) sits in the fade above 300 Hz: present, but quieter.
|
||
let six = db(amplitude(w, 330.) / sub);
|
||
assert!(six < 20. * (2f32 / 6.).log10() - 3. && six > -40., "{six}");
|
||
}
|
||
|
||
#[test]
|
||
fn ghost_leaves_harmonics_the_patch_already_has() {
|
||
let (mut e, l) = engine(ghost(100.));
|
||
// The patch carries its 8th harmonic (440 Hz) louder than Ghost would add it.
|
||
let x = tones(&[(55., 0.5), (440., 0.2)], 1.);
|
||
let y = render(&mut e, &x, false);
|
||
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
|
||
let eighth = amplitude(w, 440.);
|
||
assert!((db(eighth) - db(0.2)).abs() < 0.5, "{}", db(eighth));
|
||
// The 9th is missing from the patch, so Ghost fills it.
|
||
let ninth = amplitude(w, 495.) / amplitude(w, 55.);
|
||
assert!(
|
||
(db(ninth) - 20. * (2f32 / 9.).log10()).abs() < 1.,
|
||
"{}",
|
||
db(ninth)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() {
|
||
let x = tones(&[(55., 0.5)], 1.);
|
||
let (mut e, l) = engine(ghost(0.));
|
||
let y = render(&mut e, &x, true);
|
||
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
|
||
for k in 2..=10 {
|
||
assert!(amplitude(w, 55. * k as f32) < 1e-4, "harmonic {k}");
|
||
}
|
||
let phone = |amount: f32| {
|
||
let (mut e, l) = engine(ghost(amount));
|
||
let y: Vec<f32> = x
|
||
.iter()
|
||
.map(|&s| {
|
||
e.process_sample(
|
||
[s, s],
|
||
Monitor {
|
||
solo: true,
|
||
phone: true,
|
||
audition: false,
|
||
},
|
||
)[0]
|
||
})
|
||
.collect();
|
||
rms(&y[seconds(0.5) + l..seconds(0.7) + l])
|
||
};
|
||
// A clean 55 Hz sub all but vanishes on a phone; Ghost makes the note heard.
|
||
let (plain, ghosted) = (phone(0.), phone(100.));
|
||
assert!(db(plain) < -50., "{}", db(plain));
|
||
assert!(
|
||
db(ghosted) - db(plain) > 25.,
|
||
"{} vs {}",
|
||
db(ghosted),
|
||
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(×, 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]
|
||
);
|
||
}
|
||
}
|