Skip to content

feat(editor): beat-lock — sync points immune to tempo re-fits (Phase A5) - #138

Merged
byrongamatos merged 3 commits into
mainfrom
feat/editor-beat-lock
Jul 9, 2026
Merged

feat(editor): beat-lock — sync points immune to tempo re-fits (Phase A5)#138
byrongamatos merged 3 commits into
mainfrom
feat/editor-beat-lock

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Phase A5 — beat-lock

Final Phase A slice (§1.8 / D-T5). Stacked on A2 (#135) — base is feat/editor-beat-primary; retargets to main when A2 merges. Sibling to A4 (#137).

The problem it guards

Under pure beat-primary, notes placed by ear on transients before the grid is trusted move with the grid on a later big re-fit (the transcription "Scenario B"). Beat-lock is the promised guard: a hand-verified sync point can be pinned so a global tempo op can't walk it off.

What it does

Lock a Tempo-Map sync point — press S, or right-click the pole → Lock sync point — and its time is held through the three global re-fit ops:

  • detect-tempo / sync-to-audio (editorApplySync)
  • metric modulation (_editorModulateTempoAtSelection)
  • measure BPM re-space (editorSetBPM / _tempoSetMeasureBpm)

Each routes its candidate grid through _respaceWithLocksPure(oldBeats, newBeats), which treats the two song ends (carrying the re-fit) plus every locked anchor (pinned to its old time) as fixed points, and affine-remaps each run between them — so:

  • a locked anchor's time is unchanged while its neighbours move;
  • the song ends still carry the tempo change (a global BPM edit still lengthens/shortens the song);
  • no beat is ever pushed before the song start, even under a big stretch;
  • the output stays monotonic.

Direct pole drag is unaffected (dragging is deliberate hand-placement); the lock only guards indirect/global re-fits.

Distinct render

Locked poles render emerald on the beat bar (the selection halo still shows through, so lock ≠ selection). The drag handle stays — a locked point can still be dragged directly.

Persistence (editor-pref, never the pack — D15)

Locks are stored in localStorage keyed by filename (editorBeatLocks:<file>) as the locked seconds (which survive save/reload — the pack rebuilds the grid to the same times), and re-attached to the matching beats by time on load. Nothing is written to the pack / wire. In-session the locked flag rides on the beat objects, so it survives every tempo command + undo/redo for free via the existing {...b} clones.

Command wiring

Reuses the pre-existing tempoToggleSyncLock registry slot (was status: 'planned', key S, dispatch stubbed) — flipped to ready, handler implemented, plus the right-click affordance.

Tests

tests/beat_lock.test.js11, all would-fail-on-main:

  • _respaceWithLocksPure — no-locks identity; the §1.8 acceptance (locked anchor holds while neighbours move); ends still carry the re-fit; big-stretch + early lock never goes negative; two-lock interior interpolation; length-mismatch no-op; the locked flag rides onto the output.
  • persistence pures — key-by-filename; _beatLockParsePure defensive (junk / non-array / bad values drop out); _applyBeatLocksPure re-attaches by nearest-within-tolerance and clears the rest.

Verification

  • node --check screen.js clean
  • Full JS suite: 72 pass / 0 fail
  • No routes.py change (no pytest); no wire/spec change

Ordering

Requires A2 (#135). With this, Phase A of the workspace-shell time-model foundation is complete (A1 #133 merged · A2 #135 · A3 #136 · A4 #137 · A5 this). Phase B (shell chrome) waits on the feature-PR backlog draining. Design source: SONG-EDITOR-WORKSPACE-SHELL-CHARRETTE.md §1.8 / D-T5.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Beat-lock support for tempo-map sync points, keeping selected anchors fixed during tempo edits and re-fitting.
    • Added a tempo-map lock/unlock control; lock state is restored from your editor preferences (not saved to the pack).
    • Locked sync points are now visually distinguished in the tempo map.
  • Bug Fixes
    • Ensured beat-grid timing stays monotonic when locks are active.
    • Reprojected notes and sustain endpoints onto the warped (locked) grid during sync-to-audio and BPM/tempo operations.
  • Tests
    • Added automated regression tests for locking, persistence parsing, and timing alignment.
  • Documentation
    • Updated the changelog to reflect Beat-lock.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 018adf5b-ce4b-4d57-acdd-99677e61e1ac

📥 Commits

Reviewing files that changed from the base of the PR and between 4221a17 and 98315cb.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • screen.js
  • tests/beat_lock.test.js

📝 Walkthrough

Walkthrough

Adds Beat-lock for tempo-map sync points: locked anchors survive tempo re-fits, persist per file in editor preferences, update rendering and commands, and are covered by a dedicated test suite.

Changes

Beat-lock implementation

Layer / File(s) Summary
Core lock-aware respace and persistence helpers
screen.js
Introduces lock-aware re-spacing, persistence helpers, lock reattachment, and the editor command that toggles sync-point locks.
Command dispatch and load-time restoration
screen.js
Wires the tempo-lock command into the dispatcher and restores persisted locks during editor initialization.
Lock-aware tempo re-fit and reprojection
screen.js
Uses lock-aware re-spacing in BPM and grid-scaling paths, and reprojects note, sustain, and section timing onto the adjusted grid.
UI rendering and context menu
screen.js
Updates tempo-map pole rendering for locked sync points and adds the context-menu toggle entry and handler.
Beat-lock test suite
tests/beat_lock.test.js
Adds tests for lock-aware re-spacing, monotonicity regressions, reprojection behavior, and persistence helpers.
Changelog entry
CHANGELOG.md
Documents the Beat-lock feature and its editor-preference storage in the changelog.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TempoContextUI
  participant Editor as _editorToggleSyncLock
  participant Storage as _saveBeatLocks/_restoreBeatLocks
  participant Respace as _respaceWithLocksPure
  participant Renderer as Tempo Map renderer

  User->>TempoContextUI: click togglelock
  TempoContextUI->>Editor: _editorToggleSyncLock()
  Editor->>Storage: save locked sync-point positions
  Storage-->>Editor: persisted editor preference

  User->>Editor: edit BPM or modulate tempo
  Editor->>Respace: _respaceWithLocksPure(oldBeats, newBeats)
  Respace-->>Editor: lock-adjusted beat grid
  Editor->>Editor: reproject note and section timing
  Editor->>Renderer: redraw locked poles with distinct colors
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main editor feature: beat-locking sync points to keep them immune to tempo re-fits.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-beat-lock

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.1)
screen.js

ast-grep timed out on this file


Comment @coderabbitai help to get the list of available commands.

@byrongamatos
byrongamatos force-pushed the feat/editor-beat-lock branch from d4426e6 to 60676d4 Compare July 9, 2026 06:28
@byrongamatos

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@CHANGELOG.md`:
- Around line 25-26: The changelog entry misstates the persistence scope by
saying the lock is keyed by song; update the wording in the editor-pref release
note to match the actual contract used in tests and implementation, which
stores/restores it per filename and never writes it to the pack. Keep the note
aligned with the beat_lock behavior referenced by tests/beat_lock.test.js so the
release wording matches what the code actually does.

In `@screen.js`:
- Around line 10096-10126: The locked-grid handling in the beat rescale flow
should also reproject section start times, not just note times, so sections stay
aligned with the warped beat map around locked anchors. Update the same timing
path in screen.js where `locked` is computed from `_respaceWithLocksPure` to
apply the `beatOf(oldBeats, ...)` then `timeOf(respaced, ...)` mapping to
section markers (the `start_time` values), and keep the linear scale path only
for the unlocked case.
🪄 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: 3ed32dd9-75a2-4a73-ab3a-f6335f11f5a8

📥 Commits

Reviewing files that changed from the base of the PR and between 504a710 and 60676d4.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • screen.js
  • tests/beat_lock.test.js

Comment thread CHANGELOG.md Outdated
Comment thread screen.js
@byrongamatos

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/beat_lock.test.js (1)

172-194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Regex-extracted eval of the section-scaling loop is fragile but self-guarding.

Extracting // Scale section times ... for (const s of S.sections) {...} from screen.js via regex and running it through new Function(...) (Line 177-179) ties this test to the exact comment text and loop shape in screen.js. Any refactor there (renamed comment, restructured loop) breaks extraction — mitigated somewhat by assert.ok(secLoop, ...) at Line 176, which fails loudly instead of silently passing a stale/empty block.

Longer-term, exporting the section-scaling logic as a small named pure function in screen.js (similar to _respaceWithLocksPure) would let this test import it directly instead of scraping source text, improving robustness against comment/formatting drift.

🤖 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/beat_lock.test.js` around lines 172 - 194, The test is fragile because
it scrapes the section-scaling loop from screen.js via a regex and executes it
with new Function, so any comment or loop-shape refactor can break it. Move the
section-time scaling logic out of the inline “Scale section times” block in
screen.js into a small named pure function (similar to _respaceWithLocksPure),
then have editorApplySync/section-scaling call that helper and update this test
to import and invoke it directly instead of extracting source text.
🤖 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.

Nitpick comments:
In `@tests/beat_lock.test.js`:
- Around line 172-194: The test is fragile because it scrapes the
section-scaling loop from screen.js via a regex and executes it with new
Function, so any comment or loop-shape refactor can break it. Move the
section-time scaling logic out of the inline “Scale section times” block in
screen.js into a small named pure function (similar to _respaceWithLocksPure),
then have editorApplySync/section-scaling call that helper and update this test
to import and invoke it directly instead of extracting source text.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 31c6d214-caf2-4dee-8f08-45d5462bf194

📥 Commits

Reviewing files that changed from the base of the PR and between 60676d4 and 4221a17.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • screen.js
  • tests/beat_lock.test.js
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • screen.js

@byrongamatos
byrongamatos changed the base branch from feat/editor-beat-primary to main July 9, 2026 07:44
ChrisBeWithYou and others added 3 commits July 9, 2026 09:45
…e-fits (Phase A5)

Lock a Tempo-Map sync point (press S, or right-click the pole → Lock) and its
time is held through GLOBAL tempo re-fits — detect / sync-to-audio, metric
modulation, and measure-BPM re-space. `_respaceWithLocksPure` holds every locked
anchor at its old time and affine-interpolates the runs around it: the song ends
still carry the tempo change (a global BPM edit still lengthens/shortens the
song) and no beat is ever pushed before the start. Locked poles render emerald
on the beat bar. This is the guard (charrette §1.8/D-T5) that makes the
beat-primary model safe for placing notes by ear before the grid is trusted — a
nailed passage can't be walked off by a later global tempo op.

- Locks are EDITOR-PREF, keyed by filename, restored on load, NEVER written to
  the pack (D15): persist the locked seconds, re-attach by time-match on load.
- Reuses the pre-wired (previously 'planned') tempoToggleSyncLock command (key
  S) + a right-click "Lock/unlock sync point" affordance. The `locked` flag
  rides through every tempo command via the existing {...b} clones, so it
  survives undo/redo for free.

Requires A2 (beat-primary) — stacked on feat/editor-beat-primary; rebase onto
main when A2 merges. Tests: tests/beat_lock.test.js (11, would-fail-on-main).
Full JS suite 72/0. No routes.py / no wire change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv
…tes (review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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