fix(editor): route Sync / BPM-rescale / Offset through undoable total-reproject commands - #218
Conversation
…-reproject commands Sync tempo, the BPM box's constant-tempo rescale, and the audio Offset nudge each mutated the timeline directly with no undo, walking only the current arrangement's plain notes plus the global beats/sections. Every other arrangement — and all chords/anchors/handshapes/phrases, plus the drum tab on the partial paths — was left behind: silent multi-part corruption that couldn't be undone. All three now route through one command (TempoMapCmd / TempoOffsetCmd) whose beat-primary lift->reproject is total (every timed object, every part) and fully undoable. Sync and rescale pivot the scale at the first downbeat (or the focused barline) rather than t=0, so a pickup/lead-in song no longer skews. The applied audio offset moved from the DOM input's dataset.applied onto command-owned S.appliedOffset so undo restores it; the Sync dialog's duplicate offset field was removed in favor of the undoable toolbar Offset. New tests/tempo_op_commands.test.mjs deep-diffs a two-arrangement song with chords + drums to prove every part moves and undo restores the exact seconds, plus source guards that the wrappers route through the commands. Removes the obsolete apply_offset.test.js (sliced the deleted _shiftArrangementTimes) and updates beat_lock.test.mjs's section case off the deleted inline loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughTempo sync, BPM rescaling, and audio offset edits now use undoable tempo commands with pivot-based beat reprojection. Applied offset state moved into editor state, the duplicate Sync offset field was replaced by a manual BPM override, and multi-part undo/redo coverage was added. ChangesTempo operation routing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant SyncDialog
participant Editor
participant History
participant TempoMapCmd
participant SongParts
SyncDialog->>Editor: submit tempo factor
Editor->>TempoMapCmd: create pivoted respaced grid
Editor->>History: execute command
History->>SongParts: reproject notes, phrases, drums, and sections
History->>Editor: redraw and update status
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/main.js`:
- Around line 1413-1451: The editorApplyOffset function currently bypasses undo
history when S.beats.length is less than 2. Remove that special-case branch,
including its direct S.appliedOffset update and status handling, so every
meaningful offset change proceeds through TempoOffsetCmd and S.history.exec;
retain the existing no-op delta check and normal UI updates.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 25ca6c4b-f77a-4f9c-9586-ec952a770279
📒 Files selected for processing (10)
CHANGELOG.mdscreen.htmlsrc/file-ops.jssrc/main.jssrc/state.jssrc/sync-tempo.jssrc/tempo.jstests/apply_offset.test.jstests/beat_lock.test.mjstests/tempo_op_commands.test.mjs
💤 Files with no reviewable changes (2)
- tests/apply_offset.test.js
- screen.html
…/redo Undo restored S.appliedOffset but left the visible toolbar input stale, and editorNudgeOffset computes the NEXT offset from el.value — so one +10ms click after a Ctrl-Z re-applied the undone nudge on top (input 0.010 → passes 0.020, delta = 0.020 − 0 = 0.020). exec/rollback now both sync the input to S.appliedOffset (typeof-document guarded for the node suites). Regression test fails pre-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…— every phrase was stranded by tempo ops
Phrases anchor on start_time (input.js authoring, routes.py save); _eachTimed
visited ph.time, so lift skipped every phrase and reproject left them on the
old timeline through sync / BPM-rescale / offset — the exact stale-scale bug
class this PR set out to close, hidden because the fixtures (here AND in the
pre-A2 golden's frozen legacy reference) seeded the same wrong field, making
the phrase assertions vacuous. Now visits ('start_time', 'span') so end_time
(present on server-loaded phrases) rides too; fixtures use the real shape and
the tempo_op_commands phrase assertions fail pre-fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he direct S.appliedOffset write skipped history beatOf/timeOf are identity on a degenerate grid, so the command's lift->reproject is a no-op on note seconds and just carries the scalar, undoably. The special case made a reachable offset change non-undoable: Ctrl-Z restored nothing and the next nudge's delta computed off a base history never recorded. Addresses CodeRabbit review on PR #218. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Charrette UX P5 / arch 1 (first half). Select and delete multiple Tempo Map barlines at once, without touching the single-focus model (S.tempoSel — which inspector / tap / lock / modulate / suggest all key on). - New S.tempoSelMulti: Set<downbeatIdx> (the S.drumSel pattern). Shift+click a pole extends the contiguous downbeat range; a drag on empty grid arms a tempo-marquee (the drum editor's deferred-3px `moved` idiom) that box-selects downbeats in its swept X range (plain replaces, Shift unions); Ctrl+A in mode selects every downbeat. The set is index-based, so it is CLEARED — never remapped — in TempoGridCmd exec/rollback and on Tempo Map exit. - Render: a light amber wash across the selected range, and selected poles read amber (the focus keeps its unique halo). Rides the existing draw pass. - Bulk delete = ONE TempoGridCmd: _tempoDeleteBarlinesPure demotes every selected INTERIOR downbeat (never the first/last — the existing guard, generalized to a set) + one renumber. Reachable via Del and right-click "Delete N barlines". - Escape clears the selection, layered UNDER #215's suggest-dismiss (ghosts own Escape while showing). tests/tempo_multiselect.test.mjs (6): marquee hit math, the delete transform (demote+renumber, first/last guard), the bulk-delete round-trip through the command, the exec/rollback set-clearing contract, and range selection. Verified live: Ctrl+A → "105 barlines selected" (amber render), Escape clears, a marquee box-selects, Ctrl+A+Del → "Deleted 103 barlines" (first/last kept), no errors. npm test 115 green, lint 0 errors. Independent of the in-review queue (deps only on merged #215); keep-both seams with #220 (_tempoMapDraw) and #218/#225 on the shared tempo.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
…5a) (#226) Charrette UX P5 / arch 1 (first half). Select and delete multiple Tempo Map barlines at once, without touching the single-focus model (S.tempoSel — which inspector / tap / lock / modulate / suggest all key on). - New S.tempoSelMulti: Set<downbeatIdx> (the S.drumSel pattern). Shift+click a pole extends the contiguous downbeat range; a drag on empty grid arms a tempo-marquee (the drum editor's deferred-3px `moved` idiom) that box-selects downbeats in its swept X range (plain replaces, Shift unions); Ctrl+A in mode selects every downbeat. The set is index-based, so it is CLEARED — never remapped — in TempoGridCmd exec/rollback and on Tempo Map exit. - Render: a light amber wash across the selected range, and selected poles read amber (the focus keeps its unique halo). Rides the existing draw pass. - Bulk delete = ONE TempoGridCmd: _tempoDeleteBarlinesPure demotes every selected INTERIOR downbeat (never the first/last — the existing guard, generalized to a set) + one renumber. Reachable via Del and right-click "Delete N barlines". - Escape clears the selection, layered UNDER #215's suggest-dismiss (ghosts own Escape while showing). tests/tempo_multiselect.test.mjs (6): marquee hit math, the delete transform (demote+renumber, first/last guard), the bulk-delete round-trip through the command, the exec/rollback set-clearing contract, and range selection. Verified live: Ctrl+A → "105 barlines selected" (amber render), Escape clears, a marquee box-selects, Ctrl+A+Del → "Deleted 103 barlines" (first/last kept), no errors. npm test 115 green, lint 0 errors. Independent of the in-review queue (deps only on merged #215); keep-both seams with #220 (_tempoMapDraw) and #218/#225 on the shared tempo.js. Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ommands # Conflicts: # src/tempo.js
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/tempo_markers.test.mjs`:
- Around line 37-44: Update the beat fixture in the within-tolerance test around
_tempoMarkersPure so measure 2 has a subsequent downbeat, ensuring its BPM is
calculated from the constructed span rather than using the final-run fallback.
Preserve the 60.005 BPM duration and assert that only the baseline tempo marker
is emitted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b513aa2f-8c1d-4782-8f55-5ba51b678191
📒 Files selected for processing (8)
CHANGELOG.mdscreen.htmlsrc/file-ops.jssrc/main.jssrc/state.jssrc/tempo.jstests/tempo_markers.test.mjstests/tempo_op_commands.test.mjs
🚧 Files skipped from review as they are similar to previous changes (5)
- src/file-ops.js
- screen.html
- src/state.js
- CHANGELOG.md
- tests/tempo_op_commands.test.mjs
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/tempo_markers.test.mjs`:
- Around line 37-44: Update the beat fixture in the within-tolerance test around
_tempoMarkersPure so measure 2 has a subsequent downbeat, ensuring its BPM is
calculated from the constructed span rather than using the final-run fallback.
Preserve the 60.005 BPM duration and assert that only the baseline tempo marker
is emitted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b513aa2f-8c1d-4782-8f55-5ba51b678191
📒 Files selected for processing (8)
CHANGELOG.mdscreen.htmlsrc/file-ops.jssrc/main.jssrc/state.jssrc/tempo.jstests/tempo_markers.test.mjstests/tempo_op_commands.test.mjs
🚧 Files skipped from review as they are similar to previous changes (5)
- src/file-ops.js
- screen.html
- src/state.js
- CHANGELOG.md
- tests/tempo_op_commands.test.mjs
🛑 Comments failed to post (1)
tests/tempo_markers.test.mjs (1)
37-44: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Test doesn't actually exercise the tolerance comparison it claims to.
dbhere is[0, 4]— measure 2 (index 4) is the last downbeat, so_tempoMarkersPuretakes thenextI === nullbranch and setsbpm = runBpmdirectly, never computing the ratio from the carefully constructedspan. The test would pass identically even if the> tolcomparison were broken, since that branch is never reached for this beat set.🧪 Proposed fix: close measure 2 so its BPM is actually computed
const span = (4 * 60) / 60.005; // m2 duration for 60.005 BPM - const beats = g([0, 1], [1, -1], [2, -1], [3, -1], [4, 2], [4 + span / 4, -1], [4 + span / 2, -1], [4 + 3 * span / 4, -1]); + const beats = g([0, 1], [1, -1], [2, -1], [3, -1], [4, 2], [4 + span / 4, -1], [4 + span / 2, -1], [4 + 3 * span / 4, -1], [4 + span, 3]); const mk = _tempoMarkersPure(beats, 0.01); assert.deepStrictEqual(mk.filter(m => m.kind === 'tempo').length, 1, 'only the baseline tempo');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.t('a within-tolerance drift does NOT emit a new tempo marker (run behaviour)', () => { // m2 spans 4.001s → ~59.985 BPM, inside 0.01 of 60? No — but test the run: // make m2 exactly 60.005 BPM (within 0.01 tol → no marker). const span = (4 * 60) / 60.005; // m2 duration for 60.005 BPM const beats = g([0, 1], [1, -1], [2, -1], [3, -1], [4, 2], [4 + span / 4, -1], [4 + span / 2, -1], [4 + 3 * span / 4, -1], [4 + span, 3]); const mk = _tempoMarkersPure(beats, 0.01); assert.deepStrictEqual(mk.filter(m => m.kind === 'tempo').length, 1, 'only the baseline tempo'); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/tempo_markers.test.mjs` around lines 37 - 44, Update the beat fixture in the within-tolerance test around _tempoMarkersPure so measure 2 has a subsequent downbeat, ensuring its BPM is calculated from the constructed span rather than using the final-run fallback. Preserve the 60.005 BPM duration and assert that only the baseline tempo marker is emitted.
…d) (#225) * fix(editor): route Sync / BPM-rescale / Offset through undoable total-reproject commands Sync tempo, the BPM box's constant-tempo rescale, and the audio Offset nudge each mutated the timeline directly with no undo, walking only the current arrangement's plain notes plus the global beats/sections. Every other arrangement — and all chords/anchors/handshapes/phrases, plus the drum tab on the partial paths — was left behind: silent multi-part corruption that couldn't be undone. All three now route through one command (TempoMapCmd / TempoOffsetCmd) whose beat-primary lift->reproject is total (every timed object, every part) and fully undoable. Sync and rescale pivot the scale at the first downbeat (or the focused barline) rather than t=0, so a pickup/lead-in song no longer skews. The applied audio offset moved from the DOM input's dataset.applied onto command-owned S.appliedOffset so undo restores it; the Sync dialog's duplicate offset field was removed in favor of the undoable toolbar Offset. New tests/tempo_op_commands.test.mjs deep-diffs a two-arrangement song with chords + drums to prove every part moves and undo restores the exact seconds, plus source guards that the wrappers route through the commands. Removes the obsolete apply_offset.test.js (sliced the deleted _shiftArrangementTimes) and updates beat_lock.test.mjs's section case off the deleted inline loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q * feat(editor): flatten dialog names both directions (conform vs rebuild) Charrette UX P2 / arch 4 (tempo-mapping PR 3). Typing a BPM for a song with a variable tempo map replaced the bare window.confirm with a small in-app dialog (new _editorPromptChoice in ui.js, same modal idiom as _editorPromptText) that NAMES the two genuinely-different edits: - Conform notes to the new tempo — the previously-missing path. Flattens in the TempoMapCmd direction: beats are truth, seconds reproject onto the flat grid, so every part (all arrangements, chords, drums, anchors, handshapes) rides to the new constant tempo. No hand-scaled seconds (an invariant violation on locked/warped grids — the command's lift→reproject is the safe path). - Rebuild the grid only — today's flatten (TempoGridCmd): seconds hold, beats re-lift, for when the notes already sit on the recording. Both use the same flat grid from _tempoFlattenToBpmPure (anchored at bar 1 = PR 1's pivot) and are undoable; the status lines name the result + "Undo restores the map." editorSetBPM's flatten branch becomes async to await the choice. tests/flatten_choice.test.mjs: conform moves notes in ALL parts (2-arr song) and undo restores; rebuild keeps every note's exact seconds while the grid flattens; _tempoFlattenToBpmPure keeps the beat count + bar-1 anchor. tests/prompt_choice.test.mjs: the dialog resolves the chosen key, Cancel→null, and a second dialog settles the first. npm test 116 green, lint 0 errors. Stacked on #218 (needs its pivot helper) — base this PR on fix/editor-tempo-op-commands; after #218 squash-merges, rebase onto main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q * fix(editor): sync the #editor-offset input across TempoOffsetCmd undo/redo Undo restored S.appliedOffset but left the visible toolbar input stale, and editorNudgeOffset computes the NEXT offset from el.value — so one +10ms click after a Ctrl-Z re-applied the undone nudge on top (input 0.010 → passes 0.020, delta = 0.020 − 0 = 0.020). exec/rollback now both sync the input to S.appliedOffset (typeof-document guarded for the node suites). Regression test fails pre-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): _eachTimed visited phrases via a field they never carry — every phrase was stranded by tempo ops Phrases anchor on start_time (input.js authoring, routes.py save); _eachTimed visited ph.time, so lift skipped every phrase and reproject left them on the old timeline through sync / BPM-rescale / offset — the exact stale-scale bug class this PR set out to close, hidden because the fixtures (here AND in the pre-A2 golden's frozen legacy reference) seeded the same wrong field, making the phrase assertions vacuous. Now visits ('start_time', 'span') so end_time (present on server-loaded phrases) rides too; fixtures use the real shape and the tempo_op_commands phrase assertions fail pre-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): re-validate session + variable-map after the flatten dialog await The flatten choice dialog awaits across real time. The overlay traps pointer and keyboard, but an already-in-flight async import can land while the dialog is open, swapping the session and beat grid — the user's choice would then flatten the NEW song unprompted. Re-check S.sessionId and the variable-map precondition after the await, before either command executes. (window.confirm was synchronous, so this gap is new with the dialog.) Also correct the _editorPromptChoice doc: the backdrop re-focuses the dialog (like the editor's other modals); it does not dismiss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
The P0
Three whole-song tempo operations — Sync tempo, the BPM box's constant-tempo rescale, and the audio Offset nudge — mutated the timeline directly, with no undo, and moved only the current arrangement's plain
notes()plus the global beat grid and sections. Everything else was left behind:So a multi-part song silently drifted out of phase and the damage couldn't be undone. This is the charrette's opening P0 (
TEMPO-MAPPING-CHARRETTE.md).The fix
All three now route through one command each —
TempoMapCmd('sync'/'rescale') and the newTempoOffsetCmd('offset') — whose beat-primary lift→reproject is total:_eachTimedwalks every timed object in every part, so a grid change is structurally incapable of stranding a note. Fully undoable, exact-seconds rollback.t=0:t' = t0 + (t − t0)·scale. A song with a pickup / lead-in no longer skews under a baretime *= factor. New pure_tempoPivotTimePure.S.appliedOffsetreplaces the Offset input's hiddendataset.applied, so undo restores the applied shift (the next nudge's delta was computing off a stale base)._effectiveAudioOffset()reads it;TempoOffsetCmdowns it undoably; keeps the drum-hit ≥0 clamp.TempoMapCmdnow carriessongScope = true(matchingTempoGridCmd) so these toolbar-fired ops aren't refused by the read-only-roll lock.Tests
tests/tempo_op_commands.test.mjs(new): deep-diffs a two-arrangement song with chords + drums, proving every part moves on a sync stretch and undo restores the exact pre-edit seconds;TempoOffsetCmdrigid-shift + past-grid extrapolation pin + drum ≥0 clamp + exec→undo→redo;_tempoPivotTimePurecases; and source guards that each wrapper routes through the commands (fail-on-main wiring proof).apply_offset.test.js(sliced the deleted_shiftArrangementTimes); updatedbeat_lock.test.mjs's section case off the deleted inline loop.npm test114 files green,npm run lint0 errors (3 pre-existing warnings = main's ratchet).routes.pyuntouched.First PR of the tempo-mapping track (
TEMPO-MAPPING-PR-PROMPTS.md, PR 1). PRs 3/6/9 depend on this pivot + offset command.🤖 Generated with Claude Code
Summary by CodeRabbit