Lightningbeam/daw-backend/src/tempo_map.rs

333 lines
13 KiB
Rust

//! TempoMap — beats ↔ seconds conversion with variable tempo support.
//!
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
//! between beats and seconds at render / scheduling time.
//!
//! # Interpolation
//! Each `TempoEntry` has an `interpolation` field that controls how the BPM
//! changes between that entry and the next:
//! - `Step`: BPM is constant from this entry's beat until the next entry. Instant change.
//! - `Linear`: BPM linearly interpolates from this entry's BPM to the next entry's BPM
//! over the beat range. The seconds calculation uses the exact integral:
//! `Δt = (60 / slope) * ln(bpm_end / bpm_start)` where slope = (bpm_end - bpm_start) / span_beats.
//!
//! # Format
//! `entries` is a sorted `Vec<TempoEntry>` where the first entry must always
//! have `beat == 0.0`.
//!
//! # Sequential-access optimisation
//! An `AtomicUsize` caches the index of the last segment visited by
//! `beats_to_seconds`. When calls are in ascending order (the common case when
//! walking events in order) the scan starts from the cached index instead of
//! the beginning, giving amortised O(1) behaviour.
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::time::{Beats, Seconds};
/// How the BPM transitions from one `TempoEntry` to the next.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
pub enum TempoInterpolation {
/// BPM stays constant from this entry's beat until the next entry (instant change).
#[default]
Step,
/// BPM linearly interpolates from this entry's BPM to the next entry's BPM
/// over the beat span between the two entries.
Linear,
}
/// A single tempo segment: from `beat` onwards the tempo changes according to `interpolation`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TempoEntry {
/// Start of this tempo segment in beats (quarter-note beats).
pub beat: f64,
/// Tempo at the start of this segment in beats per minute.
pub bpm: f64,
/// Cumulative seconds elapsed at the start of this segment.
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
/// mutation or after deserialization.
#[serde(skip, default)]
pub seconds: f64,
/// How the BPM transitions from this entry to the next.
#[serde(default)]
pub interpolation: TempoInterpolation,
}
/// A piecewise tempo map used to convert between beats and seconds.
#[derive(Debug, Serialize, Deserialize)]
pub struct TempoMap {
/// Sorted list of tempo segments. Must always have at least one entry at beat 0.
pub entries: Vec<TempoEntry>,
/// Sequential-access cache: index of the last segment used by `beats_to_seconds`.
#[serde(skip, default)]
last_index: AtomicUsize,
}
impl Clone for TempoMap {
fn clone(&self) -> Self {
Self {
entries: self.entries.clone(),
last_index: AtomicUsize::new(self.last_index.load(Ordering::Relaxed)),
}
}
}
impl Default for TempoMap {
fn default() -> Self {
Self::constant(120.0)
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Seconds elapsed traversing `span_beats` starting at `bpm_start`.
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
/// uses the exact logarithmic integral.
#[inline]
fn segment_duration(span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
match bpm_end {
None => span_beats * 60.0 / bpm_start,
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => span_beats * 60.0 / bpm_start,
Some(b1) => {
// Linear BPM: BPM(b) = bpm_start + slope * (b - b_start)
// Δt = ∫₀^span 60 / BPM(b) db = (60/slope) * ln(b1/bpm_start)
let slope = (b1 - bpm_start) / span_beats;
(60.0 / slope) * (b1 / bpm_start).ln()
}
}
}
/// Beats elapsed given `delta_seconds` starting at `bpm_start`.
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
/// uses the exact exponential inverse.
#[inline]
fn segment_beats(delta_seconds: f64, span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
match bpm_end {
None => delta_seconds * bpm_start / 60.0,
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => delta_seconds * bpm_start / 60.0,
Some(b1) => {
// Inverse of the logarithmic integral:
// b = b_start + (bpm_start / slope) * (exp(delta_t * slope / 60) - 1)
let slope = (b1 - bpm_start) / span_beats;
(bpm_start / slope) * ((delta_seconds * slope / 60.0).exp() - 1.0)
}
}
}
impl TempoMap {
/// Create a constant-tempo map.
pub fn constant(bpm: f64) -> Self {
Self {
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step }],
last_index: AtomicUsize::new(0),
}
}
/// Rebuild the `seconds` field on every entry from scratch.
/// **Must be called** after any mutation (add/remove/reorder entry) and
/// after deserialization.
pub fn rebuild_seconds(&mut self) {
let mut cumulative = 0.0_f64;
let n = self.entries.len();
for i in 0..n {
self.entries[i].seconds = cumulative;
if i + 1 < n {
let span = self.entries[i + 1].beat - self.entries[i].beat;
let bpm_end = if self.entries[i].interpolation == TempoInterpolation::Linear {
Some(self.entries[i + 1].bpm)
} else {
None
};
cumulative += segment_duration(span, self.entries[i].bpm, bpm_end);
}
}
self.last_index.store(0, Ordering::Relaxed);
}
/// Return the instantaneous BPM active at `beat`.
/// For linear segments, returns the interpolated value at that beat.
pub fn bpm_at(&self, beat: Beats) -> f64 {
let n = self.entries.len();
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
let idx = idx.min(n - 1);
let entry = &self.entries[idx];
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let t = (beat.0 - entry.beat) / (next.beat - entry.beat);
entry.bpm + (next.bpm - entry.bpm) * t
} else {
entry.bpm
}
}
/// Convert beats to seconds using the tempo map.
///
/// Uses the sequential cache: if `beat` is at or after the last cached
/// segment, the scan starts there instead of from the beginning.
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
Seconds(self.transform(beat.0))
}
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
Beats(self.inverse_transform(seconds.0))
}
/// Global BPM — the BPM of the first entry (at beat 0).
pub fn global_bpm(&self) -> f64 {
self.entries[0].bpm
}
/// Set the global BPM (first entry). Rebuilds seconds.
pub fn set_global_bpm(&mut self, bpm: f64) {
self.entries[0].bpm = bpm;
self.rebuild_seconds();
}
/// Convert local beats to parent time units (raw `f64`).
///
/// At the root level the result is absolute seconds. Inside a nested
/// group the result is the *parent group's* beats.
pub fn transform(&self, beat: f64) -> f64 {
if beat <= 0.0 {
return 0.0;
}
let n = self.entries.len();
let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1));
let start = if beat >= self.entries[cached].beat { cached } else { 0 };
let mut idx = start;
while idx + 1 < n && self.entries[idx + 1].beat <= beat {
idx += 1;
}
self.last_index.store(idx, Ordering::Relaxed);
let entry = &self.entries[idx];
let beat_in_seg = beat - entry.beat;
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let span = next.beat - entry.beat;
entry.seconds + segment_duration(beat_in_seg, entry.bpm, Some(entry.bpm + (next.bpm - entry.bpm) * beat_in_seg / span))
} else {
entry.seconds + beat_in_seg * 60.0 / entry.bpm
}
}
/// Inverse of [`transform`]: convert parent time units back to local beats.
pub fn inverse_transform(&self, parent_time: f64) -> f64 {
if parent_time <= 0.0 {
return 0.0;
}
let n = self.entries.len();
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
let idx = idx.min(n - 1);
let entry = &self.entries[idx];
let delta_t = parent_time - entry.seconds;
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let span = next.beat - entry.beat;
entry.beat + segment_beats(delta_t, span, entry.bpm, Some(next.bpm))
} else {
entry.beat + delta_t * entry.bpm / 60.0
}
}
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes (step interpolation).
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
if keyframes.is_empty() {
return Self::constant(120.0);
}
let mut entries: Vec<TempoEntry> = keyframes
.iter()
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step })
.collect();
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
if entries[0].beat > 0.0 {
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0, interpolation: TempoInterpolation::Step });
}
let mut map = Self { entries, last_index: AtomicUsize::new(0) };
map.rebuild_seconds();
map
}
}
/// Convert local beats through a stack of tempo maps to absolute seconds.
pub fn beats_to_seconds_stack(beat: f64, stack: &[&TempoMap]) -> f64 {
let mut t = beat;
for tm in stack.iter().rev() {
t = tm.transform(t);
}
t
}
/// Inverse of [`beats_to_seconds_stack`]: absolute seconds → local beats.
pub fn seconds_to_beats_stack(seconds: f64, stack: &[&TempoMap]) -> f64 {
let mut t = seconds;
for tm in stack.iter() {
t = tm.inverse_transform(t);
}
t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_bpm_round_trip() {
let m = TempoMap::constant(120.0);
assert!((m.beats_to_seconds(Beats(2.0)).0 - 1.0).abs() < 1e-9);
assert!((m.seconds_to_beats(Seconds(1.0)).0 - 2.0).abs() < 1e-9);
}
#[test]
fn variable_tempo_step() {
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
// Beat 0-4: 120 BPM → 4 beats = 2 seconds
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9, "got {}", m.beats_to_seconds(Beats(4.0)).0);
// Beat 4-5: 60 BPM → 1 beat = 1 second
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
}
#[test]
fn linear_interpolation_round_trip() {
// 120→240 BPM over 4 beats: slope = (240-120)/4 = 30 BPM/beat
// Δt = (60/30) * ln(240/120) = 2 * ln(2) ≈ 1.386s for beats 0-4
let mut m = TempoMap::constant(120.0);
m.entries.push(TempoEntry { beat: 4.0, bpm: 240.0, seconds: 0.0, interpolation: TempoInterpolation::Step });
m.entries[0].interpolation = TempoInterpolation::Linear;
m.rebuild_seconds();
let expected = 2.0 * std::f64::consts::LN_2;
let got = m.beats_to_seconds(Beats(4.0)).0;
assert!((got - expected).abs() < 1e-9, "got {got}, expected {expected}");
// Round-trip
let beats_back = m.seconds_to_beats(Seconds(expected)).0;
assert!((beats_back - 4.0).abs() < 1e-9, "round-trip got {beats_back}");
}
#[test]
fn stack_composition() {
let root = TempoMap::constant(120.0);
let group = TempoMap::constant(60.0);
let stack: Vec<&TempoMap> = vec![&root, &group];
let secs = beats_to_seconds_stack(2.0, &stack);
assert!((secs - 1.0).abs() < 1e-9, "got {secs}");
let beats = seconds_to_beats_stack(1.0, &stack);
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
}
#[test]
fn sequential_cache() {
let m = TempoMap::constant(120.0);
for i in 0..10 {
let secs = m.beats_to_seconds(Beats(i as f64));
assert!((secs.0 - i as f64 * 0.5).abs() < 1e-9);
}
}
}