Skip to main content

honk_core/
synth.rs

1//! The honk synthesiser: turns a [`HonkSpec`] into mono `f32` samples.
2//!
3//! Each honk is one or more oscillators (sawtooth or square) with a pitch
4//! sweep, optional vibrato and noise, through a one-pole low-pass filter, an
5//! attack/release envelope and soft clipping. Output is deterministic.
6
7use std::f32::consts::TAU;
8
9use crate::spec::{HonkSpec, Mood};
10use crate::style::{Voice, Wave};
11
12pub const SAMPLE_RATE: u32 = 44_100;
13
14const SR: f32 = SAMPLE_RATE as f32;
15/// The last honk is this much longer when sad.
16const SAD_STRETCH: f32 = 1.6;
17/// Sad bends the last honk down to this pitch multiplier.
18const SAD_END_PITCH: f32 = 0.6;
19/// Happy bends up over the last part of the last honk.
20const HAPPY_FROM: f32 = 0.6;
21const HAPPY_END_PITCH: f32 = 1.3;
22/// Soft clip drive; output is normalised so a full-scale input maps to 1.0.
23const DRIVE: f32 = 1.5;
24
25fn secs_to_samples(secs: f32) -> usize {
26    (secs * SR).round() as usize
27}
28
29fn honk_secs(spec: &HonkSpec, voice: &Voice, last: bool) -> f32 {
30    let mut secs = voice.honk_secs;
31    if spec.long {
32        secs *= 2.0;
33    }
34    if last && spec.mood == Mood::Sad {
35        secs *= SAD_STRETCH;
36    }
37    secs
38}
39
40/// Total length of [`render`]'s output in seconds.
41pub fn duration_secs(spec: &HonkSpec) -> f32 {
42    let voice = spec.style.voice();
43    let samples: usize = (0..spec.times)
44        .map(|i| secs_to_samples(honk_secs(spec, &voice, i + 1 == spec.times)))
45        .sum::<usize>()
46        + spec.times.saturating_sub(1) as usize * secs_to_samples(voice.gap_secs);
47    samples as f32 / SR
48}
49
50/// Mood pitch bend at position `t` (0..=1) of the last honk.
51fn mood_bend(mood: Mood, t: f32) -> f32 {
52    match mood {
53        Mood::Neutral => 1.0,
54        Mood::Sad => 1.0 + (SAD_END_PITCH - 1.0) * t,
55        Mood::Happy if t > HAPPY_FROM => {
56            1.0 + (HAPPY_END_PITCH - 1.0) * ((t - HAPPY_FROM) / (1.0 - HAPPY_FROM))
57        }
58        Mood::Happy => 1.0,
59    }
60}
61
62fn oscillator(wave: Wave, phase: f32) -> f32 {
63    match wave {
64        Wave::Saw => 2.0 * phase - 1.0,
65        Wave::Square => {
66            if phase < 0.5 {
67                1.0
68            } else {
69                -1.0
70            }
71        }
72    }
73}
74
75/// Tiny deterministic noise source (xorshift), so renders are reproducible.
76struct Noise(u32);
77
78impl Noise {
79    fn next(&mut self) -> f32 {
80        self.0 ^= self.0 << 13;
81        self.0 ^= self.0 >> 17;
82        self.0 ^= self.0 << 5;
83        (self.0 as f32 / u32::MAX as f32) * 2.0 - 1.0
84    }
85}
86
87/// Render the honk to mono samples at [`SAMPLE_RATE`].
88pub fn render(spec: &HonkSpec) -> Vec<f32> {
89    let voice = spec.style.voice();
90    let gap = secs_to_samples(voice.gap_secs);
91    let mut out = Vec::new();
92    for i in 0..spec.times {
93        let last = i + 1 == spec.times;
94        if i > 0 {
95            out.extend(std::iter::repeat_n(0.0, gap));
96        }
97        let mood = if last { spec.mood } else { Mood::Neutral };
98        let n = secs_to_samples(honk_secs(spec, &voice, last));
99        render_honk(&mut out, spec, &voice, n, mood);
100    }
101    out
102}
103
104fn render_honk(out: &mut Vec<f32>, spec: &HonkSpec, voice: &Voice, n: usize, mood: Mood) {
105    let attack = secs_to_samples(voice.attack_secs).max(1);
106    let release = secs_to_samples(voice.release_secs).max(1);
107    let alpha = 1.0 - (-TAU * voice.cutoff_hz / SR).exp();
108    let norm = DRIVE.tanh();
109    let mut phases = vec![0.0f32; voice.freqs.len()];
110    let mut noise = Noise(0x9E37_79B9);
111    let mut lp = 0.0f32;
112
113    for j in 0..n {
114        let t = j as f32 / n as f32;
115        let vibrato = 1.0 + voice.vibrato_depth * (TAU * voice.vibrato_hz * j as f32 / SR).sin();
116        let pitch = spec.pitch * voice.pitch_at(t) * mood_bend(mood, t) * vibrato;
117
118        let mut x = 0.0;
119        for (phase, &f) in phases.iter_mut().zip(voice.freqs) {
120            x += oscillator(voice.wave, *phase);
121            *phase = (*phase + f * pitch / SR).fract();
122        }
123        x /= voice.freqs.len() as f32;
124        x = x * (1.0 - voice.noise) + noise.next() * voice.noise;
125
126        lp += alpha * (x - lp);
127
128        let env = if j < attack {
129            j as f32 / attack as f32
130        } else if j + release >= n {
131            (n - 1 - j) as f32 / release as f32
132        } else {
133            1.0
134        };
135
136        out.push((lp * DRIVE).tanh() / norm * env * spec.volume);
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::spec::{HonkSpec, Mood};
144    use crate::style::Style;
145
146    fn spec(style: Style) -> HonkSpec {
147        HonkSpec {
148            style,
149            ..HonkSpec::default()
150        }
151    }
152
153    /// Split into bursts: runs of sound separated by at least 100 exact-zero samples.
154    fn bursts(samples: &[f32]) -> Vec<(usize, usize)> {
155        let mut out = Vec::new();
156        let (mut start, mut zeros) = (None, 0usize);
157        for (i, &s) in samples.iter().enumerate() {
158            if s == 0.0 {
159                zeros += 1;
160                if zeros == 100
161                    && let Some(st) = start.take()
162                {
163                    out.push((st, i + 1 - 100));
164                }
165            } else {
166                zeros = 0;
167                start.get_or_insert(i);
168            }
169        }
170        if let Some(st) = start {
171            out.push((st, samples.len()));
172        }
173        out
174    }
175
176    /// Positive-going zero crossings in samples[a..b], a pitch proxy.
177    fn crossings(samples: &[f32], a: usize, b: usize) -> usize {
178        samples[a..b]
179            .windows(2)
180            .filter(|w| w[0] < 0.0 && w[1] >= 0.0)
181            .count()
182    }
183
184    #[test]
185    fn bulb_has_the_expected_length() {
186        // 2 honks of 0.22 s with one 0.12 s gap.
187        assert_eq!(render(&spec(Style::Bulb)).len(), 2 * 9702 + 5292);
188    }
189
190    #[test]
191    fn long_doubles_each_honk() {
192        let s = HonkSpec {
193            long: true,
194            ..spec(Style::Bulb)
195        };
196        assert_eq!(render(&s).len(), 2 * 19404 + 5292);
197    }
198
199    #[test]
200    fn duration_matches_render() {
201        for style in Style::ALL {
202            let s = HonkSpec {
203                times: 3,
204                mood: Mood::Sad,
205                ..spec(style)
206            };
207            let secs = render(&s).len() as f32 / SAMPLE_RATE as f32;
208            assert!((secs - duration_secs(&s)).abs() < 0.001, "{style:?}");
209        }
210    }
211
212    #[test]
213    fn every_style_stays_within_volume_and_is_finite() {
214        for style in Style::ALL {
215            for volume in [0.0, 0.3, 1.0] {
216                let s = HonkSpec {
217                    volume,
218                    times: 2,
219                    ..spec(style)
220                };
221                let samples = render(&s);
222                assert!(!samples.is_empty());
223                assert!(samples.iter().all(|x| x.is_finite()), "{style:?}");
224                let peak = samples.iter().fold(0.0f32, |m, x| m.max(x.abs()));
225                assert!(peak <= volume + 1e-6, "{style:?} peak {peak} > {volume}");
226                if volume > 0.0 {
227                    assert!(peak > volume * 0.3, "{style:?} is too quiet: {peak}");
228                }
229            }
230        }
231    }
232
233    #[test]
234    fn times_gives_that_many_bursts() {
235        for style in Style::ALL {
236            for times in [1, 3, 5] {
237                let s = HonkSpec {
238                    times,
239                    ..spec(style)
240                };
241                assert_eq!(
242                    bursts(&render(&s)).len(),
243                    times as usize,
244                    "{style:?} x{times}"
245                );
246            }
247        }
248    }
249
250    #[test]
251    fn rendering_is_deterministic() {
252        let s = spec(Style::Clown);
253        assert_eq!(render(&s), render(&s));
254    }
255
256    #[test]
257    fn sad_bends_down_and_is_longer() {
258        let neutral = render(&HonkSpec {
259            times: 1,
260            ..spec(Style::Bulb)
261        });
262        let sad = render(&HonkSpec {
263            times: 1,
264            mood: Mood::Sad,
265            ..spec(Style::Bulb)
266        });
267        assert!(sad.len() > neutral.len());
268        let n = sad.len();
269        let start = crossings(&sad, n / 10, n * 3 / 10);
270        let end = crossings(&sad, n * 7 / 10, n * 9 / 10);
271        assert!(
272            (end as f32) < start as f32 * 0.85,
273            "start {start}, end {end}"
274        );
275    }
276
277    #[test]
278    fn happy_bends_up_at_the_end() {
279        let happy = render(&HonkSpec {
280            times: 1,
281            mood: Mood::Happy,
282            ..spec(Style::Bulb)
283        });
284        let n = happy.len();
285        let start = crossings(&happy, n / 10, n * 3 / 10);
286        let end = crossings(&happy, n * 75 / 100, n * 95 / 100);
287        assert!(end as f32 > start as f32 * 1.08, "start {start}, end {end}");
288    }
289
290    #[test]
291    fn only_the_last_honk_carries_the_mood() {
292        let neutral = render(&HonkSpec {
293            times: 2,
294            ..spec(Style::Car)
295        });
296        let sad = render(&HonkSpec {
297            times: 2,
298            mood: Mood::Sad,
299            ..spec(Style::Car)
300        });
301        let first = bursts(&neutral)[0];
302        assert_eq!(&neutral[first.0..first.1], &sad[first.0..first.1]);
303    }
304
305    #[test]
306    fn higher_pitch_means_more_crossings() {
307        let low = render(&HonkSpec {
308            times: 1,
309            pitch: 0.5,
310            ..spec(Style::Bulb)
311        });
312        let high = render(&HonkSpec {
313            times: 1,
314            pitch: 2.0,
315            ..spec(Style::Bulb)
316        });
317        assert!(crossings(&high, 0, high.len()) > 3 * crossings(&low, 0, low.len()));
318    }
319}