Skip to content

fix: scope spanner number pools to the part, not the staff - #351

Merged
webern merged 1 commit into
webern:mainfrom
rpatters1:fix/spanner-number-part-scope
Jul 13, 2026
Merged

fix: scope spanner number pools to the part, not the staff#351
webern merged 1 commit into
webern:mainfrom
rpatters1:fix/spanner-number-part-scope

Conversation

@rpatters1

Copy link
Copy Markdown
Contributor

Summary

Fixes #350. SpannerNumberResolver pooled spanner numbers per staff, but MusicXML's number-level type scopes concurrency to the part in document order — its own piano example requires a cross-staff slur to take a different number from staff-local slurs it overlaps. Two identity slurs overlapping in document order on different staves of the same part therefore both received number="1", and readers that pair endpoints by number within the part (e.g. MuseScore) mispaired them.

Fix

Drop the staff dimension from the resolver: events no longer carry a staff index, and explicit reservations and identity groups live in one pool per part + spanner class. The cross-staff "reserve in every staff pool touched" logic disappears rather than needing repair.

Fixed File

This file is the same one I posted in the issue, but it now has non-conflicting number levels.

numberlev_2staffpart_fixed.musicxml.txt

Testing

  • New: overlappingSlursOnDifferentStavesGetDistinctNumbers ‚Äî barline-crossing slurs on two staves of one part take numbers 1 and 2 (fails against the unfixed resolver: both came back 1)
  • New: sameMeasureSlursOnDifferentStavesReuseTheNumber ‚Äî the spec-blessed reuse case still gets 1/1, guarding against a naive always-increment fix
  • make test (425 cases), make test-api-roundtrip (224/224 pinned), make fmt
  • Unity build (CMAKE_UNITY_BUILD=ON, batch size 0) compiles
  • Verified end-to-end in a denigma export: the previously colliding two-staff slur fixture now emits distinct numbers

MusicXML's number-level type judges concurrency in document order within
the part; the staff never enters into it. The spec's own piano example
requires a cross-staff slur to take a different number from staff-local
slurs it overlaps in document order. SpannerNumberResolver pooled per
staff, so two identity slurs overlapping in document order on different
staves of the same part both received number 1, and readers that pair
endpoints by number within the part (e.g. MuseScore) mispaired them.

Drop the staff dimension from the resolver: events no longer carry a
staff index, reservations and identity groups live in one pool per part
and spanner class, and the cross-staff every-pool-touched reservation
logic disappears. New tests pin both directions of the contract: slurs
overlapping in document order across staves take distinct numbers, and
same-measure slurs on different staves (no document-order overlap) still
reuse number 1 as the spec example allows.
@github-actions

Copy link
Copy Markdown

Coverage report

Core-dev coverage src/private/mx/core/

Metric Coverage Covered / Total
Lines 77.8% 28514 / 36648
Functions 74.2% 6352 / 8556
Branches 50.7% 22680 / 44751

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
Lines 84.5% 7228 / 8551
Functions 74.9% 2511 / 3352
Branches 52.4% 6307 / 12042

Core HTML report | API HTML report

Commit 6fd3df3b52622df3eadb3ae3088f165be4035538.

@github-actions

Copy link
Copy Markdown

gen-quality gen/

gen-quality: 84.5 / 100   (floor 84.5, +0.0)

  structure     86.5  x0.50   [fn 90.5 / file 82.6]
  cyclomatic    88.4  x0.25
  cognitive     76.6  x0.25

  409 functions across 31 files, 7702 lines (largest file 1044)
  max cc 56  max cognitive 44  max fn loc 152

Worst offenders (top 5 per axis; full lists in score.json):
  cyclomatic gen/xsd/analyze.py:311     report                             56
  cyclomatic gen/plates/build.py:956    _validate_config_against_ir        35
  cyclomatic gen/press/context.py:145   plate_context                      34
  cyclomatic gen/__main__.py:46         _ir                                23
  cyclomatic gen/tests/test_ir.py:102   _check_references                  20
  cognitive  gen/xsd/analyze.py:311     report                             44
  cognitive  gen/ir/resolve.py:119      flat_elements                      40
  cognitive  gen/tests/test_ir.py:102   _check_references                  38
  cognitive  gen/press/context.py:145   plate_context                      37
  cognitive  gen/xsd/analyze.py:207     _sccs                              37
  size       gen/xsd/analyze.py:311     report                             152
  size       gen/press/context.py:145   plate_context                      96
  size       gen/plates/build.py:533    _value_plate                       89
  size       gen/plates/build.py:956    _validate_config_against_ir        89
  size       gen/ir/resolve.py:119      flat_elements                      78

Commit 6fd3df3b52622df3eadb3ae3088f165be4035538.

@webern webern left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, thank you for fixing my mistake. Nice tests, too.

@webern
webern merged commit 5165214 into webern:main Jul 13, 2026
10 checks passed
webern added a commit that referenced this pull request Jul 19, 2026
## Human Summary

Changes the way pedals are modeled into two different things. Pedal
marks, which are the old fashioned Ped and * marks, and a separate type
for more modern lines.

## Summary

`DirectionReader::parsePedal` handled only `PedalType` start/stop and
silently dropped
`sostenuto`, `change`, `continue`, `discontinue`, and `resume`, even
though `core::PedalType`
carries all seven tags. This was the last unmodeled item in #324.

The shape decision (called for in #324) was to store the pedal type as
one fact in one field
rather than scatter it across start/stop/other vectors. This replaces
the `pedalStarts` /
`pedalStops` spanner vectors on `DirectionData` with a single
`std::vector<PedalData> pedals`,
where each `PedalData` carries a `PedalKind` enum covering every
pedal-line type. Each pedal event
stands on its own and is placed at its own tick, so the model has no
neighbor-dependent meaning.

- New `PedalData.h`: the `PedalKind` enum (`start`, `stop`, `sostenuto`,
`change`, `continueLine`,
  `discontinue`, `resume`) and the `PedalData` struct.
- Reader: line pedals (`line="yes"`, plus the inherently line-formatting
types
`sostenuto`/`change`/`continue`/`discontinue`/`resume`) go to `pedals`;
sign start/stop still
  route through `MarkData` as `MarkType::pedal` / `damp`, unchanged.
- Writer: a single `emitPedal` maps `PedalKind` back to
`core::PedalType`, emits `line="yes"`, and
  emits nothing for an unspecified kind.

Only two corpus files use these types (`lysuite/ly31a_Directions.xml`,
`lysuite/ly33a_Spanners.xml`)
and both fail first on unrelated features, so this does not flip any
file to PASS. It is validated
by a focused unit test that round-trips each pedal type through the api.

This is a breaking change: it reshapes `DirectionData`'s pedal fields.

## Testing

- [x] New `PedalAllKinds` round-trips all seven pedal types through the
api; `PedalPlacement` covers
  placement (`*Pedal*`: 44 assertions in 5 test cases)
- [x] Full unit suite passes (5130 assertions in 451 test cases)
- [x] api round-trip regression unchanged (284 passed, 0 failed of 284
pinned)

## References

- Closes #324 (the `symbol` item from that worklist is tracked
separately by #294 and is not
  included here)
- Follows the merged direction-type stack #358#363
- Part of #208
- Spanner-numbering context: #320, #351
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.

SpannerNumberResolver pools numbers per staff, but number-level scope is the part

2 participants