Skip to main content

devalang_wasm/engine/audio/
duration.rs

1use crate::language::syntax::ast::DurationValue;
2use anyhow::{Result, anyhow};
3
4/// Convert a parsed Devalang duration into seconds for a given tempo.
5///
6/// Temporal identifiers use the internal normalized format
7/// `__temporal__<beats>_beat`, so all named units are resolved to beats before
8/// reaching runtime.
9pub fn duration_to_seconds(duration: &DurationValue, bpm: f32) -> Result<f32> {
10    if !bpm.is_finite() || bpm <= 0.0 {
11        return Err(anyhow!("Invalid BPM for duration conversion: {}", bpm));
12    }
13
14    match duration {
15        DurationValue::Milliseconds(ms) => Ok(ms / 1000.0),
16        DurationValue::Beats(beats) => Ok(beats * seconds_per_beat(bpm)?),
17        DurationValue::Beat(value) => parse_beat_fraction(value)
18            .map(|beats| beats * seconds_per_beat(bpm).unwrap_or(0.0))
19            .ok_or_else(|| anyhow!("Invalid beat fraction: {}", value)),
20        DurationValue::Number(ms) => Ok(ms / 1000.0),
21        DurationValue::Identifier(identifier) => parse_temporal_identifier(identifier)
22            .map(|beats| beats * seconds_per_beat(bpm).unwrap_or(0.0))
23            .ok_or_else(|| anyhow!("Cannot resolve identifier duration: {}", identifier)),
24        DurationValue::Auto => Ok(seconds_per_beat(bpm)?),
25    }
26}
27
28pub fn seconds_per_beat(bpm: f32) -> Result<f32> {
29    if !bpm.is_finite() || bpm <= 0.0 {
30        Err(anyhow!("Invalid BPM for beat duration: {}", bpm))
31    } else {
32        Ok(60.0 / bpm)
33    }
34}
35
36fn parse_beat_fraction(value: &str) -> Option<f32> {
37    if let Ok(beats) = value.trim().parse::<f32>() {
38        return Some(beats);
39    }
40
41    let mut parts = value.split('/');
42    let numerator = parts.next()?.trim().parse::<f32>().ok()?;
43    let denominator = parts.next()?.trim().parse::<f32>().ok()?;
44    if parts.next().is_some() || denominator.abs() <= f32::EPSILON {
45        return None;
46    }
47
48    Some(numerator / denominator)
49}
50
51fn parse_temporal_identifier(identifier: &str) -> Option<f32> {
52    let rest = identifier.strip_prefix("__temporal__")?;
53    let last_underscore = rest.rfind('_')?;
54    rest[..last_underscore].parse::<f32>().ok()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn test_duration_to_seconds_converts_supported_units() {
63        assert_eq!(
64            duration_to_seconds(&DurationValue::Milliseconds(500.0), 120.0).unwrap(),
65            0.5
66        );
67        assert_eq!(
68            duration_to_seconds(&DurationValue::Beats(2.0), 120.0).unwrap(),
69            1.0
70        );
71        assert_eq!(
72            duration_to_seconds(&DurationValue::Beat("1/4".to_string()), 120.0).unwrap(),
73            0.125
74        );
75        assert_eq!(
76            duration_to_seconds(
77                &DurationValue::Identifier("__temporal__4_beat".to_string()),
78                120.0,
79            )
80            .unwrap(),
81            2.0
82        );
83    }
84
85    #[test]
86    fn test_duration_to_seconds_rejects_invalid_values() {
87        assert!(duration_to_seconds(&DurationValue::Auto, 0.0).is_err());
88        assert!(duration_to_seconds(&DurationValue::Beat("1/0".to_string()), 120.0).is_err());
89        assert!(
90            duration_to_seconds(&DurationValue::Identifier("missing".to_string()), 120.0).is_err()
91        );
92    }
93}