use super::*;
pub(crate) struct KickEngine {
pub(crate) sample_rate: f32,
pub(crate) trigger: GridTrigger,
pub(crate) voices: Vec<KickVoice>,
pub(crate) rng: StdRng,
pub(crate) telemetry: Arc<FluidTelemetry>,
}
impl KickEngine {
pub(crate) fn new(sample_rate: f32, telemetry: Arc<FluidTelemetry>) -> Self {
Self {
sample_rate,
trigger: GridTrigger::new(),
voices: Vec::with_capacity(4),
rng: StdRng::from_entropy(),
telemetry,
}
}
pub(crate) fn next(&mut self, c: &KickControls, timing: TimingContext) -> (f32, f32) {
if self
.trigger
.pop_swung(timing, c.interval_beats, c.offset_beats, c.swing)
{
self.voices.push(KickVoice::new(
wrapped_index(c.voice_type, KICK_TYPES.len()),
c,
self.sample_rate,
&mut self.rng,
));
self.telemetry.publish_kick(c.level);
}
let rng = &mut self.rng;
mix_and_retain(&mut self.voices, |v| v.next(rng), KickVoice::is_done)
}
}
pub(crate) struct KickVoiceCore {
pub(crate) amp: f32,
pub(crate) amp_decay: f32,
pub(crate) click_remaining: u64,
pub(crate) click_level: f32,
pub(crate) attack_remaining: u64,
pub(crate) attack_gain: f32,
pub(crate) attack_inc: f32,
pub(crate) pan_gains: (f32, f32),
}
impl KickVoiceCore {
pub(crate) fn new(
c: &KickControls,
sample_rate: f32,
rng: &mut StdRng,
attack_ms: f32,
click_scale: f32,
) -> Self {
let amp_tau = (c.amp_decay_ms * 0.001 * sample_rate / 3.0).max(1.0);
let attack_samples = (attack_ms * 0.001 * sample_rate).round().max(0.0) as u64;
Self {
amp: c.level,
amp_decay: (-1.0 / amp_tau).exp(),
click_remaining: (c.amp_decay_ms * 0.001 * sample_rate * 0.04).round() as u64,
click_level: c.click * click_scale,
attack_remaining: attack_samples,
attack_gain: 0.0,
attack_inc: if attack_samples == 0 {
0.0
} else {
1.0 / attack_samples as f32
},
pan_gains: StereoPanner::gains(rng.gen_range(-0.15f32..0.15)),
}
}
#[inline]
pub(crate) fn shape<R: Rng>(&mut self, body: f32, rng: &mut R) -> f32 {
let mut s = body * self.amp;
if self.click_remaining > 0 {
s += rng.gen_range(-1.0f32..1.0) * self.click_level * self.amp;
self.click_remaining -= 1;
}
if self.attack_remaining > 0 {
s *= self.attack_gain;
self.attack_gain = (self.attack_gain + self.attack_inc).min(1.0);
self.attack_remaining -= 1;
}
self.amp *= self.amp_decay;
s
}
pub(crate) fn is_done(&self) -> bool {
self.amp < 0.0001
}
}
pub(crate) enum KickVoice {
Lowpass(LowpassKickVoice),
Wood(WoodKickVoice),
Analog(AnalogKickVoice),
}
impl KickVoice {
pub(crate) fn new(
voice_type: usize,
c: &KickControls,
sample_rate: f32,
rng: &mut StdRng,
) -> Self {
match voice_type {
0 => Self::Lowpass(LowpassKickVoice::new(&KICK_SUB, c, sample_rate, rng)),
1 => Self::Lowpass(LowpassKickVoice::new(&KICK_WARM, c, sample_rate, rng)),
2 => Self::Wood(WoodKickVoice::new(c, sample_rate, rng)),
3 => Self::Lowpass(LowpassKickVoice::new(&KICK_FELT, c, sample_rate, rng)),
4 => Self::Analog(AnalogKickVoice::new(&KICK_909, c, sample_rate, rng)),
5 => Self::Analog(AnalogKickVoice::new(&KICK_DEEP, c, sample_rate, rng)),
_ => Self::Analog(AnalogKickVoice::new(&KICK_DUST, c, sample_rate, rng)),
}
}
pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
match self {
Self::Lowpass(voice) => voice.next(rng),
Self::Wood(voice) => voice.next(rng),
Self::Analog(voice) => voice.next(rng),
}
}
pub(crate) fn is_done(&self) -> bool {
match self {
Self::Lowpass(voice) => voice.is_done(),
Self::Wood(voice) => voice.is_done(),
Self::Analog(voice) => voice.is_done(),
}
}
}
pub(crate) struct KickFmBody {
pub(crate) freq: f32,
pub(crate) target_freq: f32,
pub(crate) freq_glide: f32,
pub(crate) stack: FmStack,
}
impl KickFmBody {
pub(crate) fn new(
c: &KickControls,
sample_rate: f32,
pitch_drop_ratio: f32,
mod_ratio: f32,
fm_depth: f32,
carrier_wave: FmWave,
) -> Self {
let tau = (c.pitch_decay_ms * 0.001 * sample_rate / 3.0).max(1.0);
let fm_tau = (c.pitch_decay_ms * 0.001 * sample_rate / 9.0).max(1.0);
Self {
freq: c.start_freq,
target_freq: c.start_freq * pitch_drop_ratio,
freq_glide: 1.0 / tau,
stack: FmStack::new(sample_rate).with_pair(
FmPair::new(mod_ratio, KICK_CARRIER_RATIO, fm_depth)
.with_wave(carrier_wave)
.with_index_decay(fm_tau),
),
}
}
#[inline]
pub(crate) fn next(&mut self) -> f32 {
self.freq += (self.target_freq - self.freq) * self.freq_glide;
self.stack.next(self.freq)
}
}
const KICK_CARRIER_RATIO: f32 = 1.0;
pub(crate) const KICK_CHARACTER_FILTER_POSITION: f32 = 0.7;
pub(crate) struct KickLowPass {
pub(crate) state: f32,
pub(crate) coeff: f32,
}
impl KickLowPass {
pub(crate) fn new(filter: f32, bias: f32) -> Self {
Self {
state: 0.0,
coeff: 10_f32.powf(filter * 3.0 + bias).clamp(0.01, 0.99),
}
}
#[inline]
pub(crate) fn process(&mut self, s: f32) -> f32 {
self.state += self.coeff * (s - self.state);
self.state
}
}
pub(crate) struct LowpassKickRecipe {
attack_ms: f32,
click_scale: f32,
pitch_drop_ratio: f32,
fm_mod_ratio: f32,
fm_depth: f32,
wave: FmWave,
filter_bias: f32,
output_gain: f32,
}
pub(crate) const KICK_SUB: LowpassKickRecipe = LowpassKickRecipe {
attack_ms: 0.0,
click_scale: 1.0,
pitch_drop_ratio: 0.28,
fm_mod_ratio: 2.0,
fm_depth: 3.5,
wave: FmWave::Sine,
filter_bias: -2.5,
output_gain: 1.0,
};
pub(crate) const KICK_WARM: LowpassKickRecipe = LowpassKickRecipe {
attack_ms: 6.0,
click_scale: 0.45,
pitch_drop_ratio: 0.42,
fm_mod_ratio: 1.5,
fm_depth: 1.2,
wave: FmWave::Sine,
filter_bias: -2.35,
output_gain: 1.11,
};
pub(crate) const KICK_FELT: LowpassKickRecipe = LowpassKickRecipe {
attack_ms: 8.0,
click_scale: 0.25,
pitch_drop_ratio: 0.28,
fm_mod_ratio: KICK_SUB.fm_mod_ratio,
fm_depth: 1.8,
wave: FmWave::Triangle,
filter_bias: -2.9,
output_gain: 1.36,
};
pub(crate) struct LowpassKickVoice {
pub(crate) core: KickVoiceCore,
pub(crate) body: KickFmBody,
pub(crate) lowpass: KickLowPass,
pub(crate) output_gain: f32,
}
impl LowpassKickVoice {
pub(crate) fn new(
recipe: &LowpassKickRecipe,
c: &KickControls,
sample_rate: f32,
rng: &mut StdRng,
) -> Self {
Self {
core: KickVoiceCore::new(c, sample_rate, rng, recipe.attack_ms, recipe.click_scale),
body: KickFmBody::new(
c,
sample_rate,
recipe.pitch_drop_ratio,
recipe.fm_mod_ratio,
recipe.fm_depth,
recipe.wave,
),
lowpass: KickLowPass::new(KICK_CHARACTER_FILTER_POSITION, recipe.filter_bias),
output_gain: recipe.output_gain,
}
}
pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
if self.core.is_done() {
return (0.0, 0.0);
}
let body = self.body.next();
let s = self
.lowpass
.process(self.core.shape(body, rng) * self.output_gain);
(s * self.core.pan_gains.0, s * self.core.pan_gains.1)
}
pub(crate) fn is_done(&self) -> bool {
self.core.is_done()
}
}
pub(crate) struct AnalogKickRecipe {
start_ratio: f32,
end_ratio: f32,
pitch_tau_ratio: f32,
amp_tau_ratio: f32,
click_scale: f32,
attack_harmonic: f32,
excitation: f32,
impact: f32,
output_gain: f32,
}
pub(crate) const KICK_909: AnalogKickRecipe = AnalogKickRecipe {
start_ratio: 1.3,
end_ratio: 0.27,
pitch_tau_ratio: 0.48,
amp_tau_ratio: 1.2,
click_scale: 5.0,
attack_harmonic: 0.1,
excitation: 0.75,
impact: 2.0,
output_gain: 0.72,
};
pub(crate) const KICK_DEEP: AnalogKickRecipe = AnalogKickRecipe {
start_ratio: 1.15,
end_ratio: 0.25,
pitch_tau_ratio: 0.55,
amp_tau_ratio: 1.25,
click_scale: 2.5,
attack_harmonic: 0.05,
excitation: 0.35,
impact: 1.4,
output_gain: 0.75,
};
pub(crate) const KICK_DUST: AnalogKickRecipe = AnalogKickRecipe {
start_ratio: 1.3,
end_ratio: 0.27,
pitch_tau_ratio: 0.48,
amp_tau_ratio: 0.9,
click_scale: 6.0,
attack_harmonic: 0.25,
excitation: 0.9,
impact: 1.5,
output_gain: 0.77,
};
pub(crate) struct AnalogKickVoice {
sample_rate: f32,
sample_index: u64,
onset_samples: u64,
attack_samples: f32,
impact_center: f32,
impact_half_width: f32,
impact: f32,
phase: f32,
freq: f32,
target_freq: f32,
freq_glide: f32,
amp: f32,
amp_decay: f32,
click: f32,
click_remaining: u64,
click_decay: f32,
attack_harmonic: f32,
harmonic_decay: f32,
excitation: f32,
excitation_decay: f32,
output_gain: f32,
pan_gains: (f32, f32),
}
impl AnalogKickVoice {
pub(crate) fn new(
recipe: &AnalogKickRecipe,
c: &KickControls,
sample_rate: f32,
rng: &mut StdRng,
) -> Self {
let pitch_tau = (c.pitch_decay_ms * 0.001 * sample_rate * recipe.pitch_tau_ratio).max(1.0);
let amp_tau = (c.amp_decay_ms * 0.001 * sample_rate * recipe.amp_tau_ratio).max(1.0);
Self {
sample_rate,
sample_index: 0,
onset_samples: (0.0027 * sample_rate).round() as u64,
attack_samples: 0.001 * sample_rate,
impact_center: 0.0034 * sample_rate,
impact_half_width: 0.00018 * sample_rate,
impact: recipe.impact,
phase: -1.3,
freq: c.start_freq * recipe.start_ratio,
target_freq: c.start_freq * recipe.end_ratio,
freq_glide: 1.0 - (-1.0 / pitch_tau).exp(),
amp: c.level,
amp_decay: (-1.0 / amp_tau).exp(),
click: c.click * recipe.click_scale * c.level,
click_remaining: (0.015 * sample_rate).round() as u64,
click_decay: (-1.0 / (0.003 * sample_rate)).exp(),
attack_harmonic: recipe.attack_harmonic,
harmonic_decay: (-1.0 / (0.018 * sample_rate)).exp(),
excitation: recipe.excitation,
excitation_decay: (-1.0 / (0.015 * sample_rate)).exp(),
output_gain: recipe.output_gain,
pan_gains: StereoPanner::gains(rng.gen_range(-0.15f32..0.15)),
}
}
#[inline]
pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
if self.is_done() {
return (0.0, 0.0);
}
self.freq += (self.target_freq - self.freq) * self.freq_glide;
let body = if self.sample_index < self.onset_samples {
0.0
} else {
self.phase += std::f32::consts::TAU * self.freq / self.sample_rate;
if self.phase >= std::f32::consts::TAU {
self.phase -= std::f32::consts::TAU;
}
let attack =
((self.sample_index - self.onset_samples) as f32 / self.attack_samples).min(1.0);
let harmonic = if self.attack_harmonic > 0.0001 {
self.attack_harmonic * (self.phase * 2.0).sin()
} else {
0.0
};
(self.phase.sin() + harmonic) * attack
};
let impact_distance = (self.sample_index as f32 - self.impact_center).abs();
let impact = if impact_distance < self.impact_half_width {
self.impact * (1.0 - impact_distance / self.impact_half_width)
} else {
0.0
};
let click = if self.click_remaining > 0 {
self.click_remaining -= 1;
rng.gen_range(-1.0f32..1.0) * self.click
} else {
0.0
};
let sample = (body * self.amp * (1.0 + self.excitation) + click - impact * self.amp)
* self.output_gain;
self.amp *= self.amp_decay;
self.click *= self.click_decay;
self.attack_harmonic *= self.harmonic_decay;
self.excitation *= self.excitation_decay;
self.sample_index += 1;
(sample * self.pan_gains.0, sample * self.pan_gains.1)
}
pub(crate) fn is_done(&self) -> bool {
self.amp < 0.001
}
}
const KICK_WOOD_CENTER_MIN_HZ: f32 = 110.0;
const KICK_WOOD_CENTER_MAX_HZ: f32 = 400.0;
const KICK_WOOD_DAMP: f32 = 0.9;
const KICK_WOOD_PITCH_DROP_RATIO: f32 = 0.35;
const KICK_WOOD_BANDPASS_MIX: f32 = 0.6;
const KICK_WOOD_ATTACK_MS: f32 = 5.0;
const KICK_WOOD_CLICK_SCALE: f32 = 0.35;
const KICK_WOOD_OUTPUT_GAIN_AT_DARKEST: f32 = 0.78;
const KICK_WOOD_OUTPUT_GAIN_SPAN: f32 = 1.91;
const KICK_WOOD_OUTPUT_GAIN_CURVE: f32 = 0.7;
fn kick_wood_output_gain(filter: f32) -> f32 {
KICK_WOOD_OUTPUT_GAIN_AT_DARKEST
* (1.0 + KICK_WOOD_OUTPUT_GAIN_SPAN * filter.powf(KICK_WOOD_OUTPUT_GAIN_CURVE))
}
pub(crate) struct WoodKickVoice {
pub(crate) core: KickVoiceCore,
pub(crate) body: KickFmBody,
pub(crate) svf_low: f32,
pub(crate) svf_band: f32,
pub(crate) svf_f: f32,
pub(crate) output_gain: f32,
}
impl WoodKickVoice {
pub(crate) fn new(c: &KickControls, sample_rate: f32, rng: &mut StdRng) -> Self {
let filter = KICK_CHARACTER_FILTER_POSITION;
let center_hz = KICK_WOOD_CENTER_MIN_HZ
* (KICK_WOOD_CENTER_MAX_HZ / KICK_WOOD_CENTER_MIN_HZ).powf(filter);
let svf_f = (2.0 * (std::f32::consts::PI * center_hz / sample_rate).sin()).clamp(0.0, 1.9);
Self {
core: KickVoiceCore::new(
c,
sample_rate,
rng,
KICK_WOOD_ATTACK_MS,
KICK_WOOD_CLICK_SCALE,
),
body: KickFmBody::new(
c,
sample_rate,
KICK_WOOD_PITCH_DROP_RATIO,
KICK_SUB.fm_mod_ratio,
KICK_SUB.fm_depth,
FmWave::Sine,
),
svf_low: 0.0,
svf_band: 0.0,
svf_f,
output_gain: kick_wood_output_gain(filter),
}
}
pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
if self.core.is_done() {
return (0.0, 0.0);
}
let body = self.body.next();
let dry = self.core.shape(body, rng);
let high = dry - self.svf_low - KICK_WOOD_DAMP * self.svf_band;
self.svf_band += self.svf_f * high;
self.svf_low += self.svf_f * self.svf_band;
let s = (self.svf_band * KICK_WOOD_BANDPASS_MIX + dry * (1.0 - KICK_WOOD_BANDPASS_MIX))
* self.output_gain;
(s * self.core.pan_gains.0, s * self.core.pan_gains.1)
}
pub(crate) fn is_done(&self) -> bool {
self.core.is_done()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn swing_delays_the_kicks_odd_subdivision() {
let telemetry = Arc::new(FluidTelemetry::default());
let mut kick = KickEngine::new(48_000.0, Arc::clone(&telemetry));
let controls = KickControls {
level: 1.0,
interval_beats: 0.5,
swing: 1.0,
..KickControls::default()
};
kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.0));
kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.5));
assert_eq!(telemetry.kick_pulse.load(Ordering::Relaxed), 1);
kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.75));
assert_eq!(telemetry.kick_pulse.load(Ordering::Relaxed), 2);
}
fn render_one_hit(voice_type: usize) -> Vec<f32> {
const SAMPLE_RATE: f32 = 48_000.0;
let controls = KickControls {
level: 1.0,
..KickControls::default()
};
let mut rng = StdRng::seed_from_u64(42);
let mut voice = KickVoice::new(voice_type, &controls, SAMPLE_RATE, &mut rng);
let mut rendered = Vec::new();
while !voice.is_done() && rendered.len() < SAMPLE_RATE as usize {
let (left, right) = voice.next(&mut rng);
rendered.push((left * left + right * right).sqrt());
}
rendered
}
fn render_mono_hit(voice_type: usize) -> Vec<f32> {
const SAMPLE_RATE: f32 = 48_000.0;
let controls = KickControls {
level: 1.0,
..KickControls::default()
};
let mut rng = StdRng::seed_from_u64(42);
let mut voice = KickVoice::new(voice_type, &controls, SAMPLE_RATE, &mut rng);
let mut rendered = Vec::with_capacity(SAMPLE_RATE as usize / 2);
for _ in 0..SAMPLE_RATE as usize / 2 {
let (left, right) = voice.next(&mut rng);
rendered.push((left + right) * 0.5);
}
rendered
}
fn window_rms(samples: &[f32], start_ms: usize, end_ms: usize) -> f32 {
crate::synth::fm::rms(&samples[start_ms * 48..end_ms * 48])
}
fn band_rms(samples: &[f32], low_hz: f32, high_hz: f32, start_ms: usize, end_ms: usize) -> f32 {
const SAMPLE_RATE: f32 = 48_000.0;
let low_coeff = 1.0 - (-std::f32::consts::TAU * low_hz / SAMPLE_RATE).exp();
let high_coeff = 1.0 - (-std::f32::consts::TAU * high_hz / SAMPLE_RATE).exp();
let (mut low, mut high, mut energy) = (0.0f32, 0.0f32, 0.0f32);
let start = start_ms * 48;
let end = end_ms * 48;
for (sample_index, &sample) in samples[..end].iter().enumerate() {
low += low_coeff * (sample - low);
high += high_coeff * (sample - high);
if sample_index >= start {
energy += (high - low).powi(2);
}
}
(energy / (end - start) as f32).sqrt()
}
#[test]
fn kick_909_has_a_short_attack_and_audible_bass_tail() {
let samples = render_mono_hit(4);
let attack = window_rms(&samples, 10, 40);
let middle = window_rms(&samples, 40, 100);
let tail = window_rms(&samples, 180, 300);
assert!(attack > middle * 1.3, "attack must lead the body");
assert!(
(0.18..=0.36).contains(&(tail / attack)),
"bass tail is {:.2}x the attack, outside the reference shape",
tail / attack
);
}
#[test]
fn kick_909_moves_from_low_mids_into_bass_instead_of_holding_a_second_tone() {
let samples = render_mono_hit(4);
let early_mid = band_rms(&samples, 160.0, 500.0, 10, 40);
let late_mid = band_rms(&samples, 160.0, 500.0, 100, 180);
let tail_bass = band_rms(&samples, 20.0, 90.0, 180, 300);
let tail_mid = band_rms(&samples, 160.0, 500.0, 180, 300);
assert!(
early_mid > late_mid * 4.0,
"midrange should be an attack: {:.1}x",
early_mid / late_mid
);
assert!(
tail_bass > tail_mid * 3.0,
"tail should ring in the bass: {:.1}x",
tail_bass / tail_mid
);
}
#[test]
fn kick_types_render_at_a_matched_level() {
let reference = crate::synth::fm::rms(&render_one_hit(0));
for (voice_type, label) in KICK_TYPES.iter().enumerate().skip(1) {
let level = crate::synth::fm::rms(&render_one_hit(voice_type));
let ratio = level / reference;
assert!(
(ratio - 1.0).abs() <= MATCHED_LEVEL_TOLERANCE,
"kick type {voice_type} ({label}) renders at {ratio:.2}x Sub; \
retune its output trim",
);
}
}
#[test]
fn no_kick_type_exceeds_the_headroom_budget() {
for (voice_type, label) in KICK_TYPES.iter().enumerate() {
let peak = render_one_hit(voice_type)
.into_iter()
.fold(0.0f32, f32::max);
assert!(
peak <= MAX_KICK_PEAK,
"kick type {voice_type} ({label}) peaks at {peak:.2}",
);
}
}
const MATCHED_LEVEL_TOLERANCE: f32 = 0.15;
const MAX_KICK_PEAK: f32 = 1.8;
}