Repository: librosa/librosa
Pull request #1756 (2023-09-21 04:59:35)

Mode support
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at
the contribution guidelines: https://github.com/librosa/librosa/blob/main/CONTRIBUTING.md#how-to-contribute
-->
#### Reference Issue
Multiple accidentals in tonics, expanded support for modes in note spelling #1724 


#### What does this implement/fix? Explain your changes.

I implemented support for modes, as discussed in [this PR](https://github.com/librosa/librosa/pull/1739). This mostly just involved copying and pasting @bmcfee's solutions from the discussion in that PR. Now `key_to_degrees` and `key_to_notes` should work with arbitrary modes, including with double-accidentals, e.g. `Gbb:dor`.

#### Any other comments?

Here is a chromagram from a recording of a G locrian mode, which shows that the implementation plays nicely with at least some other parts of the package:

![G-loc_example](https://github.com/librosa/librosa/assets/30175021/772f1d57-9a13-48fd-9fdf-4d100313c0e4)

After some preliminary local tests, it seems that all of the downstream functions that use `key_to_degrees` and `key_to_notes` work as expected with modes. Happy to discuss further.



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/librosa/librosa/pull/1756?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=librosa) Report
Patch coverage is **`84.21%`** of modified lines.

| [Files Changed](https://app.codecov.io/gh/librosa/librosa/pull/1756?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=librosa) | Coverage |
|---|---|
| [librosa/core/notation.py](https://app.codecov.io/gh/librosa/librosa/pull/1756?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=librosa#diff-bGlicm9zYS9jb3JlL25vdGF0aW9uLnB5) | `84.21%` |


:loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=librosa).

erabinov: @bmcfee I am having a bit of trouble understanding the following type of `pytest` error:

`FAILED tests/test_display.py::test_waveshow_mono_trans - AttributeError: '_process_plot_var_args' object has no attribute 'prop_cycler'`

All the errors occur with `test_display.py`, and I've tried to follow the advice on the contribution document to regenerate the baseline images. When I did this and ran `pytest` locally in the `librosa-dev` environment, I didn't see these errors. Do you know what might be going wrong?
bmcfee: > @bmcfee I am having a bit of trouble understanding the following type of `pytest` error:

This is fixed in PR #1755 which I haven't yet merged.

You should not need to replace the fixture images, please remove those from this PR.
erabinov: Ok, noted.
bmcfee: Ok, #1755 is merged now.  If you pull and rebase, the display test issues should disappear.
erabinov: Thanks for the suggestions! Let me know what you think of the suggestions. I'm happy to go with the simplest `OFFSET_DICT`. I'll work on getting the specshow advanced example working.
erabinov: Nope! I went back to the original issue to double-check whether there was anything else you had planned, but I think we've covered it!
bmcfee: Awesome.  Great job @erabinov - and thanks again!  :shipit: 

Patch:
diff --git a/docs/examples/plot_display.py b/docs/examples/plot_display.py
index 06e00129..150c2732 100644
--- a/docs/examples/plot_display.py
+++ b/docs/examples/plot_display.py
@@ -182,8 +182,8 @@ fig.colorbar(img, ax=ax)
 # pass this to specshow and it will spell the notes properly:
 fig, ax = plt.subplots()
 img = librosa.display.specshow(chroma, y_axis='chroma', x_axis='time',
-                               key='Eb:maj', ax=ax)
-ax.set(title='Chromagram explicitly in Eb:maj')
+                               key='F:dor', ax=ax)
+ax.set(title='Chromagram explicitly in F:dor')
 fig.colorbar(img, ax=ax)
 
 # %%
diff --git a/librosa/core/notation.py b/librosa/core/notation.py
index 38f4245f..237da7ed 100644
--- a/librosa/core/notation.py
+++ b/librosa/core/notation.py
@@ -124,14 +124,29 @@ MELAKARTA_MAP = {
 
 # Pre-compiled regular expressions for note and key parsing
 KEY_RE = re.compile(
-    r"^(?P<tonic>[A-Ga-g])" r"(?P<accidental>([#♯b!♭𝄪𝄫♮]*))" r":(?P<scale>(maj|min)(or)?)$"
+    r"^(?P<tonic>[A-Ga-g])"
+        r"(?P<accidental>[#♯𝄪b!♭𝄫]*)"
+        r":((?P<scale>(maj|min)(or)?)|(?P<mode>(((ion|dor|phryg|lyd|mixolyd|aeol|locr)(ian)?)|phr|mix|aeo|loc)))$"
 )
+
 NOTE_RE = re.compile(
     r"^(?P<note>[A-Ga-g])"
     r"(?P<accidental>[#♯𝄪b!♭𝄫♮]*)"
     r"(?P<octave>[+-]?\d+)?"
     r"(?P<cents>[+-]\d+)?$"
 )
+# A dictionary converting the tonic name to the associated major key, e.g. C Dorian uses the notes of the Bb major scale, hence MAJOR_DICT['dor']['C'] = 'B♭'
+MAJOR_DICT = {
+    'ion': {'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'A': 'A', 'B': 'B'},
+    'dor': {'C': 'B♭', 'D': 'C', 'E': 'D', 'F': 'E♭', 'G': 'F', 'A': 'G', 'B': 'A'},
+    'phr': {'C': 'A♭', 'D': 'B♭', 'E': 'C', 'F': 'D♭', 'G': 'E♭', 'A': 'F', 'B': 'G'},
+    'lyd': {'C': 'G', 'D': 'A', 'E': 'B', 'F': 'C', 'G': 'D', 'A': 'E', 'B': 'F♯'},
+    'mix': {'C': 'F', 'D': 'G', 'E': 'A', 'F': 'B♭', 'G': 'C', 'A': 'D', 'B': 'E'},
+    'aeo': {'C': 'E♭', 'D': 'F', 'E': 'G', 'F': 'A♭', 'G': 'B♭', 'A': 'C', 'B': 'D'},
+    'loc': {'C': 'D♭', 'D': 'E♭', 'E': 'F', 'F': 'G♭', 'G': 'A♭', 'A': 'B♭', 'B': 'C'}
+}
+
+OFFSET_DICT = { "ion": 0, "dor": 1, "phr": 2, "lyd": 3, "mix": 4, "aeo": 5, "loc": 6 }
 
 ACC_MAP = {"#": 1, "♮": 0, "": 0, "b": -1, "!": -1, "♯": 1, "♭": -1, "𝄪": 2, "𝄫": -2}
 
@@ -560,11 +575,38 @@ def __simplify_note(key: Union[str, _IterableLike[str], Iterable[str]], addition
     
     return simplified_note
     
+def __mode_to_key(signature: str, unicode: bool = True) -> str:
+    """Translate a mode (eg D:dorian) into its equivalent major key. If unicode==True, return the accidentals as unicode symbols, regardless of nature of accidentals in signature. Otherwise, return accidentals as ASCII symbols.
+
+    >>> librosa.__mode_to_key('Db:loc')
+    'E𝄫:maj'
+
+    >>> librosa.__mode_to_key('D♭:loc', unicode = False)
+    'Ebb:maj'
+
+    """
+    match = KEY_RE.match(signature)
+    
+    if not match:
+        raise ParameterError("Improper format: {:s}".format(signature))
+
+    if match.group('scale') or not match.group("mode"):
+        # We're already fine here, but let's pass the key through __simpify_note() to ensure good formatting.
+        signature = __simplify_note(match.group("tonic").upper()+match.group('accidental'), unicode=unicode)+(':'+match.group("scale") if match.group("scale") else '')
+        return signature
+        
+    # We have a mode, time to translate
+    mode = match.group("mode").lower()[:3]
+
+    # Get the relative major
+    tonic = MAJOR_DICT[mode][match.group("tonic").upper()]
+
+    return __simplify_note(tonic+match.group("accidental"), unicode = unicode)+":maj"
 
 @cache(level=10)
 def key_to_notes(key: str, *, unicode: bool = True) -> List[str]:
     """List all 12 note names in the chromatic scale, as spelled according to
-    a given key (major or minor).
+    a given key (major or minor) or mode (ionian, dorian, phrygian, lydian, mixolydian, aeolian, locrian). The following abbreviations are accepted for the modes: either the first three letters of the mode name (e.g. "mix") or the mode name without "ian" (e.g. "mixolyd").
 
     This function exists to resolve enharmonic equivalences between different
     spellings for the same pitch (e.g. C♯ vs D♭), and is primarily useful when producing
@@ -634,6 +676,11 @@ def key_to_notes(key: str, *, unicode: bool = True) -> List[str]:
 
     >>> librosa.key_to_notes('Fb:min')
     ['D𝄫', 'D♭', 'E𝄫', 'E♭', 'F♭', 'F', 'G♭', 'A𝄫', 'A♭', 'B𝄫', 'B♭', 'C♭']
+
+    `G:loc` uses flats
+
+    >>> librosa.key_to_notes('G:loc')
+    ['C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B']
     """
     # Parse the key signature
     match = KEY_RE.match(key)
@@ -648,6 +695,10 @@ def key_to_notes(key: str, *, unicode: bool = True) -> List[str]:
 
     offset = sum([ACC_MAP[acc] for acc in accidental])
 
+    if match.group('mode') or not match.group('scale'):
+        equiv = __mode_to_key(key)
+        return key_to_notes(equiv, unicode=unicode)
+
     scale = match.group("scale")[:3].lower()
 
     multiple = abs(offset)>=2
@@ -804,6 +855,11 @@ def key_to_degrees(key: str) -> np.ndarray:
 
     if not match:
         raise ParameterError(f"Improper key format: {key:s}")
+    
+    if match.group('mode') or not match.group('scale'):
+        equiv = __mode_to_key(key)
+        offset = OFFSET_DICT[match.group('mode')[:3]]
+        return np.roll(key_to_degrees(equiv),-offset)
 
     pitch_map = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
     tonic = match.group("tonic").upper()


Test patch:
diff --git a/tests/test_notation.py b/tests/test_notation.py
index 0426df69..609c27af 100644
--- a/tests/test_notation.py
+++ b/tests/test_notation.py
@@ -74,7 +74,16 @@ def test_note_to_degree_badnote():
         # Test for multiple accidentals in tonic name.
         ("F##:maj", ["B#", "C#", "C##", "D#", "D##", "E#", "E##", "F##", "G#", "G##", "A#", "A##"]),
         ("Fbb:maj", ["Dbb", "Db", "Ebb", "Fbb", "Fb", "Gbb", "Gb", "Abb", "Bbbb", "Bbb", "Cbb", "Cb"]),
-        ("A###:min", ["A###", "B##", "B###", "C###", "D##", "D###", "E##", "E###", "F###", "G##", "G###", "A##"])
+        ("A###:min", ["A###", "B##", "B###", "C###", "D##", "D###", "E##", "E###", "F###", "G##", "G###", "A##"]),
+
+        #Testing that the modes work. These were copied from the output generated in the discussion at https://github.com/librosa/librosa/pull/1739#issuecomment-1711949365.
+        ("E:ion",['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']),
+        ("E#:mix",['B#', 'C#', 'C##', 'D#', 'E', 'E#', 'F#', 'F##', 'G#', 'G##', 'A#', 'B']),
+        ("E#:lyd",['B#', 'C#', 'C##', 'D#', 'D##', 'E#', 'F#', 'F##', 'G#', 'G##', 'A#', 'A##']),
+        ("Gb:dor",['C', 'Db', 'D', 'Eb', 'Fb', 'F', 'Gb', 'G', 'Ab', 'Bbb', 'Bb', 'Cb']),
+        ("Gb:phr",['Dbb', 'Db', 'Ebb', 'Eb', 'Fb', 'Gbb', 'Gb', 'Abb', 'Ab', 'Bbb', 'Bb', 'Cb']),
+        ("B#:aeol",['B#', 'C#', 'C##', 'D#', 'E', 'E#', 'F#', 'F##', 'G#', 'A', 'A#', 'B']),
+        ("B#:loc",['B#', 'C#', 'D', 'D#', 'E', 'E#', 'F#', 'G', 'G#', 'A', 'A#', 'B'])
     ],
 )
 def test_key_to_notes(key, ref_notes):
@@ -140,7 +149,15 @@ def test_key_to_degrees_badkey():
         ("C:min", [0, 2, 3, 5, 7, 8, 10]),
         ("A:min", [9, 11, 0, 2, 4, 5, 7]),
         ("Gb:maj", [6, 8, 10, 11, 1, 3, 5]),
-        ("A###:maj", [0, 2, 4, 5, 7, 9, 11])
+        ("A###:maj", [0, 2, 4, 5, 7, 9, 11]),
+        ("C:ion", [ 0,  2,  4,  5,  7,  9, 11]),
+        ("C:dor", [ 0,  2,  3,  5,  7,  9, 10]),
+        ("C:phr", [ 0,  1,  3,  5,  7,  8, 10]),
+        ("D#:lyd", [ 3,  5,  7,  9, 10,  0,  2]),
+        ("D#:mix", [ 3,  5,  7,  8, 10,  0,  1]),
+        ("Ebb:aeol" , [2, 4, 5, 7, 9, 10, 0]),
+        ("Ebb:loc", [2, 3, 5, 7, 8, 10, 0])
+
     ],
 )
 def test_key_to_degrees(key, ref_degrees):
@@ -149,6 +166,22 @@ def test_key_to_degrees(key, ref_degrees):
     for (d, rd) in zip(degrees, ref_degrees):
         assert d == rd
 
+@pytest.mark.xfail(raises=librosa.ParameterError)
+def test_mode_to_key_badkey():
+    librosa.core.notation.__mode_to_key("not a key")
+
+@pytest.mark.parametrize(
+    "mode, ref_mode",
+    [
+        (
+            'C:maj', 'C:maj'
+        )
+    ],
+)
+def test_mode_to_key_no_change(mode, ref_mode):
+    simplified_mode = librosa.core.notation.__mode_to_key(mode)
+    for (n, rn) in zip(mode, ref_mode):
+        assert n == rn
 
 def test_list_thaat():
     thaat = librosa.list_thaat()