use std::str::FromStr;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::samples::SampleTrigger;
use crate::audio::format::SampleFormat;
fn default_threshold() -> f32 {
0.1
}
fn default_retrigger_time_ms() -> u32 {
30
}
fn default_scan_time_ms() -> u32 {
5
}
fn default_gain() -> f32 {
1.0
}
fn default_fixed_velocity() -> u8 {
127
}
fn default_noise_floor_decay_ms() -> u32 {
200
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct TriggerConfig {
device: Option<String>,
sample_rate: Option<u32>,
sample_format: Option<String>,
bits_per_sample: Option<u16>,
buffer_size: Option<usize>,
#[serde(default)]
inputs: Vec<TriggerInput>,
crosstalk_window_ms: Option<u32>,
crosstalk_threshold: Option<f32>,
}
impl TriggerConfig {
pub fn device(&self) -> Option<&str> {
self.device.as_deref()
}
pub fn sample_rate(&self) -> Option<u32> {
self.sample_rate
}
pub fn sample_format(&self) -> Option<SampleFormat> {
self.sample_format.as_deref().and_then(|s| {
SampleFormat::from_str(s)
.inspect_err(|_| {
warn!(
value = s,
"invalid sample_format, expected 'int' or 'float'"
)
})
.ok()
})
}
pub fn bits_per_sample(&self) -> Option<u16> {
self.bits_per_sample
}
pub fn buffer_size(&self) -> Option<usize> {
self.buffer_size
}
pub fn inputs(&self) -> &[TriggerInput] {
&self.inputs
}
pub fn crosstalk_window_ms(&self) -> Option<u32> {
self.crosstalk_window_ms
}
pub fn crosstalk_threshold(&self) -> Option<f32> {
self.crosstalk_threshold
}
pub fn has_audio_inputs(&self) -> bool {
self.inputs
.iter()
.any(|i| matches!(i, TriggerInput::Audio(_)))
}
pub fn midi_triggers(&self) -> Vec<SampleTrigger> {
self.inputs
.iter()
.filter_map(|i| match i {
TriggerInput::Midi(midi) => Some(SampleTrigger::new(
midi.event().clone(),
midi.sample().to_string(),
)),
_ => None,
})
.collect()
}
pub fn add_input(&mut self, input: TriggerInput) {
self.inputs.push(input);
}
pub(crate) fn new_midi_only(inputs: Vec<TriggerInput>) -> Self {
Self {
device: None,
sample_rate: None,
sample_format: None,
bits_per_sample: None,
buffer_size: None,
inputs,
crosstalk_window_ms: None,
crosstalk_threshold: None,
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TriggerInput {
Audio(AudioTriggerInput),
Midi(MidiTriggerInput),
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TriggerInputAction {
#[default]
Trigger,
Release,
}
#[derive(Deserialize, Serialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VelocityCurve {
#[default]
Linear,
Logarithmic,
Fixed,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct AudioTriggerInput {
channel: u16,
sample: Option<String>,
#[serde(default = "default_threshold")]
threshold: f32,
#[serde(default = "default_retrigger_time_ms")]
retrigger_time_ms: u32,
#[serde(default = "default_scan_time_ms")]
scan_time_ms: u32,
#[serde(default = "default_gain")]
gain: f32,
#[serde(default)]
velocity_curve: VelocityCurve,
#[serde(default = "default_fixed_velocity")]
fixed_velocity: u8,
release_group: Option<String>,
#[serde(default)]
action: TriggerInputAction,
highpass_freq: Option<f32>,
dynamic_threshold_decay_ms: Option<u32>,
noise_floor_sensitivity: Option<f32>,
#[serde(default = "default_noise_floor_decay_ms")]
noise_floor_decay_ms: u32,
}
#[cfg(test)]
impl AudioTriggerInput {
pub fn new_trigger(channel: u16, sample: &str) -> Self {
Self {
channel,
sample: Some(sample.to_string()),
threshold: default_threshold(),
retrigger_time_ms: default_retrigger_time_ms(),
scan_time_ms: default_scan_time_ms(),
gain: default_gain(),
velocity_curve: VelocityCurve::default(),
fixed_velocity: default_fixed_velocity(),
release_group: None,
action: TriggerInputAction::Trigger,
highpass_freq: None,
dynamic_threshold_decay_ms: None,
noise_floor_sensitivity: None,
noise_floor_decay_ms: default_noise_floor_decay_ms(),
}
}
pub fn new_release(channel: u16, release_group: &str) -> Self {
Self {
channel,
sample: None,
threshold: default_threshold(),
retrigger_time_ms: default_retrigger_time_ms(),
scan_time_ms: default_scan_time_ms(),
gain: default_gain(),
velocity_curve: VelocityCurve::default(),
fixed_velocity: default_fixed_velocity(),
release_group: Some(release_group.to_string()),
action: TriggerInputAction::Release,
highpass_freq: None,
dynamic_threshold_decay_ms: None,
noise_floor_sensitivity: None,
noise_floor_decay_ms: default_noise_floor_decay_ms(),
}
}
pub fn new_trigger_no_sample(channel: u16) -> Self {
Self {
channel,
sample: None,
action: TriggerInputAction::Trigger,
..Self::new_trigger(channel, "")
}
}
pub fn new_release_no_group(channel: u16) -> Self {
Self {
channel,
sample: None,
release_group: None,
action: TriggerInputAction::Release,
..Self::new_release(channel, "")
}
}
pub fn set_threshold(&mut self, threshold: f32) {
self.threshold = threshold;
}
pub fn set_retrigger_time_ms(&mut self, ms: u32) {
self.retrigger_time_ms = ms;
}
pub fn set_scan_time_ms(&mut self, ms: u32) {
self.scan_time_ms = ms;
}
}
impl AudioTriggerInput {
pub fn channel(&self) -> u16 {
self.channel
}
pub fn sample(&self) -> Option<&str> {
self.sample.as_deref()
}
pub fn threshold(&self) -> f32 {
self.threshold
}
pub fn retrigger_time_ms(&self) -> u32 {
self.retrigger_time_ms
}
pub fn scan_time_ms(&self) -> u32 {
self.scan_time_ms
}
pub fn gain(&self) -> f32 {
self.gain
}
pub fn velocity_curve(&self) -> VelocityCurve {
self.velocity_curve
}
pub fn fixed_velocity(&self) -> u8 {
self.fixed_velocity
}
pub fn release_group(&self) -> Option<&str> {
self.release_group.as_deref()
}
pub fn action(&self) -> TriggerInputAction {
self.action
}
pub fn highpass_freq(&self) -> Option<f32> {
self.highpass_freq
}
pub fn dynamic_threshold_decay_ms(&self) -> Option<u32> {
self.dynamic_threshold_decay_ms
}
pub fn noise_floor_sensitivity(&self) -> Option<f32> {
self.noise_floor_sensitivity
}
pub fn noise_floor_decay_ms(&self) -> u32 {
self.noise_floor_decay_ms
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct MidiTriggerInput {
event: super::midi::Event,
sample: String,
}
impl MidiTriggerInput {
pub fn event(&self) -> &super::midi::Event {
&self.event
}
pub fn sample(&self) -> &str {
&self.sample
}
pub fn new(event: super::midi::Event, sample: String) -> Self {
Self { event, sample }
}
}
#[cfg(test)]
mod tests {
use config::{Config, File, FileFormat};
use super::*;
fn unwrap_audio(input: &TriggerInput) -> &AudioTriggerInput {
match input {
TriggerInput::Audio(audio) => audio,
_ => panic!("Expected TriggerInput::Audio"),
}
}
#[test]
fn test_trigger_config_deserialize() {
let yaml = r#"
device: "UltraLite-mk5"
sample_rate: 44100
inputs:
- kind: audio
channel: 1
sample: "kick"
threshold: 0.1
retrigger_time_ms: 30
scan_time_ms: 5
gain: 1.0
velocity_curve: linear
release_group: "kick"
- kind: audio
channel: 3
sample: "cymbal"
threshold: 0.08
release_group: "cymbal"
- kind: audio
channel: 4
action: release
release_group: "cymbal"
threshold: 0.05
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.device(), Some("UltraLite-mk5"));
assert_eq!(config.sample_rate(), Some(44100));
assert_eq!(config.inputs().len(), 3);
let input0 = unwrap_audio(&config.inputs()[0]);
assert_eq!(input0.channel(), 1);
assert_eq!(input0.sample(), Some("kick"));
assert!((input0.threshold() - 0.1).abs() < 0.001);
assert_eq!(input0.retrigger_time_ms(), 30);
assert_eq!(input0.scan_time_ms(), 5);
assert!((input0.gain() - 1.0).abs() < 0.001);
assert_eq!(input0.velocity_curve(), VelocityCurve::Linear);
assert_eq!(input0.release_group(), Some("kick"));
assert_eq!(input0.action(), TriggerInputAction::Trigger);
let input1 = unwrap_audio(&config.inputs()[1]);
assert_eq!(input1.channel(), 3);
assert_eq!(input1.sample(), Some("cymbal"));
assert_eq!(input1.release_group(), Some("cymbal"));
assert_eq!(input1.action(), TriggerInputAction::Trigger);
let input2 = unwrap_audio(&config.inputs()[2]);
assert_eq!(input2.channel(), 4);
assert_eq!(input2.sample(), None);
assert_eq!(input2.release_group(), Some("cymbal"));
assert_eq!(input2.action(), TriggerInputAction::Release);
}
#[test]
fn test_trigger_config_defaults() {
let yaml = r#"
device: "test-device"
inputs:
- kind: audio
channel: 1
sample: "kick"
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.device(), Some("test-device"));
assert_eq!(config.sample_rate(), None);
let input = unwrap_audio(&config.inputs()[0]);
assert!((input.threshold() - 0.1).abs() < 0.001);
assert_eq!(input.retrigger_time_ms(), 30);
assert_eq!(input.scan_time_ms(), 5);
assert!((input.gain() - 1.0).abs() < 0.001);
assert_eq!(input.velocity_curve(), VelocityCurve::Linear);
assert_eq!(input.fixed_velocity(), 127);
assert_eq!(input.release_group(), None);
assert_eq!(input.action(), TriggerInputAction::Trigger);
}
#[test]
fn test_trigger_config_audio_knobs() {
let yaml = r#"
device: "UltraLite-mk5"
sample_format: int
bits_per_sample: 16
buffer_size: 512
inputs:
- kind: audio
channel: 1
sample: "kick"
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.sample_format(), Some(SampleFormat::Int));
assert_eq!(config.bits_per_sample(), Some(16));
assert_eq!(config.buffer_size(), Some(512));
let yaml_float = r#"
device: "test"
sample_format: float
bits_per_sample: 32
inputs: []
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml_float, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.sample_format(), Some(SampleFormat::Float));
assert_eq!(config.bits_per_sample(), Some(32));
assert_eq!(config.buffer_size(), None);
}
#[test]
fn test_signal_conditioning_fields_deserialize() {
let yaml = r#"
device: "UltraLite-mk5"
crosstalk_window_ms: 4
crosstalk_threshold: 3.0
inputs:
- kind: audio
channel: 1
sample: "kick"
highpass_freq: 80.0
dynamic_threshold_decay_ms: 50
- kind: audio
channel: 2
sample: "snare"
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.crosstalk_window_ms(), Some(4));
assert!((config.crosstalk_threshold().unwrap() - 3.0).abs() < 0.001);
let input0 = unwrap_audio(&config.inputs()[0]);
assert!((input0.highpass_freq().unwrap() - 80.0).abs() < 0.001);
assert_eq!(input0.dynamic_threshold_decay_ms(), Some(50));
let input1 = unwrap_audio(&config.inputs()[1]);
assert_eq!(input1.highpass_freq(), None);
assert_eq!(input1.dynamic_threshold_decay_ms(), None);
}
#[test]
fn test_signal_conditioning_defaults_to_none() {
let yaml = r#"
device: "test-device"
inputs:
- kind: audio
channel: 1
sample: "kick"
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.crosstalk_window_ms(), None);
assert_eq!(config.crosstalk_threshold(), None);
assert_eq!(unwrap_audio(&config.inputs()[0]).highpass_freq(), None);
assert_eq!(
unwrap_audio(&config.inputs()[0]).dynamic_threshold_decay_ms(),
None
);
}
#[test]
fn test_velocity_curves_deserialize() {
let yaml = r#"
device: "test"
inputs:
- kind: audio
channel: 1
sample: "a"
velocity_curve: linear
- kind: audio
channel: 2
sample: "b"
velocity_curve: logarithmic
- kind: audio
channel: 3
sample: "c"
velocity_curve: fixed
fixed_velocity: 100
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(
unwrap_audio(&config.inputs()[0]).velocity_curve(),
VelocityCurve::Linear
);
assert_eq!(
unwrap_audio(&config.inputs()[1]).velocity_curve(),
VelocityCurve::Logarithmic
);
assert_eq!(
unwrap_audio(&config.inputs()[2]).velocity_curve(),
VelocityCurve::Fixed
);
assert_eq!(unwrap_audio(&config.inputs()[2]).fixed_velocity(), 100);
}
#[test]
fn test_midi_trigger_input_deserialize() {
let yaml = r#"
inputs:
- kind: midi
event:
type: note_on
channel: 10
key: 60
sample: kick
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.device(), None);
assert_eq!(config.inputs().len(), 1);
assert!(!config.has_audio_inputs());
assert_eq!(config.midi_triggers().len(), 1);
assert_eq!(config.midi_triggers()[0].sample(), "kick");
}
#[test]
fn test_mixed_audio_and_midi_inputs() {
let yaml = r#"
device: "UltraLite-mk5"
inputs:
- kind: audio
channel: 1
sample: "kick"
- kind: midi
event:
type: note_on
channel: 10
key: 60
sample: snare
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.inputs().len(), 2);
assert!(config.has_audio_inputs());
assert_eq!(config.midi_triggers().len(), 1);
assert_eq!(config.midi_triggers()[0].sample(), "snare");
}
#[test]
fn test_device_optional_for_midi_only() {
let yaml = r#"
inputs:
- kind: midi
event:
type: note_on
channel: 10
key: 60
sample: kick
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.device(), None);
assert!(!config.has_audio_inputs());
}
#[test]
fn new_midi_only_constructor() {
let midi_input = TriggerInput::Midi(MidiTriggerInput::new(
super::super::midi::note_on(1, 60, 127),
"kick".to_string(),
));
let config = TriggerConfig::new_midi_only(vec![midi_input]);
assert_eq!(config.device(), None);
assert_eq!(config.sample_rate(), None);
assert_eq!(config.sample_format(), None);
assert_eq!(config.bits_per_sample(), None);
assert_eq!(config.buffer_size(), None);
assert_eq!(config.inputs().len(), 1);
assert!(!config.has_audio_inputs());
assert_eq!(config.midi_triggers().len(), 1);
}
#[test]
fn add_input() {
let mut config = TriggerConfig::new_midi_only(vec![]);
assert!(config.inputs().is_empty());
config.add_input(TriggerInput::Midi(MidiTriggerInput::new(
super::super::midi::note_on(1, 60, 127),
"snare".to_string(),
)));
assert_eq!(config.inputs().len(), 1);
}
#[test]
fn has_audio_inputs_false_when_empty() {
let config = TriggerConfig::new_midi_only(vec![]);
assert!(!config.has_audio_inputs());
}
#[test]
fn midi_trigger_input_accessors() {
let event = super::super::midi::note_on(10, 36, 100);
let midi = MidiTriggerInput::new(event.clone(), "hihat".to_string());
assert_eq!(midi.sample(), "hihat");
assert_eq!(*midi.event(), event);
}
#[test]
fn invalid_sample_format_returns_none() {
let yaml = r#"
device: "test"
sample_format: "banana"
inputs: []
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(config.sample_format(), None);
}
#[test]
fn noise_floor_fields_deserialize() {
let yaml = r#"
device: "test"
inputs:
- kind: audio
channel: 1
sample: "kick"
noise_floor_sensitivity: 5.0
noise_floor_decay_ms: 300
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
let input = unwrap_audio(&config.inputs()[0]);
assert!((input.noise_floor_sensitivity().unwrap() - 5.0).abs() < 0.001);
assert_eq!(input.noise_floor_decay_ms(), 300);
}
#[test]
fn noise_floor_decay_ms_default() {
let yaml = r#"
device: "test"
inputs:
- kind: audio
channel: 1
sample: "kick"
"#;
let config: TriggerConfig = Config::builder()
.add_source(File::from_str(yaml, FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
let input = unwrap_audio(&config.inputs()[0]);
assert_eq!(input.noise_floor_decay_ms(), 200);
assert_eq!(input.noise_floor_sensitivity(), None);
}
}