Skip to main content

rustmotion_core/audio/
score.rs

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/// One entry of `score`: trigger `voice` once at `at`, or repeatedly every
11/// `every` between `from` (default: the scenario's start) and `to`
12/// (default: the scenario's end), phase-shifted by `offset`. Exactly one
13/// of `at`/`every` must be given — [`Score::resolve_hits`] rejects an
14/// event with neither.
15///
16/// `at`/`from`/`to` are **instants**: resolved with the scenario's real
17/// `bpm`/`beat_offset`, exactly like [`crate::schema::Scene::at`]. `every`/
18/// `offset` are **durations**: also [`TimePoint`]s, resolved through the
19/// same [`TimeCtx`] machinery (the frozen contract — nothing here reshapes
20/// `TimePoint`/`TimeCtx`), but against a context whose `beat_offset` is
21/// forced to zero first (see [`resolve_duration`]'s doc) — `beat_offset` is
22/// a *phase*, and must not multiply into how long one beat lasts.
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct ScoreEvent {
26    /// Key into [`Score::voices`].
27    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    /// Per-event gain override, applied on top of the voice's own `gain`.
39    /// Linear, not dB (unlike [`CompressorConfig::threshold`] and
40    /// [`MasterBus::gain`]) — matching [`Voice::gain`], which this
41    /// multiplies against.
42    #[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/// `master.compressor` — the issue's `{"threshold": -14, "ratio": 4}`.
55/// `threshold` is in dB, `ratio` is the `N` of an `N:1` ratio.
56#[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/// The score's master bus: an optional gain trim (dB), an optional
72/// compressor, and the limiter — deliverable #4/#5, **on by default**
73/// (`limiter` defaults to `true`; omitting `master` entirely still applies
74/// it). See [`super::dsp::apply_limiter`]'s doc for why it is a hard
75/// guarantee, not a best-effort setting.
76#[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/// The full synth block — `Scenario::audio`'s object form
98/// (`schema::scenario::AudioConfig`) carries one of these directly. See
99/// the issue's JSON example: `voices` + `score` + `master`, with `bpm`/
100/// `beat_offset` living one level up on `AudioConfig` (and, when absent
101/// there, on the scenario itself — see `AudioConfig::as_score`'s caller in
102/// `rustmotion`'s `encode` crate for exactly how that fallback works).
103#[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}