Skip to main content

music21_rs/humdrum/
harm.rs

1//! The harmonic annotations a `**harm` spine carries: music21's
2//! `humdrum.harmParser`.
3//!
4//! A `**harm` token is a scale degree with what is written round it --
5//! `V7b`, `#IVo7`, `Gnd`, `V/V/V`, `(I)`, `I[V]` -- and the two notations
6//! are not the same, so music21 converts one into the other: a `V7b` there
7//! is a `V65` to a [`RomanNumeral`](crate::roman::RomanNumeral), the
8//! inversion letter standing for the figured bass. The representation is
9//! described at <https://www.humdrum.org/rep/harm/>.
10//!
11//! music21 reads a token with a regular expression; the crate has none, so
12//! [`Harm::parse`] reads it by hand in the same order -- the accidental,
13//! the degree, the quality, the intervals, the inversion, an alternative
14//! reading in brackets, and a secondary function after a slash. The two
15//! agree on every token music21's own tests and docstrings name, and on the
16//! ones they do not: `harm::tests` holds forty more read off music21.
17//!
18//! The degree is matched longest first where music21's alternation tries
19//! the shortest and backtracks. The two come to the same thing, since no
20//! prefix of a degree (`i`, `v`, `I`, `V`) is a quality, an interval, an
21//! inversion, a bracket or a slash, so a shorter degree never leaves
22//! something the rest of the token can read.
23
24/// The degrees and special chords a `**harm` token may name, longest first.
25const ROOTS: [&str; 19] = [
26    "iii", "vii", "III", "VII", "iv", "vi", "ii", "IV", "VI", "II", "Gn", "Lt", "Fr", "Tr", "i",
27    "v", "I", "V", "N",
28];
29
30/// A `**harm` annotation read: music21's `HarmParser.parse`, whose answer is
31/// a dictionary of these same parts.
32#[derive(Clone, Debug, PartialEq, Eq, Default)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[must_use]
35pub struct Harm {
36    accidental: String,
37    root: String,
38    attribute: Option<char>,
39    intervals: Option<String>,
40    inversion: Option<char>,
41    alternative: Option<Box<Harm>>,
42    secondary: Option<Box<Harm>>,
43    implied: bool,
44}
45
46impl Harm {
47    /// Reads a `**harm` token, or nothing where it is not one: music21's
48    /// `HarmParser.parse`, which answers an empty dictionary there.
49    ///
50    /// ```
51    /// use music21_rs::humdrum::harm::Harm;
52    ///
53    /// let read = Harm::parse("#IV7b").expect("a harm token");
54    /// assert_eq!(read.accidental(), "#");
55    /// assert_eq!(read.root(), "IV");
56    /// assert_eq!(read.intervals(), Some("7"));
57    /// assert_eq!(read.inversion(), Some('b'));
58    /// assert_eq!(read.roman_figure(), "#IV65");
59    /// assert_eq!(Harm::parse("VIII"), None);
60    /// ```
61    pub fn parse(token: &str) -> Option<Self> {
62        // Harmony the annotator thought implied is written in parentheses
63        // round the whole token, and nothing else may be: music21's own
64        // pattern matches the whole string or fails.
65        if let Some(inner) = token
66            .strip_prefix('(')
67            .and_then(|rest| rest.strip_suffix(')'))
68            && !inner.is_empty()
69            && !inner.contains('(')
70            && !inner.contains(')')
71        {
72            let mut found = Self::parse(inner)?;
73            found.implied = true;
74            return Some(found);
75        }
76
77        let mut rest = token;
78        let mut read = Self::default();
79
80        // The accidental: up to two of `#` or `-`, as many as are written.
81        // Taking both can cost nothing, since no degree begins with either.
82        while read.accidental.len() < 2 {
83            let Some(mark) = rest
84                .chars()
85                .next()
86                .filter(|mark| *mark == '#' || *mark == '-')
87            else {
88                break;
89            };
90            read.accidental.push(mark);
91            rest = &rest[mark.len_utf8()..];
92        }
93
94        let root = ROOTS.iter().find(|root| rest.starts_with(**root))?;
95        read.root = (*root).to_string();
96        rest = &rest[root.len()..];
97
98        // Diminished or augmented, where the token says so.
99        if let Some(quality) = rest
100            .chars()
101            .next()
102            .filter(|mark| *mark == 'o' || *mark == '+')
103        {
104            read.attribute = Some(quality);
105            rest = &rest[quality.len_utf8()..];
106        }
107
108        // The intervals written over the degree, as many as the annotator
109        // wrote. music21 keeps the last of them, its group having matched
110        // once per interval, and that is what it reads the seventh off.
111        while let Some((interval, after)) = next_interval(rest) {
112            read.intervals = Some(interval.to_string());
113            rest = after;
114        }
115
116        if let Some(inversion) = rest
117            .chars()
118            .next()
119            .filter(|mark| ('b'..='d').contains(mark))
120        {
121            read.inversion = Some(inversion);
122            rest = &rest[inversion.len_utf8()..];
123        }
124
125        // An alternative reading in brackets, which music21 reads and the
126        // figure then says nothing of.
127        if let Some(after) = rest.strip_prefix('[') {
128            let (inner, after) = after.split_once(']')?;
129            if inner.is_empty() || inner.contains('[') {
130                return None;
131            }
132            read.alternative = Self::parse(inner).map(Box::new);
133            rest = after;
134        }
135
136        // A secondary function after a slash: the degree the one before it
137        // is read in. One that cannot be read is kept as nothing, which is
138        // the empty dictionary music21 keeps and then passes over.
139        if let Some(after) = rest.strip_prefix('/') {
140            if after.is_empty() {
141                return None;
142            }
143            read.secondary = Self::parse(after).map(Box::new);
144            rest = "";
145        }
146
147        rest.is_empty().then_some(read)
148    }
149
150    /// The accidental written in front of the degree: `#`, `--`, or nothing.
151    pub fn accidental(&self) -> &str {
152        &self.accidental
153    }
154
155    /// The degree or special chord: `V`, `ii`, `Gn`, `N`.
156    pub fn root(&self) -> &str {
157        &self.root
158    }
159
160    /// `o` for a diminished reading, `+` for an augmented one.
161    pub fn attribute(&self) -> Option<char> {
162        self.attribute
163    }
164
165    /// The last of the intervals written over the degree, as music21 keeps
166    /// them: `7` of `V7`, `AA6` of `VAA6`, `9` of `V79`.
167    pub fn intervals(&self) -> Option<&str> {
168        self.intervals.as_deref()
169    }
170
171    /// The inversion letter: `b` for the first inversion, `c` for the
172    /// second, `d` for the third.
173    pub fn inversion(&self) -> Option<char> {
174        self.inversion
175    }
176
177    /// The reading written in brackets beside this one, which the figure
178    /// says nothing of.
179    pub fn alternative(&self) -> Option<&Harm> {
180        self.alternative.as_deref()
181    }
182
183    /// The degree this one is read in, written after a slash.
184    pub fn secondary(&self) -> Option<&Harm> {
185        self.secondary.as_deref()
186    }
187
188    /// Whether the annotator wrote the token in parentheses, meaning the
189    /// harmony is implied rather than sounded.
190    pub fn implied(&self) -> bool {
191        self.implied
192    }
193
194    /// The figure a [`RomanNumeral`](crate::roman::RomanNumeral) is built
195    /// from: music21's `convertHarmToRoman`.
196    ///
197    /// The inversion letter becomes the figured bass it stands for -- a
198    /// seventh chord's `b` is `65` and a triad's is `6` -- a German or
199    /// French sixth is read as a seventh chord whatever intervals were
200    /// written, and only the *degrees* of the secondary functions are
201    /// carried across, as music21 carries them.
202    pub fn roman_figure(&self) -> String {
203        let (degree, intervals) = match self.root.as_str() {
204            "Gn" => ("Ger", Some("7")),
205            "Lt" => ("It", self.intervals()),
206            "Fr" => ("Fr", Some("7")),
207            root => (root, self.intervals()),
208        };
209        let seventh = intervals.is_some_and(|intervals| intervals.contains('7'));
210        let inversion = match self.inversion {
211            None => {
212                if seventh {
213                    "7"
214                } else {
215                    ""
216                }
217            }
218            Some('b') => {
219                if seventh {
220                    "65"
221                } else {
222                    "6"
223                }
224            }
225            Some('c') => {
226                if seventh {
227                    "43"
228                } else {
229                    "64"
230                }
231            }
232            // A third inversion is a seventh chord's, or a special chord's.
233            _ => "2",
234        };
235        let mut figure = format!(
236            "{}{degree}{}{inversion}",
237            self.accidental,
238            self.attribute.map(String::from).unwrap_or_default()
239        );
240        let mut secondary = self.secondary();
241        while let Some(found) = secondary {
242            figure.push('/');
243            figure.push_str(&found.root);
244            secondary = found.secondary();
245        }
246        figure
247    }
248}
249
250/// The interval at the front of what is left, if one is written there:
251/// `AA6`, `DD7`, `M7` or a plain `7`, as music21's own alternation reads
252/// them.
253fn next_interval(rest: &str) -> Option<(&str, &str)> {
254    let digits = |from: usize| -> Option<usize> {
255        let count = rest[from..]
256            .chars()
257            .take_while(char::is_ascii_digit)
258            .count();
259        (count > 0).then_some(from + count)
260    };
261    for prefix in ["AA", "DD"] {
262        if rest.starts_with(prefix)
263            && let Some(end) = digits(prefix.len())
264        {
265            return Some((&rest[..end], &rest[end..]));
266        }
267    }
268    if rest
269        .chars()
270        .next()
271        .is_some_and(|first| "mMPAD".contains(first))
272        && let Some(end) = digits(1)
273    {
274        return Some((&rest[..end], &rest[end..]));
275    }
276    digits(0).map(|end| (&rest[..end], &rest[end..]))
277}
278
279/// The figure a `**harm` token stands for, or nothing where it is no token:
280/// music21's `convertHarmToRoman`.
281///
282/// ```
283/// use music21_rs::humdrum::harm::convert_harm_to_roman;
284///
285/// assert_eq!(convert_harm_to_roman("V7b").as_deref(), Some("V65"));
286/// assert_eq!(convert_harm_to_roman("Gnd").as_deref(), Some("Ger2"));
287/// assert_eq!(convert_harm_to_roman("x"), None);
288/// ```
289pub fn convert_harm_to_roman(token: &str) -> Option<String> {
290    Harm::parse(token).map(|harm| harm.roman_figure())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    /// Every figure here is music21's own for the same token: the triads,
298    /// sevenths, special chords and alterations of its own tests, the
299    /// docstrings' examples, and forty more read off music21 beside them.
300    #[test]
301    fn a_harm_token_reads_as_music21_reads_it() {
302        for (token, figure) in [
303            // The docstrings' own.
304            ("I", Some("I")),
305            ("Vc", Some("V64")),
306            ("Ib", Some("I6")),
307            ("iib", Some("ii6")),
308            ("V", Some("V")),
309            ("viiob", Some("viio6")),
310            ("vib", Some("vi6")),
311            ("V7", Some("V7")),
312            ("viio7c", Some("viio43")),
313            ("V7d", Some("V2")),
314            ("viio7b", Some("viio65")),
315            ("V7c", Some("V43")),
316            ("Lt", Some("It")),
317            ("Ltb", Some("It6")),
318            ("Ltc", Some("It64")),
319            ("Fr", Some("Fr7")),
320            ("Frb", Some("Fr65")),
321            ("Frc", Some("Fr43")),
322            ("Frd", Some("Fr2")),
323            ("Gn", Some("Ger7")),
324            ("Gnb", Some("Ger65")),
325            ("Gnc", Some("Ger43")),
326            ("Gnd", Some("Ger2")),
327            // Its unit tests' special chords and alterations.
328            ("N", Some("N")),
329            ("Nb", Some("N6")),
330            ("Nc", Some("N64")),
331            ("-IIb", Some("-II6")),
332            ("--IIc", Some("--II64")),
333            ("#IV7", Some("#IV7")),
334            ("##IV7b", Some("##IV65")),
335            // Every degree, in both cases.
336            ("i", Some("i")),
337            ("iv", Some("iv")),
338            ("vii", Some("vii")),
339            ("III", Some("III")),
340            ("VI", Some("VI")),
341            ("IV", Some("IV")),
342            ("ib", Some("i6")),
343            ("ivc", Some("iv64")),
344            ("viid", Some("vii2")),
345            ("I7", Some("I7")),
346            ("I7b", Some("I65")),
347            ("I7c", Some("I43")),
348            ("I7d", Some("I2")),
349            ("i7", Some("i7")),
350            // The qualities.
351            ("viio", Some("viio")),
352            ("viio7", Some("viio7")),
353            ("V+", Some("V+")),
354            ("ii+7", Some("ii+7")),
355            ("iiio", Some("iiio")),
356            ("iio", Some("iio")),
357            ("iio7", Some("iio7")),
358            ("iiiob", Some("iiio6")),
359            ("Io7", Some("Io7")),
360            ("V+7b", Some("V+65")),
361            ("vii+", Some("vii+")),
362            // The intervals: only a seventh among them changes the figure,
363            // and the quality letter in front of one is not carried over.
364            ("VM7", Some("V7")),
365            ("Vm7", Some("V7")),
366            ("VD7", Some("V7")),
367            ("VA6", Some("V")),
368            ("VAA6", Some("V")),
369            ("VDD7", Some("V7")),
370            ("V9", Some("V")),
371            ("V11", Some("V")),
372            ("V13", Some("V")),
373            ("V79", Some("V7")),
374            ("V0", Some("V")),
375            ("I8", Some("I")),
376            ("IM7b", Some("I65")),
377            ("Vm7c", Some("V43")),
378            ("TrD7", Some("Tr7")),
379            ("N7", Some("N7")),
380            ("Lt7", Some("It7")),
381            ("Ltd", Some("It2")),
382            // A German or French sixth is a seventh chord whatever is
383            // written over it.
384            ("Gn7b", Some("Ger65")),
385            ("Fr7b", Some("Fr65")),
386            // The secondary functions, of which only the degrees carry.
387            ("V/V", Some("V/V")),
388            ("V7/IV", Some("V7/IV")),
389            ("viio/ii", Some("viio/ii")),
390            ("V/V/V", Some("V/V/V")),
391            ("viioD7/iv/v", Some("viio7/iv/v")),
392            ("V7b/ii", Some("V65/ii")),
393            ("V/#iv", Some("V/iv")),
394            ("V/ivb", Some("V/iv")),
395            ("V/iv7", Some("V/iv")),
396            ("V7/V7", Some("V7/V")),
397            ("V/N", Some("V/N")),
398            ("V/Gn", Some("V/Gn")),
399            // A secondary that cannot be read is passed over, as music21
400            // passes over the empty dictionary its own parse answers.
401            ("V//V", Some("V")),
402            // Implied harmony, and an alternative reading.
403            ("(I)", Some("I")),
404            ("(V/V)", Some("V/V")),
405            ("(Ib)", Some("I6")),
406            ("I[V]", Some("I")),
407            ("I[V/IV]", Some("I")),
408            ("V7[viio]", Some("V7")),
409            ("I[V]/V", Some("I/V")),
410            // Both accidentals, in either order, and on a special chord.
411            ("#-I", Some("#-I")),
412            ("-#I", Some("-#I")),
413            ("#N", Some("#N")),
414            ("-Lt", Some("-It")),
415            ("Tr", Some("Tr")),
416            ("Trb", Some("Tr6")),
417            // What is no token at all.
418            ("", None),
419            (".", None),
420            ("x", None),
421            ("VIII", None),
422            ("I/", None),
423            ("/V", None),
424            ("Ie", None),
425            ("I!", None),
426            ("I-", None),
427            ("I7o", None),
428            ("iib7", None),
429            ("I#", None),
430            ("I(", None),
431            ("#", None),
432            ("-", None),
433            ("o", None),
434            ("+", None),
435            ("(I)/V", None),
436        ] {
437            assert_eq!(
438                convert_harm_to_roman(token).as_deref(),
439                figure,
440                "**harm token {token}"
441            );
442        }
443    }
444
445    /// The parts music21's own `parse` answers with, for the tokens whose
446    /// dictionary its docstrings show.
447    #[test]
448    fn the_parts_of_a_token_are_music21s() {
449        let read = Harm::parse("iib").expect("a token");
450        assert_eq!((read.root(), read.inversion()), ("ii", Some('b')));
451        assert!(!read.implied());
452
453        let read = Harm::parse("#IV7").expect("a token");
454        assert_eq!(read.accidental(), "#");
455        assert_eq!(read.intervals(), Some("7"));
456
457        let read = Harm::parse("VAA6").expect("a token");
458        assert_eq!(read.intervals(), Some("AA6"));
459
460        let read = Harm::parse("(I)").expect("a token");
461        assert!(read.implied());
462        assert_eq!(read.root(), "I");
463
464        let read = Harm::parse("I[V]").expect("a token");
465        assert_eq!(read.alternative().map(Harm::root), Some("V"));
466        assert!(read.secondary().is_none());
467
468        // The chain of secondary functions, which the figure walks: a
469        // degree read in a degree read in the key.
470        let read = Harm::parse("V/V/V").expect("a token");
471        assert_eq!(read.secondary().map(Harm::root), Some("V"));
472        assert_eq!(
473            read.secondary().and_then(Harm::secondary).map(Harm::root),
474            Some("V")
475        );
476        assert!(
477            read.secondary()
478                .and_then(Harm::secondary)
479                .and_then(Harm::secondary)
480                .is_none()
481        );
482    }
483}