use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Style {
#[default]
Bulb,
Awooga,
Car,
Truck,
Clown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wave {
Square,
Saw,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Voice {
pub wave: Wave,
pub freqs: &'static [f32],
pub honk_secs: f32,
pub gap_secs: f32,
pub attack_secs: f32,
pub release_secs: f32,
pub sweep: [(f32, f32); 3],
pub vibrato_hz: f32,
pub vibrato_depth: f32,
pub noise: f32,
pub cutoff_hz: f32,
}
impl Voice {
pub fn pitch_at(&self, t: f32) -> f32 {
let [(t0, p0), (t1, p1), (t2, p2)] = self.sweep;
let t = t.clamp(0.0, 1.0);
if t <= t1 {
p0 + (p1 - p0) * ((t - t0) / (t1 - t0))
} else {
p1 + (p2 - p1) * ((t - t1) / (t2 - t1))
}
}
}
const FLAT: [(f32, f32); 3] = [(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)];
impl Style {
pub const ALL: [Style; 5] = [
Style::Bulb,
Style::Awooga,
Style::Car,
Style::Truck,
Style::Clown,
];
pub fn name(self) -> &'static str {
match self {
Style::Bulb => "bulb",
Style::Awooga => "awooga",
Style::Car => "car",
Style::Truck => "truck",
Style::Clown => "clown",
}
}
pub fn voice(self) -> Voice {
match self {
Style::Bulb => Voice {
wave: Wave::Square,
freqs: &[370.0],
honk_secs: 0.22,
gap_secs: 0.12,
attack_secs: 0.01,
release_secs: 0.05,
sweep: FLAT,
vibrato_hz: 0.0,
vibrato_depth: 0.0,
noise: 0.08,
cutoff_hz: 2500.0,
},
Style::Awooga => Voice {
wave: Wave::Saw,
freqs: &[220.0],
honk_secs: 0.6,
gap_secs: 0.2,
attack_secs: 0.03,
release_secs: 0.1,
sweep: [(0.0, 0.75), (0.35, 1.25), (1.0, 0.95)],
vibrato_hz: 0.0,
vibrato_depth: 0.0,
noise: 0.02,
cutoff_hz: 1800.0,
},
Style::Car => Voice {
wave: Wave::Saw,
freqs: &[415.0, 523.0],
honk_secs: 0.35,
gap_secs: 0.1,
attack_secs: 0.01,
release_secs: 0.04,
sweep: FLAT,
vibrato_hz: 0.0,
vibrato_depth: 0.0,
noise: 0.0,
cutoff_hz: 3000.0,
},
Style::Truck => Voice {
wave: Wave::Saw,
freqs: &[140.0, 141.5, 175.0],
honk_secs: 0.7,
gap_secs: 0.18,
attack_secs: 0.04,
release_secs: 0.12,
sweep: FLAT,
vibrato_hz: 0.0,
vibrato_depth: 0.0,
noise: 0.01,
cutoff_hz: 1500.0,
},
Style::Clown => Voice {
wave: Wave::Square,
freqs: &[880.0],
honk_secs: 0.15,
gap_secs: 0.08,
attack_secs: 0.005,
release_secs: 0.03,
sweep: [(0.0, 1.0), (0.5, 1.15), (1.0, 0.9)],
vibrato_hz: 12.0,
vibrato_depth: 0.04,
noise: 0.03,
cutoff_hz: 4000.0,
},
}
}
}
impl FromStr for Style {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lower = s.to_ascii_lowercase();
Style::ALL
.into_iter()
.find(|st| st.name() == lower)
.ok_or_else(|| {
format!("unknown style '{s}' (choose one of: bulb, awooga, car, truck, clown)")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_style_by_name() {
for s in Style::ALL {
assert_eq!(s.name().parse::<Style>().unwrap(), s);
}
assert_eq!("AWOOGA".parse::<Style>().unwrap(), Style::Awooga);
}
#[test]
fn unknown_style_lists_the_options() {
let err = "kazoo".parse::<Style>().unwrap_err();
assert!(err.contains("bulb, awooga, car, truck, clown"), "{err}");
}
#[test]
fn default_is_bulb() {
assert_eq!(Style::default(), Style::Bulb);
}
#[test]
fn every_voice_is_sane() {
for s in Style::ALL {
let v = s.voice();
assert!(!v.freqs.is_empty(), "{s:?}");
assert!(v.honk_secs > v.attack_secs + v.release_secs, "{s:?}");
assert!(v.gap_secs > 0.0 && v.cutoff_hz > 100.0, "{s:?}");
assert_eq!(v.sweep[0].0, 0.0);
assert_eq!(v.sweep[2].0, 1.0);
}
}
#[test]
fn sweep_interpolates_between_points() {
let v = Style::Awooga.voice();
assert!((v.pitch_at(0.0) - v.sweep[0].1).abs() < 1e-6);
assert!((v.pitch_at(v.sweep[1].0) - v.sweep[1].1).abs() < 1e-6);
assert!((v.pitch_at(1.0) - v.sweep[2].1).abs() < 1e-6);
}
}