Skip to content

feat: model unmeasured tremolos and the full slash range in mx::api - #383

Merged
webern merged 3 commits into
webern:mainfrom
rpatters1:unmeasured-tremolos
Aug 5, 2026
Merged

feat: model unmeasured tremolos and the full slash range in mx::api#383
webern merged 3 commits into
webern:mainfrom
rpatters1:unmeasured-tremolos

Conversation

@rpatters1

Copy link
Copy Markdown
Contributor

Human Summary

Unmeasured tremolos (the buzz roll) now round-trip through mx::api, keeping their SMuFL glyph name. One-note tremolos cover MusicXML's full 1-8 slash range instead of silently collapsing 6, 7, and 8 down to three.

Summary

MusicXML's <tremolo> has three kinds and mx::api modeled two of them. An unmeasured tremolo (<tremolo type="unmeasured">0</tremolo>) was labeled "this tremolo is not a mark" by OrnamentsFunctions and then discarded by parseOrnamentsSet, so it vanished on read and could not be written at all. mx::core models it completely; the gap was confined to the api and impl layers.

MarkType::tremoloUnmeasured joins the existing tremolo enumerators. Which kind of tremolo a mark is stays in MarkType, where it already lived for tremoloSingleOne through tremoloSingleFive and tremoloStart/tremoloStop, rather than becoming a second type field in the payload that could contradict it.

TremoloMarkData gains an optional smufl glyph name, following the precedent in PercussionData and NoteData::noteheadSmufl. It is read and written for every tremolo type, not only unmeasured. MusicXML defines no meaning for it elsewhere, but recording it verbatim costs nothing and stops synthetic/tremolo.3.1.xml from losing the attribute it exists to cover.

One-note tremolos now cover the whole tremolo-marks range. The reader handled 0 through 5 and let 6, 7, and 8 fall through to the ornamentsMap default, silently rewriting a seven-slash tremolo as three. tremoloSingleSix, tremoloSingleSeven, and tremoloSingleEight close that, and both slash-count lookups become switches. Note that SMuFL only provides precomposed glyphs for the first five; because those are combining marks, a renderer builds the higher counts by stacking them.

A count of 0 on a single-type tremolo still reads back as three slashes. It is legal MusicXML -- the 3.0 schema allowed it years before unmeasured existed -- but degenerate, since zero slashes draw nothing. The fallback is now documented, with a TODO to log the downgrade if mx ever grows a warning channel.

Adds data/synthetic/tremolo.unmeasured.3.1.xml and pins it in the api round-trip baseline. It omits the font and color attributes its tremolo.3.0 and tremolo.3.1 siblings carry, because MarkData models neither; those two files still fail the round trip on that unrelated limitation. The corert pinned count moves to 837.

Testing

  • make api-test MX_RUNNING_IN_DOCKER=1 (498 test cases, 5487 assertions)
  • make api-roundtrip MX_RUNNING_IN_DOCKER=1 (297 pinned files, 0 failed)
  • make core-roundtrip-test MX_RUNNING_IN_DOCKER=1 (838 test cases, 837 pinned files)
  • make audit MX_RUNNING_IN_DOCKER=1 (regenerates data/corpus.xml and the new .features.xml sidecar)
  • Three new/extended cases in NoteDataTest: an api round trip of tremoloUnmeasured carrying a glyph name, a read of data/synthetic/tremolo.unmeasured.3.1.xml pinning the core to api path, and the existing tremolos case extended from five slashes to eight
  • data/synthetic/tremolo.unmeasured.3.1.xml validated with xmllint --nonet --schema docs/musicxml-3.1-8bbe8e5.xsd
  • Strict unity build of target mx
  • git diff --check
  • make fmt-check MX_RUNNING_IN_DOCKER=1 clean for every file this PR touches. A local run also flags src/include/mx/api/ApiEquality.h, which this branch does not modify -- local clang-format is 19.1.6 while CI pins 18.

## Summary

MusicXML's `<tremolo>` has three kinds and `mx::api` modeled two of them.
An unmeasured tremolo (`<tremolo type="unmeasured">0</tremolo>`, the buzz
roll) was labeled "this tremolo is not a mark" by `OrnamentsFunctions` and
then discarded by `parseOrnamentsSet`, so it vanished on read and could not
be written at all. `mx::core` models it completely; the gap was confined to
the api and impl layers.

- `MarkType::tremoloUnmeasured` joins the existing tremolo enumerators.
Which kind of tremolo a mark is stays in `MarkType`, where it already lived
for `tremoloSingleOne` through `tremoloSingleFive` and
`tremoloStart`/`tremoloStop`, rather than becoming a second `type` field in
the payload that could contradict it.
- `TremoloMarkData` gains an optional `smufl` glyph name, following the
precedent in `PercussionData` and `NoteData::noteheadSmufl`. It is read and
written for every tremolo type, not only unmeasured. MusicXML defines no
meaning for it elsewhere, but recording it verbatim costs nothing and stops
`synthetic/tremolo.3.1.xml` from losing the attribute it exists to cover.
- One-note tremolos now cover the whole `tremolo-marks` range. The reader
handled 0 through 5 and let 6, 7 and 8 fall through to the `ornamentsMap`
default, silently rewriting a seven-slash tremolo as three.
`tremoloSingleSix`, `tremoloSingleSeven` and `tremoloSingleEight` close
that, and both slash-count lookups become switches.

