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

+1 -1
View File
@@ -22,7 +22,7 @@ note. Each plugin is a separate product with its own version, guide and release.
| --- | --- | --- | --- | --- |
| **[Rivet](docs/rivet/README.md)** | Tells a MIDI motif as a story on a storyboard of roles and joins the seams into one phrase | Channel Rack | none | [Guide](docs/rivet/README.md) · [Changes](docs/rivet/CHANGELOG.md) |
| **[Patina](docs/patina/README.md)** | Smooths harsh, ringing resonances while plain harmonic tone and transients pass | Any mixer track | one analysis frame | [Guide](docs/patina/README.md) · [Changes](docs/patina/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note | Bass bus | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note, locked to the kick | Bass bus, kick as sidechain | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Crucible](docs/crucible/README.md)** | Records the last bars all the time, then slices, cleans and exports key-labelled samples with one button | End of the chain to resample | none | [Guide](docs/crucible/README.md) · [Changes](docs/crucible/CHANGELOG.md) |
| **[Lathe](docs/lathe/README.md)** | EQ with one band on each harmonic of the played note: a cut on harmonic 3 stays on harmonic 3 | Bass, after the synth | ≈ 67 ms | [Guide](docs/lathe/README.md) · [Changes](docs/lathe/CHANGELOG.md) |
| **[Temper](docs/temper/README.md)** | Distortion that drives every note the same: level and tone filters follow the note | Bass | ≈ 67 ms + oversampling | [Guide](docs/temper/README.md) · [Changes](docs/temper/CHANGELOG.md) |
+6
View File
@@ -8,6 +8,12 @@
does not change.
- Add **Phone**, a listen mode: mono, band-limited to 400 Hz–7 kHz. Not saved.
- Show the patch's harmonics and the added ones in an inset in the scope.
- Add Kick lock: with the kick in the sidechain, each kick ducks the sub
(**Duck**, **Release**) and restarts it at Phase, shifted by **Offset**
(−30 to +30 ms, also before the kick).
- Add the offset **Audition**: steps the offset from −30 to +30 ms, 4 kicks each,
shows how kick and sub summed per offset, and **Keep** takes the offset heard.
- Anvil now has a sidechain input named Kick. Without Kick lock it is ignored.
## 0.2.0
+36
View File
@@ -54,6 +54,42 @@ harmonic counts as missing.
Drive works differently: it saturates the sine itself, adds only odd harmonics,
and changes the sub's own shape.
### Kick lock
With **Retrigger**, every note starts at Phase, but a kick that falls in the
middle of a long note meets the sub at whatever phase it has reached. Kick lock
listens to the kick through the sidechain. On each kick it dips the sub by
**Duck** (coming back over **Release**) and restarts the sub at Phase, so the
kick and the sub meet the same way on every hit.
Set it up in FL Studio:
1. Select the kick's mixer track. Right-click the route arrow under the bass
bus and choose **Sidechain to this track**. The kick still goes to the
master on its own; the sidechain only lets Anvil hear it.
2. In Anvil's plugin wrapper, open the settings (gear icon) and set the
sidechain input to the kick's track.
3. Turn on **Kick lock**. The kick count under the switch must rise with the
kicks.
**Offset** moves the restart against the kick, from 30 ms before it to 30 ms
after. Kicks are found on the undelayed sidechain, so with the lookahead the
sub can restart before the kick is heard.
Where the kick and sub sound like one hit is not always where they sum
loudest: offsets one period of the note apart sum the same, and the attack of
the kick decides where the hit is heard. So the offset is set by ear:
1. Turn on **Audition** and play a loop with kicks and a long bass note. Each
offset from −30 to +30 ms plays for 4 kicks; the line under the bars says
which one plays now.
2. Press **Keep** when kick and sub sound like one hit. Offset takes that
value and the audition stops.
The bars show, per offset, how the kick and the sub summed below 150 Hz in the
60 ms after each hit: +3 dB is a full sum of equal levels, 0 dB unrelated,
below 0 dB they cancel. Use them to check the choice, not to make it.
Other controls:
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 304 KiB

+2 -1
View File
@@ -29,7 +29,8 @@ bass bus: Anvil → (bus compression, clipping) → Crucible
layer but cannot move it earlier. If FL Studio does not compensate the
sidechain route, any latency added after Weld's input on the main layer makes
that layer late.
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.**
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.** For Kick
lock, send the kick's track to Anvil as a sidechain.
- **Crucible at the end of the chain you want to resample.**
## Knurl and pitch detection
+11 -6
View File
@@ -212,11 +212,16 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
## Next for released plugins
- **Anvil**: *Kick lock*: the kick through the sidechain ducks the sub on each
hit and restarts its phase so kick and sub sum (problem 6). With it, an
*offset audition*: a sweep of sub offsets against the kick (−30 to +30 ms,
then finer) and a marker for where the hit is heard, kept apart from the
low-band cancellation reading (problem 21). A sub shape from sine to rounded
- **Anvil**: *Kick lock* and the *offset audition* are done (unreleased, see
[Anvil](anvil/README.md)): the kick through the sidechain ducks the sub and
restarts its phase at an offset (problem 6); the audition steps −30 to +30 ms,
4 kicks each, Keep takes the offset heard, and the low-band sum per offset is
shown apart from that choice (problem 21). Measured on a 55 Hz kick burst
against a 55 Hz sub: +2.4 dB at 0 ms, −4.6 dB at −10 ms, −5.9 dB at +10 ms,
and +1.8 to +2.2 dB again one period away (±20 ms), so the sum alone cannot
pick the hit. Open: a finer second sweep around the kept offset; a gate or
sensitivity for kicks with long tails or a kick bus with other drums.
A sub shape from sine to rounded
triangle. A note attack counted in cycles. *Ghost harmonics* and the
*Phone* listen are done (unreleased, see [Anvil](anvil/README.md)): harmonics
2–10 above a "from" frequency, filled only where the patch lacks them, with
@@ -264,7 +269,7 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
spread (done, unreleased); the fatness lane, as the first Assay piece
(prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen
(done, unreleased); Braze (done, unreleased); Anvil Kick lock with offset
audition.
audition (done, unreleased).
4. Forge, when enough modules exist.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
+280 -4
View File
@@ -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);
+33 -1
View File
@@ -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 {
+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 {
+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]
}
+30 -10
View File
@@ -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
View File
@@ -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));
}
+16 -3
View File
@@ -99,13 +99,26 @@ fn main() {
let mut anvil_state = anvil_ui::State::default();
if anvil {
anvil_settings.ghost = 60.;
anvil_settings.kick_lock = true;
anvil_state.audition = true;
let monitor = anvil_core::Monitor {
audition: true,
..Default::default()
};
let mut engine = anvil_core::Engine::new(48000., 2).unwrap();
engine.set_settings(anvil_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
// A distorted F1 bass whose own sub drifts in phase and level.
let total = 300000;
for i in 0..total {
// A distorted F1 bass whose own sub drifts in phase and level, and a kick every
// 0.2 s in the sidechain while the audition runs.
let x = (saw.sample() * 1.6 + 0.3 * (i as f32 * 0.0009).sin()).tanh() * 0.4;
engine.process_sample([x, x], anvil_core::Monitor::default());
let t = (i % 9600) as f32 / 48000.;
let kick = 0.8 * (std::f32::consts::TAU * 50. * t).sin() * (-t / 0.06).exp();
engine.process_with_kick([x, x], kick, monitor);
if i < total - 36000 {
continue;
}
if i % 1600 == 0 {
anvil_state.accept_frame(engine.take_analyzer());
}