Skip to content

feat: write <lyric> back into the serialized <note> and model its children - #64

Merged
thaihuynhxyz merged 11 commits into
mainfrom
fix/lyric-round-trip
Aug 12, 2026
Merged

feat: write <lyric> back into the serialized <note> and model its children#64
thaihuynhxyz merged 11 commits into
mainfrom
fix/lyric-round-trip

Conversation

@thaihuynhxyz

@thaihuynhxyz thaihuynhxyz commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes the <lyric> half of #60: a <lyric> was parsed into Note.lyrics but never written back, so it disappeared on a round trip.

What changed

  • Note now writes its lyrics into its children, after <notations>, where the content model puts them.
  • <text>, <syllabic> and <elision> are real element classes instead of anonymous XmlElement.tag(...) calls, so they can carry their own attributes later. They are named LyricText, LyricSyllabic and LyricElision, after the existing LyricFont and LyricLanguage.
  • New xsd:NMTOKEN data type, used for the new Lyric.number attribute.

Not a breaking change

An earlier version of this branch made Lyric extend XmlElement. That forced Lyric.name to be renamed, because XmlElement.name is the tag name, and it would have made the next release a 3.0.0. That was too high a price for one rename, so Lyric stays a plain class with .name and Note calls toXmlElement() instead.

Code written against 2.8.0 compiles unchanged, so this ships as 2.9.0.

The trade-off to be aware of: Lyric is now the only element in the package that is not an XmlElement, so note.childElements.whereType<Lyric>() finds nothing, and new lyric attributes have to be copied by hand inside toXmlElement().

Test plan

  • dart analyze clean
  • 127 tests pass
  • New test/lyric_test.dart covers NmToken validation, each child element round trip, and that the 2.8.0 name / syllabic / text API still works

Lyric was a plain class, not an XmlElement, so Note could not put it in
its child list and every <lyric> was dropped on serialization. A vocal
score lost all of its words on a parse-then-write round trip: 94 lyrics
in the musicXML.xml test asset alone.

Make Lyric an XmlElement that builds its <syllabic>, <text> and <elision>
children in content-model order, and add it to the <note> child list after
<notations>. Also keep the number attribute, without which two lyrics on
one note cannot be told apart.

BREAKING: Lyric.name is now Lyric.lyricName, because XmlElement.name is
the tag name. This matches LyricFont.lyricName.
<text>, <syllabic> and <elision> were written as anonymous XmlElement.tag
calls, so they could not carry their own attributes and did not match how
every other element in the package is modelled. They are real classes now.

The <text> class is named LyricText so it does not clash with the Flutter
Text widget, and the Syllabic enum becomes SyllabicValue so that Syllabic
can be the element, the same split as NoteTypeValue/NoteType.

Lyric.number is an NmToken instead of a String, because the spec types it
as xsd:NMTOKEN.
Making Lyric extend XmlElement forced Lyric.name to be renamed, because
XmlElement.name is the tag name. That one rename was the only reason the
release needed a major bump, so it is not worth it.

Lyric goes back to a plain class with .name, and Note calls toXmlElement()
when it builds its children, so <lyric> is still written back out.

The new elements are named LyricText, LyricSyllabic and LyricElision after
the existing LyricFont and LyricLanguage. That frees the Syllabic enum from
being renamed too.

Code written against 2.8.0 now compiles unchanged, so this is 2.9.0.
@thaihuynhxyz thaihuynhxyz changed the title fix: write <lyric> back into the serialized <note> feat: write <lyric> back into the serialized <note> and model its children Aug 7, 2026
Adds the <elision> example from the MusicXML reference as an asset and
checks that "cro" and "a" split into two items around the undertie, that
the children come back in the order the example writes them, and that the
note keeps the <lyric> on a round trip.

One test records a known gap: default-y on <lyric> is still dropped.
Lyric holds the <lyric> element now, like every other element class, so a
note can put it straight into its children. XmlElement.name is the tag
name, so the name attribute takes the field lyricName, matching
LyricFont.lyricName and LyricLanguage.lyricName.
2.9.0 went to pub.dev on 2026-07-21 but was never tagged, so its changelog
entry looked unreleased and my earlier edits wrote over it.

The <volume>/<pan>, percent and rotation-degrees work belongs to 2.9.0 and
is put back word for word. The lyric elements, the NMTOKEN type and the
round-trip fixes are unreleased, so they move to a new 2.10.0 heading.
LyricItem stored the syllabic, text and elision a second time, next to the
children that Lyric writes out. Two copies of the same data is what caused
issue #60, and here the copy was also confusing: the elision of a syllable
is the mark written in front of it, so items.first.elision was always null.

Lyric.items is grouped from the children now, so there is one copy. Each
item holds the LyricSyllabic, LyricText and LyricElision objects, which
gives <text> attributes somewhere to live later, and keeps syllabic, text
and elision readable as plain values.

Parse builds the items in one pass, which also drops the null check that
crashed on a <lyric> starting with <elision>.
The model is syllabic? text ((elision syllabic?)? text)*, so a later
<syllabic> is only allowed after an <elision>, and two <text> runs with no
<elision> between them are one syllable with two formats. The flat item
list matched neither rule and could build a lyric the schema rejects.

Lyric.first is the opening syllable, which has no slot for an elision, and
Lyric.rest holds ElidedSyllable, which requires one. The invalid shape is
now impossible to construct rather than caught later.

Malformed input is repaired: a leading <elision> and a second <syllabic>
inside one syllable are dropped.

The outer choice of <extend>, <laughing> and <humming> stays for a later
PR, together with those elements.
first, rest and syllables are rebuilt from the children on every read, so
adding to one of the returned lists compiled, ran, and quietly did nothing.
Making the fields final last commit did not help, because final protects
the reference and not the contents.

The lists throw on a write now, and LyricSyllable copies the list it is
given so a caller cannot change a syllable from outside afterwards.
The repeat block of the content model is ((elision syllabic?)? text), so a
later item carries exactly one <text> and the elision group in front of it
is optional. ElidedSyllable made the elision required, which the grammar
never says, and the list of texts only existed to hold the bare <text> case
that the wrong type could not express.

LyricItem is the opening syllabic? text. LyricNextItem is one <text> with
an optional SyllableStart, which pairs a required <elision> with an
optional <syllabic>. A bare run and a new syllable are both expressible,
and a <syllabic> with no <elision> still is not.
Reworks the lyric model around the content model
`syllabic? text ((elision syllabic?)? text)*`.

One LyricItem now covers one <text> with the elision and syllabic that
come in front of it, and Lyric.items is a flat list of them, the way
2.9.0 had it. This drops LyricFirstItem, LyricNextItem and SyllableStart
and their factories, so reads like lyric.items.first.text keep working.

Parsing does one pass over the children and one over the attributes,
building the items as it goes, and never groups a second time.

A file is data, not a mistake in code, so a lyric that breaks the
content model is written back the way its author wrote it. A list handed
to Lyric() in code is asserted instead.

Drops Lyric.text and Lyric.syllabic; read them from items.first.
@thaihuynhxyz
thaihuynhxyz merged commit 7e78366 into main Aug 12, 2026
1 check passed
@thaihuynhxyz
thaihuynhxyz deleted the fix/lyric-round-trip branch August 12, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant