Skip to main content

sound_to_patch/music/
roman.rs

1//! Roman numerals, stored as semitones above the tonic.
2//!
3//! Upper case is a major chord and lower case a minor one; `°` (or `o`)
4//! marks diminished, `ø` half-diminished and `+` augmented. A numeral
5//! without a sign takes its degree from the mode of the line, and a `b` or
6//! `#` is read against major. So `i-VI-III-VII` and `i-bVI-bIII-bVII` name
7//! the same chords. `X/Y` is a secondary chord: X read in the major key of
8//! the degree Y, so `V/V` lies 2 semitones above the tonic.
9//!
10//! Give the mode when the key is known. Without one, a line that starts
11//! with `I` reads as major, since a major key also borrows `III`, `VI` and
12//! `VII` as secondary chords. Any other line is minor when it starts with
13//! `i` or holds a bare `III`, `VI` or `VII`, the degrees that only minor
14//! makes major. A line read as major without a minor sign comes back
15//! flagged. `I/Y` is refused: it could be a slash chord over the bass Y.
16//!
17//! ```
18//! use sound_to_patch::music::roman::parse_line;
19//!
20//! let offsets = |text| -> Vec<u8> {
21//!     parse_line(text, None).unwrap().numerals.iter().map(|n| n.offset).collect()
22//! };
23//! assert_eq!(offsets("i-VI-III-VII"), [0, 8, 3, 10]);
24//! assert_eq!(offsets("i-bVI-bIII-bVII"), [0, 8, 3, 10]);
25//! assert_eq!(offsets("vi-IV-I-V"), [9, 5, 0, 7]);
26//! ```
27
28use crate::music::MusicError;
29use crate::music::key::Mode;
30
31/// The quality a numeral names.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33pub enum Quality {
34    /// Upper case.
35    Major,
36    /// Lower case.
37    Minor,
38    /// `°` or `o`.
39    Diminished,
40    /// `ø`.
41    HalfDiminished,
42    /// `+`.
43    Augmented,
44}
45
46/// One numeral of a line.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Numeral {
49    /// Semitones above the tonic.
50    pub offset: u8,
51    /// The quality the case and the sign name.
52    pub quality: Quality,
53    /// What follows the numeral, such as `7` or `maj7`.
54    pub extension: String,
55    /// For a secondary chord `X/Y`, the offset of Y above the tonic.
56    pub of: Option<u8>,
57}
58
59/// A parsed line of numerals and the mode it was read in.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Line {
62    /// The numerals in order.
63    pub numerals: Vec<Numeral>,
64    /// The mode the degrees were read in.
65    pub mode: Mode,
66    /// True when no mode was given and nothing in the line names one, so
67    /// major is an assumption to record or ask about.
68    pub assumed: bool,
69}
70
71const MAJOR: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
72const MINOR: [u8; 7] = [0, 2, 3, 5, 7, 8, 10];
73const DEGREES: [&str; 7] = ["vii", "vi", "iv", "v", "iii", "ii", "i"];
74
75/// Parses a line of numerals separated by `-`, `–`, spaces or commas, in
76/// `mode` when given.
77///
78/// # Errors
79///
80/// [`MusicError::UnknownNumeral`] for the first part that is no numeral.
81pub fn parse_line(text: &str, mode: Option<Mode>) -> Result<Line, MusicError> {
82    let parts: Vec<&str> = text
83        .split(|c: char| matches!(c, '-' | '–' | ',') || c.is_whitespace())
84        .filter(|part| !part.is_empty())
85        .collect();
86    if parts.is_empty() {
87        return Err(MusicError::UnknownNumeral(text.to_owned()));
88    }
89    let (mode, assumed) = match mode {
90        Some(mode) => (mode, false),
91        None if names_minor(&parts) => (Mode::Minor, false),
92        None => (Mode::Major, true),
93    };
94    let numerals = parts
95        .iter()
96        .map(|part| parse_numeral(part, mode))
97        .collect::<Result<_, _>>()?;
98    Ok(Line {
99        numerals,
100        mode,
101        assumed,
102    })
103}
104
105/// Whether a line names minor: it starts with `i`, or it does not start
106/// with `I` and a part is a bare `III`, `VI` or `VII`.
107fn names_minor(parts: &[&str]) -> bool {
108    fn chord<'a>(part: &&'a str) -> Option<Parsed<'a>> {
109        read(part.split('/').next().unwrap_or(part))
110    }
111    let Some(first) = parts.first().and_then(chord) else {
112        return false;
113    };
114    if first.degree == 0 && first.accidental == 0 {
115        return first.lower;
116    }
117    parts.iter().filter_map(chord).any(|numeral| {
118        numeral.accidental == 0
119            && !numeral.lower
120            && numeral.sign.is_none()
121            && matches!(numeral.degree, 2 | 5 | 6)
122    })
123}
124
125/// Parses one numeral in a line of `mode`.
126///
127/// # Errors
128///
129/// [`MusicError::UnknownNumeral`] when the text is no numeral.
130pub fn parse_numeral(text: &str, mode: Mode) -> Result<Numeral, MusicError> {
131    let unknown = || MusicError::UnknownNumeral(text.to_owned());
132    if let Some((chord, target)) = text.split_once('/')
133        && read(target).is_some()
134    {
135        let target = parse_numeral(target, mode)?;
136        if target.of.is_some() {
137            return Err(unknown());
138        }
139        let chord = parse_numeral(chord, Mode::Major)?;
140        if chord.of.is_some() || chord.offset == 0 {
141            return Err(unknown());
142        }
143        return Ok(Numeral {
144            offset: (target.offset + chord.offset) % 12,
145            of: Some(target.offset),
146            ..chord
147        });
148    }
149    let parsed = read(text).ok_or_else(unknown)?;
150    let quality = match parsed.sign {
151        Some('°' | 'o') => Quality::Diminished,
152        Some('ø') => Quality::HalfDiminished,
153        Some('+') => Quality::Augmented,
154        _ if parsed.lower => Quality::Minor,
155        _ => Quality::Major,
156    };
157    let minor_line = mode == Mode::Minor;
158    let leading_tone = matches!(quality, Quality::Diminished | Quality::HalfDiminished);
159    let offset = match parsed.accidental {
160        0 if minor_line && parsed.degree == 6 && leading_tone => 11,
161        0 if minor_line => MINOR[parsed.degree],
162        step => {
163            let base = i16::from(MAJOR[parsed.degree]) + i16::from(step);
164            u8::try_from(base.rem_euclid(12)).unwrap_or(0)
165        }
166    };
167    Ok(Numeral {
168        offset,
169        quality,
170        extension: parsed.extension.to_owned(),
171        of: None,
172    })
173}
174
175struct Parsed<'a> {
176    accidental: i8,
177    degree: usize,
178    lower: bool,
179    sign: Option<char>,
180    extension: &'a str,
181}
182
183fn read(text: &str) -> Option<Parsed<'_>> {
184    let (accidental, rest) = match text.chars().next()? {
185        'b' | '♭' => (-1, &text[text.chars().next()?.len_utf8()..]),
186        '#' | '♯' => (1, &text[text.chars().next()?.len_utf8()..]),
187        _ => (0, text),
188    };
189    let lower_rest = rest.to_ascii_lowercase();
190    let (index, name) = DEGREES
191        .iter()
192        .enumerate()
193        .find(|(_, name)| lower_rest.starts_with(*name))?;
194    let numeral = &rest[..name.len()];
195    let lower = numeral.chars().all(|c| c.is_ascii_lowercase());
196    if !lower && !numeral.chars().all(|c| c.is_ascii_uppercase()) {
197        return None;
198    }
199    let after = &rest[name.len()..];
200    let sign = after
201        .chars()
202        .next()
203        .filter(|c| matches!(c, '°' | 'o' | 'ø' | '+'));
204    let extension = &after[sign.map_or(0, char::len_utf8)..];
205    let known_start = extension.is_empty()
206        || extension.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '#' | 'b'))
207        || ["maj", "sus", "add"]
208            .iter()
209            .any(|word| extension.starts_with(word));
210    // A slash belongs only to the six-nine extension; `X/Y` is read by
211    // `parse_numeral`.
212    let known_chars = extension
213        .replace("6/9", "")
214        .chars()
215        .all(|c| c.is_ascii_alphanumeric() || c == '#');
216    if !known_start || !known_chars {
217        return None;
218    }
219    let degree = match index {
220        0 => 6,
221        1 => 5,
222        2 => 3,
223        3 => 4,
224        4 => 2,
225        5 => 1,
226        _ => 0,
227    };
228    Some(Parsed {
229        accidental,
230        degree,
231        lower,
232        sign,
233        extension,
234    })
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn offsets(line: &str) -> Vec<u8> {
242        parse_line(line, None)
243            .unwrap_or_else(|error| panic!("{line}: {error}"))
244            .numerals
245            .iter()
246            .map(|numeral| numeral.offset)
247            .collect()
248    }
249
250    #[test]
251    fn reads_the_major_and_minor_degrees() {
252        assert_eq!(offsets("I ii iii IV V vi vii°"), [0, 2, 4, 5, 7, 9, 11]);
253        assert_eq!(offsets("i ii° III iv v VI VII"), [0, 2, 3, 5, 7, 8, 10]);
254        assert_eq!(offsets("bII bIII bVI bVII"), [1, 3, 8, 10]);
255    }
256
257    #[test]
258    fn a_line_is_minor_only_when_it_says_so() {
259        assert_eq!(offsets("vi-IV-I-V"), [9, 5, 0, 7]);
260        assert_eq!(offsets("iii-vi-ii-V-I"), [4, 9, 2, 7, 0]);
261        assert_eq!(offsets("vi-V-IV-V"), [9, 7, 5, 7]);
262        assert_eq!(offsets("IV-V-iii-vi"), [5, 7, 4, 9]);
263        assert_eq!(offsets("VI-VII-i-i"), [8, 10, 0, 0]);
264        assert_eq!(offsets("iv-VII-III-VI"), [5, 10, 3, 8]);
265        assert!(parse_line("vi-IV-I-V", None).unwrap().assumed);
266        for (line, offsets_read) in [
267            ("I-VI-ii-V", [0, 9, 2, 7]),
268            ("I-VI7-ii7-V7", [0, 9, 2, 7]),
269            ("I-V-VI-IV", [0, 7, 9, 5]),
270        ] {
271            assert_eq!(offsets(line), offsets_read, "{line}");
272            let parsed = parse_line(line, None).unwrap();
273            assert_eq!((parsed.mode, parsed.assumed), (Mode::Major, true), "{line}");
274        }
275        assert_eq!(offsets("I-III7-vi"), [0, 4, 9]);
276        assert!(parse_line("I-III7-vi", None).unwrap().assumed);
277        assert!(!parse_line("i-iv-v", None).unwrap().assumed);
278        let given = parse_line("vi-IV-I-V", Some(Mode::Minor)).unwrap();
279        assert_eq!((given.mode, given.assumed), (Mode::Minor, false));
280        assert_eq!(given.numerals[0].offset, 8);
281    }
282
283    #[test]
284    fn both_minor_spellings_name_the_same_chords() {
285        assert_eq!(offsets("i-VI-III-VII"), offsets("i-bVI-bIII-bVII"));
286        assert_eq!(offsets("I-bVII-IV"), [0, 10, 5]);
287        assert_eq!(offsets("bII-i"), [1, 0]);
288        assert_eq!(offsets("i-iv-V"), [0, 5, 7]);
289        assert_eq!(offsets("i vii°"), [0, 11]);
290        assert_eq!(offsets("i viiø7"), [0, 11]);
291    }
292
293    #[test]
294    fn reads_secondary_chords() {
295        let line = parse_line("I V7/V V", None).unwrap();
296        assert_eq!(line.numerals[1].offset, 2);
297        assert_eq!(line.numerals[1].of, Some(7));
298        assert_eq!(line.numerals[1].extension, "7");
299        assert_eq!(offsets("I V/vi vi"), [0, 4, 9]);
300        assert_eq!(offsets("I viiø7/V V"), [0, 6, 7]);
301        assert_eq!(
302            parse_line("I6/9", None).unwrap().numerals[0].extension,
303            "6/9"
304        );
305        for text in ["V7/X", "V/V/V", "I7/9", "V/", "I/V", "i/iv"] {
306            assert!(parse_line(text, None).is_err(), "{text}");
307        }
308    }
309
310    #[test]
311    fn keeps_quality_and_extension() {
312        let line = parse_line("ii7-V7-Imaj7 iiø7", None).unwrap();
313        let read: Vec<(u8, Quality, &str)> = line
314            .numerals
315            .iter()
316            .map(|numeral| (numeral.offset, numeral.quality, numeral.extension.as_str()))
317            .collect();
318        assert_eq!(
319            read,
320            [
321                (2, Quality::Minor, "7"),
322                (7, Quality::Major, "7"),
323                (0, Quality::Major, "maj7"),
324                (2, Quality::HalfDiminished, "7"),
325            ]
326        );
327        assert_eq!(
328            parse_line("III+", None).unwrap().numerals[0].quality,
329            Quality::Augmented
330        );
331    }
332
333    #[test]
334    fn rejects_text_that_is_no_numeral() {
335        for text in ["", "X", "Iv", "VIII", "IVx", "C-G-Am"] {
336            assert!(parse_line(text, None).is_err(), "{text}");
337        }
338    }
339}