mxl-agent 0.1.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.
- mxl_agent/__init__.py +1 -0
- mxl_agent/adapters/__init__.py +5 -0
- mxl_agent/adapters/chord_text.py +89 -0
- mxl_agent/adapters/jjazzlab_sng.py +200 -0
- mxl_agent/adapters/mcp.py +246 -0
- mxl_agent/adapters/mei.py +59 -0
- mxl_agent/adapters/musescore.py +206 -0
- mxl_agent/adapters/render_identity.py +237 -0
- mxl_agent/adapters/timemap.py +77 -0
- mxl_agent/adapters/vendor/ext_apps_client.js +86 -0
- mxl_agent/adapters/verovio.py +154 -0
- mxl_agent/analysis/__init__.py +49 -0
- mxl_agent/analysis/_cross_part.py +71 -0
- mxl_agent/analysis/_harmony.py +141 -0
- mxl_agent/analysis/_key.py +58 -0
- mxl_agent/analysis/_time_signature.py +50 -0
- mxl_agent/analysis/_transposition.py +28 -0
- mxl_agent/analysis/active_key.py +74 -0
- mxl_agent/analysis/base.py +30 -0
- mxl_agent/analysis/cadence_candidate.py +84 -0
- mxl_agent/analysis/chord_recognition.py +101 -0
- mxl_agent/analysis/chord_tone.py +168 -0
- mxl_agent/analysis/doubling.py +108 -0
- mxl_agent/analysis/harmonic_rhythm.py +105 -0
- mxl_agent/analysis/interval_above_bass.py +137 -0
- mxl_agent/analysis/lint.py +249 -0
- mxl_agent/analysis/local_key.py +131 -0
- mxl_agent/analysis/non_chord_tone.py +196 -0
- mxl_agent/analysis/note_density.py +61 -0
- mxl_agent/analysis/parallel_motion.py +132 -0
- mxl_agent/analysis/playability.py +119 -0
- mxl_agent/analysis/polyphony.py +53 -0
- mxl_agent/analysis/registry.py +31 -0
- mxl_agent/analysis/roman_numeral.py +145 -0
- mxl_agent/analysis/run.py +22 -0
- mxl_agent/analysis/scale_degree.py +175 -0
- mxl_agent/analysis/syncopation.py +104 -0
- mxl_agent/analysis/voice_leading.py +113 -0
- mxl_agent/cli.py +1471 -0
- mxl_agent/document/mutation_guard.py +80 -0
- mxl_agent/document/score_document.py +83 -0
- mxl_agent/document/source_locator.py +45 -0
- mxl_agent/errors.py +29 -0
- mxl_agent/generate/__init__.py +5 -0
- mxl_agent/generate/accompaniment.py +236 -0
- mxl_agent/generate/arrangement.py +103 -0
- mxl_agent/generate/from_midi.py +392 -0
- mxl_agent/generate/lead_sheet.py +300 -0
- mxl_agent/generate/reharmonization.py +305 -0
- mxl_agent/generate/voicing.py +258 -0
- mxl_agent/midi/__init__.py +2 -0
- mxl_agent/midi/reader.py +232 -0
- mxl_agent/model/addresses.py +17 -0
- mxl_agent/model/chord_recognition.py +91 -0
- mxl_agent/model/chord_tones.py +145 -0
- mxl_agent/model/diff.py +34 -0
- mxl_agent/model/duration.py +169 -0
- mxl_agent/model/events.py +38 -0
- mxl_agent/model/harmony.py +561 -0
- mxl_agent/model/instrument_ranges.py +81 -0
- mxl_agent/model/key_signature.py +135 -0
- mxl_agent/model/layout.py +61 -0
- mxl_agent/model/merge.py +135 -0
- mxl_agent/model/metadata.py +36 -0
- mxl_agent/model/non_chord_tone.py +54 -0
- mxl_agent/model/pitch.py +177 -0
- mxl_agent/model/roman_numeral.py +300 -0
- mxl_agent/model/score_index.py +208 -0
- mxl_agent/model/selection.py +203 -0
- mxl_agent/model/semantic_diff.py +309 -0
- mxl_agent/model/timeline.py +299 -0
- mxl_agent/model/timewise.py +126 -0
- mxl_agent/operations/__init__.py +44 -0
- mxl_agent/operations/_key.py +71 -0
- mxl_agent/operations/_measure_attributes.py +84 -0
- mxl_agent/operations/_note_selection.py +120 -0
- mxl_agent/operations/_note_xml.py +117 -0
- mxl_agent/operations/_offset_position.py +65 -0
- mxl_agent/operations/_parts.py +44 -0
- mxl_agent/operations/_score_header.py +39 -0
- mxl_agent/operations/_xml_order.py +26 -0
- mxl_agent/operations/barlines.py +247 -0
- mxl_agent/operations/base.py +31 -0
- mxl_agent/operations/change_duration.py +234 -0
- mxl_agent/operations/chord_symbol_style.py +157 -0
- mxl_agent/operations/copy_notes.py +330 -0
- mxl_agent/operations/create_tuplet.py +343 -0
- mxl_agent/operations/credit.py +186 -0
- mxl_agent/operations/delete_note.py +172 -0
- mxl_agent/operations/direction_spanners.py +586 -0
- mxl_agent/operations/directions.py +393 -0
- mxl_agent/operations/envelope.py +67 -0
- mxl_agent/operations/harmony.py +339 -0
- mxl_agent/operations/insert_note.py +328 -0
- mxl_agent/operations/layout.py +321 -0
- mxl_agent/operations/lyrics.py +248 -0
- mxl_agent/operations/measure_operations.py +448 -0
- mxl_agent/operations/merge_tied_notes.py +228 -0
- mxl_agent/operations/metadata.py +299 -0
- mxl_agent/operations/move_note.py +313 -0
- mxl_agent/operations/noop.py +42 -0
- mxl_agent/operations/notations.py +422 -0
- mxl_agent/operations/notes.py +171 -0
- mxl_agent/operations/registry.py +28 -0
- mxl_agent/operations/rename_part.py +186 -0
- mxl_agent/operations/repair.py +326 -0
- mxl_agent/operations/respell.py +167 -0
- mxl_agent/operations/score_defaults.py +286 -0
- mxl_agent/operations/set_attributes.py +422 -0
- mxl_agent/operations/spanners.py +383 -0
- mxl_agent/operations/split_note.py +236 -0
- mxl_agent/operations/transpose.py +205 -0
- mxl_agent/package/__init__.py +0 -0
- mxl_agent/package/compare.py +96 -0
- mxl_agent/package/manifest.py +33 -0
- mxl_agent/package/reader.py +189 -0
- mxl_agent/package/safety.py +262 -0
- mxl_agent/package/writer.py +96 -0
- mxl_agent/patch/__init__.py +0 -0
- mxl_agent/patch/apply.py +51 -0
- mxl_agent/patch/inverse.py +88 -0
- mxl_agent/patch/model.py +136 -0
- mxl_agent/patch/plan.py +79 -0
- mxl_agent/patch/repair.py +44 -0
- mxl_agent/query/inspect.py +134 -0
- mxl_agent/query/pagination.py +90 -0
- mxl_agent/query/projection.py +101 -0
- mxl_agent/result.py +40 -0
- mxl_agent/service/__init__.py +13 -0
- mxl_agent/service/session.py +96 -0
- mxl_agent/session/__init__.py +0 -0
- mxl_agent/session/checkpoints.py +63 -0
- mxl_agent/session/revisions.py +92 -0
- mxl_agent/session/selection_store.py +47 -0
- mxl_agent/session/transaction.py +451 -0
- mxl_agent/session/workspace.py +272 -0
- mxl_agent/validation/__init__.py +0 -0
- mxl_agent/validation/compatibility.py +65 -0
- mxl_agent/validation/issue.py +15 -0
- mxl_agent/validation/package.py +29 -0
- mxl_agent/validation/preservation.py +80 -0
- mxl_agent/validation/relationships.py +113 -0
- mxl_agent/validation/reopen.py +34 -0
- mxl_agent/validation/report.py +70 -0
- mxl_agent/validation/schema.py +29 -0
- mxl_agent/validation/temporal.py +63 -0
- mxl_agent/viewer/__init__.py +10 -0
- mxl_agent/viewer/server.py +633 -0
- mxl_agent/viewer/static/index.html +210 -0
- mxl_agent/viewer/static/viewer.js +790 -0
- mxl_agent/xml/__init__.py +0 -0
- mxl_agent/xml/canonical.py +41 -0
- mxl_agent/xml/detect.py +35 -0
- mxl_agent/xml/parser.py +84 -0
- mxl_agent/xml/schema.py +140 -0
- mxl_agent-0.1.0.dist-info/METADATA +80 -0
- mxl_agent-0.1.0.dist-info/RECORD +161 -0
- mxl_agent-0.1.0.dist-info/WHEEL +4 -0
- mxl_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mxl_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- mxl_agent-0.1.0.dist-info/licenses/NOTICE +84 -0
mxl_agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Chord-chart text adapters: a small, pluggable text grammar for typed chord-chart input
|
|
2
|
+
(Section 15.12, 20 Slice 8.2).
|
|
3
|
+
|
|
4
|
+
This slice implements only "a small documented generic dialect" -- the spec bullet's first half.
|
|
5
|
+
JJazzLab-oriented fixtures/dialect support is a documented follow-up, not implemented here.
|
|
6
|
+
|
|
7
|
+
Grammar (the "generic" dialect):
|
|
8
|
+
|
|
9
|
+
- Bars are separated by ``|``. A single leading and/or trailing ``|`` (an opening/closing
|
|
10
|
+
barline) is optional and stripped before splitting -- it does not itself produce an extra bar.
|
|
11
|
+
- Within a bar, chord symbols are separated by whitespace and are spaced evenly across the bar's
|
|
12
|
+
exact quarter-note length (Section 3.5's exact rational time, never a float): the *i*-th of
|
|
13
|
+
*n* tokens in a bar sits at offset ``i * measure_length / n``.
|
|
14
|
+
- An empty bar (no tokens between two ``|``) means no chord in that measure -- a deliberate rest/
|
|
15
|
+
unspecified bar, not an error.
|
|
16
|
+
- A bar containing exactly the single token ``%`` repeats the immediately preceding bar's chord
|
|
17
|
+
entries verbatim (the standard chart "simile" convention); it cannot be the chart's first bar,
|
|
18
|
+
and cannot be mixed with other tokens in the same bar.
|
|
19
|
+
|
|
20
|
+
This grammar does not itself validate chord-symbol *text* -- ``symbol`` is carried through as-is.
|
|
21
|
+
That mirrors ``generate.lead_sheet``'s own layering: its harmony entries aren't run through
|
|
22
|
+
``model.harmony.parse_chord_symbol`` until they are actually materialized, keeping "chart
|
|
23
|
+
grammar" and "chord-symbol semantics" as two separately testable layers.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from fractions import Fraction
|
|
30
|
+
|
|
31
|
+
from mxl_agent.errors import MxlAgentError
|
|
32
|
+
from mxl_agent.model.duration import measure_quarter_length
|
|
33
|
+
|
|
34
|
+
__all__ = ["ChordChartParseError", "ChordChartEntry", "parse_generic_chord_chart"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChordChartParseError(MxlAgentError):
|
|
38
|
+
code = "CHORD_CHART_PARSE_ERROR"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ChordChartEntry:
|
|
43
|
+
offset_q: Fraction
|
|
44
|
+
symbol: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strip_outer_barlines(text: str) -> str:
|
|
48
|
+
stripped = text.strip()
|
|
49
|
+
if stripped.startswith("|"):
|
|
50
|
+
stripped = stripped[1:]
|
|
51
|
+
if stripped.endswith("|"):
|
|
52
|
+
stripped = stripped[:-1]
|
|
53
|
+
return stripped
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_generic_chord_chart(
|
|
57
|
+
text: str, *, beats: int, beat_type: int
|
|
58
|
+
) -> tuple[tuple[ChordChartEntry, ...], ...]:
|
|
59
|
+
"""Parse ``text`` (the generic dialect above) into one tuple of ``ChordChartEntry`` per bar,
|
|
60
|
+
in order. ``beats``/``beat_type`` give the time signature used to space each bar's tokens --
|
|
61
|
+
same meaning as ``model.duration.measure_quarter_length``."""
|
|
62
|
+
if not text.strip():
|
|
63
|
+
raise ChordChartParseError("chord chart text is empty")
|
|
64
|
+
measure_length = measure_quarter_length(beats, beat_type)
|
|
65
|
+
|
|
66
|
+
bars: list[tuple[ChordChartEntry, ...]] = []
|
|
67
|
+
body = _strip_outer_barlines(text)
|
|
68
|
+
for bar_index, raw_bar in enumerate(body.split("|")):
|
|
69
|
+
tokens = raw_bar.split()
|
|
70
|
+
if "%" in tokens:
|
|
71
|
+
if tokens != ["%"]:
|
|
72
|
+
raise ChordChartParseError(
|
|
73
|
+
f"bar {bar_index}: '%' must be the only token in its bar, got {tokens!r}"
|
|
74
|
+
)
|
|
75
|
+
if not bars:
|
|
76
|
+
raise ChordChartParseError(
|
|
77
|
+
f"bar {bar_index} uses '%' but there is no preceding bar to repeat"
|
|
78
|
+
)
|
|
79
|
+
bars.append(bars[-1])
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
count = len(tokens)
|
|
83
|
+
bars.append(
|
|
84
|
+
tuple(
|
|
85
|
+
ChordChartEntry(offset_q=measure_length * i / count, symbol=token)
|
|
86
|
+
for i, token in enumerate(tokens)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return tuple(bars)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""JJazzLab ``.sng`` chord-chart import (Section 15.12, 20 Slice 8.2's JJazzLab residual,
|
|
2
|
+
Slice 11.13).
|
|
3
|
+
|
|
4
|
+
``.sng`` is JJazzLab's own project file format: a custom (not raw-reflection XStream) XML
|
|
5
|
+
serialization of its internal song model, undocumented and not covered by any compatibility
|
|
6
|
+
guarantee -- field names could shift across JJazzLab versions. Reverse-engineered here against a
|
|
7
|
+
real corpus of ~1,460 ``.sng`` files (JJazzLab 4, each itself converted from iReal Pro charts via
|
|
8
|
+
MuseScore/MusicXML import), not just a single sample. Read-only and best-effort: this project
|
|
9
|
+
never authors ``.sng``, only extracts a chord leadsheet from an existing one.
|
|
10
|
+
|
|
11
|
+
Structure actually observed (the only part this adapter reads):
|
|
12
|
+
|
|
13
|
+
- ``<Song spName="..." spTempo="...">`` -- the whole project. ``spName``/``spTempo`` become
|
|
14
|
+
``JJazzLabChart.name``/``.tempo``.
|
|
15
|
+
- ``<spChordLeadSheet><spItems>...</spItems><spSize>N</spSize></spChordLeadSheet>`` -- the chord
|
|
16
|
+
chart itself, ``N`` bars long (global bar count, not per-section). ``<spItems>`` interleaves:
|
|
17
|
+
- ``<CLI__SectionImpl spName="..." spTs="FOUR_FOUR" spBarIndex="8">`` -- a section starting at
|
|
18
|
+
a global bar index, carrying its own time signature (``spTs``: one of the seven enum values
|
|
19
|
+
in ``_TIME_SIGNATURES`` below, the complete set found across the whole corpus). A later
|
|
20
|
+
section's ``spTs`` applies from its ``spBarIndex`` onward, carried forward exactly like every
|
|
21
|
+
other "active X at this position" walk in this codebase (e.g. ``analysis._key.active_key_at``)
|
|
22
|
+
-- confirmed necessary, not just theoretical: several real files in the corpus do change time
|
|
23
|
+
signature mid-chart.
|
|
24
|
+
- ``<CLI__ChordSymbolImpl>`` wrapping ``<spChord spName="Fm7" .../>`` (the chord symbol text,
|
|
25
|
+
JJazzLab's own dialect, structurally the same vocabulary as a plain pop chord symbol -- slash
|
|
26
|
+
chords, altered tensions, etc.) and ``<spPos spPos="[bar:beat]"/>`` (a 0-indexed global bar
|
|
27
|
+
and a 0-indexed *beat* within that bar's own time signature -- confirmed against real files:
|
|
28
|
+
beat values only ever appear in the range the active time signature's beat count allows).
|
|
29
|
+
When a section repeats an earlier one with the exact same chord (common: an "A-2" section
|
|
30
|
+
reusing "A-1"'s chords), JJazzLab does not duplicate the ``<spChord>`` content -- it writes an
|
|
31
|
+
empty ``<spChord reference="../../CLI__ChordSymbolImpl[N]/spChord"/>`` pointing back at the
|
|
32
|
+
first real one instead (an XStream object-identity back-reference). Confirmed the *only*
|
|
33
|
+
reference shape in the corpus (``_CHORD_BACK_REFERENCE``); resolved via a validated,
|
|
34
|
+
restricted XPath lookup rather than executing the untrusted ``reference`` string outright.
|
|
35
|
+
|
|
36
|
+
Deliberately not read: ``<spSongStructure>``/``<SongPartImpl>`` (rhythm-style/playback assignment
|
|
37
|
+
-- a different concern from the chord chart itself), ``<spRenderingInfo>`` (display-only chord
|
|
38
|
+
symbol features like PEDAL_BASS), ``<spClientProperties>`` (arbitrary key/value extension data),
|
|
39
|
+
and the back-reference ``reference="../.."``-style XStream plumbing used for cyclic object graphs.
|
|
40
|
+
None of these affect the chord-chart content this adapter extracts.
|
|
41
|
+
|
|
42
|
+
Output deliberately mirrors ``adapters.chord_text.parse_generic_chord_chart``'s shape -- one tuple
|
|
43
|
+
of ``ChordChartEntry`` per bar, same dataclass -- so a parsed JJazzLab chart slots into the exact
|
|
44
|
+
same "copy into `create lead-sheet`'s `measures[].harmony` by hand" workflow Slice 8.2 already
|
|
45
|
+
established, rather than inventing a second, incompatible typed shape for the same kind of data.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import re
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from fractions import Fraction
|
|
53
|
+
|
|
54
|
+
from lxml import etree
|
|
55
|
+
|
|
56
|
+
from mxl_agent.adapters.chord_text import ChordChartEntry
|
|
57
|
+
from mxl_agent.errors import MxlAgentError
|
|
58
|
+
from mxl_agent.model.duration import measure_quarter_length
|
|
59
|
+
from mxl_agent.xml.parser import parse_xml_document
|
|
60
|
+
|
|
61
|
+
# The one back-reference shape found across a ~1,460-file corpus (see below): a repeated chord
|
|
62
|
+
# object -- the same chord symbol reused verbatim, typically because a later section repeats an
|
|
63
|
+
# earlier one -- is serialized as an empty `<spChord reference="../../CLI__ChordSymbolImpl.../
|
|
64
|
+
# spChord"/>` pointing back at the first `<spChord>` with the real content, instead of duplicating
|
|
65
|
+
# it. Validated against this fixed pattern (rather than handed to `.xpath()` unchecked) since
|
|
66
|
+
# `reference` is untrusted input and arbitrary XPath is not a string this project executes blind.
|
|
67
|
+
_CHORD_BACK_REFERENCE = re.compile(r"^(?:\.\./)+CLI__ChordSymbolImpl(?:\[[1-9][0-9]*\])?/spChord$")
|
|
68
|
+
|
|
69
|
+
__all__ = ["JJazzLabSngParseError", "JJazzLabChart", "parse_jjazzlab_sng"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class JJazzLabSngParseError(MxlAgentError):
|
|
73
|
+
code = "JJAZZLAB_SNG_PARSE_ERROR"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# The complete set of JJazzLab time-signature enum values found across a ~1,460-file corpus.
|
|
77
|
+
_TIME_SIGNATURES: dict[str, tuple[int, int]] = {
|
|
78
|
+
"TWO_FOUR": (2, 4),
|
|
79
|
+
"THREE_FOUR": (3, 4),
|
|
80
|
+
"FOUR_FOUR": (4, 4),
|
|
81
|
+
"FIVE_FOUR": (5, 4),
|
|
82
|
+
"SIX_FOUR": (6, 4),
|
|
83
|
+
"SIX_EIGHT": (6, 8),
|
|
84
|
+
"TWELVE_EIGHT": (12, 8),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class JJazzLabChart:
|
|
90
|
+
name: str
|
|
91
|
+
tempo: int | None
|
|
92
|
+
bars: tuple[tuple[ChordChartEntry, ...], ...]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_chord_element(spchord_el: etree._Element) -> etree._Element:
|
|
96
|
+
"""Follow a repeated chord's back-``reference`` (see ``_CHORD_BACK_REFERENCE``) to the
|
|
97
|
+
``<spChord>`` element that actually carries ``spName``; returns ``spchord_el`` unchanged if it
|
|
98
|
+
has no ``reference``."""
|
|
99
|
+
reference = spchord_el.get("reference")
|
|
100
|
+
if reference is None:
|
|
101
|
+
return spchord_el
|
|
102
|
+
if not _CHORD_BACK_REFERENCE.match(reference):
|
|
103
|
+
raise JJazzLabSngParseError(f"unrecognized <spChord> reference shape: {reference!r}")
|
|
104
|
+
targets = spchord_el.xpath(reference)
|
|
105
|
+
if not isinstance(targets, list) or not targets or not isinstance(targets[0], etree._Element):
|
|
106
|
+
raise JJazzLabSngParseError(f"<spChord> reference {reference!r} does not resolve")
|
|
107
|
+
return targets[0]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_position(raw: str, item_desc: str) -> tuple[int, int]:
|
|
111
|
+
body = raw[1:-1] if raw.startswith("[") and raw.endswith("]") else None
|
|
112
|
+
if body is None or ":" not in body:
|
|
113
|
+
raise JJazzLabSngParseError(f"{item_desc}: malformed position {raw!r}")
|
|
114
|
+
bar_text, _, beat_text = body.partition(":")
|
|
115
|
+
try:
|
|
116
|
+
return int(bar_text), int(beat_text)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise JJazzLabSngParseError(f"{item_desc}: malformed position {raw!r}") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_jjazzlab_sng(data: bytes) -> JJazzLabChart:
|
|
122
|
+
"""Extract a chord chart from a JJazzLab ``.sng`` project file's bytes."""
|
|
123
|
+
tree = parse_xml_document(data)
|
|
124
|
+
root = tree.getroot()
|
|
125
|
+
if root.tag != "Song":
|
|
126
|
+
raise JJazzLabSngParseError(f"expected a <Song> root element, got <{root.tag}>")
|
|
127
|
+
|
|
128
|
+
name = root.get("spName") or ""
|
|
129
|
+
tempo_text = root.get("spTempo")
|
|
130
|
+
tempo = int(tempo_text) if tempo_text is not None and tempo_text.isdigit() else None
|
|
131
|
+
|
|
132
|
+
leadsheet = root.find("spChordLeadSheet")
|
|
133
|
+
if leadsheet is None:
|
|
134
|
+
raise JJazzLabSngParseError("<Song> has no <spChordLeadSheet>")
|
|
135
|
+
items = leadsheet.find("spItems")
|
|
136
|
+
size_el = leadsheet.find("spSize")
|
|
137
|
+
if items is None or size_el is None or size_el.text is None:
|
|
138
|
+
raise JJazzLabSngParseError("<spChordLeadSheet> is missing <spItems> or <spSize>")
|
|
139
|
+
try:
|
|
140
|
+
size = int(size_el.text)
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise JJazzLabSngParseError(f"<spSize> is not an integer: {size_el.text!r}") from exc
|
|
143
|
+
|
|
144
|
+
sections: list[tuple[int, tuple[int, int]]] = []
|
|
145
|
+
for section_el in items.findall("CLI__SectionImpl"):
|
|
146
|
+
bar_index_text = section_el.get("spBarIndex")
|
|
147
|
+
ts_text = section_el.get("spTs")
|
|
148
|
+
if bar_index_text is None or ts_text is None:
|
|
149
|
+
raise JJazzLabSngParseError("<CLI__SectionImpl> is missing spBarIndex or spTs")
|
|
150
|
+
if ts_text not in _TIME_SIGNATURES:
|
|
151
|
+
raise JJazzLabSngParseError(f"unrecognized JJazzLab time signature {ts_text!r}")
|
|
152
|
+
try:
|
|
153
|
+
bar_index = int(bar_index_text)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
raise JJazzLabSngParseError(
|
|
156
|
+
f"<CLI__SectionImpl> spBarIndex is not an integer: {bar_index_text!r}"
|
|
157
|
+
) from exc
|
|
158
|
+
sections.append((bar_index, _TIME_SIGNATURES[ts_text]))
|
|
159
|
+
if not sections or sections[0][0] != 0:
|
|
160
|
+
raise JJazzLabSngParseError("chord leadsheet has no section starting at bar 0")
|
|
161
|
+
sections.sort(key=lambda entry: entry[0])
|
|
162
|
+
|
|
163
|
+
def _time_signature_at(bar_index: int) -> tuple[int, int]:
|
|
164
|
+
active = sections[0][1]
|
|
165
|
+
for start, time_signature in sections:
|
|
166
|
+
if start > bar_index:
|
|
167
|
+
break
|
|
168
|
+
active = time_signature
|
|
169
|
+
return active
|
|
170
|
+
|
|
171
|
+
entries_by_bar: dict[int, list[tuple[int, str]]] = {}
|
|
172
|
+
for chord_el in items.findall("CLI__ChordSymbolImpl"):
|
|
173
|
+
chord_ref = chord_el.find("spChord")
|
|
174
|
+
pos_ref = chord_el.find("spPos")
|
|
175
|
+
if chord_ref is None or pos_ref is None:
|
|
176
|
+
raise JJazzLabSngParseError("<CLI__ChordSymbolImpl> is missing spChord or spPos")
|
|
177
|
+
chord_ref = _resolve_chord_element(chord_ref)
|
|
178
|
+
symbol = chord_ref.get("spName")
|
|
179
|
+
pos_text = pos_ref.get("spPos")
|
|
180
|
+
if symbol is None or pos_text is None:
|
|
181
|
+
raise JJazzLabSngParseError("<CLI__ChordSymbolImpl> is missing spName or spPos")
|
|
182
|
+
bar_index, beat = _parse_position(pos_text, f"chord {symbol!r}")
|
|
183
|
+
if not (0 <= bar_index < size):
|
|
184
|
+
raise JJazzLabSngParseError(
|
|
185
|
+
f"chord {symbol!r} at bar {bar_index} is outside the chart's {size}-bar range"
|
|
186
|
+
)
|
|
187
|
+
entries_by_bar.setdefault(bar_index, []).append((beat, symbol))
|
|
188
|
+
|
|
189
|
+
bars: list[tuple[ChordChartEntry, ...]] = []
|
|
190
|
+
for bar_index in range(size):
|
|
191
|
+
beats, beat_type = _time_signature_at(bar_index)
|
|
192
|
+
quarter_per_beat = measure_quarter_length(beats, beat_type) / beats
|
|
193
|
+
raw_entries = sorted(entries_by_bar.get(bar_index, ()), key=lambda entry: entry[0])
|
|
194
|
+
bars.append(
|
|
195
|
+
tuple(
|
|
196
|
+
ChordChartEntry(offset_q=Fraction(beat) * quarter_per_beat, symbol=symbol)
|
|
197
|
+
for beat, symbol in raw_entries
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return JJazzLabChart(name=name, tempo=tempo, bars=tuple(bars))
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""MCP server adapter: exposes the Slice 10.1 `session` service as MCP tools, plus an embedded,
|
|
2
|
+
read-only score view through the MCP Apps extension (Section 15.10/15.13, 20 Slice 10.2/10.3).
|
|
3
|
+
|
|
4
|
+
Read-only tools first (Section 20's own MCP rollout order): every session tool here calls the
|
|
5
|
+
exact same ``mxl_agent.service.session`` functions the CLI's ``session open``/``status``/
|
|
6
|
+
``checkout``/``revisions`` commands do -- "The MCP server delegates to the same application
|
|
7
|
+
service used by the CLI. It must not duplicate parsing or editing logic" (Section 15.10). None
|
|
8
|
+
of these tools mutate score content; ``checkout`` only moves a session's current-revision
|
|
9
|
+
pointer between revisions that already exist.
|
|
10
|
+
|
|
11
|
+
Every tool returns the *same* JSON envelope shape the CLI's ``--json`` output does
|
|
12
|
+
(``mxl_agent.result.build_envelope``) rather than raising on an expected failure -- one client
|
|
13
|
+
sees one contract whether it calls the CLI or MCP.
|
|
14
|
+
|
|
15
|
+
``session_view`` additionally renders that score as an embedded "MCP App" (a still-evolving MCP
|
|
16
|
+
extension, specification dated 2026-01-26) -- a read-only Verovio SVG shown inline in a
|
|
17
|
+
compatible client, through ``mcp.server.apps.Apps`` and the vendored official client library
|
|
18
|
+
(``adapters/vendor/ext_apps_client.js``, see NOTICE). This project never reimplements that
|
|
19
|
+
extension's JSON-RPC-over-postMessage handshake itself (Section 22: "prefer reusing tested
|
|
20
|
+
machinery over re-deriving it"). Server-side registration and the tool's own structured output
|
|
21
|
+
are covered by this project's test suite; end-to-end rendering inside a real compatible host is
|
|
22
|
+
not, since no such host is available in this environment -- an explicit, documented limitation
|
|
23
|
+
(Section 21).
|
|
24
|
+
|
|
25
|
+
This module is the only place that imports the optional ``mcp`` package -- like
|
|
26
|
+
``adapters.verovio``/``adapters.musescore``, it is entirely absent from the default install and
|
|
27
|
+
the core CLI/library never import it (CLAUDE.md: "Core behavior must work without Claude, a
|
|
28
|
+
browser, Verovio, or MuseScore").
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from mxl_agent.adapters.verovio import render_score_page
|
|
38
|
+
from mxl_agent.errors import MxlAgentError
|
|
39
|
+
from mxl_agent.result import build_envelope
|
|
40
|
+
from mxl_agent.service import session as session_service
|
|
41
|
+
from mxl_agent.session.workspace import DEFAULT_WORKSPACE_ROOT, open_workspace
|
|
42
|
+
|
|
43
|
+
__all__ = ["McpUnavailableError", "build_server", "run_stdio_server", "mcp_availability"]
|
|
44
|
+
|
|
45
|
+
_SCORE_VIEW_RESOURCE_URI = "ui://mxl-agent/score-view"
|
|
46
|
+
_VENDORED_CLIENT_JS_PATH = Path(__file__).parent / "vendor" / "ext_apps_client.js"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class McpUnavailableError(MxlAgentError):
|
|
50
|
+
code = "MCP_UNAVAILABLE"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _import_mcp_server_class() -> Any:
|
|
54
|
+
try:
|
|
55
|
+
from mcp.server.mcpserver import MCPServer
|
|
56
|
+
except ImportError:
|
|
57
|
+
return None
|
|
58
|
+
return MCPServer
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _import_apps_extension_class() -> Any:
|
|
62
|
+
try:
|
|
63
|
+
from mcp.server.apps import Apps
|
|
64
|
+
except ImportError:
|
|
65
|
+
return None
|
|
66
|
+
return Apps
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mcp_availability() -> dict[str, object]:
|
|
70
|
+
"""``doctor``'s capability-detection payload for the optional MCP server (`mcp-serve`)."""
|
|
71
|
+
if _import_mcp_server_class() is None:
|
|
72
|
+
return {"available": False, "version": None}
|
|
73
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
installed_version = version("mcp")
|
|
77
|
+
except PackageNotFoundError:
|
|
78
|
+
installed_version = None
|
|
79
|
+
return {"available": True, "version": installed_version}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_score_view_html() -> str:
|
|
83
|
+
"""The ``ui://mxl-agent/score-view`` resource: the vendored MCP Apps client library, plus a
|
|
84
|
+
small amount of mxl-agent-authored initialization that connects to the host and renders
|
|
85
|
+
whatever SVG the ``session_view`` tool's ``structuredContent.svg`` carries."""
|
|
86
|
+
vendored_js = _VENDORED_CLIENT_JS_PATH.read_text(encoding="utf-8")
|
|
87
|
+
return f"""<!DOCTYPE html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="UTF-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
92
|
+
<meta name="color-scheme" content="light dark">
|
|
93
|
+
<title>mxl-agent score view</title>
|
|
94
|
+
<style>
|
|
95
|
+
body {{ margin: 0; padding: 8px; font-family: system-ui, sans-serif; }}
|
|
96
|
+
#score svg {{ max-width: 100%; height: auto; }}
|
|
97
|
+
</style>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<div id="score">Loading score...</div>
|
|
101
|
+
<script type="module">
|
|
102
|
+
{vendored_js}
|
|
103
|
+
|
|
104
|
+
// -- mxl-agent's own initialization (Slice 10.3), using the App class the vendored library
|
|
105
|
+
// -- above exposed via window.McpApp (see the comment at the end of that file).
|
|
106
|
+
const {{ App }} = window.McpApp;
|
|
107
|
+
const app = new App({{ name: "mxl-agent score view", version: "1.0.0" }});
|
|
108
|
+
const container = document.getElementById("score");
|
|
109
|
+
|
|
110
|
+
function render(result) {{
|
|
111
|
+
const content = result && result.structuredContent;
|
|
112
|
+
if (content && content.ok && content.svg) {{
|
|
113
|
+
container.innerHTML = content.svg;
|
|
114
|
+
}} else if (content && content.error) {{
|
|
115
|
+
container.textContent = "Could not render score: " + content.error.message;
|
|
116
|
+
}} else {{
|
|
117
|
+
container.textContent = "No score data received.";
|
|
118
|
+
}}
|
|
119
|
+
}}
|
|
120
|
+
|
|
121
|
+
app.ontoolresult = render;
|
|
122
|
+
app.onerror = (err) => {{ container.textContent = "Error: " + err; }};
|
|
123
|
+
app.connect().catch((err) => {{ container.textContent = "Failed to connect: " + err; }});
|
|
124
|
+
</script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _tool_result(command: str, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
|
130
|
+
"""Run ``fn``, returning the same envelope shape ``cli._run`` builds for ``--json`` output
|
|
131
|
+
-- an expected ``MxlAgentError`` becomes ``{"ok": false, "error": {...}}``, never a raised
|
|
132
|
+
exception the MCP transport would have to translate on our behalf."""
|
|
133
|
+
try:
|
|
134
|
+
result = fn()
|
|
135
|
+
return build_envelope(command, ok=True, **result)
|
|
136
|
+
except MxlAgentError as exc:
|
|
137
|
+
return build_envelope(command, ok=False, **exc.to_envelope_fields())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_server(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT) -> Any:
|
|
141
|
+
"""Build (but do not run) the MCP server exposing the session tool family.
|
|
142
|
+
|
|
143
|
+
Raises ``McpUnavailableError`` if the optional ``mcp`` package isn't installed.
|
|
144
|
+
"""
|
|
145
|
+
mcp_server_cls = _import_mcp_server_class()
|
|
146
|
+
if mcp_server_cls is None:
|
|
147
|
+
raise McpUnavailableError(
|
|
148
|
+
"the optional `mcp` package is not installed (install the `mcp` extra to enable this)"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# The Apps extension (Slice 10.3) is registered before the server itself is constructed --
|
|
152
|
+
# MCPServer takes its extensions at construction time. Older installs of the `mcp` package
|
|
153
|
+
# may lack this specific extension even when the base server class is present, so this is
|
|
154
|
+
# guarded separately: a missing Apps extension degrades to "no embedded score view", not to
|
|
155
|
+
# "no MCP server at all".
|
|
156
|
+
apps_extension_cls = _import_apps_extension_class()
|
|
157
|
+
extensions = None
|
|
158
|
+
if apps_extension_cls is not None:
|
|
159
|
+
apps_extension = apps_extension_cls()
|
|
160
|
+
apps_extension.add_html_resource(
|
|
161
|
+
_SCORE_VIEW_RESOURCE_URI,
|
|
162
|
+
_build_score_view_html(),
|
|
163
|
+
name="score-view",
|
|
164
|
+
title="Score view",
|
|
165
|
+
description="A read-only rendered view of the session's current score.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@apps_extension.tool(resource_uri=_SCORE_VIEW_RESOURCE_URI) # type: ignore[untyped-decorator]
|
|
169
|
+
async def session_view(session_id: str, page: int = 1) -> dict[str, Any]:
|
|
170
|
+
"""Render the session's current revision as an embedded score view (Section
|
|
171
|
+
15.13, Slice 10.3), through the optional Verovio integration -- read-only.
|
|
172
|
+
|
|
173
|
+
Declared ``async`` (and calling Verovio directly, not via a thread pool) on purpose:
|
|
174
|
+
the MCP SDK dispatches plain ``def`` tool functions through ``anyio.to_thread``, and
|
|
175
|
+
Verovio's native toolkit has been observed to permanently break every later call in
|
|
176
|
+
the process once its first use happens on such a worker thread rather than the main
|
|
177
|
+
thread (see ROADMAP.md Slice 10.3). Blocking the event loop briefly here is an
|
|
178
|
+
acceptable trade-off for a single-client local stdio server (Section 15.10).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def _fn() -> dict[str, Any]:
|
|
182
|
+
ws = open_workspace(session_id, workspace_root=workspace_root)
|
|
183
|
+
svg = render_score_page(ws.read_working_bytes(), page=page)
|
|
184
|
+
return {"session_id": session_id, "page": page, "svg": svg}
|
|
185
|
+
|
|
186
|
+
return _tool_result("session_view", _fn)
|
|
187
|
+
|
|
188
|
+
extensions = [apps_extension]
|
|
189
|
+
|
|
190
|
+
server = mcp_server_cls(name="mxl-agent", extensions=extensions)
|
|
191
|
+
|
|
192
|
+
@server.tool() # type: ignore[untyped-decorator]
|
|
193
|
+
def session_open(input_path: str, score: str | None = None) -> dict[str, Any]:
|
|
194
|
+
"""Open a .musicxml, .xml, or .mxl file into a new session at revision 0. `score`
|
|
195
|
+
selects which score to open when an .mxl package declares more than one."""
|
|
196
|
+
|
|
197
|
+
def _fn() -> dict[str, Any]:
|
|
198
|
+
data = Path(input_path).read_bytes()
|
|
199
|
+
return session_service.open_session(
|
|
200
|
+
data, workspace_root=workspace_root, score_selector=score
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return _tool_result("session_open", _fn)
|
|
204
|
+
|
|
205
|
+
@server.tool() # type: ignore[untyped-decorator]
|
|
206
|
+
def session_status(session_id: str) -> dict[str, Any]:
|
|
207
|
+
"""The session's current revision and (if `undo` was just called) its pending redo
|
|
208
|
+
target."""
|
|
209
|
+
return _tool_result(
|
|
210
|
+
"session_status",
|
|
211
|
+
lambda: session_service.session_status(session_id, workspace_root=workspace_root),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@server.tool() # type: ignore[untyped-decorator]
|
|
215
|
+
def session_checkout(
|
|
216
|
+
session_id: str, revision: int | None = None, checkpoint: str | None = None
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""Jump directly to any existing revision, by number or by checkpoint name (exactly one
|
|
219
|
+
of `revision`/`checkpoint` is required)."""
|
|
220
|
+
return _tool_result(
|
|
221
|
+
"session_checkout",
|
|
222
|
+
lambda: session_service.session_checkout(
|
|
223
|
+
session_id,
|
|
224
|
+
revision=revision,
|
|
225
|
+
checkpoint=checkpoint,
|
|
226
|
+
workspace_root=workspace_root,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@server.tool() # type: ignore[untyped-decorator]
|
|
231
|
+
def session_revisions(session_id: str) -> dict[str, Any]:
|
|
232
|
+
"""Every revision's own metadata, sorted by revision number -- the full branch
|
|
233
|
+
structure."""
|
|
234
|
+
return _tool_result(
|
|
235
|
+
"session_revisions",
|
|
236
|
+
lambda: session_service.session_revisions(session_id, workspace_root=workspace_root),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return server
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def run_stdio_server(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT) -> None:
|
|
243
|
+
"""Build and run the MCP server over stdio -- the standard transport for a locally spawned
|
|
244
|
+
MCP server process."""
|
|
245
|
+
server = build_server(workspace_root=workspace_root)
|
|
246
|
+
server.run(transport="stdio")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""MEI adapter: optional MusicXML -> MEI conversion and MEI -> SVG render interoperability,
|
|
2
|
+
through the same optional Verovio integration (Section 8.2, 9.6; 20 Slice 9.6).
|
|
3
|
+
|
|
4
|
+
MEI (Music Encoding Initiative) is supported only for export and read-only rendering
|
|
5
|
+
interoperability -- never as an editable input format (CLAUDE.md: "MusicXML is canonical. Never
|
|
6
|
+
regenerate a score through MIDI, MEI, music21, Verovio, or MuseScore."). There is no
|
|
7
|
+
``session open`` path for MEI and no MEI-sourced workspace: ``convert_to_mei`` returns a plain
|
|
8
|
+
string the caller may save wherever it likes, and ``render_mei_to_svg`` renders a standalone MEI
|
|
9
|
+
document directly, entirely outside the session/workspace system.
|
|
10
|
+
|
|
11
|
+
Reuses ``adapters.verovio``'s toolkit loader and error types -- MEI support is the same optional
|
|
12
|
+
Verovio dependency, just a different input/output format.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mxl_agent.adapters.verovio import VerovioRenderError, _load_toolkit
|
|
20
|
+
|
|
21
|
+
__all__ = ["convert_to_mei", "render_mei_to_svg"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_to_mei(musicxml_bytes: bytes, *, options: dict[str, Any] | None = None) -> str:
|
|
25
|
+
"""Convert ``musicxml_bytes`` (raw, uncompressed MusicXML) to an MEI XML string, through
|
|
26
|
+
Verovio. Export/interoperability only: the result is never written back as a session's
|
|
27
|
+
canonical document.
|
|
28
|
+
|
|
29
|
+
Raises ``VerovioUnavailableError``/``VerovioRenderError`` (same meanings as
|
|
30
|
+
``adapters.verovio.render_score_page``).
|
|
31
|
+
"""
|
|
32
|
+
toolkit = _load_toolkit(musicxml_bytes, options)
|
|
33
|
+
try:
|
|
34
|
+
return str(toolkit.getMEI())
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
raise VerovioRenderError(f"verovio failed to convert to MEI: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_mei_to_svg(mei_bytes: bytes, *, page: int = 1) -> str:
|
|
40
|
+
"""Render page ``page`` (1-based) of a standalone MEI document to an SVG string, through
|
|
41
|
+
Verovio. Read-only interoperability: ``mei_bytes`` is never opened as a session/workspace,
|
|
42
|
+
and MEI never becomes this project's canonical document.
|
|
43
|
+
|
|
44
|
+
Raises ``VerovioUnavailableError`` if the optional package isn't installed, or
|
|
45
|
+
``VerovioRenderError`` if Verovio can't load ``mei_bytes`` or ``page`` is out of range.
|
|
46
|
+
"""
|
|
47
|
+
toolkit = _load_toolkit(mei_bytes, input_from="mei")
|
|
48
|
+
|
|
49
|
+
page_count = toolkit.getPageCount()
|
|
50
|
+
if page < 1 or page > page_count:
|
|
51
|
+
raise VerovioRenderError(
|
|
52
|
+
f"page {page} is out of range (document has {page_count} page(s))",
|
|
53
|
+
page=page,
|
|
54
|
+
page_count=page_count,
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
return str(toolkit.renderToSVG(page))
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise VerovioRenderError(f"verovio failed to render page {page}: {exc}") from exc
|