Notation interop: MusicXML
Celeritas reads and writes MusicXML (score-partwise), converting between the
interchange format the notation world speaks and the engine's
NoteBuffer / NoteEvent
model. The entry point is MusicXmlIo.
using Celeritas.Core.Notation;
Importing
using var buffer = MusicXmlIo.Import("score.musicxml"); // from a file
using var b2 = MusicXmlIo.Import(stream); // from a stream
using var b3 = MusicXmlIo.Parse(xmlText); // from a string
Compressed .mxl archives are unwrapped automatically — the score named by
META-INF/container.xml is read (falling back to the first score entry). The
format is detected by content, so a mis-named file still works. XML is parsed with
DTD processing off and no external resolver, so the DOCTYPE real MusicXML carries
is never fetched (no XXE, no network).
Exporting
string xml = MusicXmlIo.ToXml(buffer); // as a string (4/4)
MusicXmlIo.Export(buffer, "out.musicxml"); // ...or straight to a file
MusicXmlIo.Export(buffer, stream); // ...or a stream
// Bar the notes into a specific meter:
MusicXmlIo.Export(buffer, "out.musicxml", new TimeSignature(3, 4));
Notes are divided into measures of the given meter (4/4 by default), and a note crossing a barline is split into tied notes — the exact inverse of import's tie merge. Round-trips exactly (with one exception, below) — import → export → import yields the same notes:
using var original = MusicXmlIo.Import("score.musicxml");
using var again = MusicXmlIo.Parse(MusicXmlIo.ToXml(original));
// `again` has the same pitches, offsets, and durations as `original`.
The exception is a zero-duration note. It is clamped to one <divisions>
unit on export, so the pitch and onset come back but the zero duration does not —
see Boundaries.
What maps to what
| MusicXML | Celeritas |
|---|---|
<pitch> step + octave + <alter> |
MIDI pitch (NoteEvent.Pitch); octave 4 = middle C (60) |
<duration> in <divisions> |
whole-note Rational; a quarter = 1/4. Fractional values are accepted and converted exactly — <duration>1.5</duration> at <divisions>3</divisions> imports as exactly 1/8 |
<rest> |
advances time, emits no note |
<unpitched> (a hit on a percussion staff) |
advances time, emits no note — the engine holds pitches and a drum has none |
<chord/> |
notes sharing an onset |
<tie> chain (or <notations><tied>) |
merged into one sustained note; matched per voice, so two voices tying the same pitch stay independent |
multiple <part>s, <backup>/<forward> |
merged onto one timeline |
voices (via <backup>) |
overlapping notes; exported back as <voice> lines |
<dynamics> marks / <sound dynamics> |
note velocity (NoteEvent.Velocity) |
<attributes><transpose> (<chromatic> + 12 × <octave-change>) |
applied on import, per staff when it carries a number — pitches are sounding, so a B♭ clarinet's written D5 imports as C5 (72); export writes concert pitch and no <transpose> |
On export, pitches are spelled with sharps, <divisions> is chosen so that
every offset, every duration and the measure length land on an exact integer,
chords share an onset, gaps become rests, and overlapping lines are split into
voices.
Folding the measure length into that choice is what lets irregular meters bar and
round-trip without truncation: a whole note exported into 7/8 picks
<divisions>2</divisions> and splits into <duration>7</duration> tied to
<duration>1</duration>. Sizing <divisions> from the notes alone would have
picked 1, and the tail segment — an eighth, half a division — would have
truncated to <duration>0</duration>.
Conventions to keep in mind
- Time is whole-note-relative. Offsets and durations are fractions of a whole note, independent of meter — see the time model.
- Pitch is a number. MIDI pitches don't carry spelling; C♯ and D♭ are the same pitch — see enharmonic spelling.
Boundaries
This is a working core, not a full MusicXML implementation. Remaining approximations, spelled out:
- Tuplet grouping metadata (
<time-modification>) is ignored — tuplet durations import exactly (a triplet-eighth is exactly1/12), only the notational grouping is dropped. - Grace notes are approximated: the pitch is kept as a short note (
1/32) at the beat of its principal note, without shifting time. - Unpitched notes (
<unpitched>, a hit on a percussion staff) are not imported: the hit takes up its time and yields no note, so a score with a drum part imports its pitched parts and leaves the drums out, and a drums-only score imports as empty. score-timewiseis transposed to partwise on import and read normally.- Dynamics on export are written for single-voice music only; polyphonic velocity is left at the default.
- Zero-duration notes do not survive export intact. A note of duration
0would occupy no measure segment at all and vanish from the score, so it is clamped to one<divisions>unit: the pitch and onset round-trip, the zero duration does not. How long that unit is depends on the score —1/4when the export settles on<divisions>1</divisions>,1/12when a triplet elsewhere in the buffer forces<divisions>3</divisions>.
From the command line
# Convert MusicXML <-> MIDI (direction inferred from the extensions)
celeritas musicxml convert --in score.musicxml --out score.mid
celeritas musicxml convert --in score.mid --out score.musicxml
# Summarize a score: notes, range, detected key, chord timeline
celeritas musicxml analyze --in score.musicxml
See also
MusicXmlIo— the full API.- The Cookbook has copy-pasteable notation recipes.