Skip to main content

music21_rs/serial/
mod.rs

1//! Tone rows and twelve-tone serial transformations, a port of music21's
2//! `serial` module.
3//!
4//! A [`ToneRow`] is an ordered sequence of pitch classes. It need not have
5//! twelve members and need not be a permutation of the aggregate; the methods
6//! that only make sense for a true twelve-tone row (`is_all_interval`,
7//! `link_classification`, `are_combinatorial`) return an error otherwise,
8//! where music21 raises `SerialException`.
9//!
10//! music21 separates `ToneRow`, `TwelveToneRow` and `HistoricalTwelveToneRow`
11//! as stream subclasses. Nothing here dispatches on that distinction, so there
12//! is one row type, and the historical rows are a table of [`HistoricalRow`]
13//! entries that build a `ToneRow` on demand.
14
15use std::fmt;
16use std::ops::Index;
17use std::str::FromStr;
18
19use crate::{
20    chord::root,
21    defaults::{IntegerType, UnsignedIntegerType},
22    error::{Error, Result},
23    pitch::{Pitch, pitchclass::convert_pitch_class_to_str},
24};
25
26mod tables;
27
28pub use tables::HISTORICAL_ROWS;
29use tables::LINK_CHORDS;
30
31/// A serial transformation of a tone row.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub enum Transformation {
35    /// The row itself, transposed. music21 writes this `P` in the
36    /// zero-centered convention and `T` in the original-centered one.
37    Prime,
38    /// The row inverted about its first pitch class.
39    Inversion,
40    /// The row backwards.
41    Retrograde,
42    /// The inversion backwards.
43    RetrogradeInversion,
44}
45
46impl Transformation {
47    /// The four transformations in music21's order.
48    pub const ALL: [Transformation; 4] = [
49        Transformation::Prime,
50        Transformation::Inversion,
51        Transformation::Retrograde,
52        Transformation::RetrogradeInversion,
53    ];
54
55    /// music21's label under the given convention: `P`/`T`, `I`, `R`, `RI`.
56    pub fn label(self, convention: TransformationConvention) -> &'static str {
57        match (self, convention) {
58            (Transformation::Prime, TransformationConvention::ZeroCentered) => "P",
59            (Transformation::Prime, TransformationConvention::OriginalCentered) => "T",
60            (Transformation::Inversion, _) => "I",
61            (Transformation::Retrograde, _) => "R",
62            (Transformation::RetrogradeInversion, _) => "RI",
63        }
64    }
65
66    /// Parses music21's label. `P` and `T` both mean [`Transformation::Prime`].
67    pub fn from_name(name: &str) -> Result<Self> {
68        match name {
69            "P" | "T" => Ok(Transformation::Prime),
70            "I" => Ok(Transformation::Inversion),
71            "R" => Ok(Transformation::Retrograde),
72            "RI" => Ok(Transformation::RetrogradeInversion),
73            other => Err(Error::Serial(format!(
74                "Invalid transformation type: {other}"
75            ))),
76        }
77    }
78}
79
80impl fmt::Display for Transformation {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.label(TransformationConvention::ZeroCentered))
83    }
84}
85
86impl FromStr for Transformation {
87    type Err = Error;
88
89    fn from_str(name: &str) -> Result<Self> {
90        Transformation::from_name(name)
91    }
92}
93
94/// What the index of a transformation refers to.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub enum TransformationConvention {
98    /// `P(n)` and `I(n)` start on pitch class `n`; `R(n)` and `RI(n)` end on
99    /// it. This is the common convention and music21's default.
100    ZeroCentered,
101    /// `T(n)` transposes the original row up `n` semitones, and `I(n)`,
102    /// `R(n)` and `RI(n)` transform the row in place and then transpose the
103    /// result by `n`.
104    OriginalCentered,
105}
106
107impl TransformationConvention {
108    /// Parses music21's convention argument, `"zero"` or `"original"`.
109    pub fn from_name(name: &str) -> Result<Self> {
110        match name {
111            "zero" => Ok(TransformationConvention::ZeroCentered),
112            "original" => Ok(TransformationConvention::OriginalCentered),
113            _ => Err(Error::Serial(
114                "Invalid convention - choose 'zero' or 'original'.".to_string(),
115            )),
116        }
117    }
118}
119
120/// A transformation and its index, as music21 reports one: `("I", 8)`.
121pub type IndexedTransformation = (Transformation, u8);
122
123/// An ordered sequence of pitch classes.
124#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[must_use]
127pub struct ToneRow {
128    pitch_classes: Vec<u8>,
129}
130
131fn wrap(value: IntegerType) -> u8 {
132    value.rem_euclid(12) as u8
133}
134
135impl ToneRow {
136    /// Builds a row from pitch-class integers, which are reduced modulo 12 the
137    /// way music21's `pcToToneRow` does, so `[-6, 19, 128]` is `[6, 7, 8]`.
138    pub fn new(pitch_classes: impl IntoIterator<Item = IntegerType>) -> Self {
139        Self {
140            pitch_classes: pitch_classes.into_iter().map(wrap).collect(),
141        }
142    }
143
144    /// Builds a row from the pitch classes of a sequence of pitches.
145    pub fn from_pitches<'a>(pitches: impl IntoIterator<Item = &'a Pitch>) -> Self {
146        Self {
147            pitch_classes: pitches.into_iter().map(root::pitch_class).collect(),
148        }
149    }
150
151    /// The pitch classes in order.
152    pub fn pitch_classes(&self) -> &[u8] {
153        &self.pitch_classes
154    }
155
156    /// The number of pitch classes in the row.
157    pub fn len(&self) -> usize {
158        self.pitch_classes.len()
159    }
160
161    /// Whether the row has no pitch classes.
162    pub fn is_empty(&self) -> bool {
163        self.pitch_classes.is_empty()
164    }
165
166    /// The row as octave-less pitches, spelled the way music21 spells a pitch
167    /// built from a pitch class (`C#`, `E-`, `F#`, `G#`, `B-`).
168    pub fn pitches(&self) -> Vec<Pitch> {
169        self.pitch_classes
170            .iter()
171            .map(|&pc| {
172                Pitch::from_pitch_class(IntegerType::from(pc))
173                    .expect("a pitch class below twelve always spells")
174            })
175            .collect()
176    }
177
178    /// The note names of the row, music21's `noteNames`.
179    pub fn note_names(&self) -> Vec<String> {
180        self.pitches().iter().map(Pitch::name).collect()
181    }
182
183    /// Whether the row is a permutation of all twelve pitch classes.
184    pub fn is_twelve_tone_row(&self) -> bool {
185        self.pitch_classes.len() == 12 && (0..12u8).all(|pc| self.pitch_classes.contains(&pc))
186    }
187
188    /// The row as a twelve-tone row: music21's `makeTwelveToneRow`. There is
189    /// one row type here, so this is the row itself, and a row that is not a
190    /// permutation of the twelve pitch classes is an error rather than a row
191    /// whose twelve-tone questions fail one by one.
192    pub fn make_twelve_tone_row(&self) -> Result<ToneRow> {
193        if !self.is_twelve_tone_row() {
194            return Err(Error::Serial(
195                "A twelve-tone row must contain each pitch class exactly once".to_string(),
196            ));
197        }
198        Ok(self.clone())
199    }
200
201    /// Whether two rows have the same pitch classes in the same order.
202    pub fn is_same_row(&self, other: &ToneRow) -> bool {
203        self == other
204    }
205
206    /// The intervals between consecutive pitch classes as one character each,
207    /// with `T` for ten and `E` for eleven: music21's `getIntervalsAsString`.
208    pub fn intervals_as_string(&self) -> String {
209        self.pitch_classes
210            .windows(2)
211            .map(|pair| {
212                let interval = wrap(IntegerType::from(pair[1]) - IntegerType::from(pair[0]));
213                match interval {
214                    10 => 'T',
215                    11 => 'E',
216                    digit => char::from(b'0' + digit),
217                }
218            })
219            .collect()
220    }
221
222    /// Transforms the row in the zero-centered convention, where `P(n)` and
223    /// `I(n)` start on pitch class `n` and `R(n)` and `RI(n)` end on it.
224    pub fn zero_centered_transformation(
225        &self,
226        transformation: Transformation,
227        index: IntegerType,
228    ) -> ToneRow {
229        let Some(&first) = self.pitch_classes.first() else {
230            return ToneRow::default();
231        };
232        let first = IntegerType::from(first);
233        let forward = self.pitch_classes.iter().map(|&pc| IntegerType::from(pc));
234        let backward = self
235            .pitch_classes
236            .iter()
237            .rev()
238            .map(|&pc| IntegerType::from(pc));
239        let transformed: Vec<IntegerType> = match transformation {
240            Transformation::Prime => forward.map(|pc| pc - first + index).collect(),
241            Transformation::Inversion => forward.map(|pc| index + first - pc).collect(),
242            Transformation::Retrograde => backward.map(|pc| index + pc - first).collect(),
243            Transformation::RetrogradeInversion => backward.map(|pc| index - pc + first).collect(),
244        };
245        ToneRow::new(transformed)
246    }
247
248    /// Transforms the row in the original-centered convention, where `T(n)`
249    /// transposes the row up `n` semitones and the other transformations act
250    /// in place before transposing by `n`.
251    pub fn original_centered_transformation(
252        &self,
253        transformation: Transformation,
254        index: IntegerType,
255    ) -> ToneRow {
256        let Some(&first) = self.pitch_classes.first() else {
257            return ToneRow::default();
258        };
259        self.zero_centered_transformation(transformation, IntegerType::from(first) + index)
260    }
261
262    /// Transforms the row under either convention.
263    pub fn transformation(
264        &self,
265        convention: TransformationConvention,
266        transformation: Transformation,
267        index: IntegerType,
268    ) -> ToneRow {
269        match convention {
270            TransformationConvention::ZeroCentered => {
271                self.zero_centered_transformation(transformation, index)
272            }
273            TransformationConvention::OriginalCentered => {
274                self.original_centered_transformation(transformation, index)
275            }
276        }
277    }
278
279    /// The zero-centered transformations that take this row to `other`, in
280    /// music21's order `P`, `I`, `R`, `RI`.
281    pub fn find_zero_centered_transformations(
282        &self,
283        other: &ToneRow,
284    ) -> Vec<IndexedTransformation> {
285        let (Some(&first), Some(&last)) = (other.pitch_classes.first(), other.pitch_classes.last())
286        else {
287            return Vec::new();
288        };
289        if self.len() != other.len() {
290            return Vec::new();
291        }
292        let candidates = [
293            (Transformation::Prime, first),
294            (Transformation::Inversion, first),
295            (Transformation::Retrograde, last),
296            (Transformation::RetrogradeInversion, last),
297        ];
298        candidates
299            .into_iter()
300            .filter(|&(transformation, index)| {
301                self.zero_centered_transformation(transformation, IntegerType::from(index))
302                    == *other
303            })
304            .collect()
305    }
306
307    /// The original-centered transformations that take this row to `other`,
308    /// in music21's order `T`, `I`, `R`, `RI`.
309    pub fn find_original_centered_transformations(
310        &self,
311        other: &ToneRow,
312    ) -> Vec<IndexedTransformation> {
313        let (Some(&old_first), Some(&old_last), Some(&new_first)) = (
314            self.pitch_classes.first(),
315            self.pitch_classes.last(),
316            other.pitch_classes.first(),
317        ) else {
318            return Vec::new();
319        };
320        if self.len() != other.len() {
321            return Vec::new();
322        }
323        let (old_first, old_last, new_first) = (
324            IntegerType::from(old_first),
325            IntegerType::from(old_last),
326            IntegerType::from(new_first),
327        );
328        let transposition = wrap(new_first - old_first);
329        let retrograde = wrap(new_first - old_last);
330        let retrograde_inversion = wrap(new_first - 2 * old_first + old_last);
331        let candidates = [
332            (Transformation::Prime, transposition),
333            (Transformation::Inversion, transposition),
334            (Transformation::Retrograde, retrograde),
335            (Transformation::RetrogradeInversion, retrograde_inversion),
336        ];
337        candidates
338            .into_iter()
339            .filter(|&(transformation, index)| {
340                self.original_centered_transformation(transformation, IntegerType::from(index))
341                    == *other
342            })
343            .collect()
344    }
345
346    /// The transformations that take this row to `other` under either
347    /// convention.
348    pub fn find_transformations(
349        &self,
350        convention: TransformationConvention,
351        other: &ToneRow,
352    ) -> Vec<IndexedTransformation> {
353        match convention {
354            TransformationConvention::ZeroCentered => {
355                self.find_zero_centered_transformations(other)
356            }
357            TransformationConvention::OriginalCentered => {
358                self.find_original_centered_transformations(other)
359            }
360        }
361    }
362
363    /// The row's matrix: every transposition of the row, ordered so that the
364    /// leading diagonal is zero and each column reads the inversion.
365    pub fn matrix(&self) -> TwelveToneMatrix {
366        let rows = self
367            .pitch_classes
368            .iter()
369            .map(|&pc| {
370                let transposition = wrap(12 - IntegerType::from(pc));
371                ToneRow::new(
372                    self.pitch_classes
373                        .iter()
374                        .map(|&x| IntegerType::from(x) + IntegerType::from(transposition)),
375                )
376            })
377            .collect();
378        TwelveToneMatrix { rows }
379    }
380
381    /// The historical rows identical to this one.
382    pub fn find_historical(&self) -> Vec<&'static HistoricalRow> {
383        HISTORICAL_ROWS
384            .iter()
385            .filter(|historical| historical.pitch_classes == self.pitch_classes.as_slice())
386            .collect()
387    }
388
389    /// The historical rows of which this row is a transformation, each with
390    /// the transformations taking the historical row to this one.
391    pub fn find_transformed_historical(
392        &self,
393        convention: TransformationConvention,
394    ) -> Vec<(&'static HistoricalRow, Vec<IndexedTransformation>)> {
395        HISTORICAL_ROWS
396            .iter()
397            .filter_map(|historical| {
398                let transformations = historical.row().find_transformations(convention, self);
399                (!transformations.is_empty()).then_some((historical, transformations))
400            })
401            .collect()
402    }
403
404    fn require_twelve_tone(&self, purpose: &str) -> Result<()> {
405        if self.is_twelve_tone_row() {
406            Ok(())
407        } else {
408            Err(Error::Serial(format!(
409                "{purpose} must be a twelve-tone row."
410            )))
411        }
412    }
413
414    /// Whether every interval class from one to eleven occurs between
415    /// consecutive members of the row.
416    ///
417    /// Errors unless the row is a twelve-tone row.
418    pub fn is_all_interval(&self) -> Result<bool> {
419        self.require_twelve_tone("An all-interval row")?;
420        let intervals = self.intervals_as_string();
421        Ok("123456789TE"
422            .chars()
423            .all(|interval| intervals.contains(interval)))
424    }
425
426    /// The Link chord classification of the row, if it is one: an
427    /// all-interval row containing a voicing of the all-trichord hexachord
428    /// `[0, 1, 2, 4, 7, 8]`, numbered as in John Link's catalogue.
429    ///
430    /// Errors unless the row is a twelve-tone row.
431    pub fn link_classification(&self) -> Result<Option<LinkClassification>> {
432        self.require_twelve_tone("A Link Chord")?;
433        let forms = [
434            self.clone(),
435            self.zero_centered_transformation(Transformation::Inversion, 0),
436            self.zero_centered_transformation(Transformation::Retrograde, 0),
437            self.zero_centered_transformation(Transformation::RetrogradeInversion, 0),
438        ];
439        let mut number = None;
440        let mut special_intervals = Vec::new();
441        for form in &forms {
442            let intervals = form.intervals_as_string();
443            for link in LINK_CHORDS
444                .iter()
445                .filter(|link| link.intervals == intervals)
446            {
447                number = Some(link.classification);
448                special_intervals.push(link.special_intervals);
449            }
450        }
451        Ok(number.map(|number| LinkClassification {
452            number,
453            special_intervals,
454        }))
455    }
456
457    /// Whether the row is a Link chord. Errors unless it is a twelve-tone row.
458    pub fn is_link_chord(&self) -> Result<bool> {
459        Ok(self.link_classification()?.is_some())
460    }
461
462    /// Whether two zero-centered transformations of the row are
463    /// hexachordally combinatorial: their first hexachords together form the
464    /// aggregate.
465    ///
466    /// Errors unless the row is a twelve-tone row.
467    pub fn are_combinatorial(
468        &self,
469        first: Transformation,
470        first_index: IntegerType,
471        second: Transformation,
472        second_index: IntegerType,
473    ) -> Result<bool> {
474        self.require_twelve_tone("Combinatoriality applies only to twelve-tone rows; this")?;
475        let first = self.zero_centered_transformation(first, first_index);
476        let second = self.zero_centered_transformation(second, second_index);
477        let combined = first.pitch_classes[..6]
478            .iter()
479            .chain(&second.pitch_classes[..6])
480            .map(|&pc| IntegerType::from(pc));
481        Ok(ToneRow::new(combined).is_twelve_tone_row())
482    }
483}
484
485impl Index<usize> for ToneRow {
486    type Output = u8;
487
488    fn index(&self, index: usize) -> &Self::Output {
489        &self.pitch_classes[index]
490    }
491}
492
493impl<'a> IntoIterator for &'a ToneRow {
494    type Item = &'a u8;
495    type IntoIter = std::slice::Iter<'a, u8>;
496
497    fn into_iter(self) -> Self::IntoIter {
498        self.pitch_classes.iter()
499    }
500}
501
502impl fmt::Display for ToneRow {
503    /// The pitch classes as single characters, `A` and `B` for ten and eleven.
504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505        for &pc in &self.pitch_classes {
506            f.write_str(&convert_pitch_class_to_str(IntegerType::from(pc)))?;
507        }
508        Ok(())
509    }
510}
511
512impl From<Vec<u8>> for ToneRow {
513    fn from(pitch_classes: Vec<u8>) -> Self {
514        ToneRow::new(pitch_classes.into_iter().map(IntegerType::from))
515    }
516}
517
518impl<const N: usize> From<[u8; N]> for ToneRow {
519    fn from(pitch_classes: [u8; N]) -> Self {
520        ToneRow::new(pitch_classes.into_iter().map(IntegerType::from))
521    }
522}
523
524/// The Link chord number of a row and the interval sets that voice the
525/// all-trichord hexachord within it.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct LinkClassification {
528    /// The classification number, 1 to 194.
529    pub number: UnsignedIntegerType,
530    /// The five-interval strings within the row (or a transformation of it)
531    /// that voice the all-trichord hexachord.
532    pub special_intervals: Vec<&'static str>,
533}
534
535/// The transpositions of a row laid out as a matrix.
536#[derive(Debug, Clone, PartialEq, Eq)]
537#[must_use]
538pub struct TwelveToneMatrix {
539    rows: Vec<ToneRow>,
540}
541
542impl TwelveToneMatrix {
543    /// The rows of the matrix, top to bottom.
544    pub fn rows(&self) -> &[ToneRow] {
545        &self.rows
546    }
547
548    /// One row of the matrix.
549    pub fn row(&self, index: usize) -> Option<&ToneRow> {
550        self.rows.get(index)
551    }
552}
553
554impl fmt::Display for TwelveToneMatrix {
555    /// music21's layout: each pitch class right-aligned in three columns,
556    /// with `A` and `B` for ten and eleven.
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        for (index, row) in self.rows.iter().enumerate() {
559            if index > 0 {
560                writeln!(f)?;
561            }
562            for &pc in row.pitch_classes() {
563                write!(
564                    f,
565                    "{:>3}",
566                    convert_pitch_class_to_str(IntegerType::from(pc))
567                )?;
568            }
569        }
570        Ok(())
571    }
572}
573
574/// music21's `rowToMatrix`: the matrix of a pitch-class list as text, with
575/// the pitch classes written in decimal rather than as `A` and `B`.
576pub fn row_to_matrix(pitch_classes: &[IntegerType]) -> String {
577    ToneRow::new(pitch_classes.iter().copied())
578        .matrix()
579        .rows()
580        .iter()
581        .map(|row| {
582            row.pitch_classes()
583                .iter()
584                .map(|pc| format!("{pc:>3}"))
585                .collect::<String>()
586        })
587        .collect::<Vec<_>>()
588        .join("\n")
589}
590
591/// A twelve-tone row from the historical literature, with the attributes
592/// music21 stores for it.
593#[derive(Debug, Clone, PartialEq, Eq)]
594#[must_use]
595pub struct HistoricalRow {
596    /// music21's key for the row, such as `SchoenbergOp37`.
597    pub name: &'static str,
598    /// The composer's surname.
599    pub composer: &'static str,
600    /// The opus, where the work has one.
601    pub opus: Option<&'static str>,
602    /// The title of the work, or of the row within it.
603    pub title: &'static str,
604    /// The row's pitch classes.
605    pub pitch_classes: [u8; 12],
606}
607
608impl HistoricalRow {
609    /// The row as a [`ToneRow`].
610    pub fn row(&self) -> ToneRow {
611        ToneRow::from(self.pitch_classes)
612    }
613}
614
615/// Looks a historical row up by music21's name for it. The pre-v6 names with a
616/// `Row` prefix are accepted too.
617pub fn historical_row_by_name(name: &str) -> Result<&'static HistoricalRow> {
618    let name = name.strip_prefix("Row").unwrap_or(name);
619    HISTORICAL_ROWS
620        .iter()
621        .find(|row| row.name == name)
622        .ok_or_else(|| Error::Serial("No historical row with given name found".to_string()))
623}
624
625struct LinkChord {
626    intervals: &'static str,
627    special_intervals: &'static str,
628    classification: UnsignedIntegerType,
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn only_a_permutation_of_the_twelve_classes_makes_a_twelve_tone_row() {
637        assert!(ToneRow::new(0..11).make_twelve_tone_row().is_err());
638        let row = ToneRow::new(0..12).make_twelve_tone_row().unwrap();
639        assert_eq!(row.pitch_classes().len(), 12);
640    }
641
642    #[test]
643    fn a_row_indexes_and_iterates_over_its_pitch_classes() {
644        let row = ToneRow::new([0, 13, -1]);
645
646        assert_eq!(row[1], 1);
647        let collected: Vec<u8> = (&row).into_iter().copied().collect();
648        assert_eq!(collected, vec![0, 1, 11]);
649    }
650
651    fn row(pitch_classes: impl IntoIterator<Item = IntegerType>) -> ToneRow {
652        ToneRow::new(pitch_classes)
653    }
654
655    fn chromatic() -> ToneRow {
656        row(0..12)
657    }
658
659    fn historical(name: &str) -> ToneRow {
660        historical_row_by_name(name).unwrap().row()
661    }
662
663    fn labelled(
664        transformations: &[IndexedTransformation],
665        convention: TransformationConvention,
666    ) -> Vec<(&'static str, u8)> {
667        transformations
668            .iter()
669            .map(|&(transformation, index)| (transformation.label(convention), index))
670            .collect()
671    }
672
673    #[test]
674    fn pitch_classes_are_reduced_modulo_twelve() {
675        let quintuple = row((0..12).map(|i| 5 * i));
676        assert_eq!(
677            quintuple.pitch_classes(),
678            &[0, 5, 10, 3, 8, 1, 6, 11, 4, 9, 2, 7]
679        );
680        assert_eq!(quintuple.to_string(), "05A3816B4927");
681        assert_eq!(row([-6, 19, 128]).pitch_classes(), &[6, 7, 8]);
682        assert_eq!(row([-6, 19, 128]).note_names(), ["F#", "G", "G#"]);
683    }
684
685    #[test]
686    fn note_names_spell_pitch_classes_as_music21_does() {
687        assert_eq!(
688            chromatic().note_names(),
689            [
690                "C", "C#", "D", "E-", "E", "F", "F#", "G", "G#", "A", "B-", "B"
691            ]
692        );
693    }
694
695    #[test]
696    fn twelve_tone_row_needs_all_twelve_pitch_classes_once() {
697        assert!(chromatic().is_twelve_tone_row());
698        assert!(!row([0, 4, 8]).is_twelve_tone_row());
699        assert!(!row([3; 12]).is_twelve_tone_row());
700        assert!(!ToneRow::default().is_twelve_tone_row());
701    }
702
703    #[test]
704    fn same_row_compares_pitch_classes_in_order() {
705        assert!(row([6, 7, 8]).is_same_row(&row([-6, 19, 128])));
706        assert!(!row([6, 7, 8]).is_same_row(&row([6, 7, -8])));
707        assert!(!row([6, 7, 8]).is_same_row(&row([6, 7])));
708    }
709
710    #[test]
711    fn intervals_as_string_uses_t_and_e() {
712        assert_eq!(row([0]).intervals_as_string(), "");
713        assert_eq!(ToneRow::default().intervals_as_string(), "");
714        assert_eq!(row((0..12).rev()).intervals_as_string(), "EEEEEEEEEEE");
715        assert_eq!(
716            historical("BergLyricSuite").intervals_as_string(),
717            "E89T7652341"
718        );
719    }
720
721    #[test]
722    fn zero_centered_transformations_match_music21() {
723        assert_eq!(
724            chromatic()
725                .zero_centered_transformation(Transformation::Prime, 3)
726                .pitch_classes(),
727            &[3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2]
728        );
729        assert_eq!(
730            chromatic()
731                .zero_centered_transformation(Transformation::Inversion, 6)
732                .pitch_classes(),
733            &[6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7]
734        );
735        let schoenberg = historical("SchoenbergOp26");
736        assert_eq!(
737            schoenberg.pitch_classes(),
738            &[3, 7, 9, 11, 1, 0, 10, 2, 4, 6, 8, 5]
739        );
740        assert_eq!(
741            schoenberg
742                .zero_centered_transformation(Transformation::Retrograde, 8)
743                .pitch_classes(),
744            &[10, 1, 11, 9, 7, 3, 5, 6, 4, 2, 0, 8]
745        );
746        assert_eq!(
747            schoenberg
748                .zero_centered_transformation(Transformation::RetrogradeInversion, 9)
749                .note_names(),
750            [
751                "G", "E", "F#", "G#", "B-", "D", "C", "B", "C#", "E-", "F", "A"
752            ]
753        );
754    }
755
756    #[test]
757    fn original_centered_transformations_match_music21() {
758        assert_eq!(
759            chromatic()
760                .original_centered_transformation(Transformation::Prime, 3)
761                .pitch_classes(),
762            &[3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2]
763        );
764        assert_eq!(
765            chromatic()
766                .original_centered_transformation(Transformation::Inversion, 6)
767                .pitch_classes(),
768            &[6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7]
769        );
770        let schoenberg = historical("SchoenbergOp26");
771        assert_eq!(
772            schoenberg
773                .original_centered_transformation(Transformation::Retrograde, 8)
774                .pitch_classes(),
775            &[1, 4, 2, 0, 10, 6, 8, 9, 7, 5, 3, 11]
776        );
777        assert_eq!(
778            schoenberg
779                .original_centered_transformation(Transformation::RetrogradeInversion, 9)
780                .note_names(),
781            [
782                "B-", "G", "A", "B", "C#", "F", "E-", "D", "E", "F#", "G#", "C"
783            ]
784        );
785    }
786
787    #[test]
788    fn transforming_an_empty_row_gives_an_empty_row() {
789        let empty = ToneRow::default();
790        assert!(
791            empty
792                .zero_centered_transformation(Transformation::Retrograde, 3)
793                .is_empty()
794        );
795        assert!(
796            empty
797                .original_centered_transformation(Transformation::Inversion, 3)
798                .is_empty()
799        );
800        assert!(empty.find_zero_centered_transformations(&empty).is_empty());
801        assert!(
802            empty
803                .find_original_centered_transformations(&empty)
804                .is_empty()
805        );
806    }
807
808    #[test]
809    fn finds_zero_centered_transformations_between_rows() {
810        let zero = TransformationConvention::ZeroCentered;
811        let rising = row([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1]);
812        let falling = row([8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9]);
813        assert_eq!(
814            labelled(&rising.find_zero_centered_transformations(&falling), zero),
815            [("I", 8), ("R", 9)]
816        );
817        let op25 = historical("SchoenbergOp25");
818        let op26 = historical("SchoenbergOp26");
819        assert!(op25.find_zero_centered_transformations(&op26).is_empty());
820        let ri8 = op26.zero_centered_transformation(Transformation::RetrogradeInversion, 8);
821        assert_eq!(
822            labelled(&op26.find_zero_centered_transformations(&ri8), zero),
823            [("RI", 8)]
824        );
825        assert!(
826            op26.find_zero_centered_transformations(&row([0]))
827                .is_empty()
828        );
829    }
830
831    #[test]
832    fn finds_original_centered_transformations_between_rows() {
833        let original = TransformationConvention::OriginalCentered;
834        let rising = row([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1]);
835        let falling = row([8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9]);
836        assert_eq!(
837            labelled(
838                &rising.find_original_centered_transformations(&falling),
839                original
840            ),
841            [("I", 6), ("R", 7)]
842        );
843        let op25 = historical("SchoenbergOp25");
844        let op26 = historical("SchoenbergOp26");
845        assert!(
846            op25.find_original_centered_transformations(&op26)
847                .is_empty()
848        );
849        let ri8 = op26.original_centered_transformation(Transformation::RetrogradeInversion, 8);
850        assert_eq!(
851            labelled(&op26.find_original_centered_transformations(&ri8), original),
852            [("RI", 8)]
853        );
854        assert_eq!(
855            labelled(&rising.find_transformations(original, &falling), original),
856            [("I", 6), ("R", 7)]
857        );
858    }
859
860    #[test]
861    fn every_transformation_is_found_again() {
862        let source = historical("WebernOp27");
863        for convention in [
864            TransformationConvention::ZeroCentered,
865            TransformationConvention::OriginalCentered,
866        ] {
867            for transformation in Transformation::ALL {
868                for index in 0..12 {
869                    let target = source.transformation(convention, transformation, index);
870                    let found = source.find_transformations(convention, &target);
871                    assert!(
872                        found.contains(&(transformation, index as u8)),
873                        "{transformation:?} {index} under {convention:?} not found in {found:?}"
874                    );
875                }
876            }
877        }
878    }
879
880    #[test]
881    fn matrix_matches_music21_layout() {
882        let matrix = row([0, 2, 11, 7, 8, 3, 9, 1, 4, 10, 6, 5]).matrix();
883        let expected = [
884            "  0  2  B  7  8  3  9  1  4  A  6  5",
885            "  A  0  9  5  6  1  7  B  2  8  4  3",
886            "  1  3  0  8  9  4  A  2  5  B  7  6",
887            "  5  7  4  0  1  8  2  6  9  3  B  A",
888            "  4  6  3  B  0  7  1  5  8  2  A  9",
889            "  9  B  8  4  5  0  6  A  1  7  3  2",
890            "  3  5  2  A  B  6  0  4  7  1  9  8",
891            "  B  1  A  6  7  2  8  0  3  9  5  4",
892            "  8  A  7  3  4  B  5  9  0  6  2  1",
893            "  2  4  1  9  A  5  B  3  6  0  8  7",
894            "  6  8  5  1  2  9  3  7  A  4  0  B",
895            "  7  9  6  2  3  A  4  8  B  5  1  0",
896        ]
897        .join(
898            "
899",
900        );
901        assert_eq!(matrix.to_string(), expected);
902        assert_eq!(matrix.rows().len(), 12);
903        assert_eq!(
904            matrix.row(0).unwrap().pitch_classes(),
905            &[0, 2, 11, 7, 8, 3, 9, 1, 4, 10, 6, 5]
906        );
907        assert!(matrix.row(12).is_none());
908
909        let op37 = historical("SchoenbergOp37").matrix();
910        assert_eq!(
911            op37.row(0).unwrap().note_names(),
912            [
913                "C", "B", "G", "G#", "E-", "C#", "D", "B-", "F#", "F", "E", "A"
914            ]
915        );
916        assert!(
917            op37.to_string()
918                .starts_with("  0  B  7  8  3  1  2  A  6  5  4  9\n")
919        );
920    }
921
922    #[test]
923    fn row_to_matrix_writes_decimal_pitch_classes() {
924        let text = row_to_matrix(&[0, 2, 11, 7, 8, 3, 9, 1, 4, 10, 6, 5]);
925        let lines: Vec<&str> = text.lines().collect();
926        assert_eq!(lines.len(), 12);
927        assert_eq!(lines[0], "  0  2 11  7  8  3  9  1  4 10  6  5");
928        assert_eq!(lines[1], " 10  0  9  5  6  1  7 11  2  8  4  3");
929        assert_eq!(lines[11], "  7  9  6  2  3 10  4  8 11  5  1  0");
930    }
931
932    #[test]
933    fn historical_rows_are_found_by_name_with_or_without_the_old_prefix() {
934        let webern = historical_row_by_name("WebernOp29").unwrap();
935        assert_eq!(webern.composer, "Webern");
936        assert_eq!(webern.opus, Some("Op. 29"));
937        assert_eq!(webern.title, "Cantata I");
938        assert_eq!(webern.pitch_classes, [3, 11, 2, 1, 5, 4, 7, 6, 10, 9, 0, 8]);
939        assert_eq!(historical_row_by_name("RowWebernOp29").unwrap(), webern);
940        assert_eq!(
941            historical_row_by_name("SchoenbergJakobsleiter")
942                .unwrap()
943                .opus,
944            None
945        );
946        assert!(matches!(
947            historical_row_by_name("Nope"),
948            Err(Error::Serial(_))
949        ));
950    }
951
952    #[test]
953    fn every_historical_row_is_a_twelve_tone_row_with_a_unique_name() {
954        let mut names: Vec<&str> = HISTORICAL_ROWS.iter().map(|row| row.name).collect();
955        names.sort_unstable();
956        names.dedup();
957        assert_eq!(names.len(), HISTORICAL_ROWS.len());
958        for historical in &HISTORICAL_ROWS {
959            assert!(
960                historical.row().is_twelve_tone_row(),
961                "{} is not a twelve-tone row",
962                historical.name
963            );
964        }
965    }
966
967    #[test]
968    fn finds_historical_rows_and_their_transformations() {
969        let names = |rows: Vec<&HistoricalRow>| -> Vec<&str> {
970            rows.into_iter().map(|row| row.name).collect()
971        };
972        assert_eq!(
973            names(row([2, 3, 9, 1, 11, 5, 8, 7, 4, 0, 10, 6]).find_historical()),
974            ["SchoenbergOp32"]
975        );
976        assert!(chromatic().find_historical().is_empty());
977
978        let transformed = row([5, 9, 11, 3, 6, 7, 4, 10, 0, 8, 2, 1]);
979        let original =
980            transformed.find_transformed_historical(TransformationConvention::OriginalCentered);
981        assert_eq!(original.len(), 1);
982        assert_eq!(original[0].0.name, "SchoenbergOp32");
983        assert_eq!(
984            labelled(&original[0].1, TransformationConvention::OriginalCentered),
985            [("R", 11)]
986        );
987        let zero = transformed.find_transformed_historical(TransformationConvention::ZeroCentered);
988        assert_eq!(zero[0].0.name, "SchoenbergOp32");
989        assert_eq!(
990            labelled(&zero[0].1, TransformationConvention::ZeroCentered),
991            [("R", 1)]
992        );
993    }
994
995    #[test]
996    fn all_interval_rows() {
997        assert!(!chromatic().is_all_interval().unwrap());
998        let berg = historical("BergLyricSuite");
999        assert_eq!(
1000            berg.pitch_classes(),
1001            &[5, 4, 0, 9, 7, 2, 8, 1, 3, 6, 10, 11]
1002        );
1003        assert!(berg.is_all_interval().unwrap());
1004        assert!(matches!(
1005            row([0, 4, 8]).is_all_interval(),
1006            Err(Error::Serial(_))
1007        ));
1008    }
1009
1010    #[test]
1011    fn link_chords_match_music21() {
1012        let berg = historical("BergLyricSuite");
1013        assert_eq!(berg.link_classification().unwrap(), None);
1014        assert!(!berg.is_link_chord().unwrap());
1015
1016        let link = row([0, 3, 8, 2, 10, 11, 9, 4, 1, 5, 7, 6]);
1017        assert_eq!(
1018            link.link_classification().unwrap(),
1019            Some(LinkClassification {
1020                number: 62,
1021                special_intervals: vec!["8352E"],
1022            })
1023        );
1024        assert!(link.is_link_chord().unwrap());
1025
1026        let double = row([0, 1, 8, 5, 7, 10, 4, 3, 11, 9, 2, 6]);
1027        assert_eq!(
1028            double.link_classification().unwrap(),
1029            Some(LinkClassification {
1030                number: 33,
1031                special_intervals: vec!["236E8", "36E8T"],
1032            })
1033        );
1034        assert!(matches!(
1035            row([0, 4, 8]).is_link_chord(),
1036            Err(Error::Serial(_))
1037        ));
1038    }
1039
1040    #[test]
1041    fn every_link_chord_interval_string_classifies_as_itself() {
1042        for link in &LINK_CHORDS {
1043            let mut pitch_classes = vec![0];
1044            for interval in link.intervals.chars() {
1045                let step = match interval {
1046                    'T' => 10,
1047                    'E' => 11,
1048                    digit => IntegerType::from(digit.to_digit(10).unwrap() as u8),
1049                };
1050                pitch_classes.push(pitch_classes.last().unwrap() + step);
1051            }
1052            let classified = row(pitch_classes).link_classification().unwrap().unwrap();
1053            assert_eq!(classified.number, link.classification, "{}", link.intervals);
1054            assert!(
1055                classified
1056                    .special_intervals
1057                    .contains(&link.special_intervals)
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn combinatoriality_matches_music21() {
1064        let moses = historical("SchoenbergMosesAron");
1065        assert_eq!(
1066            moses.pitch_classes(),
1067            &[9, 10, 4, 2, 3, 1, 7, 5, 6, 8, 11, 0]
1068        );
1069        let (p, i, r, ri) = (
1070            Transformation::Prime,
1071            Transformation::Inversion,
1072            Transformation::Retrograde,
1073            Transformation::RetrogradeInversion,
1074        );
1075        assert!(moses.are_combinatorial(p, 0, i, 3).unwrap());
1076        assert!(moses.are_combinatorial(p, 1, i, 4).unwrap());
1077        assert!(moses.are_combinatorial(r, 1, ri, 4).unwrap());
1078        assert!(!moses.are_combinatorial(r, 6, ri, 4).unwrap());
1079        assert!(matches!(
1080            row([0, 4, 8]).are_combinatorial(p, 0, i, 3),
1081            Err(Error::Serial(_))
1082        ));
1083    }
1084
1085    #[test]
1086    fn transformation_labels_round_trip() {
1087        for transformation in Transformation::ALL {
1088            for convention in [
1089                TransformationConvention::ZeroCentered,
1090                TransformationConvention::OriginalCentered,
1091            ] {
1092                let label = transformation.label(convention);
1093                assert_eq!(Transformation::from_name(label).unwrap(), transformation);
1094                assert_eq!(label.parse::<Transformation>().unwrap(), transformation);
1095            }
1096        }
1097        assert_eq!(Transformation::Prime.to_string(), "P");
1098        assert!(matches!(
1099            Transformation::from_name("X"),
1100            Err(Error::Serial(_))
1101        ));
1102        assert_eq!(
1103            TransformationConvention::from_name("zero").unwrap(),
1104            TransformationConvention::ZeroCentered
1105        );
1106        assert!(TransformationConvention::from_name("sideways").is_err());
1107    }
1108
1109    #[test]
1110    fn rows_build_from_pitches_and_arrays() {
1111        let pitches: Vec<Pitch> = ["C4", "F#3", "B-5"]
1112            .iter()
1113            .map(|&name| Pitch::from_name(name).unwrap())
1114            .collect();
1115        assert_eq!(ToneRow::from_pitches(&pitches).pitch_classes(), &[0, 6, 10]);
1116        assert_eq!(ToneRow::from([0u8, 6, 10]).pitch_classes(), &[0, 6, 10]);
1117        assert_eq!(ToneRow::from(vec![0u8, 6, 10]).len(), 3);
1118        assert_eq!(
1119            ToneRow::from([0u8, 6, 10]).pitches()[2].name_with_octave(),
1120            "B-"
1121        );
1122    }
1123}