use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ModulationDef {
pub target: ModulationTarget,
pub signal: SignalSource,
#[serde(default = "default_range")]
pub range: [f32; 2],
#[serde(default)]
pub smoothing: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ModulationTarget {
Emissive,
Scale,
LightIntensity,
Opacity,
OffsetY,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SignalSource {
Energy,
Beat,
Bass,
Highs,
Stem(StemKind),
Oscillator {
frequency: f32,
},
Constant(f32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum StemKind {
Drums,
Bass,
Vocals,
Other,
}
fn default_range() -> [f32; 2] {
[1.0, 1.0]
}
impl ModulationDef {
pub fn new(target: ModulationTarget, signal: SignalSource, range: [f32; 2]) -> Self {
Self {
target,
signal,
range,
smoothing: 0.0,
}
}
pub fn with_smoothing(mut self, seconds: f32) -> Self {
self.smoothing = seconds;
self
}
pub fn factor(&self, signal: f32) -> f32 {
let s = signal.clamp(0.0, 1.0);
self.range[0] + (self.range[1] - self.range[0]) * s
}
pub fn inactive_factor(&self) -> f32 {
match self.target {
ModulationTarget::OffsetY => 0.0,
_ => 1.0,
}
}
pub fn smooth(&self, current: f32, raw: f32, dt: f32) -> f32 {
if self.smoothing <= 0.0 {
return raw;
}
let k = (dt / self.smoothing).clamp(0.0, 1.0);
current + (raw - current) * k
}
pub fn is_valid(&self) -> bool {
self.range.iter().all(|v| v.is_finite())
&& self.smoothing.is_finite()
&& self.smoothing >= 0.0
&& match &self.signal {
SignalSource::Oscillator { frequency } => frequency.is_finite() && *frequency > 0.0,
SignalSource::Constant(v) => v.is_finite(),
_ => true,
}
}
}
impl SignalSource {
pub fn needs_soundtrack(&self) -> bool {
!matches!(
self,
SignalSource::Oscillator { .. } | SignalSource::Constant(_)
)
}
pub fn free_running_at(&self, t: f32) -> Option<f32> {
match self {
SignalSource::Oscillator { frequency } => {
Some(((t * frequency * std::f32::consts::TAU).sin() + 1.0) * 0.5)
}
SignalSource::Constant(v) => Some(v.clamp(0.0, 1.0)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factor_interpolates_and_clamps() {
let m = ModulationDef::new(ModulationTarget::Emissive, SignalSource::Beat, [0.5, 2.0]);
assert_eq!(m.factor(0.0), 0.5);
assert_eq!(m.factor(1.0), 2.0);
assert_eq!(m.factor(2.0), 2.0);
assert_eq!(m.factor(-1.0), 0.5);
assert!((m.factor(0.5) - 1.25).abs() < 1e-6);
}
#[test]
fn inactive_factor_is_identity() {
let mul = ModulationDef::new(ModulationTarget::Scale, SignalSource::Energy, [0.0, 3.0]);
let add = ModulationDef::new(ModulationTarget::OffsetY, SignalSource::Energy, [0.0, 3.0]);
assert_eq!(mul.inactive_factor(), 1.0);
assert_eq!(add.inactive_factor(), 0.0);
}
#[test]
fn smoothing_is_one_pole() {
let raw = ModulationDef::new(ModulationTarget::Scale, SignalSource::Energy, [1.0, 2.0]);
assert_eq!(raw.smooth(0.0, 1.0, 0.016), 1.0);
let slow = raw.clone().with_smoothing(1.0);
let s = slow.smooth(0.0, 1.0, 0.25);
assert!((s - 0.25).abs() < 1e-6);
assert_eq!(slow.smooth(0.0, 1.0, 5.0), 1.0);
}
#[test]
fn free_running_signals() {
let osc = SignalSource::Oscillator { frequency: 1.0 };
assert!((osc.free_running_at(0.0).unwrap() - 0.5).abs() < 1e-6);
assert!((osc.free_running_at(0.25).unwrap() - 1.0).abs() < 1e-6);
assert_eq!(SignalSource::Constant(7.0).free_running_at(3.0), Some(1.0));
assert_eq!(SignalSource::Energy.free_running_at(3.0), None);
assert!(SignalSource::Energy.needs_soundtrack());
assert!(!osc.needs_soundtrack());
}
#[test]
fn validity() {
let ok = ModulationDef::new(ModulationTarget::Scale, SignalSource::Energy, [1.0, 2.0]);
assert!(ok.is_valid());
let mut bad = ok.clone();
bad.range = [f32::NAN, 1.0];
assert!(!bad.is_valid());
let mut bad = ok.clone();
bad.smoothing = -1.0;
assert!(!bad.is_valid());
let bad = ModulationDef::new(
ModulationTarget::Scale,
SignalSource::Oscillator { frequency: 0.0 },
[1.0, 2.0],
);
assert!(!bad.is_valid());
}
#[test]
fn serde_shapes_are_stable() {
let m = ModulationDef::new(
ModulationTarget::LightIntensity,
SignalSource::Stem(StemKind::Drums),
[0.5, 1.5],
)
.with_smoothing(0.1);
let json = serde_json::to_string(&m).unwrap();
assert_eq!(
json,
r#"{"target":"light_intensity","signal":{"stem":"drums"},"range":[0.5,1.5],"smoothing":0.1}"#
);
let back: ModulationDef = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
let osc: ModulationDef = serde_json::from_str(
r#"{"target":"offset_y","signal":{"oscillator":{"frequency":0.5}}}"#,
)
.unwrap();
assert_eq!(osc.range, [1.0, 1.0]);
assert_eq!(osc.smoothing, 0.0);
let plain: ModulationDef =
serde_json::from_str(r#"{"target":"emissive","signal":"beat","range":[1,3]}"#).unwrap();
assert_eq!(plain.signal, SignalSource::Beat);
}
}