tonalis 0.1.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.
- tonalis-0.1.0/LICENSE +21 -0
- tonalis-0.1.0/PKG-INFO +71 -0
- tonalis-0.1.0/README.md +36 -0
- tonalis-0.1.0/pyproject.toml +25 -0
- tonalis-0.1.0/setup.cfg +4 -0
- tonalis-0.1.0/tonalis/GRAMMAR.md +118 -0
- tonalis-0.1.0/tonalis/__init__.py +36 -0
- tonalis-0.1.0/tonalis/__main__.py +26 -0
- tonalis-0.1.0/tonalis/ast.py +93 -0
- tonalis-0.1.0/tonalis/chord_grammar.py +4 -0
- tonalis-0.1.0/tonalis/chords.py +39 -0
- tonalis-0.1.0/tonalis/lint.py +140 -0
- tonalis-0.1.0/tonalis/parser.py +347 -0
- tonalis-0.1.0/tonalis/serialize.py +152 -0
- tonalis-0.1.0/tonalis/serialize_text.py +111 -0
- tonalis-0.1.0/tonalis.egg-info/PKG-INFO +71 -0
- tonalis-0.1.0/tonalis.egg-info/SOURCES.txt +18 -0
- tonalis-0.1.0/tonalis.egg-info/dependency_links.txt +1 -0
- tonalis-0.1.0/tonalis.egg-info/requires.txt +1 -0
- tonalis-0.1.0/tonalis.egg-info/top_level.txt +1 -0
tonalis-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DRYCodeWorks
|
|
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.
|
tonalis-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tonalis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text).
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 DRYCodeWorks
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/drycode/tonalis
|
|
28
|
+
Project-URL: Repository, https://github.com/drycode/tonalis
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Requires-Dist: tonalis-music-dsl==0.1.0
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# tonalis
|
|
37
|
+
|
|
38
|
+
A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
|
|
39
|
+
charts to a canonical AST, lint them with structured findings, and render back
|
|
40
|
+
to canonical text or JSON. This is the Python reference implementation;
|
|
41
|
+
TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
|
|
42
|
+
to the same normative spec and a shared 255-case conformance suite.
|
|
43
|
+
|
|
44
|
+
Music-theory questions (chord validity, scales, keys) are delegated to the
|
|
45
|
+
pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install tonalis
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from tonalis import parse_dsl, lint, serialize, to_json
|
|
57
|
+
|
|
58
|
+
result = parse_dsl(open("chart.txt").read())
|
|
59
|
+
findings = list(result.findings) + (lint(result.chart) if result.chart else [])
|
|
60
|
+
chart = result.chart # a LeadSheet AST
|
|
61
|
+
json_obj = to_json(chart) # canonical JSON (a dict)
|
|
62
|
+
text = serialize(chart) # canonical text rendering
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A small CLI is included: `python -m tonalis chart.txt` prints lint findings
|
|
66
|
+
(exit 1 if any error).
|
|
67
|
+
|
|
68
|
+
## Links
|
|
69
|
+
|
|
70
|
+
- Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
|
|
71
|
+
- License: MIT
|
tonalis-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# tonalis
|
|
2
|
+
|
|
3
|
+
A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
|
|
4
|
+
charts to a canonical AST, lint them with structured findings, and render back
|
|
5
|
+
to canonical text or JSON. This is the Python reference implementation;
|
|
6
|
+
TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
|
|
7
|
+
to the same normative spec and a shared 255-case conformance suite.
|
|
8
|
+
|
|
9
|
+
Music-theory questions (chord validity, scales, keys) are delegated to the
|
|
10
|
+
pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tonalis
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from tonalis import parse_dsl, lint, serialize, to_json
|
|
22
|
+
|
|
23
|
+
result = parse_dsl(open("chart.txt").read())
|
|
24
|
+
findings = list(result.findings) + (lint(result.chart) if result.chart else [])
|
|
25
|
+
chart = result.chart # a LeadSheet AST
|
|
26
|
+
json_obj = to_json(chart) # canonical JSON (a dict)
|
|
27
|
+
text = serialize(chart) # canonical text rendering
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A small CLI is included: `python -m tonalis chart.txt` prints lint findings
|
|
31
|
+
(exit 1 if any error).
|
|
32
|
+
|
|
33
|
+
## Links
|
|
34
|
+
|
|
35
|
+
- Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
|
|
36
|
+
- License: MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tonalis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text)."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["tonalis-music-dsl==0.1.0"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/drycode/tonalis"
|
|
19
|
+
Repository = "https://github.com/drycode/tonalis"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["tonalis"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
tonalis = ["GRAMMAR.md"]
|
tonalis-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Tonalis DSL v1 — Grammar Reference
|
|
2
|
+
|
|
3
|
+
Emit a chart in this DSL. Output **only** the DSL, nothing else.
|
|
4
|
+
|
|
5
|
+
## Header (required keys: title, key, time)
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
title: All The Things You Are
|
|
9
|
+
composer: Jerome Kern
|
|
10
|
+
style: Medium Swing
|
|
11
|
+
key: Ab
|
|
12
|
+
time: 4/4
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Rules: one `key: value` per line; **no `=` in any value**; `time` is `n/d`.
|
|
16
|
+
|
|
17
|
+
## Sections and measures
|
|
18
|
+
|
|
19
|
+
Sections are `[A]`, `[B]`, `[C]`, `[D]`, `[Intro]`/`[i]`, `[Verse]`/`[v]`.
|
|
20
|
+
Measures are separated by `|`. Chords inside a measure split the bar evenly;
|
|
21
|
+
add `:N` for an explicit beat count.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
[A]
|
|
25
|
+
| F-7 | Bb7 | Eb^7 | Ab^7 |
|
|
26
|
+
| D-7 G7 | C^7 | C^7 | C^7 |
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Repeats, endings, navigation
|
|
30
|
+
|
|
31
|
+
`{ ... }` is a repeat; `1.`/`2.` mark first/second endings; `@segno`, `@coda`,
|
|
32
|
+
`[time: 3/4]` mid-chart, and `<staff text>` are supported. `@break` (or
|
|
33
|
+
`@newline`) on its own line forces the next section/measure onto a new line
|
|
34
|
+
(a vertical-space / line-break layout hint); use it for layout, e.g. to put an intro on its own row.
|
|
35
|
+
|
|
36
|
+
Layout model: grid-style chart renderers commonly display a fixed **4-cell bar**
|
|
37
|
+
regardless of meter (the time signature affects playback only) and auto-wrap
|
|
38
|
+
every **4 bars per row**.
|
|
39
|
+
So rows come out clean by default; emit `@break` only where a section must
|
|
40
|
+
start on a fresh row after a short tail (e.g. a 2-bar second ending). Bars
|
|
41
|
+
holding 4+ chords render wider than the grid — unavoidable in v1.
|
|
42
|
+
For a D.S./D.C. al Coda: `@segno` marks the jump-back point, `@tocoda` (placed
|
|
43
|
+
*after* a measure) marks the "To Coda" jump-from, and `@coda` marks the coda
|
|
44
|
+
section (the destination). `@fine` marks the Fine (end) point for a D.C./D.S. al Fine.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[Intro]
|
|
48
|
+
| D-7 | G7 |
|
|
49
|
+
@break
|
|
50
|
+
[A]
|
|
51
|
+
{ | C^7 | A-7 | 1. D-7 | G7 | }
|
|
52
|
+
| 2. D-7 | G7 |
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Chord vocabulary
|
|
56
|
+
|
|
57
|
+
Quality: `-` minor, `^` major-7, `o` dim, `h` half-dim, `+` aug, `sus`.
|
|
58
|
+
Extensions: `b5 #5 6 b9 9 #9 11 #11 b13 13`. No-chord: `N.C.`.
|
|
59
|
+
|
|
60
|
+
The following are all VALID chord tokens (the drift gate asserts each passes
|
|
61
|
+
the parser's `is_valid_chord`):
|
|
62
|
+
|
|
63
|
+
<!-- valid-chords -->
|
|
64
|
+
```
|
|
65
|
+
C
|
|
66
|
+
C^7
|
|
67
|
+
A-7
|
|
68
|
+
G7
|
|
69
|
+
D-7
|
|
70
|
+
F^7
|
|
71
|
+
Bbo7
|
|
72
|
+
Eh7
|
|
73
|
+
C+
|
|
74
|
+
Csus
|
|
75
|
+
C7b9
|
|
76
|
+
G7#5
|
|
77
|
+
F#-7b5
|
|
78
|
+
Ab^7
|
|
79
|
+
C/E
|
|
80
|
+
N.C.
|
|
81
|
+
```
|
|
82
|
+
<!-- /valid-chords -->
|
|
83
|
+
|
|
84
|
+
## Banned constructs
|
|
85
|
+
|
|
86
|
+
The following DSL snippets MUST fail to compile (the drift gate asserts each
|
|
87
|
+
yields an error finding):
|
|
88
|
+
|
|
89
|
+
<!-- banned-dsl -->
|
|
90
|
+
```
|
|
91
|
+
title: Has = Sign
|
|
92
|
+
key: C
|
|
93
|
+
time: 4/4
|
|
94
|
+
|
|
95
|
+
[A]
|
|
96
|
+
| C |
|
|
97
|
+
---
|
|
98
|
+
title: Bad Section
|
|
99
|
+
key: C
|
|
100
|
+
time: 4/4
|
|
101
|
+
|
|
102
|
+
[Z]
|
|
103
|
+
| C |
|
|
104
|
+
---
|
|
105
|
+
title: Bad Time
|
|
106
|
+
key: C
|
|
107
|
+
time: 9
|
|
108
|
+
---
|
|
109
|
+
title: Bad Chord
|
|
110
|
+
key: C
|
|
111
|
+
time: 4/4
|
|
112
|
+
|
|
113
|
+
[A]
|
|
114
|
+
| Zxq9 |
|
|
115
|
+
```
|
|
116
|
+
<!-- /banned-dsl -->
|
|
117
|
+
|
|
118
|
+
Each banned snippet above is separated by a line containing only `---`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic."""
|
|
2
|
+
|
|
3
|
+
from tonalis.ast import (
|
|
4
|
+
Barline,
|
|
5
|
+
Cell,
|
|
6
|
+
LeadSheet,
|
|
7
|
+
LintFinding,
|
|
8
|
+
Measure,
|
|
9
|
+
ParseResult,
|
|
10
|
+
Section,
|
|
11
|
+
SectionKind,
|
|
12
|
+
)
|
|
13
|
+
from tonalis.chords import is_valid_chord
|
|
14
|
+
from tonalis.lint import lint
|
|
15
|
+
from tonalis.parser import parse_dsl
|
|
16
|
+
from tonalis.serialize import ast_from_json, ast_to_json, from_json, to_json
|
|
17
|
+
from tonalis.serialize_text import serialize
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"parse_dsl",
|
|
21
|
+
"lint",
|
|
22
|
+
"is_valid_chord",
|
|
23
|
+
"serialize",
|
|
24
|
+
"ast_to_json",
|
|
25
|
+
"ast_from_json",
|
|
26
|
+
"to_json",
|
|
27
|
+
"from_json",
|
|
28
|
+
"LeadSheet",
|
|
29
|
+
"Cell",
|
|
30
|
+
"Measure",
|
|
31
|
+
"Section",
|
|
32
|
+
"SectionKind",
|
|
33
|
+
"Barline",
|
|
34
|
+
"LintFinding",
|
|
35
|
+
"ParseResult",
|
|
36
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from tonalis.parser import parse_dsl
|
|
5
|
+
from tonalis.lint import lint
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None) -> int:
|
|
9
|
+
ap = argparse.ArgumentParser(prog="tonalis", description="Lint/parse a lead-sheet DSL chart.")
|
|
10
|
+
ap.add_argument("file")
|
|
11
|
+
ap.add_argument("--lint-only", action="store_true")
|
|
12
|
+
args = ap.parse_args(argv)
|
|
13
|
+
try:
|
|
14
|
+
text = open(args.file, encoding="utf-8").read()
|
|
15
|
+
except OSError as e:
|
|
16
|
+
print(f"error: cannot read {args.file}: {e}", file=sys.stderr)
|
|
17
|
+
return 2
|
|
18
|
+
pr = parse_dsl(text)
|
|
19
|
+
findings = list(pr.findings) + (lint(pr.chart) if pr.chart else [])
|
|
20
|
+
for f in findings:
|
|
21
|
+
print(f"{f.severity}:{f.line}: [{f.code}] {f.message}", file=sys.stderr)
|
|
22
|
+
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""DSL abstract syntax — frozen-ish dataclasses produced by the parser, consumed by the
|
|
2
|
+
linter and a downstream codec.
|
|
3
|
+
|
|
4
|
+
Phase 2: the IR is NEUTRAL. The root is ``LeadSheet`` (was ``DslChart``); a section carries a
|
|
5
|
+
neutral ``kind`` enum (derived by the parser from the bracket name); a measure carries a
|
|
6
|
+
``barline`` enum (was the raw ``bar_close`` glyph) plus an opaque ``hints`` list (where
|
|
7
|
+
``@break`` lives — it has no lead-sheet semantics). No field stores a format-specific value; a
|
|
8
|
+
downstream codec maps kind/barline/hints to the target format's section/barline markers itself.
|
|
9
|
+
"""
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SectionKind(str, Enum):
|
|
16
|
+
"""Neutral section role, derived by the parser from the bracket name."""
|
|
17
|
+
A = "a"
|
|
18
|
+
B = "b"
|
|
19
|
+
C = "c"
|
|
20
|
+
D = "d"
|
|
21
|
+
INTRO = "intro"
|
|
22
|
+
VERSE = "verse"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Barline(str, Enum):
|
|
26
|
+
"""Neutral right-barline. ``'|'`` -> NORMAL, ``'}'`` -> REPEAT_END, ``'Z'``/``'||'`` -> FINAL."""
|
|
27
|
+
NORMAL = "normal"
|
|
28
|
+
REPEAT_END = "repeat_end"
|
|
29
|
+
FINAL = "final"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Cell:
|
|
34
|
+
chord: str
|
|
35
|
+
beats: Optional[int] = None # explicit :N, else None (even split of the bar)
|
|
36
|
+
alt: Optional[str] = None # raw "(A-7 D7)" alt-chord text, if any
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def chord_obj(self):
|
|
40
|
+
"""Lazy MusicDSL Chord for a present chord token; None for empty / no-chord /
|
|
41
|
+
bare-slash cells; raises InvalidChordStringException for a malformed-present token."""
|
|
42
|
+
from music_dsl.domain.chords.chord import Chord # lazy: keeps the dep at use-time
|
|
43
|
+
t = (self.chord or "").replace("*", "")
|
|
44
|
+
if not t or t in ("N.C.", "n") or t.startswith("/"):
|
|
45
|
+
return None
|
|
46
|
+
return Chord(t)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def chord_obj_or_none(self):
|
|
50
|
+
"""Non-raising form of chord_obj (None for malformed tokens too)."""
|
|
51
|
+
from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
|
|
52
|
+
try:
|
|
53
|
+
return self.chord_obj
|
|
54
|
+
except InvalidChordStringException:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Measure:
|
|
60
|
+
cells: List[Cell] = field(default_factory=list)
|
|
61
|
+
ending: Optional[int] = None # 1./2. nth-ending number
|
|
62
|
+
bar_open: bool = False # preceded by '{'
|
|
63
|
+
barline: Barline = Barline.NORMAL # neutral right-barline (was bar_close: str)
|
|
64
|
+
nav: Tuple = () # ('segno',), ('coda',), ('fine',), ('text', '<...>'), ('time', (n, d))
|
|
65
|
+
hints: Tuple[str, ...] = () # opaque render-hints, e.g. ('break',); NO semantics
|
|
66
|
+
line: int = 0 # source line for findings
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Section:
|
|
71
|
+
label: str
|
|
72
|
+
kind: SectionKind # neutral role (the parser derives it from the bracket name)
|
|
73
|
+
measures: List[Measure] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class LeadSheet:
|
|
78
|
+
meta: dict = field(default_factory=dict) # title/composer/style/key/time
|
|
79
|
+
sections: List[Section] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class LintFinding:
|
|
84
|
+
severity: str # "error" | "warning"
|
|
85
|
+
line: int
|
|
86
|
+
code: str
|
|
87
|
+
message: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class ParseResult:
|
|
92
|
+
chart: Optional[LeadSheet]
|
|
93
|
+
findings: List[LintFinding] = field(default_factory=list)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Chord-token validation for the lead-sheet linter, standing on MusicDSL.
|
|
2
|
+
|
|
3
|
+
Replaces ``chord_grammar``'s hand-rolled regex: the "is this a real chord" decision is
|
|
4
|
+
delegated to MusicDSL's ``Chord()`` (the theory library is the single source of truth for
|
|
5
|
+
what a chord is). The handful of non-chord lead-sheet tokens that ``Chord()`` rightly
|
|
6
|
+
rejects but the linter must still accept are preserved here verbatim:
|
|
7
|
+
|
|
8
|
+
* ``N.C.`` / ``n`` — explicit no-chord markers.
|
|
9
|
+
* a standalone slash-bass continuation (``/A``) — a bass note carried over from the
|
|
10
|
+
previous chord, with no quality of its own.
|
|
11
|
+
* the augmented layout-star artifact (``Bb*7+*``) — lead-sheet notation sometimes glues a
|
|
12
|
+
``*`` to a chord as a layout marker; strip it before validating.
|
|
13
|
+
|
|
14
|
+
This module may import ``music_dsl`` (the theory lib tonalis stands on) per the Phase 1
|
|
15
|
+
one-way dependency rule (leadsheet -> music_dsl); ``music_dsl`` must never import tonalis.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
|
|
21
|
+
from music_dsl.domain.chords.chord import Chord
|
|
22
|
+
|
|
23
|
+
_SLASH_BASS = re.compile(r"^/[A-G][b#]?$") # standalone bass continuation, e.g. /A
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_valid_chord(token: str) -> bool:
|
|
27
|
+
"""True iff the token is a well-formed chord OR a no-chord / bass marker."""
|
|
28
|
+
if token in ("N.C.", "n"):
|
|
29
|
+
return True
|
|
30
|
+
if not token:
|
|
31
|
+
return False
|
|
32
|
+
t = token.replace("*", "") # tolerate the augmented layout-star artifact (Bb*7+*)
|
|
33
|
+
if _SLASH_BASS.match(t):
|
|
34
|
+
return True
|
|
35
|
+
try:
|
|
36
|
+
Chord(t)
|
|
37
|
+
return True
|
|
38
|
+
except InvalidChordStringException:
|
|
39
|
+
return False
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""DSL linter: structural + chord-grammar checks over a parsed LeadSheet.
|
|
2
|
+
|
|
3
|
+
Errors block compilation; warnings don't. Uses the real chord grammar (chord_grammar) for
|
|
4
|
+
bad-chord, not the permissive CST scanner.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from tonalis.ast import Barline, LintFinding
|
|
8
|
+
from tonalis.chords import is_valid_chord
|
|
9
|
+
from tonalis.parser import MAX_CHORD_TOKEN_LEN
|
|
10
|
+
|
|
11
|
+
# corpus-derived known-good uneven :N layouts (4/4); all-equal splits are always fine.
|
|
12
|
+
_ALLOWED_UNEVEN = {(2, 1, 1), (1, 1, 1, 1), (1, 1, 2), (3, 1), (1, 3), (2, 2)}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _all_measures(chart):
|
|
16
|
+
return [(s, m) for s in chart.sections for m in s.measures]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _beat_pattern(measure, numerator):
|
|
20
|
+
"""Per-cell beat counts: explicit :N consume their count; unannotated split the
|
|
21
|
+
remainder evenly. Returns (pattern_tuple, ok) where ok is False if it can't fill the bar.
|
|
22
|
+
"""
|
|
23
|
+
cells = measure.cells
|
|
24
|
+
if not cells:
|
|
25
|
+
return (), True
|
|
26
|
+
explicit = sum(c.beats for c in cells if c.beats)
|
|
27
|
+
unannotated = [c for c in cells if not c.beats]
|
|
28
|
+
remainder = numerator - explicit
|
|
29
|
+
if unannotated:
|
|
30
|
+
if remainder <= 0 or remainder % len(unannotated) != 0:
|
|
31
|
+
return tuple(c.beats or 0 for c in cells), False
|
|
32
|
+
each = remainder // len(unannotated)
|
|
33
|
+
return tuple(c.beats if c.beats else each for c in cells), True
|
|
34
|
+
return tuple(c.beats for c in cells), explicit == numerator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lint(chart):
|
|
38
|
+
findings = []
|
|
39
|
+
|
|
40
|
+
def err(line, code, msg):
|
|
41
|
+
findings.append(LintFinding("error", line, code, msg))
|
|
42
|
+
|
|
43
|
+
def warn(line, code, msg):
|
|
44
|
+
findings.append(LintFinding("warning", line, code, msg))
|
|
45
|
+
|
|
46
|
+
measures = _all_measures(chart)
|
|
47
|
+
numerator = (chart.meta.get("time") or (4, 4))[0]
|
|
48
|
+
|
|
49
|
+
# repeats / endings balance, codas/segno, final bar, per-measure beats + chords
|
|
50
|
+
balance = n_coda = n_segno = 0
|
|
51
|
+
last_idx = len(measures) - 1
|
|
52
|
+
for idx, (section, m) in enumerate(measures):
|
|
53
|
+
for nav in m.nav:
|
|
54
|
+
if nav == ("coda",):
|
|
55
|
+
n_coda += 1
|
|
56
|
+
elif nav == ("segno",):
|
|
57
|
+
n_segno += 1
|
|
58
|
+
elif nav and nav[0] == "time":
|
|
59
|
+
numerator = nav[1][0]
|
|
60
|
+
if m.bar_open:
|
|
61
|
+
balance += 1
|
|
62
|
+
if m.barline == Barline.REPEAT_END:
|
|
63
|
+
if balance == 0:
|
|
64
|
+
err(m.line, "unbalanced-repeat", "'}' with no matching '{'")
|
|
65
|
+
else:
|
|
66
|
+
balance -= 1
|
|
67
|
+
if m.barline == Barline.FINAL and idx != last_idx:
|
|
68
|
+
warn(
|
|
69
|
+
m.line,
|
|
70
|
+
"mid-final-bar",
|
|
71
|
+
"final barline (Z/||) is not on the last measure",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# chords
|
|
75
|
+
for c in m.cells:
|
|
76
|
+
# skip is_valid_chord for tokens that already triggered token-too-long (parser
|
|
77
|
+
# error already emitted; MusicDSL would reject them as bad-chord too but the
|
|
78
|
+
# conformance spec treats over-length tokens as a single token-too-long error)
|
|
79
|
+
if c.chord and len(c.chord) <= MAX_CHORD_TOKEN_LEN and not is_valid_chord(c.chord):
|
|
80
|
+
err(m.line, "bad-chord", f"invalid chord token: {c.chord!r}")
|
|
81
|
+
if c.alt:
|
|
82
|
+
for tok in c.alt.strip("()").split():
|
|
83
|
+
if not is_valid_chord(tok):
|
|
84
|
+
err(m.line, "bad-chord", f"invalid alt chord: {tok!r}")
|
|
85
|
+
|
|
86
|
+
# empty measure
|
|
87
|
+
if not m.cells:
|
|
88
|
+
warn(m.line, "empty-measure", "measure has no chords (emitted as N.C.)")
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# beats. Pickup relaxation only excuses an UNDER-full first/last bar (anacrusis);
|
|
92
|
+
# an OVER-full bar is always wrong, even for a pickup.
|
|
93
|
+
pattern, ok = _beat_pattern(m, numerator)
|
|
94
|
+
total = sum(pattern)
|
|
95
|
+
is_pickup = idx in (0, last_idx)
|
|
96
|
+
if total > numerator:
|
|
97
|
+
err(m.line, "beat-sum", f"beats {pattern} overflow a {numerator}-beat bar")
|
|
98
|
+
elif not ok and not is_pickup:
|
|
99
|
+
err(
|
|
100
|
+
m.line,
|
|
101
|
+
"beat-sum",
|
|
102
|
+
f"beats {pattern} do not fill a {numerator}-beat bar",
|
|
103
|
+
)
|
|
104
|
+
elif ok and len(set(pattern)) > 1 and pattern not in _ALLOWED_UNEVEN:
|
|
105
|
+
warn(
|
|
106
|
+
m.line,
|
|
107
|
+
"beat-unsupported",
|
|
108
|
+
f"uneven beat layout {pattern} not in the allowlist; even-split fallback",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if balance != 0:
|
|
112
|
+
err(0, "unbalanced-repeat", f"{balance} unclosed '{{'")
|
|
113
|
+
# an empty section (header but no measures) silently drops in render — surface it
|
|
114
|
+
for section in chart.sections:
|
|
115
|
+
if not section.measures:
|
|
116
|
+
warn(
|
|
117
|
+
0,
|
|
118
|
+
"empty-section",
|
|
119
|
+
f"section [{section.label}] has no measures (dropped)",
|
|
120
|
+
)
|
|
121
|
+
# nth endings must belong to a repeat (a section with endings needs a bar_open)
|
|
122
|
+
for section in chart.sections:
|
|
123
|
+
has_open = any(m.bar_open for m in section.measures)
|
|
124
|
+
if any(m.ending for m in section.measures) and not has_open:
|
|
125
|
+
ln = next((m.line for m in section.measures if m.ending), 0)
|
|
126
|
+
err(ln, "ending-without-repeat", "nth ending outside a repeat block")
|
|
127
|
+
if n_coda > 2:
|
|
128
|
+
err(
|
|
129
|
+
0,
|
|
130
|
+
"coda-count",
|
|
131
|
+
f"{n_coda} coda points (max 2 — the encoder cannot flatten more)",
|
|
132
|
+
)
|
|
133
|
+
elif n_coda == 2 and n_segno == 0:
|
|
134
|
+
warn(
|
|
135
|
+
0,
|
|
136
|
+
"coda-needs-segno",
|
|
137
|
+
"2-coda jump without @segno (will play D.C., not D.S.)",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return findings
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Bounded, single-pass DSL parser. Never eval/exec; limit violations are findings.
|
|
2
|
+
|
|
3
|
+
DSL → ParseResult{LeadSheet, findings}. The parser records structure; the linter (lint.py)
|
|
4
|
+
judges chord validity, beat sums, etc. Header values are read literally to end-of-line.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from tonalis.ast import (
|
|
10
|
+
Barline,
|
|
11
|
+
Cell,
|
|
12
|
+
LeadSheet,
|
|
13
|
+
LintFinding,
|
|
14
|
+
Measure,
|
|
15
|
+
ParseResult,
|
|
16
|
+
Section,
|
|
17
|
+
SectionKind,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
MAX_BYTES = 256 * 1024
|
|
21
|
+
MAX_LINES = 5000
|
|
22
|
+
MAX_LINE_LEN = 1000
|
|
23
|
+
MAX_MEASURES = 2000
|
|
24
|
+
MAX_CELLS_PER_MEASURE = 16
|
|
25
|
+
MAX_CHORD_TOKEN_LEN = 50
|
|
26
|
+
MAX_ALT_CHORD_TOKENS = 8
|
|
27
|
+
# Numeric value cap (SPEC.md §1.4): a meter num/den, a :N beat count, and an ending number must be
|
|
28
|
+
# <= MAX_METER. Exceeding it never reaches rendering, so no port can be driven into a multi-GB
|
|
29
|
+
# " "-padding allocation or a fixed-width-int overflow.
|
|
30
|
+
MAX_METER = 64
|
|
31
|
+
|
|
32
|
+
# Line terminators (SPEC.md §1.1): ONLY \r\n, \n, \r. NOT Python's broader str.splitlines() set
|
|
33
|
+
# (which also splits \v \f \x1c \x1d \x1e \x85
) — those are ordinary characters here,
|
|
34
|
+
# so all ports split identically.
|
|
35
|
+
_LINE_SPLIT_RE = re.compile(r"\r\n|\r|\n")
|
|
36
|
+
|
|
37
|
+
_REQUIRED = ("title", "key", "time")
|
|
38
|
+
_META_KEYS = ("title", "composer", "style", "key", "time")
|
|
39
|
+
_SECTIONS = {
|
|
40
|
+
"A": SectionKind.A,
|
|
41
|
+
"B": SectionKind.B,
|
|
42
|
+
"C": SectionKind.C,
|
|
43
|
+
"D": SectionKind.D,
|
|
44
|
+
"Intro": SectionKind.INTRO,
|
|
45
|
+
"i": SectionKind.INTRO,
|
|
46
|
+
"Verse": SectionKind.VERSE,
|
|
47
|
+
"v": SectionKind.VERSE,
|
|
48
|
+
}
|
|
49
|
+
_BARLINE_RE = re.compile(r"(\{|\}|\|\||\||Z)")
|
|
50
|
+
_CELL_RE = re.compile(r"\([^)]*\)|\S+") # a (alt) group, or a non-space run
|
|
51
|
+
_INLINE_ALT_RE = re.compile(r"^([^(]+)(\([^)]*\))$") # "G7(Db7)" -> chord + (alt)
|
|
52
|
+
# ASCII digits only (SPEC.md §1.1): explicit [0-9] classes, never \d (Unicode-aware in Python).
|
|
53
|
+
_ENDING_RE = re.compile(r"^([0-9]+)\.\s*(.*)$")
|
|
54
|
+
_TIME_RE = re.compile(r"\[time:\s*([0-9]+)\s*/\s*([0-9]+)\s*\]\s*$")
|
|
55
|
+
_SECTION_RE = re.compile(r"\[([A-Za-z]+)([0-9]*)\]\s*$")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_lines(text):
|
|
59
|
+
"""SPEC.md §1.1 line split: ONLY \\r\\n, \\n, \\r (not Python's wider splitlines() set).
|
|
60
|
+
|
|
61
|
+
Mirrors ``str.splitlines()`` for these three forms — including dropping the trailing empty
|
|
62
|
+
element a terminal newline would otherwise leave — so the common case is byte-identical, but
|
|
63
|
+
a stray \\x1c/\\v/\\x85/etc. stays inside its line (an ordinary char), matching TS/Rust.
|
|
64
|
+
"""
|
|
65
|
+
if text == "":
|
|
66
|
+
return []
|
|
67
|
+
parts = _LINE_SPLIT_RE.split(text)
|
|
68
|
+
if parts and parts[-1] == "":
|
|
69
|
+
parts.pop()
|
|
70
|
+
return parts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_ascii_digits(s):
|
|
74
|
+
"""ASCII-only digit check (SPEC.md §1.1). Unlike ``str.isdigit()`` this rejects non-ASCII
|
|
75
|
+
digits (``٣``) AND numeric-but-non-decimal characters (``²``, which would crash ``int()``).
|
|
76
|
+
"""
|
|
77
|
+
return s != "" and all("0" <= c <= "9" for c in s)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_dsl(text):
|
|
81
|
+
findings = []
|
|
82
|
+
|
|
83
|
+
def err(line, code, msg):
|
|
84
|
+
findings.append(LintFinding("error", line, code, msg))
|
|
85
|
+
|
|
86
|
+
def warn(line, code, msg):
|
|
87
|
+
findings.append(LintFinding("warning", line, code, msg))
|
|
88
|
+
|
|
89
|
+
if len(text.encode("utf-8", "replace")) > MAX_BYTES:
|
|
90
|
+
err(0, "too-big", "input exceeds MAX_BYTES")
|
|
91
|
+
return ParseResult(None, findings)
|
|
92
|
+
text = text.lstrip("")
|
|
93
|
+
lines = _split_lines(text)
|
|
94
|
+
if len(lines) > MAX_LINES:
|
|
95
|
+
err(0, "too-big", "too many lines")
|
|
96
|
+
return ParseResult(None, findings)
|
|
97
|
+
for n, raw in enumerate(lines, 1):
|
|
98
|
+
if len(raw) > MAX_LINE_LEN:
|
|
99
|
+
err(n, "too-big", "line too long")
|
|
100
|
+
return ParseResult(None, findings)
|
|
101
|
+
|
|
102
|
+
meta = {}
|
|
103
|
+
i, total = 0, len(lines)
|
|
104
|
+
|
|
105
|
+
def assign_meta(k, v, lineno):
|
|
106
|
+
# Header keys are singletons. Re-stating one is a duplicate (same value, redundant)
|
|
107
|
+
# or a conflict (different value, mutually exclusive) — never silently last-wins.
|
|
108
|
+
if k in meta:
|
|
109
|
+
if meta[k] == v:
|
|
110
|
+
warn(lineno, "duplicate-header", f"duplicate header {k!r}")
|
|
111
|
+
else:
|
|
112
|
+
err(
|
|
113
|
+
lineno,
|
|
114
|
+
"conflicting-header",
|
|
115
|
+
f"conflicting header {k!r}: {meta[k]!r} vs {v!r}",
|
|
116
|
+
)
|
|
117
|
+
return # keep the first value; a conflict already blocks compile
|
|
118
|
+
meta[k] = v
|
|
119
|
+
|
|
120
|
+
# --- header ---
|
|
121
|
+
while i < total:
|
|
122
|
+
raw = lines[i]
|
|
123
|
+
line = raw.strip()
|
|
124
|
+
if line == "" or line.startswith("#"):
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if line[0] in "[|{":
|
|
128
|
+
break
|
|
129
|
+
m = re.match(r"([A-Za-z]+)\s*:\s*(.*)$", raw) # value read literally to EOL
|
|
130
|
+
if not m:
|
|
131
|
+
break
|
|
132
|
+
key, val = m.group(1).lower(), m.group(2).rstrip()
|
|
133
|
+
if "=" in val:
|
|
134
|
+
err(i + 1, "meta-delimiter", f"'=' not allowed in metadata value: {val!r}")
|
|
135
|
+
if key == "time":
|
|
136
|
+
# ASCII digits only (SPEC.md §1.1); both components must be <= MAX_METER (§1.4).
|
|
137
|
+
tm = re.match(r"([0-9]+)\s*/\s*([0-9]+)$", val)
|
|
138
|
+
if tm and int(tm.group(1)) <= MAX_METER and int(tm.group(2)) <= MAX_METER:
|
|
139
|
+
assign_meta("time", (int(tm.group(1)), int(tm.group(2))), i + 1)
|
|
140
|
+
else:
|
|
141
|
+
err(i + 1, "bad-time", f"bad time signature: {val!r}")
|
|
142
|
+
elif key in _META_KEYS:
|
|
143
|
+
assign_meta(key, val, i + 1)
|
|
144
|
+
else:
|
|
145
|
+
warn(i + 1, "unknown-key", f"unknown header key: {key}")
|
|
146
|
+
i += 1
|
|
147
|
+
|
|
148
|
+
for k in _REQUIRED:
|
|
149
|
+
if k not in meta:
|
|
150
|
+
err(0, "missing-header", f"missing required header: {k}")
|
|
151
|
+
if meta.get("title", "") == "":
|
|
152
|
+
err(0, "missing-header", "title must be non-empty")
|
|
153
|
+
|
|
154
|
+
# --- body ---
|
|
155
|
+
sections = []
|
|
156
|
+
cur = None
|
|
157
|
+
pending_nav = []
|
|
158
|
+
pending_hints = []
|
|
159
|
+
measure_count = 0
|
|
160
|
+
current_time = meta.get("time", (4, 4)) # for redundant mid-chart [time:] detection
|
|
161
|
+
|
|
162
|
+
def ensure_section():
|
|
163
|
+
nonlocal cur
|
|
164
|
+
if cur is None:
|
|
165
|
+
cur = Section(label="A", kind=SectionKind.A)
|
|
166
|
+
sections.append(cur)
|
|
167
|
+
return cur
|
|
168
|
+
|
|
169
|
+
while i < total:
|
|
170
|
+
raw = lines[i]
|
|
171
|
+
line = raw.strip()
|
|
172
|
+
ln = i + 1
|
|
173
|
+
i += 1
|
|
174
|
+
if line == "" or line.startswith("#"):
|
|
175
|
+
continue
|
|
176
|
+
tm = _TIME_RE.match(line)
|
|
177
|
+
if line.startswith("[time") and tm:
|
|
178
|
+
n, d = int(tm.group(1)), int(tm.group(2))
|
|
179
|
+
if n > MAX_METER or d > MAX_METER:
|
|
180
|
+
# recognized [time:…] shape but the meter is out of range (SPEC.md §1.4/§4.4):
|
|
181
|
+
# reject it as bad-time; it contributes no nav.
|
|
182
|
+
err(ln, "bad-time", f"meter component exceeds MAX_METER: {n}/{d}")
|
|
183
|
+
continue
|
|
184
|
+
newt = (n, d)
|
|
185
|
+
if newt == current_time:
|
|
186
|
+
warn(
|
|
187
|
+
ln,
|
|
188
|
+
"redundant-time",
|
|
189
|
+
f"[time: {newt[0]}/{newt[1]}] repeats the current meter",
|
|
190
|
+
)
|
|
191
|
+
current_time = newt
|
|
192
|
+
pending_nav.append(("time", newt))
|
|
193
|
+
continue
|
|
194
|
+
sec = _SECTION_RE.match(line)
|
|
195
|
+
if sec:
|
|
196
|
+
name = sec.group(1)
|
|
197
|
+
kind = _SECTIONS.get(name)
|
|
198
|
+
if kind is None:
|
|
199
|
+
err(
|
|
200
|
+
ln,
|
|
201
|
+
"unknown-section",
|
|
202
|
+
f"unknown section {name!r}; allowed: A-D, Intro, Verse",
|
|
203
|
+
)
|
|
204
|
+
kind = SectionKind.A
|
|
205
|
+
cur = Section(label=name + sec.group(2), kind=kind)
|
|
206
|
+
sections.append(cur)
|
|
207
|
+
continue
|
|
208
|
+
if line.startswith("@"):
|
|
209
|
+
if "segno" in line:
|
|
210
|
+
pending_nav.append(("segno",))
|
|
211
|
+
elif "tocoda" in line or "to coda" in line or "to-coda" in line:
|
|
212
|
+
# "To Coda" jump-point: a Q AFTER the PRECEDING measure's chords (not the
|
|
213
|
+
# next one). Checked before "coda" because "tocoda" contains "coda".
|
|
214
|
+
if cur is not None and cur.measures:
|
|
215
|
+
cur.measures[-1].nav = cur.measures[-1].nav + (("tocoda",),)
|
|
216
|
+
else:
|
|
217
|
+
warn(
|
|
218
|
+
ln, "tocoda-without-measure", "@tocoda has no preceding measure"
|
|
219
|
+
)
|
|
220
|
+
elif "fine" in line:
|
|
221
|
+
pending_nav.append(("fine",))
|
|
222
|
+
elif "coda" in line:
|
|
223
|
+
pending_nav.append(("coda",))
|
|
224
|
+
elif "break" in line or "newline" in line:
|
|
225
|
+
# page-layout only — NO lead-sheet semantics. Demoted to an opaque
|
|
226
|
+
# render-hint (Phase 2 §3.3); a downstream codec reads it, text-target ignores it.
|
|
227
|
+
pending_hints.append("break")
|
|
228
|
+
else:
|
|
229
|
+
warn(ln, "unknown-nav", f"unknown @directive: {line}")
|
|
230
|
+
continue
|
|
231
|
+
if line.startswith("<") and line.endswith(">"):
|
|
232
|
+
pending_nav.append(("text", line))
|
|
233
|
+
continue
|
|
234
|
+
# otherwise: a measure line
|
|
235
|
+
section = ensure_section()
|
|
236
|
+
measures = _parse_measure_line(line, ln, err, warn)
|
|
237
|
+
for m in measures:
|
|
238
|
+
if pending_nav:
|
|
239
|
+
m.nav = tuple(pending_nav) + m.nav
|
|
240
|
+
pending_nav = []
|
|
241
|
+
if pending_hints:
|
|
242
|
+
m.hints = tuple(pending_hints) + m.hints
|
|
243
|
+
pending_hints = []
|
|
244
|
+
section.measures.append(m)
|
|
245
|
+
measure_count += 1
|
|
246
|
+
if measure_count > MAX_MEASURES:
|
|
247
|
+
err(ln, "too-big", "too many measures")
|
|
248
|
+
return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
|
|
249
|
+
|
|
250
|
+
if not sections or all(not s.measures for s in sections):
|
|
251
|
+
err(0, "empty-chart", "no sections or measures")
|
|
252
|
+
return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_measure_line(line, ln, err, warn):
|
|
256
|
+
parts = _BARLINE_RE.split(line)
|
|
257
|
+
measures = []
|
|
258
|
+
bar_open = False
|
|
259
|
+
buf = None
|
|
260
|
+
for p in parts:
|
|
261
|
+
if p == "{":
|
|
262
|
+
bar_open = True
|
|
263
|
+
continue
|
|
264
|
+
if p in ("|", "}", "||", "Z"):
|
|
265
|
+
if buf is not None and buf.strip() != "":
|
|
266
|
+
if p in ("||", "Z"):
|
|
267
|
+
close = Barline.FINAL
|
|
268
|
+
elif p == "}":
|
|
269
|
+
close = Barline.REPEAT_END
|
|
270
|
+
else:
|
|
271
|
+
close = Barline.NORMAL
|
|
272
|
+
ending, cells = _parse_cells(buf, ln, err, warn)
|
|
273
|
+
measures.append(
|
|
274
|
+
Measure(
|
|
275
|
+
cells=cells,
|
|
276
|
+
ending=ending,
|
|
277
|
+
bar_open=bar_open,
|
|
278
|
+
barline=close,
|
|
279
|
+
line=ln,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
bar_open = False
|
|
283
|
+
elif p == "}" and measures:
|
|
284
|
+
measures[-1].barline = Barline.REPEAT_END
|
|
285
|
+
buf = None
|
|
286
|
+
continue
|
|
287
|
+
if p.strip() != "":
|
|
288
|
+
buf = p
|
|
289
|
+
if buf is not None and buf.strip() != "":
|
|
290
|
+
ending, cells = _parse_cells(buf, ln, err, warn)
|
|
291
|
+
measures.append(
|
|
292
|
+
Measure(
|
|
293
|
+
cells=cells, ending=ending, bar_open=bar_open, barline=Barline.NORMAL, line=ln
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return measures
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_cells(content, ln, err, warn):
|
|
300
|
+
content = content.strip()
|
|
301
|
+
ending = None
|
|
302
|
+
m = _ENDING_RE.match(content)
|
|
303
|
+
if m:
|
|
304
|
+
ending = int(m.group(1))
|
|
305
|
+
content = m.group(2).strip()
|
|
306
|
+
# An ending number > MAX_METER (SPEC.md §1.4) is a typo and would otherwise overflow a
|
|
307
|
+
# fixed-width int in the ports; reject with the generic size-cap code.
|
|
308
|
+
if ending > MAX_METER:
|
|
309
|
+
err(ln, "too-big", f"ending number exceeds MAX_METER: {ending}")
|
|
310
|
+
cells = []
|
|
311
|
+
for tok in _CELL_RE.findall(content):
|
|
312
|
+
if tok.startswith("("):
|
|
313
|
+
if len(tok.strip("()").split()) > MAX_ALT_CHORD_TOKENS:
|
|
314
|
+
err(ln, "alt-too-long", "alt chord has too many tokens")
|
|
315
|
+
if cells:
|
|
316
|
+
cells[-1].alt = tok
|
|
317
|
+
else:
|
|
318
|
+
warn(ln, "alt-without-chord", f"alt group {tok} has no preceding chord")
|
|
319
|
+
continue
|
|
320
|
+
# Split an inline alt off the chord, e.g. "G7(Db7)" -> chord "G7" + alt "(Db7)"
|
|
321
|
+
# (lead-sheet notation writes alts with no space; the space-separated "G7 (Db7)" form is handled
|
|
322
|
+
# by the `tok.startswith("(")` branch above).
|
|
323
|
+
inline_alt = None
|
|
324
|
+
am = _INLINE_ALT_RE.match(tok)
|
|
325
|
+
if am:
|
|
326
|
+
tok, inline_alt = am.group(1), am.group(2)
|
|
327
|
+
if len(inline_alt.strip("()").split()) > MAX_ALT_CHORD_TOKENS:
|
|
328
|
+
err(ln, "alt-too-long", "alt chord has too many tokens")
|
|
329
|
+
chord, beats = tok, None
|
|
330
|
+
if ":" in tok:
|
|
331
|
+
chord, _, b = tok.partition(":")
|
|
332
|
+
# ASCII digits only (SPEC.md §1.1) — str.isdigit() accepts non-ASCII digits and
|
|
333
|
+
# numeric-but-non-decimal chars like '²' (which would crash int()). Cap at MAX_METER.
|
|
334
|
+
if _is_ascii_digits(b):
|
|
335
|
+
if int(b) > MAX_METER:
|
|
336
|
+
err(ln, "bad-beats", f"beat count exceeds MAX_METER in {tok!r}")
|
|
337
|
+
else:
|
|
338
|
+
beats = int(b)
|
|
339
|
+
else:
|
|
340
|
+
err(ln, "bad-beats", f"bad beat count in {tok!r} (expected :<digits>)")
|
|
341
|
+
if len(chord) > MAX_CHORD_TOKEN_LEN:
|
|
342
|
+
err(ln, "token-too-long", f"chord token too long: {chord[:20]}…")
|
|
343
|
+
cells.append(Cell(chord=chord, beats=beats, alt=inline_alt))
|
|
344
|
+
if len(cells) > MAX_CELLS_PER_MEASURE:
|
|
345
|
+
err(ln, "too-big", "too many cells in a measure")
|
|
346
|
+
break
|
|
347
|
+
return ending, cells
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Canonical IR <-> JSON serialization for the conformance suite.
|
|
2
|
+
|
|
3
|
+
Phase 2: the JSON reflects the NEUTRAL IR and carries a top-level ``schema_version: 1``.
|
|
4
|
+
``ast_from_json`` rejects an unknown major version. ``LeadSheet`` replaces ``DslChart``;
|
|
5
|
+
``Section.kind`` replaces ``marker``; ``Measure.barline`` (enum) replaces ``bar_close`` (glyph);
|
|
6
|
+
``Measure.hints`` is a new opaque string list. ``nav`` round-trips list<->tuple as before; the
|
|
7
|
+
new ``fine`` nav item is a bare ``["fine"]``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from tonalis.ast import Barline, Cell, LeadSheet, Measure, Section, SectionKind
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _meta_to_json(meta: dict) -> dict:
|
|
16
|
+
out = {}
|
|
17
|
+
for k, v in meta.items():
|
|
18
|
+
if k == "time" and isinstance(v, tuple):
|
|
19
|
+
out[k] = list(v)
|
|
20
|
+
else:
|
|
21
|
+
out[k] = v
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _meta_from_json(d: dict) -> dict:
|
|
26
|
+
out = {}
|
|
27
|
+
for k, v in d.items():
|
|
28
|
+
if k == "time" and isinstance(v, list):
|
|
29
|
+
out[k] = tuple(v)
|
|
30
|
+
else:
|
|
31
|
+
out[k] = v
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _nav_to_json(nav) -> list:
|
|
36
|
+
return [_nav_item_to_json(item) for item in nav]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _nav_item_to_json(item):
|
|
40
|
+
return [_nav_atom_to_json(a) for a in item]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _nav_atom_to_json(a):
|
|
44
|
+
if isinstance(a, tuple):
|
|
45
|
+
return list(a)
|
|
46
|
+
return a
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nav_from_json(nav) -> tuple:
|
|
50
|
+
return tuple(_nav_item_from_json(item) for item in nav)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _nav_item_from_json(item) -> tuple:
|
|
54
|
+
return tuple(_nav_atom_from_json(a) for a in item)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _nav_atom_from_json(a):
|
|
58
|
+
if isinstance(a, list):
|
|
59
|
+
return tuple(a)
|
|
60
|
+
return a
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cell_to_json(c: Cell) -> dict:
|
|
64
|
+
return {"chord": c.chord, "beats": c.beats, "alt": c.alt}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cell_from_json(d: dict) -> Cell:
|
|
68
|
+
return Cell(chord=d["chord"], beats=d.get("beats"), alt=d.get("alt"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _measure_to_json(m: Measure) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"cells": [_cell_to_json(c) for c in m.cells],
|
|
74
|
+
"ending": m.ending,
|
|
75
|
+
"bar_open": m.bar_open,
|
|
76
|
+
"barline": m.barline.value,
|
|
77
|
+
"nav": _nav_to_json(m.nav),
|
|
78
|
+
"hints": list(m.hints),
|
|
79
|
+
"line": m.line,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _measure_from_json(d: dict) -> Measure:
|
|
84
|
+
return Measure(
|
|
85
|
+
cells=[_cell_from_json(c) for c in d["cells"]],
|
|
86
|
+
ending=d.get("ending"),
|
|
87
|
+
bar_open=d.get("bar_open", False),
|
|
88
|
+
barline=Barline(d.get("barline", "normal")),
|
|
89
|
+
nav=_nav_from_json(d.get("nav", [])),
|
|
90
|
+
hints=tuple(d.get("hints", [])),
|
|
91
|
+
line=d.get("line", 0),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _section_to_json(s: Section) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"label": s.label,
|
|
98
|
+
"kind": s.kind.value,
|
|
99
|
+
"measures": [_measure_to_json(m) for m in s.measures],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _section_from_json(d: dict) -> Section:
|
|
104
|
+
return Section(
|
|
105
|
+
label=d["label"],
|
|
106
|
+
kind=SectionKind(d["kind"]),
|
|
107
|
+
measures=[_measure_from_json(m) for m in d["measures"]],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ast_to_json(chart: LeadSheet) -> dict:
|
|
112
|
+
"""Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1)."""
|
|
113
|
+
return {
|
|
114
|
+
"schema_version": SCHEMA_VERSION,
|
|
115
|
+
"meta": _meta_to_json(chart.meta),
|
|
116
|
+
"sections": [_section_to_json(s) for s in chart.sections],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Public aliases (spec §1.1 surface names): to_json / from_json.
|
|
121
|
+
to_json = ast_to_json
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_schema_version(d: dict) -> None:
|
|
125
|
+
"""Reject an unknown/malformed ``schema_version`` with a clean ValueError.
|
|
126
|
+
|
|
127
|
+
Missing -> default 1 (accepted). Present must be the INTEGER ``SCHEMA_VERSION`` — a string
|
|
128
|
+
``"2"``, ``null`` (Python ``None``), a bool, or any non-1 int are all rejected with a
|
|
129
|
+
ValueError (never a TypeError crash on ``int(None)``). Consistent with the TS/Rust ports.
|
|
130
|
+
"""
|
|
131
|
+
if "schema_version" not in d:
|
|
132
|
+
return
|
|
133
|
+
version = d["schema_version"]
|
|
134
|
+
# bool is an int subclass but is never a valid version; reject it explicitly.
|
|
135
|
+
if isinstance(version, bool) or not isinstance(version, int) or version != SCHEMA_VERSION:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"unsupported schema_version {version!r}; this build understands {SCHEMA_VERSION}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def ast_from_json(d: dict) -> LeadSheet:
|
|
142
|
+
"""Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown
|
|
143
|
+
``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical
|
|
144
|
+
JSON is a public interface — malformed input is rejected uniformly across all three ports)."""
|
|
145
|
+
_check_schema_version(d)
|
|
146
|
+
return LeadSheet(
|
|
147
|
+
meta=_meta_from_json(d.get("meta", {})),
|
|
148
|
+
sections=[_section_from_json(s) for s in d.get("sections", [])],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
from_json = ast_from_json
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Deterministic canonical-DSL pretty-printer: LeadSheet -> DSL text (Phase 2 §1.1).
|
|
2
|
+
|
|
3
|
+
The inverse direction of ``parser.parse_dsl`` for the canonical form. ``parse(serialize(ir)) == ir``
|
|
4
|
+
is gated by the conformance suite; this printer is the only place IR -> text lives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from tonalis.ast import Barline, LeadSheet, Measure, Section
|
|
8
|
+
|
|
9
|
+
_HEADER_ORDER = ("title", "composer", "style", "key", "time")
|
|
10
|
+
_BARLINE_GLYPH = {
|
|
11
|
+
Barline.NORMAL: "|",
|
|
12
|
+
Barline.REPEAT_END: "}",
|
|
13
|
+
Barline.FINAL: "||",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _header(meta: dict) -> str:
|
|
18
|
+
out = []
|
|
19
|
+
for k in _HEADER_ORDER:
|
|
20
|
+
if k not in meta:
|
|
21
|
+
continue
|
|
22
|
+
v = meta[k]
|
|
23
|
+
if k == "time":
|
|
24
|
+
out.append(f"time: {v[0]}/{v[1]}")
|
|
25
|
+
else:
|
|
26
|
+
out.append(f"{k}: {v}")
|
|
27
|
+
return "\n".join(out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cell_str(cell) -> str:
|
|
31
|
+
s = cell.chord
|
|
32
|
+
if cell.beats is not None:
|
|
33
|
+
s += f":{cell.beats}"
|
|
34
|
+
if cell.alt:
|
|
35
|
+
# A single-token alt glues inline (`G7(Db7)`, the grammar's "inline alt, no space" form),
|
|
36
|
+
# but a MULTI-token alt carries an interior space (`(A-7 D7)`) and the glued form
|
|
37
|
+
# `G7(A-7 D7)` re-tokenizes wrong (the cell scanner `\([^)]*\)|\S+` breaks at the space).
|
|
38
|
+
# Emit the separated `chord (alt)` form so `parse(serialize(ir)) == ir` holds (SPEC §2.1).
|
|
39
|
+
s += (" " if " " in cell.alt else "") + cell.alt
|
|
40
|
+
return s
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _nav_lines_before(m: Measure) -> list:
|
|
44
|
+
lines = []
|
|
45
|
+
for item in m.nav:
|
|
46
|
+
head = item[0]
|
|
47
|
+
if head == "segno":
|
|
48
|
+
lines.append("@segno")
|
|
49
|
+
elif head == "coda":
|
|
50
|
+
lines.append("@coda")
|
|
51
|
+
elif head == "fine":
|
|
52
|
+
lines.append("@fine")
|
|
53
|
+
elif head == "text":
|
|
54
|
+
lines.append(item[1])
|
|
55
|
+
elif head == "time":
|
|
56
|
+
lines.append(f"[time: {item[1][0]}/{item[1][1]}]")
|
|
57
|
+
# tocoda is emitted AFTER the measure (see _nav_after)
|
|
58
|
+
return lines
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nav_after(m: Measure) -> list:
|
|
62
|
+
return ["@tocoda" for item in m.nav if item and item[0] == "tocoda"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _measure_str(m: Measure, *, first_on_line: bool) -> str:
|
|
66
|
+
# Canonical body line: a leading "| " opens the line, measures are "| "-joined, and each
|
|
67
|
+
# measure ends with its barline glyph. A repeat-open prefixes "{ ".
|
|
68
|
+
prefix = "{ " if m.bar_open else ("| " if first_on_line else "")
|
|
69
|
+
body = ""
|
|
70
|
+
if m.ending is not None:
|
|
71
|
+
body += f"{m.ending}. "
|
|
72
|
+
body += " ".join(_cell_str(c) for c in m.cells)
|
|
73
|
+
return f"{prefix}{body} {_BARLINE_GLYPH[m.barline]}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _section_text(s: Section) -> str:
|
|
77
|
+
# One body line per section (parser-faithful): directives (@break/@segno/.../<text>) sit on
|
|
78
|
+
# their own line and FLUSH the in-progress measure line before them; @tocoda flushes after.
|
|
79
|
+
lines = [f"[{s.label}]"]
|
|
80
|
+
run = [] # measures accumulating on the current body line
|
|
81
|
+
|
|
82
|
+
def flush():
|
|
83
|
+
if run:
|
|
84
|
+
lines.append(" ".join(run))
|
|
85
|
+
run.clear()
|
|
86
|
+
|
|
87
|
+
for m in s.measures:
|
|
88
|
+
before = []
|
|
89
|
+
for h in m.hints:
|
|
90
|
+
if h == "break":
|
|
91
|
+
before.append("@break")
|
|
92
|
+
before.extend(_nav_lines_before(m))
|
|
93
|
+
if before:
|
|
94
|
+
flush()
|
|
95
|
+
lines.extend(before)
|
|
96
|
+
run.append(_measure_str(m, first_on_line=not run))
|
|
97
|
+
after = _nav_after(m)
|
|
98
|
+
if after:
|
|
99
|
+
flush()
|
|
100
|
+
lines.extend(after)
|
|
101
|
+
flush()
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def serialize(chart: LeadSheet) -> str:
|
|
106
|
+
"""Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated)."""
|
|
107
|
+
parts = [_header(chart.meta)]
|
|
108
|
+
for s in chart.sections:
|
|
109
|
+
parts.append("") # blank line before each section
|
|
110
|
+
parts.append(_section_text(s))
|
|
111
|
+
return "\n".join(parts) + "\n"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tonalis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text).
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 DRYCodeWorks
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/drycode/tonalis
|
|
28
|
+
Project-URL: Repository, https://github.com/drycode/tonalis
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Requires-Dist: tonalis-music-dsl==0.1.0
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# tonalis
|
|
37
|
+
|
|
38
|
+
A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
|
|
39
|
+
charts to a canonical AST, lint them with structured findings, and render back
|
|
40
|
+
to canonical text or JSON. This is the Python reference implementation;
|
|
41
|
+
TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
|
|
42
|
+
to the same normative spec and a shared 255-case conformance suite.
|
|
43
|
+
|
|
44
|
+
Music-theory questions (chord validity, scales, keys) are delegated to the
|
|
45
|
+
pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install tonalis
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from tonalis import parse_dsl, lint, serialize, to_json
|
|
57
|
+
|
|
58
|
+
result = parse_dsl(open("chart.txt").read())
|
|
59
|
+
findings = list(result.findings) + (lint(result.chart) if result.chart else [])
|
|
60
|
+
chart = result.chart # a LeadSheet AST
|
|
61
|
+
json_obj = to_json(chart) # canonical JSON (a dict)
|
|
62
|
+
text = serialize(chart) # canonical text rendering
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A small CLI is included: `python -m tonalis chart.txt` prints lint findings
|
|
66
|
+
(exit 1 if any error).
|
|
67
|
+
|
|
68
|
+
## Links
|
|
69
|
+
|
|
70
|
+
- Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
|
|
71
|
+
- License: MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tonalis/GRAMMAR.md
|
|
5
|
+
tonalis/__init__.py
|
|
6
|
+
tonalis/__main__.py
|
|
7
|
+
tonalis/ast.py
|
|
8
|
+
tonalis/chord_grammar.py
|
|
9
|
+
tonalis/chords.py
|
|
10
|
+
tonalis/lint.py
|
|
11
|
+
tonalis/parser.py
|
|
12
|
+
tonalis/serialize.py
|
|
13
|
+
tonalis/serialize_text.py
|
|
14
|
+
tonalis.egg-info/PKG-INFO
|
|
15
|
+
tonalis.egg-info/SOURCES.txt
|
|
16
|
+
tonalis.egg-info/dependency_links.txt
|
|
17
|
+
tonalis.egg-info/requires.txt
|
|
18
|
+
tonalis.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tonalis-music-dsl==0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tonalis
|