Skip to main content

music21_rs/chord/
setclass.rs

1//! The chord as a pitch-class set: its place in the Forte tables, the
2//! prime and normal forms, the interval vector and the constructors that
3//! build a chord from those.
4
5use super::*;
6
7impl Chord {
8    /// Builds the chord of a Forte set class from its name, `3-11` or
9    /// `4-27B`: music21's `fromForteClass`. The pitches are the transposed
10    /// normal form from C, without octaves, so `3-11` is `C E- G` and `3-11B`
11    /// is `C E G`.
12    pub fn from_forte_class(notation: &str) -> Result<Self> {
13        let Some((cardinality, rest)) = notation.split_once('-') else {
14            return Err(Error::Chord(format!(
15                "cannot extract set-class representation from string: {notation}"
16            )));
17        };
18        // A Z-related class is written `4-Z15`, or `4-z15`; the number is the
19        // same address either way.
20        let rest = rest.trim_start_matches(['z', 'Z']);
21        let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
22        let letters = &rest[digits.len()..];
23        let inversion = match letters.to_ascii_lowercase().as_str() {
24            "a" => Some(1),
25            "b" => Some(-1),
26            _ => None,
27        };
28        let cardinality = cardinality
29            .parse::<u8>()
30            .map_err(|_| Error::Chord(format!("cannot read a cardinality out of {notation}")))?;
31        let index = digits
32            .parse::<u8>()
33            .map_err(|_| Error::Chord(format!("cannot read a Forte number out of {notation}")))?;
34        Self::from_forte_address(cardinality, index, inversion)
35    }
36
37    /// Builds the chord of a Forte set class from its table address: the
38    /// cardinality, the number within it, and `1`, `-1` or `None` for the
39    /// inversion, where `None` takes the form the table lists first.
40    pub fn from_forte_address(cardinality: u8, index: u8, inversion: Option<i8>) -> Result<Self> {
41        let pitch_classes = tables::transposed_normal_form(cardinality, index, inversion)?;
42        Self::from_pitch_class_list(&pitch_classes)
43    }
44
45    /// Builds the chord whose interval-class vector this is: music21's
46    /// `fromIntervalVector`. Z-related pairs share a vector; the first of
47    /// the pair is returned unless `z_relation` asks for the second. `None`
48    /// when no set class has the vector.
49    pub fn from_interval_vector(vector: &[u8; 6], z_relation: bool) -> Option<Self> {
50        let mut seen: Vec<(u8, String)> = Vec::new();
51        for entry in tables::known_chord_table_entries() {
52            if entry.interval_class_vector != vector {
53                continue;
54            }
55            let number = entry.forte_class.trim_end_matches(['A', 'B']).to_string();
56            if seen
57                .iter()
58                .any(|(card, seen_number)| *card == entry.cardinality && *seen_number == number)
59            {
60                continue;
61            }
62            seen.push((entry.cardinality, number));
63        }
64        let (cardinality, number) = match (seen.len(), z_relation) {
65            (1, _) | (2, false) => seen.first()?.clone(),
66            (2, true) => seen.get(1)?.clone(),
67            _ => return None,
68        };
69        let index = number.split_once('-')?.1.parse().ok()?;
70        Self::from_forte_address(cardinality, index, None).ok()
71    }
72
73    /// A chord from pitch-class integers, spelled the way music21 spells a
74    /// chord built from integers: each class on its own and then
75    /// `simplifyEnharmonics` over the whole, so `[0, 3, 6, 8]` is `C E- G- A-`.
76    pub(super) fn from_pitch_class_list(pitch_classes: &[u8]) -> Result<Self> {
77        let pitches = pitch_classes
78            .iter()
79            .map(|&pc| Pitch::from_pitch_class(IntegerType::from(pc)))
80            .collect::<Result<Vec<_>>>()?;
81        Self::new(pitches.as_slice())?.simplify_enharmonics(None)
82    }
83
84    /// Returns the Forte class, such as `"3-11B"`, when available.
85    ///
86    /// Returns `None` when the chord's pitch-class set has no Forte-table
87    /// entry, including empty or otherwise unsupported pitch-class sets.
88    pub fn forte_class(&self) -> Option<String> {
89        let ordered_pcs = self.ordered_pitch_classes();
90        let address = tables::seek_chord_tables_address(&ordered_pcs).ok()?;
91        tables::address_to_forte_name(address, "tn").ok()
92    }
93
94    /// Returns the normal form transposed to start on zero, `[0, 3, 6, 8]`
95    /// for `C E G B-`. This is the Forte-table form music21 reads off the
96    /// chord's table address; [`Self::normal_order`] is music21's
97    /// `normalOrder`, on the chord's own pitch classes.
98    ///
99    /// Returns `None` when the chord's pitch-class set cannot be found in the
100    /// chord tables, including empty or otherwise unsupported pitch-class sets.
101    pub fn normal_form(&self) -> Option<Vec<u8>> {
102        let ordered_pcs = self.ordered_pitch_classes();
103        let address = tables::seek_chord_tables_address(&ordered_pcs).ok()?;
104        tables::transposed_normal_form_from_address(address).ok()
105    }
106
107    /// Returns the interval-class vector when table metadata is available.
108    ///
109    /// Returns `None` when the chord's pitch-class set cannot be found in the
110    /// chord tables, including empty or otherwise unsupported pitch-class sets.
111    pub fn interval_class_vector(&self) -> Option<Vec<u8>> {
112        let ordered_pcs = self.ordered_pitch_classes();
113        let address = tables::seek_chord_tables_address(&ordered_pcs).ok()?;
114        tables::interval_class_vector_from_address(address).ok()
115    }
116
117    /// Returns Robert Morris's eight-entry invariance vector, when available.
118    ///
119    /// The values are taken from the same music21 Forte table as
120    /// [`Self::forte_class`] and [`Self::interval_class_vector`].
121    pub fn invariance_vector(&self) -> Option<Vec<u8>> {
122        let ordered_pcs = self.ordered_pitch_classes();
123        let address = tables::seek_chord_tables_address(&ordered_pcs).ok()?;
124        tables::invariance_vector_from_address(address).ok()
125    }
126
127    /// Returns this chord's Z-related Forte class, when music21 records one.
128    pub fn z_relation(&self) -> Option<String> {
129        let ordered_pcs = self.ordered_pitch_classes();
130        let address = tables::seek_chord_tables_address(&ordered_pcs).ok()?;
131        tables::z_relation_from_address(address).ok().flatten()
132    }
133
134    pub(super) fn forte_address(&self) -> Option<(u8, u8, i8)> {
135        tables::seek_chord_tables_address(&self.ordered_pitch_classes())
136            .ok()
137            .map(|(card, index, inversion, _)| (card, index, inversion))
138    }
139
140    /// Returns the Forte prime form of the pitch-class set, such as
141    /// `[0, 3, 7]` for any major or minor triad. Empty when the chord has no
142    /// table entry.
143    pub fn prime_form(&self) -> Vec<u8> {
144        tables::seek_chord_tables_address(&self.ordered_pitch_classes())
145            .and_then(tables::prime_form_from_address)
146            .unwrap_or_default()
147    }
148
149    /// Returns the prime form in music21's angle-bracket notation, such as
150    /// `"<037>"`, with `A` and `B` standing for ten and eleven.
151    pub fn prime_form_string(&self) -> String {
152        format_pitch_classes(&self.prime_form())
153    }
154
155    /// Returns the Forte class without the `A`/`B` inversion suffix, such as
156    /// `"3-11"` for both major and minor triads.
157    pub fn forte_class_tni(&self) -> Option<String> {
158        let address = tables::seek_chord_tables_address(&self.ordered_pitch_classes()).ok()?;
159        tables::address_to_forte_name(address, "tni").ok()
160    }
161
162    /// Returns the number of distinct pitch classes.
163    pub fn pitch_class_cardinality(&self) -> usize {
164        self.pitch_class_set().len()
165    }
166
167    /// Returns the distinct pitch classes in ascending order in music21's
168    /// angle-bracket notation, such as `"<047>"`.
169    pub fn ordered_pitch_classes_string(&self) -> String {
170        format_pitch_classes(&self.ordered_pitch_classes())
171    }
172
173    pub(super) fn ordered_pitch_classes(&self) -> Vec<u8> {
174        let mut pcs = self
175            .notes
176            .iter()
177            .map(|note| root::pitch_class(&note.pitch))
178            .collect::<Vec<_>>();
179        pcs.sort_unstable();
180        pcs.dedup();
181        pcs
182    }
183
184    pub(super) fn pitch_class_set(&self) -> std::collections::BTreeSet<u8> {
185        self.ordered_pitch_classes().into_iter().collect()
186    }
187
188    pub(super) fn pitch_class_mask(&self) -> u16 {
189        self.ordered_pitch_classes()
190            .into_iter()
191            .fold(0_u16, |mask, pc| mask | (1_u16 << pc))
192    }
193
194    /// Where this chord's set class sits in the Forte tables.
195    ///
196    /// An empty chord answers all zeros rather than failing, which is the
197    /// one place music21's `Chord.chordTablesAddress` differs from the
198    /// `seekChordTablesAddress` underneath it.
199    pub fn chord_tables_address_entry(&self) -> ChordTableAddress {
200        match self.chord_tables_address() {
201            Some((cardinality, forte_class, inversion, original)) => ChordTableAddress {
202                cardinality,
203                forte_class,
204                inversion,
205                pitch_class_original: original.unwrap_or(0),
206            },
207            None => ChordTableAddress {
208                cardinality: 0,
209                forte_class: 0,
210                inversion: 0,
211                pitch_class_original: 0,
212            },
213        }
214    }
215
216    pub(super) fn chord_tables_address(&self) -> Option<tables::RawAddress> {
217        tables::seek_chord_tables_address(&self.ordered_pitch_classes()).ok()
218    }
219
220    /// Returns music21's `geometricNormalForm`: the distinct pitch classes
221    /// rotated so the intervals between neighbours read smallest first, then
222    /// written from zero, so both `C E G` and `E G C` are `[0, 3, 8]`. Empty
223    /// for an empty chord.
224    pub fn geometric_normal_form(&self) -> Vec<u8> {
225        let pitch_classes = self.ordered_pitch_classes();
226        if pitch_classes.is_empty() {
227            return Vec::new();
228        }
229        let intervals: Vec<u8> = pitch_classes
230            .iter()
231            .zip(pitch_classes.iter().cycle().skip(1))
232            .map(|(&low, &high)| (high + 12 - low) % 12)
233            .collect();
234        let best = (0..intervals.len())
235            .map(|rotation| {
236                let mut rotated = intervals[rotation + 1..].to_vec();
237                rotated.extend_from_slice(&intervals[..=rotation]);
238                rotated
239            })
240            .min()
241            .unwrap_or_default();
242        let mut sum = 0;
243        best.iter()
244            .map(|interval| {
245                let pitch_class = sum;
246                sum += interval;
247                pitch_class
248            })
249            .collect()
250    }
251
252    /// Returns the interval-class vector in music21's angle-bracket
253    /// notation, `<001110>`; an empty chord reads `<000000>`.
254    pub fn interval_vector_string(&self) -> String {
255        format_pitch_classes(&self.interval_class_vector().unwrap_or_else(|| vec![0; 6]))
256    }
257
258    /// Returns music21's `normalOrder`: the most compact rotation of the
259    /// pitch classes, on the chord's own pitch classes rather than
260    /// transposed to zero, so `C E G B-` is `[4, 7, 10, 0]` where
261    /// [`Self::normal_form`] is `[0, 3, 6, 8]`. Empty for an empty chord.
262    pub fn normal_order(&self) -> Vec<u8> {
263        let Some(transposed) = self.normal_form() else {
264            return Vec::new();
265        };
266        let ordered = self.ordered_pitch_classes();
267        ordered
268            .iter()
269            .map(|&transposition| {
270                transposed
271                    .iter()
272                    .map(|&pc| (pc + transposition) % 12)
273                    .collect::<Vec<u8>>()
274            })
275            .find(|candidate| {
276                let mut sorted = candidate.clone();
277                sorted.sort_unstable();
278                sorted == ordered
279            })
280            .unwrap_or_default()
281    }
282
283    /// Returns [`Self::normal_order`] in music21's angle-bracket notation,
284    /// `<47A0>`.
285    pub fn normal_order_string(&self) -> String {
286        format_pitch_classes(&self.normal_order())
287    }
288
289    /// Returns the Forte class number within the cardinality, `11` for a
290    /// major or minor triad. `None` for an empty chord.
291    pub fn forte_class_number(&self) -> Option<u8> {
292        self.chord_tables_address().map(|address| address.1)
293    }
294
295    /// Returns the Forte class under transposition equivalence, with the
296    /// `A`/`B` inversion suffix: music21's `forteClassTn`, the same as
297    /// [`Self::forte_class`].
298    pub fn forte_class_tn(&self) -> Option<String> {
299        self.forte_class()
300    }
301
302    /// Returns the number of notes, counting repeated pitch classes: music21's
303    /// `multisetCardinality`.
304    pub fn multiset_cardinality(&self) -> usize {
305        self.notes.len()
306    }
307
308    /// Returns whether the pitch-class set is the inversion of its prime
309    /// form, so its Forte class carries a `B` suffix.
310    pub fn is_prime_form_inversion(&self) -> bool {
311        self.chord_tables_address()
312            .is_some_and(|address| address.2 == -1)
313    }
314
315    /// Returns whether music21 records a Z-related set class for this chord.
316    pub fn has_z_relation(&self) -> bool {
317        self.z_relation().is_some()
318    }
319
320    /// Returns whether `other` belongs to the set class Z-related to this
321    /// chord's, so the two share an interval vector without being related by
322    /// transposition or inversion.
323    pub fn are_z_relations(&self, other: &Chord) -> bool {
324        let Some(z_relation) = self.z_relation() else {
325            return false;
326        };
327        other
328            .chord_tables_address()
329            .is_some_and(|address| format!("{}-{}", address.0, address.1) == z_relation)
330    }
331
332    /// Returns whether the pitch classes form a fully diminished seventh
333    /// however it is spelled: music21's `isFalseDiminishedSeventh`, true for
334    /// `C E- G- A` where [`Self::is_diminished_seventh`] is not.
335    pub fn is_false_diminished_seventh(&self) -> bool {
336        self.chord_tables_address()
337            .is_some_and(|address| (address.0, address.1, address.2) == (4, 28, 0))
338    }
339}
340
341/// Writes a list of pitch classes the way music21's
342/// `Chord.formatVectorString` does, with ten and eleven as `A` and `B`:
343/// `[0, 11]` is `<0B>`.
344pub fn format_vector_string(values: &[u8]) -> String {
345    let digits: String = values
346        .iter()
347        .map(|value| crate::pitch::convert_pitch_class_to_str(*value as IntegerType))
348        .collect();
349    format!("<{digits}>")
350}
351
352pub(super) fn format_pitch_classes(pitch_classes: &[u8]) -> String {
353    let mut out = String::with_capacity(pitch_classes.len() + 2);
354    out.push('<');
355    for pitch_class in pitch_classes {
356        out.push_str(&crate::pitch::pitchclass::convert_pitch_class_to_str(
357            IntegerType::from(*pitch_class),
358        ));
359    }
360    out.push('>');
361    out
362}
363
364/// Where a chord's set class sits in the Forte tables: music21's
365/// `ChordTableAddress`.
366///
367/// The cardinality and the class number index the table; the inversion says
368/// which of an inversionally related pair this is, `0` when the class is its
369/// own inversion; and the original pitch class is the one the prime form was
370/// transposed away from.
371#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
373#[must_use]
374pub struct ChordTableAddress {
375    /// How many distinct pitch classes the chord has.
376    pub cardinality: u8,
377    /// The Forte class number within that cardinality.
378    pub forte_class: u8,
379    /// `1`, `-1`, or `0` for a class that is its own inversion.
380    pub inversion: i8,
381    /// The pitch class the prime form was transposed away from.
382    pub pitch_class_original: u8,
383}