A count of 0 on a single-type tremolo still reads back as three slashes. It
is legal MusicXML -- the 3.0 schema allowed it years before `unmeasured`
existed -- but degenerate, since zero slashes draw nothing. The fallback is
now documented, with a TODO to log the downgrade if mx ever grows a warning
channel.

Adds `data/synthetic/tremolo.unmeasured.3.1.xml` and pins it in
`roundtrip-baseline.txt`. It omits the font and color attributes its
`tremolo.3.0` and `tremolo.3.1` siblings carry, because `MarkData` models
neither; those two files still fail the round trip on that unrelated
limitation. The corert pinned count moves to 837.

## Testing

- [x] Two new NoteDataTest cases: an api round trip of `tremoloUnmeasured`
carrying a glyph name, and a read of
`data/synthetic/tremolo.unmeasured.3.1.xml` that pins the core to api path
- [x] The existing `tremolos` case extended from five slashes to eight,
with a size assertion so a future truncation fails instead of passing on a
short vector
- [x] Full api suite passes (5487 assertions in 498 test cases)
- [x] `make core-roundtrip-test` passes (838 test cases, 837 pinned files)
- [x] `make api-roundtrip` passes (297 of 297 pinned)
Signed-off-by: Matthew James Briggs <matthew.james.briggs@gmail.com>
@rpatters1

Copy link
Copy Markdown
Contributor Author

The linux build error is an insidious merge issue. Two parallel PRs bumped the number of input files by one file. That means that the count tracker only got bumped once but the number of files got bumped twice. There's a counter that needs to be manually increased by 1.

@webern

webern commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The linux build error is an insidious merge issue. Two parallel PRs bumped the number of input files by one file. That means that the count tracker only got bumped once but the number of files got bumped twice. There's a counter that needs to be manually increased by 1.

Yeah that check is kind of annoying. Easy fix, I can take care of it.

@webern
webern merged commit e9a6f99 into webern:main Aug 5, 2026
7 checks passed
webern pushed a commit that referenced this pull request Aug 5, 2026
## Human Summary

mx::api could only ever write an undecorated `<harmonic/>`. The
natural/artificial distinction and the base/touching/sounding-pitch
detail were dropped in both directions, so an artificial harmonic was
indistinguishable from a natural one. This adds them as a MarkDataChoice
payload.

Also fixes the harmonic writer dropping print-object, font, and color.
The wider print-object gap it uncovered is called out in the Summary and
left for a separate change.

## Summary

MusicXML's `<technical><harmonic>` states how a harmonic is produced
(`<natural/>` or `<artificial/>`) and which of its three pitches the
written notehead represents (`<base-pitch/>`, `<touching-pitch/>`,
`<sounding-pitch/>`). `mx::core::Harmonic` models both, as `choice` and
`choice2`, but `mx::api` dropped them in both directions:
`NotationsWriter::addTechnical` built a bare `core::Harmonic` and never
called `setChoice`/`setChoice2`, and
`TechnicalFunctions::parseTechicalMark` read only the attributes.

Following the `TremoloMarkData` / `ArpeggiateMarkData` precedent, this
adds a fourth `MarkDataChoice` alternative:

- `HarmonicMarkData::kind` is a `HarmonicKind` of unspecified, natural,
or artificial.
- `HarmonicMarkData::pitch` is a `HarmonicPitch` of unspecified,
basePitch, touchingPitch, or soundingPitch.

Both default to unspecified, so authoring without the payload writes the
plain `<harmonic/>` exactly as before, and the reader leaves `choice` as
none for a bare element rather than inventing a natural/artificial
value. The two notes of an artificial harmonic each carry their own
mark, artificial with basePitch on the stopped note and artificial with
touchingPitch on the diamond-notehead note, so neither note depends on
its neighbor for meaning.

Also fixes a write-side gap found while adding it. The harmonic writer
used `setAttributesFromPositionData`, so the element's font, color, and
print-object were all lost on write. It now uses
`setAttributesFromMarkData` and sets print-object explicitly, because
`setAttributesFromPrintData` writes color and font but never touches
printObject. That omission still affects every other mark going through
`setAttributesFromMarkData` (fermata, dynamics, arpeggiate,
non-arpeggiate), and the `impl::setPrintObject` template in
`PrintFunctions.h` that looks like it was meant to cover it is unused
and has its condition inverted. Left for a separate change.

`data/synthetic/harmonic.3.0.xml` already existed but had never passed;
it is now pinned (296 -> 297). `lysuite/ly32a_Notations.xml` and
`ksuite/k004a_Technical.xml` get past their harmonic content but still
fail on unrelated `<lyric>` and `<alter>` ordering, so neither is
pinned.

## Testing

- [x] Three new cases in MarkRoundTripTest: the artificial pair (base
pitch and touching pitch), all eleven kind/pitch combinations, and a
bare `<harmonic/>` that must read back with no payload
- [x] Harmonic tests pass (`*Harmonic*`: 43 assertions in 3 test cases)
- [x] Full api suite passes (5512 assertions in 499 test cases)
- [x] `make api-roundtrip` passes (297 of 297 pinned)
- [x] `make core-roundtrip-test` passes (837 test cases)

## References

- Related to #382 and #383, which also add `MarkDataChoice`
alternatives. Whichever of the three lands first, the others need a
union resolution in `MarkDataChoice.h` and `MarkDataChoice.cpp`; the
three payloads are independent.
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.

2 participants