hamonpy 0.3.0__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.
- hamonpy/__init__.py +5 -0
- hamonpy/__main__.py +5 -0
- hamonpy/adapters/__init__.py +20 -0
- hamonpy/adapters/bps_fh.py +161 -0
- hamonpy/adapters/choro.py +141 -0
- hamonpy/adapters/dcml.py +383 -0
- hamonpy/adapters/dcml_expanded.py +168 -0
- hamonpy/adapters/dezrann.py +208 -0
- hamonpy/adapters/dilemma.py +321 -0
- hamonpy/adapters/flexohr.py +319 -0
- hamonpy/adapters/formats.py +363 -0
- hamonpy/adapters/harm.py +201 -0
- hamonpy/adapters/harte.py +439 -0
- hamonpy/adapters/humdrum.py +268 -0
- hamonpy/adapters/ireal.py +239 -0
- hamonpy/adapters/jams.py +198 -0
- hamonpy/adapters/kp.py +91 -0
- hamonpy/adapters/mei.py +288 -0
- hamonpy/adapters/ms3_adapter.py +90 -0
- hamonpy/adapters/music21_adapter.py +514 -0
- hamonpy/adapters/music21_converter.py +87 -0
- hamonpy/adapters/musicxml.py +115 -0
- hamonpy/adapters/partitura_adapter.py +148 -0
- hamonpy/adapters/rock_corpus.py +160 -0
- hamonpy/adapters/romantext.py +75 -0
- hamonpy/adapters/treebank.py +79 -0
- hamonpy/ast.py +286 -0
- hamonpy/capability.py +296 -0
- hamonpy/cli.py +540 -0
- hamonpy/datasets.py +318 -0
- hamonpy/export.py +293 -0
- hamonpy/generated/__init__.py +0 -0
- hamonpy/generated/antlr/__init__.py +0 -0
- hamonpy/generated/antlr/hamonLexer.interp +185 -0
- hamonpy/generated/antlr/hamonLexer.py +354 -0
- hamonpy/generated/antlr/hamonLexer.tokens +81 -0
- hamonpy/generated/antlr/hamonParser.interp +167 -0
- hamonpy/generated/antlr/hamonParser.py +3841 -0
- hamonpy/generated/antlr/hamonParser.tokens +81 -0
- hamonpy/generated/antlr/hamonParserListener.py +408 -0
- hamonpy/generated/antlr/hamonParserVisitor.py +233 -0
- hamonpy/normalize.py +683 -0
- hamonpy/parse.py +56 -0
- hamonpy/parse_visitor.py +289 -0
- hamonpy/report.py +604 -0
- hamonpy/serialize.py +169 -0
- hamonpy/validate.py +54 -0
- hamonpy-0.3.0.dist-info/METADATA +84 -0
- hamonpy-0.3.0.dist-info/RECORD +53 -0
- hamonpy-0.3.0.dist-info/WHEEL +5 -0
- hamonpy-0.3.0.dist-info/entry_points.txt +2 -0
- hamonpy-0.3.0.dist-info/licenses/LICENSE +232 -0
- hamonpy-0.3.0.dist-info/top_level.txt +1 -0
hamonpy/__init__.py
ADDED
hamonpy/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""We collect adapters for MEI, MusicXML, Humdrum/**kern, ABC, LilyPond, MuseScore, and music21."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Dict, List
|
|
7
|
+
|
|
8
|
+
from ..ast import HarmonyGroup
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Adapter:
|
|
12
|
+
name: str
|
|
13
|
+
from_: Callable[[Any], List[HarmonyGroup]]
|
|
14
|
+
to: Callable[[List[HarmonyGroup]], Any]
|
|
15
|
+
|
|
16
|
+
registry: Dict[str, Adapter] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(adapter: Adapter) -> None:
|
|
20
|
+
registry[adapter.name] = adapter
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""BPS-FH adapter — Beethoven Piano Sonatas Functional Harmony (Chen & Su) → HamonSequence.
|
|
2
|
+
|
|
3
|
+
The BPS-FH dataset (https://github.com/Tsung-Ping/functional-harmony) ships one
|
|
4
|
+
``chords.xlsx`` per movement with seven unlabelled columns:
|
|
5
|
+
|
|
6
|
+
onset | offset | key | degree | quality | inversion | roman
|
|
7
|
+
|
|
8
|
+
where ``key`` is a letter (upper = major, lower = minor) with ``-`` = flat / ``+`` =
|
|
9
|
+
sharp (e.g. ``A-`` = A♭ major, ``c+`` = C♯ minor); ``degree`` is a scale degree
|
|
10
|
+
(``1``–``7``, optionally ``-``/``+`` altered and ``P/S`` for a secondary, e.g. ``5/5``);
|
|
11
|
+
``quality`` ∈ {M, m, D7, M7, m7, d, d7, h7, a, a6}; ``inversion`` ∈ {0,1,2,3}; and the
|
|
12
|
+
last column is the analysts' Roman-numeral string.
|
|
13
|
+
|
|
14
|
+
This adapter reconstructs a **canonical HAMON Roman surface** from the structured
|
|
15
|
+
``degree``/``quality``/``inversion`` columns (so ``°``/``ø``/``+`` and figured-bass tails
|
|
16
|
+
match HAMON's grammar), parses it into the rn/degree layer, attaches each chord's onset
|
|
17
|
+
as a time ``Position``, and turns key changes into ``TonalRegion``s (first = home key,
|
|
18
|
+
later = modulation). Augmented-sixth chords (``a6``: It/Ger/Fr) have no HAMON Roman
|
|
19
|
+
vocabulary yet — their original label (``It+6``…) is preserved as the surface (text).
|
|
20
|
+
|
|
21
|
+
Reading ``.xlsx`` needs ``openpyxl`` (``pip install -e ./hamonpy[bps]``).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import replace
|
|
26
|
+
from fractions import Fraction as _Fraction
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
from hamonpy.ast import (
|
|
31
|
+
Fraction, HamonSequence, HarmonyGroup, HarmonyLabel, Key, PitchClass,
|
|
32
|
+
Position, RenderingHints, TextSemantic, TonalRegion,
|
|
33
|
+
)
|
|
34
|
+
from hamonpy.parse import parse_hamon_sequence
|
|
35
|
+
|
|
36
|
+
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII"}
|
|
37
|
+
|
|
38
|
+
# quality -> (case, is_seventh, quality_glyph)
|
|
39
|
+
_QUALITY = {
|
|
40
|
+
"M": ("upper", False, ""),
|
|
41
|
+
"m": ("lower", False, ""),
|
|
42
|
+
"a": ("upper", False, "+"),
|
|
43
|
+
"d": ("lower", False, "°"),
|
|
44
|
+
"D7": ("upper", True, ""),
|
|
45
|
+
"M7": ("upper", True, ""),
|
|
46
|
+
"m7": ("lower", True, ""),
|
|
47
|
+
"d7": ("lower", True, "°"),
|
|
48
|
+
"h7": ("lower", True, "ø"),
|
|
49
|
+
}
|
|
50
|
+
_TRIAD_FIG = {0: "", 1: "6", 2: "64"}
|
|
51
|
+
_SEVENTH_FIG = {0: "7", 1: "65", 2: "43", 3: "42"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_key(s: str) -> Optional[Key]:
|
|
55
|
+
s = (s or "").strip()
|
|
56
|
+
if not s or not s[0].isalpha():
|
|
57
|
+
return None
|
|
58
|
+
mode = "minor" if s[0].islower() else "major"
|
|
59
|
+
acc = {"-": "flat", "+": "sharp"}.get(s[1], None) if len(s) > 1 else None
|
|
60
|
+
return Key(tonic=PitchClass(note=s[0].upper(), accidental=acc), mode=mode)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _degree_part_to_roman(part: str, case: str) -> Optional[str]:
|
|
64
|
+
part = part.strip()
|
|
65
|
+
acc = ""
|
|
66
|
+
if part and part[0] in "+-":
|
|
67
|
+
acc = "#" if part[0] == "+" else "b"
|
|
68
|
+
part = part[1:]
|
|
69
|
+
if not part.isdigit() or int(part) not in _ROMAN:
|
|
70
|
+
return None
|
|
71
|
+
roman = _ROMAN[int(part)]
|
|
72
|
+
return acc + (roman.lower() if case == "lower" else roman)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _row_to_roman_parts(degree: str, quality: str, inversion: int):
|
|
76
|
+
"""Return ``(primary_surface, secondary_roman_or_None)`` for a BPS-FH row, or
|
|
77
|
+
``None`` for unsupported qualities (a6). ``primary_surface`` is a HAMON Roman the
|
|
78
|
+
grammar parses on its own; the secondary is attached separately because HAMON's
|
|
79
|
+
Roman grammar rejects flat-prefixed secondaries (e.g. ``/bVII``)."""
|
|
80
|
+
if quality not in _QUALITY:
|
|
81
|
+
return None # a6 (augmented sixth) and any future quality
|
|
82
|
+
case, is_seventh, glyph = _QUALITY[quality]
|
|
83
|
+
primary, _, secondary = degree.partition("/")
|
|
84
|
+
base = _degree_part_to_roman(primary, case)
|
|
85
|
+
if base is None:
|
|
86
|
+
return None
|
|
87
|
+
fig = (_SEVENTH_FIG if is_seventh else _TRIAD_FIG).get(inversion, "7" if is_seventh else "")
|
|
88
|
+
primary_surface = base + glyph + fig
|
|
89
|
+
sec = _degree_part_to_roman(secondary, "upper") if secondary else None
|
|
90
|
+
return primary_surface, sec
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _to_fraction(value) -> Optional[Fraction]:
|
|
94
|
+
try:
|
|
95
|
+
fr = _Fraction(float(value)).limit_denominator(960)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
return Fraction(numerator=fr.numerator, denominator=fr.denominator)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rows_to_hamon(rows) -> HamonSequence:
|
|
102
|
+
"""rows: iterable of (onset, key, degree, quality, inversion, roman_label)."""
|
|
103
|
+
groups: List[HarmonyGroup] = []
|
|
104
|
+
regions: List[TonalRegion] = []
|
|
105
|
+
open_key: Optional[int] = None
|
|
106
|
+
last_key_str: Optional[str] = None
|
|
107
|
+
|
|
108
|
+
for onset, key_str, degree, quality, inversion, roman_label in rows:
|
|
109
|
+
key_str = (str(key_str) if key_str is not None else "").strip()
|
|
110
|
+
if key_str and key_str != last_key_str:
|
|
111
|
+
key = _parse_key(key_str)
|
|
112
|
+
if key is not None:
|
|
113
|
+
gi = len(groups)
|
|
114
|
+
if open_key is not None and gi > 0:
|
|
115
|
+
regions[open_key].to_group = gi - 1
|
|
116
|
+
regions.append(TonalRegion(
|
|
117
|
+
key=key, kind=("key" if open_key is None else "modulation"), from_group=gi,
|
|
118
|
+
))
|
|
119
|
+
open_key = len(regions) - 1
|
|
120
|
+
last_key_str = key_str
|
|
121
|
+
|
|
122
|
+
parts = _row_to_roman_parts(str(degree), str(quality).strip(), int(inversion))
|
|
123
|
+
label = None
|
|
124
|
+
if parts is not None:
|
|
125
|
+
primary_surface, secondary = parts
|
|
126
|
+
parsed = parse_hamon_sequence("@rn\n" + primary_surface).groups
|
|
127
|
+
if parsed and parsed[0].primary[0].semantic.kind == "roman":
|
|
128
|
+
label = parsed[0].primary[0]
|
|
129
|
+
full = f"{primary_surface}/{secondary}" if secondary else primary_surface
|
|
130
|
+
label = replace(label, surface=full)
|
|
131
|
+
if secondary:
|
|
132
|
+
label = replace(label, semantic=replace(label.semantic, secondary=secondary))
|
|
133
|
+
if label is None: # a6 / unparseable: preserve the analysts' original label
|
|
134
|
+
label = HarmonyLabel(
|
|
135
|
+
surface=str(roman_label), semantic=TextSemantic(text=str(roman_label)),
|
|
136
|
+
rendering=RenderingHints(), detected_system="text", system="text",
|
|
137
|
+
sequence_system_hint="rn",
|
|
138
|
+
)
|
|
139
|
+
pos = _to_fraction(onset)
|
|
140
|
+
groups.append(HarmonyGroup(
|
|
141
|
+
primary=[label], position=Position(time=pos) if pos is not None else None,
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
return HamonSequence(groups=groups, sequence_system_hint="rn", regions=regions or None)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def bps_fh_chords_file_to_hamon(path: str) -> HamonSequence:
|
|
148
|
+
"""Convert one BPS-FH ``chords.xlsx`` (7 unlabelled columns) into a HamonSequence."""
|
|
149
|
+
try:
|
|
150
|
+
import pandas as pd # noqa: PLC0415 - optional, only needed for .xlsx
|
|
151
|
+
except ImportError as exc: # pragma: no cover
|
|
152
|
+
raise ImportError("Reading BPS-FH .xlsx needs pandas+openpyxl: "
|
|
153
|
+
"pip install -e ./hamonpy[bps]") from exc
|
|
154
|
+
df = pd.read_excel(path, header=None)
|
|
155
|
+
rows = ((r[0], r[2], r[3], r[4], r[5], r[6]) for _, r in df.iterrows())
|
|
156
|
+
return _rows_to_hamon(rows)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def bps_fh_movement_to_hamon(folder: str) -> HamonSequence:
|
|
160
|
+
"""Convert a BPS-FH movement folder (uses ``<folder>/chords.xlsx``)."""
|
|
161
|
+
return bps_fh_chords_file_to_hamon(str(Path(folder) / "chords.xlsx"))
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Adapter for the DCML *Choro Songbook Corpus* (github.com/DCMLab/choro).
|
|
2
|
+
|
|
3
|
+
The corpus describes the same music **twice**, which is exactly why HAMON is useful
|
|
4
|
+
here — it makes the two representations comparable:
|
|
5
|
+
|
|
6
|
+
* ``choro.tsv`` — every piece merged into one table, **one row per chord onset**,
|
|
7
|
+
with the chord in three encodings (``chord`` absolute, ``rn_chord`` Roman, ``harte``
|
|
8
|
+
Harte) plus ``local_key``, ``bar_no`` and the form columns (``phrase``/``part``),
|
|
9
|
+
keyed by ``filename``.
|
|
10
|
+
* ``transcriptions/<piece>.txt`` — a compact **form grammar**:
|
|
11
|
+
|
|
12
|
+
``P<n>: bar | bar | …`` a *phrase*: bars split by ``|``; an empty bar or ``.``
|
|
13
|
+
holds the previous chord; a bar may hold several space-separated chords.
|
|
14
|
+
``PartX[key]: $P1 $P2`` a *part*: a sequence of ``$``-references.
|
|
15
|
+
``S[key, meter]: $Intro $PartA …`` the *song*: parts/phrases in order,
|
|
16
|
+
where ``$ref*N`` repeats a reference ``N`` times.
|
|
17
|
+
|
|
18
|
+
Expanding the song grammar to its flat chord-onset sequence should reproduce the
|
|
19
|
+
``chord`` column of the TSV — the consistency check in ``use-cases/choro/``.
|
|
20
|
+
|
|
21
|
+
This adapter only parses; the cross-checks (transcription↔TSV, chord↔Harte↔Roman)
|
|
22
|
+
live in the use-case, built on the parsed models.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
import io
|
|
28
|
+
import re
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from typing import Dict, List
|
|
31
|
+
|
|
32
|
+
from hamonpy.ast import HamonSequence, HarmonyGroup, Position
|
|
33
|
+
from hamonpy.parse import parse_hamon_sequence
|
|
34
|
+
from hamonpy.adapters.harte import harte_text_to_hamon
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Brazilian songbook notation writes a major seventh as `7M` (e.g. `Bb7M`,
|
|
38
|
+
# `C7M(#11)`); HAMON's chord grammar spells it `maj7`.
|
|
39
|
+
def normalize_chord(chord: str) -> str:
|
|
40
|
+
"""Map corpus chord glyphs to HAMON surface syntax (``7M`` → ``maj7``)."""
|
|
41
|
+
return re.sub(r"7M", "maj7", chord.strip())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# transcriptions/<piece>.txt — the form grammar
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_REF = re.compile(r"^\$(\w+)(?:\*(\d+))?$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_definitions(text: str) -> "OrderedDict[str, str]":
|
|
52
|
+
"""`LABEL[annot]: BODY` lines → {label: body} (the `[...]` annotation dropped)."""
|
|
53
|
+
defs: "OrderedDict[str, str]" = OrderedDict()
|
|
54
|
+
for line in text.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or ":" not in line:
|
|
57
|
+
continue
|
|
58
|
+
label, body = line.split(":", 1)
|
|
59
|
+
label = re.sub(r"\[.*?\]", "", label).strip()
|
|
60
|
+
defs[label] = body.strip()
|
|
61
|
+
return defs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _expand(label: str, defs: Dict[str, str], seen: tuple = ()) -> List[str]:
|
|
65
|
+
if label in seen or label not in defs: # guard against reference cycles
|
|
66
|
+
return []
|
|
67
|
+
body = defs[label]
|
|
68
|
+
out: List[str] = []
|
|
69
|
+
if "$" in body: # a reference sequence (a Part, or the song S)
|
|
70
|
+
for token in body.split():
|
|
71
|
+
m = _REF.match(token)
|
|
72
|
+
if m:
|
|
73
|
+
out += _expand(m.group(1), defs, seen + (label,)) * int(m.group(2) or 1)
|
|
74
|
+
else: # a phrase: bars split by "|", chords split by whitespace
|
|
75
|
+
for bar in body.split("|"):
|
|
76
|
+
out += bar.split()
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def expand_transcription(text: str, top: str = "S") -> List[str]:
|
|
81
|
+
"""Flat chord-onset surfaces of a `.txt` transcription (the song, expanded).
|
|
82
|
+
|
|
83
|
+
Empty bars and ``.`` hold the previous chord, so they are resolved to that
|
|
84
|
+
chord — giving one surface per onset, aligned with the TSV's ``chord`` column.
|
|
85
|
+
"""
|
|
86
|
+
defs = _parse_definitions(text)
|
|
87
|
+
onsets = _expand(top, defs)
|
|
88
|
+
resolved: List[str] = []
|
|
89
|
+
prev = None
|
|
90
|
+
for chord in onsets:
|
|
91
|
+
chord = prev if chord == "." else chord
|
|
92
|
+
resolved.append(chord)
|
|
93
|
+
prev = chord
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def transcription_to_hamon(text: str) -> HamonSequence:
|
|
98
|
+
"""Parse a `.txt` transcription into a HamonSequence of chord symbols."""
|
|
99
|
+
surfaces = [normalize_chord(c) for c in expand_transcription(text)]
|
|
100
|
+
body = "\n".join(surfaces)
|
|
101
|
+
return parse_hamon_sequence("@cs\n" + body) if body else HamonSequence(groups=[])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# choro.tsv — the merged table
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def read_tsv(text: str) -> "OrderedDict[str, List[dict]]":
|
|
109
|
+
"""Group the merged ``choro.tsv`` rows by piece (``filename``), in file order."""
|
|
110
|
+
reader = csv.DictReader(io.StringIO(text), delimiter="\t")
|
|
111
|
+
pieces: "OrderedDict[str, List[dict]]" = OrderedDict()
|
|
112
|
+
for row in reader:
|
|
113
|
+
pieces.setdefault(row["filename"], []).append(row)
|
|
114
|
+
return pieces
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_COLUMN_SYSTEM = {"chord": "cs", "rn_chord": "rn", "harte": "harte"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def tsv_piece_to_hamon(rows: List[dict], column: str = "chord") -> HamonSequence:
|
|
121
|
+
"""One piece's rows → HamonSequence, from the chosen encoding column.
|
|
122
|
+
|
|
123
|
+
``column`` is ``chord`` (absolute chord symbols, the default), ``rn_chord``
|
|
124
|
+
(Roman numerals) or ``harte`` (Harte). Each group keeps the ``bar_no`` as its
|
|
125
|
+
position.
|
|
126
|
+
"""
|
|
127
|
+
if column not in _COLUMN_SYSTEM:
|
|
128
|
+
raise ValueError(f"column must be one of {sorted(_COLUMN_SYSTEM)}; got {column!r}")
|
|
129
|
+
surfaces = [r[column].strip() for r in rows if r.get(column, "").strip()]
|
|
130
|
+
if column == "harte":
|
|
131
|
+
seq = harte_text_to_hamon("\n".join(surfaces))
|
|
132
|
+
else:
|
|
133
|
+
prefix = "@" + _COLUMN_SYSTEM[column]
|
|
134
|
+
norm = surfaces if column == "rn_chord" else [normalize_chord(s) for s in surfaces]
|
|
135
|
+
seq = parse_hamon_sequence(prefix + "\n" + "\n".join(norm)) if norm else HamonSequence(groups=[])
|
|
136
|
+
# attach bar positions (best-effort; some rows may lack a numeric bar_no)
|
|
137
|
+
for group, row in zip(seq.groups, rows):
|
|
138
|
+
bar = row.get("bar_no", "")
|
|
139
|
+
if bar and str(bar).strip().lstrip("-").isdigit():
|
|
140
|
+
group.position = Position(measure=int(float(bar)))
|
|
141
|
+
return seq
|