Skip to content

feat(editor): per-beat rubato drag in Tempo Map mode - #97

Merged
byrongamatos merged 2 commits into
mainfrom
feat/editor-per-beat-drag
Jul 6, 2026
Merged

feat(editor): per-beat rubato drag in Tempo Map mode#97
byrongamatos merged 2 commits into
mainfrom
feat/editor-per-beat-drag

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Sync-point poles retime whole measures — too coarse for recordings that accelerate or ritard inside a bar (rubato classical takes, live un-clicked drummers). This makes individual beat ticks draggable in Tempo Map mode:

  • Grab any non-downbeat grid line and drag: the measure's bounding downbeats stay fixed, and the neighbouring sub-beats re-space proportionally on both sides — the natural accel/rit shape. The engine (_tempoApplyDrag) was already general over any beat index; this PR adds the sub-beat hit-test, safe clamping, and drag routing.
  • New pure _tempoBeatDragBoundsPure clamps the drag inside the measure with per-gap 5 ms minimums (beats can squeeze, never collapse or reorder); pickup/trailing beats without a bounding downbeat clamp against their immediate neighbor or the song end.
  • Finalizes through the same revert-then-exec path as a pole drag — one undoable TempoMapCmd, equal-count invariant, notes riding per the tempo-ride scope. Poles win the hit-test when both are within reach, so existing pole-drag muscle memory is untouched. The Tempo Map HUD hint now mentions beat ticks.

