use super::filter::BiquadHighPass;
use crate::config::trigger::{AudioTriggerInput, TriggerInputAction, VelocityCurve};
use crate::samples::{TriggerAction, TriggerEvent};
enum State {
Idle,
Scanning { peak: f32, remaining_samples: u32 },
Lockout { remaining_samples: u32 },
}
pub(super) struct TriggerDetector {
state: State,
gain: f32,
threshold: f32,
scan_samples: u32,
lockout_samples: u32,
velocity_curve: VelocityCurve,
fixed_velocity: u8,
sample_name: Option<String>,
release_group: Option<String>,
action: TriggerInputAction,
highpass: Option<BiquadHighPass>,
dynamic_decay_coeff: Option<f32>,
dynamic_level: f32,
crosstalk_remaining: u32,
crosstalk_multiplier: f32,
noise_floor_alpha: Option<f32>,
noise_floor_ema: f32,
noise_floor_sensitivity: f32,
}
impl TriggerDetector {
pub(super) fn from_input(input: &AudioTriggerInput, sample_rate: u32) -> Self {
Self::new(
sample_rate,
input.threshold(),
input.retrigger_time_ms(),
input.scan_time_ms(),
input.gain(),
input.velocity_curve(),
input.fixed_velocity(),
input.sample().map(|s| s.to_string()),
input.release_group().map(|s| s.to_string()),
input.action(),
input.highpass_freq(),
input.dynamic_threshold_decay_ms(),
input.noise_floor_sensitivity(),
input.noise_floor_decay_ms(),
)
}
#[allow(clippy::too_many_arguments)]
fn new(
sample_rate: u32,
threshold: f32,
retrigger_time_ms: u32,
scan_time_ms: u32,
gain: f32,
velocity_curve: VelocityCurve,
fixed_velocity: u8,
sample_name: Option<String>,
release_group: Option<String>,
action: TriggerInputAction,
highpass_freq: Option<f32>,
dynamic_threshold_decay_ms: Option<u32>,
noise_floor_sensitivity: Option<f32>,
noise_floor_decay_ms: u32,
) -> Self {
let highpass = highpass_freq.map(|freq| BiquadHighPass::new(freq, sample_rate));
let dynamic_decay_coeff = dynamic_threshold_decay_ms.map(|ms| {
let decay_samples = (ms as f64) * (sample_rate as f64) / 1000.0;
(-1.0 / decay_samples).exp() as f32
});
let noise_floor_alpha = noise_floor_sensitivity.map(|_| {
let decay_samples = (noise_floor_decay_ms as f64) * (sample_rate as f64) / 1000.0;
(-1.0 / decay_samples).exp() as f32
});
Self {
state: State::Idle,
gain,
threshold,
scan_samples: super::ms_to_samples(scan_time_ms, sample_rate),
lockout_samples: super::ms_to_samples(retrigger_time_ms, sample_rate),
velocity_curve,
fixed_velocity,
sample_name,
release_group,
action,
highpass,
dynamic_decay_coeff,
dynamic_level: 0.0,
crosstalk_remaining: 0,
crosstalk_multiplier: 1.0,
noise_floor_alpha,
noise_floor_ema: 0.0,
noise_floor_sensitivity: noise_floor_sensitivity.unwrap_or(0.0),
}
}
pub(super) fn process_sample(&mut self, sample: f32) -> Option<TriggerAction> {
let filtered = match &mut self.highpass {
Some(hpf) => hpf.process(sample),
None => sample,
};
self.detect(filtered)
}
fn detect(&mut self, sample: f32) -> Option<TriggerAction> {
if let Some(coeff) = self.dynamic_decay_coeff {
self.dynamic_level *= coeff;
}
if self.crosstalk_remaining > 0 {
self.crosstalk_remaining -= 1;
}
let amplitude = (sample * self.gain).abs();
if matches!(self.state, State::Idle) {
if let Some(alpha) = self.noise_floor_alpha {
self.noise_floor_ema = alpha * self.noise_floor_ema + (1.0 - alpha) * amplitude;
}
}
let effective_threshold = self.effective_threshold();
match &mut self.state {
State::Idle => {
if amplitude > effective_threshold {
if self.scan_samples == 0 {
let action = self.fire(amplitude);
self.state = State::Lockout {
remaining_samples: self.lockout_samples,
};
return Some(action);
}
self.state = State::Scanning {
peak: amplitude,
remaining_samples: self.scan_samples - 1,
};
}
None
}
State::Scanning {
peak,
remaining_samples,
} => {
if amplitude > *peak {
*peak = amplitude;
}
if *remaining_samples == 0 {
let final_peak = *peak;
let action = self.fire(final_peak);
self.state = State::Lockout {
remaining_samples: self.lockout_samples,
};
Some(action)
} else {
*remaining_samples -= 1;
None
}
}
State::Lockout { remaining_samples } => {
if *remaining_samples == 0 {
self.state = State::Idle;
return self.detect(sample);
}
*remaining_samples -= 1;
None
}
}
}
fn effective_threshold(&self) -> f32 {
let adaptive_floor = self.noise_floor_ema * self.noise_floor_sensitivity;
let base = self.threshold.max(adaptive_floor) + self.dynamic_level;
if self.crosstalk_remaining > 0 {
base * self.crosstalk_multiplier
} else {
base
}
}
fn amplitude_to_velocity(&self, peak: f32) -> u8 {
match self.velocity_curve {
VelocityCurve::Linear => {
let clamped = peak.min(1.0);
(clamped * 127.0) as u8
}
VelocityCurve::Logarithmic => {
if peak <= self.threshold {
return 1;
}
let clamped = peak.min(1.0);
let range = 1.0 - self.threshold;
if range < f32::EPSILON {
return 127;
}
let normalized = (clamped - self.threshold) / range;
let log_val = (normalized + 1.0).ln() / 2.0_f32.ln();
let velocity = 1.0 + log_val * 126.0;
(velocity.clamp(1.0, 127.0)) as u8
}
VelocityCurve::Fixed => self.fixed_velocity,
}
}
fn fire(&mut self, peak: f32) -> TriggerAction {
if self.dynamic_decay_coeff.is_some() {
self.dynamic_level = (peak - self.threshold).max(0.0);
}
match self.action {
TriggerInputAction::Trigger => {
let velocity = self.amplitude_to_velocity(peak);
TriggerAction::Trigger(TriggerEvent {
sample_name: self.sample_name.clone().unwrap_or_default(),
velocity,
release_group: self.release_group.clone(),
})
}
TriggerInputAction::Release => TriggerAction::Release {
group: self.release_group.clone().unwrap_or_default(),
},
}
}
pub(super) fn apply_crosstalk_suppression(&mut self, window_samples: u32, multiplier: f32) {
self.crosstalk_remaining = self.crosstalk_remaining.max(window_samples);
self.crosstalk_multiplier = self.crosstalk_multiplier.max(multiplier);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn expect_trigger(action: Option<TriggerAction>) -> TriggerEvent {
match action {
Some(TriggerAction::Trigger(e)) => e,
other => panic!("Expected TriggerAction::Trigger, got {:?}", other),
}
}
fn expect_release(action: Option<TriggerAction>) -> String {
match action {
Some(TriggerAction::Release { group }) => group,
other => panic!("Expected TriggerAction::Release, got {:?}", other),
}
}
fn make_trigger_detector(threshold: f32, scan_ms: u32, lockout_ms: u32) -> TriggerDetector {
TriggerDetector::new(
44100,
threshold,
lockout_ms,
scan_ms,
1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
Some("kick".to_string()),
TriggerInputAction::Trigger,
None,
None,
None,
200,
)
}
#[test]
fn test_below_threshold_no_trigger() {
let mut det = make_trigger_detector(0.1, 5, 30);
for _ in 0..1000 {
assert!(det.process_sample(0.05).is_none());
}
}
#[test]
fn test_above_threshold_fires() {
let mut det = make_trigger_detector(0.1, 0, 30);
let event = expect_trigger(det.process_sample(0.5));
assert_eq!(event.sample_name, "kick");
assert_eq!(event.release_group, Some("kick".to_string()));
assert_eq!(event.velocity, 63);
}
#[test]
fn test_peak_detection_during_scan() {
let mut det = make_trigger_detector(0.1, 5, 30);
assert!(det.process_sample(0.2).is_none());
let scan_samples = ((5.0_f64 * 44100.0) / 1000.0).ceil() as u32;
for i in 0..scan_samples {
let sample = if i == scan_samples / 2 { 0.8 } else { 0.3 };
let result = det.process_sample(sample);
if i == scan_samples - 1 {
let event = expect_trigger(result);
assert_eq!(event.velocity, 101);
return;
}
}
panic!("Scan window ended without trigger");
}
#[test]
fn test_retrigger_lockout() {
let mut det = make_trigger_detector(0.1, 0, 30);
assert!(det.process_sample(0.5).is_some());
let lockout_samples = ((30.0_f64 * 44100.0) / 1000.0).ceil() as u32;
for _ in 0..lockout_samples {
assert!(det.process_sample(0.9).is_none());
}
assert!(det.process_sample(0.5).is_some());
}
#[test]
fn test_linear_velocity() {
let mut det = make_trigger_detector(0.1, 0, 0);
assert_eq!(expect_trigger(det.process_sample(1.0)).velocity, 127);
assert_eq!(expect_trigger(det.process_sample(0.5)).velocity, 63);
}
#[test]
fn test_logarithmic_velocity() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
1.0,
VelocityCurve::Logarithmic,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
None,
200,
);
assert!(expect_trigger(det.process_sample(0.1001)).velocity <= 2);
assert_eq!(expect_trigger(det.process_sample(1.0)).velocity, 127);
let vel = expect_trigger(det.process_sample(0.55)).velocity;
assert!(vel > 1 && vel < 127);
}
#[test]
fn test_fixed_velocity() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
1.0,
VelocityCurve::Fixed,
100,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
None,
200,
);
assert_eq!(expect_trigger(det.process_sample(0.5)).velocity, 100);
assert_eq!(expect_trigger(det.process_sample(0.9)).velocity, 100);
}
#[test]
fn test_release_action() {
let mut det = TriggerDetector::new(
44100,
0.05,
0,
0,
1.0,
VelocityCurve::Linear,
127,
None,
Some("cymbal".to_string()),
TriggerInputAction::Release,
None,
None,
None,
200,
);
assert_eq!(expect_release(det.process_sample(0.3)), "cymbal");
}
#[test]
fn test_gain_multiplier() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
2.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
None,
200,
);
let result = det.process_sample(0.06);
assert!(result.is_some(), "Gain should push signal above threshold");
}
#[test]
fn test_negative_samples() {
let mut det = make_trigger_detector(0.1, 0, 0);
assert_eq!(expect_trigger(det.process_sample(-0.5)).velocity, 63);
}
#[test]
fn test_dynamic_threshold_prevents_ringing_retrigger() {
let mut det = TriggerDetector::new(
44100,
0.1,
0, 0, 1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
Some(50), None,
200,
);
let result = det.process_sample(0.8);
assert!(result.is_some(), "Initial hit should trigger");
let result = det.process_sample(0.3);
assert!(result.is_none(), "Ringing should not retrigger");
let result = det.process_sample(0.5);
assert!(result.is_none(), "Ringing at 0.5 should not retrigger");
}
#[test]
fn test_dynamic_threshold_decays_allows_retrigger() {
let mut det = TriggerDetector::new(
44100,
0.1,
0, 0, 1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
Some(10), None,
200,
);
assert!(det.process_sample(0.8).is_some());
let silence_samples = ((200.0_f64 * 44100.0) / 1000.0).ceil() as u32;
for _ in 0..silence_samples {
assert!(det.process_sample(0.0).is_none());
}
let result = det.process_sample(0.3);
assert!(result.is_some(), "Real hit after decay should trigger");
}
#[test]
fn test_crosstalk_suppression_elevates_threshold() {
let mut det = make_trigger_detector(0.1, 0, 0);
det.apply_crosstalk_suppression(441, 3.0);
let result = det.process_sample(0.2);
assert!(
result.is_none(),
"Should be suppressed during crosstalk window"
);
let result = det.process_sample(0.4);
assert!(
result.is_some(),
"Strong signal should overcome crosstalk suppression"
);
}
#[test]
fn test_crosstalk_suppression_expires() {
let mut det = make_trigger_detector(0.1, 0, 0);
det.apply_crosstalk_suppression(100, 5.0);
for _ in 0..100 {
det.process_sample(0.0);
}
let result = det.process_sample(0.2);
assert!(
result.is_some(),
"Should trigger normally after suppression expires"
);
}
#[test]
fn test_crosstalk_suppression_extends_window() {
let mut det = make_trigger_detector(0.1, 0, 0);
det.apply_crosstalk_suppression(50, 3.0);
for _ in 0..30 {
det.process_sample(0.0);
}
det.apply_crosstalk_suppression(100, 3.0);
for _ in 0..50 {
det.process_sample(0.0);
}
let result = det.process_sample(0.2);
assert!(
result.is_none(),
"Should still be suppressed after window extension"
);
}
#[test]
fn test_adaptive_noise_floor_raises_threshold() {
let mut det = TriggerDetector::new(
44100,
0.1, 0, 0, 1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
Some(5.0), 200, );
let noise_samples = 44100;
for _ in 0..noise_samples {
assert!(det.process_sample(0.05).is_none());
}
let result = det.process_sample(0.2);
assert!(
result.is_none(),
"Signal below adaptive floor should not trigger"
);
let result = det.process_sample(0.3);
assert!(
result.is_some(),
"Signal above adaptive floor should trigger"
);
}
#[test]
fn test_adaptive_noise_floor_disabled_by_default() {
let mut det = make_trigger_detector(0.1, 0, 0);
for _ in 0..4410 {
assert!(det.process_sample(0.05).is_none());
}
let result = det.process_sample(0.2);
assert!(
result.is_some(),
"Without adaptive noise floor, 0.2 should trigger above 0.1 threshold"
);
}
#[test]
fn test_adaptive_noise_floor_frozen_during_scanning() {
let mut det = TriggerDetector::new(
44100,
0.1, 100, 5, 1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
Some(5.0),
200,
);
for _ in 0..4410 {
det.process_sample(0.01);
}
det.process_sample(0.5);
let ema_after_crossing = det.noise_floor_ema;
let scan_samples = ((5.0_f64 * 44100.0) / 1000.0).ceil() as u32;
for _ in 0..scan_samples {
det.process_sample(0.8);
}
assert!(
(det.noise_floor_ema - ema_after_crossing).abs() < 1e-6,
"Noise floor EMA should be frozen during Scanning state"
);
}
#[test]
fn test_adaptive_noise_floor_frozen_during_lockout() {
let mut det = TriggerDetector::new(
44100,
0.1, 100, 0, 1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
Some(5.0),
200,
);
for _ in 0..4410 {
det.process_sample(0.01);
}
assert!(det.process_sample(0.5).is_some());
let ema_after_fire = det.noise_floor_ema;
let lockout_samples = ((100.0_f64 * 44100.0) / 1000.0).ceil() as u32;
for _ in 0..lockout_samples {
det.process_sample(0.9);
}
assert!(
(det.noise_floor_ema - ema_after_fire).abs() < 1e-6,
"Noise floor EMA should be frozen during Lockout state"
);
}
#[test]
fn test_highpass_filter_path() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
Some(80.0), None,
None,
200,
);
let result = det.process_sample(0.9);
assert!(result.is_some());
}
#[test]
fn test_logarithmic_velocity_at_threshold() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
1.0,
VelocityCurve::Logarithmic,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
None,
200,
);
let event = expect_trigger(det.process_sample(0.1001));
assert!(event.velocity <= 2);
}
#[test]
fn test_logarithmic_velocity_threshold_equals_one() {
let mut det = TriggerDetector::new(
44100,
1.0, 0,
0,
2.0, VelocityCurve::Logarithmic,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
None,
200,
);
let event = expect_trigger(det.process_sample(0.6));
assert_eq!(event.velocity, 127);
}
#[test]
fn test_adaptive_noise_floor_recovers_after_noise_drops() {
let mut det = TriggerDetector::new(
44100,
0.1,
0,
0,
1.0,
VelocityCurve::Linear,
127,
Some("kick".to_string()),
None,
TriggerInputAction::Trigger,
None,
None,
Some(5.0),
50, );
for _ in 0..44100 {
det.process_sample(0.08);
}
let result = det.process_sample(0.3);
assert!(result.is_none(), "Should not trigger during high noise");
for _ in 0..(44100 * 2) {
det.process_sample(0.0);
}
let result = det.process_sample(0.2);
assert!(
result.is_some(),
"Should trigger after noise floor decays back down"
);
}
}