Skip to content

Lyrics bind to the notes they are sung on — plus the diagnostics info --verbose was hiding - #5

Merged
SuperInstance merged 2 commits into
masterfrom
claude/init-1wgqn0
Aug 17, 2026
Merged

Lyrics bind to the notes they are sung on — plus the diagnostics info --verbose was hiding#5
SuperInstance merged 2 commits into
masterfrom
claude/init-1wgqn0

Conversation

@SuperInstance

@SuperInstance SuperInstance commented Aug 17, 2026

Copy link
Copy Markdown
Owner
[V1] (Verse - 1 Bars)
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. 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.py counts.

Gated, and defaulting to the old reading

[core]
lyrics = "bound"

PLAINSONG_CORE_LYRICS does it for one run. The default stays independent
because 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.

  • The barline resyncs. Each bar's syllables bind to that bar's notes;
    nothing crosses a |. Too many in a bar are reported and not sung, and the
    next bar is unaffected — a miscount costs one bar rather than shifting every
    remaining word in the song.
  • A held syllable needs no mark. Fewer words than notes and the last carries
    across them. LyricEvent.duration says how long.
  • Lyrics with no melody to bind to are kept where they were written and
    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.md concluded from format research that a sustain token
in a lyric row should read as a melisma, by analogy with ABC's _. The only
file in this repository with lyric rows writes:

Melody: | Bb3 .   F4    .   |
Lyrics: | sing .   every .   |

Two words, two notes — the dots hold the column under a sustaining melody.
Read as melismas they each consume a note and every falls off the end of the
bar. 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 it
never forms a second opinion about where anything is.

plainsong lyrics — ask, don't infer

$ plainsong lyrics song.song
ok  6 syllable(s); 4 move when bound to their notes
    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 same reason chord --explain does: guessing was cheaper than
checking, 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 la six times doesn't pair the fifth with the
second.

info --verbose was showing half the diagnostics it promised

Diagnostics 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.describe arranged the score and then reported
score.diagnostics, discarding the arrangement's. A file whose only chord was
Xm9 reported:

notes     0

and explained nowhere. This hit every consumer of describe, not just
info. 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 ended up with only the parser's.

Second time this fault has shipped — cmd_check had it before — so CLAUDE.md
now records the shape rather than just the instance.

Documentation

  • 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 row disagreement under the default mode.
  • README gains "a column is not a promise" beside the bar-division rule —
    the second thing that surprises people, previously documented nowhere.
  • AGENTS.md rewrites "whitespace is decorative" to say a column is
    decorative too, and adds the diagnostics fault to its catalogue of mistakes:
    an agent reading a clean report is precisely who that bug caught.
  • docs/notation.md warns against trusting a lyric's column, in the row table
    and in what-to-avoid.
  • CLAUDE.md carries both rules and the repeat offence.

Three corrections to the Phase 3 plan, from primary sources

  • Staff space — the plan quoted the wrong one of two. SMuFL defines both:
    0.25 em for scoring metrics and 0.2 em for text metrics
    (scoring-metrics-glyph-registration.html vs
    text-metrics-glyph-registration.html, same in releases 1.3 and 1.4). The
    plan used the text figure alongside Bravura's scoring engravingDefaults,
    so a chart wants 0.25.
  • 2.4 is a product, not a property. LilyPond's shortest-duration-space
    is 2.0; 2.4 is that times the 1.2 increment (lily/spacing-options.cc,
    scm/define-grobs.scm), which cites Gourlay 1987 in the source comment.
  • The flat sign is not in the font and the sharp is. Verified by walking
    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♯ and
    cannot draw B♭ — the wrong half to lose, given a songbook full of flats
    and 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
M is 833/1000 em — Arial's figure, not Helvetica's 889. Bravura's
engravingDefaults are confirmed from bravura-1.481.

Verification

634 tests, 7 specs, ruff clean, ok 6339 file(s) checked, 3 warning(s), and
6321 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 describe to the
parser'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

claude added 2 commits August 17, 2026 20:31
    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
@SuperInstance SuperInstance changed the title Lyrics bind to the notes they are sung on — and the plan was wrong about melisma Lyrics bind to the notes they are sung on — plus the diagnostics info --verbose was hiding Aug 17, 2026
@SuperInstance
SuperInstance merged commit 6e8c781 into master Aug 17, 2026
16 checks passed
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