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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.