From e6d96c852abae4acd7082f8a416c93135540cac8 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Thu, 16 Jul 2026 08:30:08 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20parse-arrangement=20endpoint=20?= =?UTF-8?q?=E2=80=94=20MusicXML=20straight=20into=20an=20editor=20keys=20a?= =?UTF-8?q?rrangement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor plugin's Add-Keys ▸ MusicXML path has POSTed to /api/plugins/musicxml_import/parse-arrangement since it shipped — an endpoint that never existed here, so every MusicXML pick surfaced as "plugin not installed". This adds it. The endpoint is stateless (parses from the request body, keeps nothing on disk) and returns a ready editor arrangement: - Flat editor-shape notes ({time, string, fret, sustain, techniques} with midi = string*24 + fret) built from a new tie-folding, grace-skipping flat_notes pass inside parse_musicxml — exact onsets and sustains from the same divisions/tempo integration the MIDI pass uses, so a tied pair lands as ONE held note on the piano roll. - The score's authored hand assignment per note (techniques.hand, 'rh'/'lh' from grand-staff provenance; staves beyond the grand staff — e.g. organ pedals — stay unassigned rather than guessed). - The full notation payload stashed on the arrangement so the editor can preserve authored notation on its save rail instead of re-deriving hand splits heuristically. - An arrangement name that satisfies the editor's prefix-anchored keys router ("Piano" passes through; "Electric Piano" becomes "Keys — Electric Piano"; placeholder part ids like "P1" are dropped). Upload validation is factored into a shared _decode_upload helper so /upload and /parse-arrangement enforce identical extension/size/base64 rules. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q Signed-off-by: ChrisBeWithYou --- CHANGELOG.md | 22 +++ mxml2notation.py | 92 ++++++++++++ routes.py | 79 ++++++++--- tests/test_parse_arrangement.py | 238 ++++++++++++++++++++++++++++++++ 4 files changed, 415 insertions(+), 16 deletions(-) create mode 100644 tests/test_parse_arrangement.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f44c49..98cc86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- **`POST /api/plugins/musicxml_import/parse-arrangement`** — parses a + MusicXML file (`.xml` / `.musicxml` / `.mxl`) straight into a ready + editor keys arrangement, the endpoint the editor plugin's + Add-Keys ▸ MusicXML path has been calling (and 404-ing on) since it + shipped. Stateless — nothing is kept server-side. The response carries: + - flat editor-shape notes with **the score's authored hand assignment + per note** (`techniques.hand`, `'rh'`/`'lh'` from the grand-staff + split; staves beyond the grand staff — e.g. organ pedals — stay + unassigned). Ties fold into one held note; grace notes are skipped. + - the full **notation payload** stashed on the arrangement, so the + editor can preserve authored notation instead of re-deriving hand + splits heuristically. + - an arrangement name guaranteed to satisfy the editor's + prefix-anchored keys router (a non-keys part name gets a + `Keys — ` prefix; placeholder part ids like `P1` are dropped). +- `parse_musicxml` result now includes `flat_notes` + (`{'t','sus','midi','staff'}`, tie-folded, grace-skipped, staff = hand + provenance) and a pure `editor_arrangement(result)` builder for the + endpoint's payload. + ### Changed - **Output is now `.feedpak`** (was `.sloppak`) per the published diff --git a/mxml2notation.py b/mxml2notation.py index f2c1eaf..8323b0c 100644 --- a/mxml2notation.py +++ b/mxml2notation.py @@ -923,6 +923,9 @@ def parse_musicxml(xml_bytes: bytes) -> dict: 'midi_bytes': bytes, 'part_names': list[str], 'measure_count': int, + 'flat_notes': list[dict], # {'t','sus','midi','staff'} — ties + # folded, grace skipped, staff = the + # authored hand (rh/lh) provenance } """ if _is_mxl(xml_bytes): @@ -1034,6 +1037,14 @@ def parse_musicxml(xml_bytes: bytes) -> dict: ts_changes_out: list[dict] = [] tempo_map_idx = 0 midi_notes: list[tuple[float, float, int, int]] = [] + # Flat editor notes: {'t', 'sus', 'midi', 'staff'} — staff provenance is + # the authored hand assignment (rh/lh). Unlike midi_notes, tied + # continuations FOLD into the origin note's sustain (one held note, not + # a re-strike) so a piano-roll editor sees the score's note objects. + flat_notes: list[dict] = [] + # Open-tie origin per (staff_id, voice_id, midi): the flat note a tied + # continuation extends. Mirrors tie_open's keying. + flat_open: dict[tuple[str, str, int], dict] = {} max_time = 0.0 section_counters: dict[str, int] = {} @@ -1475,6 +1486,25 @@ def _warn_dropped(feature: str, measure_number: int) -> None: velocity = 80 midi_notes.append((note_time, sustain, midi, velocity)) + # Flat-note accumulation (skip grace notes, like the MIDI pass). + # A tied continuation extends its origin note's sustain instead + # of emitting a new note; the origin stays registered while the + # tie chain continues ('start' alongside the 'stop'). + if not is_grace: + if is_tied_continuation and tie_key in flat_open: + origin = flat_open[tie_key] + end = note_time + sustain + if end > origin['t'] + origin['sus']: + origin['sus'] = round(end - origin['t'], 4) + if 'start' not in tie_types: + del flat_open[tie_key] + elif sustain > 0: + flat = {'t': note_time, 'sus': sustain, + 'midi': midi, 'staff': sid} + flat_notes.append(flat) + if 'start' in tie_types: + flat_open[tie_key] = flat + # ── Flush measure ────────────────────────────────────────────────── ts = [ts_beats, ts_beat_type] measure_dict: dict = {'idx': measure_number, 't': round( @@ -1631,6 +1661,68 @@ def _warn_dropped(feature: str, measure_number: int) -> None: 'midi_bytes': midi_bytes, 'part_names': part_names, 'measure_count': len(measures_out), + 'flat_notes': sorted(flat_notes, key=lambda n: (n['t'], n['midi'])), + } + + +# --------------------------------------------------------------------------- +# Editor arrangement builder (the parse-arrangement endpoint's payload) +# --------------------------------------------------------------------------- + +# The editor's runtime lane/roll router is prefix-anchored +# (/^(keys|piano|keyboard|synth)/i) and its save-side keys detector is +# word-boundary — a name must satisfy the prefix rule or the arrangement +# renders as a 6-lane guitar chart instead of a piano roll. +_KEYS_NAME_RE = re.compile(r'^(keys|piano|keyboard|synth)', re.IGNORECASE) + + +def editor_arrangement(result: dict) -> dict: + """Map a ``parse_musicxml`` result to a ready editor keys arrangement. + + Returns the shape the editor plugin's Add-Keys flow appends directly: + flat notes in the editor's packed pitch encoding (``midi = string*24 + + fret``), the authored hand carried per note as ``techniques.hand`` + (``'rh'``/``'lh'`` from grand-staff provenance; other staves — e.g. an + organ pedal staff — are left unassigned), and the full notation payload + stashed under ``notation`` for the editor's authored-notation save rail + (which otherwise re-derives hand splits heuristically). + """ + notes = [] + for n in result.get('flat_notes') or []: + midi = n['midi'] + note = { + 'time': n['t'], + 'string': midi // 24, + 'fret': midi % 24, + 'sustain': n['sus'], + 'techniques': ( + {'hand': n['staff']} if n.get('staff') in ('rh', 'lh') else {} + ), + } + notes.append(note) + + # A missing falls back to the score-part id ("P1", "P2", …) + # upstream — a placeholder, not a name; don't leak it into the label. + part = next( + (p.strip() for p in result.get('part_names') or [] + if p and p.strip() and not re.fullmatch(r'P\d+', p.strip())), + '', + ) + if part and _KEYS_NAME_RE.match(part): + name = part + elif part: + name = f'Keys — {part}' + else: + name = 'Keys' + + return { + 'name': name, + 'tuning': [0, 0, 0, 0, 0, 0], + 'capo': 0, + 'notes': notes, + 'chords': [], + 'chord_templates': [], + 'notation': result.get('notation'), } diff --git a/routes.py b/routes.py index 077a3d1..074cc82 100644 --- a/routes.py +++ b/routes.py @@ -6,6 +6,13 @@ for the UI to display (title, composer, measure count, duration). Saves the raw bytes to a temp file for the build step. + POST /api/plugins/musicxml_import/parse-arrangement + Receives a MusicXML file as base64 and returns a ready editor keys + arrangement (the editor plugin's Add-Keys ▸ MusicXML path): flat + notes with authored per-note hand assignments from the grand-staff + split, plus the full notation payload for the editor's + authored-notation save rail. Stateless — nothing is kept server-side. + WS /ws/plugins/musicxml_import/build Builds a .feedpak from the uploaded MusicXML file, streaming progress messages. Produces notation_.json + song_timeline.json in @@ -60,6 +67,29 @@ def _purge_stale_uploads() -> None: shutil.rmtree(path.parent, ignore_errors=True) +def _decode_upload(data: dict) -> bytes | dict: + """Validate a `{filename, data}` base64 upload body shared by /upload + and /parse-arrangement. Returns the decoded bytes, or an `{'error': …}` + dict ready to send back.""" + filename = data.get('filename', '') + b64 = data.get('data', '') + if not filename or not b64: + return {'error': 'No file data'} + + ext = Path(filename).suffix.lower() + if ext not in ('.xml', '.musicxml', '.mxl'): + return {'error': f'Unsupported format ({ext}). Only .xml / .musicxml / .mxl files are supported.'} + + # Cheap size bound on the encoded payload (base64 ≈ 4/3 overhead). + if len(b64) > _MAX_UPLOAD_BYTES * 4 // 3 + 4: + return {'error': 'File too large (max 20 MB)'} + + try: + return base64.b64decode(b64) + except Exception: + return {'error': 'Invalid base64 data'} + + def setup(app, context): global _get_dlc_dir, _extract_meta, _meta_db, _config_dir, _log, _mxml _get_dlc_dir = context['get_dlc_dir'] @@ -72,23 +102,10 @@ def setup(app, context): @app.post('/api/plugins/musicxml_import/upload') async def upload_mxml(data: dict): """Receive a MusicXML file as base64, parse metadata, return summary.""" + xml_bytes = _decode_upload(data) + if isinstance(xml_bytes, dict): + return xml_bytes filename = data.get('filename', '') - b64 = data.get('data', '') - if not filename or not b64: - return {'error': 'No file data'} - - ext = Path(filename).suffix.lower() - if ext not in ('.xml', '.musicxml', '.mxl'): - return {'error': f'Unsupported format ({ext}). Only .xml / .musicxml / .mxl files are supported.'} - - # Cheap size bound on the encoded payload (base64 ≈ 4/3 overhead). - if len(b64) > _MAX_UPLOAD_BYTES * 4 // 3 + 4: - return {'error': 'File too large (max 20 MB)'} - - try: - xml_bytes = base64.b64decode(b64) - except Exception: - return {'error': 'Invalid base64 data'} _purge_stale_uploads() @@ -117,6 +134,36 @@ async def upload_mxml(data: dict): shutil.rmtree(tmp_dir, ignore_errors=True) return {'error': f'Failed to parse: {e}'} + @app.post('/api/plugins/musicxml_import/parse-arrangement') + async def parse_arrangement(data: dict): + """Parse a MusicXML file into a ready editor keys arrangement. + + Consumed by the editor plugin's Add-Keys ▸ MusicXML path. Returns + `{arrangement: {name, notes, chords, chord_templates, notation, …}}` + — flat editor-shape notes carrying the score's authored hand + assignment (`techniques.hand`, 'rh'/'lh' from grand-staff + provenance) plus the notation payload for the editor's + authored-notation save rail. Stateless: parses from the request + body, keeps nothing on disk. + """ + xml_bytes = _decode_upload(data) + if isinstance(xml_bytes, dict): + return xml_bytes + + try: + result = _mxml.parse_musicxml(xml_bytes) + except Exception as e: + _log.exception('MusicXML parse error') + return {'error': f'Failed to parse: {e}'} + + return { + 'arrangement': _mxml.editor_arrangement(result), + 'title': result['title'], + 'composer': result['composer'], + 'duration': result['duration'], + 'measure_count': result['measure_count'], + } + @app.websocket('/ws/plugins/musicxml_import/build') async def ws_build_mxml( websocket: WebSocket, diff --git a/tests/test_parse_arrangement.py b/tests/test_parse_arrangement.py new file mode 100644 index 0000000..59061de --- /dev/null +++ b/tests/test_parse_arrangement.py @@ -0,0 +1,238 @@ +"""The parse-arrangement surface: flat_notes (tie folding, grace skipping, +staff provenance), editor_arrangement (packed pitch encoding, per-note hand, +keys-recognizable naming, notation passthrough), and the stateless endpoint. + +The per-note hand vocabulary is 'rh'/'lh' (matching the notation staff ids); +staves beyond the grand staff (e.g. an organ pedal staff) stay unassigned — +the editor's heuristic keeps owning those. +""" + +import base64 +import json +import logging + +import mxml2notation + + +def _score(measures_xml: str, part_name: str = 'Piano', + staves: int = 2) -> bytes: + staves_attr = (f'{staves}' if staves > 1 else '') + return f''' + + Hand Test + + {part_name} + + + + + 2 + 0 + + {staves_attr} + G2 + {'F4' if staves > 1 else ''} + + {measures_xml} + + +'''.encode() + + +def _note(step='C', octave='4', staff='1', dur='2', ntype='quarter', + voice='1', extra=''): + return f''' + {step}{octave} + {dur} + {voice} + {ntype} + {staff} + {extra} + ''' + + +# ── flat_notes semantics ──────────────────────────────────────────────────── + +def test_flat_notes_carry_staff_provenance(): + # RH C4 then (after a backup) LH C3 at the same onset. + xml = _score( + _note('C', '4', staff='1') + + '2' + + _note('C', '3', staff='2', voice='2') + ) + result = mxml2notation.parse_musicxml(xml) + flat = result['flat_notes'] + assert [(n['midi'], n['staff']) for n in flat] == [(48, 'lh'), (60, 'rh')] + # Both at the same onset (sorted by (t, midi) — LH's lower pitch first). + assert flat[0]['t'] == flat[1]['t'] == 0.0 + + +def test_flat_notes_fold_ties_into_one_note(): + # Quarter C4 tied to quarter C4: ONE flat note, sustain = both. + xml = _score( + _note('C', '4', extra='') + + _note('C', '4', extra='') + ) + result = mxml2notation.parse_musicxml(xml) + flat = result['flat_notes'] + assert len(flat) == 1 + # 120 BPM default: quarter = 0.5 s, tied pair = 1.0 s. + assert abs(flat[0]['sus'] - 1.0) < 1e-6 + # The MIDI pass still re-strikes (unchanged upstream behavior). + assert result['midi_bytes'] + + +def test_flat_notes_tie_chain_keeps_extending(): + # start -> stop+start -> stop across three quarters = one 1.5 s note. + xml = _score( + _note('C', '4', extra='') + + _note('C', '4', extra='') + + _note('C', '4', extra='') + ) + flat = mxml2notation.parse_musicxml(xml)['flat_notes'] + assert len(flat) == 1 + assert abs(flat[0]['sus'] - 1.5) < 1e-6 + + +def test_flat_notes_skip_grace_notes(): + xml = _score( + ''' + + B3 + 1 + eighth + 1 + ''' + + _note('C', '4') + ) + flat = mxml2notation.parse_musicxml(xml)['flat_notes'] + assert [n['midi'] for n in flat] == [60] + + +# ── editor_arrangement ────────────────────────────────────────────────────── + +def _arrangement(xml: bytes) -> dict: + return mxml2notation.editor_arrangement(mxml2notation.parse_musicxml(xml)) + + +def test_editor_notes_pack_pitch_and_carry_hand(): + xml = _score( + _note('C', '4', staff='1') + + '2' + + _note('C', '3', staff='2', voice='2') + ) + arr = _arrangement(xml) + notes = arr['notes'] + assert len(notes) == 2 + lh, rh = notes # sorted by (t, midi) + # Packed pitch encoding: midi = string*24 + fret. + assert lh['string'] * 24 + lh['fret'] == 48 + assert rh['string'] * 24 + rh['fret'] == 60 + assert lh['techniques'] == {'hand': 'lh'} + assert rh['techniques'] == {'hand': 'rh'} + assert all('time' in n and 'sustain' in n for n in notes) + + +def test_extra_staves_stay_unassigned(): + # Staff 3 (e.g. organ pedals) is not a grand-staff hand: no hand key. + xml = _score( + _note('C', '2', staff='3'), staves=3 + ) + arr = _arrangement(xml) + assert arr['notes'][0]['techniques'] == {} + + +def test_notation_payload_rides_along_verbatim(): + xml = _score(_note('C', '4')) + result = mxml2notation.parse_musicxml(xml) + arr = mxml2notation.editor_arrangement(result) + assert arr['notation'] is result['notation'] + assert arr['notation']['measures'] + + +def test_arrangement_shape_matches_editor_add_keys_contract(): + arr = _arrangement(_score(_note('C', '4'))) + assert arr['tuning'] == [0, 0, 0, 0, 0, 0] + assert arr['capo'] == 0 + assert arr['chords'] == [] + assert arr['chord_templates'] == [] + + +def test_name_keeps_keys_recognizable_part_name(): + # "Piano" already satisfies the editor's prefix-anchored keys router. + arr = _arrangement(_score(_note('C', '4'), part_name='Piano')) + assert arr['name'] == 'Piano' + + +def test_name_prefixes_unrecognizable_part_name(): + # "Electric Piano" fails the prefix rule (start-anchored) — prefix it. + arr = _arrangement(_score(_note('C', '4'), part_name='Electric Piano')) + assert arr['name'] == 'Keys — Electric Piano' + + +def test_name_falls_back_without_part_name(): + arr = _arrangement(_score(_note('C', '4'), part_name='')) + assert arr['name'] == 'Keys' + + +# ── the endpoint (stateless) ──────────────────────────────────────────────── + +def _client(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + import routes + + app = FastAPI() + context = { + 'get_dlc_dir': lambda: None, + 'extract_meta': lambda p: {}, + 'meta_db': None, + 'config_dir': None, + 'log': logging.getLogger('test'), + 'load_sibling': lambda name: mxml2notation, + } + routes.setup(app, context) + return TestClient(app) + + +def _post(client, filename, payload: bytes): + return client.post( + '/api/plugins/musicxml_import/parse-arrangement', + json={'filename': filename, + 'data': base64.b64encode(payload).decode()}, + ) + + +def test_endpoint_returns_ready_arrangement(): + client = _client() + resp = _post(client, 'score.musicxml', _score(_note('C', '4'))) + assert resp.status_code == 200 + data = resp.json() + assert 'error' not in data + arr = data['arrangement'] + assert arr['name'] == 'Piano' + assert arr['notes'][0]['techniques'] == {'hand': 'rh'} + assert arr['notation']['measures'] + # JSON-serializable end to end (no bytes leaked into the payload). + json.dumps(data) + + +def test_endpoint_rejects_unsupported_extension(): + client = _client() + resp = _post(client, 'score.gp5', b'not xml') + assert 'error' in resp.json() + + +def test_endpoint_rejects_bad_base64(): + client = _client() + resp = client.post( + '/api/plugins/musicxml_import/parse-arrangement', + json={'filename': 'score.xml', 'data': '!!!not-base64!!!'}, + ) + assert 'error' in resp.json() + + +def test_endpoint_reports_parse_failure_as_error(): + client = _client() + resp = _post(client, 'score.xml', b'') + assert 'error' in resp.json() From b2128cc1dfe31d4e6950beedbefdd96cc004bdcc Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 18 Jul 2026 16:36:04 -0500 Subject: [PATCH 2/2] Harden MusicXML arrangement parsing --- routes.py | 6 ++++-- tests/test_parse_arrangement.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/routes.py b/routes.py index 074cc82..eef8928 100644 --- a/routes.py +++ b/routes.py @@ -85,7 +85,7 @@ def _decode_upload(data: dict) -> bytes | dict: return {'error': 'File too large (max 20 MB)'} try: - return base64.b64decode(b64) + return base64.b64decode(b64, validate=True) except Exception: return {'error': 'Invalid base64 data'} @@ -151,7 +151,9 @@ async def parse_arrangement(data: dict): return xml_bytes try: - result = _mxml.parse_musicxml(xml_bytes) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor( + None, _mxml.parse_musicxml, xml_bytes) except Exception as e: _log.exception('MusicXML parse error') return {'error': f'Failed to parse: {e}'} diff --git a/tests/test_parse_arrangement.py b/tests/test_parse_arrangement.py index 59061de..05e33e6 100644 --- a/tests/test_parse_arrangement.py +++ b/tests/test_parse_arrangement.py @@ -227,9 +227,9 @@ def test_endpoint_rejects_bad_base64(): client = _client() resp = client.post( '/api/plugins/musicxml_import/parse-arrangement', - json={'filename': 'score.xml', 'data': '!!!not-base64!!!'}, + json={'filename': 'score.xml', 'data': 'aGVs!bG8='}, ) - assert 'error' in resp.json() + assert resp.json() == {'error': 'Invalid base64 data'} def test_endpoint_reports_parse_failure_as_error():