313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! The per-region time axis (Chapter 7 §"Layout Regions" / §"The Time Axis").
|
|
//!
|
|
//! [`TimeAxisModel`] is a **tagged enum** over the three built-in region time
|
|
//! models plus a registered variant for extension-defined axes — *not*
|
|
//! `Box<dyn TimeAxis>` (QUICKSTART, Agent E; Chapter 7: "The enum form is
|
|
//! canonical for serialization, hashing, and conformance comparison"). The
|
|
//! dynamic [`TimeAxis`] interface is also provided for spacing implementations.
|
|
|
|
use epiphany_core::{MusicalPosition, Region, RegionTimeModel, WallClockTime};
|
|
|
|
use crate::{SpringSlotId, StaffSpace};
|
|
|
|
/// A registry id for an extension-defined [`TimeAxisModel::Registered`]
|
|
/// (Chapter 7: `TimeAxisRegistryId`). Opaque in v0 (no external registries).
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
|
pub struct TimeAxisRegistryId(pub u128);
|
|
|
|
/// Canonical opaque payload for an extension-defined time axis.
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct SerializedRegisteredAxis(pub Vec<u8>);
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum TimePoint {
|
|
Musical(MusicalPosition),
|
|
WallClock(WallClockTime),
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum TimeRange {
|
|
Musical {
|
|
start: MusicalPosition,
|
|
end: MusicalPosition,
|
|
},
|
|
WallClock {
|
|
start: WallClockTime,
|
|
end: WallClockTime,
|
|
},
|
|
}
|
|
|
|
/// One placement on the time axis: the spring slot occupying a given time.
|
|
/// Placements are held in ascending time order, so the axis maps a queried time
|
|
/// to the slot covering it (the greatest placement at or before the query).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct SlotPlacement {
|
|
pub time: TimePoint,
|
|
pub slot: SpringSlotId,
|
|
}
|
|
|
|
/// Metric-axis projection data: the time→slot placements, populated during
|
|
/// spacing (a region's measure/beat grid resolves to ordered spring slots).
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct MetricTimeAxis {
|
|
pub placements: Vec<SlotPlacement>,
|
|
}
|
|
|
|
/// Proportional-axis projection data: horizontal position is linear in
|
|
/// wall-clock time, and the placements map each slot's time onto the axis.
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub struct ProportionalTimeAxis {
|
|
pub duration_ns: i64,
|
|
pub space_per_second: StaffSpace,
|
|
pub placements: Vec<SlotPlacement>,
|
|
}
|
|
|
|
/// Aleatoric-axis projection data. Placement order is topological layer order.
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct AleatoricTimeAxis {
|
|
pub placements: Vec<SlotPlacement>,
|
|
}
|
|
|
|
/// The canonical representation of a region's time axis (Chapter 7). The
|
|
/// variants correspond to the three built-in [`RegionTimeModel`]s; the
|
|
/// `Registered` variant carries an extension-defined axis by registry id.
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub enum TimeAxisModel {
|
|
/// Metric time: positions map through measures and beats.
|
|
Metric(MetricTimeAxis),
|
|
/// Proportional time: horizontal position is linear in wall-clock time over
|
|
/// the region's `duration_ns` nanoseconds.
|
|
Proportional(ProportionalTimeAxis),
|
|
/// Aleatoric time: ordering is a DAG, not a metric line.
|
|
Aleatoric(AleatoricTimeAxis),
|
|
/// An extension-defined axis kind.
|
|
Registered(TimeAxisRegistryId, SerializedRegisteredAxis),
|
|
}
|
|
|
|
/// The built-in axis kinds, the discriminator of [`TimeAxisModel`]
|
|
/// (Chapter 7: `TimeAxisKind`).
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
|
pub enum TimeAxisKind {
|
|
Metric,
|
|
Proportional,
|
|
Aleatoric,
|
|
Registered(TimeAxisRegistryId),
|
|
}
|
|
|
|
/// Compares two [`TimePoint`]s of the *same* kind; mixed kinds are
|
|
/// incomparable (`None`), which a uniform region never produces.
|
|
fn time_cmp(a: &TimePoint, b: &TimePoint) -> Option<core::cmp::Ordering> {
|
|
match (a, b) {
|
|
(TimePoint::Musical(x), TimePoint::Musical(y)) => Some(x.cmp(y)),
|
|
(TimePoint::WallClock(x), TimePoint::WallClock(y)) => Some(x.cmp(y)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Dynamic time-axis interface used by spacing implementations. The tagged
|
|
/// [`TimeAxisModel`] remains the canonical representation.
|
|
pub trait TimeAxis: Send + Sync {
|
|
fn kind(&self) -> TimeAxisKind;
|
|
/// The spring slot covering `time`: the placement with the greatest time at
|
|
/// or before `time` (or the first placement if `time` precedes them all).
|
|
fn project(&self, time: TimePoint) -> SpringSlotId;
|
|
/// The spring slots in time order.
|
|
fn slots(&self) -> Vec<SpringSlotId>;
|
|
/// The spring slots whose time falls in the half-open `range` `[start, end)`.
|
|
fn affected_slots(&self, range: TimeRange) -> Vec<SpringSlotId>;
|
|
}
|
|
|
|
impl TimeAxisModel {
|
|
/// This axis's kind discriminator.
|
|
pub fn kind(&self) -> TimeAxisKind {
|
|
match self {
|
|
TimeAxisModel::Metric(_) => TimeAxisKind::Metric,
|
|
TimeAxisModel::Proportional(_) => TimeAxisKind::Proportional,
|
|
TimeAxisModel::Aleatoric(_) => TimeAxisKind::Aleatoric,
|
|
TimeAxisModel::Registered(id, _) => TimeAxisKind::Registered(*id),
|
|
}
|
|
}
|
|
|
|
/// The axis's time→slot placements, in ascending time order.
|
|
pub fn placements(&self) -> &[SlotPlacement] {
|
|
match self {
|
|
TimeAxisModel::Metric(axis) => &axis.placements,
|
|
TimeAxisModel::Proportional(axis) => &axis.placements,
|
|
TimeAxisModel::Aleatoric(axis) => &axis.placements,
|
|
TimeAxisModel::Registered(_, _) => &[],
|
|
}
|
|
}
|
|
|
|
/// Returns this axis populated with `placements` (sorted into ascending time
|
|
/// order). A `Registered` axis is opaque and returned unchanged. This is how
|
|
/// the spacing stage drives the axis from its resolved spring slots.
|
|
pub fn with_placements(self, mut placements: Vec<SlotPlacement>) -> Self {
|
|
placements.sort_by(|a, b| time_cmp(&a.time, &b.time).unwrap_or(core::cmp::Ordering::Equal));
|
|
match self {
|
|
TimeAxisModel::Metric(mut axis) => {
|
|
axis.placements = placements;
|
|
TimeAxisModel::Metric(axis)
|
|
}
|
|
TimeAxisModel::Proportional(mut axis) => {
|
|
axis.placements = placements;
|
|
TimeAxisModel::Proportional(axis)
|
|
}
|
|
TimeAxisModel::Aleatoric(mut axis) => {
|
|
axis.placements = placements;
|
|
TimeAxisModel::Aleatoric(axis)
|
|
}
|
|
other @ TimeAxisModel::Registered(_, _) => other,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TimeAxis for TimeAxisModel {
|
|
fn kind(&self) -> TimeAxisKind {
|
|
TimeAxisModel::kind(self)
|
|
}
|
|
|
|
fn project(&self, time: TimePoint) -> SpringSlotId {
|
|
let placements = self.placements();
|
|
// The greatest placement at or before `time` (the slot covering it)...
|
|
let covering = placements.iter().rfind(|p| {
|
|
matches!(
|
|
time_cmp(&p.time, &time),
|
|
Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal)
|
|
)
|
|
});
|
|
// ...or the first placement when `time` precedes them all.
|
|
covering
|
|
.or_else(|| placements.first())
|
|
.map(|p| p.slot)
|
|
.unwrap_or(SpringSlotId(0))
|
|
}
|
|
|
|
fn slots(&self) -> Vec<SpringSlotId> {
|
|
self.placements().iter().map(|p| p.slot).collect()
|
|
}
|
|
|
|
fn affected_slots(&self, range: TimeRange) -> Vec<SpringSlotId> {
|
|
let (start, end) = match range {
|
|
TimeRange::Musical { start, end } => {
|
|
(TimePoint::Musical(start), TimePoint::Musical(end))
|
|
}
|
|
TimeRange::WallClock { start, end } => {
|
|
(TimePoint::WallClock(start), TimePoint::WallClock(end))
|
|
}
|
|
};
|
|
self.placements()
|
|
.iter()
|
|
.filter(|p| {
|
|
let at_or_after_start = matches!(
|
|
time_cmp(&p.time, &start),
|
|
Some(core::cmp::Ordering::Greater | core::cmp::Ordering::Equal)
|
|
);
|
|
let before_end = matches!(time_cmp(&p.time, &end), Some(core::cmp::Ordering::Less));
|
|
at_or_after_start && before_end
|
|
})
|
|
.map(|p| p.slot)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Maps a score region's [`RegionTimeModel`] to its layout [`TimeAxisModel`]
|
|
/// (Chapter 7 §"Region Uniformity": the region kind shows up here and in the
|
|
/// object mix, never in the container type).
|
|
pub fn time_axis_of(region: &Region) -> TimeAxisModel {
|
|
match ®ion.time_model {
|
|
RegionTimeModel::Metric(_) => TimeAxisModel::Metric(MetricTimeAxis::default()),
|
|
RegionTimeModel::Proportional(p) => TimeAxisModel::Proportional(ProportionalTimeAxis {
|
|
duration_ns: p.duration.0,
|
|
space_per_second: StaffSpace(1.0),
|
|
placements: Vec::new(),
|
|
}),
|
|
RegionTimeModel::Aleatoric(_) => TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn kind_matches_variant() {
|
|
assert_eq!(
|
|
TimeAxisModel::Metric(MetricTimeAxis::default()).kind(),
|
|
TimeAxisKind::Metric
|
|
);
|
|
assert_eq!(
|
|
TimeAxisModel::Proportional(ProportionalTimeAxis {
|
|
duration_ns: 42,
|
|
space_per_second: StaffSpace(1.0),
|
|
placements: vec![],
|
|
})
|
|
.kind(),
|
|
TimeAxisKind::Proportional
|
|
);
|
|
assert_eq!(
|
|
TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()).kind(),
|
|
TimeAxisKind::Aleatoric
|
|
);
|
|
let r = TimeAxisRegistryId(7);
|
|
assert_eq!(
|
|
TimeAxisModel::Registered(r, SerializedRegisteredAxis::default()).kind(),
|
|
TimeAxisKind::Registered(r)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_and_affected_slots_consume_placements() {
|
|
// Built out of time order; `with_placements` sorts by time.
|
|
let axis = TimeAxisModel::Metric(MetricTimeAxis::default()).with_placements(vec![
|
|
SlotPlacement {
|
|
time: TimePoint::WallClock(WallClockTime(10)),
|
|
slot: SpringSlotId(1),
|
|
},
|
|
SlotPlacement {
|
|
time: TimePoint::WallClock(WallClockTime(30)),
|
|
slot: SpringSlotId(3),
|
|
},
|
|
SlotPlacement {
|
|
time: TimePoint::WallClock(WallClockTime(20)),
|
|
slot: SpringSlotId(2),
|
|
},
|
|
]);
|
|
|
|
// Slots come back in ascending time order.
|
|
assert_eq!(
|
|
axis.slots(),
|
|
vec![SpringSlotId(1), SpringSlotId(2), SpringSlotId(3)]
|
|
);
|
|
// project() returns the covering slot (greatest time <= query)...
|
|
assert_eq!(
|
|
axis.project(TimePoint::WallClock(WallClockTime(25))),
|
|
SpringSlotId(2)
|
|
);
|
|
assert_eq!(
|
|
axis.project(TimePoint::WallClock(WallClockTime(30))),
|
|
SpringSlotId(3)
|
|
);
|
|
// ...and the first slot when the query precedes every placement.
|
|
assert_eq!(
|
|
axis.project(TimePoint::WallClock(WallClockTime(5))),
|
|
SpringSlotId(1)
|
|
);
|
|
// affected_slots() respects the half-open range [10, 30): excludes 30.
|
|
assert_eq!(
|
|
axis.affected_slots(TimeRange::WallClock {
|
|
start: WallClockTime(10),
|
|
end: WallClockTime(30),
|
|
}),
|
|
vec![SpringSlotId(1), SpringSlotId(2)]
|
|
);
|
|
|
|
// An empty axis projects to the default slot and has no slots.
|
|
let empty = TimeAxisModel::Metric(MetricTimeAxis::default());
|
|
assert_eq!(
|
|
empty.project(TimePoint::WallClock(WallClockTime(0))),
|
|
SpringSlotId(0)
|
|
);
|
|
assert!(empty.slots().is_empty());
|
|
}
|
|
}
|