Skip to main content

sim_lib_midi_core/
tempo.rs

1//! MIDI tempo maps and exact conversions among ticks, quarter beats, and wall
2//! time.
3
4use sim_kernel::Symbol;
5use sim_lib_stream_clock::{Clock, ClockIndex, Instant, TempoMap as ClockTempoMap, TempoSegment};
6
7use crate::{MetaEvent, MidiError, MidiEvent, MidiPayload, TickTime};
8
9/// The default MIDI tempo of 500_000 microseconds per quarter (120 BPM).
10pub const DEFAULT_US_PER_QUARTER: u32 = 500_000;
11
12/// Converts beats per minute to microseconds per quarter note (rounded).
13pub fn bpm_to_us_per_quarter(bpm: f64) -> u32 {
14    (60_000_000.0 / bpm).round() as u32
15}
16
17/// Converts microseconds per quarter note to beats per minute.
18pub fn us_per_quarter_to_bpm(us_per_quarter: u32) -> f64 {
19    60_000_000.0 / us_per_quarter as f64
20}
21
22/// An exact, non-negative position measured in MIDI quarter-note beats.
23///
24/// The reduced rational representation avoids rounding tuplets or rebased
25/// ticks. MIDI tempo always uses a quarter note as its beat unit, independent
26/// of the notated time signature.
27#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct MidiBeat {
29    numerator: i128,
30    denominator: i128,
31}
32
33impl MidiBeat {
34    /// Builds `numerator / denominator` quarter-note beats and reduces it.
35    ///
36    /// Negative values and a zero denominator are rejected because SMF tempo
37    /// timelines begin at tick zero.
38    pub fn new(numerator: i128, denominator: i128) -> Result<Self, MidiError> {
39        if denominator == 0 {
40            return Err(MidiError::InvalidRatio(
41                narrow_i128(numerator)?,
42                narrow_i128(denominator)?,
43            ));
44        }
45        let (numerator, denominator) = if denominator < 0 {
46            (
47                numerator.checked_neg().ok_or(MidiError::TempoOverflow)?,
48                denominator.checked_neg().ok_or(MidiError::TempoOverflow)?,
49            )
50        } else {
51            (numerator, denominator)
52        };
53        if numerator < 0 {
54            return Err(MidiError::NegativeTempoTick);
55        }
56        let divisor = gcd(numerator, denominator);
57        Ok(Self {
58            numerator: numerator / divisor,
59            denominator: denominator / divisor,
60        })
61    }
62
63    /// Returns the reduced numerator in quarter-note beats.
64    pub const fn numerator(self) -> i128 {
65        self.numerator
66    }
67
68    /// Returns the reduced, positive denominator in quarter-note beats.
69    pub const fn denominator(self) -> i128 {
70        self.denominator
71    }
72}
73
74/// A piecewise-constant MIDI tempo map at one ticks-per-quarter resolution.
75///
76/// Construction consumes already ordered MIDI events, applies the standard
77/// 120-BPM default before the first tempo meta event, and lets the last tempo
78/// meta event at a tick win. Wall-time conversion delegates to the generic
79/// exact chart in `sim-lib-stream-clock`.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct MidiTempoMap {
82    tpq: u32,
83    clock_map: ClockTempoMap,
84}
85
86impl MidiTempoMap {
87    /// Builds a tempo map from an ordered event stream.
88    ///
89    /// Every event must be non-negative, exactly expressible at `tpq`, and
90    /// monotonic. Non-tempo events participate in the ordering check but do not
91    /// create segments.
92    pub fn from_ordered_events<'a>(
93        tpq: u32,
94        events: impl IntoIterator<Item = &'a MidiEvent>,
95    ) -> Result<Self, MidiError> {
96        if tpq == 0 {
97            return Err(MidiError::ZeroTpq);
98        }
99        let mut segments = vec![TempoSegment::new(0, DEFAULT_US_PER_QUARTER).map_err(chart_error)?];
100        let mut previous_tick = 0_u64;
101        for event in events {
102            let time = event.time.rebase(tpq)?;
103            let tick = u64::try_from(time.ticks).map_err(|_| MidiError::NegativeTempoTick)?;
104            if tick < previous_tick {
105                return Err(MidiError::TempoEventsOutOfOrder);
106            }
107            previous_tick = tick;
108            let MidiPayload::Meta(MetaEvent::Tempo { us_per_quarter }) = &event.payload else {
109                continue;
110            };
111            if *us_per_quarter == 0 {
112                return Err(MidiError::ZeroTempo);
113            }
114            if segments
115                .last()
116                .is_some_and(|segment| segment.start_tick == tick)
117            {
118                let last = segments
119                    .last_mut()
120                    .expect("the default tempo segment is always present");
121                *last = TempoSegment::new(tick, *us_per_quarter).map_err(chart_error)?;
122            } else {
123                segments.push(TempoSegment::new(tick, *us_per_quarter).map_err(chart_error)?);
124            }
125        }
126        let clock_map = ClockTempoMap::new(segments).map_err(chart_error)?;
127        Ok(Self { tpq, clock_map })
128    }
129
130    /// Returns this map's ticks-per-quarter resolution.
131    pub const fn tpq(&self) -> u32 {
132        self.tpq
133    }
134
135    /// Returns the ordered constant-tempo segments.
136    pub fn segments(&self) -> &[TempoSegment] {
137        self.clock_map.segments()
138    }
139
140    /// Converts a tick position to an exact quarter-note beat.
141    pub fn beat_for_tick(&self, tick: TickTime) -> Result<MidiBeat, MidiError> {
142        let tick = tick.rebase(self.tpq)?;
143        MidiBeat::new(i128::from(tick.ticks), i128::from(self.tpq))
144    }
145
146    /// Converts an exact quarter-note beat to an integer tick.
147    ///
148    /// Returns [`MidiError::InexactTempoTick`] when the beat lies between tick
149    /// boundaries at this map's resolution.
150    pub fn tick_for_beat(&self, beat: MidiBeat) -> Result<TickTime, MidiError> {
151        let scaled = beat
152            .numerator
153            .checked_mul(i128::from(self.tpq))
154            .ok_or(MidiError::TempoOverflow)?;
155        if scaled % beat.denominator != 0 {
156            return Err(MidiError::InexactTempoTick);
157        }
158        let ticks =
159            i64::try_from(scaled / beat.denominator).map_err(|_| MidiError::TempoOverflow)?;
160        TickTime::new(ticks, self.tpq)
161    }
162
163    /// Converts a tick position to exact non-negative wall time in seconds.
164    pub fn wall_time_for_tick(&self, tick: TickTime) -> Result<Instant, MidiError> {
165        let tick = tick.rebase(self.tpq)?;
166        let index = u64::try_from(tick.ticks).map_err(|_| MidiError::NegativeTempoTick)?;
167        self.clock()?
168            .instant_for_index(ClockIndex::new(index))
169            .map_err(chart_error)
170    }
171
172    /// Converts exact wall time to an integer tick.
173    ///
174    /// Returns [`MidiError::InexactTempoTick`] instead of rounding when the
175    /// instant lies between MIDI tick boundaries.
176    pub fn tick_for_wall_time(&self, wall_time: Instant) -> Result<TickTime, MidiError> {
177        let conversion = self
178            .clock()?
179            .index_for_instant(wall_time)
180            .map_err(chart_error)?;
181        if !conversion.is_exact() {
182            return Err(MidiError::InexactTempoTick);
183        }
184        let ticks =
185            i64::try_from(conversion.index().value()).map_err(|_| MidiError::TempoOverflow)?;
186        TickTime::new(ticks, self.tpq)
187    }
188
189    fn clock(&self) -> Result<Clock, MidiError> {
190        Clock::midi(
191            Symbol::qualified("midi/tempo", "timeline"),
192            self.tpq,
193            self.clock_map.clone(),
194        )
195        .map_err(chart_error)
196    }
197}
198
199fn chart_error(error: sim_kernel::Error) -> MidiError {
200    MidiError::TempoChart(error.to_string())
201}
202
203fn narrow_i128(value: i128) -> Result<i64, MidiError> {
204    i64::try_from(value).map_err(|_| MidiError::TempoOverflow)
205}
206
207fn gcd(mut left: i128, mut right: i128) -> i128 {
208    left = left.abs();
209    right = right.abs();
210    while right != 0 {
211        let remainder = left % right;
212        left = right;
213        right = remainder;
214    }
215    left.max(1)
216}