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.
@@ -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.
@@ -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
+ [![CI](https://github.com/float3/music21-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/float3/music21-rs/actions/workflows/ci.yml)
20
+ [![PyPI](https://img.shields.io/pypi/v/harte-rs.svg)](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
+ [![CI](https://github.com/float3/music21-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/float3/music21-rs/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/harte-rs.svg)](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)