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}