diff --git a/README.md b/README.md index f33cd69..bf6da56 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docs/anvil/CHANGELOG.md b/docs/anvil/CHANGELOG.md index 6e1cda0..c0218af 100644 --- a/docs/anvil/CHANGELOG.md +++ b/docs/anvil/CHANGELOG.md @@ -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 diff --git a/docs/anvil/README.md b/docs/anvil/README.md index b337e7f..12ef047 100644 --- a/docs/anvil/README.md +++ b/docs/anvil/README.md @@ -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 diff --git a/docs/anvil/assets/editor.png b/docs/anvil/assets/editor.png index c122b3f..4cbdeb9 100644 Binary files a/docs/anvil/assets/editor.png and b/docs/anvil/assets/editor.png differ diff --git a/docs/chain.md b/docs/chain.md index 320fe81..e37a19c 100644 --- a/docs/chain.md +++ b/docs/chain.md @@ -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 diff --git a/docs/ideas.md b/docs/ideas.md index 73a6f1a..76b5cda 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -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. diff --git a/plugins/anvil/core/src/lib.rs b/plugins/anvil/core/src/lib.rs index 6268fb7..0b0b8f5 100644 --- a/plugins/anvil/core/src/lib.rs +++ b/plugins/anvil/core/src/lib.rs @@ -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, +} + +/// 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], + delay: Vec, + delay_at: usize, + low: Biquad, + duck: f32, + rising: bool, + attack: f32, + measure: usize, + measure_slot: Option, + 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); diff --git a/plugins/anvil/core/src/settings.rs b/plugins/anvil/core/src/settings.rs index 6474339..ae1f3c9 100644 --- a/plugins/anvil/core/src/settings.rs +++ b/plugins/anvil/core/src/settings.rs @@ -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 { diff --git a/plugins/anvil/core/tests/dsp.rs b/plugins/anvil/core/tests/dsp.rs index eb251ca..17645cf 100644 --- a/plugins/anvil/core/tests/dsp.rs +++ b/plugins/anvil/core/tests/dsp.rs @@ -19,7 +19,15 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec { } fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec { 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 { + 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 { + 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 = (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] + ); + } +} diff --git a/plugins/anvil/core/tests/realtime.rs b/plugins/anvil/core/tests/realtime.rs index ddc2efa..dc76897 100644 --- a/plugins/anvil/core/tests/realtime.rs +++ b/plugins/anvil/core/tests/realtime.rs @@ -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 { diff --git a/plugins/anvil/plugin/src/lib.rs b/plugins/anvil/plugin/src/lib.rs index 4e42806..7e74a16 100644 --- a/plugins/anvil/plugin/src/lib.rs +++ b/plugins/anvil/plugin/src/lib.rs @@ -14,10 +14,11 @@ struct Shared { analyzer: ArrayQueue, solo: AtomicBool, phone: AtomicBool, + audition: AtomicBool, } struct EditorData { ui: anvil_ui::State, - gestures: [bool; 11], + gestures: [bool; 14], } pub struct Anvil { params: Arc, @@ -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, ) -> 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::() + / 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]; } diff --git a/plugins/anvil/plugin/src/params.rs b/plugins/anvil/plugin/src/params.rs index 162f76e..db72d11 100644 --- a/plugins/anvil/plugin/src/params.rs +++ b/plugins/anvil/plugin/src/params.rs @@ -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] } diff --git a/plugins/anvil/plugin/tests/host.rs b/plugins/anvil/plugin/tests/host.rs index 4475654..163956c 100644 --- a/plugins/anvil/plugin/tests/host.rs +++ b/plugins/anvil/plugin/tests/host.rs @@ -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, diff --git a/plugins/anvil/ui/src/lib.rs b/plugins/anvil/ui/src/lib.rs index 1ff5a1b..8541b42 100644 --- a/plugins/anvil/ui/src/lib.rs +++ b/plugins/anvil/ui/src/lib.rs @@ -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)); +} diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 71f86a9..07e12a7 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -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()); }