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_mask().count_ones() as usize
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    /// The twelve pitch classes as a bit each, which is every question about
189    /// *which* classes a chord has and how many, with nothing allocated.
190    pub(super) fn pitch_class_mask(&self) -> u16 {
191        self.notes.iter().fold(0_u16, |mask, note| {
192            mask | (1_u16 << root::pitch_class(&note.pitch))
193        })
194    }
195
196    /// Where this chord's set class sits in the Forte tables.
197    ///
198    /// An empty chord answers all zeros rather than failing, which is the
199    /// one place music21's `Chord.chordTablesAddress` differs from the
200    /// `seekChordTablesAddress` underneath it.
201    pub fn chord_tables_address_entry(&self) -> ChordTableAddress {
202        match self.chord_tables_address() {
203            Some((cardinality, forte_class, inversion, original)) => ChordTableAddress {
204                cardinality,
205                forte_class,
206                inversion,
207                pitch_class_original: original.unwrap_or(0),
208            },
209            None => ChordTableAddress {
210                cardinality: 0,
211                forte_class: 0,
212                inversion: 0,
213                pitch_class_original: 0,
214            },
215        }
216    }
217
218    pub(super) fn chord_tables_address(&self) -> Option<tables::RawAddress> {
219        tables::seek_chord_tables_address(&self.ordered_pitch_classes()).ok()
220    }
221
222    /// Returns music21's `geometricNormalForm`: the distinct pitch classes
223    /// rotated so the intervals between neighbours read smallest first, then
224    /// written from zero, so both `C E G` and `E G C` are `[0, 3, 8]`. Empty
225    /// for an empty chord.
226    pub fn geometric_normal_form(&self) -> Vec<u8> {
227        let pitch_classes = self.ordered_pitch_classes();
228        if pitch_classes.is_empty() {
229            return Vec::new();
230        }
231        let intervals: Vec<u8> = pitch_classes
232            .iter()
233            .zip(pitch_classes.iter().cycle().skip(1))
234            .map(|(&low, &high)| (high + 12 - low) % 12)
235            .collect();
236        let best = (0..intervals.len())
237            .map(|rotation| {
238                let mut rotated = intervals[rotation + 1..].to_vec();
239                rotated.extend_from_slice(&intervals[..=rotation]);
240                rotated
241            })
242            .min()
243            .unwrap_or_default();
244        let mut sum = 0;
245        best.iter()
246            .map(|interval| {
247                let pitch_class = sum;
248                sum += interval;
249                pitch_class
250            })
251            .collect()
252    }
253
254    /// Returns the interval-class vector in music21's angle-bracket
255    /// notation, `<001110>`; an empty chord reads `<000000>`.
256    pub fn interval_vector_string(&self) -> String {
257        format_pitch_classes(&self.interval_class_vector().unwrap_or_else(|| vec![0; 6]))
258    }
259
260    /// Returns music21's `normalOrder`: the most compact rotation of the
261    /// pitch classes, on the chord's own pitch classes rather than
262    /// transposed to zero, so `C E G B-` is `[4, 7, 10, 0]` where
263    /// [`Self::normal_form`] is `[0, 3, 6, 8]`. Empty for an empty chord.
264    pub fn normal_order(&self) -> Vec<u8> {
265        let Some(transposed) = self.normal_form() else {
266            return Vec::new();
267        };
268        let ordered = self.ordered_pitch_classes();
269        ordered
270            .iter()
271            .map(|&transposition| {
272                transposed
273                    .iter()
274                    .map(|&pc| (pc + transposition) % 12)
275                    .collect::<Vec<u8>>()
276            })
277            .find(|candidate| {
278                let mut sorted = candidate.clone();
279                sorted.sort_unstable();
280                sorted == ordered
281            })
282            .unwrap_or_default()
283    }
284
285    /// Returns [`Self::normal_order`] in music21's angle-bracket notation,
286    /// `<47A0>`.
287    pub fn normal_order_string(&self) -> String {
288        format_pitch_classes(&self.normal_order())
289    }
290
291    /// Returns the Forte class number within the cardinality, `11` for a
292    /// major or minor triad. `None` for an empty chord.
293    pub fn forte_class_number(&self) -> Option<u8> {
294        self.chord_tables_address().map(|address| address.1)
295    }
296
297    /// Returns the Forte class under transposition equivalence, with the
298    /// `A`/`B` inversion suffix: music21's `forteClassTn`, the same as
299    /// [`Self::forte_class`].
300    pub fn forte_class_tn(&self) -> Option<String> {
301        self.forte_class()
302    }
303
304    /// Returns the number of notes, counting repeated pitch classes: music21's
305    /// `multisetCardinality`.
306    pub fn multiset_cardinality(&self) -> usize {
307        self.notes.len()
308    }
309
310    /// Returns whether the pitch-class set is the inversion of its prime
311    /// form, so its Forte class carries a `B` suffix.
312    pub fn is_prime_form_inversion(&self) -> bool {
313        self.chord_tables_address()
314            .is_some_and(|address| address.2 == -1)
315    }
316
317    /// Returns whether music21 records a Z-related set class for this chord.
318    pub fn has_z_relation(&self) -> bool {
319        self.z_relation().is_some()
320    }
321
322    /// Returns whether `other` belongs to the set class Z-related to this
323    /// chord's, so the two share an interval vector without being related by
324    /// transposition or inversion.
325    pub fn are_z_relations(&self, other: &Chord) -> bool {
326        let Some(z_relation) = self.z_relation() else {
327            return false;
328        };
329        other
330            .chord_tables_address()
331            .is_some_and(|address| format!("{}-{}", address.0, address.1) == z_relation)
332    }
333
334    /// Returns whether the pitch classes form a fully diminished seventh
335    /// however it is spelled: music21's `isFalseDiminishedSeventh`, true for
336    /// `C E- G- A` where [`Self::is_diminished_seventh`] is not.
337    pub fn is_false_diminished_seventh(&self) -> bool {
338        self.chord_tables_address()
339            .is_some_and(|address| (address.0, address.1, address.2) == (4, 28, 0))
340    }
341}
342
343/// Writes a list of pitch classes the way music21's
344/// `Chord.formatVectorString` does, with ten and eleven as `A` and `B`:
345/// `[0, 11]` is `<0B>`.
346pub fn format_vector_string(values: &[u8]) -> String {
347    let digits: String = values
348        .iter()
349        .map(|value| crate::pitch::convert_pitch_class_to_str(*value as IntegerType))
350        .collect();
351    format!("<{digits}>")
352}
353
354pub(super) fn format_pitch_classes(pitch_classes: &[u8]) -> String {
355    let mut out = String::with_capacity(pitch_classes.len() + 2);
356    out.push('<');
357    for pitch_class in pitch_classes {
358        out.push(crate::pitch::pitchclass::pitch_class_char(
359            IntegerType::from(*pitch_class),
360        ));
361    }
362    out.push('>');
363    out
364}
365
366/// Where a chord's set class sits in the Forte tables: music21's
367/// `ChordTableAddress`.
368///
369/// The cardinality and the class number index the table; the inversion says
370/// which of an inversionally related pair this is, `0` when the class is its
371/// own inversion; and the original pitch class is the one the prime form was
372/// transposed away from.
373#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
374#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
375#[must_use]
376pub struct ChordTableAddress {
377    /// How many distinct pitch classes the chord has.
378    pub cardinality: u8,
379    /// The Forte class number within that cardinality.
380    pub forte_class: u8,
381    /// `1`, `-1`, or `0` for a class that is its own inversion.
382    pub inversion: i8,
383    /// The pitch class the prime form was transposed away from.
384    pub pitch_class_original: u8,
385}