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..eef8928 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, validate=True) + 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,38 @@ 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: + 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}'} + + 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..05e33e6 --- /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': 'aGVs!bG8='}, + ) + assert resp.json() == {'error': 'Invalid base64 data'} + + +def test_endpoint_reports_parse_failure_as_error(): + client = _client() + resp = _post(client, 'score.xml', b'') + assert 'error' in resp.json()