diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 97bdeb3..a1b09e6 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -48,15 +48,16 @@ use crate::event::{ use crate::graph::{ AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, BeatGroup, Canvas, - ChordSymbol, ClefChange, Comment, CrossCuttingRegistry, DecompositionAttachment, - DecompositionSource, EventOrderingDAG, GestureAnchoring, GraphicContent, GraphicGesture, - GraphicObject, Instrument, KeySignatureChange, LyricLine, Marker, Measure, - MeasureNumberVisibility, MeterChange, MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, - PartDefinition, PowerOfTwo, ProportionalTimeModel, Region, RegionContent, RegionTimeModel, - RepeatStructure, Score, ScoreMetadata, ScoreTuningContext, Slur, Spanner, Staff, - StaffBasedContent, StaffExtent, StaffGroup, StaffGroupKind, StaffInstance, - StaffLineConfiguration, StemDirection, TempoMapReference, Tie, TieClass, TimeExtent, - TimeSignature, TimeSignatureDisplay, Tuplet, TupletRatio, ViewDefinition, Voice, VoiceOrigin, + ChordSymbol, Clef, ClefChange, ClefShape, Comment, CrossCuttingRegistry, + DecompositionAttachment, DecompositionSource, EventOrderingDAG, GestureAnchoring, + GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature, KeySignatureChange, + LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange, MetricGrid, MetricTimeModel, + NotatedComponent, NoteValue, PartDefinition, PowerOfTwo, ProportionalTimeModel, Region, + RegionContent, RegionTimeModel, RepeatStructure, Score, ScoreMetadata, ScoreTuningContext, + Slur, Spanner, Staff, StaffBasedContent, StaffExtent, StaffGroup, StaffGroupKind, + StaffInstance, StaffLineConfiguration, StemDirection, TempoMapReference, Tie, TieClass, + TimeExtent, TimeSignature, TimeSignatureDisplay, Tuplet, TupletRatio, ViewDefinition, Voice, + VoiceOrigin, }; use crate::ids::{ AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId, ChordSymbolId, @@ -1453,8 +1454,27 @@ struct_codec!(MeterChange { struct_codec!(MetricGrid { meter_sequence }); struct_codec!(ProportionalTimeModel { duration }); struct_codec!(MetricTimeModel { meters, tempo }); -struct_codec!(ClefChange { anchor }); -struct_codec!(KeySignatureChange { anchor }); +cstyle_enum_codec!(ClefShape { + 0 => G, + 1 => F, + 2 => C, + 3 => Percussion, +}); +struct_codec!(Clef { + shape, + line, + octave_shift +}); +impl Codec for KeySignature { + fn enc(&self, out: &mut Vec) { + self.fifths().enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + KeySignature::new(i8::dec(r)?).ok_or(ScoreDecodeError::Reconstruct("KeySignature")) + } +} +struct_codec!(ClefChange { anchor, clef }); +struct_codec!(KeySignatureChange { anchor, key }); struct_codec!(Measure { id, start, @@ -2193,6 +2213,72 @@ mod tests { ); } + #[test] + fn clef_and_key_signature_codecs_round_trip() { + use crate::graph::{Clef, ClefChange, ClefShape, KeySignature, KeySignatureChange}; + use crate::time::{TimeAnchor, WallClockTime}; + + fn rt(v: &T) { + let mut out = Vec::new(); + v.enc(&mut out); + let mut r = Reader::new(&out); + let decoded = T::dec(&mut r).expect("decodes"); + assert_eq!(&decoded, v, "round-trip changed the value"); + } + + for shape in [ + ClefShape::G, + ClefShape::F, + ClefShape::C, + ClefShape::Percussion, + ] { + rt(&shape); + for line in [-3i8, 1, 2, 3, 4, 5] { + for octave_shift in [-1i8, 0, 1] { + rt(&Clef { + shape, + line, + octave_shift, + }); + } + } + } + for shape_clef in [Clef::treble(), Clef::bass(), Clef::alto(), Clef::tenor()] { + rt(&shape_clef); + } + for fifths in -7i8..=7 { + rt(&KeySignature::new(fifths).expect("fifths is in range")); + } + let anchor = TimeAnchor::WallClock { + time: WallClockTime(0), + }; + rt(&ClefChange { + anchor: anchor.clone(), + clef: Clef::bass(), + }); + rt(&KeySignatureChange { + anchor, + key: KeySignature::new(-3).expect("fifths is in range"), + }); + } + + #[test] + fn key_signature_rejects_out_of_range_fifths_on_decode() { + let decode = |fifths: i8| { + let mut bytes = Vec::new(); + fifths.enc(&mut bytes); + KeySignature::dec(&mut Reader::new(&bytes)) + }; + assert!(decode(-7).is_ok()); + assert!(decode(7).is_ok()); + for fifths in [-8i8, 8] { + assert!( + matches!(decode(fifths), Err(ScoreDecodeError::Reconstruct(_))), + "out-of-range fifth count {fifths} must be rejected" + ); + } + } + #[test] fn generator_scores_round_trip() { for seed in 0..200u64 { diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index a061ab8..fbafb77 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -51,17 +51,116 @@ impl Default for StaffLineConfiguration { } } -/// A clef placed at a point in a staff instance. Placeholder (Chapter 7). +/// 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 { + 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. Placeholder -/// (Chapter 7). +/// 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). diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 3ce1955..ad58b4b 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -99,9 +99,9 @@ pub use event::{ pub use graph::{ derive_promoted_voice_id, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, - BeatGroup, Canvas, ChordSymbol, ClefChange, Comment, CoordinateDiscipline, + BeatGroup, Canvas, ChordSymbol, Clef, ClefChange, ClefShape, Comment, CoordinateDiscipline, CrossCuttingRegistry, DecompositionAttachment, DecompositionSource, EventOrderingDAG, - GestureAnchoring, GraphicContent, GraphicGesture, GraphicObject, Instrument, + GestureAnchoring, GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature, KeySignatureChange, LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange, MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, PartDefinition, PowerOfTwo, ProportionalTimeModel, Region, RegionContent, RegionTimeModel, RepeatStructure, Score,