1use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Style {
11 Bulb,
12 Awooga,
13 #[default]
14 Car,
15 Truck,
16 Clown,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Wave {
22 Square,
23 Saw,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Voice {
29 pub wave: Wave,
30 pub freqs: &'static [f32],
32 pub honk_secs: f32,
33 pub gap_secs: f32,
34 pub attack_secs: f32,
35 pub release_secs: f32,
36 pub sweep: [(f32, f32); 3],
38 pub vibrato_hz: f32,
39 pub vibrato_depth: f32,
40 pub noise: f32,
42 pub cutoff_hz: f32,
44}
45
46impl Voice {
47 pub fn pitch_at(&self, t: f32) -> f32 {
49 let [(t0, p0), (t1, p1), (t2, p2)] = self.sweep;
50 let t = t.clamp(0.0, 1.0);
51 if t <= t1 {
52 p0 + (p1 - p0) * ((t - t0) / (t1 - t0))
53 } else {
54 p1 + (p2 - p1) * ((t - t1) / (t2 - t1))
55 }
56 }
57}
58
59const FLAT: [(f32, f32); 3] = [(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)];
60
61impl Style {
62 pub const ALL: [Style; 5] = [
63 Style::Bulb,
64 Style::Awooga,
65 Style::Car,
66 Style::Truck,
67 Style::Clown,
68 ];
69
70 pub fn name(self) -> &'static str {
71 match self {
72 Style::Bulb => "bulb",
73 Style::Awooga => "awooga",
74 Style::Car => "car",
75 Style::Truck => "truck",
76 Style::Clown => "clown",
77 }
78 }
79
80 pub fn voice(self) -> Voice {
81 match self {
82 Style::Bulb => Voice {
83 wave: Wave::Square,
84 freqs: &[370.0],
85 honk_secs: 0.22,
86 gap_secs: 0.12,
87 attack_secs: 0.01,
88 release_secs: 0.05,
89 sweep: FLAT,
90 vibrato_hz: 0.0,
91 vibrato_depth: 0.0,
92 noise: 0.08,
93 cutoff_hz: 2500.0,
94 },
95 Style::Awooga => Voice {
96 wave: Wave::Saw,
97 freqs: &[220.0],
98 honk_secs: 0.6,
99 gap_secs: 0.2,
100 attack_secs: 0.03,
101 release_secs: 0.1,
102 sweep: [(0.0, 0.75), (0.35, 1.25), (1.0, 0.95)],
103 vibrato_hz: 0.0,
104 vibrato_depth: 0.0,
105 noise: 0.02,
106 cutoff_hz: 1800.0,
107 },
108 Style::Car => Voice {
109 wave: Wave::Saw,
110 freqs: &[415.0, 523.0],
112 honk_secs: 0.35,
113 gap_secs: 0.1,
114 attack_secs: 0.01,
115 release_secs: 0.04,
116 sweep: FLAT,
117 vibrato_hz: 0.0,
118 vibrato_depth: 0.0,
119 noise: 0.0,
120 cutoff_hz: 3000.0,
121 },
122 Style::Truck => Voice {
123 wave: Wave::Saw,
124 freqs: &[140.0, 141.5, 175.0],
126 honk_secs: 0.7,
127 gap_secs: 0.18,
128 attack_secs: 0.04,
129 release_secs: 0.12,
130 sweep: FLAT,
131 vibrato_hz: 0.0,
132 vibrato_depth: 0.0,
133 noise: 0.01,
134 cutoff_hz: 1500.0,
135 },
136 Style::Clown => Voice {
137 wave: Wave::Square,
138 freqs: &[880.0],
139 honk_secs: 0.15,
140 gap_secs: 0.08,
141 attack_secs: 0.005,
142 release_secs: 0.03,
143 sweep: [(0.0, 1.0), (0.5, 1.15), (1.0, 0.9)],
144 vibrato_hz: 12.0,
145 vibrato_depth: 0.04,
146 noise: 0.03,
147 cutoff_hz: 4000.0,
148 },
149 }
150 }
151}
152
153impl FromStr for Style {
154 type Err = String;
155
156 fn from_str(s: &str) -> Result<Self, Self::Err> {
157 let lower = s.to_ascii_lowercase();
158 Style::ALL
159 .into_iter()
160 .find(|st| st.name() == lower)
161 .ok_or_else(|| {
162 format!("unknown style '{s}' (choose one of: bulb, awooga, car, truck, clown)")
163 })
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn parses_every_style_by_name() {
173 for s in Style::ALL {
174 assert_eq!(s.name().parse::<Style>().unwrap(), s);
175 }
176 assert_eq!("AWOOGA".parse::<Style>().unwrap(), Style::Awooga);
177 }
178
179 #[test]
180 fn unknown_style_lists_the_options() {
181 let err = "kazoo".parse::<Style>().unwrap_err();
182 assert!(err.contains("bulb, awooga, car, truck, clown"), "{err}");
183 }
184
185 #[test]
186 fn default_is_car() {
187 assert_eq!(Style::default(), Style::Car);
188 }
189
190 #[test]
191 fn every_voice_is_sane() {
192 for s in Style::ALL {
193 let v = s.voice();
194 assert!(!v.freqs.is_empty(), "{s:?}");
195 assert!(v.honk_secs > v.attack_secs + v.release_secs, "{s:?}");
196 assert!(v.gap_secs > 0.0 && v.cutoff_hz > 100.0, "{s:?}");
197 assert_eq!(v.sweep[0].0, 0.0);
198 assert_eq!(v.sweep[2].0, 1.0);
199 }
200 }
201
202 #[test]
203 fn sweep_interpolates_between_points() {
204 let v = Style::Awooga.voice();
205 assert!((v.pitch_at(0.0) - v.sweep[0].1).abs() < 1e-6);
206 assert!((v.pitch_at(v.sweep[1].0) - v.sweep[1].1).abs() < 1e-6);
207 assert!((v.pitch_at(1.0) - v.sweep[2].1).abs() < 1e-6);
208 }
209}