Skip to content

feat(editor): snap-to-onset — bridge musical time to audio-attack time (D1) - #144

Merged
byrongamatos merged 1 commit into
mainfrom
feat/editor-onset-snap
Jul 9, 2026
Merged

feat(editor): snap-to-onset — bridge musical time to audio-attack time (D1)#144
byrongamatos merged 1 commit into
mainfrom
feat/editor-onset-snap

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds a second snap target alongside the subdivision grid. A Grid / Onset toggle by the Snap controls (and the toggleSnapMode command) switches note placement between the tempo-map subdivisions and the nearest detected transient in the recording.

Grid time and the actual audio attack routinely differ by tens of milliseconds, so this is the bridge between musical time and audio-attack time for by-ear transcription (Phase D1 of the workspace-shell design). No warp — just snap placement to the onset time.

How

  • snapTime() onset branch — when snap is on and S.snapMode === 'onset', prefer the nearest onset within a ~70 ms window (ONSET_SNAP_TOL); otherwise fall back to grid snap. Since every add/drag routes through snapTime, the mode applies to all note, drum, anchor and section placement at once — one chokepoint, no per-call sites.
  • @pure:onset-snap_nearestOnsetTimePure(onsets, t, tol) — binary-searched nearest transient over the sorted onsets, or null so the caller falls back to grid. Guards non-finite t, empty onsets, and tol ≤ 0.
  • Reuses the existing onset detector that already draws the display-only onset strip (_ensureOnsets) — no new DSP, no warp.
  • UI: a Grid/Onset toolbar button + a toggleSnapMode registry command (no keybind — Shift+G/Alt+G are already claimed by the planned grid-display / custom-snap commands).
  • S.snapMode is an editor pref persisted to localStorage, never written to the pack.

Degrade behaviour

Onset mode falls back to grid snap when no attack is near, when no onsets are computed (no recording), and the master snap-off checkbox gates onset mode too — so placement stays sensible in every state.

Tests

tests/onset_snap.test.js — 13 cases:

  • the @pure:onset-snap nearest-onset helper (within/outside tolerance, nearest-of-many, before-first/after-last, degrade-to-null, adversarial inputs);
  • the snapTime routing (grid vs onset, near-transient wins, no-onset/none-computed/snap-off → grid), via the extracted function with injected deps.

