Skip to content

Name a fingering by the measure's place, not the number printed on it - #350

Open
isc wants to merge 1 commit into
mainfrom
claude/determined-mestorf-5f5d9c
Open

Name a fingering by the measure's place, not the number printed on it#350
isc wants to merge 1 commit into
mainfrom
claude/determined-mestorf-5f5d9c

Conversation

@isc

@isc isc commented Sep 8, 2026

Copy link
Copy Markdown
Owner

A fingering is stored under a name for the note it belongs to. That name began
with the measure's number attribute, which is a label and not an identity, and
the library ships scores that prove it.

What was wrong

Score Symptom
Gnossienne No. 1 No barlines, so all 11 measures export as number="0". 872 notes shared 86 names: a fingering written on one measure was drawn on all eleven, and could not be taken off any of them.
The Entertainer (two one-staff parts) OSMD numbers staves across the sheet, the injection numbered them within the part. 963 notes of the lower staff were filed under names the injection gave to the upper staff's notes.
Chopin Ballade No. 1 Same, with two parts of two staves each: the second part's staves landed on the first's.
The Entertainer (1902) A second ending printed number="X1". parseInt reads NaN, OSMD reads null — so 57 notes were stored under one name and looked for under another, and their fingerings never appeared at all.
14 more scores Cue and print-object="no" notes: the three walks that produce a name disagreed about which to count. An index is a running total, so past the first such note every fingering in the measure slid onto the note next door.

Eighteen of the ninety-eight shipped scores, not the four the ambiguity is
visible on.

The name

m<measure index>:<sheet-wide staff>:<voice>:<note>, defined once in
public/js/fingeringKeys.js.

  • measure — its position in the score, which is both OSMD's SourceMeasures
    index and the nth <measure> of a <part>. Repeats do not disturb it: it
    names the source measure, engraved once, and the playback sequence already
    maps passes onto sources.
  • staff — counted across the sheet, from each part's <staves>.
  • note — every <note> that is not a rest, cue and hidden and grace notes
    included. Each walk keeps its own reasons to leave a note out of what it
    builds; none of them may reach the counter.
  • the m — old and new names can never be confused. That matters because a
    record syncs whole and last-write-wins, so a device still on the old build
    must show nothing rather than something wrong.

The walks

The click map no longer walks the sheet at all — it reads the note model, which
already holds the name — so three copies of the counting rule become two. The
two that remain cannot be merged: injection has to happen before osmd.load().
So test/fingering_key_scheme_test.rb holds them against each other across the
whole library instead, checking that each name means a note of the same
pitch in both: 114,903 notes, 98 scores, no disagreement. The same test fails
on thirteen scores against the code this replaces.

Migration

There is no migration system, and none is needed. Each score's extraction builds
Map<old name, new name[]> as it names the notes, so the translation is exact
rather than re-derived from the file — which could not work anyway, since the
stored names carry the editor's spelling of the measure number
(MeasureNumberXML, null) and the file only offers the injection's
(parseInt, NaN).

On the first load of a score whose record still holds old names, they are
rewritten and saved with a fresh updatedAt so the fix reaches the player's
other devices, and what the injection could not place is drawn by the light
re-render a newly entered fingering already takes.

Two judgement calls, both in the code's comments:

  • A name several notes answered to is copied onto all of them. The score
    looks exactly as it did — the player sees the same fingerings in the same
    places — and each is now its own name, so the ten they never wrote can be
    deleted one at a time. Picking a single winner would move their fingering to a
    measure they never touched.
  • A name no note answers to is dropped. Those are the NaN: names, which
    could never be drawn; nothing visible is lost.

Testing

  • New browser test run standalone, plus once through the whole suite.
  • Full Ruby suite: 116 runs, 0 failures. JS suite: 358 tests, 0 failures.
  • Verified live in Chrome: a legacy 0:0:0:4 on the Gnossienne becomes 11
    explicit names and all 11 fingerings are drawn where they were.

Note for #348

That PR mirrors this scheme deliberately. It will want to rebase onto
fingeringNotesInDocument, which is the injection walk lifted out so a second
consumer — and the invariant test — can share it.

🤖 Generated with Claude Code

A fingering is stored under a name for the note it belongs to, and that name
began with the measure's `number` attribute. That attribute is a label, not an
identity, and the library has scores that prove it. Satie's Gnossienne No. 1
has no barlines, so MuseScore exports all eleven of its measures as
`number="0"`: its 872 notes shared 86 names, and a fingering written on one
measure was drawn on all eleven, with no way to take it off any of them. The
1902 Entertainer prints "X1" on a second ending, which parseInt reads as NaN
while OSMD reads it as null -- so those 57 notes were filed under one name and
looked for under another, and their fingerings never appeared at all.

The name also numbered the staff within its part, where OSMD numbers it across
the whole sheet. On the two-part Entertainer that put the whole lower staff --
963 notes -- under names the injection gave to the upper staff's notes, and on
the Chopin Ballade it put the second part's two staves on top of the first's.

And the three walks that produce the name disagreed about which notes to count:
the injector counted every note the measure has, the extraction skipped cue and
hidden notes, the click map skipped a third set. An index is a running total, so
one walk skipping a note moves every name after it in the measure -- fourteen
more scores carry a cue or hidden note, and past it the fingerings slid onto the
note next door.

A name is now `m<measure index>:<sheet-wide staff>:<voice>:<note>`, defined once
in fingeringKeys.js, and the counter spends a place for every non-rest note
whatever a walk then does with it. The `m` matters: it makes old and new names
impossible to confuse, which they have to be, since a record syncs whole and a
device still on the old build must show nothing rather than something wrong.

The click map no longer walks the sheet at all -- it reads the note model, which
already holds the name -- so what had been three copies of the counting rule is
now two, and the two that remain cannot be merged: injection happens before
osmd.load(). A browser test holds them against each other across the whole
library instead: 114,903 notes, every name the same note in both walks. The same
test fails on thirteen scores against the code this replaces.

Fingerings already stored are rewritten on the first load of each score, from a
map the extraction builds as it names the notes, so the translation is exact
rather than re-derived. A name several notes answered to is copied onto all of
them -- the score looks exactly as it did, and the copies can now be deleted one
at a time -- and a name no note answers to, which could never be drawn, is
dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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