This completes the rubato toolset ordering from the workspace design (D19): tap-tempo (#93) → per-beat drag (this) → scrub-on-drag (queued after the audio phase) → beat-lock (open design question on flag storage).

Verification

  • node --check screen.js clean
  • New tests/tempo_beat_drag.test.js — 7 cases: bounds math (interior + unbounded ends + degenerates), fixed downbeats, proportional both-side re-space, monotonic order under a clamp-edge slam, adjacent-measure isolation. The drag engine is extracted from source by name (the waveform_render harness pattern), so the tests run the real code.
  • All 26 JS test files pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

Summary by CodeRabbit

  • New Features
    • Tempo Map mode now supports rubato dragging of individual (sub-)beat ticks, not just measure downbeats.
    • Dragging a beat keeps the measure’s downbeats fixed while proportionally re-spacing nearby sub-beats for smooth timing.
    • Tempo Map updates are finalized as a single undoable action, and the Tempo Map HUD guidance was updated to reflect the new interaction.
  • Bug Fixes
    • Added clamping to prevent collapse/overlap and maintain strict ordering during extreme drag targets; later measures remain unaffected.
  • Tests
    • Added coverage for tempo beat rubato dragging behavior and bounds clamping.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@byrongamatos, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c13a9f2-cbb6-4cc9-b924-72dc1e0761ac

📥 Commits

Reviewing files that changed from the base of the PR and between 3574aba and ed710e1.

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

Walkthrough

This PR adds per-beat rubato dragging in Tempo Map mode, letting a non-downbeat grid tick be dragged independently while keeping surrounding downbeats fixed and re-spacing nearby sub-beats proportionally. It also wires the new drag state through existing handlers, updates the HUD text, and adds tests plus a changelog entry.

Changes

Tempo Map Rubato Drag

Layer / File(s) Summary
Core rubato drag helpers and bounds computation
screen.js
Adds _tempoSubBeatAtX() hit-testing, _tempoBeatDragBoundsPure() bounds computation, and _tempoBeatOnDragMove() to rebuild S.beats with proportional re-spacing.
Drag lifecycle wiring
screen.js
Starts tempo-beat drags from mousedown, routes mousemove through the new rubato handler, and finalizes tempo-beat drags alongside tempo-sync; updates the Tempo Map HUD help text.
Tests and changelog
tests/tempo_beat_drag.test.js, CHANGELOG.md
Adds a test harness for bounds and drag behavior, and documents the new Tempo Map rubato drag support in the changelog.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MouseDownHandler as _tempoMapOnMouseDown
  participant TempoBeatOnDragMove as _tempoBeatOnDragMove
  participant DragEnd as _tempoMapOnDragEnd

  User->>MouseDownHandler: press on sub-beat tick
  MouseDownHandler->>MouseDownHandler: _tempoSubBeatAtX() hit-test
  MouseDownHandler->>MouseDownHandler: start tempo-beat drag
  User->>TempoBeatOnDragMove: drag mouse
  TempoBeatOnDragMove->>TempoBeatOnDragMove: _tempoBeatDragBoundsPure() clamp
  TempoBeatOnDragMove->>TempoBeatOnDragMove: rebuild S.beats with proportional re-spacing
  User->>DragEnd: release mouse
  DragEnd->>DragEnd: finalize tempo-beat drag via TempoMapCmd
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: per-beat rubato dragging in Tempo Map mode.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-per-beat-drag

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

@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 (2)
tests/tempo_beat_drag.test.js (1)

78-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a bounds test for the leading/pickup-beat case.

Test coverage exercises the "no next downbeat" branch (trailing beat) but not the symmetric "no previous downbeat" branch (pdb < 0 && d > 0) — the pickup-measure case the PR explicitly calls out. Worth a companion case for full branch coverage of _tempoBeatDragBoundsPure.

Proposed additional test
t('bounds: leading sub-beat with no previous downbeat clamps to its neighbor', () => {
    // Two pickup sub-beats before the first downbeat.
    const beats = [
        { time: 0.2, measure: -1 },
        { time: 0.5, measure: -1 },
        { time: 1.0, measure: 1 },
        { time: 3.0, measure: 2 },
    ];
    const b = _tempoBeatDragBoundsPure(beats, 1, 0.005, 4);
    assert.ok(Math.abs(b.lo - (0.2 + 0.005)) < 1e-9, 'lo from immediate neighbor');
});
🤖 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_beat_drag.test.js` around lines 78 - 89, Add a companion bounds
test for the pickup/leading sub-beat path in _tempoBeatDragBoundsPure, since
current coverage only exercises the trailing “no next downbeat” branch. Create a
case where the selected beat has no previous downbeat (pdb < 0 && d > 0) and
assert the lower bound clamps to the immediate neighbor/expected pickup
behavior. Place it alongside the existing bounds tests in
tempo_beat_drag.test.js so both symmetric branches are covered.
screen.js (1)

12030-12030: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No hover affordance for the newly advertised beat-tick drag.

The HUD now tells users they can drag beat ticks for rubato, but the tempo-map hover branch only hit-tests poles (_tempoSyncAtX), so the cursor never switches to ew-resize over a draggable sub-beat. Consider extending the hover logic to also probe _tempoSubBeatAtX so the affordance matches the advertised interaction.

🤖 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 `@screen.js` at line 12030, The tempo-map HUD now advertises beat-tick
dragging, but the hover logic in the tempo-map branch still only checks
`_tempoSyncAtX`, so the cursor never shows the resize affordance over sub-beat
ticks. Update the hover handling in `screen.js` to also probe `_tempoSubBeatAtX`
alongside `_tempoSyncAtX`, and switch the cursor to `ew-resize` when either
hit-test finds a draggable tempo element.
🤖 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 `@screen.js`:
- Line 12030: The tempo-map HUD now advertises beat-tick dragging, but the hover
logic in the tempo-map branch still only checks `_tempoSyncAtX`, so the cursor
never shows the resize affordance over sub-beat ticks. Update the hover handling
in `screen.js` to also probe `_tempoSubBeatAtX` alongside `_tempoSyncAtX`, and
switch the cursor to `ew-resize` when either hit-test finds a draggable tempo
element.

In `@tests/tempo_beat_drag.test.js`:
- Around line 78-89: Add a companion bounds test for the pickup/leading sub-beat
path in _tempoBeatDragBoundsPure, since current coverage only exercises the
trailing “no next downbeat” branch. Create a case where the selected beat has no
previous downbeat (pdb < 0 && d > 0) and assert the lower bound clamps to the
immediate neighbor/expected pickup behavior. Place it alongside the existing
bounds tests in tempo_beat_drag.test.js so both symmetric branches are covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b96cf2b9-9344-4e65-b648-89377a64a71f

📥 Commits

Reviewing files that changed from the base of the PR and between e067f25 and b9e0daf.

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

ChrisBeWithYou and others added 2 commits July 6, 2026 21:44
Sync-point poles retime whole measures — too coarse for a recording
that accelerates or ritards INSIDE a bar (rubato classical takes, live
drummers). Grabbing an individual beat tick (any non-downbeat grid
line) now drags just that beat:

- The measure's bounding downbeats stay fixed; neighbouring sub-beats
  re-space proportionally on both sides (the accel/rit shape) via the
  existing _tempoApplyDrag, which was already general over any beat
  index — this PR adds the hit-test, clamping, and drag routing.
- New pure _tempoBeatDragBoundsPure clamps the drag inside the measure
  with per-gap 5 ms minimums, so beats can squeeze but never collapse
  or reorder; ends without a bounding downbeat clamp against their
  immediate neighbor / song duration.
- Finalizes through the same revert-then-exec path as a pole drag: one
  undoable TempoMapCmd, equal-count invariant, notes ride per the
  tempo-ride scope. Poles win the hit-test when both are in reach, so
  existing pole muscle memory is unchanged. HUD hint updated.

Tests: tests/tempo_beat_drag.test.js (7 cases: bounds math incl.
unbounded ends, fixed downbeats, proportional both-side re-space,
monotonic order at clamp extremes, adjacent-measure isolation) — the
drag engine is extracted from source by name, waveform_render style.
node --check clean; all 26 JS test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
_tempoBeatOnDragMove set dg.moved = true before the `if (!bounds) return`
guard, so a drag rejected by _tempoBeatDragBoundsPure (measure too tight
to fit all beats at 5ms gaps) still left moved===true. On mouseup,
_tempoMapOnDragEnd then snapshotted the untouched grid and pushed an inert
TempoMapCmd(origBeats, identical newBeats) — an empty undo entry that
dirtied the tab with no visible change.

Move dg.moved = true to after the bounds guard so a bounds-rejected wiggle
never marks the drag as moved: no inert command, no spurious dirty.

Add a bounds.lo slam-and-assert-monotonic test (symmetry with the
existing hi-slam case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos force-pushed the feat/editor-per-beat-drag branch from 3574aba to ed710e1 Compare July 6, 2026 19:44
@byrongamatos
byrongamatos merged commit c5fd508 into main Jul 6, 2026
@byrongamatos
byrongamatos deleted the feat/editor-per-beat-drag branch July 6, 2026 19:44
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