Skip to main content

kithara_play/api/
crossfade.rs

1use std::f32::consts::FRAC_PI_2;
2
3use crate::PlayError;
4
5#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
6#[non_exhaustive]
7pub enum SelectionPlayback {
8    #[default]
9    Play,
10    Pause,
11}
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
14#[non_exhaustive]
15pub enum CrossfadeCurve {
16    /// Controls amplitude directly; unrelated tracks can have a perceived-power dip.
17    Linear,
18    /// Approximately preserves power for uncorrelated tracks, but correlated material can sum louder.
19    #[default]
20    EqualPower,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
24pub struct CrossfadeSettings {
25    pub curve: CrossfadeCurve,
26    pub depth: f32,
27    pub duration: f32,
28    pub position: f32,
29}
30
31impl Default for CrossfadeSettings {
32    fn default() -> Self {
33        Self {
34            duration: 1.0,
35            curve: CrossfadeCurve::EqualPower,
36            depth: 1.0,
37            position: 0.5,
38        }
39    }
40}
41
42impl CrossfadeSettings {
43    pub fn new(
44        duration: f32,
45        curve: CrossfadeCurve,
46        depth: f32,
47        position: f32,
48    ) -> Result<Self, PlayError> {
49        let settings = Self {
50            curve,
51            depth,
52            duration,
53            position,
54        };
55        settings.validate()?;
56        Ok(settings)
57    }
58
59    #[must_use]
60    pub fn gains(self, progress: f32) -> (f32, f32) {
61        let x = progress.clamp(0.0, 1.0);
62        if x == 0.0 {
63            return (1.0, 0.0);
64        }
65        if x == 1.0 {
66            return (0.0, 1.0);
67        }
68        let u = if x <= self.position {
69            x / (2.0 * self.position)
70        } else {
71            0.5 + (x - self.position) / (2.0 * (1.0 - self.position))
72        };
73        let linear = (1.0 - u, u);
74        let selected = match self.curve {
75            CrossfadeCurve::Linear => linear,
76            CrossfadeCurve::EqualPower => {
77                let angle = FRAC_PI_2 * u;
78                (angle.cos(), angle.sin())
79            }
80        };
81        (
82            self.depth.mul_add(selected.0 - linear.0, linear.0),
83            self.depth.mul_add(selected.1 - linear.1, linear.1),
84        )
85    }
86
87    pub fn validate(self) -> Result<Self, PlayError> {
88        if !self.duration.is_finite() || self.duration < 0.0 {
89            return Err(PlayError::InvalidParameter {
90                name: "crossfade.duration".into(),
91                value: self.duration,
92            });
93        }
94        if !self.depth.is_finite() || !(0.0..=1.0).contains(&self.depth) {
95            return Err(PlayError::InvalidParameter {
96                name: "crossfade.depth".into(),
97                value: self.depth,
98            });
99        }
100        if !self.position.is_finite() || self.position <= 0.0 || self.position >= 1.0 {
101            return Err(PlayError::InvalidParameter {
102                name: "crossfade.position".into(),
103                value: self.position,
104            });
105        }
106        Ok(self)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use kithara_test_utils::kithara;
113
114    use super::*;
115
116    #[kithara::test]
117    fn gain_law_keeps_endpoints_and_pivot_continuous() {
118        let settings = CrossfadeSettings::new(2.0, CrossfadeCurve::EqualPower, 1.0, 0.3)
119            .expect("valid settings");
120        assert_eq!(settings.gains(0.0), (1.0, 0.0));
121        assert_eq!(settings.gains(1.0), (0.0, 1.0));
122        let pivot = settings.gains(0.3);
123        let expected = 0.5_f32.sqrt();
124        assert!((pivot.0 - expected).abs() < 1.0e-6);
125        assert!((pivot.1 - expected).abs() < 1.0e-6);
126        let left = settings.gains(0.3 - f32::EPSILON);
127        let right = settings.gains(0.3 + f32::EPSILON);
128        assert!((left.0 - right.0).abs() < 1.0e-5);
129        assert!((left.1 - right.1).abs() < 1.0e-5);
130    }
131
132    #[kithara::test]
133    fn depth_zero_is_linear_and_invalid_values_fail() {
134        let settings = CrossfadeSettings::new(1.0, CrossfadeCurve::EqualPower, 0.0, 0.5)
135            .expect("valid settings");
136        assert_eq!(settings.gains(0.25), (0.75, 0.25));
137        for invalid in [f32::NAN, f32::INFINITY, -1.0] {
138            assert!(CrossfadeSettings::new(invalid, CrossfadeCurve::Linear, 1.0, 0.5).is_err());
139        }
140        assert!(CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.1, 0.5).is_err());
141        assert!(CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.0, 0.0).is_err());
142        assert!(CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.0, 1.0).is_err());
143    }
144
145    #[kithara::test]
146    fn gain_law_is_monotonic_symmetric_and_supports_asymmetric_pivots() {
147        let centred = CrossfadeSettings::new(1.0, CrossfadeCurve::EqualPower, 1.0, 0.5)
148            .expect("valid settings");
149        let mut previous = centred.gains(0.0);
150        for step in 1..=100 {
151            let gains = centred.gains(step as f32 / 100.0);
152            assert!(gains.0 <= previous.0);
153            assert!(gains.1 >= previous.1);
154            previous = gains;
155        }
156        let quarter = centred.gains(0.25);
157        let three_quarters = centred.gains(0.75);
158        assert!((quarter.0 - three_quarters.1).abs() < 1.0e-6);
159        assert!((quarter.1 - three_quarters.0).abs() < 1.0e-6);
160
161        let early =
162            CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.0, 0.25).expect("valid settings");
163        assert_eq!(early.gains(0.25), (0.5, 0.5));
164        assert_ne!(early.gains(0.25), centred.gains(0.25));
165
166        let equal_power = CrossfadeSettings::new(1.0, CrossfadeCurve::EqualPower, 1.0, 0.5)
167            .expect("valid settings");
168        let linear =
169            CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.0, 0.5).expect("valid settings");
170        assert_ne!(equal_power.gains(0.25), linear.gains(0.25));
171    }
172
173    #[kithara::test]
174    fn every_non_finite_or_out_of_range_field_is_rejected() {
175        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.1] {
176            assert!(CrossfadeSettings::new(invalid, CrossfadeCurve::Linear, 1.0, 0.5).is_err());
177        }
178        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.1, 1.1] {
179            assert!(CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, invalid, 0.5).is_err());
180        }
181        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, 1.0] {
182            assert!(CrossfadeSettings::new(1.0, CrossfadeCurve::Linear, 1.0, invalid).is_err());
183        }
184    }
185}