Table of Contents

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 exactly 1/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-timewise is 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 0 would 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/4 when the export settles on <divisions>1</divisions>, 1/12 when 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