Skip to main content

sim_lib_pitch_set/
conventional.rs

1use sim_lib_pitch_core::PitchClass;
2
3use crate::PitchClassMask;
4
5/// Conventional pitch-set equivalence policy.
6///
7/// This is intentionally distinct from [`PitchClassMask::normalize`], whose
8/// numeric mask identity remains source-compatible.
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
10pub enum SetEquivalence {
11    /// Compare set classes under transposition only.
12    Transposition,
13    /// Compare set classes under transposition and inversion.
14    TranspositionInversion,
15}
16
17/// Conventional pitch-set class identity.
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct SetClass {
20    /// Forte-style normal order, transposed so the first pitch class is C.
21    pub normal: Vec<PitchClass>,
22    /// Prime form under the selected [`SetEquivalence`] policy.
23    pub prime: Vec<PitchClass>,
24    /// The equivalence policy used to produce `prime`.
25    pub equivalence: SetEquivalence,
26}
27
28/// Classifies a pitch-class mask using conventional normal-order and prime-form
29/// identity.
30pub fn classify_set(set: PitchClassMask, equivalence: SetEquivalence) -> SetClass {
31    let normal = normal_order(set.pitch_classes());
32    let prime = match equivalence {
33        SetEquivalence::Transposition => normal.clone(),
34        SetEquivalence::TranspositionInversion => {
35            let inverted = normal_order(set.invert(PitchClass::C).pitch_classes());
36            if compare_prime_forms(&inverted, &normal).is_lt() {
37                inverted
38            } else {
39                normal.clone()
40            }
41        }
42    };
43    SetClass {
44        normal,
45        prime,
46        equivalence,
47    }
48}
49
50/// A derived Forte-compatible fact for a pitch-set class.
51#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub struct ForteFact {
53    /// Forte set-class label when this crate carries a named example for it.
54    pub label: &'static str,
55    /// Cardinality of the set class.
56    pub cardinality: u8,
57    /// Ordinal part of the Forte label.
58    pub ordinal: u8,
59    /// Prime form associated with this fact.
60    pub prime: Vec<PitchClass>,
61}
62
63/// Returns a small Forte-compatible example fact when the class is one this
64/// crate names explicitly.
65pub fn forte_fact_for(set: PitchClassMask) -> Option<ForteFact> {
66    let prime = classify_set(set, SetEquivalence::TranspositionInversion).prime;
67    let (label, cardinality, ordinal) = match pitch_class_values(&prime).as_slice() {
68        [0, 3, 7] => ("3-11A", 3, 11),
69        [0, 4, 7] => ("3-11B", 3, 11),
70        [0, 1, 4, 6] => ("4-Z15", 4, 15),
71        [0, 1, 3, 7] => ("4-Z29", 4, 29),
72        [0, 1, 4, 6, 8, 9] => ("6-Z17", 6, 17),
73        [0, 1, 3, 4, 6, 8] => ("6-Z43", 6, 43),
74        _ => return None,
75    };
76    Some(ForteFact {
77        label,
78        cardinality,
79        ordinal,
80        prime,
81    })
82}
83
84pub(crate) fn normal_order(mut pitch_classes: Vec<PitchClass>) -> Vec<PitchClass> {
85    if pitch_classes.len() <= 1 {
86        return pitch_classes;
87    }
88    pitch_classes.sort_by_key(|pitch_class| pitch_class.value());
89    pitch_classes.dedup();
90
91    let mut best: Option<Vec<PitchClass>> = None;
92    for start in 0..pitch_classes.len() {
93        let candidate = rotate_pitch_classes(&pitch_classes, start);
94        if best
95            .as_ref()
96            .is_none_or(|current| compare_normal_orders(&candidate, current).is_lt())
97        {
98            best = Some(candidate);
99        }
100    }
101    transpose_to_zero(&best.expect("non-empty pitch-class list has a rotation"))
102}
103
104fn rotate_pitch_classes(pitch_classes: &[PitchClass], start: usize) -> Vec<PitchClass> {
105    let root = pitch_classes[start].value();
106    (0..pitch_classes.len())
107        .map(|offset| {
108            let index = (start + offset) % pitch_classes.len();
109            let value = pitch_classes[index].value();
110            let wrapped = if index < start { value + 12 } else { value };
111            PitchClass::new(wrapped - root).expect("normal-order rotation folds to pitch class")
112        })
113        .collect()
114}
115
116fn transpose_to_zero(pitch_classes: &[PitchClass]) -> Vec<PitchClass> {
117    let Some(first) = pitch_classes.first() else {
118        return Vec::new();
119    };
120    let transposition = -i32::from(first.value());
121    pitch_classes
122        .iter()
123        .map(|pitch_class| pitch_class.transpose(transposition))
124        .collect()
125}
126
127fn compare_normal_orders(left: &[PitchClass], right: &[PitchClass]) -> std::cmp::Ordering {
128    compare_by_span_then_packed(left, right, true)
129}
130
131fn compare_prime_forms(left: &[PitchClass], right: &[PitchClass]) -> std::cmp::Ordering {
132    compare_by_span_then_packed(left, right, false)
133}
134
135fn compare_by_span_then_packed(
136    left: &[PitchClass],
137    right: &[PitchClass],
138    prefer_right_packed: bool,
139) -> std::cmp::Ordering {
140    for index in (1..left.len()).rev() {
141        let ordering = left[index].value().cmp(&right[index].value());
142        if !ordering.is_eq() {
143            return ordering;
144        }
145    }
146    if prefer_right_packed {
147        for index in 1..left.len() {
148            let ordering = right[index].value().cmp(&left[index].value());
149            if !ordering.is_eq() {
150                return ordering;
151            }
152        }
153    }
154    pitch_class_values(left).cmp(&pitch_class_values(right))
155}
156
157fn pitch_class_values(pitch_classes: &[PitchClass]) -> Vec<u8> {
158    pitch_classes
159        .iter()
160        .map(|pitch_class| pitch_class.value())
161        .collect()
162}