Stacked on #33, which is on #31 and #30. Merge from the top of the chain down: #33's chain first, then this one.
The beat grid came from the detected tempo (or the BPM override), with GAP always pinned to the first note. It didn't always line up with the music, so snapping in the editor didn't land on the song's beats. This lets BPM and GAP be set from the sheet.
The GRID bar
A strip above the toolbar:
Fields: BPM, with ×2 / ÷2 for a tempo detected at double or half speed, and GAP in ms, with −10 / +10.
ALIGN TO NOTE: moves GAP by the least amount that puts the selected note's start on a beat.
Live preview: typing or a button redraws the grid at once. "Not applied" shows until APPLY charts it.
CANCEL / RESET: CANCEL drops the trial. RESET goes back to songtrace's choice: your BPM override, or the detected tempo, with GAP at the first note.
Note editing pauses while a trial grid is pending, so notes never snap to a grid that's then cancelled.
Unsaved lines: APPLY and RESET are refused while the current line has unsaved changes, since applying reloads the line.
How it's kept
Stored with the edits: the grid lives in edits.json next to the line edits, so every rebuild keeps it, and lyric changes don't affect it. A grid set on its own keeps the file alive.
The grid stage uses it in place of its own choice. It logs "using the BPM and GAP set in the editor", and records its own choice in tempo.json for RESET.
Older songs: a song built before tempo.json existed has its chart's BPM recorded when a grid is first set, so RESET returns to the original tempo rather than the edited one. The browser test caught that.
Every chart rewrite uses the same rule, line saves included: the editor's grid if set, otherwise the pipeline's tempo with GAP at the first note.
GAP stays valid: a GAP after the first note would put notes on negative beats, which players reject, so to_ultrastar moves it back by whole beats. That keeps the grid lines where they were set.
Notes keep their timing: they're stored in seconds and re-snap to the new grid.
API: PUT /api/jobs/{id}/grid with {bpm, gap_ms} (BPM above 0 and at most 2000, GAP 0 or more), and DELETE to reset. Both rewrite the charts in place and return the review, which now also has grid_edited and auto_bpm.
Testing
New tests:
edits: validation; the grid surviving lyric re-keying; the file kept for a grid alone.
grid: explicit GAP; a late GAP moved back by whole beats with the grid kept in place; the stage using the editor's grid and recording its own.
Web: set, then a line save keeping it; reset to the recorded tempo and to an older song's original tempo; bad values refused.
In Chromium on the seeded 300 BPM song:
÷2 previewed and paused editing; CANCEL restored everything.
Typed 280 / 1990, then APPLY: the server's charts updated.
ALIGN TO NOTE left the selected note within 0.43ms of a beat (GAP is whole milliseconds).
APPLY with an unsaved line was refused; the line saved afterwards kept the grid.
RESET returned to 300 BPM with GAP at the first note.
No script errors.
Full suite passes, except test_version (needs the package installed).
**Summary generated by LLM agent**
Stacked on #33, which is on #31 and #30. Merge from the top of the chain down: #33's chain first, then this one.
The beat grid came from the detected tempo (or the BPM override), with GAP always pinned to the first note. It didn't always line up with the music, so snapping in the editor didn't land on the song's beats. This lets BPM and GAP be set from the sheet.
## The GRID bar
A strip above the toolbar:
- **Fields:** BPM, with ×2 / ÷2 for a tempo detected at double or half speed, and GAP in ms, with −10 / +10.
- **ALIGN TO NOTE:** moves GAP by the least amount that puts the selected note's start on a beat.
- **Live preview:** typing or a button redraws the grid at once. "Not applied" shows until APPLY charts it.
- **CANCEL / RESET:** CANCEL drops the trial. RESET goes back to songtrace's choice: your BPM override, or the detected tempo, with GAP at the first note.
- **Note editing pauses while a trial grid is pending,** so notes never snap to a grid that's then cancelled.
- **Unsaved lines:** APPLY and RESET are refused while the current line has unsaved changes, since applying reloads the line.
## How it's kept
- **Stored with the edits:** the grid lives in `edits.json` next to the line edits, so every rebuild keeps it, and lyric changes don't affect it. A grid set on its own keeps the file alive.
- **The grid stage uses it** in place of its own choice. It logs "using the BPM and GAP set in the editor", and records its own choice in `tempo.json` for RESET.
- **Older songs:** a song built before `tempo.json` existed has its chart's BPM recorded when a grid is first set, so RESET returns to the original tempo rather than the edited one. The browser test caught that.
- **Every chart rewrite uses the same rule,** line saves included: the editor's grid if set, otherwise the pipeline's tempo with GAP at the first note.
- **GAP stays valid:** a GAP after the first note would put notes on negative beats, which players reject, so `to_ultrastar` moves it back by whole beats. That keeps the grid lines where they were set.
- **Notes keep their timing:** they're stored in seconds and re-snap to the new grid.
API: `PUT /api/jobs/{id}/grid` with `{bpm, gap_ms}` (BPM above 0 and at most 2000, GAP 0 or more), and `DELETE` to reset. Both rewrite the charts in place and return the review, which now also has `grid_edited` and `auto_bpm`.
## Testing
- New tests:
- `edits`: validation; the grid surviving lyric re-keying; the file kept for a grid alone.
- `grid`: explicit GAP; a late GAP moved back by whole beats with the grid kept in place; the stage using the editor's grid and recording its own.
- Web: set, then a line save keeping it; reset to the recorded tempo and to an older song's original tempo; bad values refused.
- In Chromium on the seeded 300 BPM song:
- ÷2 previewed and paused editing; CANCEL restored everything.
- Typed 280 / 1990, then APPLY: the server's charts updated.
- ALIGN TO NOTE left the selected note within 0.43ms of a beat (GAP is whole milliseconds).
- APPLY with an unsaved line was refused; the line saved afterwards kept the grid.
- RESET returned to 300 BPM with GAP at the first note.
- No script errors.
- Full suite passes, except `test_version` (needs the package installed).
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 #33, which is on #31 and #30. Merge from the top of the chain down: #33's chain first, then this one.
The beat grid came from the detected tempo (or the BPM override), with GAP always pinned to the first note. It didn't always line up with the music, so snapping in the editor didn't land on the song's beats. This lets BPM and GAP be set from the sheet.
The GRID bar
A strip above the toolbar:
How it's kept
edits.jsonnext to the line edits, so every rebuild keeps it, and lyric changes don't affect it. A grid set on its own keeps the file alive.tempo.jsonfor RESET.tempo.jsonexisted has its chart's BPM recorded when a grid is first set, so RESET returns to the original tempo rather than the edited one. The browser test caught that.to_ultrastarmoves it back by whole beats. That keeps the grid lines where they were set.API:
PUT /api/jobs/{id}/gridwith{bpm, gap_ms}(BPM above 0 and at most 2000, GAP 0 or more), andDELETEto reset. Both rewrite the charts in place and return the review, which now also hasgrid_editedandauto_bpm.Testing
edits: validation; the grid surviving lyric re-keying; the file kept for a grid alone.grid: explicit GAP; a late GAP moved back by whole beats with the grid kept in place; the stage using the editor's grid and recording its own.test_version(needs the package installed).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.