use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SoundtrackDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default)]
pub duration: f32,
#[serde(default)]
pub bpm: f32,
#[serde(default)]
pub beat_offset: f32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sections: Vec<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub energy: Vec<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stems: Option<StemCurves>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct StemCurves {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub drums: Vec<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bass: Vec<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vocals: Vec<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub other: Vec<f32>,
}
impl Default for SoundtrackDef {
fn default() -> Self {
Self {
path: None,
title: None,
artist: None,
license: None,
duration: 0.0,
bpm: 0.0,
beat_offset: 0.0,
sections: Vec::new(),
energy: Vec::new(),
stems: None,
}
}
}
pub fn curve_at(curve: &[f32], t: f32) -> f32 {
match curve.len() {
0 => 0.0,
1 => curve[0].clamp(0.0, 1.0),
n => {
let t = t.clamp(0.0, (n - 1) as f32);
let i = t.floor() as usize;
let f = t - i as f32;
if i + 1 >= n {
curve[n - 1].clamp(0.0, 1.0)
} else {
(curve[i] + (curve[i + 1] - curve[i]) * f).clamp(0.0, 1.0)
}
}
}
}
impl SoundtrackDef {
pub fn energy_at(&self, t: f32) -> f32 {
curve_at(&self.energy, t)
}
pub fn beat_at(&self, t: f32) -> f32 {
if self.bpm <= 0.0 || !self.bpm.is_finite() {
return 0.0;
}
let period = 60.0 / self.bpm;
let since = (t - self.beat_offset).rem_euclid(period);
1.0 - since / period
}
pub fn section_at(&self, t: f32) -> usize {
if self.duration <= 0.0 || self.sections.is_empty() {
return 0;
}
let frac = (t / self.duration).clamp(0.0, 1.0);
self.sections
.iter()
.rposition(|&start| start <= frac)
.unwrap_or(0)
}
pub fn stem_at(&self, stem: crate::StemKind, t: f32) -> f32 {
let curve = self.stems.as_ref().map(|s| match stem {
crate::StemKind::Drums => &s.drums,
crate::StemKind::Bass => &s.bass,
crate::StemKind::Vocals => &s.vocals,
crate::StemKind::Other => &s.other,
});
match curve {
Some(c) if !c.is_empty() => curve_at(c, t),
_ => self.energy_at(t),
}
}
pub fn is_valid(&self) -> bool {
let non_negative = |v: f32| v.is_finite() && v >= 0.0;
let unit = |c: &[f32]| c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v));
non_negative(self.duration)
&& non_negative(self.bpm)
&& non_negative(self.beat_offset)
&& unit(&self.energy)
&& unit(&self.sections)
&& self.sections.windows(2).all(|w| w[0] <= w[1])
&& self.stems.as_ref().is_none_or(|s| {
unit(&s.drums) && unit(&s.bass) && unit(&s.vocals) && unit(&s.other)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track() -> SoundtrackDef {
SoundtrackDef {
duration: 4.0,
bpm: 120.0,
beat_offset: 0.25,
sections: vec![0.0, 0.5],
energy: vec![0.0, 1.0, 0.5, 0.5],
..Default::default()
}
}
#[test]
fn curve_sampling() {
assert_eq!(curve_at(&[], 3.0), 0.0);
assert_eq!(curve_at(&[0.7], 3.0), 0.7);
let c = [0.0, 1.0, 0.5];
assert_eq!(curve_at(&c, 0.5), 0.5);
assert_eq!(curve_at(&c, 1.5), 0.75);
assert_eq!(curve_at(&c, 9.0), 0.5);
assert_eq!(curve_at(&c, -1.0), 0.0);
assert_eq!(curve_at(&[2.0, 2.0], 0.5), 1.0, "clamped to unit range");
}
#[test]
fn beat_pulse() {
let t = track();
assert!((t.beat_at(0.25) - 1.0).abs() < 1e-6);
assert!((t.beat_at(0.5) - 0.5).abs() < 1e-6);
assert!((t.beat_at(0.75) - 1.0).abs() < 1e-6);
assert!(
t.beat_at(0.0) > 0.0 && t.beat_at(0.0) < 1.0,
"before the offset wraps"
);
assert_eq!(SoundtrackDef::default().beat_at(1.0), 0.0);
}
#[test]
fn sections_and_stems() {
let mut t = track();
assert_eq!(t.section_at(0.0), 0);
assert_eq!(t.section_at(1.9), 0);
assert_eq!(t.section_at(2.0), 1);
assert_eq!(t.section_at(99.0), 1);
assert_eq!(
t.stem_at(crate::StemKind::Drums, 1.0),
1.0,
"falls back to energy"
);
t.stems = Some(StemCurves {
drums: vec![0.2, 0.2, 0.2, 0.2],
..Default::default()
});
assert!((t.stem_at(crate::StemKind::Drums, 1.0) - 0.2).abs() < 1e-6);
assert_eq!(
t.stem_at(crate::StemKind::Vocals, 1.0),
1.0,
"empty stem falls back"
);
}
#[test]
fn validity() {
assert!(track().is_valid());
let mut bad = track();
bad.energy[1] = 1.5;
assert!(!bad.is_valid());
let mut bad = track();
bad.sections = vec![0.5, 0.2];
assert!(!bad.is_valid());
let mut bad = track();
bad.bpm = f32::NAN;
assert!(!bad.is_valid());
}
#[test]
fn serde_omits_empties() {
let t = SoundtrackDef {
title: Some("Amber Drift".into()),
license: Some("CC0-1.0".into()),
..Default::default()
};
let json = serde_json::to_string(&t).unwrap();
assert_eq!(
json,
r#"{"title":"Amber Drift","license":"CC0-1.0","duration":0.0,"bpm":0.0,"beat_offset":0.0}"#
);
let back: SoundtrackDef = serde_json::from_str(&json).unwrap();
assert_eq!(back, t);
}
}