1.1.0: the demo cut every held note short, and the time matrix that makes alignment mean something - #4
Merged
Merged
Conversation
Rows divide their bars independently. That is the point of the notation --
three tokens in a bar are triplets no matter what the row above did -- but
it means the columns a reader trusts mean nothing to the compiler:
Melody: | A4 . C5 E5 |
Lyrics: | the tide came |
`came` is written directly beneath `C5` and sounds two thirds of a beat
after it, because one row divided the bar into four and the other into
three. This was not merely unreported, it was inexpressible: there was no
coordinate in which the two could be compared. A renderer had nothing to
lay lyrics against, a merge had no space to detect a conflict in, and a
linter had no way to say "this row disagrees with its neighbours".
notation/timegrid.py places every written token on one coordinate system.
`bar` and `unit` are derived in TimeGrid.add and nowhere else, which is the
whole point -- a lyric and a note cannot drift apart by being computed in
two places. Rests and sustains are recorded too, before the note dispatch,
so a token that makes no sound still holds its column: a renderer has to
leave room for it and a merge has to see it as taken.
Three problems become one projection. Rendering is x = unit * bar_width.
Merging is set intersection on (row, bar, unit), and the row axis is
disjoint -- players keyed by name -- so two agents editing different rows
provably cannot collide. Linting gains a question it could not previously
ask: grid.disagreements() names each bar whose rows divide it differently.
Nothing is reported and nothing is enforced. Uneven subdivision is legal
and usually deliberate; a held chord under a running melody is two tokens
against sixteen and there is nothing wrong with it.
The grid observes the arranger and does not steer it, which is held by the
fingerprint rather than by intent: 6,321 files compile to exactly the music
they did. 610 tests, 7 specs, ruff clean.
One thing worth keeping from building it. The first version guarded the bar
boundary twice, rounding the position and nudging it before flooring, and
neither mutation could be made to fail a test because each fixed the case
alone. Two guards no test can tell apart are one guard and one piece of
dead code. Collapsed to the nudge, which then failed the suite when removed.
Also settles the one open question in the plan, in the other direction from
the draft. Every other format stacks repeated lyric rows as parallel verses
where Plainsong runs repeated rows on in time, and the draft proposed
inventing `Lyrics 2:` to have both. Run-on is a claim about time; once
lyrics bind to notes they own no time for a second row to follow on into,
so the rule does not reach them and needs no exception clause. A repeated
Lyrics row is a verse over the same music, and no syntax is invented.
Phase 1 of proposals/02-the-voyage.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
`.` and `-` sat in the demo's REST set where the compiler has them in SUSTAIN. They are the two most common tokens in the notation and they hold the note before them, so `| Am . . . |` played a one-beat chord and three beats of silence instead of a chord lasting the bar. Every sustained note on the landing page -- the first thing anybody hears -- was cut to a single subdivision. The parity test watched this the whole time and saw nothing, because it pins the note *count* each preset produces, and a rest and a sustain produce the same count. They differ only in how long the note before them lasts. The counts matched exactly, start to finish, and the page sounded wrong. A second implementation checked by counting is not checked. Counts are the property most likely to survive a divergence. So the guard now measures what actually differed: - tests/test_demo.py compares both token sets against notation/parser.py and pins the durations that follow from them. Restoring the old sets fails it -- confirmed by doing exactly that. - tools/demo_differential.py runs notation through the real page in a browser and through the compiler and compares pitch, start and duration of every note, across triplets, 3/4, 6/8, chord qualities, stacks, sustains, rests and repeated rows. CI cannot run it, since the suite installs nothing and assumes no browser, so it is a manual check. Against this revision all eight cases agree; against the previous one, six differ, every difference a duration. The page also claimed 6/8 parity that nothing checked. It is checked now, and the claim says how. While in here, the ah-ha. The playground had a text box and a piano roll side by side and never drew the line between them: a visitor had to infer that the third A4 they typed made the third block moving in the canvas. That inference is the entire idea of the notation. Each token now lights while the note it wrote sounds, the held `.` tokens included, so `Am . . .` illuminates as one thing and the dots are visibly part of the chord. A textarea cannot hold a styled span, so the text is painted behind it in identical metrics and lit there. A silent preview sweeps the playhead once on load so the page is visibly a thing that plays before anything is clicked. Canvas and CSS only -- it never opens an AudioContext, so it cannot make a sound nobody asked for -- and it ends at the first interaction and does not run under prefers-reduced-motion. On a phone the editor used to push Play below the fold; the panel that makes sound now comes first. Verified in headless Chromium at three viewport sizes: 97 token spans built, three rows lighting together on the beat, held sustains lighting with their note, and no horizontal overflow introduced (485/500 before and after). Also drops server.pid, a stale pid file that had been committed, and ignores it. 615 tests, 7 specs, ruff clean, 6,321 files compile exactly as recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
Left at 1.0.1 with the changes filed under Unreleased, tagging v1.0.1 would have published the time matrix, the demo fix and the differential tool as part of a release that does not contain them -- and PyPI will not accept that filename a second time to correct it. The release workflow only holds the tag to version.py, which would have agreed. A minor rather than a patch: notation/timegrid.py is a new public module and Arrangement.grid a new public field. Nothing existing changes, and the 6,321 fingerprinted files still compile to exactly the music they did. Wheel built and exercised in a clean venv outside the source tree: version, specs, packaged songbook, and the new grid all present and correct from /tmp rather than from the checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
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.
Two things, plus a decision that had been left open.
The landing page was playing the music wrong
.and-sat in the demo'sRESTset where the compiler has them inSUSTAIN. They are the two most common tokens in the notation and they hold thenote before them, so
| Am . . . |played a one-beat chord followed by threebeats of silence instead of a chord lasting the bar. Every sustained note on
the front door was cut to a single subdivision — the first thing anyone
arriving from an announcement hears.
The parity test watched this the whole time and saw nothing. It pins the note
count each preset produces, and a rest and a sustain produce the same count:
they differ only in how long the note before them lasts. The counts matched
exactly, start to finish.
So the guard now measures what actually differed:
tests/test_demo.pycompares both token sets againstnotation/parser.pyandpins the durations that follow from them. Restoring the old sets fails it —
confirmed by doing exactly that.
tools/demo_differential.pyis new: it runs notation through the real pagein a browser and through the compiler, comparing pitch, start and duration
of every note across triplets, 3/4, 6/8, chord qualities, stacks, sustains,
rests and repeated rows. CI cannot run it — the suite installs nothing and
assumes no browser — so it is a documented manual check. Against this revision
all eight cases agree; against the previous one six differ, every difference
a duration.
The page also claimed 6/8 parity that nothing checked. It is checked now, and
the claim says how.
The demo also gained the missing parenthesised token forms (
(hold),(rest),(let ring), and the rule that any other(direction)holds what is sounding),none of which it had at all.
The ah-ha
The playground had a text box and a piano roll side by side and never drew the
line between them: a visitor had to infer that the third
A4they typed made thethird block moving in the canvas. That inference is the entire idea of the
notation, and it was left as an exercise.
Each token now lights while the note it wrote is sounding — including the
.tokens the note is held across, so
Am . . .illuminates as one thing and thedots are visibly part of the chord rather than events of their own. A textarea
cannot hold a styled span, so the same text is painted behind it in identical
metrics and lit there.
A silent preview sweeps the playhead once on load, so the page is visibly a thing
that plays before anything is clicked. Canvas and CSS only — it never opens an
AudioContext, so it cannot make a sound nobody asked for — and it ends at thefirst interaction and does not run under
prefers-reduced-motion. On a phone theeditor used to push Play below the fold; the panel that makes sound now comes
first.
Verified in headless Chromium at three viewport sizes: 97 token spans built,
three rows lighting together on the beat, held sustains lighting with their note,
and no horizontal overflow introduced (485/500 both before and after).
Every token now has a position, sounding or not
Rows divide their bars independently, which is the point of the notation and also
means the columns a reader trusts mean nothing to the compiler:
cameis written directly beneathC5and sounds two thirds of a beat after it.That was not merely unreported, it was inexpressible — there was no
coordinate in which the two could be compared, which is why a renderer had
nothing to lay lyrics against, a merge had no space to detect a conflict in, and
a linter had no way to say "this row disagrees with its neighbours".
notation/timegrid.pyplaces every written token on one coordinate system.barandunitare derived inTimeGrid.addand nowhere else — a lyric and anote cannot drift apart by being computed in two places. Rests and sustains are
recorded too, so a token that makes no sound still holds its column.
Three problems become one projection: rendering is
x = unit * bar_width;merging is set intersection on
(row, bar, unit)with a disjoint row axis, so twoagents editing different rows provably cannot collide; and
grid.disagreements()names each bar whose rows divide it differently.Nothing is reported and nothing is enforced — uneven subdivision is legal and
usually deliberate. The grid observes the arranger and does not steer it, which
the fingerprint holds rather than intent.
Phase 1 of
proposals/02-the-voyage.md, exit criterion met.One thing worth keeping from building it
The first version guarded the bar boundary twice — rounding the position and
nudging it before flooring — and neither mutation could be made to fail a test,
because each fixed the case alone. Two guards no test can tell apart are one
guard and one piece of dead code. Collapsed to the nudge, which then failed the
suite when removed.
The open question in the plan, settled
Every other format stacks repeated lyric rows as parallel verses; Plainsong runs
repeated rows on in time. The draft proposed inventing
Lyrics 2:to have both.That is the wrong shape. Run-on is a claim about time, and once lyrics bind to
notes they own no time for a second row to follow on into — so the rule does not
reach them and needs no exception clause.
No new syntax. Not one of the 6,321
.songfiles here has a section with morethan one
Lyrics:row, and lyrics do not enter the fingerprint, so this can moveno note. It ships gated with the rest of lyric binding rather than on its own.
Verification
615 tests, 7 specs, ruff clean,
ok 6334 file(s) checked, 3 warning(s), and6321 file(s) compile exactly as recorded— the corpus is untouched by all ofit, which for the time matrix is the whole point.
Also drops
server.pid, a stale pid file that had been committed, and ignores it.🤖 Generated with Claude Code
https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
Generated by Claude Code