Both seams fail on main (the @pure block and the snapMode branch don't exist there).

Verification

  • node --check screen.js clean
  • Full JS suite: 72/72 green
  • No routes.py change → no pytest
  • Branched off fresh origin/main (b367c23)

Notes

Onset-snap operates in the seconds domain (snaps to the onset time), so it has zero dependency on the Phase A2 note.beat model (#135) and rebases clean regardless of Phase-A merge order. It's placement, not the time model.

Summary by CodeRabbit

  • New Features

    • Added a new snap target mode that lets the editor snap to detected audio onsets instead of only grid subdivisions.
    • Snap mode is saved as a local editor preference and falls back to grid snapping when no nearby onset is available.
  • Tests

    • Added coverage for onset snapping behavior, including edge cases and fallback behavior.

@coderabbitai

coderabbitai Bot commented Jul 9, 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: 1 minute

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: 8646a51b-d396-4ee5-bdfb-825c49c83f53

📥 Commits

Reviewing files that changed from the base of the PR and between 7644a3a and 031430f.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • screen.html
  • screen.js
  • tests/onset_snap.test.js
📝 Walkthrough

Walkthrough

Adds a "Snap to audio onset" editor preference that toggles snapping between tempo-grid subdivisions and nearest detected audio transients, wired through a new UI button, command entry, persisted localStorage preference, updated snapTime logic with a new nearest-onset helper, tests, and changelog entry.

Changes

Snap to Audio Onset Feature

Layer / File(s) Summary
Snap mode preference and onset snap logic
screen.js
Adds a snapMode config field (default 'grid'), ONSET_SNAP_TOL constant, and extends snapTime(t) to branch to onset-based snapping via a new _nearestOnsetTimePure binary-search helper, falling back to grid snap.
Toggle UI, command wiring, and persistence
screen.html, screen.js
Adds a snap-mode toggle button, a toggleSnapMode command/menu entry and dispatch case, and _editorToggleSnapMode/_refreshSnapModeBtn functions that toggle, persist, and reflect the mode in the UI.
Test coverage and changelog
tests/onset_snap.test.js, CHANGELOG.md
Adds a test harness that extracts and executes snapTime/_nearestOnsetTimePure from screen.js, tests covering tolerance/boundary/fallback behavior, and a changelog entry describing the feature.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

🚥 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 matches the main change: adding editor snap-to-onset behavior.
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-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.

🧹 Nitpick comments (3)
screen.js (1)

7058-7092: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor inconsistency: unguarded _ensureOnsets() call vs. guarded usage in snapTime.

Line 7076 calls _ensureOnsets() directly, while snapTime (line 1184) guards the same call with typeof _ensureOnsets === 'function'. If the guard in snapTime is defensively necessary, the same defensiveness should apply here for consistency; if it isn't necessary, the guard in snapTime could be dropped instead.

Optional consistency fix
     if (S.snapMode === 'onset') {
-        const onsets = _ensureOnsets();
+        const onsets = (typeof _ensureOnsets === 'function') ? _ensureOnsets() : null;
         setStatus(onsets && onsets.length
🤖 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` around lines 7058 - 7092, The snap-mode toggle in
_editorToggleSnapMode() calls _ensureOnsets() directly, while snapTime() uses a
typeof guard for the same helper. Make the two paths consistent by either
guarding the _ensureOnsets() call here as well or removing the unnecessary guard
in snapTime(), and keep the fallback status messaging in sync with the chosen
approach.
tests/onset_snap.test.js (2)

55-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

No test for _ensureOnsets being unavailable.

The mock always supplies _ensureOnsets as a function returning onsets; the real typeof _ensureOnsets === 'function' guard branch (falling back to null) in snapTime is never exercised.

🤖 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/onset_snap.test.js` around lines 55 - 65, Add a test in
onset_snap.test.js that exercises the snapTime path where _ensureOnsets is not
available, since the current setup only mocks _ensureOnsets as a function.
Update the snapTime test cases around the existing _nearestOnsetTimePure/onsets
setup to pass an undefined or non-function _ensureOnsets and assert the fallback
null behavior is used. Keep the existing snapTime helper invocation, but vary
the _ensureOnsets dependency so the typeof _ensureOnsets === 'function' guard is
covered.

96-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Incorrect math in comment.

Math.round(2.5) returns 3 in JavaScript (rounds ties toward +∞), not 2 as the comment states — gridPoint is actually 1.5, not 1.0. The assertion still passes either way since 1.234 !== gridPoint regardless, but the inline reasoning is misleading for future maintainers.

📝 Fix the comment
     const onset = _nearestOnsetTimePure([{ t: 1.234 }], 1.25, 0.07);
-    const gridPoint = Math.round(1.25 * 2) / 2; // 1.5? no: round(2.5)=2 -> 1.0... nearest 0.5
+    const gridPoint = Math.round(1.25 * 2) / 2; // round(2.5)=3 -> 1.5 (nearest 0.5)
🤖 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/onset_snap.test.js` around lines 96 - 103, The inline note in the
_nearestOnsetTimePure test has incorrect rounding math, so update the comment to
match JavaScript’s Math.round behavior and the actual gridPoint value. Keep the
test logic unchanged, but revise the explanatory text near _nearestOnsetTimePure
so it accurately states that Math.round(2.5) yields 3 and the nearest 0.5-second
grid point is 1.5.
🤖 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`:
- Around line 7058-7092: The snap-mode toggle in _editorToggleSnapMode() calls
_ensureOnsets() directly, while snapTime() uses a typeof guard for the same
helper. Make the two paths consistent by either guarding the _ensureOnsets()
call here as well or removing the unnecessary guard in snapTime(), and keep the
fallback status messaging in sync with the chosen approach.

In `@tests/onset_snap.test.js`:
- Around line 55-65: Add a test in onset_snap.test.js that exercises the
snapTime path where _ensureOnsets is not available, since the current setup only
mocks _ensureOnsets as a function. Update the snapTime test cases around the
existing _nearestOnsetTimePure/onsets setup to pass an undefined or non-function
_ensureOnsets and assert the fallback null behavior is used. Keep the existing
snapTime helper invocation, but vary the _ensureOnsets dependency so the typeof
_ensureOnsets === 'function' guard is covered.
- Around line 96-103: The inline note in the _nearestOnsetTimePure test has
incorrect rounding math, so update the comment to match JavaScript’s Math.round
behavior and the actual gridPoint value. Keep the test logic unchanged, but
revise the explanatory text near _nearestOnsetTimePure so it accurately states
that Math.round(2.5) yields 3 and the nearest 0.5-second grid point is 1.5.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 817a2abe-480f-4e40-b241-b4cbfb6ff3d4

📥 Commits

Reviewing files that changed from the base of the PR and between b367c23 and 7644a3a.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • screen.html
  • screen.js
  • tests/onset_snap.test.js

…e (D1)

Add a second snap target alongside the subdivision grid: a Grid / Onset
toggle (and the `toggleSnapMode` command) switches note placement between
the tempo-map subdivisions and the nearest detected transient in the
recording. Grid time and the actual attack routinely differ by tens of
milliseconds, so this is the bridge between musical time and audio-attack
time for by-ear transcription.

- `snapTime()` gains an onset branch: when snap is on and `S.snapMode` is
  'onset', prefer the nearest onset within ~70 ms (`ONSET_SNAP_TOL`), else
  fall back to grid snap. Because every add/drag routes through `snapTime`,
  the mode applies to all note, drum, anchor and section placement at once.
- `@pure:onset-snap` → `_nearestOnsetTimePure(onsets, t, tol)`: binary-
  searched nearest transient, or null so the caller falls back to grid.
- Reuses the existing onset detector that already draws the display-only
  onset strip — no warp, no new DSP.
- Grid/Onset toolbar button + registry command; `S.snapMode` is an editor
  pref persisted to localStorage, never written to the pack.

Tests: tests/onset_snap.test.js (the pure nearest-onset helper + the
grid-vs-onset snapTime routing; both fail on main).

Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
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