Files
bass-foundry/plugins/anvil/core/src/lib.rs
T
nvrl fadea4c471
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
feat(anvil): add Kick lock with offset audition
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.
2026-11-06 14:09:59 +01:00

778 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sub anchor: replaces the band below the split with a clean mono sine that follows the
//! bass pitch, holds one level for every note and restarts at the same phase on each note.
//!
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
//!
//! Ghost harmonics add the sub's own harmonics above a chosen frequency, so the note
//! 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, 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).
pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz;
pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
/// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz.
pub const GHOST_TOP: usize = 10;
/// Ghost harmonics shown and generated: 2..=GHOST_TOP.
pub const GHOST_COUNT: usize = GHOST_TOP - 1;
/// Measurement window per note. Resolves harmonics of fundamentals down to about 35 Hz.
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.;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
/// Hear only the generated sub and Ghost harmonics.
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)]
pub struct AnalyzerFrame {
/// Current sub frequency in Hz, 0 when silent.
pub hz: f32,
/// MIDI note of the current sub, rounded.
pub note: Option<u8>,
pub low_db: f32,
pub sub_db: f32,
/// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the
/// patch carries above the split, and what Ghost adds. −120 when absent.
pub patch_harmonics: [f32; GHOST_COUNT],
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],
/// Duration of the scope window in milliseconds.
pub scope_ms: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
low_db: -120.,
sub_db: -120.,
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.,
latency: 0,
sample_rate: 48000.,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Voice {
phase: f32,
hz: f32,
target: f32,
amp: f32,
releasing: bool,
}
/// Sine voices and per-note state, driven by tracker events at output time.
#[derive(Clone, Copy, Debug, Default)]
struct Synth {
voice: Voice,
old: Voice,
old_gain: f32,
note_peak: f32,
/// Set when a note starts or changes pitch; the Ghost measurement starts over.
fresh: bool,
}
impl Synth {
fn event(&mut self, s: &Settings, e: NoteEvent) {
match e {
NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide),
NoteEvent::Move { pitch } => {
if !s.snap {
self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32);
}
}
NoteEvent::Stop => self.voice.releasing = true,
}
}
fn start(&mut self, s: &Settings, pitch: f32, glide: bool) {
let note = if s.snap { pitch.round() } else { pitch };
let hz = midi_to_hz(note + 12. * s.octave as f32);
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
if glide && audible {
self.voice.target = hz;
return;
}
if s.retrigger || !audible {
if self.voice.amp > 1e-4 {
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
self.old = self.voice;
self.old_gain = 1.;
}
self.voice = Voice {
phase: s.phase / 360.,
hz,
target: hz,
amp: 0.,
releasing: false,
};
} else {
self.voice.target = hz;
self.voice.hz = hz;
}
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 {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
crossover: [Crossover; 2],
tracker: Tracker,
synth: Synth,
glide: f32,
low_env: f32,
env_coef: f32,
smooth: f32,
level: f32,
keep: f32,
output: f32,
bypass: f32,
meter_coef: f32,
low_power: f32,
sub_power: f32,
scope_low: [f32; 2 * SCOPE],
scope_sub: [f32; 2 * SCOPE],
scope_at: usize,
scope_stride: usize,
scope_count: usize,
meter: HarmonicMeter,
/// Linear peak of the patch's harmonic k (index k − 2) above the split, this note.
patch: [f32; GHOST_COUNT],
/// Smoothed Ghost gain per harmonic, relative to the sub's peak.
fill: [f32; GHOST_COUNT],
phone_coefs: [SvfCoef; 3],
phone: [Svf; 3],
kick: Kick,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::SUB);
let latency = tracker.latency();
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
crossover: [Crossover::new(rate, settings.split); 2],
tracker,
synth: Synth::default(),
glide: 1.,
low_env: 0.,
env_coef: coefficient(rate, 10.),
smooth: coefficient(rate, 10.),
level: db_to_gain(settings.level),
keep: 0.,
output: 1.,
bypass: 0.,
meter_coef: coefficient(rate, 150.),
low_power: 0.,
sub_power: 0.,
scope_low: [0.; 2 * SCOPE],
scope_sub: [0.; 2 * SCOPE],
scope_at: 0,
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
scope_count: 0,
meter: HarmonicMeter::new(rate, GHOST_WINDOW_MS),
patch: [0.; GHOST_COUNT],
fill: [0.; GHOST_COUNT],
phone_coefs: [
// Fourth-order Butterworth high-pass, second-order low-pass.
SvfCoef::highpass(rate, PHONE_LOW, 0.541),
SvfCoef::highpass(rate, PHONE_LOW, 1.307),
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)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
let old = self.settings;
if (s.split - old.split).abs() > 1e-3 {
for x in &mut self.crossover {
x.retune(self.rate, s.split);
}
}
if self
.tracker
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
{
self.synth.voice.releasing = true;
}
self.glide = if s.glide <= 0. {
1.
} else {
coefficient(self.rate, s.glide / 3.)
};
self.settings = s;
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
for x in &mut self.crossover {
x.reset();
}
self.tracker.reset();
self.synth = Synth::default();
self.low_env = 0.;
self.low_power = 0.;
self.sub_power = 0.;
self.scope_low = [0.; 2 * SCOPE];
self.scope_sub = [0.; 2 * SCOPE];
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
self.fill = [0.; GHOST_COUNT];
for f in &mut self.phone {
f.reset();
}
self.kick.reset();
}
/// Lowest frequency Ghost fills.
fn ghost_edge(&self) -> f32 {
self.settings.ghost_from.max(self.settings.split)
}
/// Moves each Ghost gain toward what the patch lacks at the current note.
fn update_fill(&mut self) {
let s = &self.settings;
let v = self.synth.voice;
let amount = s.ghost * 0.01;
let edge = self.ghost_edge();
let sub = self.level.max(1e-6);
for (i, fill) in self.fill.iter_mut().enumerate() {
let k = (i + 2) as f32;
let f = k * v.hz;
let target = if amount > 0. && v.hz > 0. && f < 0.45 * self.rate {
let ramp = ((f / edge).log2() / GHOST_RAMP_OCT).clamp(0., 1.);
// A 1/k slope, like a saw; at 100 % harmonic k sits at 2/k of the sub.
let want = amount * 2. / k * ramp;
(want - self.patch[i] / sub).max(0.)
} else {
0.
};
*fill += (target - *fill) * self.smooth;
}
}
/// Ghost harmonics for a voice at `phase`, without the voice's amplitude.
#[inline]
fn ghost(&self, phase: f32) -> f32 {
let mut out = 0.;
for (i, &g) in self.fill.iter().enumerate() {
if g > 1e-5 {
out += g * (TAU * ((i + 2) as f32 * phase).fract()).sin();
}
}
out
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
#[inline]
fn shape(&self, x: f32) -> f32 {
let drive = self.settings.drive * 0.01;
if drive <= 0. {
return x;
}
let k = 1. + 6. * drive;
(k * x).tanh() / k.tanh()
}
/// The shaped sub and the Ghost harmonics, before level and dynamics.
fn oscillator(&mut self) -> (f32, f32) {
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
let release = 1. / (self.rate * RELEASE_MS * 0.001);
let v = &mut self.synth.voice;
v.amp = if v.releasing {
(v.amp - release).max(0.)
} else {
(v.amp + attack).min(1.)
};
v.hz += (v.target - v.hz) * self.glide;
let (mut out, mut ghost) = (0., 0.);
let v = self.synth.voice;
if v.amp > 0. {
out += (TAU * v.phase).sin() * v.amp;
ghost += self.ghost(v.phase) * v.amp;
self.synth.voice.phase = (v.phase + v.hz / self.rate).fract();
}
let s = self.synth;
if s.old_gain > 0. {
let o = s.old;
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
ghost += self.ghost(o.phase) * o.amp * s.old_gain;
let s = &mut self.synth;
s.old.phase = (o.phase + o.hz / self.rate).fract();
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
}
(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 {
x[0]
};
let (synth, settings) = (&mut self.synth, &self.settings);
self.tracker.process(mono, |e| synth.event(settings, e));
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
// The slot after the newest write is the oldest: exactly `latency` samples ago.
let y = self.delay[self.delay_at];
let s = self.settings;
let (l0, h0) = self.crossover[0].process(y[0]);
let (l1, h1) = if self.channels == 2 {
self.crossover[1].process(y[1])
} else {
(l0, h0)
};
let low = 0.5 * (l0 + l1);
if std::mem::take(&mut self.synth.fresh) {
// A new note: measure its harmonics from its first sample. Until the first
// window completes they count as missing.
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
}
let v = self.synth.voice;
let hz = if v.amp > 0. && !v.releasing { v.hz } else { 0. };
if self.meter.push(0.5 * (h0 + h1), hz) && self.meter.hz() > 0. {
for (p, db) in self.patch.iter_mut().zip(&self.meter.levels()[1..]) {
*p = if *db > -119. { db_to_gain(*db) } else { 0. };
}
}
self.low_env += (low * low - self.low_env) * self.env_coef;
let env = self.low_env.sqrt();
if !self.synth.voice.releasing {
self.synth.note_peak = self.synth.note_peak.max(env);
}
let follow = if self.synth.note_peak > 1e-6 {
(env / self.synth.note_peak).min(1.)
} else {
1.
};
let dynamics = s.dynamics * 0.01;
let shape = 1. - dynamics + dynamics * follow;
self.level += (db_to_gain(s.level) - self.level) * self.smooth;
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 * 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;
if self.scope_count >= self.scope_stride {
self.scope_count = 0;
self.scope_low[self.scope_at] = low;
self.scope_sub[self.scope_at] = generated;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
let wet = |l: f32, h: f32| {
if monitor.solo {
generated
} else {
h + self.keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
}
if monitor.phone {
let mut m = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
for (f, c) in self.phone.iter_mut().zip(&self.phone_coefs) {
m = f.process(c, m);
}
out = [m, if self.channels == 2 { m } else { 0. }];
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
latency: self.latency as u32,
sample_rate: self.rate,
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
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);
let rel = |g: f32| {
if g > 1e-6 {
20. * (g / sub).log10()
} else {
-120.
}
};
for i in 0..GHOST_COUNT {
f.patch_harmonics[i] = rel(self.patch[i]);
f.ghost_harmonics[i] = rel(self.fill[i] * sub);
}
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
f.hz = v.hz;
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
}
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
let n = 2 * SCOPE;
let oldest = self.scope_at;
let mut start = oldest;
for back in 0..SCOPE {
let i = (oldest + SCOPE - back) % n;
let prev = (i + n - 1) % n;
if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. {
start = i;
break;
}
}
for k in 0..SCOPE {
let i = (start + k) % n;
f.scope_low[k] = self.scope_low[i];
f.scope_sub[k] = self.scope_sub[i];
}
f
}
}