Skip to content

feat(editor): light onset-snap on a dragged tempo-map barline (tempo PR 11) - #235

Merged
byrongamatos merged 2 commits into
mainfrom
feat/editor-tempo-onset-snap
Jul 13, 2026
Merged

feat(editor): light onset-snap on a dragged tempo-map barline (tempo PR 11)#235
byrongamatos merged 2 commits into
mainfrom
feat/editor-tempo-onset-snap

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

The manual complement to Suggest-fit (G): in Tempo Map mode with Snap = Onset, dragging a barline gently pulls to the nearest detected audio attack — so downbeats land on real hits instead of by eye. (Christian's request off the tempo charrette.)

Behaviour

  • Light by design. The grab is a few pixels wide (so the pull feels the same at any zoom), capped at 50ms so a zoomed-out view can't reach a distant attack and it never fights a deliberate drag. A snapped barline is re-clamped into the drag's existing bounds, so it can never cross a neighbour.
  • Locked barlines never snap (they defend their time). With Snap = Grid the drag stays a plain continuous move. A drag-end status confirms when a barline lands on an attack.
  • No new state, no per-frame allocation, no backend calls — it reuses the existing _nearestOnsetTimePure / _ensureOnsets onset infrastructure (the same detection the note-placement onset-snap and Suggest already use).

Implementation

One gated branch in _tempoMapOnDragMove over two new pures: _tempoOnsetSnapTolPure (pixel window → seconds, capped) and _tempoOnsetSnapPure (snap within tol, re-clamp into bounds, else pass through). Gated on S.snapMode === 'onset' and a non-locked pole.

Note: group-drag (#230, not yet merged) is a separate drag type; when it lands, its handler can opt into the same gate if wanted — no conflict here.

Tests / gates

tests/tempo_onset_snap.test.mjs (9): pixel-window + seconds-cap + NaN-safety, snap-within-tol, bound re-clamp, no-onset / no-window no-ops, and drag-handler source guards for the Snap=Onset + non-locked gate. 126 JS suites green, lint 0 errors (3 pre-existing ratchet warnings). routes.py untouched.

Verified live

On AC/DC — Back In Black: with Snap = Onset, dragging a barline snapped it to a detected attack ("Barline snapped to a detected attack at 6.96s"); with Snap = Grid the same drag produced no snap. No errors from the change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • In Tempo Map mode with Snap set to Onset, barline dragging now snaps to nearby detected audio attacks.
    • Locked barlines remain unaffected, while Grid snapping continues to support continuous dragging.
    • Status feedback indicates when a barline snaps to an audio attack.
  • Tests

    • Added coverage for snapping tolerance, nearest-attack matching, boundary handling, and locked-barline behavior.

…PR 11)

The manual complement to Suggest-fit (G): in Tempo Map mode with Snap = Onset,
dragging a barline gently pulls to the nearest detected audio attack, so
downbeats land on real hits instead of by eye.

- The snap is LIGHT — a few pixels' worth of grab (so the pull feels the same at
  any zoom), capped at 50ms so a zoomed-out view can't reach a distant attack and
  it never fights a deliberate drag. It never crosses a neighbouring barline (the
  snapped time is re-clamped into the drag's existing bounds).
- **Locked barlines never snap** (they defend their time); with Snap = Grid the
  drag stays a plain continuous move. A drag-end status confirms when a barline
  lands on an attack.
- Wiring is one gated branch in `_tempoMapOnDragMove` over two new pures
  (`_tempoOnsetSnapTolPure`, `_tempoOnsetSnapPure`) that reuse the existing
  `_nearestOnsetTimePure` / `_ensureOnsets` onset infrastructure — no new state,
  no per-frame allocation, no backend calls.

`tests/tempo_onset_snap.test.mjs` (9: pixel window + seconds cap + NaN-safety,
snap-within-tol, bound re-clamp, no-onset/no-window no-ops, and drag-handler
source guards for the Snap=Onset + non-locked gate). 126 JS suites green, lint
0-err (3 pre-existing ratchet warnings). routes.py untouched.

Verified live on AC/DC — Back In Black: with Snap = Onset, dragging a barline
snapped it to a detected attack ("Barline snapped to a detected attack at
6.96s"); Snap = Grid produced no snap; no errors from the change.

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

coderabbitai Bot commented Jul 13, 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: 19 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: ed746f30-e531-4d37-acca-f28b5cc404c5

📥 Commits

Reviewing files that changed from the base of the PR and between dcfda07 and 7cfb1a9.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • src/tempo.js
📝 Walkthrough

Walkthrough

Tempo Map barline dragging now snaps to nearby detected audio onsets in Onset mode, while respecting locked barlines and drag bounds. Drag completion reports successful snaps, and tests cover utility behavior and integration guards.

Changes

Tempo Map onset snapping

Layer / File(s) Summary
Onset snap utilities
src/tempo.js
Adds exported helpers for tolerance calculation and bounded nearest-onset snapping.
Drag integration and feedback
src/tempo.js, CHANGELOG.md
Applies snapping for unlocked barlines in Onset mode and reports successful snaps on drag completion; documents the behavior.
Onset snapping validation
tests/tempo_onset_snap.test.mjs
Tests tolerance, snapping, bounds, empty inputs, source guards, and failure reporting.

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

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant TempoMapDrag
  participant OnsetCache
  participant StatusFeedback
  Editor->>TempoMapDrag: drag barline
  TempoMapDrag->>OnsetCache: read live onset cache
  OnsetCache-->>TempoMapDrag: detected onsets
  TempoMapDrag->>TempoMapDrag: snap eligible drag
  TempoMapDrag->>StatusFeedback: show snap status on completion
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: light onset snapping for dragged tempo-map barlines in the editor.
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-tempo-onset-snap

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.

Actionable comments posted: 3

🤖 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/tempo.js`:
- Around line 2088-2092: Update _tempoOnsetSnapPure to return null when no onset
is within tolerance, or the clamped numeric snap time when one is found,
eliminating per-call result-object allocation. Adjust _tempoMapOnDragMove to
interpret that return value and derive dg.snappedT and snapped state while
preserving existing drag behavior.
- Around line 2078-2082: Update _tempoOnsetSnapTolPure to validate secPerPx and
pxWindow individually before multiplication, treating non-finite or non-positive
values as zero so negative pairs cannot produce a positive window and Infinity
multiplied by zero cannot yield NaN. Preserve the existing non-negative maxSec
cap and return the bounded tolerance.

In `@tests/tempo_onset_snap.test.mjs`:
- Around line 65-80: Replace the regex-only tests for _tempoMapOnDragMove and
_tempoMapOnDragEnd with behavioral coverage using runtime integration or pure
gating/status helpers, verifying onset-mode gating, locked-pole bypass, and
conditional snap status. Also update body() to assert that the end marker is
found before slicing, preventing a missing marker from producing slice(s, -1).
🪄 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: 7d8628bc-5d2b-45d9-af81-ebb9b7b8a946

📥 Commits

Reviewing files that changed from the base of the PR and between 33521a4 and dcfda07.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/tempo.js
  • tests/tempo_onset_snap.test.mjs

Comment thread src/tempo.js
Comment on lines +2078 to +2082
export function _tempoOnsetSnapTolPure(secPerPx, pxWindow, maxSec) {
const px = Math.max(0, (Number(secPerPx) || 0) * (Number(pxWindow) || 0));
const cap = Math.max(0, Number(maxSec) || 0);
return Math.min(px, cap);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate tolerance operands before multiplying.

Clamping only the product means two negative inputs can produce a positive snap window, e.g. (-1, -6), and Infinity * 0 produces NaN. Validate each operand individually so non-positive inputs reliably disable snapping.

Proposed fix
 export function _tempoOnsetSnapTolPure(secPerPx, pxWindow, maxSec) {
-    const px = Math.max(0, (Number(secPerPx) || 0) * (Number(pxWindow) || 0));
-    const cap = Math.max(0, Number(maxSec) || 0);
-    return Math.min(px, cap);
+    const sec = Number(secPerPx);
+    const pxWindowN = Number(pxWindow);
+    const cap = Number(maxSec);
+    if (!(sec > 0) || !(pxWindowN > 0) || !(cap > 0)) return 0;
+    return Math.min(sec * pxWindowN, cap);
 }
📝 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.

Suggested change
export function _tempoOnsetSnapTolPure(secPerPx, pxWindow, maxSec) {
const px = Math.max(0, (Number(secPerPx) || 0) * (Number(pxWindow) || 0));
const cap = Math.max(0, Number(maxSec) || 0);
return Math.min(px, cap);
}
export function _tempoOnsetSnapTolPure(secPerPx, pxWindow, maxSec) {
const sec = Number(secPerPx);
const pxWindowN = Number(pxWindow);
const cap = Number(maxSec);
if (!(sec > 0) || !(pxWindowN > 0) || !(cap > 0)) return 0;
return Math.min(sec * pxWindowN, cap);
}
🤖 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 `@src/tempo.js` around lines 2078 - 2082, Update _tempoOnsetSnapTolPure to
validate secPerPx and pxWindow individually before multiplication, treating
non-finite or non-positive values as zero so negative pairs cannot produce a
positive window and Infinity multiplied by zero cannot yield NaN. Preserve the
existing non-negative maxSec cap and return the bounded tolerance.

Comment thread src/tempo.js
Comment on lines +2088 to +2092
export function _tempoOnsetSnapPure(rawT, onsets, tol, loBound, hiBound) {
const near = _nearestOnsetTimePure(onsets, rawT, tol);
if (near === null) return { t: rawT, snapped: false };
return { t: Math.max(loBound, Math.min(hiBound, near)), snapped: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid allocating a result object on every drag frame.

_tempoMapOnDragMove invokes _tempoOnsetSnapPure for every onset-mode move, and the helper allocates { t, snapped } even when no snap occurs. This conflicts with the PR objective of avoiding per-frame allocation and can add garbage-collection churn during dragging. Return null for no snap and the clamped numeric time for a snap, then derive dg.snappedT in the caller.

Proposed fix
 export function _tempoOnsetSnapPure(rawT, onsets, tol, loBound, hiBound) {
     const near = _nearestOnsetTimePure(onsets, rawT, tol);
-    if (near === null) return { t: rawT, snapped: false };
-    return { t: Math.max(loBound, Math.min(hiBound, near)), snapped: true };
+    if (near === null) return null;
+    return Math.max(loBound, Math.min(hiBound, near));
 }

-        const res = _tempoOnsetSnapPure(rawT, _ensureOnsets(), tol, loBound, hiBound);
-        newT = res.t;
-        if (res.snapped) dg.snappedT = newT;
+        const snappedT = _tempoOnsetSnapPure(rawT, _ensureOnsets(), tol, loBound, hiBound);
+        if (snappedT !== null) {
+            newT = snappedT;
+            dg.snappedT = snappedT;
+        }

Also applies to: 2110-2122

🤖 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 `@src/tempo.js` around lines 2088 - 2092, Update _tempoOnsetSnapPure to return
null when no onset is within tolerance, or the clamped numeric snap time when
one is found, eliminating per-call result-object allocation. Adjust
_tempoMapOnDragMove to interpret that return value and derive dg.snappedT and
snapped state while preserving existing drag behavior.

Comment on lines +65 to +80
const src = fs.readFileSync(new URL('../src/tempo.js', import.meta.url), 'utf8');
function body(header, end) {
const s = src.indexOf(header);
assert.ok(s >= 0, `"${header}" must exist`);
return src.slice(s, end ? src.indexOf(end, s) : s + 1400);
}
t('the drag move gates onset-snap on Snap = Onset and a non-locked pole', () => {
const b = body('export function _tempoMapOnDragMove', 'export function _tempoMapOnDragEnd');
assert.match(b, /S\.snapMode === 'onset'/, 'only snaps in Onset mode');
assert.match(b, /!\(orig\[d\] && orig\[d\]\.locked\)/, 'locked poles never snap');
assert.match(b, /_tempoOnsetSnapPure\(rawT, _ensureOnsets\(\)/, 'uses the live onset cache');
});
t('the drag end reports a snap', () => {
const b = body('export function _tempoMapOnDragEnd', 'export function _makeTimeRemap');
assert.match(b, /dg\.snappedT != null/, 'status only when a snap actually landed');
assert.match(b, /snapped to a detected attack/, 'names the snap for the user');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the handler checks behavioral, not only textual.

Lines 71-80 only verify that source snippets contain certain regexes; they do not prove snapping is actually gated, locked poles bypass it, or drag-end status is conditional. A regression such as an unconditional status message could still pass. Also, body() silently uses slice(s, -1) when the end marker is missing.

Prefer a runtime integration test or pure gating/status helpers; at minimum, assert the end marker exists before slicing.

Suggested hardening
 function body(header, end) {
     const s = src.indexOf(header);
     assert.ok(s >= 0, `"${header}" must exist`);
-    return src.slice(s, end ? src.indexOf(end, s) : s + 1400);
+    const e = end ? src.indexOf(end, s) : s + 1400;
+    assert.ok(e > s, `"${end}" must exist after "${header}"`);
+    return src.slice(s, e);
 }
📝 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.

Suggested change
const src = fs.readFileSync(new URL('../src/tempo.js', import.meta.url), 'utf8');
function body(header, end) {
const s = src.indexOf(header);
assert.ok(s >= 0, `"${header}" must exist`);
return src.slice(s, end ? src.indexOf(end, s) : s + 1400);
}
t('the drag move gates onset-snap on Snap = Onset and a non-locked pole', () => {
const b = body('export function _tempoMapOnDragMove', 'export function _tempoMapOnDragEnd');
assert.match(b, /S\.snapMode === 'onset'/, 'only snaps in Onset mode');
assert.match(b, /!\(orig\[d\] && orig\[d\]\.locked\)/, 'locked poles never snap');
assert.match(b, /_tempoOnsetSnapPure\(rawT, _ensureOnsets\(\)/, 'uses the live onset cache');
});
t('the drag end reports a snap', () => {
const b = body('export function _tempoMapOnDragEnd', 'export function _makeTimeRemap');
assert.match(b, /dg\.snappedT != null/, 'status only when a snap actually landed');
assert.match(b, /snapped to a detected attack/, 'names the snap for the user');
const src = fs.readFileSync(new URL('../src/tempo.js', import.meta.url), 'utf8');
function body(header, end) {
const s = src.indexOf(header);
assert.ok(s >= 0, `"${header}" must exist`);
const e = end ? src.indexOf(end, s) : s + 1400;
assert.ok(e > s, `"${end}" must exist after "${header}"`);
return src.slice(s, e);
}
t('the drag move gates onset-snap on Snap = Onset and a non-locked pole', () => {
const b = body('export function _tempoMapOnDragMove', 'export function _tempoMapOnDragEnd');
assert.match(b, /S\.snapMode === 'onset'/, 'only snaps in Onset mode');
assert.match(b, /!\(orig\[d\] && orig\[d\]\.locked\)/, 'locked poles never snap');
assert.match(b, /_tempoOnsetSnapPure\(rawT, _ensureOnsets\(\)/, 'uses the live onset cache');
});
t('the drag end reports a snap', () => {
const b = body('export function _tempoMapOnDragEnd', 'export function _makeTimeRemap');
assert.match(b, /dg\.snappedT != null/, 'status only when a snap actually landed');
assert.match(b, /snapped to a detected attack/, 'names the snap for the user');
🤖 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_onset_snap.test.mjs` around lines 65 - 80, Replace the regex-only
tests for _tempoMapOnDragMove and _tempoMapOnDragEnd with behavioral coverage
using runtime integration or pure gating/status helpers, verifying onset-mode
gating, locked-pole bypass, and conditional snap status. Also update body() to
assert that the end marker is found before slicing, preventing a missing marker
from producing slice(s, -1).

@byrongamatos
byrongamatos merged commit d7643db into main Jul 13, 2026
4 checks passed
@byrongamatos
byrongamatos deleted the feat/editor-tempo-onset-snap branch July 13, 2026 13:10
byrongamatos pushed a commit that referenced this pull request Jul 14, 2026
_tempoMapOnDragMove snapped a dragged barline to the nearest onset using
_ensureOnsets() (BUFFER time) against rawT (CHART time). When the recording is
shifted (S.audioShift != 0) the two diverge, so the barline snapped to the
un-shifted attack. Use _ensureOnsetsShifted() — the chart-time onsets Suggest-fit
already uses. Pre-existing since #235; low impact (only with a shifted audio),
undoable, but a wrong snap target.

Also drops the now-unused _ensureOnsets import. tempo_onset_snap.test.mjs's
source-text assertion updated to _ensureOnsetsShifted. 146 JS green, lint 0-err.

Fixes #254.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
byrongamatos pushed a commit that referenced this pull request Jul 14, 2026
…255)

_tempoMapOnDragMove snapped a dragged barline to the nearest onset using
_ensureOnsets() (BUFFER time) against rawT (CHART time). When the recording is
shifted (S.audioShift != 0) the two diverge, so the barline snapped to the
un-shifted attack. Use _ensureOnsetsShifted() — the chart-time onsets Suggest-fit
already uses. Pre-existing since #235; low impact (only with a shifted audio),
undoable, but a wrong snap target.

Also drops the now-unused _ensureOnsets import. tempo_onset_snap.test.mjs's
source-text assertion updated to _ensureOnsetsShifted. 146 JS green, lint 0-err.

Fixes #254.


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>
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