Skip to main content

honk_core/
style.rs

1//! Horn styles and the synthesis preset ("voice") behind each one.
2
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7/// A horn style the user can pick with `--style`.
8#[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/// Oscillator waveform.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Wave {
22    Square,
23    Saw,
24}
25
26/// Synthesis parameters for one style.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Voice {
29    pub wave: Wave,
30    /// Base frequencies in Hz, sounded together (two tones = dual-tone horn).
31    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    /// Pitch multiplier over one honk: (position 0..=1, multiplier), three points.
37    pub sweep: [(f32, f32); 3],
38    pub vibrato_hz: f32,
39    pub vibrato_depth: f32,
40    /// Noise mix 0..=1, the rasp of a rubber bulb.
41    pub noise: f32,
42    /// One-pole low-pass cutoff in Hz.
43    pub cutoff_hz: f32,
44}
45
46impl Voice {
47    /// Pitch multiplier at position `t` (0..=1) through a honk, linear between sweep points.
48    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                // G#4 and C5: a major third, like a real dual-tone car horn.
111                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                // Two nearly equal tones beat slowly; the third adds body.
125                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}