1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::schema::time::{TimeCtx, TimeError, TimePoint};
7
8use super::voices::Voice;
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct ScoreEvent {
26 pub voice: String,
28 #[serde(default)]
29 pub at: Option<TimePoint>,
30 #[serde(default)]
31 pub every: Option<TimePoint>,
32 #[serde(default)]
33 pub from: Option<TimePoint>,
34 #[serde(default)]
35 pub to: Option<TimePoint>,
36 #[serde(default)]
37 pub offset: Option<TimePoint>,
38 #[serde(default)]
43 pub gain: Option<f32>,
44}
45
46fn default_compressor_attack_ms() -> f32 {
47 5.0
48}
49
50fn default_compressor_release_ms() -> f32 {
51 50.0
52}
53
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct CompressorConfig {
59 pub threshold: f32,
60 pub ratio: f32,
61 #[serde(default = "default_compressor_attack_ms")]
62 pub attack: f32,
63 #[serde(default = "default_compressor_release_ms")]
64 pub release: f32,
65}
66
67fn default_limiter_on() -> bool {
68 true
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct MasterBus {
79 #[serde(default)]
80 pub gain: Option<f32>,
81 #[serde(default)]
82 pub compressor: Option<CompressorConfig>,
83 #[serde(default = "default_limiter_on")]
84 pub limiter: bool,
85}
86
87impl Default for MasterBus {
88 fn default() -> Self {
89 MasterBus {
90 gain: None,
91 compressor: None,
92 limiter: true,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
104#[serde(deny_unknown_fields)]
105pub struct Score {
106 #[serde(default)]
107 pub voices: HashMap<String, Voice>,
108 #[serde(default)]
109 pub score: Vec<ScoreEvent>,
110 #[serde(default)]
111 pub master: MasterBus,
112}
113
114#[derive(Debug, Clone, PartialEq, thiserror::Error)]
115pub enum ScoreError {
116 #[error("score event references unknown voice '{0}' (not declared in `voices`)")]
117 UnknownVoice(String),
118 #[error("score event for voice '{voice}' has neither 'at' nor 'every' — one is required")]
119 MissingTiming { voice: String },
120 #[error(
121 "score event for voice '{voice}': 'every' resolves to a non-positive interval ({interval}s)"
122 )]
123 NonPositiveInterval { voice: String, interval: f64 },
124 #[error(
125 "score event for voice '{voice}' would produce more than {limit} hits — \
126 check 'every'/'from'/'to' for a runaway repeat"
127 )]
128 TooManyHits { voice: String, limit: usize },
129 #[error("score event for voice '{voice}': {source}")]
130 Time {
131 voice: String,
132 #[source]
133 source: TimeError,
134 },
135}
136
137const MAX_HITS_PER_EVENT: usize = 100_000;
138
139impl Score {
140 pub fn is_empty(&self) -> bool {
141 self.voices.is_empty() && self.score.is_empty()
142 }
143
144 pub fn resolve_hits(
145 &self,
146 ctx: &TimeCtx,
147 scenario_duration: f64,
148 ) -> Result<HashMap<String, Vec<f64>>, ScoreError> {
149 let mut hits: HashMap<String, Vec<f64>> = HashMap::new();
150 for event in &self.score {
151 if !self.voices.contains_key(&event.voice) {
152 return Err(ScoreError::UnknownVoice(event.voice.clone()));
153 }
154 let times = resolve_event_hits(event, ctx, scenario_duration)?;
155 hits.entry(event.voice.clone()).or_default().extend(times);
156 }
157 Ok(hits)
158 }
159}
160
161fn resolve_instant(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result<f64, ScoreError> {
162 tp.resolve_relative(ctx).map_err(|source| ScoreError::Time {
163 voice: voice.to_string(),
164 source,
165 })
166}
167
168fn resolve_duration(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result<f64, ScoreError> {
169 let duration_ctx = TimeCtx {
170 beat_offset: 0.0,
171 ..*ctx
172 };
173 tp.resolve_relative(&duration_ctx)
174 .map_err(|source| ScoreError::Time {
175 voice: voice.to_string(),
176 source,
177 })
178}
179
180fn resolve_event_hits(
181 event: &ScoreEvent,
182 ctx: &TimeCtx,
183 scenario_duration: f64,
184) -> Result<Vec<f64>, ScoreError> {
185 if let Some(at) = &event.at {
186 let t = resolve_instant(at, ctx, &event.voice)?;
187 return Ok(vec![t]);
188 }
189
190 let Some(every) = &event.every else {
191 return Err(ScoreError::MissingTiming {
192 voice: event.voice.clone(),
193 });
194 };
195 let interval = resolve_duration(every, ctx, &event.voice)?;
196 if interval <= 0.0 {
197 return Err(ScoreError::NonPositiveInterval {
198 voice: event.voice.clone(),
199 interval,
200 });
201 }
202
203 let from = match &event.from {
204 Some(tp) => resolve_instant(tp, ctx, &event.voice)?,
205 None => 0.0,
206 };
207 let offset = match &event.offset {
208 Some(tp) => resolve_duration(tp, ctx, &event.voice)?,
209 None => 0.0,
210 };
211 let to = match &event.to {
212 Some(tp) => resolve_instant(tp, ctx, &event.voice)?,
213 None => scenario_duration,
214 };
215
216 let start = from + offset;
217 let mut hits = Vec::new();
218 let mut t = start;
219 while t <= to + 1e-9 {
220 if t >= 0.0 {
221 hits.push(t);
222 }
223 if hits.len() > MAX_HITS_PER_EVENT {
224 return Err(ScoreError::TooManyHits {
225 voice: event.voice.clone(),
226 limit: MAX_HITS_PER_EVENT,
227 });
228 }
229 t += interval;
230 }
231 Ok(hits)
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn ctx(bpm: Option<f64>, beat_offset: f64) -> TimeCtx {
239 TimeCtx {
240 bpm,
241 beat_offset,
242 scene_start: 0.0,
243 }
244 }
245
246 fn tp(s: &str) -> TimePoint {
247 TimePoint::Spec(s.to_string())
248 }
249
250 #[test]
251 fn every_1b_is_one_beat_regardless_of_beat_offset() {
252 let c = ctx(Some(120.0), 2.2);
253 let d = resolve_duration(&tp("1b"), &c, "kick").unwrap();
254 assert!(
255 (d - 0.5).abs() < 1e-9,
256 "expected exactly one beat (0.5s), got {d}"
257 );
258 }
259
260 #[test]
261 fn issues_two_voice_example_kick_lands_on_every_beat_from_2_2s_to_12_6s() {
262 let c = ctx(Some(115.0), 0.0);
263 let event = ScoreEvent {
264 voice: "kick".to_string(),
265 at: None,
266 every: Some(tp("1b")),
267 from: Some(tp("@2.2s")),
268 to: Some(tp("@12.6s")),
269 offset: None,
270 gain: None,
271 };
272 let hits = resolve_event_hits(&event, &c, 999.0).unwrap();
273 let beat = 60.0 / 115.0;
274 assert!((hits[0] - 2.2).abs() < 1e-9);
275 assert!((hits[1] - (2.2 + beat)).abs() < 1e-9);
276 assert!(*hits.last().unwrap() <= 12.6 + 1e-6);
277 assert!(*hits.last().unwrap() > 12.6 - beat);
278 }
279
280 #[test]
281 fn hat_with_no_to_plays_until_the_given_scenario_duration() {
282 let c = ctx(Some(115.0), 0.0);
283 let event = ScoreEvent {
284 voice: "hat".to_string(),
285 at: None,
286 every: Some(tp("1b")),
287 from: Some(tp("@2.2s")),
288 to: None,
289 offset: Some(tp("0.5b")),
290 gain: None,
291 };
292 let hits = resolve_event_hits(&event, &c, 10.0).unwrap();
293 let beat = 60.0 / 115.0;
294 assert!((hits[0] - (2.2 + 0.5 * beat)).abs() < 1e-9);
295 assert!(*hits.last().unwrap() <= 10.0 + 1e-6);
296 }
297
298 #[test]
299 fn event_with_neither_at_nor_every_is_a_named_error() {
300 let c = ctx(Some(115.0), 0.0);
301 let event = ScoreEvent {
302 voice: "kick".to_string(),
303 at: None,
304 every: None,
305 from: None,
306 to: None,
307 offset: None,
308 gain: None,
309 };
310 assert_eq!(
311 resolve_event_hits(&event, &c, 10.0),
312 Err(ScoreError::MissingTiming {
313 voice: "kick".to_string()
314 })
315 );
316 }
317
318 #[test]
319 fn unknown_voice_reference_is_a_named_error() {
320 let score = Score {
321 voices: HashMap::new(),
322 score: vec![ScoreEvent {
323 voice: "ghost".to_string(),
324 at: Some(tp("1s")),
325 every: None,
326 from: None,
327 to: None,
328 offset: None,
329 gain: None,
330 }],
331 master: MasterBus::default(),
332 };
333 let c = ctx(None, 0.0);
334 assert_eq!(
335 score.resolve_hits(&c, 10.0),
336 Err(ScoreError::UnknownVoice("ghost".to_string()))
337 );
338 }
339
340 #[test]
341 fn runaway_interval_is_capped_not_infinite() {
342 let c = ctx(None, 0.0);
343 let event = ScoreEvent {
344 voice: "kick".to_string(),
345 at: None,
346 every: Some(TimePoint::Seconds(0.0001)),
347 from: None,
348 to: None,
349 offset: None,
350 gain: None,
351 };
352 let err = resolve_event_hits(&event, &c, 100.0).unwrap_err();
353 assert!(matches!(err, ScoreError::TooManyHits { .. }));
354 }
355
356 #[test]
357 fn master_defaults_to_limiter_on_with_no_compressor() {
358 let m = MasterBus::default();
359 assert!(m.limiter);
360 assert!(m.compressor.is_none());
361 }
362
363 #[test]
364 fn score_deserializes_from_the_issues_json_shape() {
365 let json = serde_json::json!({
366 "voices": {
367 "kick": { "type": "sine", "freq": [150, 42], "sweep": 0.14, "decay": 0.38 },
368 "hat": { "type": "noise", "filter": {"type":"highpass","freq":7500}, "decay": 0.05 }
369 },
370 "score": [
371 { "voice": "kick", "every": "1b", "from": "@2.2s", "to": "@12.6s" },
372 { "voice": "hat", "every": "1b", "offset": "0.5b", "from": "@2.2s" }
373 ],
374 "master": { "compressor": { "threshold": -14, "ratio": 4 }, "limiter": true }
375 });
376 let score: Score = serde_json::from_value(json).expect("issue's score shape must parse");
377 assert_eq!(score.voices.len(), 2);
378 assert_eq!(score.score.len(), 2);
379 assert!(score.master.limiter);
380 assert_eq!(score.master.compressor.unwrap().ratio, 4.0);
381 }
382}