Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions mxml2notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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] = {}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <part-name> 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'),
}


Expand Down
81 changes: 65 additions & 16 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<instrument>.json + song_timeline.json in
Expand Down Expand Up @@ -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'}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def setup(app, context):
global _get_dlc_dir, _extract_meta, _meta_db, _config_dir, _log, _mxml
_get_dlc_dir = context['get_dlc_dir']
Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading