tonalis
Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic.
1"""Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic.""" 2 3from tonalis.ast import ( 4 Barline, 5 Cell, 6 LeadSheet, 7 LintFinding, 8 Measure, 9 ParseResult, 10 Section, 11 SectionKind, 12) 13from tonalis.chords import is_valid_chord 14from tonalis.lint import lint 15from tonalis.parser import parse_dsl 16from tonalis.serialize import ast_from_json, ast_to_json, from_json, to_json 17from 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]
81def parse_dsl(text): 82 findings = [] 83 84 def err(line, code, msg): 85 findings.append(LintFinding("error", line, code, msg)) 86 87 def warn(line, code, msg): 88 findings.append(LintFinding("warning", line, code, msg)) 89 90 if len(text.encode("utf-8", "replace")) > MAX_BYTES: 91 err(0, "too-big", "input exceeds MAX_BYTES") 92 return ParseResult(None, findings) 93 text = text.lstrip("") 94 lines = _split_lines(text) 95 if len(lines) > MAX_LINES: 96 err(0, "too-big", "too many lines") 97 return ParseResult(None, findings) 98 for n, raw in enumerate(lines, 1): 99 if len(raw) > MAX_LINE_LEN: 100 err(n, "too-big", "line too long") 101 return ParseResult(None, findings) 102 103 meta = {} 104 i, total = 0, len(lines) 105 106 def assign_meta(k, v, lineno): 107 # Header keys are singletons. Re-stating one is a duplicate (same value, redundant) 108 # or a conflict (different value, mutually exclusive) — never silently last-wins. 109 if k in meta: 110 if meta[k] == v: 111 warn(lineno, "duplicate-header", f"duplicate header {k!r}") 112 else: 113 err( 114 lineno, 115 "conflicting-header", 116 f"conflicting header {k!r}: {meta[k]!r} vs {v!r}", 117 ) 118 return # keep the first value; a conflict already blocks compile 119 meta[k] = v 120 121 # --- header --- 122 while i < total: 123 raw = lines[i] 124 line = raw.strip() 125 if line == "" or line.startswith("#"): 126 i += 1 127 continue 128 if line[0] in "[|{": 129 break 130 m = re.match(r"([A-Za-z]+)\s*:\s*(.*)$", raw) # value read literally to EOL 131 if not m: 132 break 133 key, val = m.group(1).lower(), m.group(2).rstrip() 134 if "=" in val: 135 err(i + 1, "meta-delimiter", f"'=' not allowed in metadata value: {val!r}") 136 if key == "time": 137 # ASCII digits only (SPEC.md §1.1); both components must be <= MAX_METER (§1.4). 138 tm = re.match(r"([0-9]+)\s*/\s*([0-9]+)$", val) 139 if tm and int(tm.group(1)) <= MAX_METER and int(tm.group(2)) <= MAX_METER: 140 assign_meta("time", (int(tm.group(1)), int(tm.group(2))), i + 1) 141 else: 142 err(i + 1, "bad-time", f"bad time signature: {val!r}") 143 elif key in _META_KEYS: 144 assign_meta(key, val, i + 1) 145 else: 146 warn(i + 1, "unknown-key", f"unknown header key: {key}") 147 i += 1 148 149 for k in _REQUIRED: 150 if k not in meta: 151 err(0, "missing-header", f"missing required header: {k}") 152 if meta.get("title", "") == "": 153 err(0, "missing-header", "title must be non-empty") 154 155 # --- body --- 156 sections = [] 157 cur = None 158 pending_nav = [] 159 pending_hints = [] 160 measure_count = 0 161 current_time = meta.get("time", (4, 4)) # for redundant mid-chart [time:] detection 162 163 def ensure_section(): 164 nonlocal cur 165 if cur is None: 166 cur = Section(label="A", kind=SectionKind.A) 167 sections.append(cur) 168 return cur 169 170 while i < total: 171 raw = lines[i] 172 line = raw.strip() 173 ln = i + 1 174 i += 1 175 if line == "" or line.startswith("#"): 176 continue 177 tm = _TIME_RE.match(line) 178 if line.startswith("[time") and tm: 179 n, d = int(tm.group(1)), int(tm.group(2)) 180 if n > MAX_METER or d > MAX_METER: 181 # recognized [time:…] shape but the meter is out of range (SPEC.md §1.4/§4.4): 182 # reject it as bad-time; it contributes no nav. 183 err(ln, "bad-time", f"meter component exceeds MAX_METER: {n}/{d}") 184 continue 185 newt = (n, d) 186 if newt == current_time: 187 warn( 188 ln, 189 "redundant-time", 190 f"[time: {newt[0]}/{newt[1]}] repeats the current meter", 191 ) 192 current_time = newt 193 pending_nav.append(("time", newt)) 194 continue 195 sec = _SECTION_RE.match(line) 196 if sec: 197 name = sec.group(1) 198 kind = _SECTIONS.get(name) 199 if kind is None: 200 err( 201 ln, 202 "unknown-section", 203 f"unknown section {name!r}; allowed: A-D, Intro, Verse", 204 ) 205 kind = SectionKind.A 206 cur = Section(label=name + sec.group(2), kind=kind) 207 sections.append(cur) 208 continue 209 if line.startswith("@"): 210 if "segno" in line: 211 pending_nav.append(("segno",)) 212 elif "tocoda" in line or "to coda" in line or "to-coda" in line: 213 # "To Coda" jump-point: a Q AFTER the PRECEDING measure's chords (not the 214 # next one). Checked before "coda" because "tocoda" contains "coda". 215 if cur is not None and cur.measures: 216 cur.measures[-1].nav = cur.measures[-1].nav + (("tocoda",),) 217 else: 218 warn( 219 ln, "tocoda-without-measure", "@tocoda has no preceding measure" 220 ) 221 elif "fine" in line: 222 pending_nav.append(("fine",)) 223 elif "coda" in line: 224 pending_nav.append(("coda",)) 225 elif "break" in line or "newline" in line: 226 # page-layout only — NO lead-sheet semantics. Demoted to an opaque 227 # render-hint (Phase 2 §3.3); a downstream codec reads it, text-target ignores it. 228 pending_hints.append("break") 229 else: 230 warn(ln, "unknown-nav", f"unknown @directive: {line}") 231 continue 232 if line.startswith("<") and line.endswith(">"): 233 pending_nav.append(("text", line)) 234 continue 235 # otherwise: a measure line 236 section = ensure_section() 237 measures = _parse_measure_line(line, ln, err, warn) 238 for m in measures: 239 if pending_nav: 240 m.nav = tuple(pending_nav) + m.nav 241 pending_nav = [] 242 if pending_hints: 243 m.hints = tuple(pending_hints) + m.hints 244 pending_hints = [] 245 section.measures.append(m) 246 measure_count += 1 247 if measure_count > MAX_MEASURES: 248 err(ln, "too-big", "too many measures") 249 return ParseResult(LeadSheet(meta=meta, sections=sections), findings) 250 251 if not sections or all(not s.measures for s in sections): 252 err(0, "empty-chart", "no sections or measures") 253 return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
38def lint(chart): 39 findings = [] 40 41 def err(line, code, msg): 42 findings.append(LintFinding("error", line, code, msg)) 43 44 def warn(line, code, msg): 45 findings.append(LintFinding("warning", line, code, msg)) 46 47 measures = _all_measures(chart) 48 numerator = (chart.meta.get("time") or (4, 4))[0] 49 50 # repeats / endings balance, codas/segno, final bar, per-measure beats + chords 51 balance = n_coda = n_segno = 0 52 last_idx = len(measures) - 1 53 for idx, (section, m) in enumerate(measures): 54 for nav in m.nav: 55 if nav == ("coda",): 56 n_coda += 1 57 elif nav == ("segno",): 58 n_segno += 1 59 elif nav and nav[0] == "time": 60 numerator = nav[1][0] 61 if m.bar_open: 62 balance += 1 63 if m.barline == Barline.REPEAT_END: 64 if balance == 0: 65 err(m.line, "unbalanced-repeat", "'}' with no matching '{'") 66 else: 67 balance -= 1 68 if m.barline == Barline.FINAL and idx != last_idx: 69 warn( 70 m.line, 71 "mid-final-bar", 72 "final barline (Z/||) is not on the last measure", 73 ) 74 75 # chords 76 for c in m.cells: 77 # skip is_valid_chord for tokens that already triggered token-too-long (parser 78 # error already emitted; MusicDSL would reject them as bad-chord too but the 79 # conformance spec treats over-length tokens as a single token-too-long error) 80 if c.chord and len(c.chord) <= MAX_CHORD_TOKEN_LEN and not is_valid_chord(c.chord): 81 err(m.line, "bad-chord", f"invalid chord token: {c.chord!r}") 82 if c.alt: 83 for tok in c.alt.strip("()").split(): 84 if not is_valid_chord(tok): 85 err(m.line, "bad-chord", f"invalid alt chord: {tok!r}") 86 87 # empty measure 88 if not m.cells: 89 warn(m.line, "empty-measure", "measure has no chords (emitted as N.C.)") 90 continue 91 92 # beats. Pickup relaxation only excuses an UNDER-full first/last bar (anacrusis); 93 # an OVER-full bar is always wrong, even for a pickup. 94 pattern, ok = _beat_pattern(m, numerator) 95 total = sum(pattern) 96 is_pickup = idx in (0, last_idx) 97 if total > numerator: 98 err(m.line, "beat-sum", f"beats {pattern} overflow a {numerator}-beat bar") 99 elif not ok and not is_pickup: 100 err( 101 m.line, 102 "beat-sum", 103 f"beats {pattern} do not fill a {numerator}-beat bar", 104 ) 105 elif ok and len(set(pattern)) > 1 and pattern not in _ALLOWED_UNEVEN: 106 warn( 107 m.line, 108 "beat-unsupported", 109 f"uneven beat layout {pattern} not in the allowlist; even-split fallback", 110 ) 111 112 if balance != 0: 113 err(0, "unbalanced-repeat", f"{balance} unclosed '{{'") 114 # an empty section (header but no measures) silently drops in render — surface it 115 for section in chart.sections: 116 if not section.measures: 117 warn( 118 0, 119 "empty-section", 120 f"section [{section.label}] has no measures (dropped)", 121 ) 122 # nth endings must belong to a repeat (a section with endings needs a bar_open) 123 for section in chart.sections: 124 has_open = any(m.bar_open for m in section.measures) 125 if any(m.ending for m in section.measures) and not has_open: 126 ln = next((m.line for m in section.measures if m.ending), 0) 127 err(ln, "ending-without-repeat", "nth ending outside a repeat block") 128 if n_coda > 2: 129 err( 130 0, 131 "coda-count", 132 f"{n_coda} coda points (max 2 — the encoder cannot flatten more)", 133 ) 134 elif n_coda == 2 and n_segno == 0: 135 warn( 136 0, 137 "coda-needs-segno", 138 "2-coda jump without @segno (will play D.C., not D.S.)", 139 ) 140 141 return findings
27def is_valid_chord(token: str) -> bool: 28 """True iff the token is a well-formed chord OR a no-chord / bass marker.""" 29 if token in ("N.C.", "n"): 30 return True 31 if not token: 32 return False 33 t = token.replace("*", "") # tolerate the augmented layout-star artifact (Bb*7+*) 34 if _SLASH_BASS.match(t): 35 return True 36 try: 37 Chord(t) 38 return True 39 except InvalidChordStringException: 40 return False
True iff the token is a well-formed chord OR a no-chord / bass marker.
106def serialize(chart: LeadSheet) -> str: 107 """Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated).""" 108 parts = [_header(chart.meta)] 109 for s in chart.sections: 110 parts.append("") # blank line before each section 111 parts.append(_section_text(s)) 112 return "\n".join(parts) + "\n"
Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated).
112def ast_to_json(chart: LeadSheet) -> dict: 113 """Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1).""" 114 return { 115 "schema_version": SCHEMA_VERSION, 116 "meta": _meta_to_json(chart.meta), 117 "sections": [_section_to_json(s) for s in chart.sections], 118 }
Serialize a LeadSheet to the canonical JSON-able dict (SPEC.md §2.1).
142def ast_from_json(d: dict) -> LeadSheet: 143 """Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown 144 ``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical 145 JSON is a public interface — malformed input is rejected uniformly across all three ports).""" 146 _check_schema_version(d) 147 return LeadSheet( 148 meta=_meta_from_json(d.get("meta", {})), 149 sections=[_section_from_json(s) for s in d.get("sections", [])], 150 )
Inverse of ast_to_json(). Rejects an unknown major schema version, an unknown
Section.kind, or an unknown Measure.barline with a clean ValueError (the canonical
JSON is a public interface — malformed input is rejected uniformly across all three ports).
112def ast_to_json(chart: LeadSheet) -> dict: 113 """Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1).""" 114 return { 115 "schema_version": SCHEMA_VERSION, 116 "meta": _meta_to_json(chart.meta), 117 "sections": [_section_to_json(s) for s in chart.sections], 118 }
Serialize a LeadSheet to the canonical JSON-able dict (SPEC.md §2.1).
142def ast_from_json(d: dict) -> LeadSheet: 143 """Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown 144 ``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical 145 JSON is a public interface — malformed input is rejected uniformly across all three ports).""" 146 _check_schema_version(d) 147 return LeadSheet( 148 meta=_meta_from_json(d.get("meta", {})), 149 sections=[_section_from_json(s) for s in d.get("sections", [])], 150 )
Inverse of ast_to_json(). Rejects an unknown major schema version, an unknown
Section.kind, or an unknown Measure.barline with a clean ValueError (the canonical
JSON is a public interface — malformed input is rejected uniformly across all three ports).
77@dataclass 78class LeadSheet: 79 meta: dict = field(default_factory=dict) # title/composer/style/key/time 80 sections: List[Section] = field(default_factory=list)
33@dataclass 34class Cell: 35 chord: str 36 beats: Optional[int] = None # explicit :N, else None (even split of the bar) 37 alt: Optional[str] = None # raw "(A-7 D7)" alt-chord text, if any 38 39 @property 40 def chord_obj(self): 41 """Lazy MusicDSL Chord for a present chord token; None for empty / no-chord / 42 bare-slash cells; raises InvalidChordStringException for a malformed-present token.""" 43 from music_dsl.domain.chords.chord import Chord # lazy: keeps the dep at use-time 44 t = (self.chord or "").replace("*", "") 45 if not t or t in ("N.C.", "n") or t.startswith("/"): 46 return None 47 return Chord(t) 48 49 @property 50 def chord_obj_or_none(self): 51 """Non-raising form of chord_obj (None for malformed tokens too).""" 52 from music_dsl.domain.chords.abstract_chord import InvalidChordStringException 53 try: 54 return self.chord_obj 55 except InvalidChordStringException: 56 return None
39 @property 40 def chord_obj(self): 41 """Lazy MusicDSL Chord for a present chord token; None for empty / no-chord / 42 bare-slash cells; raises InvalidChordStringException for a malformed-present token.""" 43 from music_dsl.domain.chords.chord import Chord # lazy: keeps the dep at use-time 44 t = (self.chord or "").replace("*", "") 45 if not t or t in ("N.C.", "n") or t.startswith("/"): 46 return None 47 return Chord(t)
Lazy MusicDSL Chord for a present chord token; None for empty / no-chord / bare-slash cells; raises InvalidChordStringException for a malformed-present token.
49 @property 50 def chord_obj_or_none(self): 51 """Non-raising form of chord_obj (None for malformed tokens too).""" 52 from music_dsl.domain.chords.abstract_chord import InvalidChordStringException 53 try: 54 return self.chord_obj 55 except InvalidChordStringException: 56 return None
Non-raising form of chord_obj (None for malformed tokens too).
59@dataclass 60class Measure: 61 cells: List[Cell] = field(default_factory=list) 62 ending: Optional[int] = None # 1./2. nth-ending number 63 bar_open: bool = False # preceded by '{' 64 barline: Barline = Barline.NORMAL # neutral right-barline (was bar_close: str) 65 nav: Tuple = () # ('segno',), ('coda',), ('fine',), ('text', '<...>'), ('time', (n, d)) 66 hints: Tuple[str, ...] = () # opaque render-hints, e.g. ('break',); NO semantics 67 line: int = 0 # source line for findings
70@dataclass 71class Section: 72 label: str 73 kind: SectionKind # neutral role (the parser derives it from the bracket name) 74 measures: List[Measure] = field(default_factory=list)
16class SectionKind(str, Enum): 17 """Neutral section role, derived by the parser from the bracket name.""" 18 A = "a" 19 B = "b" 20 C = "c" 21 D = "d" 22 INTRO = "intro" 23 VERSE = "verse"
Neutral section role, derived by the parser from the bracket name.
26class Barline(str, Enum): 27 """Neutral right-barline. ``'|'`` -> NORMAL, ``'}'`` -> REPEAT_END, ``'Z'``/``'||'`` -> FINAL.""" 28 NORMAL = "normal" 29 REPEAT_END = "repeat_end" 30 FINAL = "final"
Neutral right-barline. '|' -> NORMAL, '}' -> REPEAT_END, 'Z'/'||' -> FINAL.
91@dataclass 92class ParseResult: 93 chart: Optional[LeadSheet] 94 findings: List[LintFinding] = field(default_factory=list)