Stacked on #30: it targets that PR's branch so this diff is only the editing. Merge #30 first.
Phase 2, part 2: the note sheet becomes an editor. Lines are saved through the edits API from #29, so every edit survives rebuilds, REBUILD ALL FROM… included.
Editing
Selecting: click a note to select it, Shift+click to add to the selection, Tab/Shift+Tab to step through notes, Esc to clear.
Dragging: drag a note to move it in time and pitch; drag either end to resize it. Everything snaps to the chart's beat grid (its own BPM and GAP) and to whole semitones, which is what the chart quantises to anyway.
Keyboard: ←/→ move a beat, ↑/↓ a semitone (Shift for an octave), Shift+←/→ change length, Delete removes, S splits at the beat nearest the middle (the second half becomes a held ~), M merges with the next note (a held ~ adds no text), G/F/R toggle golden, freestyle and rap.
Text: double-click a note or its syllable (or press Enter) to edit the syllable. A syllable that starts a word keeps the space that marks it as a new word.
Adding notes: double-click empty space to add a two-beat note there, then type its syllable.
History and playback: undo/redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y). ▶ LINE (P) plays the line and stops after it.
Saving: SAVE LINE (Ctrl+S) saves through PUT /api/jobs/{id}/lines/{n}, which rewrites the work and packaged charts in place. REVERT returns a line to the generated notes, and DISCARD drops unsaved changes.
Set-aside edits: edits set aside because their line's lyrics changed are listed above the toolbar with a DISCARD each.
Not losing work
An UNSAVED badge shows while a line has unsaved changes. Paging away, switching songs or pressing CREATE SONG asks first. Playback won't page the sheet away from a line with unsaved changes. Closing the tab warns.
Code
static/edit-model.js holds every operation as a pure function over a line's notes: snap, move, resize, stretch, split, merge, insert, remove, kinds, text, saving, and the undo history. Unit-tested with node --test (12 tests in tests/js/edit-model.test.mjs).
sheet.js handles input and drawing. It hit-tests notes by geometry (time span and pitch row) instead of the event target. The first version used the target, and a render that replaced the element under the pointer cost the sheet its keyboard focus and broke double-click. The window and pitch range stay fixed during a drag, so rows don't jump under the pointer, and only grow afterwards.
index.html adds the play-line stop, refreshes the lyric list after a save, and asks before discarding unsaved changes. The new guard on CREATE SONG sits before the existing form guard, so the guard-removal tests in test_web.py still find their anchor.
Testing
All with a synthetic counting-words song on a seeded local server, driven in Chromium:
Fixed the deliberately wrong note with ↓↓↓, then saved. The server's line 0 has the corrected pitch, the new syllable, the golden kind and edited: true.
A drag of +0.4s and one row up saved as exactly +8 beats on the 300 BPM grid and +1 semitone, with the other notes untouched. A right-edge resize widened the note.
Split, undo, redo and merge gave the expected note counts.
Paging away while unsaved prompted, and dismissing kept the line. REVERT returned the line to its generated pitches.
A set-aside edit appeared in the notice by its old line text, and DISCARD cleared it on the server.
No script errors throughout.
Full suite passes, except test_version (needs the package installed). The Node tests run through pytest.
The README gains an "Editing a song" section. Its screenshot still isn't regenerated, because scripts/screenshot_webui.py needs Python Playwright and this sandbox has only the Node one. I can port that script to Node in a follow-up.
**Summary generated by LLM agent**
Stacked on #30: it targets that PR's branch so this diff is only the editing. Merge #30 first.
Phase 2, part 2: the note sheet becomes an editor. Lines are saved through the edits API from #29, so every edit survives rebuilds, REBUILD ALL FROM… included.
## Editing
- **Selecting:** click a note to select it, Shift+click to add to the selection, Tab/Shift+Tab to step through notes, Esc to clear.
- **Dragging:** drag a note to move it in time and pitch; drag either end to resize it. Everything snaps to the chart's beat grid (its own BPM and GAP) and to whole semitones, which is what the chart quantises to anyway.
- **Keyboard:** ←/→ move a beat, ↑/↓ a semitone (Shift for an octave), Shift+←/→ change length, Delete removes, S splits at the beat nearest the middle (the second half becomes a held `~`), M merges with the next note (a held `~` adds no text), G/F/R toggle golden, freestyle and rap.
- **Text:** double-click a note or its syllable (or press Enter) to edit the syllable. A syllable that starts a word keeps the space that marks it as a new word.
- **Adding notes:** double-click empty space to add a two-beat note there, then type its syllable.
- **History and playback:** undo/redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y). ▶ LINE (P) plays the line and stops after it.
- **Saving:** SAVE LINE (Ctrl+S) saves through `PUT /api/jobs/{id}/lines/{n}`, which rewrites the work and packaged charts in place. REVERT returns a line to the generated notes, and DISCARD drops unsaved changes.
- **Set-aside edits:** edits set aside because their line's lyrics changed are listed above the toolbar with a DISCARD each.
## Not losing work
An UNSAVED badge shows while a line has unsaved changes. Paging away, switching songs or pressing CREATE SONG asks first. Playback won't page the sheet away from a line with unsaved changes. Closing the tab warns.
## Code
- `static/edit-model.js` holds every operation as a pure function over a line's notes: snap, move, resize, stretch, split, merge, insert, remove, kinds, text, saving, and the undo history. Unit-tested with `node --test` (12 tests in `tests/js/edit-model.test.mjs`).
- `sheet.js` handles input and drawing. It hit-tests notes by geometry (time span and pitch row) instead of the event target. The first version used the target, and a render that replaced the element under the pointer cost the sheet its keyboard focus and broke double-click. The window and pitch range stay fixed during a drag, so rows don't jump under the pointer, and only grow afterwards.
- `index.html` adds the play-line stop, refreshes the lyric list after a save, and asks before discarding unsaved changes. The new guard on CREATE SONG sits before the existing form guard, so the guard-removal tests in `test_web.py` still find their anchor.
## Testing
All with a synthetic counting-words song on a seeded local server, driven in Chromium:
- Fixed the deliberately wrong note with ↓↓↓, then saved. The server's line 0 has the corrected pitch, the new syllable, the golden kind and `edited: true`.
- A drag of +0.4s and one row up saved as exactly +8 beats on the 300 BPM grid and +1 semitone, with the other notes untouched. A right-edge resize widened the note.
- Split, undo, redo and merge gave the expected note counts.
- Paging away while unsaved prompted, and dismissing kept the line. REVERT returned the line to its generated pitches.
- A set-aside edit appeared in the notice by its old line text, and DISCARD cleared it on the server.
- No script errors throughout.
Full suite passes, except `test_version` (needs the package installed). The Node tests run through pytest.
The README gains an "Editing a song" section. Its screenshot still isn't regenerated, because `scripts/screenshot_webui.py` needs Python Playwright and this sandbox has only the Node one. I can port that script to Node in a follow-up.
Notes on the sheet can now be selected and edited: dragged in time and
pitch, resized from either end, nudged and stretched from the keyboard,
split, merged, added, deleted, given a syllable or a kind (golden,
freestyle, rap), with undo/redo. Everything snaps to the chart's beat grid
and to semitones, as the chart itself will. SAVE LINE stores the line
through the edits API, which rewrites the charts in place; REVERT goes back
to the generated notes; a line's edits set aside because its lyrics changed
are listed with a way to discard them. Unsaved changes are not lost to
paging, following playback, switching songs or closing the tab.
The operations live in static/edit-model.js as pure functions, unit-tested
with node --test. The sheet hit-tests by geometry rather than event
targets, since a render replaces the elements under the pointer.
The sheet's controls were listed in one run-on line under it, which was
hard to scan and grew with every feature. Replace it with a panel grouped
the way an editor's reference card is (select, move, length, notes, kind,
syllable, line) with keys drawn as key caps, opened by a KEYS button or ?,
closed by Esc, and remembered open or closed across visits.
The list is data (SHORTCUTS), and a test checks every letter key it names
is one the sheet's key handler handles, so the two cannot drift apart.
The beat grid came from the detected tempo (or the BPM override) and a GAP
pinned to the first note, so it did not always line up with the music,
and snapping did not land on its beats. A GRID bar on the sheet now sets
both, redrawing the grid live as values are tried, with x2 and /2 for a
tempo detected at double or half speed and ALIGN TO NOTE to move GAP the
least that puts the selected note on a beat. Note editing pauses while a
trial grid is unapplied, so notes never snap to a grid that is then
cancelled.
The grid is stored with the song's edits, so every rebuild keeps it, and
grid charts with it in place of its own choice; it records that choice in
tempo.json for RESET to return to. A GAP after the first note moves back
by whole beats, as notes on negative beats are rejected by players. PUT
and DELETE /api/jobs/{id}/grid set and reset it, rewriting the charts in
place.
Line breaks were always placed by songtrace, midway between lines and
recomputed around edited ones, so a break could not be put where a singer
breathes. The sheet now draws the breaks either side of a line as markers
that drag along the beat grid, kept between the lines they separate, and
double-click back to automatic. Break moves share the line's undo history
and SAVE LINE, which saves the notes only if they changed, so moving a
break never locks either line's notes.
Moved breaks are stored in edits.json keyed by the line each comes before,
follow their lines when the lyrics change, and win over the computed break
on every rebuild, clamped so a break never lands inside a line's notes.
The review lists each break with that line and whether it was moved, and
PUT /api/jobs/{id}/breaks takes {line: seconds | null}. merge now passes
untouched notes through as they are rather than rebuilding them, which
added float noise to their durations.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary generated by LLM agent
Stacked on #30: it targets that PR's branch so this diff is only the editing. Merge #30 first.
Phase 2, part 2: the note sheet becomes an editor. Lines are saved through the edits API from #29, so every edit survives rebuilds, REBUILD ALL FROM… included.
Editing
~), M merges with the next note (a held~adds no text), G/F/R toggle golden, freestyle and rap.PUT /api/jobs/{id}/lines/{n}, which rewrites the work and packaged charts in place. REVERT returns a line to the generated notes, and DISCARD drops unsaved changes.Not losing work
An UNSAVED badge shows while a line has unsaved changes. Paging away, switching songs or pressing CREATE SONG asks first. Playback won't page the sheet away from a line with unsaved changes. Closing the tab warns.
Code
static/edit-model.jsholds every operation as a pure function over a line's notes: snap, move, resize, stretch, split, merge, insert, remove, kinds, text, saving, and the undo history. Unit-tested withnode --test(12 tests intests/js/edit-model.test.mjs).sheet.jshandles input and drawing. It hit-tests notes by geometry (time span and pitch row) instead of the event target. The first version used the target, and a render that replaced the element under the pointer cost the sheet its keyboard focus and broke double-click. The window and pitch range stay fixed during a drag, so rows don't jump under the pointer, and only grow afterwards.index.htmladds the play-line stop, refreshes the lyric list after a save, and asks before discarding unsaved changes. The new guard on CREATE SONG sits before the existing form guard, so the guard-removal tests intest_web.pystill find their anchor.Testing
All with a synthetic counting-words song on a seeded local server, driven in Chromium:
edited: true.Full suite passes, except
test_version(needs the package installed). The Node tests run through pytest.The README gains an "Editing a song" section. Its screenshot still isn't regenerated, because
scripts/screenshot_webui.pyneeds Python Playwright and this sandbox has only the Node one. I can port that script to Node in a follow-up.The beat grid came from the detected tempo (or the BPM override) and a GAP pinned to the first note, so it did not always line up with the music, and snapping did not land on its beats. A GRID bar on the sheet now sets both, redrawing the grid live as values are tried, with x2 and /2 for a tempo detected at double or half speed and ALIGN TO NOTE to move GAP the least that puts the selected note on a beat. Note editing pauses while a trial grid is unapplied, so notes never snap to a grid that is then cancelled. The grid is stored with the song's edits, so every rebuild keeps it, and grid charts with it in place of its own choice; it records that choice in tempo.json for RESET to return to. A GAP after the first note moves back by whole beats, as notes on negative beats are rejected by players. PUT and DELETE /api/jobs/{id}/grid set and reset it, rewriting the charts in place.Line breaks were always placed by songtrace, midway between lines and recomputed around edited ones, so a break could not be put where a singer breathes. The sheet now draws the breaks either side of a line as markers that drag along the beat grid, kept between the lines they separate, and double-click back to automatic. Break moves share the line's undo history and SAVE LINE, which saves the notes only if they changed, so moving a break never locks either line's notes. Moved breaks are stored in edits.json keyed by the line each comes before, follow their lines when the lyrics change, and win over the computed break on every rebuild, clamped so a break never lands inside a line's notes. The review lists each break with that line and whether it was moved, and PUT /api/jobs/{id}/breaks takes {line: seconds | null}. merge now passes untouched notes through as they are rather than rebuilding them, which added float noise to their durations.9bf323b740to5877e35bfa