//! Pitch primitives and the spelling subsystem (Chapter 2; Chapter 4 for the //! tuning/pitch-space registry identifiers a pitch references). //! //! A [`Pitch`] is two independent intrinsic layers (Chapter 2 §"The Pitch //! Type"): a [`ScalePosition`] (analytical identity within a pitch space) and //! an [`AcousticPitch`] (sounding-frequency identity, relative to a tuning //! system). Spelling — what the performer sees — is **not** a field on the //! pitch (Chapter 2 §"Spelling"); it is attached externally, indexed by //! [`PitchId`], through [`SpellingAttachment`]. //! //! Pitches embedded in events are wrapped in [`IdentifiedPitch`], pairing the //! pitch with its stable [`PitchId`] (Chapter 5 §"Identified Pitches"). use epiphany_determinism::{ canonical_f64_bytes, CanonicalF64, SystemDomainTag, Tolerance, ToleranceClass, }; use unicode_normalization::UnicodeNormalization; use crate::ids::{derive_system_id, AnalysisLayerId, PitchId, VoiceId}; use crate::time::TimeAnchor; /// Defines a catalog / registry identifier: a named entry in one of the /// score's registries (pitch spaces, tuning systems, accidental registries, /// …). The built-in catalog uses short ASCII names like `"cmn-12"` and /// `"tet-12"` (Chapter 4 §"Built-in Catalog"), so the identifier is a string. /// /// Appendix D §"Text and Unicode" makes canonical text identity byte comparison /// of the UTF-8 **NFC** form: *"Canonical text fields MUST be encoded as UTF-8 /// with Unicode NFC applied … Comparisons of canonical text fields for identity /// MUST be byte comparisons of NFC-encoded UTF-8."* [`$name::new`] therefore /// **normalizes to NFC on construction**, so two canonically-equivalent names /// (e.g. precomposed "é" U+00E9 vs decomposed "e"+U+0301) intern to the same /// value and compare, hash, and order equal. The built-in catalog names are /// ASCII (already NFC) and so are unaffected. macro_rules! catalog_id { ($(#[$meta:meta])* $name:ident) => { $(#[$meta])* #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct $name(String); impl $name { /// Interns a catalog name, normalizing it to Unicode NFC so /// canonically-equivalent spellings compare equal (Appendix D /// §"Text and Unicode"). #[inline] pub fn new(name: impl Into) -> Self { $name(name.into().nfc().collect()) } /// The catalog name. #[inline] pub fn as_str(&self) -> &str { &self.0 } } impl core::fmt::Debug for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, concat!(stringify!($name), "({:?})"), self.0) } } impl core::fmt::Display for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(&self.0) } } }; } catalog_id!( /// Identifies a pitch space (Chapter 4 §"Pitch Spaces"). Built-ins include /// `cmn-12`, `edo-31`, `ji-5limit`. PitchSpaceId ); catalog_id!( /// Identifies a tuning system (Chapter 4 §"Tuning Systems"). Built-ins /// include `tet-12`, `werckmeister-iii`. TuningSystemId ); catalog_id!( /// Identifies a registered tuning function consumed by /// [`crate::tuning::TuningResolution::Function`] (Chapter 4 /// §"Tuning Resolution", `core_spec.tex:3318-3324`: "Procedural /// definition: a registered tuning function that computes frequencies /// from a reference. Historical temperaments ... live here."). The ten /// historical temperaments (Push 4b tranche 2b) are reserved built-in /// ids — `"pythagorean"`, `"werckmeister-iii"`, and so on — spelled /// identically to their [`TuningSystemId`], since each temperament's /// catalog entry and its tuning function share one identifier. An id /// with no reserved built-in has no registry to resolve against /// (`Function` is an extension point this tranche does not build a /// registry for) and fails closed. TuningFunctionId ); catalog_id!( /// Identifies a registered adaptive tuning function, consumed by /// [`crate::tuning::TuningResolution::Adaptive`] (Chapter 4 §"Adaptive /// Tuning", `req:tuning:adaptive-default-version`). The built-in catalog /// entry `ji-adaptive-5limit` is bound to `"default-v1"` — the version /// lives *inside* the identifier string, not as prose beside it, so a /// future version 2 would mint a new identifier rather than silently /// changing what `"default-v1"` means. An unregistered id (this tranche /// registers exactly the one) is a hard error with no silent fallback to /// a default version. AdaptiveTuningFunctionId ); catalog_id!( /// Identifies an accidental registry (Chapter 4 §"Accidental Registries"). AccidentalRegistryId ); catalog_id!( /// Identifies an accidental within a registry (Chapter 4). AccidentalId ); catalog_id!( /// Identifies a nominal registry (Chapter 4). NominalRegistryId ); catalog_id!( /// Identifies a registered (grammar-defined) scale position /// (Chapter 2 §"Scale Position"). PositionRegistryId ); catalog_id!( /// Identifies a registered tie class with custom validation behaviour /// (Chapter 5 §"Ties", `TieClass::Registered`). TieClassRegistryId ); catalog_id!( /// Identifies a registered staff-group kind (Chapter 5 /// §"Top-Level Score Structure", `StaffGroupKind::Registered`). StaffGroupKindRegistryId ); catalog_id!( /// Identifies a spelling rule set (Chapter 4 §"Spelling Rule Sets"). SpellingRuleSetId ); catalog_id!( /// Identifies a spelling algorithm family (Chapter 2 §"The Spelling /// Pre-Pass"). The v0 stub registers [`SpellingAlgorithmId::default_id`]. SpellingAlgorithmId ); catalog_id!( /// Identifies a notational-decomposition algorithm family (Chapter 3 /// §"Sounding Duration and Notational Decomposition"). Versioned the same /// way as [`SpellingAlgorithmId`]: the id is part of the derivation key for /// the decomposition pre-pass, so a profile-declared change deterministically /// invalidates derived decompositions. The Phase-2 default /// ([`DecompositionAlgorithmId::default_id`]) resolves to the metric /// greedy-aligned splitter in [`crate::prepass`]. DecompositionAlgorithmId ); catalog_id!( /// Identifies a foreign interchange format (e.g. MusicXML), used as a /// spelling/decomposition provenance tag. ForeignFormatId ); catalog_id!( /// Identifies a registered (grammar-defined) position structure /// (Chapter 4 §"Position Structure", `PositionStructure::Registered`). PositionStructureRegistryId ); catalog_id!( /// Identifies a registered (grammar-defined) interval algebra /// (Chapter 4 §"Interval Algebra", `IntervalAlgebra::Registered`). IntervalAlgebraRegistryId ); catalog_id!( /// Identifies a registered (grammar-defined) transposition behavior /// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`). TranspositionRegistryId ); catalog_id!( /// Identifies a custom (non-SMuFL) glyph (Chapter 4 §"Glyph References /// and SMuFL", `crate::accidental::GlyphReference::Custom`). CustomGlyphId ); catalog_id!( /// Identifies a registered (grammar-defined) pitch-space modification /// (Chapter 4 §"Pitch Space Modifications", /// `crate::accidental::PitchSpaceModification::Registered`). ModificationRegistryId ); catalog_id!( /// Identifies a compatibility group for stacking accidentals (Chapter 4 /// §"Combination Behavior", `crate::accidental::AccidentalCombination::Stacking`). AccidentalGroupId ); impl SpellingAlgorithmId { /// The Phase-2 default spelling algorithm, registered under the id /// `"default"`. The id resolves to the deterministic Temperley-style /// line-of-fifths pre-pass implemented in [`crate::prepass`] (Chapter 2 /// §"The Spelling Pre-Pass"). The literal id is part of the derivation key: /// changing the registered algorithm changes the id, so derived spellings /// computed under a different version never silently alias. pub fn default_id() -> Self { SpellingAlgorithmId::new("default") } } impl DecompositionAlgorithmId { /// The Phase-2 default decomposition algorithm, registered under the id /// `"default"`. The id resolves to the deterministic metric greedy-aligned /// splitter implemented in [`crate::prepass`] (Chapter 3 §"Sounding Duration /// and Notational Decomposition"). pub fn default_id() -> Self { DecompositionAlgorithmId::new("default") } } /// The seven CMN diatonic nominals. The discriminants are **normative**: they /// define the diatonic step ordering used by transposition (Chapter 2 /// §"The CmnNominal Type"). #[repr(u8)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub enum CmnNominal { C = 0, D = 1, E = 2, F = 3, G = 4, A = 5, B = 6, } impl CmnNominal { /// The nominal's position in the 12-chromatic layer for `cmn-12` /// (`C=0, D=2, E=4, F=5, G=7, A=9, B=11`), per the /// `DiatonicOverChromatic` mapping in Chapter 4. pub fn chromatic(self) -> u8 { match self { CmnNominal::C => 0, CmnNominal::D => 2, CmnNominal::E => 4, CmnNominal::F => 5, CmnNominal::G => 7, CmnNominal::A => 9, CmnNominal::B => 11, } } /// The nominal for a diatonic index in `0..7`, the inverse of the type's /// normative discriminant (Chapter 2 §"The CmnNominal Type"). Panics /// outside the range; callers reduce with `rem_euclid(7)` first. fn from_index(index: i32) -> Self { match index { 0 => CmnNominal::C, 1 => CmnNominal::D, 2 => CmnNominal::E, 3 => CmnNominal::F, 4 => CmnNominal::G, 5 => CmnNominal::A, 6 => CmnNominal::B, other => unreachable!("diatonic index out of 0..7: {other}"), } } } /// A signed interval: the transposition primitive (Chapter 2 /// §"Transposition and the Interval Type"; schema major 2). Both components /// are load-bearing — the diatonic one fixes the spelling (which nominal, /// hence which staff line), the chromatic one fixes the sound. Neither /// determines the other: an augmented second `(1, 3)` and a minor third /// `(2, 3)` sound alike and are written differently. /// /// [`Instrument::transposition`](crate::Instrument) carries one as its /// written-versus-sounding interval (a B-flat clarinet is `-1` diatonic, `-2` /// chromatic). Its *action* is pinned by [`Pitch::transposed`]; what stays /// advisory is its automatic application at the written/sounding boundary, /// which nothing in the core performs yet. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct TranspositionInterval { pub diatonic_steps: i32, pub chromatic_steps: i32, } impl TranspositionInterval { /// The interval that undoes this one, or `None` when it is not /// representable: `i32::MIN` has no `i32` negation. Exact where it exists, /// because [`Pitch::transposed`] never saturates — it refuses instead. /// /// The non-representable case is explicit rather than a panic or a wrap: /// an interval whose inverse cannot be written down is a fact about the /// type, and a caller composing undo out of inverses must see it. pub fn inverse(self) -> Option { Some(TranspositionInterval { diatonic_steps: self.diatonic_steps.checked_neg()?, chromatic_steps: self.chromatic_steps.checked_neg()?, }) } } /// Why a pitch could not be faithfully transposed (Chapter 2 /// `req:pitch:transposition`). Each variant is a way a transposition would /// otherwise have to lie. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum TransposeRefusal { /// The position is not [`PitchSpacePosition::Cmn`], so it has no nominal /// for the interval's diatonic component to move. NonCmnPosition, /// The enclosing pitch space's chromatic layer or nominal mapping cannot /// be established, so applying the interval would require guessed /// arithmetic (`req:pitch:space-capability-refusal`). Structural: the /// space's [`PositionStructure`](crate::pitch_space::PositionStructure) /// is looked up in [`crate::pitch_space::built_in_position_structure`] /// and must resolve to /// [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic); /// every other outcome — `Chromatic`, `JiLattice`, `Registered`, an /// unknown identifier, or one of the six built-in spaces the /// specification names but does not structurally determine — refuses. PitchSpaceUnavailable, /// The pitch's [`AcousticRealization::AbsoluteHz`] overrides the tuning /// system, so moving the scale position would move the notehead without /// moving the sound. AcousticPinned, /// The resulting `alteration` or `octave` does not fit its `i8` field. OutOfRange, } /// Resolves `space` to its [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic) /// chromatic cardinality and nominal mapping against the built-in catalog /// ([`crate::pitch_space::built_in_position_structure`]), or `None` for /// every other case — `Chromatic`, `JiLattice`, `Registered`, an unknown /// identifier, or one of the six catalog spaces the specification does not /// structurally determine. This is the structural replacement for the /// retired P13-S2 `"cmn-12"` identifier check /// (`req:pitch:space-capability-refusal`): [`Pitch::transposed`] and /// [`Pitch::twelve_tet_semitone`] are its only two call sites, matching the /// two places the interim guard used to live. fn diatonic_over_chromatic_structure(space: &PitchSpaceId) -> Option<(u16, Vec)> { match crate::pitch_space::built_in_position_structure(space)? { crate::pitch_space::PositionStructure::DiatonicOverChromatic { chromatic_positions_per_octave, nominal_to_chromatic, .. } => Some((chromatic_positions_per_octave, nominal_to_chromatic)), _ => None, } } impl Pitch { /// Transposes this pitch by `interval`, per Chapter 2 /// `req:pitch:transposition`. /// /// With `n` the nominal's normative discriminant, `C` the enclosing /// space's `chromatic_positions_per_octave`, and `m` its /// `nominal_to_chromatic` mapping (both resolved structurally — see /// below), the absolute chromatic coordinate is /// `s = m(nominal) + alteration + C*octave`, and transposing by /// `{ d, c }` yields /// /// ```text /// nominal' = CmnNominal((n + d).rem_euclid(7)) /// octave' = octave + (n + d).div_euclid(7) /// alteration' = (s + c) - (m(nominal') + C*octave') /// ``` /// /// `C` and `m` are resolved by looking `self.scale_position.space` up in /// [`crate::pitch_space::built_in_position_structure`]: when it resolves /// to [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic), /// that structure's own `chromatic_positions_per_octave` and /// `nominal_to_chromatic` are used — which is what makes `cmn-24` /// transpose in quarter-tone steps rather than semitones. Every other /// resolution — `Chromatic`, `JiLattice`, `Registered`, an unknown /// identifier, or one of the six built-in spaces the specification does /// not structurally determine — refuses /// ([`TransposeRefusal::PitchSpaceUnavailable`], /// `req:pitch:space-capability-refusal`) rather than guessing a /// structure. /// /// The diatonic component alone selects the nominal and octave; the /// alteration absorbs exactly the residue. So in `cmn-12`, `C4 + (7, 12)` /// is `C5`, not "C with twelve sharps", and `C4 + (0, 1)` is `C#4`. /// /// Refuses rather than saturating, clamping, or approximating: a /// transposition that silently produces a pitch nobody asked for reports /// success and destroys the evidence needed to notice. pub fn transposed(&self, interval: TranspositionInterval) -> Result { if matches!( self.acoustic.realization, AcousticRealization::AbsoluteHz(_) ) { return Err(TransposeRefusal::AcousticPinned); } let PitchSpacePosition::Cmn { nominal, alteration, octave, } = self.scale_position.position else { return Err(TransposeRefusal::NonCmnPosition); }; let (chromatic_card, nominal_to_chromatic) = diatonic_over_chromatic_structure(&self.scale_position.space) .ok_or(TransposeRefusal::PitchSpaceUnavailable)?; // Widen to `i64` before any arithmetic. The `i8` bound is a *result* // constraint, not an intermediate one, so an octave that overflows on // the way to a value that fits would be a spurious refusal — but // `i32` is not wide enough to hold the intermediates for an `i32` // interval, and the previous version of this function panicked on // `diatonic_steps = i32::MAX` at `chromatic_card * new_octave`. // Refusing is the contract; panicking on a value the public type // admits is not. // // `i64` is amply wide: `step` is bounded by `6 + 2^31`, so // `new_octave` by `2^31/7 + 127`, and the largest intermediate // `chromatic_card * new_octave` by roughly `chromatic_card * 3e8`, // nowhere near `i64::MAX` for any `u16` cardinality. let chromatic_of = |nom: CmnNominal| i64::from(nominal_to_chromatic[nom as usize]); let c = i64::from(chromatic_card); let n = i64::from(nominal as u8); let semitone = chromatic_of(nominal) + i64::from(alteration) + c * i64::from(octave); let step = n + i64::from(interval.diatonic_steps); let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32); let new_octave = i64::from(octave) + step.div_euclid(7); let new_alteration = (semitone + i64::from(interval.chromatic_steps)) - (chromatic_of(new_nominal) + c * new_octave); let octave = i8::try_from(new_octave).map_err(|_| TransposeRefusal::OutOfRange)?; let alteration = i8::try_from(new_alteration).map_err(|_| TransposeRefusal::OutOfRange)?; let mut out = self.clone(); out.scale_position.position = PitchSpacePosition::Cmn { nominal: new_nominal, alteration, octave, }; Ok(out) } } /// A position within a pitch space (Chapter 2 §"Scale Position"). Tagged union /// with a fast CMN path plus a registry escape hatch for arbitrary grammars. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum PitchSpacePosition { /// Common Music Notation: diatonic nominal + chromatic alteration + octave. Cmn { nominal: CmnNominal, /// Chromatic alteration in steps of the enclosing pitch space's /// chromatic layer (`req:pitch:alteration-unit`), conventionally /// `-2..=+2`. One step is a semitone in `cmn-12` and a quarter-tone in /// `cmn-24`, so a flat is respectively `-1` and `-2`. alteration: i8, /// Scientific Pitch Notation octave; middle C is C4. octave: i8, }, /// N-tone integer position (serial, EDO). Integer { space_size: u16, index: i32 }, /// Just-intonation lattice vector; one exponent per prime in the space's /// declared basis (Chapter 2 §"Scale Position", `JiVector`). JiVector { components: Vec }, /// A registered position resolved by the pitch space's grammar plugin. Registered(PositionRegistryId), } /// A pitch's analytical identity (Chapter 2 §"Scale Position"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct ScalePosition { /// The pitch space this position is defined within. pub space: PitchSpaceId, /// The position within that space. pub position: PitchSpacePosition, } /// A reference to the tuning system governing a pitch (Chapter 2 /// §"Tuning Reference and Inheritance"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum TuningReference { /// Inherit the tuning system from the enclosing scope. Malformed at score /// level (the score's tuning system must be explicit). Inherit, /// Explicitly named tuning system. Explicit(TuningSystemId), } /// How the tuning system resolves to a frequency (Chapter 2 §"Acoustic /// Realization"). The cents/Hz payloads are [`CanonicalF64`] so a NaN/inf/`-0.0` /// can never enter canonical state (Appendix D §"Floating-Point Values"). #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub enum AcousticRealization { /// Resolve through the tuning system using the scale position alone (the /// default for ordinary CMN). Implicit, /// An explicit offset in cents from the tuning system's result. CentsOffset(CanonicalF64), /// An explicit absolute frequency in Hertz, overriding the tuning system. AbsoluteHz(CanonicalF64), } impl AcousticRealization { /// Builds a cents offset, rejecting a non-finite value (Appendix D). pub fn cents_offset(cents: f64) -> Option { CanonicalF64::new(cents).map(AcousticRealization::CentsOffset) } /// Builds an absolute-frequency realization, rejecting a non-finite or /// non-positive frequency (Chapter 4 §"Reference Pitch": "positive and /// finite"). pub fn absolute_hz(hz: f64) -> Option { if hz > 0.0 { CanonicalF64::new(hz).map(AcousticRealization::AbsoluteHz) } else { None } } } /// The reference pitch anchoring a tuning system's ratios to absolute Hertz /// (Chapter 4 §"Reference Pitch"). A score-level property, not a tuning-system /// one. The frequency is a finite, positive [`CanonicalF64`]. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct ReferencePitch { /// The pitch-space position chosen as the reference (conventionally A4). pub position: PitchSpacePosition, /// The frequency in Hertz; **positive** and finite. Private so it cannot be /// mutated into an invalid value after construction (Chapter 4: "positive /// and finite") — the only way to set it is through [`ReferencePitch::new`]. frequency_hz: CanonicalF64, } impl ReferencePitch { /// Builds a reference pitch, rejecting a non-finite or non-positive /// frequency (Chapter 4: "positive and finite"). pub fn new(position: PitchSpacePosition, frequency_hz: f64) -> Option { if frequency_hz > 0.0 { CanonicalF64::new(frequency_hz).map(|frequency_hz| ReferencePitch { position, frequency_hz, }) } else { None } } /// The reference frequency in Hertz (always positive and finite). #[inline] pub fn frequency_hz(&self) -> f64 { self.frequency_hz.get() } /// The conventional default: A4 = 440 Hz in `cmn-12` (Chapter 4 §"Default /// Score Configuration"). pub fn a440() -> Self { ReferencePitch::new( PitchSpacePosition::Cmn { nominal: CmnNominal::A, alteration: 0, octave: 4, }, 440.0, ) .expect("A4=440 is a valid reference pitch") } } /// A pitch's acoustic identity (Chapter 2 §"Acoustic Realization"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct AcousticPitch { /// The tuning system governing this pitch's frequency. pub tuning: TuningReference, /// How the tuning system resolves to a frequency. pub realization: AcousticRealization, } /// A chromatic pitch class in `0..=11` — one of the twelve positions of the /// `cmn-12` chromatic layer, with no octave. Used where a value must name /// *which* pitch class rather than a full pitch, such as /// [`crate::tuning::HarmonicContext`]'s tonal centre /// (`req:tuning:adaptive-anchor-derivation`). [`Pitch::twelve_tet_class`], /// just below, already returns a `0..=11` `u8`; this newtype enforces that /// range in the type itself rather than merely documenting it, following /// [`crate::graph::KeySignature::new`]'s checked-constructor style — a raw /// `u8` field a caller could set to `200` is not a chromatic pitch class. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct ChromaticPitchClass(u8); impl ChromaticPitchClass { /// Builds a chromatic pitch class, rejecting values outside `0..=11`. pub const fn new(value: u8) -> Option { if value <= 11 { Some(ChromaticPitchClass(value)) } else { None } } /// The underlying `0..=11` value. #[inline] pub const fn get(self) -> u8 { self.0 } } /// A pitch's intrinsic identity: scale position plus acoustic realization /// (Chapter 2 §"The Pitch Type"). Spellings are attached externally. /// /// Derived `Eq` is **structural equality** (Chapter 2 §"Equality and /// Comparison"). The computed equivalences — scale-position and enharmonic — /// are separate methods and must never be conflated with structural equality. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct Pitch { /// The analytical identity within a pitch space. pub scale_position: ScalePosition, /// The acoustic identity. pub acoustic: AcousticPitch, } impl Pitch { /// Scale-position equivalence (Chapter 2): equal `ScalePosition` fields, /// ignoring acoustic realization. Exact; never tolerant. pub fn scale_position_equivalent(&self, other: &Pitch) -> bool { self.scale_position == other.scale_position } /// The 12-TET pitch class (`0..=11`) of this pitch's *scale position*, when /// its 12-chromatic structure is established: `Cmn` positions whose space /// resolves structurally to a `DiatonicOverChromatic` structure with /// exactly 12 chromatic positions (built-in `cmn-12`, at this tranche's /// catalog), and 12-EDO integer positions. Returns `None` for CMN /// positions in spaces that do not resolve that way (e.g. `cmn-24`, /// whose 24 quarter-tone positions are not a 12-TET pitch class at all) /// and positions whose 12-TET class is not determinable without a tuning /// resolver (JI vectors, non-12 EDOs, registered grammars). Octave-blind /// — for sounding comparison use [`Pitch::twelve_tet_semitone`]. pub fn twelve_tet_class(&self) -> Option { self.twelve_tet_semitone().map(|s| s.rem_euclid(12) as u8) } /// The *absolute* 12-TET semitone of this pitch's scale position, octave /// included, when its 12-chromatic structure is established. For a `Cmn` /// position this is `octave*C + m(nominal) + alteration`, where `C` and /// `m` come from the space's structural resolution /// ([`crate::pitch_space::built_in_position_structure`]) — but only when /// `C` is exactly `12`; a space resolving to `DiatonicOverChromatic` with /// any other chromatic cardinality (`cmn-24`, for instance) is **not** a /// 12-TET semitone and this deliberately does not answer for it (the /// name `twelve_tet_semitone` stays honest per /// `spec/CONTRACT_PUSH4B_PITCHSPACES.md` item 4: it answers only when the /// resolved structure truly has twelve chromatic positions, never by /// identifier). For 12-EDO integer positions it is the absolute `index`. /// `None` for CMN positions whose space does not resolve to a /// `DiatonicOverChromatic` structure with exactly 12 chromatic positions /// (`req:pitch:space-capability-refusal`) and positions not determinable /// without a tuning resolver. /// /// The CMN and 12-EDO frames use different zero references, so the absolute /// value is only meaningful *within* a frame; /// [`Pitch::enharmonic_equivalent`] compares only same-frame positions for /// that reason. pub fn twelve_tet_semitone(&self) -> Option { match &self.scale_position.position { PitchSpacePosition::Cmn { nominal, alteration, octave, } => { let (chromatic_card, nominal_to_chromatic) = diatonic_over_chromatic_structure(&self.scale_position.space)?; if chromatic_card != 12 { return None; } let m = nominal_to_chromatic[*nominal as usize] as i32; Some(*octave as i32 * 12 + m + *alteration as i32) } PitchSpacePosition::Integer { space_size, index } if *space_size == 12 => Some(*index), _ => None, } } /// Enharmonic equivalence (Chapter 2): sounding-equivalent under 12-tone /// equal temperament, regardless of the actual tuning system. This is a /// *sounding* notion, so octave matters — C4 and C5 are **not** /// enharmonically equivalent (they sound an octave apart); C-sharp4 and /// D-flat4 are. Computed from the absolute 12-TET semitone /// ([`Pitch::twelve_tet_semitone`]). /// /// Pitches in **different pitch spaces** are not directly comparable /// (Chapter 2 §"Scale Position": "pitches in different spaces cannot be /// directly compared and operations between them MUST go through an explicit /// space-conversion mechanism"), so this returns `false` unless both share a /// [`ScalePosition::space`]. The two computable frames (CMN, 12-EDO integer) /// also use different zero references, so a cross-frame pair returns `false` /// too. `false` for non-determinable positions. /// /// General *sounding* equivalence across arbitrary tuning systems is /// [`Pitch::sounding_equivalent`]. pub fn enharmonic_equivalent(&self, other: &Pitch) -> bool { if self.scale_position.space != other.scale_position.space { return false; } match (self.twelve_tet_semitone(), other.twelve_tet_semitone()) { (Some(a), Some(b)) => a == b, _ => false, } } /// Sounding equivalence (Chapter 2's third computed relation): two pitches /// are sounding-equivalent if they resolve to the same frequency under their /// respective tuning systems, within a [`Tolerance`] of the named /// [`ToleranceClass::AcousticCents`] class. /// /// The tolerance is the named class, not a raw `f64`: Appendix D §"Tolerance /// Classes" forbids ad-hoc epsilons, and a [`Tolerance`] cannot carry /// infinity or NaN ([`epiphany_determinism::CanonicalF64`] bounds). A /// tolerance of any *other* class is a category error and never matches. /// /// Frequency resolution in general depends on the full tuning-system catalog /// and reference pitch. The deterministic part of that now lives in this /// crate (`tuning::resolve_pitch_frequency`, Push 4b tranche 2); the full /// acoustic engine (Chapter 1; see `DECISIONS.md`) remains a separate /// subsystem. This method stays resolver-agnostic on purpose: callers pass a /// `resolve` closure mapping a pitch to its frequency in Hertz (`None` if it /// cannot resolve it), which may delegate to the in-crate resolver or any /// other source. An [`AcousticRealization::AbsoluteHz`] pitch resolves to its /// own stated frequency without the closure. Returns `false` if either /// frequency is unavailable. pub fn sounding_equivalent( &self, other: &Pitch, tolerance: Tolerance, mut resolve: impl FnMut(&Pitch) -> Option, ) -> bool { // The comparison is in cents, so it MUST use the AcousticCents class // (Appendix D); a tolerance of any other class never matches. if tolerance.class != ToleranceClass::AcousticCents { return false; } let freq = |p: &Pitch, resolve: &mut dyn FnMut(&Pitch) -> Option| -> Option { match p.acoustic.realization { AcousticRealization::AbsoluteHz(hz) => Some(hz.get()), _ => resolve(p), } }; let mut r = &mut resolve; match (freq(self, &mut r), freq(other, &mut r)) { (Some(a), Some(b)) if a > 0.0 && b > 0.0 => { // Compare in cents against zero: |1200·log2(a/b)| within // tolerance. `Tolerance::within` rejects a non-finite cents // value (e.g. an infinite resolved frequency), so the comparison // can never be spuriously satisfied. This is a derived, tolerant // comparison (never canonical state), so the transcendental is // admissible (Appendix D applies to canonical numeric output). let cents = 1200.0 * (a / b).log2().abs(); tolerance.within(cents, 0.0) } _ => false, } } } /// A closed pitch range, `lowest..=highest` (Chapter 2; the type /// `core_spec` references for [`Instrument`](crate::Instrument)'s declared /// range). Both endpoints are full [`Pitch`] values, so a range is expressed in /// a specific pitch space; membership is only decidable when a candidate shares /// a comparison frame with the endpoints (the same "sound but incomplete" /// discipline as [`Pitch::enharmonic_equivalent`]). Derived `Eq` is structural. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct PitchRange { /// The lowest sounding pitch admitted (inclusive). pub lowest: Pitch, /// The highest sounding pitch admitted (inclusive). pub highest: Pitch, } impl PitchRange { /// Whether `pitch` lies within `lowest..=highest`, decided by absolute /// 12-TET semitone ([`Pitch::twelve_tet_semitone`]). Returns `None` — the /// indeterminate case its advisory caller treats as a pass — when: /// /// * the three pitches do not all share a [`PitchSpaceId`] frame (absolute /// semitones across frames use different zero references and are not /// comparable); /// * any pitch's semitone is not determinable without a tuning resolver; or /// * the range is **malformed** in the comparable frame — `lowest` sorts /// strictly above `highest`. A range is well-formed only when `lowest` /// does not sort above `highest` (core spec §"Instrument"); a reversed /// range is undecidable, not "empty", so it must not reject every pitch. pub fn contains(&self, pitch: &Pitch) -> Option { let frame = &self.lowest.scale_position.space; if &self.highest.scale_position.space != frame || &pitch.scale_position.space != frame { return None; } let lo = self.lowest.twelve_tet_semitone()?; let hi = self.highest.twelve_tet_semitone()?; if lo > hi { return None; // malformed (reversed) range: undecidable, not empty } let p = pitch.twelve_tet_semitone()?; Some(lo <= p && p <= hi) } } /// Canonical, deterministic bytes for a [`Pitch`]'s intrinsic content (scale /// position plus acoustic realization), used to derive a content-addressed /// system pitch identifier. Strings are length-prefixed and already NFC (the /// catalog ids normalize on construction); the layout is fixed-shape so equal /// pitches encode to equal bytes (Appendix D §"Canonical serialization"). /// /// Public because these bytes are the normative "canonical inputs" of the /// `MUSCSPCH` derivation (`req:graph:system-derived-pitch-id`): the reduction's /// system-derived counter-collision check (Chapter 5 §"System-Derived Counter /// Collisions") compares exactly these input bytes to distinguish two pitches /// contending for one derived counter. pub fn canonical_pitch_bytes(p: &Pitch) -> Vec { // Length-prefixed UTF-8, normalized to NFC at the derivation boundary so the // canonical input is NFC regardless of how the string was obtained (Appendix // D §"Text and Unicode"). Catalog ids are already NFC at construction, so this // is a no-op for them; normalizing here makes the NFC guarantee explicit and // robust rather than relying on every caller. fn push_str(out: &mut Vec, s: &str) { let nfc: String = s.nfc().collect(); out.extend_from_slice(&(nfc.len() as u32).to_le_bytes()); out.extend_from_slice(nfc.as_bytes()); } let mut out = Vec::new(); push_str(&mut out, p.scale_position.space.as_str()); match &p.scale_position.position { PitchSpacePosition::Cmn { nominal, alteration, octave, } => { out.push(0); out.push(*nominal as u8); out.extend_from_slice(&alteration.to_le_bytes()); out.extend_from_slice(&octave.to_le_bytes()); } PitchSpacePosition::Integer { space_size, index } => { out.push(1); out.extend_from_slice(&space_size.to_le_bytes()); out.extend_from_slice(&index.to_le_bytes()); } PitchSpacePosition::JiVector { components } => { out.push(2); out.extend_from_slice(&(components.len() as u32).to_le_bytes()); for c in components { out.extend_from_slice(&c.to_le_bytes()); } } PitchSpacePosition::Registered(id) => { out.push(3); push_str(&mut out, id.as_str()); } } match &p.acoustic.tuning { TuningReference::Inherit => out.push(0), TuningReference::Explicit(t) => { out.push(1); push_str(&mut out, t.as_str()); } } let f64_bytes = |x: f64| canonical_f64_bytes(x).expect("CanonicalF64 is finite"); match &p.acoustic.realization { AcousticRealization::Implicit => out.push(0), AcousticRealization::CentsOffset(c) => { out.push(1); out.extend_from_slice(&f64_bytes(c.get())); } AcousticRealization::AbsoluteHz(h) => { out.push(2); out.extend_from_slice(&f64_bytes(h.get())); } } out } /// Derives the deterministic [`PitchId`] of a *system-derived* (synthetic) /// pitch in the [`crate::ReplicaId::SYSTEM_DERIVED`] namespace, content-addressed /// from the pitch's intrinsic identity via the `MUSCSPCH` domain tag (Chapter 5 /// §"System-Derived Identifiers"). /// /// The spec defers the exact derivation function for synthetic pitches; this is /// the prototype's concrete realization — the canonical inputs are the pitch's /// content bytes (`canonical_pitch_bytes`) — so two replicas synthesizing the same pitch /// derive a byte-identical id. Recorded as a Pass 11 candidate in `DECISIONS.md` /// (mirrors [`crate::derive_promoted_voice_id`]). The graph-invariant checker /// uses it to prove a `SYSTEM_DERIVED` embedded pitch is a legitimate /// derivation rather than an arbitrary counter. pub fn derive_system_pitch_id(p: &Pitch) -> PitchId { derive_system_id::(SystemDomainTag::PITCH, &canonical_pitch_bytes(p)) } /// A pitch embedded in an event, paired with its stable identifier (Chapter 5 /// §"Identified Pitches"). The identifier enables spelling attachments, /// respelling, pitch-level reduction, and stable tie pairing through chord /// reordering. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct IdentifiedPitch { pub id: PitchId, pub pitch: Pitch, } /// The staff position (nominal) a spelling draws on (Chapter 2 §"The Spelling /// Attachment"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum SpellingNominal { /// CMN nominal (the fast path). Cmn(CmnNominal), /// Integer nominal for N-tone systems. Integer(i32), /// Registered nominal for grammar-specific systems. Registered(NominalRegistryId), } /// Optional rendering hints on a spelling (Chapter 2). Non-normative for /// sounding pitch. #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)] pub struct SpellingRenderHints { pub parenthesized: bool, pub cautionary: bool, pub editorial: bool, pub small_print: bool, } /// An explicit spelling for a pitch: staff position, accidental stack, octave, /// and render hints (Chapter 2 §"The Spelling Attachment"). /// /// The `accidentals` stack is ordered innermost-first for engraving; an empty /// stack means *no glyph is drawn*, which is distinct from a stack containing /// only a natural sign (Chapter 2 §"Absent Accidentals"). The accidental-stack /// well-formedness rule (no repeated [`AccidentalId`] unless the accidental's /// registered combination permits it) is checked by /// [`PitchSpelling::accidental_stack_is_well_formed`]. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct PitchSpelling { pub nominal: SpellingNominal, /// Accidental stack, innermost (closest to the notehead) first. pub accidentals: Vec, pub octave: i8, pub render_hints: SpellingRenderHints, } impl PitchSpelling { /// Moves this spelling by `interval`, keeping its enharmonic choice. /// /// The authored **nominal** is what carries that choice — an author who /// wrote B♯3 rather than C4 chose the letter B — so the diatonic component /// moves the nominal and octave, and the accidental stack is then whatever /// the transposed pitch requires on that new staff line. /// `sounding_semitone` is the transposed pitch's /// [`Pitch::twelve_tet_semitone`]. /// /// So B♯3 (sounding C4) transposed by a perfect fifth `(4, 7)` becomes /// F×4 (sounding G4): the letter moved four diatonic steps — B→F carries /// the octave — and the /// double-sharp is what F needs to sound a G. The chromatic component never /// touches the spelling directly; it reaches it only through the pitch. /// /// `None` when the spelling is not CMN (no nominal to move), or when the /// resulting octave or alteration is not representable. pub fn transposed( &self, interval: TranspositionInterval, sounding_semitone: i32, ) -> Option { let SpellingNominal::Cmn(nominal) = self.nominal else { return None; }; // `i64` throughout, for the same reason `Pitch::transposed` does. let step = i64::from(nominal as u8) + i64::from(interval.diatonic_steps); let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32); let new_octave = i64::from(self.octave) + step.div_euclid(7); let alteration = i64::from(sounding_semitone) - (i64::from(new_nominal.chromatic()) + 12 * new_octave); let octave = i8::try_from(new_octave).ok()?; let alteration = i32::try_from(alteration).ok()?; // Guard the accidental stack: `accidental_ids` allocates |alteration|/2 // glyphs, so an unbounded alteration is an unbounded allocation. i8::try_from(alteration).ok()?; Some(PitchSpelling { nominal: SpellingNominal::Cmn(new_nominal), accidentals: crate::prepass::accidental_ids(alteration), octave, render_hints: self.render_hints, }) } /// A bare CMN spelling with no accidental glyph at the given octave. pub fn cmn(nominal: CmnNominal, octave: i8) -> Self { PitchSpelling { nominal: SpellingNominal::Cmn(nominal), accidentals: Vec::new(), octave, render_hints: SpellingRenderHints::default(), } } /// Whether the accidental stack is free of repeated identifiers. The /// conventional double-sharp/double-flat are single accidental /// definitions, never repeated singles (Chapter 2 §"Accidental Stack /// Semantics"). Registered accidentals that permit repetition are out of /// scope for this baseline check. pub fn accidental_stack_is_well_formed(&self) -> bool { let mut seen = std::collections::BTreeSet::new(); self.accidentals.iter().all(|a| seen.insert(a.clone())) } } /// Provenance of a spelling attachment (Chapter 2 §"Spelling Sources"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum SpellingSource { /// The user explicitly chose this spelling. UserChosen, /// Inferred by the spelling pre-pass. Lowest default precedence. Inferred, /// Imported from a foreign format. Imported { format: ForeignFormatId }, /// Propagated from a transposition or other edit. Propagated { from: PitchId }, /// An analytical spelling on a non-engraved layer. Analytical, } /// The provenance *kind* of a spelling source, for precedence ordering /// (Chapter 2 §"Configurable Precedence"). #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub enum SpellingSourceKind { UserChosen, Imported, Propagated, Inferred, Analytical, } impl SpellingSource { /// The precedence kind of this source. pub fn kind(&self) -> SpellingSourceKind { match self { SpellingSource::UserChosen => SpellingSourceKind::UserChosen, SpellingSource::Imported { .. } => SpellingSourceKind::Imported, SpellingSource::Propagated { .. } => SpellingSourceKind::Propagated, SpellingSource::Inferred => SpellingSourceKind::Inferred, SpellingSource::Analytical => SpellingSourceKind::Analytical, } } } /// A score's spelling-precedence configuration: a total ordering over the /// spelling-source kinds (Chapter 2 §"Configurable Precedence"). Earlier in the /// `order` vector wins. Every score must carry one; the default ranks /// `UserChosen > Imported > Propagated > Inferred`, with `Analytical` last /// (analytical spellings live on their own layers). #[derive(Clone, PartialEq, Eq, Debug)] pub struct SpellingPrecedence { order: Vec, } impl Default for SpellingPrecedence { fn default() -> Self { SpellingPrecedence { order: vec![ SpellingSourceKind::UserChosen, SpellingSourceKind::Imported, SpellingSourceKind::Propagated, SpellingSourceKind::Inferred, SpellingSourceKind::Analytical, ], } } } impl SpellingPrecedence { /// Builds a precedence from a total order. Returns `None` unless every /// source kind appears exactly once (the spec requires a *total* ordering). pub fn new(order: Vec) -> Option { let mut seen = std::collections::BTreeSet::new(); if order.len() != 5 || !order.iter().all(|k| seen.insert(*k)) { return None; } Some(SpellingPrecedence { order }) } /// The precedence order, for the canonical codec. pub(crate) fn order_ref(&self) -> &[SpellingSourceKind] { &self.order } /// The rank of a source kind: lower wins. `0` is the highest precedence. pub fn rank(&self, kind: SpellingSourceKind) -> usize { self.order .iter() .position(|k| *k == kind) .expect("precedence is total over all source kinds") } } /// A voice selector for scope-level directives (Chapter 2 §"Spelling /// Attachment"; completed in Chapter 6). Minimal baseline: all voices, or an /// explicit set. #[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] pub enum VoiceSelector { /// Every voice in scope. #[default] All, /// An explicit set of voices. Voices(Vec), } /// A rule for inferring spellings within a scope (Chapter 2 §"Spelling /// Attachment"). Baseline placeholder referencing a rule set; the rule /// parameters are the spelling-pre-pass open question (Appendix D). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct SpellingRule { pub rule_set: SpellingRuleSetId, } /// What a spelling attachment applies to (Chapter 2). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum SpellingScope { /// Applies to one specific pitch. Pitch(PitchId), /// Applies to all matching pitches in a time range. Range { start: TimeAnchor, end: TimeAnchor, voices: VoiceSelector, }, } /// A spelling directive: an explicit spelling or an inference rule (Chapter 2). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum SpellingDirective { /// An explicit spelling for a single pitch. Only valid with /// [`SpellingScope::Pitch`]. Explicit(PitchSpelling), /// A rule for inferring spellings within a scope. Rule(SpellingRule), } /// An externally-stored spelling, indexed by pitch identifier (Chapter 2 /// §"The Spelling Attachment"). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct SpellingAttachment { pub scope: SpellingScope, pub directive: SpellingDirective, pub source: SpellingSource, /// Tie-break priority among attachments; higher wins (after precedence). pub priority: i32, /// Analysis layer; `None` is the engraved layer. pub layer: Option, } impl SpellingAttachment { /// Whether this attachment is internally well-formed: an /// [`SpellingDirective::Explicit`] directive is valid only with a /// [`SpellingScope::Pitch`] scope (Chapter 2: "Only valid with /// SpellingScope::Pitch"). pub fn is_well_formed(&self) -> bool { !matches!( (&self.scope, &self.directive), (SpellingScope::Range { .. }, SpellingDirective::Explicit(_)) ) } } /// Context consumed by the spelling pre-pass (Chapter 2 §"The Spelling /// Pre-Pass"). Baseline placeholder; the real context (key signature, /// in-measure accidental state, melodic/harmonic context) is filled in once the /// pre-pass algorithm receives an Appendix D disposition. #[derive(Clone, Debug, Default)] pub struct SpellingContext { /// The pitch space active for the pitch being spelled. pub space: Option, } /// The context-free spelling of a single pitch: its authored CMN letter if it /// has one, else the simplest (fewest-accidental) enharmonic spelling of its /// 12-TET pitch class (Chapter 2 §"The Spelling Pre-Pass"). /// /// This is the *isolated* entry point. Real, context-aware spelling — the /// Temperley line-of-fifths pre-pass that resolves a pitch by its melodic /// neighbours — is a function of the whole score and lives in /// [`crate::prepass::derive_annotations`]; the `_ctx` argument is retained for /// source compatibility. A pitch whose space declares spelling unavailable /// (no determinable 12-TET class) falls back to a middle-C advisory default. pub fn spell(p: &Pitch, _ctx: &SpellingContext) -> PitchSpelling { crate::prepass::simplest_spelling(p).unwrap_or_else(|| PitchSpelling::cmn(CmnNominal::C, 4)) } #[cfg(test)] mod tests { use super::*; fn cmn(nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { cmn_in("cmn-12", nominal, alteration, octave) } fn cmn_in(space: &str, nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { Pitch { scale_position: ScalePosition { space: PitchSpaceId::new(space), position: PitchSpacePosition::Cmn { nominal, alteration, octave, }, }, acoustic: AcousticPitch { tuning: TuningReference::Inherit, realization: AcousticRealization::Implicit, }, } } #[test] fn cmn_nominal_discriminants_are_normative() { assert_eq!(CmnNominal::C as u8, 0); assert_eq!(CmnNominal::B as u8, 6); // The cmn-12 chromatic mapping. assert_eq!(CmnNominal::C.chromatic(), 0); assert_eq!(CmnNominal::E.chromatic(), 4); assert_eq!(CmnNominal::B.chromatic(), 11); } fn iv(diatonic_steps: i32, chromatic_steps: i32) -> TranspositionInterval { TranspositionInterval { diatonic_steps, chromatic_steps, } } fn position(p: &Pitch) -> (CmnNominal, i8, i8) { match p.scale_position.position { PitchSpacePosition::Cmn { nominal, alteration, octave, } => (nominal, alteration, octave), ref other => panic!("expected a Cmn position, got {other:?}"), } } #[test] fn transposing_an_octave_moves_the_octave_not_the_alteration() { // The P12-K2 defect, locked: the old alteration-shift produced // `C4 alteration 12` — a C with six double-sharps — for this input. let up = cmn(CmnNominal::C, 0, 4).transposed(iv(7, 12)).unwrap(); assert_eq!(position(&up), (CmnNominal::C, 0, 5)); let down = cmn(CmnNominal::C, 0, 4).transposed(iv(-7, -12)).unwrap(); assert_eq!(position(&down), (CmnNominal::C, 0, 3)); } #[test] fn the_diatonic_component_picks_the_nominal_and_the_alteration_absorbs_the_rest() { // A perfect fifth up: C4 -> G4, no accidental. let fifth = cmn(CmnNominal::C, 0, 4).transposed(iv(4, 7)).unwrap(); assert_eq!(position(&fifth), (CmnNominal::G, 0, 4)); // A *diminished* sixth up sounds the same and is spelled a step away. let dim6 = cmn(CmnNominal::C, 0, 4).transposed(iv(5, 7)).unwrap(); assert_eq!(position(&dim6), (CmnNominal::A, -2, 4)); // Sharpening: the editor's `transpose_selection(1)` equivalent. This // is the one case the frozen operation already got right. let sharp = cmn(CmnNominal::C, 0, 4).transposed(iv(0, 1)).unwrap(); assert_eq!(position(&sharp), (CmnNominal::C, 1, 4)); } #[test] fn transposition_crosses_the_octave_boundary_in_both_directions() { // B4 up a semitone spelled as a diatonic step: C5, not B#4. let up = cmn(CmnNominal::B, 0, 4).transposed(iv(1, 1)).unwrap(); assert_eq!(position(&up), (CmnNominal::C, 0, 5)); // C4 down a diatonic step: B3. Euclidean, so the negative index wraps // to 6 and the octave carries -1. let down = cmn(CmnNominal::C, 0, 4).transposed(iv(-1, -1)).unwrap(); assert_eq!(position(&down), (CmnNominal::B, 0, 3)); // B#4 exists and sounds as C5, an octave *below* the C it spells past. let bsharp = cmn(CmnNominal::B, 1, 4).transposed(iv(0, 0)).unwrap(); assert_eq!(position(&bsharp), (CmnNominal::B, 1, 4)); } #[test] fn every_transposition_is_undone_by_its_inverse() { let start = cmn(CmnNominal::E, -1, 3); for interval in [iv(4, 7), iv(-2, -3), iv(7, 12), iv(0, 1), iv(5, 7)] { let there = start.transposed(interval).unwrap(); let back = there .transposed(interval.inverse().expect("these inverses exist")) .unwrap(); assert_eq!( position(&back), position(&start), "{interval:?} did not round-trip" ); } } #[test] fn a_transposition_refuses_rather_than_saturating() { // The old operation clamped `alteration` at the i8 bound and reported // success. An octave past the i8 bound must refuse instead. let high = cmn(CmnNominal::C, 0, 127); assert_eq!( high.transposed(iv(7, 12)), Err(TransposeRefusal::OutOfRange) ); let low = cmn(CmnNominal::C, 0, -128); assert_eq!( low.transposed(iv(-7, -12)), Err(TransposeRefusal::OutOfRange) ); // An alteration that overflows while the octave is fine. let alt = cmn(CmnNominal::C, 120, 4); assert_eq!(alt.transposed(iv(0, 20)), Err(TransposeRefusal::OutOfRange)); // Nothing is mutated on refusal — `transposed` is by-value. assert_eq!(position(&high), (CmnNominal::C, 0, 127)); } #[test] fn an_extreme_interval_refuses_instead_of_panicking() { // The public type admits any `i32`. Every one of these overflowed an // `i32` intermediate and panicked (or, with overflow-checks off, wrapped // through unspecified arithmetic) before the widening to `i64`. let p = cmn(CmnNominal::C, 0, 4); for (d, c) in [ (i32::MAX, 0), (i32::MIN, 0), (0, i32::MAX), (0, i32::MIN), (i32::MAX, i32::MAX), (i32::MIN, i32::MIN), (i32::MAX, i32::MIN), ] { assert_eq!( p.transposed(iv(d, c)), Err(TransposeRefusal::OutOfRange), "({d}, {c}) must refuse, not panic" ); } // The boundary either side of a representable octave shift. assert!(cmn(CmnNominal::C, 0, 126).transposed(iv(7, 12)).is_ok()); assert_eq!( cmn(CmnNominal::C, 0, 127).transposed(iv(7, 12)), Err(TransposeRefusal::OutOfRange) ); } #[test] fn the_inverse_of_the_unrepresentable_interval_is_none() { // `-i32::MIN` is not an `i32`. Negating it panicked. assert_eq!(iv(i32::MIN, 0).inverse(), None); assert_eq!(iv(0, i32::MIN).inverse(), None); assert_eq!(iv(i32::MIN, i32::MIN).inverse(), None); assert_eq!(iv(i32::MIN + 1, 0).inverse(), Some(iv(i32::MAX, 0))); assert_eq!(iv(4, 7).inverse(), Some(iv(-4, -7))); } #[test] fn a_spelling_moves_by_its_nominal_and_keeps_the_authors_enharmonic_choice() { let semitone = |p: &Pitch| p.twelve_tet_semitone().unwrap(); // The author wrote C-sharp, not D-flat. Up a perfect fifth that must be // G-sharp, not A-flat: the pre-pass, left to itself, may prefer either. let cs4 = cmn(CmnNominal::C, 1, 4); let spelled = PitchSpelling { nominal: SpellingNominal::Cmn(CmnNominal::C), accidentals: crate::prepass::accidental_ids(1), octave: 4, render_hints: SpellingRenderHints::default(), }; let up = cs4.transposed(iv(4, 7)).unwrap(); let moved = spelled.transposed(iv(4, 7), semitone(&up)).unwrap(); assert_eq!(moved.nominal, SpellingNominal::Cmn(CmnNominal::G)); assert_eq!(moved.octave, 4); assert_eq!(moved.accidentals, crate::prepass::accidental_ids(1)); // B-sharp 3 sounds as C4. Up a fifth it must stay a *letter F*, spelled // F-double-sharp 4, sounding G4 — the nominal carries the choice. let bs3 = PitchSpelling { nominal: SpellingNominal::Cmn(CmnNominal::B), accidentals: crate::prepass::accidental_ids(1), octave: 3, render_hints: SpellingRenderHints::default(), }; let c4 = cmn(CmnNominal::C, 0, 4); let g4 = c4.transposed(iv(4, 7)).unwrap(); let moved = bs3.transposed(iv(4, 7), semitone(&g4)).unwrap(); assert_eq!(moved.nominal, SpellingNominal::Cmn(CmnNominal::F)); assert_eq!(moved.octave, 4); assert_eq!(moved.accidentals, crate::prepass::accidental_ids(2)); // A plain sharpen: the staff line never moves. let c = PitchSpelling::cmn(CmnNominal::C, 4); let sharp = c4.transposed(iv(0, 1)).unwrap(); let moved = c.transposed(iv(0, 1), semitone(&sharp)).unwrap(); assert_eq!(moved.nominal, SpellingNominal::Cmn(CmnNominal::C)); assert_eq!(moved.octave, 4); assert_eq!(moved.accidentals, crate::prepass::accidental_ids(1)); } #[test] fn a_spelling_refuses_to_move_when_it_cannot_be_written() { let c = PitchSpelling::cmn(CmnNominal::C, 4); // Octave out of range, and an extreme interval that must not panic. assert_eq!(c.transposed(iv(7 * 200, 12 * 200), 0), None); assert_eq!(c.transposed(iv(i32::MAX, 0), 0), None); assert_eq!(c.transposed(iv(i32::MIN, 0), 0), None); // An alteration that will not fit an `i8`. assert_eq!(c.transposed(iv(0, 0), 4000), None); // A non-CMN nominal has no letter to move. let integer = PitchSpelling { nominal: SpellingNominal::Integer(7), accidentals: Vec::new(), octave: 4, render_hints: SpellingRenderHints::default(), }; assert_eq!(integer.transposed(iv(4, 7), 55), None); } #[test] fn a_transposition_refuses_a_non_cmn_position() { let mut p = cmn(CmnNominal::C, 0, 4); p.scale_position.position = PitchSpacePosition::Integer { space_size: 31, index: 7, }; assert_eq!( p.transposed(iv(4, 7)), Err(TransposeRefusal::NonCmnPosition) ); p.scale_position.position = PitchSpacePosition::JiVector { components: vec![1, 0, -1], }; assert_eq!( p.transposed(iv(0, 0)), Err(TransposeRefusal::NonCmnPosition) ); } #[test] fn unresolved_cmn_space_refuses_transposition_and_twelve_tet_conversion() { // "edo-31" resolves structurally (the built-in catalog fully // determines it — `PositionStructure::Chromatic { 31 }`), but a // *resolved* structure in the wrong family is exactly as unusable to // a `Cmn` position as an unresolved one: `Cmn` only has a defined // action under `DiatonicOverChromatic`. let mut p = cmn(CmnNominal::E, -1, 4); p.scale_position.space = PitchSpaceId::new("edo-31"); assert_eq!( p.transposed(iv(4, 7)), Err(TransposeRefusal::PitchSpaceUnavailable) ); assert_eq!(p.twelve_tet_semitone(), None); assert_eq!(p.twelve_tet_class(), None); } #[test] fn transposition_refuses_a_ji_space_an_unknown_identifier_and_an_unresolved_catalog_space() { // Three distinct ways `req:pitch:space-capability-refusal` is // satisfied without guessing: a real catalog identifier whose family // is JI (`ji-5limit`), an identifier the catalog has never heard of, // and one of the six built-in identifiers the catalog names but does // not structurally determine (`maqam-base`). for space in ["ji-5limit", "not-a-built-in-pitch-space", "maqam-base"] { let p = cmn_in(space, CmnNominal::C, 0, 4); assert_eq!( p.transposed(iv(4, 7)), Err(TransposeRefusal::PitchSpaceUnavailable), "{space} must refuse a transposition rather than guess a pitch-space structure" ); } } #[test] fn twelve_tet_semitone_stays_none_for_a_resolved_non_twelve_chromatic_structure() { // `cmn-24` resolves structurally — it is one of the seven fully // determined catalog entries — but its chromatic cardinality is 24, // not 12. `twelve_tet_semitone`/`twelve_tet_class` must still refuse: // resolving *a* structure is not the same as resolving a // *twelve*-chromatic one (`spec/CONTRACT_PUSH4B_PITCHSPACES.md` item // 4 — the name stays honest only if it keeps this gate). let p = cmn_in("cmn-24", CmnNominal::E, -1, 4); assert_eq!(p.twelve_tet_semitone(), None); assert_eq!(p.twelve_tet_class(), None); } #[test] fn cmn_24_transposes_using_its_own_quarter_tone_structure() { // The tranche's acceptance criterion (`spec/CONTRACT_PUSH4B_PITCHSPACES.md` // "Proof of life"): a `cmn-24` pitch transposes end-to-end, with the // *resulting scale position* asserted — not merely `is_ok()`. // First, the quarter-tone alteration unit itself // (`core_spec.tex:3606`: "A flat is -2 and a half-flat is -1"), via a // same-nominal chromatic-only shift. let c4 = cmn_in("cmn-24", CmnNominal::C, 0, 4); let half_flat = c4.transposed(iv(0, -1)).unwrap(); assert_eq!( half_flat.scale_position.position, PitchSpacePosition::Cmn { nominal: CmnNominal::C, alteration: -1, octave: 4, } ); let flat = c4.transposed(iv(0, -2)).unwrap(); assert_eq!( flat.scale_position.position, PitchSpacePosition::Cmn { nominal: CmnNominal::C, alteration: -2, octave: 4, } ); // Second, the case that only a genuinely 24-chromatic computation // gets right: B4 up a semitone (2 quarter-tone steps) spelled as a // diatonic step lands on a *natural* C5. `cmn-24`'s nominal map is // `[0,4,8,10,14,18,22]` (`core_spec.tex:3609`) and its chromatic // cardinality is 24 — not "2x the cmn-12 map applied to a 12-space". // Arithmetic that silently fell back to `nominal.chromatic()` (the // cmn-12 map, `B=11`) and a chromatic cardinality of 12 would instead // compute `alteration' = (11+0+48+2) - (0+60) = 1`: a spurious C5♯. let b4 = cmn_in("cmn-24", CmnNominal::B, 0, 4); let up_a_semitone = b4.transposed(iv(1, 2)).unwrap(); assert_eq!( up_a_semitone.scale_position.position, PitchSpacePosition::Cmn { nominal: CmnNominal::C, alteration: 0, octave: 5, } ); } #[test] fn a_transposition_refuses_a_pitch_pinned_to_a_frequency() { // AbsoluteHz overrides the tuning system: moving the scale position // would move the notehead and leave the sound where it was. let mut p = cmn(CmnNominal::C, 0, 4); p.acoustic.realization = AcousticRealization::absolute_hz(261.6).unwrap(); assert_eq!( p.transposed(iv(4, 7)), Err(TransposeRefusal::AcousticPinned) ); // A cents offset is relative to whatever the tuning produces for the // *new* position, so it transposes and survives. let mut q = cmn(CmnNominal::C, 0, 4); q.acoustic.realization = AcousticRealization::cents_offset(-13.7).unwrap(); let moved = q.transposed(iv(4, 7)).unwrap(); assert_eq!(position(&moved), (CmnNominal::G, 0, 4)); assert_eq!(moved.acoustic.realization, q.acoustic.realization); } #[test] fn pitch_range_contains_is_advisory_and_frame_aware() { let range = PitchRange { lowest: cmn(CmnNominal::C, 0, 2), highest: cmn(CmnNominal::C, 0, 6), }; // Interior and inclusive endpoints are in range. assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 4)), Some(true)); assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 2)), Some(true)); assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 6)), Some(true)); // Below and above are out of range. assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 1)), Some(false)); assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 7)), Some(false)); // A malformed (reversed) range is *undecidable*, not "everything out of // range" — it must not reject every comparable pitch. let reversed = PitchRange { lowest: cmn(CmnNominal::C, 0, 6), highest: cmn(CmnNominal::C, 0, 2), }; assert_eq!(reversed.contains(&cmn(CmnNominal::C, 0, 4)), None); // A candidate in a different pitch-space frame is undecidable (absolute // semitones across frames are not comparable). let other_frame = Pitch { scale_position: ScalePosition { space: PitchSpaceId::new("cmn-19"), position: PitchSpacePosition::Integer { space_size: 12, index: 48, }, }, acoustic: AcousticPitch { tuning: TuningReference::Inherit, realization: AcousticRealization::Implicit, }, }; assert_eq!(other_frame.twelve_tet_semitone(), Some(48)); assert_eq!(range.contains(&other_frame), None); } #[test] fn enharmonic_equivalence_is_twelve_tet_pitch_class() { // C-sharp4 and D-flat4 are enharmonic but not structurally equal nor // scale-position equivalent. let cis = cmn(CmnNominal::C, 1, 4); let des = cmn(CmnNominal::D, -1, 4); assert_ne!(cis, des); assert!(!cis.scale_position_equivalent(&des)); assert!(cis.enharmonic_equivalent(&des)); // B-sharp 3 wraps up to C4 (same sounding semitone across the octave // boundary): octave-aware arithmetic handles this. let bis = cmn(CmnNominal::B, 1, 3); let c = cmn(CmnNominal::C, 0, 4); assert!(bis.enharmonic_equivalent(&c)); // A different pitch class is not enharmonic. assert!(!cmn(CmnNominal::C, 0, 4).enharmonic_equivalent(&cmn(CmnNominal::D, 0, 4))); // Enharmonic equivalence is a *sounding* notion: the same nominal an // octave apart is NOT equivalent (it sounds an octave higher). assert!(!cmn(CmnNominal::C, 0, 4).enharmonic_equivalent(&cmn(CmnNominal::C, 0, 5))); assert!(cmn(CmnNominal::C, 0, 4).enharmonic_equivalent(&cmn(CmnNominal::C, 0, 4))); } #[test] fn structural_equality_is_not_equivalence() { let a = cmn(CmnNominal::C, 0, 4); let b = cmn(CmnNominal::C, 0, 4); assert_eq!(a, b); assert!(a.scale_position_equivalent(&b)); } #[test] fn enharmonic_requires_the_same_pitch_space() { // Both operands are independently computable as the same absolute // 12-TET semitone. The rejection therefore proves the space-frame // branch rather than passing through conversion unavailability. let cis = cmn(CmnNominal::C, 1, 4); let other_space = Pitch { scale_position: ScalePosition { space: PitchSpaceId::new("edo-31"), position: PitchSpacePosition::Integer { space_size: 12, index: 49, }, }, acoustic: AcousticPitch { tuning: TuningReference::Inherit, realization: AcousticRealization::Implicit, }, }; assert_eq!(cis.twelve_tet_semitone(), Some(49)); assert_eq!(other_space.twelve_tet_semitone(), Some(49)); assert!(cis.enharmonic_equivalent(&cmn(CmnNominal::D, -1, 4))); assert!(!cis.enharmonic_equivalent(&other_space)); } #[test] fn sounding_equivalence_uses_acoustic_cents_tolerance_class() { use epiphany_determinism::{Tolerance, ToleranceClass, ToleranceGovernance}; let cents = |c| { Tolerance::absolute( ToleranceClass::AcousticCents, c, ToleranceGovernance::Validation, ) .unwrap() }; let a = cmn(CmnNominal::A, 0, 4); let b = cmn(CmnNominal::A, 0, 4); // A resolver placing both at 440 Hz -> equivalent; 440 vs 466 (~100c) // is not within a 5-cent tolerance. assert!(a.sounding_equivalent(&b, cents(5.0), |_| Some(440.0))); let resolve = |p: &Pitch| match &p.scale_position.position { PitchSpacePosition::Cmn { nominal, .. } if *nominal == CmnNominal::A => Some(440.0), _ => Some(466.16), }; assert!(!a.sounding_equivalent(&cmn(CmnNominal::B, -1, 4), cents(5.0), resolve)); // An AbsoluteHz pitch resolves without the closure. let mut abs = cmn(CmnNominal::A, 0, 4); abs.acoustic.realization = AcousticRealization::absolute_hz(440.0).unwrap(); assert!(abs.sounding_equivalent(&b, cents(1.0), |_| Some(440.0))); // A tolerance of the wrong class is a category error and never matches, // even for identical pitches. let wrong = Tolerance::absolute( ToleranceClass::LayoutCoordinate, 5.0, ToleranceGovernance::Validation, ) .unwrap(); assert!(!a.sounding_equivalent(&b, wrong, |_| Some(440.0))); // A resolver returning an infinite frequency never spuriously matches // (the named tolerance rejects non-finite operands). assert!(!a.sounding_equivalent(&b, cents(5.0), |_| Some(f64::INFINITY))); } #[test] fn catalog_ids_normalize_to_nfc() { // Precomposed "é" (U+00E9) vs decomposed "e" + combining acute (U+0301) // are canonically equivalent and MUST intern equal (Appendix D). let precomposed = PitchSpaceId::new("caf\u{00e9}"); let decomposed = PitchSpaceId::new("cafe\u{0301}"); assert_eq!(precomposed, decomposed); assert_eq!(precomposed.as_str(), decomposed.as_str()); use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let h = |x: &PitchSpaceId| { let mut s = DefaultHasher::new(); x.hash(&mut s); s.finish() }; assert_eq!(h(&precomposed), h(&decomposed)); } #[test] fn system_pitch_derivation_is_deterministic_and_content_addressed() { let p = cmn(CmnNominal::C, 0, 4); let id = derive_system_pitch_id(&p); // Deterministic and in the reserved namespace. assert_eq!(id, derive_system_pitch_id(&p)); assert_eq!(id.replica(), crate::ids::ReplicaId::SYSTEM_DERIVED); // Different content derives a different id. assert_ne!(id, derive_system_pitch_id(&cmn(CmnNominal::D, 0, 4))); } #[test] fn system_pitch_id_byte_form_is_locked() { // Golden: locks the MUSCSPCH canonical-input layout (space name, scale // position discriminant + payload, tuning, acoustic realization; strings // length-prefixed NFC) and the hash. RATIFIED by Pass 11 (item 1.3, // P11-6): this is the spec's golden, normative in core_spec // §"System-Derived Pitch Identity", // Requirement `req:graph:system-derived-pitch-id` — note the tuning // reference (incl. the Inherit marker) is always part of intrinsic // identity. A change to the byte form breaks this deliberately. let id = derive_system_pitch_id(&cmn(CmnNominal::C, 0, 4)); assert_eq!(id.replica(), crate::ids::ReplicaId::SYSTEM_DERIVED); const GOLDEN: [u8; 16] = [ 255, 255, 255, 255, 255, 255, 255, 255, 164, 31, 138, 24, 68, 38, 241, 168, ]; assert_eq!(id.canonical_bytes(), GOLDEN); } #[test] fn reference_pitch_rejects_non_positive_frequency() { let pos = PitchSpacePosition::Cmn { nominal: CmnNominal::A, alteration: 0, octave: 4, }; assert!(ReferencePitch::new(pos.clone(), -440.0).is_none()); assert!(ReferencePitch::new(pos.clone(), 0.0).is_none()); assert_eq!( ReferencePitch::new(pos, 440.0).unwrap().frequency_hz(), 440.0 ); assert_eq!(ReferencePitch::a440().frequency_hz(), 440.0); } #[test] fn acoustic_realization_rejects_bad_floats() { assert!(AcousticRealization::cents_offset(f64::NAN).is_none()); assert!(AcousticRealization::absolute_hz(0.0).is_none()); assert!(AcousticRealization::absolute_hz(-440.0).is_none()); assert!(AcousticRealization::absolute_hz(440.0).is_some()); } #[test] fn accidental_stack_rejects_repeats() { let mut s = PitchSpelling::cmn(CmnNominal::F, 4); s.accidentals = vec![AccidentalId::new("sharp")]; assert!(s.accidental_stack_is_well_formed()); s.accidentals = vec![AccidentalId::new("sharp"), AccidentalId::new("sharp")]; assert!(!s.accidental_stack_is_well_formed()); } #[test] fn spelling_precedence_default_and_totality() { let p = SpellingPrecedence::default(); assert!(p.rank(SpellingSourceKind::UserChosen) < p.rank(SpellingSourceKind::Inferred)); assert!(p.rank(SpellingSourceKind::Imported) < p.rank(SpellingSourceKind::Propagated)); // Non-total configurations are rejected. assert!(SpellingPrecedence::new(vec![SpellingSourceKind::UserChosen]).is_none()); assert!(SpellingPrecedence::new(vec![ SpellingSourceKind::UserChosen, SpellingSourceKind::UserChosen, SpellingSourceKind::Imported, SpellingSourceKind::Propagated, SpellingSourceKind::Inferred, ]) .is_none()); } #[test] fn explicit_spelling_requires_pitch_scope() { let pid = PitchId::new(crate::ReplicaId(1), 0); let ok = SpellingAttachment { scope: SpellingScope::Pitch(pid), directive: SpellingDirective::Explicit(PitchSpelling::cmn(CmnNominal::C, 4)), source: SpellingSource::UserChosen, priority: 0, layer: None, }; assert!(ok.is_well_formed()); } #[test] fn spell_preserves_authored_cmn_letter() { // An authored C-sharp keeps its letter (spelling follows the scale // position, not the trivial old C-default stub). let p = Pitch { scale_position: ScalePosition { space: PitchSpaceId::new("cmn-12"), position: PitchSpacePosition::Cmn { nominal: CmnNominal::C, alteration: 1, octave: 5, }, }, acoustic: AcousticPitch { tuning: TuningReference::Inherit, realization: AcousticRealization::Implicit, }, }; let s = spell(&p, &SpellingContext::default()); assert_eq!(s.nominal, SpellingNominal::Cmn(CmnNominal::C)); assert_eq!(s.accidentals, vec![AccidentalId::new("sharp")]); assert_eq!(s.octave, 5); assert_eq!(SpellingAlgorithmId::default_id().as_str(), "default"); } #[test] fn spell_chromatic_integer_pitch_is_nontrivial() { // A 12-EDO integer position (chromatic input) gets a real spelling of // its pitch class, not the old constant middle-C. let p = Pitch { scale_position: ScalePosition { space: PitchSpaceId::new("cmn-12"), position: PitchSpacePosition::Integer { space_size: 12, index: 54, // pitch class 6 (F#/Gb) }, }, acoustic: AcousticPitch { tuning: TuningReference::Inherit, realization: AcousticRealization::Implicit, }, }; let s = spell(&p, &SpellingContext::default()); // Simplest single-accidental spelling of pitch class 6 (either F# or Gb); // it is a real, non-default spelling. assert!(matches!( s.nominal, SpellingNominal::Cmn(CmnNominal::F) | SpellingNominal::Cmn(CmnNominal::G) )); assert_eq!(s.accidentals.len(), 1); } }