Skip to main content

rustmotion_core/audio/
synth.rs

1use std::collections::HashMap;
2
3use crate::schema::time::TimeCtx;
4
5use super::dsp;
6use super::score::{Score, ScoreError};
7
8pub const SYNTH_SAMPLE_RATE: u32 = 48_000;
9
10pub fn render(score: &Score, ctx: TimeCtx, duration_secs: f64) -> Result<Vec<f32>, ScoreError> {
11    let duration_secs = duration_secs.max(0.0);
12    let num_samples = (duration_secs * SYNTH_SAMPLE_RATE as f64).ceil() as usize;
13    let mut mono = vec![0.0f32; num_samples];
14
15    let hits = score.resolve_hits(&ctx, duration_secs)?;
16
17    let mut voice_names: Vec<&String> = hits.keys().collect();
18    voice_names.sort();
19
20    let mut grains: HashMap<&str, Vec<f32>> = HashMap::new();
21    for name in &voice_names {
22        if let Some(voice) = score.voices.get(*name) {
23            grains.insert(name.as_str(), voice.render_grain(SYNTH_SAMPLE_RATE));
24        }
25    }
26
27    for name in &voice_names {
28        let times = &hits[*name];
29        let Some(grain) = grains.get(name.as_str()) else {
30            continue;
31        };
32        for &t in times {
33            if t < 0.0 {
34                continue;
35            }
36            let start = (t * SYNTH_SAMPLE_RATE as f64).round() as i64;
37            for (i, &s) in grain.iter().enumerate() {
38                let idx = start + i as i64;
39                if idx < 0 {
40                    continue;
41                }
42                let idx = idx as usize;
43                if idx >= mono.len() {
44                    break;
45                }
46                mono[idx] += s;
47            }
48        }
49    }
50
51    if let Some(gain_db) = score.master.gain {
52        let gain = 10f32.powf(gain_db / 20.0);
53        for sample in mono.iter_mut() {
54            *sample *= gain;
55        }
56    }
57    if let Some(comp) = &score.master.compressor {
58        dsp::apply_compressor(
59            &mut mono,
60            dsp::CompressorParams {
61                threshold_db: comp.threshold,
62                ratio: comp.ratio,
63                attack_ms: comp.attack,
64                release_ms: comp.release,
65            },
66            SYNTH_SAMPLE_RATE,
67        );
68    }
69    if score.master.limiter {
70        dsp::apply_limiter(&mut mono, SYNTH_SAMPLE_RATE);
71    }
72
73    let mut stereo = Vec::with_capacity(mono.len() * 2);
74    for sample in mono {
75        stereo.push(sample);
76        stereo.push(sample);
77    }
78    Ok(stereo)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::audio::score::{CompressorConfig, MasterBus, ScoreEvent};
85    use crate::audio::voices::{FilterKind, FilterSpec, FreqSpec, OscKind, Voice};
86    use crate::schema::time::TimePoint;
87
88    fn issue_example_score() -> Score {
89        let mut voices = HashMap::new();
90        voices.insert(
91            "kick".to_string(),
92            Voice {
93                kind: OscKind::Sine,
94                freq: Some(FreqSpec::Sweep([150.0, 42.0])),
95                sweep: Some(0.14),
96                filter: None,
97                attack: 0.002,
98                decay: 0.38,
99                sustain: 0.0,
100                hold: 0.0,
101                release: 0.0,
102                gain: 1.0,
103                seed: None,
104            },
105        );
106        voices.insert(
107            "hat".to_string(),
108            Voice {
109                kind: OscKind::Noise,
110                freq: None,
111                sweep: None,
112                filter: Some(FilterSpec {
113                    kind: FilterKind::Highpass,
114                    freq: 7500.0,
115                    q: 0.707,
116                }),
117                attack: 0.002,
118                decay: 0.05,
119                sustain: 0.0,
120                hold: 0.0,
121                release: 0.0,
122                gain: 1.0,
123                seed: Some(11),
124            },
125        );
126        Score {
127            voices,
128            score: vec![
129                ScoreEvent {
130                    voice: "kick".to_string(),
131                    at: None,
132                    every: Some(TimePoint::Spec("1b".to_string())),
133                    from: Some(TimePoint::Spec("@2.2s".to_string())),
134                    to: Some(TimePoint::Spec("@12.6s".to_string())),
135                    offset: None,
136                    gain: None,
137                },
138                ScoreEvent {
139                    voice: "hat".to_string(),
140                    at: None,
141                    every: Some(TimePoint::Spec("1b".to_string())),
142                    from: Some(TimePoint::Spec("@2.2s".to_string())),
143                    to: None,
144                    offset: Some(TimePoint::Spec("0.5b".to_string())),
145                    gain: None,
146                },
147            ],
148            master: MasterBus {
149                gain: None,
150                compressor: Some(CompressorConfig {
151                    threshold: -14.0,
152                    ratio: 4.0,
153                    attack: 5.0,
154                    release: 50.0,
155                }),
156                limiter: true,
157            },
158        }
159    }
160
161    fn ctx() -> TimeCtx {
162        TimeCtx {
163            bpm: Some(115.0),
164            beat_offset: 0.0,
165            scene_start: 0.0,
166        }
167    }
168
169    #[test]
170    fn render_produces_a_nonsilent_stereo_buffer_of_the_requested_length() {
171        let buf = render(&issue_example_score(), ctx(), 13.0).expect("render must succeed");
172        assert_eq!(
173            buf.len(),
174            (13.0 * SYNTH_SAMPLE_RATE as f64).ceil() as usize * 2
175        );
176        assert!(buf.iter().any(|&s| s.abs() > 0.01), "must not be silent");
177    }
178
179    #[test]
180    fn render_is_deterministic_across_two_calls() {
181        let a = render(&issue_example_score(), ctx(), 13.0).unwrap();
182        let b = render(&issue_example_score(), ctx(), 13.0).unwrap();
183        assert_eq!(a, b);
184    }
185
186    #[test]
187    fn render_with_limiter_on_never_exceeds_the_limiter_ceiling() {
188        let buf = render(&issue_example_score(), ctx(), 13.0).unwrap();
189        let ceiling = 10f32.powf(dsp::LIMITER_CEILING_DB / 20.0);
190        assert!(buf.iter().all(|&s| s.abs() <= ceiling + 1e-6));
191    }
192
193    #[test]
194    fn stereo_channels_are_identical_mono_duplicated() {
195        let buf = render(&issue_example_score(), ctx(), 13.0).unwrap();
196        for pair in buf.as_chunks::<2>().0 {
197            assert_eq!(pair[0], pair[1]);
198        }
199    }
200
201    #[test]
202    fn unknown_voice_propagates_as_a_score_error() {
203        let mut score = issue_example_score();
204        score.score.push(ScoreEvent {
205            voice: "cowbell".to_string(),
206            at: Some(TimePoint::Seconds(1.0)),
207            every: None,
208            from: None,
209            to: None,
210            offset: None,
211            gain: None,
212        });
213        let err = render(&score, ctx(), 13.0).unwrap_err();
214        assert!(matches!(err, ScoreError::UnknownVoice(v) if v == "cowbell"));
215    }
216}