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.
Files changed (53) hide show
  1. hamonpy/__init__.py +5 -0
  2. hamonpy/__main__.py +5 -0
  3. hamonpy/adapters/__init__.py +20 -0
  4. hamonpy/adapters/bps_fh.py +161 -0
  5. hamonpy/adapters/choro.py +141 -0
  6. hamonpy/adapters/dcml.py +383 -0
  7. hamonpy/adapters/dcml_expanded.py +168 -0
  8. hamonpy/adapters/dezrann.py +208 -0
  9. hamonpy/adapters/dilemma.py +321 -0
  10. hamonpy/adapters/flexohr.py +319 -0
  11. hamonpy/adapters/formats.py +363 -0
  12. hamonpy/adapters/harm.py +201 -0
  13. hamonpy/adapters/harte.py +439 -0
  14. hamonpy/adapters/humdrum.py +268 -0
  15. hamonpy/adapters/ireal.py +239 -0
  16. hamonpy/adapters/jams.py +198 -0
  17. hamonpy/adapters/kp.py +91 -0
  18. hamonpy/adapters/mei.py +288 -0
  19. hamonpy/adapters/ms3_adapter.py +90 -0
  20. hamonpy/adapters/music21_adapter.py +514 -0
  21. hamonpy/adapters/music21_converter.py +87 -0
  22. hamonpy/adapters/musicxml.py +115 -0
  23. hamonpy/adapters/partitura_adapter.py +148 -0
  24. hamonpy/adapters/rock_corpus.py +160 -0
  25. hamonpy/adapters/romantext.py +75 -0
  26. hamonpy/adapters/treebank.py +79 -0
  27. hamonpy/ast.py +286 -0
  28. hamonpy/capability.py +296 -0
  29. hamonpy/cli.py +540 -0
  30. hamonpy/datasets.py +318 -0
  31. hamonpy/export.py +293 -0
  32. hamonpy/generated/__init__.py +0 -0
  33. hamonpy/generated/antlr/__init__.py +0 -0
  34. hamonpy/generated/antlr/hamonLexer.interp +185 -0
  35. hamonpy/generated/antlr/hamonLexer.py +354 -0
  36. hamonpy/generated/antlr/hamonLexer.tokens +81 -0
  37. hamonpy/generated/antlr/hamonParser.interp +167 -0
  38. hamonpy/generated/antlr/hamonParser.py +3841 -0
  39. hamonpy/generated/antlr/hamonParser.tokens +81 -0
  40. hamonpy/generated/antlr/hamonParserListener.py +408 -0
  41. hamonpy/generated/antlr/hamonParserVisitor.py +233 -0
  42. hamonpy/normalize.py +683 -0
  43. hamonpy/parse.py +56 -0
  44. hamonpy/parse_visitor.py +289 -0
  45. hamonpy/report.py +604 -0
  46. hamonpy/serialize.py +169 -0
  47. hamonpy/validate.py +54 -0
  48. hamonpy-0.3.0.dist-info/METADATA +84 -0
  49. hamonpy-0.3.0.dist-info/RECORD +53 -0
  50. hamonpy-0.3.0.dist-info/WHEEL +5 -0
  51. hamonpy-0.3.0.dist-info/entry_points.txt +2 -0
  52. hamonpy-0.3.0.dist-info/licenses/LICENSE +232 -0
  53. hamonpy-0.3.0.dist-info/top_level.txt +1 -0
hamonpy/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """We implement hamon parsing/normalization and adapters in Python."""
2
+
3
+ from .parse import parse_hamon, parse_hamon_sequence
4
+
5
+ __all__ = ["parse_hamon", "parse_hamon_sequence"]
hamonpy/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m hamonpy ...`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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