1583 lines
56 KiB
Rust
1583 lines
56 KiB
Rust
//! The score-graph structure (Chapter 5): the canvas/region model, the
|
||
//! distinct [`Staff`] and [`StaffInstance`] concepts, voices, measures,
|
||
//! barline-alignment groups, the reference-bearing cross-cutting structures,
|
||
//! and the [`Score`] root.
|
||
//!
|
||
//! The graph is a *tree of containment overlaid with cross-cutting structures
|
||
//! that hold references* (Chapter 5 §"Design Principles": "Hybrid topology").
|
||
//! The tree determines existence; cross-cutting references may dangle and
|
||
//! require re-anchoring. The spatial root is the [`Canvas`], partitioned into
|
||
//! [`Region`]s; staves live inside regions ("Canvas before staff").
|
||
//!
|
||
//! Engraving-display detail (clef/key sequences, stem direction, line styles,
|
||
//! spanner/marker visual kinds) is *fully defined in Chapter 7* per the spec
|
||
//! and belongs to Agent E; this module carries minimal placeholders for it.
|
||
|
||
use core::num::NonZeroU16;
|
||
use std::collections::BTreeSet;
|
||
|
||
use epiphany_determinism::{CanonicalF64, SystemDomainTag};
|
||
|
||
use crate::event::EventArena;
|
||
use crate::ids::{
|
||
derive_system_id, BarlineAlignmentGroupId, BeamId, ChordSymbolId, IdentityContext,
|
||
InstrumentId, LyricLineId, MeasureId, OperationId, PartDefinitionId, PitchId, RegionId,
|
||
RepeatStructureId, SlurId, SpannerId, StaffGroupId, StaffId, StaffInstanceId, TimeSignatureId,
|
||
TupletId, ViewId, VoiceId,
|
||
};
|
||
use crate::pitch::{
|
||
ForeignFormatId, PitchSpaceId, ReferencePitch, SpellingAttachment, TuningSystemId,
|
||
};
|
||
use crate::time::{MeasurePosition, MusicalDuration, TimeAnchor, WallClockDuration};
|
||
|
||
// --- Engraving-display placeholders (Chapter 7 / Agent E). ------------------
|
||
|
||
/// Default stem direction for a voice. Placeholder (Chapter 7).
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub enum StemDirection {
|
||
Up,
|
||
Down,
|
||
}
|
||
|
||
/// Staff line configuration. Placeholder beyond the line count (Chapter 7).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct StaffLineConfiguration {
|
||
pub line_count: u8,
|
||
}
|
||
|
||
impl Default for StaffLineConfiguration {
|
||
fn default() -> Self {
|
||
StaffLineConfiguration { line_count: 5 }
|
||
}
|
||
}
|
||
|
||
/// The SMuFL clef family a [`Clef`] draws from. The reference pitch each family
|
||
/// fixes (G4 / F3 / middle C4) is what pins the staff-position mapping.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub enum ClefShape {
|
||
/// G clef (treble family) — reference pitch G4.
|
||
G,
|
||
/// F clef (bass family) — reference pitch F3.
|
||
F,
|
||
/// C clef (alto / tenor family) — reference pitch middle C (C4).
|
||
C,
|
||
/// Unpitched percussion clef — no diatonic reference.
|
||
Percussion,
|
||
}
|
||
|
||
/// A clef: the SMuFL [`ClefShape`], the staff line its reference pitch sits on
|
||
/// (`1` = the bottom line of the staff, counting up), and an octave
|
||
/// transposition (`-1` for treble-8vb, `+1` for treble-8va, …). The shape's
|
||
/// reference pitch on `line` fixes where every pitch under this clef lands on
|
||
/// the staff.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub struct Clef {
|
||
pub shape: ClefShape,
|
||
pub line: i8,
|
||
pub octave_shift: i8,
|
||
}
|
||
|
||
impl Clef {
|
||
/// Treble clef — G clef on line 2.
|
||
pub const fn treble() -> Self {
|
||
Clef {
|
||
shape: ClefShape::G,
|
||
line: 2,
|
||
octave_shift: 0,
|
||
}
|
||
}
|
||
/// Bass clef — F clef on line 4.
|
||
pub const fn bass() -> Self {
|
||
Clef {
|
||
shape: ClefShape::F,
|
||
line: 4,
|
||
octave_shift: 0,
|
||
}
|
||
}
|
||
/// Alto clef — C clef on line 3.
|
||
pub const fn alto() -> Self {
|
||
Clef {
|
||
shape: ClefShape::C,
|
||
line: 3,
|
||
octave_shift: 0,
|
||
}
|
||
}
|
||
/// Tenor clef — C clef on line 4.
|
||
pub const fn tenor() -> Self {
|
||
Clef {
|
||
shape: ClefShape::C,
|
||
line: 4,
|
||
octave_shift: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for Clef {
|
||
fn default() -> Self {
|
||
Clef::treble()
|
||
}
|
||
}
|
||
|
||
/// A key signature as a position on the circle of fifths: `fifths` sharps when
|
||
/// positive (`1` = G major, one sharp), flats when negative (`-1` = F major,
|
||
/// one flat), and `0` for C major / A minor. The accidental set the renderer
|
||
/// draws is derived from this count.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
|
||
pub struct KeySignature {
|
||
fifths: i8,
|
||
}
|
||
|
||
impl KeySignature {
|
||
/// Lowest key-signature fifth count in conventional CMN notation: seven flats.
|
||
pub const MIN_FIFTHS: i8 = -7;
|
||
/// Highest key-signature fifth count in conventional CMN notation: seven sharps.
|
||
pub const MAX_FIFTHS: i8 = 7;
|
||
|
||
/// Builds a key signature, rejecting values outside the conventional
|
||
/// `-7..=7` circle-of-fifths range.
|
||
pub const fn new(fifths: i8) -> Option<Self> {
|
||
if fifths >= Self::MIN_FIFTHS && fifths <= Self::MAX_FIFTHS {
|
||
Some(KeySignature { fifths })
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The circle-of-fifths position (`-7..=7`).
|
||
pub const fn fifths(self) -> i8 {
|
||
self.fifths
|
||
}
|
||
}
|
||
|
||
/// A clef placed at a point in a staff instance (Chapter 7).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct ClefChange {
|
||
pub anchor: TimeAnchor,
|
||
pub clef: Clef,
|
||
}
|
||
|
||
/// A key-signature change at a point in a staff instance (Chapter 7).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct KeySignatureChange {
|
||
pub anchor: TimeAnchor,
|
||
pub key: KeySignature,
|
||
}
|
||
|
||
/// Whether and how a measure number is shown. Placeholder (Chapter 7).
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
|
||
pub enum MeasureNumberVisibility {
|
||
#[default]
|
||
Auto,
|
||
Always,
|
||
Never,
|
||
}
|
||
|
||
/// A graphic object stored in a region's graphic content (Chapter 5
|
||
/// §"Graphic Objects"). Carries its identifier so [`GraphicGesture::objects`]
|
||
/// and [`crate::GraphicEvent::graphics`] references can resolve against it; the
|
||
/// geometry/style detail is Chapter 7's.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct GraphicObject {
|
||
pub id: crate::ids::GraphicObjectId,
|
||
}
|
||
|
||
/// Free-graphic content of a region (Chapter 5 §"Graphic Content"): the graphic
|
||
/// objects placed in the region's coordinate space. The coordinate-system and
|
||
/// per-object geometry/style detail is Chapter 7's; this baseline carries the
|
||
/// object identities so references into the content can be resolved.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct GraphicContent {
|
||
pub objects: Vec<GraphicObject>,
|
||
}
|
||
|
||
/// Reference to a tempo map (score-level or local). Placeholder (Chapter 3
|
||
/// §"Tempo and the Tempo Map").
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct TempoMapReference;
|
||
|
||
// --- Time models (Chapter 3 §"Region Time Models"). -------------------------
|
||
|
||
/// The anchoring discipline of an aleatoric region (Chapter 3 §"Aleatoric
|
||
/// Time"): which event coordinate kinds the region permits.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub enum AleatoricAnchoringDiscipline {
|
||
/// Musical-time coordinates only.
|
||
Musical,
|
||
/// Wall-clock coordinates only.
|
||
WallClock,
|
||
/// Either kind per event, but an event's position and duration kinds must
|
||
/// agree and duration bounds use a single concrete variant.
|
||
EitherPerEvent,
|
||
/// Either kind freely; duration bounds may mix kinds.
|
||
FreelyMixed,
|
||
}
|
||
|
||
/// A meter change within a metric grid (Chapter 5 §"Staff-Based Content").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct MeterChange {
|
||
/// Where this meter takes effect, within the enclosing staff or region.
|
||
pub anchor: TimeAnchor,
|
||
/// The active time signature until the next change.
|
||
pub time_signature: TimeSignatureId,
|
||
}
|
||
|
||
/// A strictly-positive power-of-two time-signature denominator (Chapter 3
|
||
/// §"Time Signatures and Meter"): the note value `1` (whole), `2` (half), `4`
|
||
/// (quarter), …. The spec types a `Standard`/`Compound` denominator as
|
||
/// `PowerOfTwo`, so a zero or non-power-of-two value is unrepresentable by
|
||
/// construction — an irrational meter (`4/6`, `5/12`) uses the dedicated
|
||
/// [`TimeSignatureDisplay::Irrational`] variant instead.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub struct PowerOfTwo(u16);
|
||
|
||
impl PowerOfTwo {
|
||
/// Builds a power-of-two denominator, returning `None` unless `value` is a
|
||
/// strictly-positive power of two.
|
||
#[inline]
|
||
pub const fn new(value: u16) -> Option<Self> {
|
||
if value != 0 && value.is_power_of_two() {
|
||
Some(PowerOfTwo(value))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The denominator value (always a positive power of two).
|
||
#[inline]
|
||
pub const fn get(self) -> u16 {
|
||
self.0
|
||
}
|
||
}
|
||
|
||
/// How a time signature is displayed (Chapter 3 §"Time Signatures and Meter").
|
||
/// The denominator *type* per variant carries the spec's display constraint: a
|
||
/// `Standard`/`Compound` denominator is a [`PowerOfTwo`]; an `Irrational` or
|
||
/// `MixedDenominators` denominator is any [`NonZeroU16`]. Zero denominators are
|
||
/// unrepresentable. None of this affects the rational `measure_duration`.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum TimeSignatureDisplay {
|
||
Standard {
|
||
numerator: u16,
|
||
denominator: PowerOfTwo,
|
||
},
|
||
Compound {
|
||
numerators: Vec<u16>,
|
||
denominator: PowerOfTwo,
|
||
},
|
||
/// Irrational meter: the denominator is *not* a power of two (`4/6`, `5/12`).
|
||
Irrational {
|
||
numerator: u16,
|
||
denominator: NonZeroU16,
|
||
},
|
||
MixedDenominators {
|
||
components: Vec<(u16, NonZeroU16)>,
|
||
},
|
||
None,
|
||
/// Custom symbol (cut time, common time, grammar-specific). Placeholder id.
|
||
Symbolic(u32),
|
||
}
|
||
|
||
/// A beat group within a measure (Chapter 3 §"Time Signatures and Meter").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct BeatGroup {
|
||
pub duration: MusicalDuration,
|
||
pub subdivision: Option<MusicalDuration>,
|
||
/// Accent strength relative to other beat groups; higher is stronger.
|
||
pub accent: u8,
|
||
}
|
||
|
||
/// A time signature object in the score graph (Chapter 3 §"Time Signatures and
|
||
/// Meter"): not merely a numerator/denominator pair. The beat-group durations
|
||
/// must sum to `measure_duration` — enforced by [`TimeSignature::new`], the
|
||
/// spec's "reject at construction" discipline ("Implementations MUST reject time
|
||
/// signatures whose beat groups do not sum to the measure duration").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct TimeSignature {
|
||
pub id: TimeSignatureId,
|
||
pub display: TimeSignatureDisplay,
|
||
measure_duration: MusicalDuration,
|
||
beat_groups: Vec<BeatGroup>,
|
||
}
|
||
|
||
impl TimeSignature {
|
||
/// Builds a time signature, returning `None` unless the beat-group durations
|
||
/// sum exactly to `measure_duration` (Chapter 3). Fields are private so the
|
||
/// invariant cannot be broken after construction.
|
||
pub fn new(
|
||
id: TimeSignatureId,
|
||
display: TimeSignatureDisplay,
|
||
measure_duration: MusicalDuration,
|
||
beat_groups: Vec<BeatGroup>,
|
||
) -> Option<Self> {
|
||
let sum = MusicalDuration::sum(beat_groups.iter().map(|b| &b.duration));
|
||
if sum == measure_duration {
|
||
Some(TimeSignature {
|
||
id,
|
||
display,
|
||
measure_duration,
|
||
beat_groups,
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The total duration of one measure under this signature.
|
||
pub fn measure_duration(&self) -> &MusicalDuration {
|
||
&self.measure_duration
|
||
}
|
||
|
||
/// The beat groups (whose durations sum to [`Self::measure_duration`]).
|
||
pub fn beat_groups(&self) -> &[BeatGroup] {
|
||
&self.beat_groups
|
||
}
|
||
}
|
||
|
||
/// A per-staff (or per-region-default) metric organization (Chapter 5).
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct MetricGrid {
|
||
pub meter_sequence: Vec<MeterChange>,
|
||
}
|
||
|
||
/// The metric time model of a region (Chapter 3 §"Metric Time").
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct MetricTimeModel {
|
||
pub meters: Vec<MeterChange>,
|
||
pub tempo: TempoMapReference,
|
||
}
|
||
|
||
/// The proportional time model of a region (Chapter 3 §"Proportional Time").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct ProportionalTimeModel {
|
||
/// Total wall-clock duration of the region.
|
||
pub duration: WallClockDuration,
|
||
}
|
||
|
||
/// An acyclic ordering of events in an aleatoric region (Chapter 3 §"Aleatoric
|
||
/// Time"): an edge `a -> b` means "a precedes b"; two events with no path
|
||
/// between them are unordered. The DAG **must be acyclic** (Chapter 3:
|
||
/// "Cycles MUST be rejected at construction"), so the only constructors —
|
||
/// [`EventOrderingDAG::default`] (empty) and [`EventOrderingDAG::try_new`]
|
||
/// (cycle-checked) — cannot produce a cyclic graph, and the adjacency is
|
||
/// private so it stays that way.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct EventOrderingDAG {
|
||
/// `edges[a]` lists the events that directly follow `a`.
|
||
edges: std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>>,
|
||
}
|
||
|
||
impl EventOrderingDAG {
|
||
/// Builds a DAG from adjacency, returning `None` if it contains a cycle
|
||
/// (Chapter 3: cycles are rejected at construction).
|
||
pub fn try_new(
|
||
edges: std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>>,
|
||
) -> Option<Self> {
|
||
let dag = EventOrderingDAG { edges };
|
||
if dag.is_acyclic() {
|
||
Some(dag)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The events that directly follow `event`.
|
||
pub fn successors(&self, event: crate::ids::EventId) -> &[crate::ids::EventId] {
|
||
self.edges.get(&event).map(|v| v.as_slice()).unwrap_or(&[])
|
||
}
|
||
|
||
/// The raw adjacency map, for the canonical codec (which must serialize the
|
||
/// full ordering, not only the events reachable from a query).
|
||
pub(crate) fn edges_ref(
|
||
&self,
|
||
) -> &std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>> {
|
||
&self.edges
|
||
}
|
||
|
||
/// Every event the ordering names — DAG nodes (sources) and edge targets.
|
||
/// Used by the invariant checker to confirm the ordering only references
|
||
/// events that exist in the region (invariant 10).
|
||
pub fn referenced_events(&self) -> BTreeSet<crate::ids::EventId> {
|
||
let mut set = BTreeSet::new();
|
||
for (source, targets) in &self.edges {
|
||
set.insert(*source);
|
||
set.extend(targets.iter().copied());
|
||
}
|
||
set
|
||
}
|
||
|
||
/// Whether the ordering is acyclic (always true for a value built through
|
||
/// the public constructors).
|
||
pub fn is_acyclic(&self) -> bool {
|
||
// Iterative DFS three-colour cycle detection over the adjacency.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum Mark {
|
||
Open,
|
||
Done,
|
||
}
|
||
let mut state: std::collections::BTreeMap<crate::ids::EventId, Mark> =
|
||
std::collections::BTreeMap::new();
|
||
// Stack entries: (node, expanded?) — expanded marks the post-visit.
|
||
let nodes: Vec<crate::ids::EventId> = self.edges.keys().copied().collect();
|
||
for root in nodes {
|
||
if state.contains_key(&root) {
|
||
continue;
|
||
}
|
||
let mut stack = vec![(root, false)];
|
||
while let Some((node, expanded)) = stack.pop() {
|
||
if expanded {
|
||
state.insert(node, Mark::Done);
|
||
continue;
|
||
}
|
||
if let Some(Mark::Done) = state.get(&node) {
|
||
continue;
|
||
}
|
||
state.insert(node, Mark::Open);
|
||
stack.push((node, true));
|
||
for &next in self.successors(node) {
|
||
match state.get(&next) {
|
||
Some(Mark::Open) => return false, // back-edge: cycle
|
||
Some(Mark::Done) => {}
|
||
None => stack.push((next, false)),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
}
|
||
|
||
/// The aleatoric time model of a region (Chapter 3 §"Aleatoric Time").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct AleatoricTimeModel {
|
||
/// Ordering constraints among events (acyclic by construction).
|
||
pub ordering: EventOrderingDAG,
|
||
/// Which event coordinate kinds this region permits.
|
||
pub anchoring: AleatoricAnchoringDiscipline,
|
||
/// Optional per-event interval bounds.
|
||
pub bounds: std::collections::BTreeMap<crate::ids::EventId, crate::time::EventBounds>,
|
||
/// Approximate or maximum total duration, for layout.
|
||
pub duration_hint: WallClockDuration,
|
||
}
|
||
|
||
/// A region's time model (Chapter 3 §"Region Time Models").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum RegionTimeModel {
|
||
Metric(MetricTimeModel),
|
||
Proportional(ProportionalTimeModel),
|
||
Aleatoric(AleatoricTimeModel),
|
||
}
|
||
|
||
/// How a region constrains event coordinate kinds, derived from its time model
|
||
/// (Chapter 5 invariant 4).
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub enum CoordinateDiscipline {
|
||
/// Musical coordinates only (metric regions).
|
||
Musical,
|
||
/// Wall-clock coordinates only (proportional regions).
|
||
WallClock,
|
||
/// Governed by the region's aleatoric anchoring discipline.
|
||
Aleatoric(AleatoricAnchoringDiscipline),
|
||
}
|
||
|
||
impl RegionTimeModel {
|
||
/// The coordinate discipline this time model imposes on its events.
|
||
pub fn coordinate_discipline(&self) -> CoordinateDiscipline {
|
||
match self {
|
||
RegionTimeModel::Metric(_) => CoordinateDiscipline::Musical,
|
||
RegionTimeModel::Proportional(_) => CoordinateDiscipline::WallClock,
|
||
RegionTimeModel::Aleatoric(a) => CoordinateDiscipline::Aleatoric(a.anchoring),
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Staves, instances, voices, measures (Chapter 5). -----------------------
|
||
|
||
/// The provenance of a voice (Chapter 5 §"Voices"). A voice's `origin` must be
|
||
/// consistent with how it was created (invariant 18).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum VoiceOrigin {
|
||
/// Created by an explicit user action.
|
||
UserDeclared,
|
||
/// Imported from a foreign format.
|
||
Imported { format: ForeignFormatId },
|
||
/// System-promoted to resolve a concurrent-edit collision (Chapter 5
|
||
/// §"System-Promoted Voices").
|
||
SystemPromoted {
|
||
/// The lower-id concurrent operation that retained the original voice.
|
||
winning_operation: OperationId,
|
||
/// The greater-id concurrent operation moved into this voice.
|
||
losing_operation: OperationId,
|
||
original_voice: VoiceId,
|
||
},
|
||
}
|
||
|
||
/// Derives the deterministic [`VoiceId`] of a system-promoted voice from the
|
||
/// fixed function of (staff instance, original voice, winning op, losing op)
|
||
/// in Chapter 5 §"System-Promoted Voices", placed in the
|
||
/// [`crate::ReplicaId::SYSTEM_DERIVED`] namespace via the `MUSCSVCE` domain
|
||
/// tag.
|
||
///
|
||
/// The spec defers "the exact derivation function" to the semantic-operations
|
||
/// companion (Agent C); this is the prototype's concrete, deterministic
|
||
/// realization — the canonical inputs are the four identifiers' 16-byte forms
|
||
/// concatenated in the listed order. Recorded as a Pass 11 candidate in
|
||
/// `DECISIONS.md` so the companion can pin it.
|
||
pub fn derive_promoted_voice_id(
|
||
staff_instance: StaffInstanceId,
|
||
original_voice: VoiceId,
|
||
winning_op: OperationId,
|
||
losing_op: OperationId,
|
||
) -> VoiceId {
|
||
let mut inputs = Vec::with_capacity(64);
|
||
inputs.extend_from_slice(&staff_instance.canonical_bytes());
|
||
inputs.extend_from_slice(&original_voice.canonical_bytes());
|
||
inputs.extend_from_slice(&winning_op.canonical_bytes());
|
||
inputs.extend_from_slice(&losing_op.canonical_bytes());
|
||
derive_system_id::<VoiceId>(SystemDomainTag::VOICE, &inputs)
|
||
}
|
||
|
||
/// A polyphonic line within a staff instance (Chapter 5 §"Voices"). Holds
|
||
/// ordered references to events; the events live in the arena.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Voice {
|
||
pub id: VoiceId,
|
||
/// Ordered references to events in this voice, sorted by position. Each
|
||
/// referenced event has `voice == this voice's id`.
|
||
pub events: Vec<crate::ids::EventId>,
|
||
pub default_stem_direction: Option<StemDirection>,
|
||
pub is_primary: bool,
|
||
pub origin: VoiceOrigin,
|
||
}
|
||
|
||
impl Voice {
|
||
/// A new user-declared voice with no events.
|
||
pub fn user(id: VoiceId) -> Self {
|
||
Voice {
|
||
id,
|
||
events: Vec::new(),
|
||
default_stem_direction: None,
|
||
is_primary: false,
|
||
origin: VoiceOrigin::UserDeclared,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A measure belonging to exactly one staff instance (Chapter 5
|
||
/// §"Staff-Based Content").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Measure {
|
||
pub id: MeasureId,
|
||
pub start: TimeAnchor,
|
||
pub time_signature: Option<TimeSignatureId>,
|
||
pub explicit_number: Option<u32>,
|
||
pub number_visibility: MeasureNumberVisibility,
|
||
}
|
||
|
||
/// A region-local manifestation of a globally-identified [`Staff`]
|
||
/// (Chapter 5 §"Staves: Identity Versus Instance"). Distinct from `Staff`:
|
||
/// a `Staff` is the abstract identity persisting across the score; a
|
||
/// `StaffInstance` is its content for the duration of one region.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct StaffInstance {
|
||
pub id: StaffInstanceId,
|
||
/// The globally-identified staff this instance manifests.
|
||
pub staff: StaffId,
|
||
pub voices: Vec<Voice>,
|
||
pub clef_sequence: Vec<ClefChange>,
|
||
pub key_sequence: Vec<KeySignatureChange>,
|
||
pub local_metric_grid: Option<MetricGrid>,
|
||
/// Measures belonging to this instance, in order (per-staff; admits
|
||
/// polymeter).
|
||
pub measures: Vec<Measure>,
|
||
pub instrument_override: Option<InstrumentId>,
|
||
pub staff_lines_override: Option<StaffLineConfiguration>,
|
||
pub visible: bool,
|
||
}
|
||
|
||
impl StaffInstance {
|
||
/// A new empty instance of `staff`.
|
||
pub fn new(id: StaffInstanceId, staff: StaffId) -> Self {
|
||
StaffInstance {
|
||
id,
|
||
staff,
|
||
voices: Vec::new(),
|
||
clef_sequence: Vec::new(),
|
||
key_sequence: Vec::new(),
|
||
local_metric_grid: None,
|
||
measures: Vec::new(),
|
||
instrument_override: None,
|
||
staff_lines_override: None,
|
||
visible: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A declaration that the measure boundaries of two or more staff instances
|
||
/// coincide (Chapter 5 §"Staff-Based Content").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct BarlineAlignmentGroup {
|
||
pub id: BarlineAlignmentGroupId,
|
||
pub members: Vec<BarlineAlignmentMember>,
|
||
}
|
||
|
||
/// One member of a [`BarlineAlignmentGroup`].
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct BarlineAlignmentMember {
|
||
pub staff_instance: StaffInstanceId,
|
||
pub measure: MeasureId,
|
||
pub position: MeasurePosition,
|
||
}
|
||
|
||
/// The staff-based content of a region (Chapter 5 §"Staff-Based Content").
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct StaffBasedContent {
|
||
pub staff_instances: Vec<StaffInstance>,
|
||
pub default_metric_grid: Option<MetricGrid>,
|
||
pub barline_alignment_groups: Vec<BarlineAlignmentGroup>,
|
||
pub user_system_breaks: Vec<TimeAnchor>,
|
||
pub user_page_breaks: Vec<TimeAnchor>,
|
||
}
|
||
|
||
/// A region's content model (Chapter 5 §"Regions").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum RegionContent {
|
||
/// Staff-based notation.
|
||
StaffBased(StaffBasedContent),
|
||
/// Free graphic content: no staves.
|
||
FreeGraphic(GraphicContent),
|
||
/// Staves with overlaid graphic content.
|
||
Hybrid {
|
||
staves: StaffBasedContent,
|
||
overlay: GraphicContent,
|
||
overlay_below_staves: bool,
|
||
},
|
||
}
|
||
|
||
impl RegionContent {
|
||
/// The staff instances of this content, or `&[]` for free-graphic content.
|
||
pub fn staff_instances(&self) -> &[StaffInstance] {
|
||
match self {
|
||
RegionContent::StaffBased(c) => &c.staff_instances,
|
||
RegionContent::Hybrid { staves, .. } => &staves.staff_instances,
|
||
RegionContent::FreeGraphic(_) => &[],
|
||
}
|
||
}
|
||
|
||
/// The graphic objects this content places, if any (free-graphic content,
|
||
/// or a hybrid region's overlay). Empty for purely staff-based content.
|
||
pub fn graphic_objects(&self) -> &[GraphicObject] {
|
||
match self {
|
||
RegionContent::FreeGraphic(g) => &g.objects,
|
||
RegionContent::Hybrid { overlay, .. } => &overlay.objects,
|
||
RegionContent::StaffBased(_) => &[],
|
||
}
|
||
}
|
||
|
||
/// The staff-based content, if this region is staff-based or hybrid.
|
||
pub fn staff_based(&self) -> Option<&StaffBasedContent> {
|
||
match self {
|
||
RegionContent::StaffBased(c) => Some(c),
|
||
RegionContent::Hybrid { staves, .. } => Some(staves),
|
||
RegionContent::FreeGraphic(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Mutable staff-based content, if this region is staff-based or hybrid.
|
||
pub fn staff_based_mut(&mut self) -> Option<&mut StaffBasedContent> {
|
||
match self {
|
||
RegionContent::StaffBased(c) => Some(c),
|
||
RegionContent::Hybrid { staves, .. } => Some(staves),
|
||
RegionContent::FreeGraphic(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Mutable access to the staff instances, if this content has any (used by
|
||
/// editing and by the invariant shrinker in [`crate::generators`]).
|
||
pub fn staff_instances_mut(&mut self) -> Option<&mut Vec<StaffInstance>> {
|
||
match self {
|
||
RegionContent::StaffBased(c) => Some(&mut c.staff_instances),
|
||
RegionContent::Hybrid { staves, .. } => Some(&mut staves.staff_instances),
|
||
RegionContent::FreeGraphic(_) => None,
|
||
}
|
||
}
|
||
|
||
/// The barline-alignment groups of this content, if staff-based.
|
||
pub fn barline_alignment_groups(&self) -> &[BarlineAlignmentGroup] {
|
||
match self {
|
||
RegionContent::StaffBased(c) => &c.barline_alignment_groups,
|
||
RegionContent::Hybrid { staves, .. } => &staves.barline_alignment_groups,
|
||
RegionContent::FreeGraphic(_) => &[],
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The time range a region occupies (Chapter 5 §"Regions").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct TimeExtent {
|
||
pub start: TimeAnchor,
|
||
pub end: TimeAnchor,
|
||
}
|
||
|
||
impl TimeExtent {
|
||
/// The extent as an absolute wall-clock interval, when both endpoints are
|
||
/// [`TimeAnchor::WallClock`] anchors (the only extents this prototype can
|
||
/// resolve without the full tempo/measure machinery; see
|
||
/// [`Region::overlaps_in_time`]).
|
||
pub fn as_wallclock(&self) -> Option<(i64, i64)> {
|
||
match (&self.start, &self.end) {
|
||
(TimeAnchor::WallClock { time: a }, TimeAnchor::WallClock { time: b }) => {
|
||
Some((a.0, b.0))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The vertical staff range a region occupies (Chapter 5 §"Regions").
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct StaffExtent {
|
||
/// The globally-identified staves whose content this region carries.
|
||
pub staves: Vec<StaffId>,
|
||
}
|
||
|
||
/// A region of the canvas (Chapter 5 §"Regions").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Region {
|
||
pub id: RegionId,
|
||
pub time_model: RegionTimeModel,
|
||
pub content: RegionContent,
|
||
pub time_extent: TimeExtent,
|
||
pub staff_extent: StaffExtent,
|
||
pub local_tempo_map: Option<crate::tempo::TempoMap>,
|
||
}
|
||
|
||
impl Region {
|
||
/// The staff instances manifested in this region.
|
||
pub fn staff_instances(&self) -> &[StaffInstance] {
|
||
self.content.staff_instances()
|
||
}
|
||
|
||
/// Whether two regions demonstrably overlap in time. Resolvable only for
|
||
/// wall-clock extents in this prototype; symbolic (event/measure/region)
|
||
/// anchors return `false` (cannot prove overlap without the full tempo and
|
||
/// measure machinery — a sound but incomplete check). Half-open intervals:
|
||
/// touching at a boundary does not overlap.
|
||
pub fn overlaps_in_time(&self, other: &Region) -> bool {
|
||
match (
|
||
self.time_extent.as_wallclock(),
|
||
other.time_extent.as_wallclock(),
|
||
) {
|
||
(Some((a0, a1)), Some((b0, b1))) => a0 < b1 && b0 < a1,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Whether the staff extents of two regions intersect.
|
||
pub fn staff_extent_intersects(&self, other: &Region) -> bool {
|
||
let mine: BTreeSet<StaffId> = self.staff_extent.staves.iter().copied().collect();
|
||
other.staff_extent.staves.iter().any(|s| mine.contains(s))
|
||
}
|
||
}
|
||
|
||
/// A global, abstract staff identity persisting across the score (Chapter 5
|
||
/// §"Staves: Identity Versus Instance").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Staff {
|
||
pub id: StaffId,
|
||
pub name: String,
|
||
pub abbreviation: Option<String>,
|
||
pub instrument: InstrumentId,
|
||
pub default_staff_lines: StaffLineConfiguration,
|
||
pub group: Option<StaffGroupId>,
|
||
}
|
||
|
||
/// The spatial root of the score (Chapter 5 §"The Canvas").
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct Canvas {
|
||
pub regions: Vec<Region>,
|
||
|
||
/// Canvas-level layout defaults — page size and margins (Chapter 5;
|
||
/// schema major 1). A solver reads these as its default page geometry and
|
||
/// MAY override per solve.
|
||
pub layout_defaults: CanvasLayoutDefaults,
|
||
}
|
||
|
||
/// Canvas-level layout defaults (Chapter 5 §"The Canvas"; schema major 1). Page
|
||
/// size and margins are in staff spaces (1 staff space = staff height / 4). The
|
||
/// [`Default`] is A4 portrait at an 8 mm staff.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub struct CanvasLayoutDefaults {
|
||
pub page_size: CanvasSize,
|
||
pub margins: CanvasMargins,
|
||
}
|
||
|
||
/// A page size in staff spaces.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub struct CanvasSize {
|
||
pub width: CanonicalF64,
|
||
pub height: CanonicalF64,
|
||
}
|
||
|
||
/// Page margins in staff spaces.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub struct CanvasMargins {
|
||
pub top: CanonicalF64,
|
||
pub right: CanonicalF64,
|
||
pub bottom: CanonicalF64,
|
||
pub left: CanonicalF64,
|
||
}
|
||
|
||
impl Default for CanvasLayoutDefaults {
|
||
/// **A4 portrait at an 8 mm staff** (1 staff space = 2 mm): page
|
||
/// 105 × 148.5 staff spaces, 7.5-staff-space margins (a 90 × 133.5 content
|
||
/// area) — the reference engraver's documented default geometry.
|
||
fn default() -> Self {
|
||
let ss = |v: f64| CanonicalF64::new(v).expect("finite staff-space default");
|
||
CanvasLayoutDefaults {
|
||
page_size: CanvasSize {
|
||
width: ss(105.0),
|
||
height: ss(148.5),
|
||
},
|
||
margins: CanvasMargins {
|
||
top: ss(7.5),
|
||
right: ss(7.5),
|
||
bottom: ss(7.5),
|
||
left: ss(7.5),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Cross-cutting structures bearing graph references (Chapter 5). ---------
|
||
//
|
||
// Modeled here are the structures whose references the invariants check
|
||
// (invariant 10) plus ties and tuplets (invariants 16, 17). The full
|
||
// cross-cutting registry (markers, repeats, analytical annotations, comments,
|
||
// graphic gestures, lyrics, chord symbols) extends this with the same
|
||
// reference-resolution discipline.
|
||
|
||
/// A slur / phrase mark over a span of events (Chapter 5 §"Slurs").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Slur {
|
||
pub id: SlurId,
|
||
pub start_event: crate::ids::EventId,
|
||
pub end_event: crate::ids::EventId,
|
||
}
|
||
|
||
/// A beam over a sequence of events (Chapter 5 §"Beams").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Beam {
|
||
pub id: BeamId,
|
||
pub events: Vec<crate::ids::EventId>,
|
||
pub level: u8,
|
||
}
|
||
|
||
/// A generic spanning mark anchored by time (Chapter 5 §"Spanners").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Spanner {
|
||
pub id: SpannerId,
|
||
pub start: TimeAnchor,
|
||
pub end: TimeAnchor,
|
||
/// Which staves this spanner attaches to.
|
||
pub staves: Vec<StaffId>,
|
||
}
|
||
|
||
/// The class of a tie, fixing its validation profile (Chapter 5 §"Ties").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum TieClass {
|
||
/// Same voice, immediate adjacency in voice order, enharmonic pitches.
|
||
Standard,
|
||
/// Same voice, start precedes end, intervening events permitted.
|
||
Editorial,
|
||
/// Across voices on the same staff instance; start position <= end.
|
||
CrossVoice,
|
||
/// Trailing tie with no notated end (`end_event` may equal `start_event`).
|
||
LaissezVibrer,
|
||
/// Registered class with custom validation behaviour.
|
||
Registered(crate::pitch::TieClassRegistryId),
|
||
}
|
||
|
||
/// A tie between two events, pairing pitches by stable [`PitchId`] (Chapter 5
|
||
/// §"Ties").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Tie {
|
||
pub id: crate::ids::TieId,
|
||
pub start_event: crate::ids::EventId,
|
||
pub end_event: crate::ids::EventId,
|
||
/// Explicit `(start_pitch, end_pitch)` pairing; `None` means pair all
|
||
/// pitches by enharmonic matching in pitch-id-ascending order.
|
||
pub pitch_pairing: Option<Vec<(PitchId, PitchId)>>,
|
||
pub class: TieClass,
|
||
}
|
||
|
||
/// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). Built only
|
||
/// through [`TupletRatio::new`], which rejects degenerate ratios at
|
||
/// construction (Chapter 3 §"Tuplets", `req:time:tuplet-ratio-construction`):
|
||
/// both terms must be nonzero and `actual != notated`, so a degenerate ratio is
|
||
/// never a representable graph state. The fields are private to keep that
|
||
/// guarantee unbypassable.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub struct TupletRatio {
|
||
actual: u32,
|
||
notated: u32,
|
||
}
|
||
|
||
impl TupletRatio {
|
||
/// Builds an `actual:notated` ratio, returning `None` for a *degenerate*
|
||
/// ratio — either term zero, or `actual == notated` (a ratio that expresses
|
||
/// no augmentation or diminution). This is the construction-time MUST of
|
||
/// Chapter 3 §"Tuplets": degeneracy is rejected here, not by a runtime
|
||
/// invariant.
|
||
pub fn new(actual: u32, notated: u32) -> Option<Self> {
|
||
if actual == 0 || notated == 0 || actual == notated {
|
||
None
|
||
} else {
|
||
Some(TupletRatio { actual, notated })
|
||
}
|
||
}
|
||
|
||
/// The `actual` term: how many notes are played.
|
||
#[inline]
|
||
pub const fn actual(&self) -> u32 {
|
||
self.actual
|
||
}
|
||
|
||
/// The `notated` term: in the time of how many.
|
||
#[inline]
|
||
pub const fn notated(&self) -> u32 {
|
||
self.notated
|
||
}
|
||
}
|
||
|
||
/// A tuplet grouping object (Chapter 3 §"Tuplets as Grouping Objects").
|
||
/// Tuplets do not modify member sounding durations; the ratio is notational.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Tuplet {
|
||
pub id: TupletId,
|
||
pub ratio: TupletRatio,
|
||
pub members: Vec<crate::ids::EventId>,
|
||
pub parent: Option<TupletId>,
|
||
/// The structurally-required total sounding duration of the members
|
||
/// (Chapter 3 §"Tuplet Consistency"; invariant 16). For a 3:2 eighth-note
|
||
/// triplet of three members this is `1/4`.
|
||
pub required_total: MusicalDuration,
|
||
}
|
||
|
||
/// Where a point/range annotation attaches (Chapter 5 §"Analytical
|
||
/// Annotations"). Shared by annotations and comments.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum AnnotationAnchor {
|
||
Event(crate::ids::EventId),
|
||
Range { start: TimeAnchor, end: TimeAnchor },
|
||
Region(RegionId),
|
||
}
|
||
|
||
/// A point marker: rehearsal mark, segno, tempo text, … (Chapter 5 §"Markers").
|
||
/// The visual `kind` is Chapter 7's; the load-bearing field is the anchor.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Marker {
|
||
pub id: crate::ids::MarkerId,
|
||
pub anchor: TimeAnchor,
|
||
}
|
||
|
||
/// A repeat structure: simple repeat, da capo, dal segno, volta (Chapter 5
|
||
/// §"Repeat Structures"). Spanned by two time anchors.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct RepeatStructure {
|
||
pub id: RepeatStructureId,
|
||
pub start: TimeAnchor,
|
||
pub end: TimeAnchor,
|
||
}
|
||
|
||
/// An analytical annotation (Roman numeral, form label, …) (Chapter 5
|
||
/// §"Analytical Annotations").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct AnalyticalAnnotation {
|
||
pub id: crate::ids::AnalyticalAnnotationId,
|
||
pub anchor: AnnotationAnchor,
|
||
pub layer: Option<crate::ids::AnalysisLayerId>,
|
||
}
|
||
|
||
/// A review-mode comment thread anchored to a point or range (Chapter 5
|
||
/// §"Comments").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Comment {
|
||
pub id: crate::ids::CommentId,
|
||
pub anchor: AnnotationAnchor,
|
||
pub resolved: bool,
|
||
}
|
||
|
||
/// How a graphic gesture is positioned relative to score content (Chapter 5
|
||
/// §"Graphic Gestures").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum GestureAnchoring {
|
||
/// Anchored to events: the gesture moves with them.
|
||
Events(Vec<crate::ids::EventId>),
|
||
/// Anchored to a time and staff range.
|
||
Range {
|
||
start: TimeAnchor,
|
||
end: TimeAnchor,
|
||
staves: Vec<StaffId>,
|
||
},
|
||
/// Free canvas coordinates: does not follow score edits.
|
||
Free,
|
||
}
|
||
|
||
/// A drawn gesture spanning events, staves, or regions (Chapter 5
|
||
/// §"Graphic Gestures").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct GraphicGesture {
|
||
pub id: crate::ids::GraphicGestureId,
|
||
pub objects: Vec<crate::ids::GraphicObjectId>,
|
||
pub anchoring: GestureAnchoring,
|
||
}
|
||
|
||
/// A lyric line attached to a sequence of events (Chapter 5
|
||
/// §"Cross-Cutting Structures"). Baseline: the event references it carries.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct LyricLine {
|
||
pub id: LyricLineId,
|
||
pub events: Vec<crate::ids::EventId>,
|
||
}
|
||
|
||
/// A chord symbol anchored to a point in time (Chapter 5
|
||
/// §"Cross-Cutting Structures").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct ChordSymbol {
|
||
pub id: ChordSymbolId,
|
||
pub anchor: TimeAnchor,
|
||
}
|
||
|
||
/// The cross-cutting structures of a score (Chapter 5 §"Cross-Cutting
|
||
/// Structures"): musical phenomena that hold references spanning the
|
||
/// containment tree.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct CrossCuttingRegistry {
|
||
pub slurs: Vec<Slur>,
|
||
pub ties: Vec<Tie>,
|
||
pub beams: Vec<Beam>,
|
||
pub tuplets: Vec<Tuplet>,
|
||
pub spanners: Vec<Spanner>,
|
||
pub markers: Vec<Marker>,
|
||
pub repeats: Vec<RepeatStructure>,
|
||
pub analytical: Vec<AnalyticalAnnotation>,
|
||
pub comments: Vec<Comment>,
|
||
pub graphic_gestures: Vec<GraphicGesture>,
|
||
pub lyrics: Vec<LyricLine>,
|
||
pub chord_symbols: Vec<ChordSymbol>,
|
||
}
|
||
|
||
// --- Notational decomposition attachments (Chapter 3; invariants 14, 15). ---
|
||
|
||
/// Provenance of a decomposition attachment (Chapter 3 §"Notational
|
||
/// Decomposition"), mirroring [`crate::SpellingSource`].
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum DecompositionSource {
|
||
UserChosen,
|
||
Inferred,
|
||
Imported { format: ForeignFormatId },
|
||
Propagated { from: crate::ids::EventId },
|
||
}
|
||
|
||
/// A base notated note value (Chapter 3 §"Sounding Duration and Notational
|
||
/// Decomposition"). Each is half the previous; the whole note is `1/1`.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||
pub enum NoteValue {
|
||
Whole,
|
||
Half,
|
||
Quarter,
|
||
Eighth,
|
||
Sixteenth,
|
||
ThirtySecond,
|
||
SixtyFourth,
|
||
}
|
||
|
||
impl NoteValue {
|
||
/// The undotted value as a fraction of a whole note (`Quarter` -> `1/4`).
|
||
pub fn whole_note_fraction(self) -> MusicalDuration {
|
||
let denom: i64 = match self {
|
||
NoteValue::Whole => 1,
|
||
NoteValue::Half => 2,
|
||
NoteValue::Quarter => 4,
|
||
NoteValue::Eighth => 8,
|
||
NoteValue::Sixteenth => 16,
|
||
NoteValue::ThirtySecond => 32,
|
||
NoteValue::SixtyFourth => 64,
|
||
};
|
||
MusicalDuration(crate::time::RationalTime::new(1, denom).expect("nonzero denom"))
|
||
}
|
||
}
|
||
|
||
/// One notated component of an event's rhythm (Chapter 3 §"The Notational
|
||
/// Decomposition"): a base value with augmentation dots, optional tuplet
|
||
/// membership, and a tie flag.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct NotatedComponent {
|
||
pub base_value: NoteValue,
|
||
/// Augmentation dots (each adds half the previous increment).
|
||
pub dots: u8,
|
||
/// Tuplet membership, if any (scales the sounding duration).
|
||
pub tuplet: Option<TupletId>,
|
||
/// Whether this component is tied to the next.
|
||
pub tied_to_next: bool,
|
||
}
|
||
|
||
impl NotatedComponent {
|
||
/// The *notated* duration (base value with dots), ignoring any tuplet
|
||
/// scaling: the undotted value plus each augmentation dot's
|
||
/// successively-halved increment, i.e. `base * (2 - 2^(-dots))`.
|
||
///
|
||
/// Computed by exact halving rather than the closed `(2^(dots+1)-1)/2^dots`
|
||
/// form, so *every* dot count receives its defined augmentation semantics:
|
||
/// the count is never silently clamped, and [`crate::RationalTime`] promotes
|
||
/// to arbitrary precision rather than overflowing the shift (Chapter 3
|
||
/// §"Sounding Duration and Notational Decomposition").
|
||
pub fn notated_duration(&self) -> MusicalDuration {
|
||
let base = self.base_value.whole_note_fraction();
|
||
let half = crate::time::RationalTime::new(1, 2).expect("nonzero");
|
||
let mut increment = base.rational().clone();
|
||
let mut total = base.rational().clone();
|
||
for _ in 0..self.dots {
|
||
increment = increment.mul(&half);
|
||
total = total.add(&increment);
|
||
}
|
||
MusicalDuration(total)
|
||
}
|
||
|
||
/// The *sounding* duration: the notated duration scaled by a tuplet ratio
|
||
/// (`actual:notated` ⇒ ×`notated/actual`) when the component is in one. An
|
||
/// eighth in a 3:2 triplet sounds `1/8 × 2/3 = 1/12`.
|
||
pub fn sounding_duration(&self, tuplet_ratio: Option<TupletRatio>) -> MusicalDuration {
|
||
let notated = self.notated_duration();
|
||
match tuplet_ratio {
|
||
Some(r) if r.actual() != 0 && r.notated() != 0 => {
|
||
let scale = crate::time::RationalTime::new(r.notated() as i64, r.actual() as i64)
|
||
.expect("validated nonzero");
|
||
MusicalDuration(notated.rational().mul(&scale))
|
||
}
|
||
_ => notated,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A notational-decomposition attachment on an event (Chapter 3): the sequence
|
||
/// of notated components whose sounding durations sum to the event's sounding
|
||
/// duration (invariant 15). The base-value/dots → duration mapping is here; the
|
||
/// *choice* of decomposition (the pre-pass) is deferred (Appendix D §"Open
|
||
/// Algorithm Hooks").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct DecompositionAttachment {
|
||
pub target: crate::ids::EventId,
|
||
pub components: Vec<NotatedComponent>,
|
||
pub source: DecompositionSource,
|
||
}
|
||
|
||
// --- Top-level score structure (Chapter 5 §"Top-Level Score Structure"). ----
|
||
//
|
||
// These carry the Chapter 5 top-level shape. Their deep bodies (tuning
|
||
// resolution, tempo curves, part layout, view recipes) are Chapters 3/4/7 and
|
||
// later companions; this baseline models the identity- and reference-bearing
|
||
// skeleton and leaves the rest as documented placeholders.
|
||
|
||
/// Bibliographic and authorship metadata (Chapter 5 §"Score Metadata"). The
|
||
/// structure is deliberately small.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct ScoreMetadata {
|
||
pub title: Option<String>,
|
||
pub composer: Option<String>,
|
||
pub copyright: Option<String>,
|
||
}
|
||
|
||
/// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the
|
||
/// identity and name; sound configuration and ranges are the audio engine's.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Instrument {
|
||
pub id: InstrumentId,
|
||
pub name: String,
|
||
}
|
||
|
||
/// The kind of a staff grouping (Chapter 5 §"Top-Level Score Structure").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum StaffGroupKind {
|
||
GrandStaff,
|
||
Bracket,
|
||
SubBracket,
|
||
Choral,
|
||
/// Custom group defined by a layout extension.
|
||
Registered(crate::pitch::StaffGroupKindRegistryId),
|
||
}
|
||
|
||
/// A staff grouping (grand staff, bracket, choral group) (Chapter 5).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct StaffGroup {
|
||
pub id: StaffGroupId,
|
||
pub name: Option<String>,
|
||
pub kind: StaffGroupKind,
|
||
pub members: Vec<StaffId>,
|
||
}
|
||
|
||
/// A per-part view definition for extraction (Chapter 5 §"Parts"). Parts are
|
||
/// projections, not storage: only references and overrides.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct PartDefinition {
|
||
pub id: PartDefinitionId,
|
||
pub name: String,
|
||
pub staves: Vec<StaffId>,
|
||
}
|
||
|
||
/// A first-class analysis layer (Chapter 5 §"Analysis Layers and Views").
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct AnalysisLayer {
|
||
pub id: crate::ids::AnalysisLayerId,
|
||
pub name: String,
|
||
}
|
||
|
||
/// A view recipe (Chapter 5 §"Views"). Baseline: identity, name, and active
|
||
/// layers; the view-kind detail is Chapter 7's.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct ViewDefinition {
|
||
pub id: ViewId,
|
||
pub name: String,
|
||
pub active_layers: Vec<crate::ids::AnalysisLayerId>,
|
||
}
|
||
|
||
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
|
||
/// the default pitch space, tuning system, and reference pitch every score must
|
||
/// declare; per-scope overrides and accidental extensions are deferred.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct ScoreTuningContext {
|
||
pub default_pitch_space: PitchSpaceId,
|
||
pub default_tuning_system: TuningSystemId,
|
||
pub reference: ReferencePitch,
|
||
}
|
||
|
||
impl Default for ScoreTuningContext {
|
||
fn default() -> Self {
|
||
// The default score configuration (Chapter 4 §"Default Score
|
||
// Configuration"): cmn-12 / tet-12 / A4 = 440 Hz.
|
||
ScoreTuningContext {
|
||
default_pitch_space: PitchSpaceId::new("cmn-12"),
|
||
default_tuning_system: TuningSystemId::new("tet-12"),
|
||
reference: ReferencePitch::a440(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The root object of a score (Chapter 5 §"Top-Level Score Structure").
|
||
///
|
||
/// This carries the full Chapter 5 top-level shape. The invariant-bearing
|
||
/// structure (canvas, staves, events, cross-cutting, attachments, identity) is
|
||
/// modeled in depth; the remaining top-level fields (metadata, instruments,
|
||
/// staff groups, parts, tuning context, tempo map, analysis layers, views)
|
||
/// carry their Chapter 5 identity/reference skeleton with deeper bodies left to
|
||
/// the consuming crates (Agents C/E) and later companions.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct Score {
|
||
pub metadata: ScoreMetadata,
|
||
pub canvas: Canvas,
|
||
pub instruments: Vec<Instrument>,
|
||
/// Globally-identified staves; each region's instances reference these.
|
||
pub staves: Vec<Staff>,
|
||
pub staff_groups: Vec<StaffGroup>,
|
||
pub parts: Vec<PartDefinition>,
|
||
pub cross_cutting: CrossCuttingRegistry,
|
||
/// Time-signature objects referenced by measures and meter changes.
|
||
pub time_signatures: Vec<TimeSignature>,
|
||
pub tuning_context: ScoreTuningContext,
|
||
pub tempo_map: crate::tempo::TempoMap,
|
||
pub events: EventArena,
|
||
pub spelling_attachments: Vec<SpellingAttachment>,
|
||
pub decomposition_attachments: Vec<DecompositionAttachment>,
|
||
pub spelling_precedence: crate::pitch::SpellingPrecedence,
|
||
pub analysis_layers: Vec<AnalysisLayer>,
|
||
pub views: Vec<ViewDefinition>,
|
||
pub identity: IdentityContext,
|
||
/// Pitch identifiers retained as tombstones (Chapter 6 territory; referenced
|
||
/// by invariants 13/14). A tombstoned id is preserved, never re-matched.
|
||
pub tombstoned_pitches: BTreeSet<PitchId>,
|
||
/// Event identifiers retained as tombstones.
|
||
pub tombstoned_events: BTreeSet<crate::ids::EventId>,
|
||
}
|
||
|
||
impl Score {
|
||
/// An empty score for the given identity context, with the default tuning
|
||
/// context (cmn-12 / tet-12 / A4 = 440).
|
||
pub fn empty(identity: IdentityContext) -> Self {
|
||
Score {
|
||
metadata: ScoreMetadata::default(),
|
||
canvas: Canvas::default(),
|
||
instruments: Vec::new(),
|
||
staves: Vec::new(),
|
||
staff_groups: Vec::new(),
|
||
parts: Vec::new(),
|
||
cross_cutting: CrossCuttingRegistry::default(),
|
||
time_signatures: Vec::new(),
|
||
tuning_context: ScoreTuningContext::default(),
|
||
tempo_map: crate::tempo::TempoMap::default(),
|
||
events: EventArena::new(),
|
||
spelling_attachments: Vec::new(),
|
||
decomposition_attachments: Vec::new(),
|
||
spelling_precedence: crate::pitch::SpellingPrecedence::default(),
|
||
analysis_layers: Vec::new(),
|
||
views: Vec::new(),
|
||
identity,
|
||
tombstoned_pitches: BTreeSet::new(),
|
||
tombstoned_events: BTreeSet::new(),
|
||
}
|
||
}
|
||
|
||
/// Iterates every staff instance in the score, paired with its region id.
|
||
pub fn staff_instances(&self) -> impl Iterator<Item = (RegionId, &StaffInstance)> {
|
||
self.canvas
|
||
.regions
|
||
.iter()
|
||
.flat_map(|r| r.staff_instances().iter().map(move |si| (r.id, si)))
|
||
}
|
||
|
||
/// Iterates every voice in the score, paired with its region and staff
|
||
/// instance.
|
||
pub fn voices(&self) -> impl Iterator<Item = (RegionId, StaffInstanceId, &Voice)> {
|
||
self.canvas.regions.iter().flat_map(|r| {
|
||
r.staff_instances()
|
||
.iter()
|
||
.flat_map(move |si| si.voices.iter().map(move |v| (r.id, si.id, v)))
|
||
})
|
||
}
|
||
|
||
/// The set of live pitch identifiers across the arena (every embedded
|
||
/// [`crate::IdentifiedPitch`]).
|
||
pub fn live_pitch_ids(&self) -> BTreeSet<PitchId> {
|
||
let mut set = BTreeSet::new();
|
||
let mut buf = Vec::new();
|
||
for e in self.events.iter() {
|
||
buf.clear();
|
||
e.collect_identified_pitches(&mut buf);
|
||
for ip in &buf {
|
||
set.insert(ip.id);
|
||
}
|
||
}
|
||
set
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::ids::ReplicaId;
|
||
use crate::time::WallClockTime;
|
||
|
||
fn wc_extent(a: i64, b: i64) -> TimeExtent {
|
||
TimeExtent {
|
||
start: TimeAnchor::WallClock {
|
||
time: WallClockTime(a),
|
||
},
|
||
end: TimeAnchor::WallClock {
|
||
time: WallClockTime(b),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn region(id: RegionId, staves: Vec<StaffId>, ext: TimeExtent) -> Region {
|
||
Region {
|
||
id,
|
||
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
|
||
content: RegionContent::StaffBased(StaffBasedContent::default()),
|
||
time_extent: ext,
|
||
staff_extent: StaffExtent { staves },
|
||
local_tempo_map: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn promoted_voice_id_is_deterministic_and_system_namespaced() {
|
||
let r = ReplicaId(3);
|
||
let si = StaffInstanceId::new(r, 1);
|
||
let ov = VoiceId::new(r, 2);
|
||
let win = OperationId::new(r, 10);
|
||
let lose = OperationId::new(ReplicaId(4), 11);
|
||
let a = derive_promoted_voice_id(si, ov, win, lose);
|
||
let b = derive_promoted_voice_id(si, ov, win, lose);
|
||
assert_eq!(a, b);
|
||
assert_eq!(a.replica(), ReplicaId::SYSTEM_DERIVED);
|
||
// Swapping winner and loser changes the identity (order matters).
|
||
let c = derive_promoted_voice_id(si, ov, lose, win);
|
||
assert_ne!(a, c);
|
||
}
|
||
|
||
#[test]
|
||
fn promoted_voice_id_byte_form_is_locked() {
|
||
// Golden: locks the MUSCSVCE 64-byte preimage layout (staff instance ||
|
||
// original voice || winning op || losing op, each 16 big-endian bytes)
|
||
// and the hash output. RATIFIED by Pass 11 (item 1.2, P11-3 / ops C4):
|
||
// this is the spec's golden, normative in core_spec
|
||
// §"System-Promoted Voices" (`derive_promoted_voice_id`). A change to the
|
||
// input order, byte layout, or domain tag breaks this deliberately.
|
||
let id = derive_promoted_voice_id(
|
||
StaffInstanceId::new(ReplicaId(3), 1),
|
||
VoiceId::new(ReplicaId(3), 2),
|
||
OperationId::new(ReplicaId(3), 10),
|
||
OperationId::new(ReplicaId(4), 11),
|
||
);
|
||
assert_eq!(id.replica(), ReplicaId::SYSTEM_DERIVED);
|
||
const GOLDEN: [u8; 16] = [
|
||
255, 255, 255, 255, 255, 255, 255, 255, 76, 193, 12, 43, 57, 51, 131, 242,
|
||
];
|
||
assert_eq!(id.canonical_bytes(), GOLDEN);
|
||
}
|
||
|
||
#[test]
|
||
fn wallclock_time_overlap_is_half_open() {
|
||
let r = ReplicaId(1);
|
||
let s0 = StaffId::new(r, 0);
|
||
let a = region(RegionId::new(r, 0), vec![s0], wc_extent(0, 100));
|
||
let b = region(RegionId::new(r, 1), vec![s0], wc_extent(100, 200));
|
||
let c = region(RegionId::new(r, 2), vec![s0], wc_extent(50, 150));
|
||
assert!(
|
||
!a.overlaps_in_time(&b),
|
||
"touching at boundary is not overlap"
|
||
);
|
||
assert!(a.overlaps_in_time(&c));
|
||
assert!(a.staff_extent_intersects(&c));
|
||
}
|
||
|
||
#[test]
|
||
fn coordinate_discipline_follows_time_model() {
|
||
assert_eq!(
|
||
RegionTimeModel::Metric(MetricTimeModel::default()).coordinate_discipline(),
|
||
CoordinateDiscipline::Musical
|
||
);
|
||
assert_eq!(
|
||
RegionTimeModel::Proportional(ProportionalTimeModel {
|
||
duration: WallClockDuration(0)
|
||
})
|
||
.coordinate_discipline(),
|
||
CoordinateDiscipline::WallClock
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn time_signature_rejects_mismatched_beat_groups() {
|
||
let r = ReplicaId(1);
|
||
let id = TimeSignatureId::new(r, 1);
|
||
let q = || MusicalDuration(crate::time::RationalTime::new(1, 4).unwrap());
|
||
let bg = |d| BeatGroup {
|
||
duration: d,
|
||
subdivision: None,
|
||
accent: 0,
|
||
};
|
||
// 4/4: four quarter beat groups sum to a whole-note measure.
|
||
assert!(TimeSignature::new(
|
||
id,
|
||
TimeSignatureDisplay::Standard {
|
||
numerator: 4,
|
||
denominator: PowerOfTwo::new(4).unwrap()
|
||
},
|
||
MusicalDuration::whole(),
|
||
vec![bg(q()), bg(q()), bg(q()), bg(q())],
|
||
)
|
||
.is_some());
|
||
// Three quarters do not sum to a whole note -> rejected.
|
||
assert!(TimeSignature::new(
|
||
id,
|
||
TimeSignatureDisplay::Standard {
|
||
numerator: 4,
|
||
denominator: PowerOfTwo::new(4).unwrap()
|
||
},
|
||
MusicalDuration::whole(),
|
||
vec![bg(q()), bg(q()), bg(q())],
|
||
)
|
||
.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn notated_component_durations() {
|
||
// Undotted quarter = 1/4.
|
||
let c = NotatedComponent {
|
||
base_value: NoteValue::Quarter,
|
||
dots: 0,
|
||
tuplet: None,
|
||
tied_to_next: false,
|
||
};
|
||
assert_eq!(
|
||
c.notated_duration(),
|
||
MusicalDuration(crate::time::RationalTime::new(1, 4).unwrap())
|
||
);
|
||
// Dotted quarter = 3/8.
|
||
let dotted = NotatedComponent {
|
||
dots: 1,
|
||
..c.clone()
|
||
};
|
||
assert_eq!(
|
||
dotted.notated_duration(),
|
||
MusicalDuration(crate::time::RationalTime::new(3, 8).unwrap())
|
||
);
|
||
// An eighth in a 3:2 triplet sounds 1/8 × 2/3 = 1/12.
|
||
let trip = NotatedComponent {
|
||
base_value: NoteValue::Eighth,
|
||
dots: 0,
|
||
tuplet: None,
|
||
tied_to_next: false,
|
||
};
|
||
assert_eq!(
|
||
trip.sounding_duration(Some(TupletRatio {
|
||
actual: 3,
|
||
notated: 2
|
||
})),
|
||
MusicalDuration(crate::time::RationalTime::new(1, 12).unwrap())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn power_of_two_denominator_rejects_zero_and_non_powers() {
|
||
assert!(PowerOfTwo::new(0).is_none());
|
||
assert!(PowerOfTwo::new(3).is_none());
|
||
assert!(PowerOfTwo::new(6).is_none());
|
||
assert_eq!(PowerOfTwo::new(1).unwrap().get(), 1);
|
||
assert_eq!(PowerOfTwo::new(8).unwrap().get(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn dot_counts_are_not_silently_clamped() {
|
||
let comp = |dots| NotatedComponent {
|
||
base_value: NoteValue::Quarter,
|
||
dots,
|
||
tuplet: None,
|
||
tied_to_next: false,
|
||
};
|
||
// Each dot count yields its defined augmentation, distinct from the next:
|
||
// dots=8 and dots=9 differ (the old code clamped both to 8).
|
||
assert_ne!(comp(8).notated_duration(), comp(9).notated_duration());
|
||
assert_ne!(comp(8).notated_duration(), comp(255).notated_duration());
|
||
// Triple-dotted quarter = 1/4 · (1 + 1/2 + 1/4 + 1/8) = 15/32.
|
||
assert_eq!(
|
||
comp(3).notated_duration(),
|
||
MusicalDuration(crate::time::RationalTime::new(15, 32).unwrap())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn event_ordering_dag_rejects_cycles_at_construction() {
|
||
use std::collections::BTreeMap;
|
||
let r = ReplicaId(1);
|
||
let a = crate::ids::EventId::new(r, 1);
|
||
let b = crate::ids::EventId::new(r, 2);
|
||
let c = crate::ids::EventId::new(r, 3);
|
||
// a -> b -> c is acyclic.
|
||
let mut acyclic = BTreeMap::new();
|
||
acyclic.insert(a, vec![b]);
|
||
acyclic.insert(b, vec![c]);
|
||
assert!(EventOrderingDAG::try_new(acyclic).is_some());
|
||
// a -> b -> a is a cycle, rejected.
|
||
let mut cyclic = BTreeMap::new();
|
||
cyclic.insert(a, vec![b]);
|
||
cyclic.insert(b, vec![a]);
|
||
assert!(EventOrderingDAG::try_new(cyclic).is_none());
|
||
// A self-loop is a cycle.
|
||
let mut selfloop = BTreeMap::new();
|
||
selfloop.insert(a, vec![a]);
|
||
assert!(EventOrderingDAG::try_new(selfloop).is_none());
|
||
// The empty DAG is acyclic.
|
||
assert!(EventOrderingDAG::default().is_acyclic());
|
||
}
|
||
}
|