Lyrics bind to the notes they are sung on — plus the diagnostics info --verbose was hiding - #5
Merged
Merged
Conversation
Melody: | A4 . C5 E5 |
Lyrics: | the tide came |
`came` is written directly beneath `C5` and sounds two thirds of a beat
after it, because the melody row divided the bar into four and the lyric
row into three. Every other notation format binds syllables to notes and
they disagree only about the mechanism; the counting mechanism ABC and
LilyPond use is what Plainsong already is, so notation/lyrics.py counts.
Behind `core.lyrics = "bound"`, defaulting to `independent`. Lyric events
reach the MIDI file as meta events, so binding changes output, and a change
to how existing notation compiles defaults to the old reading even when the
new one is better. PLAINSONG_CORE_LYRICS does it for one run.
The barline resyncs: each bar's syllables bind to that bar's notes and
nothing crosses a `|`. Too many in a bar are reported and not sung and the
next bar is unaffected, so a miscount costs one bar instead of shifting
every remaining word in the song. Too few and the last word carries across
the remaining notes -- a melisma, needing no mark, written the way a lead
sheet already writes it. Lyrics with no melody to bind to are kept exactly
where they were and reported, because dropping words silently is the worst
answer available.
Padding is not melisma, and this is the part the plan got wrong. It
proposed reading a sustain token in a lyric row as a melisma, by analogy
with ABC's `_`. The only file here with lyric rows writes
Melody: | Bb3 . F4 . |
Lyrics: | sing . every . |
which is two words on two notes, the dots holding the column under a
sustaining melody. Read as melismas they each consume a note and `every`
falls off the end of the bar. The analogy was to another format's spelling;
the notation people actually write beat it.
Phase 2 consumes Phase 1: binding reads the finished TimeGrid, so it needs
no second opinion about where anything is.
629 tests, 7 specs including a new one that fails when binding is disabled,
ruff clean, 6,339 sources checked, and 6,321 files compiling to exactly the
music they did -- this changes lyrics and only lyrics, which the tests
assert by comparing every pitch, start and duration across both modes.
Also corrects three things in the plan against primary sources. SMuFL
defines one staff space as 0.25 em for scoring metrics and 0.2 em for text
metrics; the plan quoted the text figure while using Bravura's scoring
engravingDefaults, so a chart wants 0.25. LilyPond's shortest-duration-space
is 2.0 and the plan's 2.4 is that times the 1.2 increment -- a product, not
a property, and a future reader will otherwise "fix" it back. And the flat
sign is not in the font while the sharp is: reading Liberation Sans's cmap
directly, U+266F is present and U+266D and U+266E return glyph 0, so a
chart in the Arial-metric stack can draw C-sharp and cannot draw B-flat,
which is the wrong half to lose given a songbook full of flats.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
… its diagnostics
Two gaps in Phase 2, both of the same shape: the system knew something and
would not say it.
`plainsong lyrics song.song` prints which note each syllable is sung on,
next to where it was written, and marks the ones that move:
syllable written at sung at held
the 0 0 2
tide 1.3333 2 1 <- moves
came 2.6667 3 1 <- moves
It exists for the reason `chord --explain` does: guessing was cheaper than
checking, and that is exactly how this misalignment went unnoticed for the
life of the format. Showing both readings makes the difference a fact
rather than a claim. Syllables are paired by position, not by matching the
word or a nearby time, so a song that says `la` six times does not pair the
fifth with the second.
The second gap is older and wider. Diagnostics come from two places and the
arranger's are the ones a reader most needs, because an unreadable chord
becomes silence while arranging rather than while parsing.
`transform.describe` arranged the score and then reported score.diagnostics,
throwing the arrangement's away. So `plainsong info --verbose`, which
documents itself as giving every diagnostic, gave half -- and so did every
other consumer of describe. A file whose only chord was `Xm9` reported
notes 0
and explained nowhere. Arrangement.diagnostics is already the union of both,
and is now what gets reported; cmd_info no longer re-parses to fetch
diagnostics it has already computed, which is how it got only the parser's.
This is the second time this fault has shipped -- cmd_check had it before --
so CLAUDE.md now records the shape rather than just the instance.
Documentation, since a rule nobody reads is not a rule:
- docs/lyrics.md is the full account, including what is still missing:
melisma and skip are not distinguished, hyphenation is not understood,
and nothing warns about disagreement under the default mode.
- README gains "a column is not a promise" beside the bar-division rule,
because it is the second thing that surprises people and previously
appeared nowhere.
- AGENTS.md rewrites "whitespace is decorative" to say that a column is
decorative too, and adds the diagnostics fault to the catalogue of
mistakes -- an agent reading a clean report is exactly who this bug
caught.
- docs/notation.md warns not to trust a lyric's column, in the row table
and in what-to-avoid.
- CLAUDE.md carries both rules and the repeat offence.
634 tests, 7 specs, ruff clean, 6,339 sources checked, 6,321 files compiling
to exactly the music they did. The describe fix was mutated back and the
suite went red; every command quoted in the docs was run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
info --verbose was hiding
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.
cameis written directly beneathC5and sounds two thirds of a beat afterit, because the melody row divided the bar into four and the lyric row into
three. Alignment is a convention for human eyes and the compiler could not see
it.
Every other notation format binds syllables to notes, disagreeing only about
mechanism. MusicXML and MEI attach the syllable to the note — impossible for a
row-based text format. ABC and LilyPond count, walking a flat syllable
stream in lockstep against the notes. Plainsong is already a flat stream, so
notation/lyrics.pycounts.Gated, and defaulting to the old reading
PLAINSONG_CORE_LYRICSdoes it for one run. The default staysindependentbecause lyric events reach the MIDI file as meta events, so binding changes
output — and a change to how existing notation compiles defaults to the old
reading even when the new one is better.
nothing crosses a
|. Too many in a bar are reported and not sung, and thenext bar is unaffected — a miscount costs one bar rather than shifting every
remaining word in the song.
across them.
LyricEvent.durationsays how long.reported, because dropping words silently is the worst answer available.
The plan was wrong about melisma, and the corpus said so
proposals/02-the-voyage.mdconcluded from format research that a sustain tokenin a lyric row should read as a melisma, by analogy with ABC's
_. The onlyfile in this repository with lyric rows writes:
Two words, two notes — the dots hold the column under a sustaining melody.
Read as melismas they each consume a note and
everyfalls off the end of thebar. So padding binds to nothing. The analogy was to another format's spelling;
the notation people actually write beat it.
Phase 2 consumes Phase 1 directly: binding reads the finished
TimeGrid, so itnever forms a second opinion about where anything is.
plainsong lyrics— ask, don't inferIt exists for the same reason
chord --explaindoes: guessing was cheaper thanchecking, and that is exactly how this went unnoticed for the life of the
format. Syllables are paired by position, not by matching the word or a
nearby time, so a song that sings
lasix times doesn't pair the fifth with thesecond.
info --verbosewas showing half the diagnostics it promisedDiagnostics come from two places, and the arranger's are the ones a reader most
needs: an unreadable chord becomes silence while arranging, not while
parsing.
transform.describearranged the score and then reportedscore.diagnostics, discarding the arrangement's. A file whose only chord wasXm9reported:and explained nowhere. This hit every consumer of
describe, not justinfo.Arrangement.diagnosticsis already the union of both and is now whatgets reported;
cmd_infono longer re-parses to fetch diagnostics it hasalready computed, which is how it ended up with only the parser's.
Second time this fault has shipped —
cmd_checkhad it before — soCLAUDE.mdnow records the shape rather than just the instance.
Documentation
docs/lyrics.mdis the full account, including what is still missing:melisma and skip are not distinguished, hyphenation is not understood, and
nothing warns about row disagreement under the default mode.
the second thing that surprises people, previously documented nowhere.
decorative too, and adds the diagnostics fault to its catalogue of mistakes:
an agent reading a clean report is precisely who that bug caught.
and in what-to-avoid.
Three corrections to the Phase 3 plan, from primary sources
0.25 emfor scoring metrics and0.2 emfor text metrics(
scoring-metrics-glyph-registration.htmlvstext-metrics-glyph-registration.html, same in releases 1.3 and 1.4). Theplan used the text figure alongside Bravura's scoring
engravingDefaults,so a chart wants 0.25.
2.4is a product, not a property. LilyPond'sshortest-duration-spaceis
2.0; 2.4 is that times the1.2increment (lily/spacing-options.cc,scm/define-grobs.scm), which cites Gourlay 1987 in the source comment.Liberation Sans's
cmap:♯U+266F → glyph 2238, while♭U+266D and♮U+266E → glyph 0. A chart in the Arial-metric stack can draw
C♯andcannot draw
B♭— the wrong half to lose, given a songbook full of flatsand a parser that accepts
E7♭9.Now flagged as unproven: "Helvetica, Arial and Liberation Sans are
metric-compatible." No Helvetica AFM exists to diff against, and Liberation's
Mis 833/1000 em — Arial's figure, not Helvetica's 889. Bravura'sengravingDefaultsare confirmed frombravura-1.481.Verification
634 tests, 7 specs, ruff clean,
ok 6339 file(s) checked, 3 warning(s), and6321 file(s) compile exactly as recorded.The new spec check fails when binding is disabled. Four mutations were applied
and each turned the suite red: treating padding as words, dropping the barline
truncation, discarding unbindable lyrics, and reverting
describeto theparser's diagnostics alone. The tests assert binding changes lyrics and only
lyrics, comparing every pitch, start and duration across both modes. Every
command quoted in the docs was run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc