Skip to main content

sva_core/
tempo.rs

1// Concern: resolves optional bpm/meter into resolve_bar_spans' seconds_per_bar | Non-concern: what a resolved span feeds into (lib.rs run()) | IO: (&mut Graph) -> () or CliError
2
3use sva_ast::{Expr, Graph, Literal, PerBar};
4
5use crate::cli_error::CliError;
6
7/// An unparsable numerator is an error, not a guess.
8fn beats_per_bar(meter_str: &str) -> Result<f64, CliError> {
9    let numerator = meter_str.split('/').next().ok_or_else(|| {
10        CliError::BadTempo(format!(
11            "meter `{meter_str}` has no `/`-separated numerator"
12        ))
13    })?;
14    numerator.trim().parse::<f64>().map_err(|e| {
15        CliError::BadTempo(format!(
16            "meter `{meter_str}`'s beats-per-bar component `{numerator}` is not a number: {e}"
17        ))
18    })
19}
20
21/// A `b` literal without a tempo refuses rather than defaulting to some seconds-per-bar.
22pub fn refuse_unresolved_bars(graph: &Graph) -> Result<(), CliError> {
23    match graph.unresolved_bar_literals().first() {
24        None => Ok(()),
25        Some(path) => Err(CliError::BadTempo(format!(
26            "`{path}` uses a `b` time literal, but this composition declares no bpm/meter \
27             (add `variables/bpm` and `variables/meter`)"
28        ))),
29    }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct Tempo {
34    pub seconds_per_bar: f64,
35    pub beats_per_bar: f64,
36    pub bpm: f64,
37}
38
39/// `None` when the composition declares neither `bpm` nor `meter`.
40pub fn resolved(graph: &Graph) -> Result<Option<Tempo>, CliError> {
41    match (graph.global("bpm"), graph.global("meter")) {
42        (None, None) => Ok(None),
43        (Some(Expr::Lit(Literal::Num(bpm))), Some(Expr::Lit(Literal::Str(meter_str)))) => {
44            let bpm = *bpm;
45            if bpm <= 0.0 {
46                return Err(CliError::BadTempo(format!(
47                    "bpm must be positive, got {bpm}"
48                )));
49            }
50            let beats_per_bar = beats_per_bar(meter_str)?;
51            Ok(Some(Tempo {
52                seconds_per_bar: beats_per_bar * 60.0 / bpm,
53                beats_per_bar,
54                bpm,
55            }))
56        }
57        (Some(_), Some(_)) => Err(CliError::BadTempo(
58            "`bpm` must be a numeric literal and `meter` a string literal (e.g. \"4/4\")"
59                .to_string(),
60        )),
61        (Some(_), None) => Err(CliError::BadTempo(
62            "`bpm` is present but `meter` is missing; both or neither".to_string(),
63        )),
64        (None, Some(_)) => Err(CliError::BadTempo(
65            "`meter` is present but `bpm` is missing; both or neither".to_string(),
66        )),
67    }
68}
69
70/// Resolves every `Bars` span to `Seconds` from optional `bpm`/`meter`; absent, it's a no-op.
71pub fn resolve(graph: &mut Graph) -> Result<(), CliError> {
72    match resolved(graph)? {
73        None => refuse_unresolved_bars(graph),
74        Some(tempo) => {
75            graph.resolve_bar_spans(PerBar {
76                seconds: tempo.beats_per_bar * 60.0,
77                per: tempo.bpm,
78            });
79            Ok(())
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn beats_per_bar_parses_the_numerator() {
90        assert_eq!(beats_per_bar("4/4").unwrap(), 4.0);
91        assert_eq!(beats_per_bar("3/4").unwrap(), 3.0);
92    }
93
94    #[test]
95    fn beats_per_bar_rejects_a_non_numeric_numerator() {
96        assert!(beats_per_bar("four/4").is_err());
97    }
98}