chordsym 0.0.2__py3-none-any.whl
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.
- chordsym/__init__.py +28 -0
- chordsym/_grammar.py +104 -0
- chordsym/keys.py +78 -0
- chordsym/parse.py +121 -0
- chordsym/transpose.py +114 -0
- chordsym-0.0.2.dist-info/METADATA +60 -0
- chordsym-0.0.2.dist-info/RECORD +9 -0
- chordsym-0.0.2.dist-info/WHEEL +4 -0
- chordsym-0.0.2.dist-info/licenses/LICENSE +21 -0
chordsym/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""chordsym: chord symbols without dependencies — parse, pitch classes, keys, transposition.
|
|
2
|
+
|
|
3
|
+
>>> from chordsym import parse_chord, transpose_chord, estimate_key
|
|
4
|
+
>>> parse_chord("Am7"), transpose_chord("Am7", 2), estimate_key(["Am", "Dm", "E7"])
|
|
5
|
+
((9, 'minor'), 'Bm7', Key(tonic=9, minor=True))
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from chordsym.keys import Key, chord_function, estimate_key
|
|
9
|
+
from chordsym.parse import chord_tones, is_chord_token, parse_chord
|
|
10
|
+
from chordsym.transpose import (
|
|
11
|
+
key_name,
|
|
12
|
+
transpose_chord,
|
|
13
|
+
transposition_table,
|
|
14
|
+
uses_flats,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Key",
|
|
19
|
+
"chord_function",
|
|
20
|
+
"chord_tones",
|
|
21
|
+
"estimate_key",
|
|
22
|
+
"is_chord_token",
|
|
23
|
+
"key_name",
|
|
24
|
+
"parse_chord",
|
|
25
|
+
"transpose_chord",
|
|
26
|
+
"transposition_table",
|
|
27
|
+
"uses_flats",
|
|
28
|
+
]
|
chordsym/_grammar.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The chord-symbol grammar and tables, shared by the Python package and its TypeScript twin.
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for every regular expression and lookup table
|
|
4
|
+
chordsym uses. ``chordsym._codegen`` writes it to ``ts/src/grammar.generated.ts``, so the
|
|
5
|
+
TypeScript package reads the same patterns and tables; a test fails when the committed
|
|
6
|
+
TypeScript file is stale.
|
|
7
|
+
|
|
8
|
+
Every pattern here must mean the same thing in Python's ``re`` and in a JavaScript
|
|
9
|
+
``RegExp`` with the ``u`` flag. Hence:
|
|
10
|
+
|
|
11
|
+
- no ``\\d``, ``\\w``, ``\\s``, ``\\b`` (or their negations): Python's are Unicode-aware,
|
|
12
|
+
JavaScript's are not (``\\d``) or differ at the edges (``\\s``); use ``[0-9]`` and
|
|
13
|
+
:data:`WS` instead;
|
|
14
|
+
- ``.`` only with the DOTALL flag (``s`` in JavaScript), listed in :data:`PATTERNS`;
|
|
15
|
+
- whole-string matches are written by the caller (``fullmatch`` / ``^(?:...)$``), never
|
|
16
|
+
with ``$`` inside a pattern.
|
|
17
|
+
|
|
18
|
+
``tests/test_grammar.py`` enforces the first rule; ``fixtures/cases.json`` checks the rest
|
|
19
|
+
by running the same cases on both sides.
|
|
20
|
+
|
|
21
|
+
>>> sorted(PATTERNS)[:3]
|
|
22
|
+
['chord', 'diminished', 'major_seventh']
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
#: Whitespace, as a character-class body: exactly the characters Python's ``str.isspace``
|
|
26
|
+
#: accepts (what ``str.strip()`` removes), written out so a JavaScript ``RegExp`` (whose
|
|
27
|
+
#: ``\s`` lacks ``\x1c``-``\x1f`` and ``\x85`` and adds ``\ufeff``) matches the same set.
|
|
28
|
+
WS = (
|
|
29
|
+
"\\t\\n\\x0b\\x0c\\r\\x1c\\x1d\\x1e\\x1f \\x85\\xa0\\u1680"
|
|
30
|
+
"\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: name -> (pattern source, flags). Flags: ``"s"`` means DOTALL.
|
|
34
|
+
PATTERNS: dict[str, tuple[str, str]] = {
|
|
35
|
+
# Is this token a chord symbol? (used with a whole-string match)
|
|
36
|
+
"chord": (
|
|
37
|
+
r"[*.]{0,3}\(?" # leading marks: *D, ...Dm6
|
|
38
|
+
r"[A-G][#b]?" # root
|
|
39
|
+
r"(?:maj|min|dim|aug|sus|add|m|M|\+|°|º|ø)?" # quality
|
|
40
|
+
r"[0-9]{0,2}[M+\-]?" # extension: 7, 9, 13; Brazilian 7M, 7+, 5-
|
|
41
|
+
r"(?:sus[0-9]{0,2}|(?:maj|add|dim|aug|no|m|b|#|\+|-)[0-9]{1,2}[+\-]?)*" # b5, add9
|
|
42
|
+
r"(?:/(?:[#b]?[0-9]{1,2}[+\-#b]?|sus[0-9]{0,2}))*" # stacked: 7/9, 5-/7, 7/sus4
|
|
43
|
+
r"(?:\([^()" + WS + r"]{1,10}\))*" # parenthesised alterations: (#9), 7(4)(9)
|
|
44
|
+
r"(?:/[A-G][#b]?)?" # bass
|
|
45
|
+
r"\)?\*?",
|
|
46
|
+
"",
|
|
47
|
+
),
|
|
48
|
+
# Lead marks, root letter, accidental, the rest (used with a whole-string match).
|
|
49
|
+
"root": (r"[*.(" + WS + r"]*([A-G])([#b]?)(.*)", "s"),
|
|
50
|
+
# A diminished quality, at the start of what follows the root.
|
|
51
|
+
"diminished": (r"(?:dim|°|º|ø|m(?:in)?7?\(?b5)", ""),
|
|
52
|
+
# A power chord's 5 (not 5 of 13 or 55), at the start of what follows the root.
|
|
53
|
+
"power": (r"5(?![0-9])", ""),
|
|
54
|
+
# A note name: letter and optional accidental, at the start of a string.
|
|
55
|
+
"note": (r"([A-G])([#b]?)", ""),
|
|
56
|
+
# Seventh and extension markers, searched anywhere in what follows the root.
|
|
57
|
+
"major_seventh": (r"(maj|M|Δ)(7|9|11|13)", ""),
|
|
58
|
+
"seventh": (r"(?<!add)(7|9|11|13)", ""),
|
|
59
|
+
"sixth": (r"(?<![0-9])6", ""),
|
|
60
|
+
"ninth": (r"9|add2", ""),
|
|
61
|
+
# Root, suffix, and a final /bass with what trails it (used with a whole-string match).
|
|
62
|
+
# The bass is only a final ``/note`` (``D/F#``); a slash inside the extension
|
|
63
|
+
# (``C6/9``, ``Bm5-/7``) belongs to the chord's suffix.
|
|
64
|
+
"transposable": (
|
|
65
|
+
r"([*.(" + WS + r"]*)([A-G][#b]?)(.*?)(?:/([A-G][#b]?)([^/A-Za-z0-9#]*))?",
|
|
66
|
+
"s",
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: Natural-note pitch classes and accidental offsets.
|
|
71
|
+
PITCH_CLASSES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
72
|
+
ACCIDENTALS = {"#": 1, "b": -1, "": 0}
|
|
73
|
+
|
|
74
|
+
#: Note names by pitch class, spelled with sharps or with flats.
|
|
75
|
+
SHARP_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
|
|
76
|
+
FLAT_NAMES = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
|
|
77
|
+
|
|
78
|
+
#: Keys whose signatures use flats, as tonic pitch classes: F Bb Eb Ab Db Gb; d g c f bb eb.
|
|
79
|
+
FLAT_MAJOR_TONICS = (5, 10, 3, 8, 1, 6)
|
|
80
|
+
FLAT_MINOR_TONICS = (2, 7, 0, 5, 10, 3)
|
|
81
|
+
|
|
82
|
+
#: Qualities each scale degree (semitones above the tonic) takes in a key.
|
|
83
|
+
MAJOR_KEY = {
|
|
84
|
+
0: ("major",),
|
|
85
|
+
2: ("minor",),
|
|
86
|
+
4: ("minor",),
|
|
87
|
+
5: ("major",),
|
|
88
|
+
7: ("major",),
|
|
89
|
+
9: ("minor",),
|
|
90
|
+
11: ("diminished", "minor"),
|
|
91
|
+
}
|
|
92
|
+
MINOR_KEY = {
|
|
93
|
+
0: ("minor",),
|
|
94
|
+
2: ("diminished", "minor"),
|
|
95
|
+
3: ("major",),
|
|
96
|
+
5: ("minor",),
|
|
97
|
+
7: ("minor", "major"), # the harmonic-minor V is at home in a minor key
|
|
98
|
+
8: ("major",),
|
|
99
|
+
10: ("major",),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#: Key estimation: score of a chord that is the key's tonic, a diatonic chord, and the
|
|
103
|
+
#: bonus for a song starting or ending on the tonic chord.
|
|
104
|
+
KEY_WEIGHTS = {"tonic": 2.0, "diatonic": 1.0, "outside": 0.0, "ends": 2.0}
|
chordsym/keys.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""What a chord does in a key: estimate a song's key from its chords, label chords by function.
|
|
2
|
+
|
|
3
|
+
>>> key = estimate_key(["G", "D", "Em", "C", "G", "D", "G"])
|
|
4
|
+
>>> key
|
|
5
|
+
Key(tonic=7, minor=False)
|
|
6
|
+
>>> [chord_function(symbol, key) for symbol in ["G", "Em", "C", "Bb", "N.C."]]
|
|
7
|
+
['tonic', 'diatonic', 'diatonic', 'outside', None]
|
|
8
|
+
>>> estimate_key(["Am", "Dm", "E7", "Am"])
|
|
9
|
+
Key(tonic=9, minor=True)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from chordsym import _grammar
|
|
18
|
+
from chordsym.parse import parse_chord
|
|
19
|
+
|
|
20
|
+
_MAJOR_KEY = {degree: set(qs) for degree, qs in _grammar.MAJOR_KEY.items()}
|
|
21
|
+
_MINOR_KEY = {degree: set(qs) for degree, qs in _grammar.MINOR_KEY.items()}
|
|
22
|
+
_WEIGHTS = _grammar.KEY_WEIGHTS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Key:
|
|
27
|
+
"""A key: its tonic as a pitch class (C = 0, C# = 1, ... B = 11), major or minor."""
|
|
28
|
+
|
|
29
|
+
tonic: int
|
|
30
|
+
minor: bool = False
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
if isinstance(self.tonic, bool) or not isinstance(self.tonic, int):
|
|
34
|
+
raise TypeError(f"Key tonic must be an int pitch class, not {self.tonic!r}")
|
|
35
|
+
if not 0 <= self.tonic < 12:
|
|
36
|
+
raise ValueError(f"Key tonic must be 0-11 (C = 0), not {self.tonic}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _function(parsed: tuple[int, str], key: Key) -> str:
|
|
40
|
+
root, quality = parsed
|
|
41
|
+
degree = (root - key.tonic) % 12
|
|
42
|
+
qualities = (_MINOR_KEY if key.minor else _MAJOR_KEY).get(degree)
|
|
43
|
+
if qualities is None or (quality != "open" and quality not in qualities):
|
|
44
|
+
return "outside"
|
|
45
|
+
return "tonic" if degree == 0 else "diatonic"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def chord_function(symbol: str, key: Key) -> str | None:
|
|
49
|
+
"""``"tonic"``, ``"diatonic"`` or ``"outside"`` in ``key``; None if ``symbol`` is not a chord."""
|
|
50
|
+
parsed = parse_chord(symbol)
|
|
51
|
+
return None if parsed is None else _function(parsed, key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def estimate_key(symbols: Iterable[str]) -> Key | None:
|
|
55
|
+
"""The key that best explains a song's chords, in order; None if there are no chords.
|
|
56
|
+
|
|
57
|
+
Each chord scores for a key when it belongs to it, and more when it is its
|
|
58
|
+
tonic; a song that starts or ends on a key's tonic chord scores extra for
|
|
59
|
+
that key. Ties go to the major key, and to the lower tonic.
|
|
60
|
+
"""
|
|
61
|
+
chords = [parsed for parsed in map(parse_chord, symbols) if parsed]
|
|
62
|
+
if not chords:
|
|
63
|
+
return None
|
|
64
|
+
best, best_score = None, float("-inf")
|
|
65
|
+
for tonic in range(12):
|
|
66
|
+
for minor in (False, True):
|
|
67
|
+
key = Key(tonic, minor)
|
|
68
|
+
# A plain left-to-right sum, as the TypeScript twin does: the builtin sum()
|
|
69
|
+
# compensates float rounding (Python 3.12+), which would break ties differently.
|
|
70
|
+
score = 0.0
|
|
71
|
+
for parsed in chords:
|
|
72
|
+
score += _WEIGHTS[_function(parsed, key)]
|
|
73
|
+
for parsed in (chords[0], chords[-1]):
|
|
74
|
+
if _function(parsed, key) == "tonic":
|
|
75
|
+
score += _WEIGHTS["ends"]
|
|
76
|
+
if score > best_score:
|
|
77
|
+
best, best_score = key, score
|
|
78
|
+
return best
|
chordsym/parse.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Read a chord symbol: is it one, what is its root and quality, which pitch classes it sounds.
|
|
2
|
+
|
|
3
|
+
Pure standard library. A pitch class is an int 0-11 (C = 0, C# = 1, ... B = 11).
|
|
4
|
+
|
|
5
|
+
>>> is_chord_token("F#m7b5"), is_chord_token("Bridge")
|
|
6
|
+
(True, False)
|
|
7
|
+
>>> parse_chord("Bbmaj7/D")
|
|
8
|
+
(10, 'major')
|
|
9
|
+
>>> chord_tones("Cmaj7"), chord_tones("Am/G"), chord_tones("F#m7b5")
|
|
10
|
+
((0, 4, 7, 11), (7, 9, 0, 4), (6, 9, 0, 4))
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from chordsym import _grammar
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _compile(name: str) -> re.Pattern:
|
|
21
|
+
source, flags = _grammar.PATTERNS[name]
|
|
22
|
+
return re.compile(source, re.DOTALL if "s" in flags else 0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_CHORD_RE = _compile("chord")
|
|
26
|
+
_ROOT_RE = _compile("root")
|
|
27
|
+
_DIMINISHED_RE = _compile("diminished")
|
|
28
|
+
_POWER_RE = _compile("power")
|
|
29
|
+
_NOTE_RE = _compile("note")
|
|
30
|
+
_MAJOR_SEVENTH_RE = _compile("major_seventh")
|
|
31
|
+
_SEVENTH_RE = _compile("seventh")
|
|
32
|
+
_SIXTH_RE = _compile("sixth")
|
|
33
|
+
_NINTH_RE = _compile("ninth")
|
|
34
|
+
_PITCH_CLASSES = _grammar.PITCH_CLASSES
|
|
35
|
+
_ACCIDENTALS = _grammar.ACCIDENTALS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_chord_token(token: str) -> bool:
|
|
39
|
+
"""True if ``token`` reads as a chord symbol (``G``, ``F#m7b5``, ``E7(#9)``, ``C/G``).
|
|
40
|
+
|
|
41
|
+
>>> is_chord_token("Bb7(4)(9)"), is_chord_token("A7/sus4"), is_chord_token("Bridge")
|
|
42
|
+
(True, True, False)
|
|
43
|
+
"""
|
|
44
|
+
return bool(_CHORD_RE.fullmatch(token))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_chord(symbol: str) -> tuple[int, str] | None:
|
|
48
|
+
"""``(root pitch class, quality)``, or None for what is not a chord.
|
|
49
|
+
|
|
50
|
+
The quality is ``"major"``, ``"minor"``, ``"diminished"``, or ``"open"`` (a
|
|
51
|
+
suspended or power chord, which has no third). A bass note is ignored. What
|
|
52
|
+
the chart parser would not read as a chord (:func:`is_chord_token`) is not one.
|
|
53
|
+
|
|
54
|
+
>>> parse_chord("F#m7"), parse_chord("Bbmaj7/D"), parse_chord("A7sus4"), parse_chord("Bm7b5")
|
|
55
|
+
((6, 'minor'), (10, 'major'), (9, 'open'), (11, 'diminished'))
|
|
56
|
+
>>> parse_chord("C-7"), parse_chord("Bridge")
|
|
57
|
+
((0, 'minor'), None)
|
|
58
|
+
"""
|
|
59
|
+
symbol = symbol.strip()
|
|
60
|
+
match = _ROOT_RE.fullmatch(symbol)
|
|
61
|
+
if not (match and is_chord_token(symbol)):
|
|
62
|
+
return None
|
|
63
|
+
letter, accidental, rest = match.groups()
|
|
64
|
+
root = (_PITCH_CLASSES[letter] + _ACCIDENTALS[accidental]) % 12
|
|
65
|
+
if rest.startswith(("maj", "M")):
|
|
66
|
+
quality = "major"
|
|
67
|
+
elif _DIMINISHED_RE.match(rest):
|
|
68
|
+
quality = "diminished"
|
|
69
|
+
elif rest.startswith(("min", "m", "-")): # "C-7" is jazz for Cm7
|
|
70
|
+
quality = "minor"
|
|
71
|
+
elif "sus" in rest or _POWER_RE.match(rest):
|
|
72
|
+
quality = "open"
|
|
73
|
+
else:
|
|
74
|
+
quality = "major"
|
|
75
|
+
return root, quality
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _note(text: str) -> int | None:
|
|
79
|
+
m = _NOTE_RE.match(text.strip())
|
|
80
|
+
if not m:
|
|
81
|
+
return None
|
|
82
|
+
return (_PITCH_CLASSES[m.group(1)] + _ACCIDENTALS[m.group(2)]) % 12
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def chord_tones(symbol: str) -> tuple[int, ...] | None:
|
|
86
|
+
"""The chord's pitch classes, bass first (the root, or the bass of a slash chord).
|
|
87
|
+
|
|
88
|
+
Triads by quality (``m``, ``dim``, ``aug``/``+``, ``sus2``, ``sus4``, ``5``), then
|
|
89
|
+
sevenths (``7``, ``maj7``, ``dim7``, ``m7b5``), sixths and added ninths. ``None``
|
|
90
|
+
for what :func:`chordsym.parse_chord` does not read as a chord.
|
|
91
|
+
"""
|
|
92
|
+
parsed = parse_chord(symbol)
|
|
93
|
+
if parsed is None:
|
|
94
|
+
return None
|
|
95
|
+
root, quality = parsed
|
|
96
|
+
head, _, bass_text = symbol.strip().partition("/")
|
|
97
|
+
rest = _NOTE_RE.sub("", head, count=1)
|
|
98
|
+
third = {"minor": 3, "diminished": 3}.get(quality, 4)
|
|
99
|
+
fifth = 6 if quality == "diminished" else 7
|
|
100
|
+
if "aug" in rest or rest.startswith("+"):
|
|
101
|
+
fifth = 8
|
|
102
|
+
intervals = [0, third, fifth]
|
|
103
|
+
if quality == "open": # suspended or power: no third
|
|
104
|
+
intervals = (
|
|
105
|
+
[0, 7] if _POWER_RE.match(rest) else [0, 2 if "sus2" in rest else 5, 7]
|
|
106
|
+
)
|
|
107
|
+
if "dim7" in rest or rest in ("°7", "o7"):
|
|
108
|
+
intervals.append(9)
|
|
109
|
+
elif _MAJOR_SEVENTH_RE.search(rest):
|
|
110
|
+
intervals.append(11)
|
|
111
|
+
elif _SEVENTH_RE.search(rest):
|
|
112
|
+
intervals.append(10)
|
|
113
|
+
if _SIXTH_RE.search(rest):
|
|
114
|
+
intervals.append(9)
|
|
115
|
+
if _NINTH_RE.search(rest) and 2 not in intervals:
|
|
116
|
+
intervals.append(2)
|
|
117
|
+
tones = list(dict.fromkeys((root + i) % 12 for i in intervals))
|
|
118
|
+
bass = _note(bass_text) if bass_text else None
|
|
119
|
+
if bass is not None:
|
|
120
|
+
tones = [bass] + [t for t in tones if t != bass]
|
|
121
|
+
return tuple(tones)
|
chordsym/transpose.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Move chord symbols by semitones, spelled for the target key.
|
|
2
|
+
|
|
3
|
+
>>> transpose_chord("F#m7", 1), transpose_chord("C/E", 3, flats=True)
|
|
4
|
+
('Gm7', 'Eb/G')
|
|
5
|
+
>>> transposition_table(["G", "D", "Em", "C", "G"], 3)
|
|
6
|
+
{'semitones': 3, 'key': 'Bb', 'chords': {'G': 'Bb', 'D': 'F', 'Em': 'Gm', 'C': 'Eb'}}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from chordsym import _grammar
|
|
14
|
+
from chordsym.keys import Key, estimate_key
|
|
15
|
+
from chordsym.parse import _ACCIDENTALS, _PITCH_CLASSES, _compile, parse_chord
|
|
16
|
+
|
|
17
|
+
_SHARP_NAMES = _grammar.SHARP_NAMES
|
|
18
|
+
_FLAT_NAMES = _grammar.FLAT_NAMES
|
|
19
|
+
_FLAT_MAJOR_TONICS = frozenset(_grammar.FLAT_MAJOR_TONICS)
|
|
20
|
+
_FLAT_MINOR_TONICS = frozenset(_grammar.FLAT_MINOR_TONICS)
|
|
21
|
+
#: Groups: lead, root, rest, bass, tail.
|
|
22
|
+
_TRANSPOSABLE_RE = _compile("transposable")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_semitones(semitones) -> None:
|
|
26
|
+
if isinstance(semitones, bool) or not isinstance(semitones, int):
|
|
27
|
+
raise TypeError(f"semitones must be an int, not {semitones!r}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pitch_class(name: str) -> int:
|
|
31
|
+
return (_PITCH_CLASSES[name[0]] + _ACCIDENTALS[name[1:]]) % 12
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def uses_flats(key: Key | None) -> bool:
|
|
35
|
+
"""Whether chords in ``key`` are spelled with flats (Bb, Eb) rather than sharps.
|
|
36
|
+
|
|
37
|
+
>>> uses_flats(Key(5)), uses_flats(Key(7)), uses_flats(Key(2, minor=True)), uses_flats(None)
|
|
38
|
+
(True, False, True, False)
|
|
39
|
+
"""
|
|
40
|
+
if key is None:
|
|
41
|
+
return False
|
|
42
|
+
return key.tonic in (_FLAT_MINOR_TONICS if key.minor else _FLAT_MAJOR_TONICS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def key_name(key: Key, *, flats: bool | None = None) -> str:
|
|
46
|
+
"""``"G"``, ``"Bb"``, ``"F#m"``: the key spelled as its tonic chord.
|
|
47
|
+
|
|
48
|
+
>>> key_name(Key(10)), key_name(Key(6, minor=True)), key_name(Key(6, minor=True), flats=True)
|
|
49
|
+
('Bb', 'F#m', 'Gbm')
|
|
50
|
+
"""
|
|
51
|
+
flats = uses_flats(key) if flats is None else flats
|
|
52
|
+
return (_FLAT_NAMES if flats else _SHARP_NAMES)[key.tonic] + (
|
|
53
|
+
"m" if key.minor else ""
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def transpose_chord(symbol: str, semitones: int, *, flats: bool = False) -> str:
|
|
58
|
+
"""``symbol`` moved by ``semitones``: root and bass move, the rest is kept as written.
|
|
59
|
+
|
|
60
|
+
``flats`` spells moved notes with flats (choose it from the target key with
|
|
61
|
+
:func:`uses_flats`). What is not a chord comes back unchanged.
|
|
62
|
+
|
|
63
|
+
>>> transpose_chord("G", 2), transpose_chord("F#m7", 1), transpose_chord("C/E", 3, flats=True)
|
|
64
|
+
('A', 'Gm7', 'Eb/G')
|
|
65
|
+
>>> transpose_chord("Bbmaj7", -1, flats=True), transpose_chord("N.C.", 4), transpose_chord("Am", 0)
|
|
66
|
+
('Amaj7', 'N.C.', 'Am')
|
|
67
|
+
>>> [transpose_chord(c, 2) for c in ("D7/9", "Bm5-/7", "C6/9", "C(4/9)", "(Em7)", "Am7/G)")]
|
|
68
|
+
['E7/9', 'C#m5-/7', 'D6/9', 'D(4/9)', '(F#m7)', 'Bm7/A)']
|
|
69
|
+
"""
|
|
70
|
+
_check_semitones(semitones)
|
|
71
|
+
if semitones % 12 == 0 or parse_chord(symbol) is None:
|
|
72
|
+
return symbol
|
|
73
|
+
match = _TRANSPOSABLE_RE.fullmatch(symbol)
|
|
74
|
+
if match is None:
|
|
75
|
+
return symbol
|
|
76
|
+
names = _FLAT_NAMES if flats else _SHARP_NAMES
|
|
77
|
+
|
|
78
|
+
def moved(name: str) -> str:
|
|
79
|
+
return names[(_pitch_class(name) + semitones) % 12]
|
|
80
|
+
|
|
81
|
+
lead, root, rest, bass, tail = match.groups()
|
|
82
|
+
out = lead + moved(root) + rest
|
|
83
|
+
if bass:
|
|
84
|
+
out += "/" + moved(bass) + (tail or "")
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def transposition_table(
|
|
89
|
+
symbols: Iterable[str], semitones: int, *, key: Key | None = None
|
|
90
|
+
) -> dict:
|
|
91
|
+
"""The chords of a song moved by ``semitones``, spelled for the new key.
|
|
92
|
+
|
|
93
|
+
``symbols`` are the song's chords in order (they give the key when ``key`` is
|
|
94
|
+
not given). Returns ``{"semitones", "key", "chords"}``: the new key's name
|
|
95
|
+
(``""`` when there are no chords) and ``{original: transposed}`` over the
|
|
96
|
+
distinct chords, in order of first appearance.
|
|
97
|
+
|
|
98
|
+
>>> transposition_table(["G", "D", "Em", "C", "G"], 3)
|
|
99
|
+
{'semitones': 3, 'key': 'Bb', 'chords': {'G': 'Bb', 'D': 'F', 'Em': 'Gm', 'C': 'Eb'}}
|
|
100
|
+
"""
|
|
101
|
+
_check_semitones(semitones)
|
|
102
|
+
symbols = list(symbols)
|
|
103
|
+
key = key or estimate_key(symbols)
|
|
104
|
+
target = Key((key.tonic + semitones) % 12, key.minor) if key else None
|
|
105
|
+
flats = uses_flats(target)
|
|
106
|
+
chords = {
|
|
107
|
+
symbol: transpose_chord(symbol, semitones, flats=flats)
|
|
108
|
+
for symbol in dict.fromkeys(symbols)
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
"semitones": semitones,
|
|
112
|
+
"key": key_name(target) if target else "",
|
|
113
|
+
"chords": chords,
|
|
114
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chordsym
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Chord symbols without dependencies: parse, pitch classes, key estimation, transposition
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/chordsym
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/chordsym
|
|
7
|
+
Project-URL: Documentation, https://thorwhalen.github.io/chordsym
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chords,harmony,music,transposition
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# chordsym
|
|
23
|
+
|
|
24
|
+
Chord symbols without dependencies (standard library only): is this token a chord, which root and quality, which pitch classes, which key does a song's chord list suggest, and how do the chords read moved by N semitones.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
>>> from chordsym import parse_chord, chord_tones, transpose_chord, transposition_table, estimate_key
|
|
28
|
+
>>> parse_chord("F#m7"), chord_tones("Cmaj7")
|
|
29
|
+
((6, 'minor'), (0, 4, 7, 11))
|
|
30
|
+
>>> transpose_chord("C/E", 3, flats=True)
|
|
31
|
+
'Eb/G'
|
|
32
|
+
>>> transposition_table(["G", "D", "Em", "C"], 3)["chords"]
|
|
33
|
+
{'G': 'Bb', 'D': 'F', 'Em': 'Gm', 'C': 'Eb'}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
It reads the spellings found in scraped chord charts (`C6/9`, `Bm5-/7`, Brazilian `7M`, `E7(#9)`, `D/F#`), not only textbook ones. Pitch classes are ints 0-11 (C = 0).
|
|
37
|
+
|
|
38
|
+
## In the browser: the TypeScript twin
|
|
39
|
+
|
|
40
|
+
The same functions ship to npm as `chordsym` (in `ts/`), with camelCase names and keyword arguments as an options object:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { parseChord, transposeChord, estimateKey } from "chordsym";
|
|
44
|
+
|
|
45
|
+
parseChord("Am7"); // [9, "minor"]
|
|
46
|
+
transposeChord("C/E", 3, { flats: true }); // "Eb/G"
|
|
47
|
+
estimateKey(["Am", "Dm", "E7"]); // { tonic: 9, minor: true }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The two are kept identical by construction, not by care:
|
|
51
|
+
|
|
52
|
+
- **One grammar.** Every regular expression and table lives in `chordsym/_grammar.py`, written in the subset of regex syntax that means the same in Python and JavaScript (no `\d`, `\s`, `\w`, `\b`). `python -m chordsym._codegen` writes it to `ts/src/grammar.generated.ts`, and a test fails if that file is stale.
|
|
53
|
+
- **One set of golden cases.** `fixtures/cases.json` holds about 1,500 calls with their expected results, errors included (`ValueError` in Python is `RangeError` in TypeScript). pytest and vitest both run every one and compare them as JSON text, so order matters. To add a case, append it and run `python -m chordsym._fixtures`, which records the Python results; review the diff, since a changed expectation is a behaviour change on both sides.
|
|
54
|
+
- **A wide net before releases.** `python scripts/twin_differential.py` (after `cd ts && npm run build`) runs about 75,000 generated chord spellings and 3,000 songs through both and reports any difference.
|
|
55
|
+
|
|
56
|
+
Where the languages differ, the TypeScript side says so in its types: `transpositionTable(...).chords` is a `Map` (a plain object would reorder integer-like keys), and a `Key` is a plain `{ tonic, minor }` object checked by every function that takes one (`keyEquals` compares two).
|
|
57
|
+
|
|
58
|
+
Versions: an npm release carries the version of the Python release it was cut from (lockstep at release points, so `chordsym@X.Y.Z` on npm and PyPI are the same behaviour).
|
|
59
|
+
|
|
60
|
+
Seeded from songleaf (thorwhalen/songleaf#31). Planned: dialects (Harte, Chordonomicon, Nashville/Roman) as strategies; pychord and music21 as test oracles only.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
chordsym/__init__.py,sha256=TGiEWWrKbYgGJdknM1HOJJV3MTfkP2cekJ69kLR3sAo,752
|
|
2
|
+
chordsym/_grammar.py,sha256=IcIZo_7hrUaYEKj46TNC_7UZvRDLZbfEJkgbvXm2T9U,4632
|
|
3
|
+
chordsym/keys.py,sha256=YVKR1cIWS_ziMggubL-A6xkO9gc4X7Tsi2uW-zILWe4,3016
|
|
4
|
+
chordsym/parse.py,sha256=L8Fcn4Vqb02mgbpI2Ncg3iLU9wdsTQcda00EMceOx2E,4274
|
|
5
|
+
chordsym/transpose.py,sha256=y_8JayLP_rKm62FOVHKqDgngMHjozyeLrQvurrlSPKU,4261
|
|
6
|
+
chordsym-0.0.2.dist-info/METADATA,sha256=6dQy78xWcYIzNf_3X_qpksRiOoPzXSKC8Yxr2nUJ_WE,3628
|
|
7
|
+
chordsym-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
chordsym-0.0.2.dist-info/licenses/LICENSE,sha256=ekE13yCE9fWWEJfP1NPnUJabZ_Wwrc6coKJV2ZZx27w,1068
|
|
9
|
+
chordsym-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
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.
|