Skip to content

1.1.0: the demo cut every held note short, and the time matrix that makes alignment mean something - #4

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

1.1.0: the demo cut every held note short, and the time matrix that makes alignment mean something#4
SuperInstance merged 3 commits into
masterfrom
claude/init-1wgqn0

Conversation

@SuperInstance

Copy link
Copy Markdown
Owner

Two things, plus a decision that had been left open.

The landing page was playing the music wrong

. 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 followed by three
beats 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.

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 is new: it runs notation through the real page
    in 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 A4 they typed made the
third 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 the
dots 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 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 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:

Melody: | A4  .   C5  E5 |
Lyrics: | the tide came  |

came is written directly beneath C5 and 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.py places every written token on one coordinate system.
bar and unit are derived in TimeGrid.add and nowhere else — a lyric and a
note 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 two
agents 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.

Every Melody: row in a section concatenates into one melodic stream. Each
Lyrics: row is a verse sung over that whole stream.

No new syntax. Not one of the 6,321 .song files here has a section with more
than one Lyrics: row, and lyrics do not enter the fingerprint, so this can move
no 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), and
6321 file(s) compile exactly as recorded — the corpus is untouched by all of
it, 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

claude added 3 commits August 17, 2026 18:04
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
@SuperInstance SuperInstance changed the title The demo cut every held note short, and the time matrix that makes alignment mean something 1.1.0: the demo cut every held note short, and the time matrix that makes alignment mean something Aug 17, 2026
@SuperInstance
SuperInstance merged commit 3e88bf9 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