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

+31 -4
View File
@@ -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];
}
+33 -4
View File
@@ -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]
}