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 Linear,
18 #[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}