harte-rs 0.17.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- harte_rs-0.17.0/.gitignore +38 -0
- harte_rs-0.17.0/LICENSE-harte-library +21 -0
- harte_rs-0.17.0/PKG-INFO +105 -0
- harte_rs-0.17.0/README.md +89 -0
- harte_rs-0.17.0/harte/__init__.py +14 -0
- harte_rs-0.17.0/harte/harte.py +102 -0
- harte_rs-0.17.0/harte/interval.py +31 -0
- harte_rs-0.17.0/harte/mappings.py +35 -0
- harte_rs-0.17.0/harte/utils.py +29 -0
- harte_rs-0.17.0/pyproject.toml +32 -0
- harte_rs-0.17.0/tests/test_harte.py +245 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/target/
|
|
2
|
+
/python-parity/target/
|
|
3
|
+
/python/target/
|
|
4
|
+
/venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
/examples/web/pkg/
|
|
7
|
+
/examples/web/pkg-*/
|
|
8
|
+
/examples/web/chord/pkg/
|
|
9
|
+
/examples/web/*.js
|
|
10
|
+
/examples/web/chord/*.js
|
|
11
|
+
/examples/web/chords/*.js
|
|
12
|
+
/examples/web/polyrhythm/*.js
|
|
13
|
+
/examples/web/tuning/*.js
|
|
14
|
+
/examples/web/harte/*.js
|
|
15
|
+
/examples/web/roman/*.js
|
|
16
|
+
/examples/web/scales/*.js
|
|
17
|
+
/examples/web/serial/*.js
|
|
18
|
+
/examples/web/listen/*.js
|
|
19
|
+
/examples/web/score/*.js
|
|
20
|
+
|
|
21
|
+
# What `maturin develop` builds into the wheel's own package directory.
|
|
22
|
+
/python/pysrc/music21_rs/*.pyd
|
|
23
|
+
/python/pysrc/music21_rs/*.so
|
|
24
|
+
/python/pysrc/music21_rs/*.dylib
|
|
25
|
+
/python/pysrc/music21_rs/*.pdb
|
|
26
|
+
|
|
27
|
+
# OS/editor noise
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
.idea/
|
|
31
|
+
.codex/
|
|
32
|
+
.m21venv/
|
|
33
|
+
.wheelvenv/
|
|
34
|
+
CLAUDE.md
|
|
35
|
+
AGENTS.md
|
|
36
|
+
.serena
|
|
37
|
+
/result
|
|
38
|
+
/result-*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Andrea Poltronieri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
harte_rs-0.17.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: harte-rs
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: Harte chord notation read into music21-rs chords; a replacement for harte-library
|
|
5
|
+
Project-URL: Repository, https://github.com/float3/music21-rs
|
|
6
|
+
Author-email: hill <hill@hilll.dev>
|
|
7
|
+
License-Expression: AGPL-3.0-only
|
|
8
|
+
License-File: LICENSE-harte-library
|
|
9
|
+
Keywords: chords,harte,music,music-theory,music21
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Requires-Dist: music21-rs==0.17.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# harte-rs
|
|
18
|
+
|
|
19
|
+
[](https://github.com/float3/music21-rs/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/harte-rs/)
|
|
21
|
+
|
|
22
|
+
Harte chord notation, `C:maj7/3`, `Bb:(b3,5,b7,9)`, `F:sus4(*3,9)`, `N`,
|
|
23
|
+
read into [music21-rs](https://pypi.org/project/music21-rs/) chords. it is
|
|
24
|
+
a replacement for [harte-library](https://github.com/andreamust/harte-library):
|
|
25
|
+
it is imported as `harte`, has the same modules and names, and builds the
|
|
26
|
+
chord harte-library builds for every label in that library's coverage set.
|
|
27
|
+
the label is read by the [harte](https://crates.io/crates/harte) Rust crate,
|
|
28
|
+
and the chord is a music21-rs `Chord`, so neither music21 nor lark is
|
|
29
|
+
needed.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install harte-rs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
harte-rs and harte-library both install the `harte` package, so uninstall
|
|
36
|
+
harte-library first.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from harte.harte import Harte
|
|
40
|
+
from harte.interval import HarteInterval
|
|
41
|
+
from harte.utils import convert_interval
|
|
42
|
+
|
|
43
|
+
chord = Harte("Bb:min7/b3")
|
|
44
|
+
chord.pitches # (<music21.pitch.Pitch B-4>, <music21.pitch.Pitch D-3>, <music21.pitch.Pitch F5>, <music21.pitch.Pitch A-5>)
|
|
45
|
+
chord.bass() # <music21.pitch.Pitch D-3>
|
|
46
|
+
chord.commonName # 'minor seventh chord'
|
|
47
|
+
chord.get_root() # 'Bb'
|
|
48
|
+
chord.get_shorthand() # 'min7'
|
|
49
|
+
chord.get_bass() # 'b3'
|
|
50
|
+
|
|
51
|
+
Harte("C:(b3,5)").prettify() # 'C:min'
|
|
52
|
+
Harte("G:7").get_midi_pitches() # [67, 71, 74, 77]
|
|
53
|
+
Harte("G:7").multi_hot_encoding() # [0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1]
|
|
54
|
+
Harte("F:sus4(*5,9)").unwrap_shorthand() # ['1', '4', '9']
|
|
55
|
+
|
|
56
|
+
HarteInterval("b6").name # 'm6'
|
|
57
|
+
convert_interval("#11") # 'A4'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`Harte` is a music21-rs `Chord` and `HarteInterval` a music21-rs
|
|
61
|
+
`Interval`, so every member those have works on them. with music21
|
|
62
|
+
installed and `music21_rs.install_into_music21()` called before `harte` is
|
|
63
|
+
imported, a `Harte` is a music21 `Chord` too, and goes in music21's streams
|
|
64
|
+
as harte-library's does.
|
|
65
|
+
|
|
66
|
+
## differences from harte-library
|
|
67
|
+
|
|
68
|
+
- a label that is not Harte notation raises music21-rs's `ChordException`,
|
|
69
|
+
where harte-library raises lark's parse errors. `harte.parse_harte`, the
|
|
70
|
+
lark grammar, is not included.
|
|
71
|
+
- `str()` and `repr()` give the label in its canonical spelling,
|
|
72
|
+
`Harte(C:maj7(9)/3)`. harte-library's raise `TypeError` for every label
|
|
73
|
+
but `N`.
|
|
74
|
+
- two labels are equal when they sound the same chord: `C:maj` equals
|
|
75
|
+
`C:(3,5)`. harte-library's compares the degrees as written.
|
|
76
|
+
- `bass_is_root()` answers whether the bass is the root. harte-library's
|
|
77
|
+
compares the root's name with the bass degree, and answers `False` for
|
|
78
|
+
every chord.
|
|
79
|
+
- `N` and `X` have no shorthand: `get_shorthand()` is `None` and
|
|
80
|
+
`contains_shorthand()` is `False`.
|
|
81
|
+
|
|
82
|
+
## development
|
|
83
|
+
|
|
84
|
+
harte-rs lives in the [music21-rs](https://github.com/float3/music21-rs)
|
|
85
|
+
repository, in `harte/python`, and is released with the music21-rs wheel at
|
|
86
|
+
the same version. it builds on that wheel's `_HarteLabel`, so it needs the
|
|
87
|
+
wheel built from the same checkout:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
maturin build --release --manifest-path python/Cargo.toml --out target/wheels
|
|
91
|
+
pip install target/wheels/music21_rs-*.whl ./harte/python pytest
|
|
92
|
+
python -m pytest harte/python/tests
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
the tests check every label in harte-library's coverage set against
|
|
96
|
+
`harte/data/harte_expectations.toml`, which records what harte-library makes
|
|
97
|
+
of each.
|
|
98
|
+
|
|
99
|
+
## credits
|
|
100
|
+
|
|
101
|
+
harte-rs is released under the
|
|
102
|
+
[AGPL-3.0](https://github.com/float3/music21-rs/blob/master/LICENSE). it is
|
|
103
|
+
a port of [harte-library](https://github.com/andreamust/harte-library) by
|
|
104
|
+
Andrea Poltronieri, licensed MIT; its licence is included as
|
|
105
|
+
`LICENSE-harte-library`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# harte-rs
|
|
2
|
+
|
|
3
|
+
[](https://github.com/float3/music21-rs/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/harte-rs/)
|
|
5
|
+
|
|
6
|
+
Harte chord notation, `C:maj7/3`, `Bb:(b3,5,b7,9)`, `F:sus4(*3,9)`, `N`,
|
|
7
|
+
read into [music21-rs](https://pypi.org/project/music21-rs/) chords. it is
|
|
8
|
+
a replacement for [harte-library](https://github.com/andreamust/harte-library):
|
|
9
|
+
it is imported as `harte`, has the same modules and names, and builds the
|
|
10
|
+
chord harte-library builds for every label in that library's coverage set.
|
|
11
|
+
the label is read by the [harte](https://crates.io/crates/harte) Rust crate,
|
|
12
|
+
and the chord is a music21-rs `Chord`, so neither music21 nor lark is
|
|
13
|
+
needed.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install harte-rs
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
harte-rs and harte-library both install the `harte` package, so uninstall
|
|
20
|
+
harte-library first.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from harte.harte import Harte
|
|
24
|
+
from harte.interval import HarteInterval
|
|
25
|
+
from harte.utils import convert_interval
|
|
26
|
+
|
|
27
|
+
chord = Harte("Bb:min7/b3")
|
|
28
|
+
chord.pitches # (<music21.pitch.Pitch B-4>, <music21.pitch.Pitch D-3>, <music21.pitch.Pitch F5>, <music21.pitch.Pitch A-5>)
|
|
29
|
+
chord.bass() # <music21.pitch.Pitch D-3>
|
|
30
|
+
chord.commonName # 'minor seventh chord'
|
|
31
|
+
chord.get_root() # 'Bb'
|
|
32
|
+
chord.get_shorthand() # 'min7'
|
|
33
|
+
chord.get_bass() # 'b3'
|
|
34
|
+
|
|
35
|
+
Harte("C:(b3,5)").prettify() # 'C:min'
|
|
36
|
+
Harte("G:7").get_midi_pitches() # [67, 71, 74, 77]
|
|
37
|
+
Harte("G:7").multi_hot_encoding() # [0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1]
|
|
38
|
+
Harte("F:sus4(*5,9)").unwrap_shorthand() # ['1', '4', '9']
|
|
39
|
+
|
|
40
|
+
HarteInterval("b6").name # 'm6'
|
|
41
|
+
convert_interval("#11") # 'A4'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Harte` is a music21-rs `Chord` and `HarteInterval` a music21-rs
|
|
45
|
+
`Interval`, so every member those have works on them. with music21
|
|
46
|
+
installed and `music21_rs.install_into_music21()` called before `harte` is
|
|
47
|
+
imported, a `Harte` is a music21 `Chord` too, and goes in music21's streams
|
|
48
|
+
as harte-library's does.
|
|
49
|
+
|
|
50
|
+
## differences from harte-library
|
|
51
|
+
|
|
52
|
+
- a label that is not Harte notation raises music21-rs's `ChordException`,
|
|
53
|
+
where harte-library raises lark's parse errors. `harte.parse_harte`, the
|
|
54
|
+
lark grammar, is not included.
|
|
55
|
+
- `str()` and `repr()` give the label in its canonical spelling,
|
|
56
|
+
`Harte(C:maj7(9)/3)`. harte-library's raise `TypeError` for every label
|
|
57
|
+
but `N`.
|
|
58
|
+
- two labels are equal when they sound the same chord: `C:maj` equals
|
|
59
|
+
`C:(3,5)`. harte-library's compares the degrees as written.
|
|
60
|
+
- `bass_is_root()` answers whether the bass is the root. harte-library's
|
|
61
|
+
compares the root's name with the bass degree, and answers `False` for
|
|
62
|
+
every chord.
|
|
63
|
+
- `N` and `X` have no shorthand: `get_shorthand()` is `None` and
|
|
64
|
+
`contains_shorthand()` is `False`.
|
|
65
|
+
|
|
66
|
+
## development
|
|
67
|
+
|
|
68
|
+
harte-rs lives in the [music21-rs](https://github.com/float3/music21-rs)
|
|
69
|
+
repository, in `harte/python`, and is released with the music21-rs wheel at
|
|
70
|
+
the same version. it builds on that wheel's `_HarteLabel`, so it needs the
|
|
71
|
+
wheel built from the same checkout:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
maturin build --release --manifest-path python/Cargo.toml --out target/wheels
|
|
75
|
+
pip install target/wheels/music21_rs-*.whl ./harte/python pytest
|
|
76
|
+
python -m pytest harte/python/tests
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
the tests check every label in harte-library's coverage set against
|
|
80
|
+
`harte/data/harte_expectations.toml`, which records what harte-library makes
|
|
81
|
+
of each.
|
|
82
|
+
|
|
83
|
+
## credits
|
|
84
|
+
|
|
85
|
+
harte-rs is released under the
|
|
86
|
+
[AGPL-3.0](https://github.com/float3/music21-rs/blob/master/LICENSE). it is
|
|
87
|
+
a port of [harte-library](https://github.com/andreamust/harte-library) by
|
|
88
|
+
Andrea Poltronieri, licensed MIT; its licence is included as
|
|
89
|
+
`LICENSE-harte-library`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Harte chord notation, read into music21-rs chords.
|
|
2
|
+
|
|
3
|
+
A replacement for harte-library: the same modules and names, with the label
|
|
4
|
+
read by the `harte` Rust crate and the chord a music21-rs `Chord`.
|
|
5
|
+
|
|
6
|
+
>>> from harte.harte import Harte
|
|
7
|
+
>>> Harte("Bb:min7/b3").pitches
|
|
8
|
+
(<music21.pitch.Pitch B-4>, <music21.pitch.Pitch D-3>, <music21.pitch.Pitch F5>, <music21.pitch.Pitch A-5>)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from harte.harte import Harte
|
|
12
|
+
from harte.interval import HarteInterval
|
|
13
|
+
|
|
14
|
+
__all__ = ["Harte", "HarteInterval"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""harte-library's `Harte`: a chord read from a Harte label."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
import music21_rs
|
|
7
|
+
from music21_rs.music21_rs import _HarteLabel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _chord_class():
|
|
11
|
+
"""music21's `Chord` where `music21_rs.install_into_music21()` has put
|
|
12
|
+
music21-rs's in its place, so a `Harte` goes in music21's streams; the
|
|
13
|
+
wheel's own `Chord` otherwise."""
|
|
14
|
+
music21_chord = sys.modules.get("music21.chord")
|
|
15
|
+
installed = getattr(music21_chord, "Chord", None)
|
|
16
|
+
if isinstance(installed, type) and issubclass(installed, music21_rs.Chord):
|
|
17
|
+
return installed
|
|
18
|
+
return music21_rs.Chord
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Harte(_chord_class()):
|
|
22
|
+
"""A music21-rs `Chord` read from a Harte label, such as `C:maj7/3`.
|
|
23
|
+
|
|
24
|
+
The pitches stand in degree order above the root in octave 4, the bass an
|
|
25
|
+
octave lower where it is not the root. `N` and `X` are an empty chord.
|
|
26
|
+
A label that is not Harte notation raises `ChordException`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, chord: str, **keywords):
|
|
30
|
+
label = _HarteLabel(chord)
|
|
31
|
+
super().__init__(**keywords)
|
|
32
|
+
label.fill(self)
|
|
33
|
+
self.chord = chord
|
|
34
|
+
self._label = label
|
|
35
|
+
|
|
36
|
+
def __deepcopy__(self, *args, **kwargs):
|
|
37
|
+
return Harte(self.chord)
|
|
38
|
+
|
|
39
|
+
def __reduce__(self):
|
|
40
|
+
return (Harte, (self.chord,))
|
|
41
|
+
|
|
42
|
+
def get_degrees(self) -> Optional[List[str]]:
|
|
43
|
+
"""The degrees written in parentheses, or `None` when there are none."""
|
|
44
|
+
return self._label.degrees
|
|
45
|
+
|
|
46
|
+
def get_midi_pitches(self) -> List[int]:
|
|
47
|
+
"""The MIDI numbers of the pitches, lowest first."""
|
|
48
|
+
return sorted(pitch.midi for pitch in self.pitches)
|
|
49
|
+
|
|
50
|
+
def multi_hot_encoding(self, transpose: bool = False) -> List[int]:
|
|
51
|
+
"""Which of the twelve pitch classes the chord sounds, one flag each,
|
|
52
|
+
counted from C, or from the root with `transpose`."""
|
|
53
|
+
pitch_classes = [pitch.pitchClass for pitch in self.pitches]
|
|
54
|
+
if transpose:
|
|
55
|
+
root = self.root().pitchClass
|
|
56
|
+
pitch_classes = [(pitch_class - root) % 12 for pitch_class in pitch_classes]
|
|
57
|
+
return [1 if i in pitch_classes else 0 for i in range(12)]
|
|
58
|
+
|
|
59
|
+
def get_root(self) -> Optional[str]:
|
|
60
|
+
"""The root as written, `C#`, or `None` for no chord."""
|
|
61
|
+
return self._label.root
|
|
62
|
+
|
|
63
|
+
def get_bass(self) -> Optional[str]:
|
|
64
|
+
"""The bass as a degree above the root, `1` when none was written."""
|
|
65
|
+
return self._label.bass
|
|
66
|
+
|
|
67
|
+
def bass_is_root(self) -> bool:
|
|
68
|
+
"""Whether the bass is the root."""
|
|
69
|
+
return self._label.bass_is_root()
|
|
70
|
+
|
|
71
|
+
def contains_shorthand(self) -> bool:
|
|
72
|
+
"""Whether the label carries a shorthand."""
|
|
73
|
+
return self._label.contains_shorthand()
|
|
74
|
+
|
|
75
|
+
def get_shorthand(self) -> Optional[str]:
|
|
76
|
+
"""The shorthand, `maj7`, or `None` when there is none."""
|
|
77
|
+
return self._label.shorthand
|
|
78
|
+
|
|
79
|
+
def prettify(self) -> str:
|
|
80
|
+
"""The label rewritten with the largest shorthand its degrees contain:
|
|
81
|
+
`C:(b3,5)` is `C:min`. A label no shorthand fits comes back as written."""
|
|
82
|
+
return self._label.prettify()
|
|
83
|
+
|
|
84
|
+
def unwrap_shorthand(self) -> Optional[List[str]]:
|
|
85
|
+
"""Every degree the chord sounds where the label has a shorthand, the
|
|
86
|
+
written degrees where it has none, and `None` where it has neither."""
|
|
87
|
+
return self._label.unwrapped
|
|
88
|
+
|
|
89
|
+
def __eq__(self, other):
|
|
90
|
+
"""Two labels are equal when they sound the same chord: `C:maj` equals
|
|
91
|
+
`C:(3,5)`."""
|
|
92
|
+
if isinstance(other, Harte):
|
|
93
|
+
return self._label == other._label
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
__hash__ = None
|
|
97
|
+
|
|
98
|
+
def __repr__(self):
|
|
99
|
+
return f"Harte({self})"
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
return str(self._label)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""harte-library's `HarteInterval`: an interval read from a Harte degree."""
|
|
2
|
+
|
|
3
|
+
from music21_rs import Interval, IntervalException
|
|
4
|
+
|
|
5
|
+
from harte.utils import convert_interval
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HarteInterval(Interval):
|
|
9
|
+
"""A music21-rs `Interval` read from a Harte degree: `b6` is a minor
|
|
10
|
+
sixth. A degree that is not one raises `IntervalException`."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, harte_interval: str, **keywords) -> None:
|
|
13
|
+
self._harte_interval = harte_interval
|
|
14
|
+
try:
|
|
15
|
+
self._converted_interval = convert_interval(harte_interval)
|
|
16
|
+
except ValueError as value_error:
|
|
17
|
+
raise IntervalException("Harte Interval cannot be converted") from value_error
|
|
18
|
+
super().__init__(self._converted_interval, **keywords)
|
|
19
|
+
|
|
20
|
+
def __eq__(self, other) -> bool:
|
|
21
|
+
if isinstance(other, HarteInterval):
|
|
22
|
+
return self._harte_interval == other._harte_interval
|
|
23
|
+
return self._harte_interval == other
|
|
24
|
+
|
|
25
|
+
__hash__ = None
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f"HarteInterval({self._harte_interval})"
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
return self._harte_interval
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""harte-library's tables of Harte shorthands and the degrees they stand for."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
|
|
5
|
+
from music21_rs.music21_rs import _HarteLabel
|
|
6
|
+
|
|
7
|
+
# Each shorthand and the degrees it stands for, the root included.
|
|
8
|
+
SHORTHAND_DEGREES = {
|
|
9
|
+
name: list(degrees) for name, degrees in _HarteLabel.SHORTHAND_DEGREES
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
# The degrees `Harte.prettify` folds into each shorthand, in the order tried.
|
|
13
|
+
DEGREE_SHORTHAND_MAP = OrderedDict(
|
|
14
|
+
(tuple(degrees), name) for degrees, name in _HarteLabel.DEGREE_SHORTHANDS
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# The common shorthands and their degrees above the root.
|
|
18
|
+
SHORTHAND_DEGREE_MAP = {
|
|
19
|
+
"maj": ("3", "5"),
|
|
20
|
+
"min": ("b3", "5"),
|
|
21
|
+
"aug": ("3", "#5"),
|
|
22
|
+
"dim": ("b3", "b5"),
|
|
23
|
+
"maj7": ("3", "5", "7"),
|
|
24
|
+
"min7": ("b3", "5", "b7"),
|
|
25
|
+
"7": ("3", "5", "b7"),
|
|
26
|
+
"minmaj7": ("b3", "5", "7"),
|
|
27
|
+
"dim7": ("b3", "b5", "bb7"),
|
|
28
|
+
"hdim7": ("b3", "b5", "b7"),
|
|
29
|
+
"maj6": ("3", "5", "6"),
|
|
30
|
+
"min6": ("b3", "5", "6"),
|
|
31
|
+
"9": ("3", "5", "b7", "9"),
|
|
32
|
+
"maj9": ("3", "5", "7", "9"),
|
|
33
|
+
"min9": ("b3", "5", "b7", "9"),
|
|
34
|
+
"sus4": ("4", "5"),
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""harte-library's helpers for Harte degrees."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from music21_rs.music21_rs import _HarteLabel
|
|
6
|
+
|
|
7
|
+
from harte.mappings import SHORTHAND_DEGREES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def convert_interval(harte_interval: str) -> str:
|
|
11
|
+
"""The music21 interval a Harte degree stands for: `b3` is `m3`, `#11` is
|
|
12
|
+
`A4`. A degree that is not one raises `ValueError`."""
|
|
13
|
+
return _HarteLabel.convert_interval(harte_interval)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def degree_to_sort_key(degree: str) -> float:
|
|
17
|
+
"""Where a degree sorts among the others: by its number, a flat just
|
|
18
|
+
below it and a sharp just above."""
|
|
19
|
+
return _HarteLabel.degree_sort_key(degree)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unwrap_shorthand(harte_shorthand: str, harte_degrees: list) -> List[str]:
|
|
23
|
+
"""The degrees of a shorthand together with `harte_degrees`, ordered by
|
|
24
|
+
their number. Deprecated in harte-library; `Harte.unwrap_shorthand` is
|
|
25
|
+
what to use."""
|
|
26
|
+
assert harte_shorthand in SHORTHAND_DEGREES, "The Harte shorthand is not valid. "
|
|
27
|
+
degrees = list(set(SHORTHAND_DEGREES[harte_shorthand] + harte_degrees))
|
|
28
|
+
degrees.sort(key=lambda x: [k for k in x if k.isdigit()][0])
|
|
29
|
+
return degrees
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "hatchling.build"
|
|
3
|
+
requires = ["hatchling>=1.26"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
authors = [{ name = "hill", email = "hill@hilll.dev" }]
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Programming Language :: Python :: 3",
|
|
9
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
10
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
11
|
+
]
|
|
12
|
+
# Released with the music21-rs wheel and at its version, since it builds on
|
|
13
|
+
# that wheel's `_HarteLabel`: `harte_rs_requires_its_own_music21_rs` holds
|
|
14
|
+
# both numbers to python/Cargo.toml's.
|
|
15
|
+
dependencies = ["music21-rs==0.17.0"]
|
|
16
|
+
description = "Harte chord notation read into music21-rs chords; a replacement for harte-library"
|
|
17
|
+
keywords = ["music", "music-theory", "chords", "harte", "music21"]
|
|
18
|
+
license = "AGPL-3.0-only"
|
|
19
|
+
license-files = ["LICENSE-harte-library"]
|
|
20
|
+
name = "harte-rs"
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
requires-python = ">=3.9"
|
|
23
|
+
version = "0.17.0"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/float3/music21-rs"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["harte"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""harte-rs against harte-library.
|
|
2
|
+
|
|
3
|
+
`harte/data/harte_expectations.toml` records what harte-library, on music21,
|
|
4
|
+
makes of every label in its own coverage set; each is checked here. The other
|
|
5
|
+
cases are harte-library's own tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import pathlib
|
|
10
|
+
import pickle
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from harte.harte import Harte
|
|
15
|
+
from harte.interval import HarteInterval
|
|
16
|
+
from harte.mappings import DEGREE_SHORTHAND_MAP, SHORTHAND_DEGREES
|
|
17
|
+
from harte.utils import convert_interval, degree_to_sort_key, unwrap_shorthand
|
|
18
|
+
from music21_rs import Chord, ChordException, Interval, IntervalException
|
|
19
|
+
|
|
20
|
+
EXPECTATIONS = pathlib.Path(__file__).resolve().parents[2] / "data" / "harte_expectations.toml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cases():
|
|
24
|
+
"""The fixture's labels, where it can be read: it needs tomllib, Python
|
|
25
|
+
3.11+, and the repository, which the sdist does not carry."""
|
|
26
|
+
try:
|
|
27
|
+
import tomllib
|
|
28
|
+
except ImportError:
|
|
29
|
+
return []
|
|
30
|
+
if not EXPECTATIONS.is_file():
|
|
31
|
+
return []
|
|
32
|
+
return tomllib.loads(EXPECTATIONS.read_text(encoding="utf-8"))["chord"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CASES = _cases()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sort_parenthesised(label):
|
|
39
|
+
"""`C:maj7(#11,9)` with the degrees in its parentheses sorted, as the
|
|
40
|
+
fixture writes them."""
|
|
41
|
+
head, _, rest = label.partition("(")
|
|
42
|
+
inside, closed, tail = rest.partition(")")
|
|
43
|
+
if not closed:
|
|
44
|
+
return label
|
|
45
|
+
return f"{head}({','.join(sorted(inside.split(',')))}){tail}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _in_degree_order(degrees, pitches):
|
|
49
|
+
"""Degrees and their pitches in harte-library's degree order, its ties
|
|
50
|
+
broken by spelling: the library breaks them by set order, which is not
|
|
51
|
+
fixed from one run to the next."""
|
|
52
|
+
return sorted(zip(degrees, pitches), key=lambda pair: (degree_to_sort_key(pair[0]), pair[0]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.skipif(not CASES, reason="the fixture needs tomllib and the repository")
|
|
56
|
+
def test_the_fixture_covers_harte_librarys_coverage_set():
|
|
57
|
+
assert len(CASES) == 8064
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("case", CASES, ids=[case["label"] for case in CASES])
|
|
61
|
+
def test_every_label_builds_the_chord_harte_library_builds(case):
|
|
62
|
+
if "error" in case:
|
|
63
|
+
with pytest.raises(ChordException):
|
|
64
|
+
Harte(case["label"])
|
|
65
|
+
return
|
|
66
|
+
chord = Harte(case["label"])
|
|
67
|
+
pitches = [pitch.nameWithOctave for pitch in chord.pitches]
|
|
68
|
+
sounding = chord._label.sounding_degrees
|
|
69
|
+
assert _in_degree_order(sounding, pitches) == _in_degree_order(
|
|
70
|
+
case["degrees"], case["pitches"]
|
|
71
|
+
)
|
|
72
|
+
if case.get("root") is None:
|
|
73
|
+
assert chord.get_root() is None
|
|
74
|
+
return
|
|
75
|
+
assert chord.root().nameWithOctave == case["root"]
|
|
76
|
+
assert chord.bass().nameWithOctave == case["bass"]
|
|
77
|
+
assert _sort_parenthesised(chord.prettify()) == case["pretty"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize(
|
|
81
|
+
"label,intervals",
|
|
82
|
+
[
|
|
83
|
+
("C", ["P5", "M3"]),
|
|
84
|
+
("A", ["P5", "M3"]),
|
|
85
|
+
("C:maj", ["P5", "M3"]),
|
|
86
|
+
("C:min", ["P5", "m3"]),
|
|
87
|
+
("C:dim", ["d5", "m3"]),
|
|
88
|
+
("C:aug", ["A5", "M3"]),
|
|
89
|
+
("N", []),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_interval_extraction(label, intervals):
|
|
93
|
+
annotated = Harte(label).annotateIntervals(
|
|
94
|
+
inPlace=False, returnList=True, stripSpecifiers=False
|
|
95
|
+
)
|
|
96
|
+
assert set(intervals) == set(annotated)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize(
|
|
100
|
+
"label,pitches",
|
|
101
|
+
[
|
|
102
|
+
("F:(b3, 5, b7, 11)", ["F", "A-", "C", "E-", "B-"]),
|
|
103
|
+
("F:(b3, 11, b7, 5)", ["F", "A-", "C", "E-", "B-"]),
|
|
104
|
+
("F:maj7(#11)", ["F", "A", "C", "E", "B"]),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
def test_ordering_of_degrees(label, pitches):
|
|
108
|
+
assert [pitch.name for pitch in Harte(label).pitches] == pitches
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize(
|
|
112
|
+
"harte_interval,music21_interval",
|
|
113
|
+
[
|
|
114
|
+
(f"{accidental}{number}", f"{quality}{(number - 1) % 7 + 1}")
|
|
115
|
+
for number in range(1, 15)
|
|
116
|
+
for accidental, quality in zip(
|
|
117
|
+
("b", "", "#"),
|
|
118
|
+
("dPA" if (number - 1) % 7 + 1 in (1, 4, 5) else "mMA"),
|
|
119
|
+
)
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
def test_harte_interval(harte_interval, music21_interval):
|
|
123
|
+
assert convert_interval(harte_interval) == music21_interval
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_the_parts_of_a_label():
|
|
127
|
+
chord = Harte("Bb:min7/b3")
|
|
128
|
+
assert isinstance(chord, Chord)
|
|
129
|
+
assert chord.chord == "Bb:min7/b3"
|
|
130
|
+
assert chord.get_root() == "Bb"
|
|
131
|
+
assert chord.get_shorthand() == "min7"
|
|
132
|
+
assert chord.contains_shorthand()
|
|
133
|
+
assert chord.get_bass() == "b3"
|
|
134
|
+
assert not chord.bass_is_root()
|
|
135
|
+
assert chord.get_degrees() is None
|
|
136
|
+
assert chord.unwrap_shorthand() == ["1", "b3", "5", "b7"]
|
|
137
|
+
assert chord.root().nameWithOctave == "B-4"
|
|
138
|
+
assert chord.bass().nameWithOctave == "D-3"
|
|
139
|
+
assert chord.commonName == "minor seventh chord"
|
|
140
|
+
assert str(chord) == "Bb:min7/b3"
|
|
141
|
+
assert repr(chord) == "Harte(Bb:min7/b3)"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_midi_pitches_and_multi_hot_encoding():
|
|
145
|
+
chord = Harte("G:7")
|
|
146
|
+
assert chord.get_midi_pitches() == [67, 71, 74, 77]
|
|
147
|
+
assert chord.multi_hot_encoding() == [0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1]
|
|
148
|
+
assert chord.multi_hot_encoding(True) == [1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_degrees_and_unwrapping():
|
|
152
|
+
chord = Harte("F:sus4(*5,9)")
|
|
153
|
+
assert chord.get_degrees() == ["*5", "9"]
|
|
154
|
+
assert chord.unwrap_shorthand() == ["1", "4", "9"]
|
|
155
|
+
assert Harte("C:(b3,5)").unwrap_shorthand() == ["b3", "5"]
|
|
156
|
+
assert Harte("C").unwrap_shorthand() is None
|
|
157
|
+
assert Harte("C").bass_is_root()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_prettify():
|
|
161
|
+
assert Harte("C:(b3,5)").prettify() == "C:min"
|
|
162
|
+
assert Harte("D:(b3,5,7,9)/b3").prettify() == "D:minmaj7(9)/b3"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_no_chord():
|
|
166
|
+
for label in ("N", "X"):
|
|
167
|
+
chord = Harte(label)
|
|
168
|
+
assert chord.pitches == ()
|
|
169
|
+
assert chord.get_root() is None
|
|
170
|
+
assert chord.get_shorthand() is None
|
|
171
|
+
assert not chord.contains_shorthand()
|
|
172
|
+
assert str(chord) == "N"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_equality_is_the_chord_sounded():
|
|
176
|
+
assert Harte("C:maj") == Harte("C:(3,5)")
|
|
177
|
+
assert Harte("C:maj") != Harte("C:min")
|
|
178
|
+
assert Harte("C:maj") != Chord("C4 E4 G4")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_a_label_that_is_not_harte_notation():
|
|
182
|
+
with pytest.raises(ChordException):
|
|
183
|
+
Harte("C:foo")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_keywords_reach_the_chord():
|
|
187
|
+
assert Harte("C", quarterLength=2).quarterLength == 2.0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_copies_keep_the_label():
|
|
191
|
+
chord = Harte("Bb:min7/b3")
|
|
192
|
+
for copied in (copy.deepcopy(chord), pickle.loads(pickle.dumps(chord))):
|
|
193
|
+
assert type(copied) is Harte
|
|
194
|
+
assert str(copied) == "Bb:min7/b3"
|
|
195
|
+
assert copied.pitches == chord.pitches
|
|
196
|
+
transposed = chord.transpose("P5")
|
|
197
|
+
assert type(transposed) is Harte
|
|
198
|
+
assert transposed.get_root() == "Bb"
|
|
199
|
+
assert [pitch.nameWithOctave for pitch in transposed.pitches] == ["F5", "A-3", "C6", "E-6"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_harte_interval_class():
|
|
203
|
+
interval = HarteInterval("b6")
|
|
204
|
+
assert isinstance(interval, Interval)
|
|
205
|
+
assert interval.name == "m6"
|
|
206
|
+
assert interval.isConsonant()
|
|
207
|
+
assert str(interval) == "b6"
|
|
208
|
+
assert repr(interval) == "HarteInterval(b6)"
|
|
209
|
+
assert interval == "b6"
|
|
210
|
+
assert interval == HarteInterval("b6")
|
|
211
|
+
with pytest.raises(IntervalException):
|
|
212
|
+
HarteInterval("x")
|
|
213
|
+
with pytest.raises(ValueError):
|
|
214
|
+
convert_interval("x")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_mappings():
|
|
218
|
+
assert len(SHORTHAND_DEGREES) == 48
|
|
219
|
+
assert SHORTHAND_DEGREES["hdim7"] == ["1", "b3", "b5", "b7"]
|
|
220
|
+
assert next(iter(DEGREE_SHORTHAND_MAP.items())) == (("3", "5", "b7", "9"), "9")
|
|
221
|
+
assert unwrap_shorthand("maj", ["9"]) == ["1", "3", "5", "9"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_a_harte_goes_in_music21s_streams_once_installed():
|
|
225
|
+
"""With `install_into_music21()` called before `harte` is imported, a
|
|
226
|
+
`Harte` is music21's `Chord`, as harte-library's is."""
|
|
227
|
+
pytest.importorskip("music21.stream")
|
|
228
|
+
import subprocess
|
|
229
|
+
import sys
|
|
230
|
+
|
|
231
|
+
script = """
|
|
232
|
+
import music21_rs
|
|
233
|
+
music21_rs.install_into_music21()
|
|
234
|
+
from music21 import base, chord, stream
|
|
235
|
+
from harte.harte import Harte
|
|
236
|
+
h = Harte('C:maj7')
|
|
237
|
+
assert isinstance(h, chord.Chord) and isinstance(h, base.Music21Object)
|
|
238
|
+
s = stream.Stream()
|
|
239
|
+
s.append(h)
|
|
240
|
+
s.append(Harte('G:7/3'))
|
|
241
|
+
assert [p.nameWithOctave for p in s.pitches][-1] == 'F5'
|
|
242
|
+
copied = h.transpose(2)
|
|
243
|
+
assert copied.get_root() == 'C' and copied.pitches[0].nameWithOctave == 'D4'
|
|
244
|
+
"""
|
|
245
|
+
subprocess.run([sys.executable, "-c", script], check=True)
|