Skip to main content

rustmotion_core/audio/
voices.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::dsp;
5
6pub use dsp::{FilterKind, OscKind};
7
8/// A voice's frequency: either fixed (a sustained tone), or a two-point
9/// sweep — `"freq": [150, 42]` in the issue's `kick` example, read start-to-end
10/// against `sweep` (seconds). Linear interpolation, not exponential: simpler,
11/// and "small, boring synth" (deliverable #2) does not ask for a
12/// perceptually-uniform pitch glide.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
14#[serde(untagged)]
15pub enum FreqSpec {
16    Fixed(f32),
17    Sweep([f32; 2]),
18}
19
20fn default_q() -> f32 {
21    0.707
22}
23
24/// `voices.hat.filter` in the issue's example
25/// (`{"type":"highpass","freq":7500}`).
26#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct FilterSpec {
29    #[serde(rename = "type")]
30    pub kind: FilterKind,
31    pub freq: f32,
32    #[serde(default = "default_q")]
33    pub q: f32,
34}
35
36fn default_attack() -> f32 {
37    0.002
38}
39
40fn default_gain() -> f32 {
41    1.0
42}
43
44/// One instrument: an oscillator or a noise source, an optional filter,
45/// and an ADSR envelope (`attack`/`decay`/`sustain`/`hold`/`release` — see
46/// [`dsp::Adsr`]'s doc for why `hold` stands in for a note-off this score
47/// model never sends). Every field but `type` and `decay` has a
48/// drum-machine-flavoured default, so the issue's own two-voice example
49/// (`"kick": {"type":"sine","freq":[150,42],"sweep":0.14,"decay":0.38}`)
50/// needs nothing else.
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct Voice {
54    #[serde(rename = "type")]
55    pub kind: OscKind,
56    /// Required for every `type` but `noise`; ignored for `noise`. Falls
57    /// back to 440Hz if omitted on a tonal voice rather than erroring —
58    /// consistent with this module's general posture (see
59    /// [`dsp::BiquadCoeffs::design`]'s doc) of making an underspecified
60    /// voice audible rather than rejecting it.
61    #[serde(default)]
62    pub freq: Option<FreqSpec>,
63    /// Seconds the `freq` sweep takes to go from its first to its second
64    /// value. Ignored unless `freq` is [`FreqSpec::Sweep`].
65    #[serde(default)]
66    pub sweep: Option<f32>,
67    #[serde(default)]
68    pub filter: Option<FilterSpec>,
69    #[serde(default = "default_attack")]
70    pub attack: f32,
71    #[serde(default)]
72    pub decay: f32,
73    #[serde(default)]
74    pub sustain: f32,
75    #[serde(default)]
76    pub hold: f32,
77    #[serde(default)]
78    pub release: f32,
79    #[serde(default = "default_gain")]
80    pub gain: f32,
81    /// Seeds this voice's [`dsp::Xorshift32`] noise source. Two `noise`
82    /// voices with no explicit seed share the same fixed default and so
83    /// sound identical to each other — a minor aesthetic simplification
84    /// documented here rather than hidden; give each its own `seed` to
85    /// tell them apart.
86    #[serde(default)]
87    pub seed: Option<u32>,
88}
89
90impl Voice {
91    fn envelope(&self) -> dsp::Adsr {
92        dsp::Adsr {
93            attack: self.attack.max(0.0),
94            decay: self.decay.max(0.0),
95            sustain: self.sustain.clamp(0.0, 1.0),
96            hold: self.hold.max(0.0),
97            release: self.release.max(0.0),
98        }
99    }
100
101    pub fn render_grain(&self, sample_rate: u32) -> Vec<f32> {
102        let envelope = self.envelope();
103        let sr = sample_rate as f32;
104        let total = envelope.total_duration().max(1.0 / sr);
105        let n = (total * sr).ceil() as usize;
106
107        let mut filter = self.filter.map(|f| {
108            (
109                dsp::BiquadCoeffs::design(f.kind, f.freq, f.q, sr),
110                dsp::BiquadState::default(),
111            )
112        });
113        let mut noise = dsp::Xorshift32::new(self.seed.unwrap_or(0x1234_5678));
114
115        let (f0, f1, sweep) = match self.freq {
116            Some(FreqSpec::Fixed(f)) => (f, f, 0.0),
117            Some(FreqSpec::Sweep([a, b])) => (a, b, self.sweep.unwrap_or(0.0).max(0.0)),
118            None => (440.0, 440.0, 0.0),
119        };
120
121        let mut phase = 0.0f32;
122        let mut out = Vec::with_capacity(n);
123        for i in 0..n {
124            let t = i as f32 / sr;
125            let freq = if sweep > 0.0 && t < sweep {
126                f0 + (f1 - f0) * (t / sweep)
127            } else {
128                f1
129            };
130
131            let mut sample = match self.kind {
132                OscKind::Sine => dsp::sine(phase),
133                OscKind::Square => dsp::square(phase),
134                OscKind::Saw => dsp::saw(phase),
135                OscKind::Triangle => dsp::triangle(phase),
136                OscKind::Noise => noise.next_f32(),
137            };
138            if self.kind != OscKind::Noise {
139                phase = (phase + freq / sr).rem_euclid(1.0);
140            }
141
142            if let Some((coeffs, state)) = filter.as_mut() {
143                sample = coeffs.process(state, sample);
144            }
145
146            out.push(sample * envelope.level_at(t) * self.gain);
147        }
148        out
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn kick() -> Voice {
157        Voice {
158            kind: OscKind::Sine,
159            freq: Some(FreqSpec::Sweep([150.0, 42.0])),
160            sweep: Some(0.14),
161            filter: None,
162            attack: default_attack(),
163            decay: 0.38,
164            sustain: 0.0,
165            hold: 0.0,
166            release: 0.0,
167            gain: default_gain(),
168            seed: None,
169        }
170    }
171
172    fn hat() -> Voice {
173        Voice {
174            kind: OscKind::Noise,
175            freq: None,
176            sweep: None,
177            filter: Some(FilterSpec {
178                kind: FilterKind::Highpass,
179                freq: 7500.0,
180                q: default_q(),
181            }),
182            attack: default_attack(),
183            decay: 0.05,
184            sustain: 0.0,
185            hold: 0.0,
186            release: 0.0,
187            gain: default_gain(),
188            seed: Some(7),
189        }
190    }
191
192    #[test]
193    fn kick_grain_starts_and_ends_at_silence_and_is_within_range() {
194        let grain = kick().render_grain(48_000);
195        assert!(!grain.is_empty());
196        assert!(
197            grain[0].abs() < 1e-3,
198            "grain must start near silence (attack ramp)"
199        );
200        assert!(
201            grain.last().unwrap().abs() < 1e-3,
202            "grain must decay to silence by the end of its envelope"
203        );
204        assert!(grain.iter().all(|s| s.abs() <= 1.0 + 1e-3));
205    }
206
207    #[test]
208    fn hat_grain_is_filtered_noise_not_silence() {
209        let grain = hat().render_grain(48_000);
210        assert!(!grain.is_empty());
211        assert!(
212            grain.iter().any(|&s| s.abs() > 0.01),
213            "a highpassed noise burst must not be silent"
214        );
215    }
216
217    #[test]
218    fn voice_deserializes_from_the_issues_json_shape() {
219        let json = serde_json::json!({
220            "type": "sine",
221            "freq": [150, 42],
222            "sweep": 0.14,
223            "decay": 0.38
224        });
225        let voice: Voice = serde_json::from_value(json).expect("kick voice parses");
226        assert_eq!(voice.kind, OscKind::Sine);
227        assert_eq!(voice.freq, Some(FreqSpec::Sweep([150.0, 42.0])));
228        assert_eq!(voice.decay, 0.38);
229        assert_eq!(voice.attack, default_attack());
230
231        let json = serde_json::json!({
232            "type": "noise",
233            "filter": {"type": "highpass", "freq": 7500},
234            "decay": 0.05
235        });
236        let voice: Voice = serde_json::from_value(json).expect("hat voice parses");
237        assert_eq!(voice.kind, OscKind::Noise);
238        assert_eq!(voice.filter.unwrap().freq, 7500.0);
239    }
240
241    #[test]
242    fn rendering_the_same_voice_twice_is_byte_identical() {
243        let a = hat().render_grain(48_000);
244        let b = hat().render_grain(48_000);
245        assert_eq!(a, b);
246    }
247}