1use sim_lib_pitch_core::PitchClass;
2
3use crate::PitchClassMask;
4
5#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
10pub enum SetEquivalence {
11 Transposition,
13 TranspositionInversion,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct SetClass {
20 pub normal: Vec<PitchClass>,
22 pub prime: Vec<PitchClass>,
24 pub equivalence: SetEquivalence,
26}
27
28pub 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub struct ForteFact {
53 pub label: &'static str,
55 pub cardinality: u8,
57 pub ordinal: u8,
59 pub prime: Vec<PitchClass>,
61}
62
63pub 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}