feat: write <lyric> back into the serialized <note> and model its children - #64
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the
<lyric>half of #60: a<lyric>was parsed intoNote.lyricsbut never written back, so it disappeared on a round trip.What changed
Notenow writes its lyrics into its children, after<notations>, where the content model puts them.<text>,<syllabic>and<elision>are real element classes instead of anonymousXmlElement.tag(...)calls, so they can carry their own attributes later. They are namedLyricText,LyricSyllabicandLyricElision, after the existingLyricFontandLyricLanguage.xsd:NMTOKENdata type, used for the newLyric.numberattribute.Not a breaking change
An earlier version of this branch made
LyricextendXmlElement. That forcedLyric.nameto be renamed, becauseXmlElement.nameis the tag name, and it would have made the next release a3.0.0. That was too high a price for one rename, soLyricstays a plain class with.nameandNotecallstoXmlElement()instead.Code written against 2.8.0 compiles unchanged, so this ships as 2.9.0.
The trade-off to be aware of:
Lyricis now the only element in the package that is not anXmlElement, sonote.childElements.whereType<Lyric>()finds nothing, and new lyric attributes have to be copied by hand insidetoXmlElement().Test plan
dart analyzecleantest/lyric_test.dartcoversNmTokenvalidation, each child element round trip, and that the 2.8.0name/syllabic/textAPI still works