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 {
|
||||
|
||||
@@ -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(×, 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,10 +14,11 @@ struct Shared {
|
||||
analyzer: ArrayQueue<AnalyzerFrame>,
|
||||
solo: AtomicBool,
|
||||
phone: AtomicBool,
|
||||
audition: AtomicBool,
|
||||
}
|
||||
struct EditorData {
|
||||
ui: anvil_ui::State,
|
||||
gestures: [bool; 11],
|
||||
gestures: [bool; 14],
|
||||
}
|
||||
pub struct Anvil {
|
||||
params: Arc<AnvilParams>,
|
||||
@@ -34,6 +35,7 @@ impl Default for Anvil {
|
||||
analyzer: ArrayQueue::new(2),
|
||||
solo: AtomicBool::new(false),
|
||||
phone: AtomicBool::new(false),
|
||||
audition: AtomicBool::new(false),
|
||||
}),
|
||||
engine: None,
|
||||
publish_count: 0,
|
||||
@@ -51,11 +53,21 @@ impl Plugin for Anvil {
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
aux_input_ports: &[new_nonzero_u32(2)],
|
||||
names: PortNames {
|
||||
aux_inputs: &["Kick"],
|
||||
..PortNames::const_default()
|
||||
},
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
aux_input_ports: &[new_nonzero_u32(1)],
|
||||
names: PortNames {
|
||||
aux_inputs: &["Kick"],
|
||||
..PortNames::const_default()
|
||||
},
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
@@ -73,11 +85,12 @@ impl Plugin for Anvil {
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: anvil_ui::State::default(),
|
||||
gestures: [false; 11],
|
||||
gestures: [false; 14],
|
||||
},
|
||||
|ctx, data| {
|
||||
data.ui.solo = false;
|
||||
data.ui.phone = false;
|
||||
data.ui.audition = false;
|
||||
anvil_ui::style(ctx);
|
||||
},
|
||||
move |ctx, setter, data| {
|
||||
@@ -105,6 +118,7 @@ impl Plugin for Anvil {
|
||||
// after the editor closes.
|
||||
shared.solo.store(data.ui.solo, Ordering::Release);
|
||||
shared.phone.store(data.ui.phone, Ordering::Release);
|
||||
shared.audition.store(data.ui.audition, Ordering::Release);
|
||||
if data.ui.analyzer.note.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -131,6 +145,7 @@ impl Plugin for Anvil {
|
||||
self.engine = Some(engine);
|
||||
self.shared.solo.store(false, Ordering::Release);
|
||||
self.shared.phone.store(false, Ordering::Release);
|
||||
self.shared.audition.store(false, Ordering::Release);
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
@@ -142,7 +157,7 @@ impl Plugin for Anvil {
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
aux: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(engine) = self.engine.as_mut() else {
|
||||
@@ -153,7 +168,11 @@ impl Plugin for Anvil {
|
||||
let monitor = Monitor {
|
||||
solo: self.shared.solo.load(Ordering::Acquire) && open,
|
||||
phone: self.shared.phone.load(Ordering::Acquire) && open,
|
||||
audition: self.shared.audition.load(Ordering::Acquire) && open,
|
||||
};
|
||||
// A host that does not connect the sidechain still passes a buffer; treat a
|
||||
// missing one as silence.
|
||||
let side = aux.inputs.first().map(|b| b.as_slice_immutable());
|
||||
let mut next = context.next_event();
|
||||
for (index, mut sample) in buffer.iter_samples().enumerate() {
|
||||
while let Some(event) = next {
|
||||
@@ -180,7 +199,15 @@ impl Plugin for Anvil {
|
||||
0.
|
||||
},
|
||||
];
|
||||
let out = engine.process_sample(x, monitor);
|
||||
let kick = side.map_or(0., |side| {
|
||||
let n = side.len().min(2);
|
||||
side[..n]
|
||||
.iter()
|
||||
.map(|c| c.get(index).copied().unwrap_or(0.))
|
||||
.sum::<f32>()
|
||||
/ n.max(1) as f32
|
||||
});
|
||||
let out = engine.process_with_kick(x, kick, monitor);
|
||||
for (c, value) in sample.into_iter().enumerate() {
|
||||
*value = out[c];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anvil_core::{Settings, Source};
|
||||
use anvil_core::{Settings, Source, KICK_OFFSET_MAX};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::Arc;
|
||||
@@ -50,6 +50,14 @@ pub struct AnvilParams {
|
||||
pub snap: BoolParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
#[id = "kick_lock"]
|
||||
pub kick_lock: BoolParam,
|
||||
#[id = "kick_duck"]
|
||||
pub kick_duck: FloatParam,
|
||||
#[id = "kick_release"]
|
||||
pub kick_release: FloatParam,
|
||||
#[id = "kick_offset"]
|
||||
pub kick_offset: FloatParam,
|
||||
}
|
||||
impl Default for AnvilParams {
|
||||
fn default() -> Self {
|
||||
@@ -90,6 +98,16 @@ impl Default for AnvilParams {
|
||||
retrigger: BoolParam::new("Retrigger", d.retrigger),
|
||||
snap: BoolParam::new("Snap to semitone", d.snap),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
kick_lock: BoolParam::new("Kick lock", d.kick_lock),
|
||||
kick_duck: linear("Kick duck", d.kick_duck, 0., 24., " dB"),
|
||||
kick_release: linear("Kick release", d.kick_release, 10., 300., " ms"),
|
||||
kick_offset: linear(
|
||||
"Kick offset",
|
||||
d.kick_offset,
|
||||
-KICK_OFFSET_MAX,
|
||||
KICK_OFFSET_MAX,
|
||||
" ms",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +133,10 @@ impl AnvilParams {
|
||||
retrigger: self.retrigger.value(),
|
||||
snap: self.snap.value(),
|
||||
bypass: self.bypass.value(),
|
||||
kick_lock: self.kick_lock.value(),
|
||||
kick_duck: self.kick_duck.value(),
|
||||
kick_release: self.kick_release.value(),
|
||||
kick_offset: self.kick_offset.value(),
|
||||
}
|
||||
}
|
||||
/// Pushes every changed editor value to the host. Continuous controls hold one
|
||||
@@ -125,9 +147,9 @@ impl AnvilParams {
|
||||
old: &Settings,
|
||||
new: &Settings,
|
||||
dragging: bool,
|
||||
open: &mut [bool; 11],
|
||||
open: &mut [bool; 14],
|
||||
) {
|
||||
let floats: [(&FloatParam, f32, f32); 10] = [
|
||||
let floats: [(&FloatParam, f32, f32); 13] = [
|
||||
(&self.level, old.level, new.level),
|
||||
(&self.split, old.split, new.split),
|
||||
(&self.dynamics, old.dynamics, new.dynamics),
|
||||
@@ -138,6 +160,9 @@ impl AnvilParams {
|
||||
(&self.keep, old.keep, new.keep),
|
||||
(&self.ghost, old.ghost, new.ghost),
|
||||
(&self.ghost_from, old.ghost_from, new.ghost_from),
|
||||
(&self.kick_duck, old.kick_duck, new.kick_duck),
|
||||
(&self.kick_release, old.kick_release, new.kick_release),
|
||||
(&self.kick_offset, old.kick_offset, new.kick_offset),
|
||||
];
|
||||
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
|
||||
if changed {
|
||||
@@ -155,7 +180,7 @@ impl AnvilParams {
|
||||
for (i, (p, a, b)) in floats.into_iter().enumerate() {
|
||||
continuous(i, a != b, &|| setter.set_parameter(p, b));
|
||||
}
|
||||
continuous(10, old.output != new.output, &|| {
|
||||
continuous(13, old.output != new.output, &|| {
|
||||
setter.set_parameter(&self.output, new.output)
|
||||
});
|
||||
let once = |changed: bool, f: &dyn Fn()| {
|
||||
@@ -172,6 +197,7 @@ impl AnvilParams {
|
||||
(&self.retrigger, old.retrigger, new.retrigger),
|
||||
(&self.snap, old.snap, new.snap),
|
||||
(&self.bypass, old.bypass, new.bypass),
|
||||
(&self.kick_lock, old.kick_lock, new.kick_lock),
|
||||
] {
|
||||
once(a != b, &|| {
|
||||
setter.begin_set_parameter(p);
|
||||
@@ -201,6 +227,9 @@ impl AnvilParams {
|
||||
&self.keep,
|
||||
&self.ghost,
|
||||
&self.ghost_from,
|
||||
&self.kick_duck,
|
||||
&self.kick_release,
|
||||
&self.kick_offset,
|
||||
&self.output,
|
||||
][i]
|
||||
}
|
||||
|
||||
@@ -83,8 +83,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
|
||||
as *const clap_plugin_audio_ports;
|
||||
assert!(!ports.is_null());
|
||||
// Main input plus the kick sidechain; one output.
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 2);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
|
||||
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
|
||||
as *const clap_plugin_latency;
|
||||
assert!(!latency.is_null());
|
||||
@@ -108,6 +109,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
.params
|
||||
.insert("level".into(), ParamValue::F32(-14.));
|
||||
envelope.params.insert("octave".into(), ParamValue::I32(-1));
|
||||
envelope.params.insert("ghost".into(), ParamValue::F32(40.));
|
||||
envelope
|
||||
.params
|
||||
.insert("kick_lock".into(), ParamValue::Bool(true));
|
||||
envelope
|
||||
.params
|
||||
.insert("kick_offset".into(), ParamValue::F32(-7.));
|
||||
envelope
|
||||
.params
|
||||
.insert("snap".into(), ParamValue::Bool(false));
|
||||
@@ -154,14 +162,26 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
let mut left = [0f32; 512];
|
||||
let mut right = [0f32; 512];
|
||||
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
|
||||
// A connected but silent kick sidechain.
|
||||
let (mut kick_left, mut kick_right) = ([0f32; 512], [0f32; 512]);
|
||||
let mut kick_channels = [kick_left.as_mut_ptr(), kick_right.as_mut_ptr()];
|
||||
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
|
||||
let incoming = clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let incoming = [
|
||||
clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
},
|
||||
clap_audio_buffer {
|
||||
data32: kick_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
},
|
||||
];
|
||||
let mut outgoing_audio = clap_audio_buffer {
|
||||
data32: out_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
@@ -182,9 +202,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
steady_time: position as i64,
|
||||
frames_count: frames as u32,
|
||||
transport: ptr::null(),
|
||||
audio_inputs: &incoming,
|
||||
audio_inputs: incoming.as_ptr(),
|
||||
audio_outputs: &mut outgoing_audio,
|
||||
audio_inputs_count: 1,
|
||||
audio_inputs_count: 2,
|
||||
audio_outputs_count: 1,
|
||||
in_events: &input,
|
||||
out_events: &outgoing,
|
||||
|
||||
+158
-2
@@ -1,16 +1,21 @@
|
||||
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
|
||||
use anvil_core::{AnalyzerFrame, Settings, Source, GHOST_COUNT, PHONE_LOW, SCOPE};
|
||||
use anvil_core::{
|
||||
AnalyzerFrame, Settings, Source, AUDITION, AUDITION_HITS, GHOST_COUNT, KICK_OFFSET_MAX,
|
||||
PHONE_LOW, SCOPE,
|
||||
};
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
pub use common_ui::style;
|
||||
pub const WIDTH: u32 = 980;
|
||||
pub const HEIGHT: u32 = 704;
|
||||
pub const HEIGHT: u32 = 872;
|
||||
|
||||
pub struct State {
|
||||
pub solo: bool,
|
||||
/// Phone-like listen. A monitor, never saved.
|
||||
pub phone: bool,
|
||||
/// Kick offset audition. A monitor, never saved.
|
||||
pub audition: bool,
|
||||
pub analyzer: AnalyzerFrame,
|
||||
/// True while a pointer button is held, so knob drags become one host gesture.
|
||||
pub gesture_active: bool,
|
||||
@@ -21,6 +26,7 @@ impl Default for State {
|
||||
Self {
|
||||
solo: false,
|
||||
phone: false,
|
||||
audition: false,
|
||||
analyzer: AnalyzerFrame::default(),
|
||||
gesture_active: false,
|
||||
help: DEFAULT_HELP.into(),
|
||||
@@ -249,6 +255,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.vertical(|ui| controls(ui, settings, state));
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
kick_panel(ui, settings, state);
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(RichText::new(&state.help).color(MUTED));
|
||||
}
|
||||
@@ -443,3 +454,148 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
);
|
||||
});
|
||||
}
|
||||
fn kick_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let defaults = Settings::default();
|
||||
let frame = state.analyzer;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(150.);
|
||||
caption(ui, "KICK LOCK");
|
||||
toggle(
|
||||
ui,
|
||||
state,
|
||||
&mut settings.kick_lock,
|
||||
"KICK LOCK",
|
||||
"Route the kick into Anvil's sidechain. On each kick the sub dips and \
|
||||
restarts at Phase, so kick and sub hit together.",
|
||||
);
|
||||
ui.add_space(6.);
|
||||
let status = if !settings.kick_lock {
|
||||
"off".to_string()
|
||||
} else if frame.kick_hits == 0 {
|
||||
"no kick in the sidechain".to_string()
|
||||
} else if frame.kick_sum_db.is_finite() {
|
||||
format!("{} kicks · sum {:+.1} dB", frame.kick_hits, frame.kick_sum_db)
|
||||
} else {
|
||||
format!("{} kicks", frame.kick_hits)
|
||||
};
|
||||
ui.label(RichText::new(status).size(12.).color(MUTED));
|
||||
});
|
||||
ui.columns(4, |c| {
|
||||
knob(
|
||||
&mut c[0],
|
||||
state,
|
||||
"Duck",
|
||||
&mut settings.kick_duck,
|
||||
0. ..=24.,
|
||||
defaults.kick_duck,
|
||||
" dB",
|
||||
54.,
|
||||
"How far the sub dips on each kick.",
|
||||
);
|
||||
knob(
|
||||
&mut c[1],
|
||||
state,
|
||||
"Release",
|
||||
&mut settings.kick_release,
|
||||
10. ..=300.,
|
||||
defaults.kick_release,
|
||||
" ms",
|
||||
54.,
|
||||
"Time for the sub to come back after a kick.",
|
||||
);
|
||||
knob(
|
||||
&mut c[2],
|
||||
state,
|
||||
"Offset",
|
||||
&mut settings.kick_offset,
|
||||
-KICK_OFFSET_MAX..=KICK_OFFSET_MAX,
|
||||
defaults.kick_offset,
|
||||
" ms",
|
||||
54.,
|
||||
"When the sub restarts against the kick. Negative: before it. Use Audition to find it by ear.",
|
||||
);
|
||||
c[3].vertical(|ui| audition(ui, settings, state));
|
||||
});
|
||||
});
|
||||
}
|
||||
/// The audition: each offset plays for a few kicks; bars show how kick and sub summed.
|
||||
fn audition(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let frame = state.analyzer;
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.selectable_label(state.audition, RichText::new("AUDITION").size(12.));
|
||||
if r.clicked() {
|
||||
state.audition = !state.audition;
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
&format!(
|
||||
"Steps the offset from −30 to +30 ms, {AUDITION_HITS} kicks each. Press KEEP \
|
||||
when kick and sub sound like one hit. Not saved."
|
||||
),
|
||||
);
|
||||
let r = ui.add_enabled(
|
||||
state.audition && frame.kick_hits > 0,
|
||||
egui::Button::new(RichText::new("KEEP").size(12.)),
|
||||
);
|
||||
if r.clicked() {
|
||||
settings.kick_offset = frame.kick_offset;
|
||||
state.audition = false;
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Set Offset to the one playing now and stop the audition.",
|
||||
);
|
||||
});
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 58.), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2., SCREEN);
|
||||
let slot = rect.width() / AUDITION.len() as f32;
|
||||
// −9 dB to +3 dB: +3 is a full sum of equal levels.
|
||||
let height = |db: f32| ((db + 9.) / 12.).clamp(0.02, 1.) * (rect.height() - 14.);
|
||||
for (i, &offset) in AUDITION.iter().enumerate() {
|
||||
let x = rect.left() + slot * i as f32;
|
||||
let db = frame.audition_sums[i];
|
||||
let playing = state.audition && (offset - frame.kick_offset).abs() < 0.01;
|
||||
if db.is_finite() {
|
||||
let h = height(db);
|
||||
p.rect_filled(
|
||||
egui::Rect::from_min_max(
|
||||
Pos2::new(x + slot * 0.2, rect.bottom() - 12. - h),
|
||||
Pos2::new(x + slot * 0.8, rect.bottom() - 12.),
|
||||
),
|
||||
1.,
|
||||
if playing { ACCENT } else { STEEL },
|
||||
);
|
||||
}
|
||||
if (offset - settings.kick_offset).abs() < 0.01 {
|
||||
// The kept offset.
|
||||
let (cx, base) = (x + slot * 0.5, rect.bottom() - 2.);
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![
|
||||
Pos2::new(cx, base - 7.),
|
||||
Pos2::new(cx + 4., base),
|
||||
Pos2::new(cx - 4., base),
|
||||
],
|
||||
ACCENT,
|
||||
Stroke::NONE,
|
||||
));
|
||||
} else if i % 2 == 0 {
|
||||
p.text(
|
||||
Pos2::new(x + slot * 0.5, rect.bottom() - 1.),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
format!("{offset:+.0}"),
|
||||
FontId::proportional(8.),
|
||||
DIM,
|
||||
);
|
||||
}
|
||||
}
|
||||
let line = if state.audition {
|
||||
format!("playing {:+.0} ms", frame.kick_offset)
|
||||
} else {
|
||||
"sum per offset, low band".into()
|
||||
};
|
||||
ui.label(RichText::new(line).size(11.).color(MUTED));
|
||||
}
|
||||
Reference in new issue
Block a user