Skip to content

feat(editor): assisted tempo mapping — suggest a barline fit from onsets - #215

Merged
byrongamatos merged 2 commits into
mainfrom
feat/editor-tempo-suggest-fit
Jul 12, 2026
Merged

feat(editor): assisted tempo mapping — suggest a barline fit from onsets#215
byrongamatos merged 2 commits into
mainfrom
feat/editor-tempo-suggest-fit

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What

The first Assisted Mapping slice from docs/TEMPO-MAPPING-DESIGN.md (the accepted timing-model design, #211): the seed → suggest → correct loop over existing downbeats.

In Tempo Map mode, G (also Tempo/Grid ▸ Suggest barline fit from anchor) proposes corrected times for every downbeat ahead of the anchor — the selected barline, or bar 1:

  • Each bar is predicted from the grid's own per-bar spacing (so meter changes and pickup bars carry automatically), scaled by a drift-tracking stretch EMA, and snapped to the strongest onset within a ±12% window; the onset's strength × proximity becomes the proposal's confidence.
  • Proposals render as dashed amber ghost poles (alpha = confidence) with hollow handles offset below the real pole handles — the two grab bands never collide.
  • Click a ghost handle to accept through that barline: one undoable TempoMapCmd (equal beat count — notes ride the reproject; interior beats re-space by original fraction, exactly like a pole drag with a fixed far edge), then the suggestions ahead regenerate from the newly confirmed anchor.
  • Locked barlines are pinned at their own times with full confidence and re-anchor the march.
  • Where the onsets stop corroborating (silence, phase break, tempo change) the run stops, drops its trailing guesses, and asks for the next human anchor in the HUD — per the design's "never silently infer" rule.
  • Esc dismisses. Proposals are keyed on editGen, so any edit invalidates them before a stale click can land. Mode exit and song load also clear them.

Design conformance (the non-negotiables)

  • Suggestions are uncommitted analysis proposals, visually distinct — nothing ever commits silently.
  • Manual anchors are authoritative; onset assist never moves a mark the user placed (locks pinned).
  • Long gaps never silently infer bars (miss-run truncation).
  • Accept = map fit: object beats fixed, seconds reprojected (the design's invariant test).

Architecture

New src/tempo-suggest.js imports only state/geometry/ui (fully node-testable). The onset list is passed in by input.js — which already imports audio.js — and remembered for regeneration, so tempo.js never gains an audio import and no cycle forms. Command execution stays in tempo.js (ghost-click accept) per the one-undo-owner convention. The pinned _tempoMapHudTextPure strings are untouched — suggestion state uses its own HUD pure.

Testing

  • tests/tempo_suggest.test.mjs (13, real-import ESM): drift tracking against a 120-grid/126-recording mismatch (all bars within 90 ms of truth), silence stop + trailing-guess drop, lock pinning, accept-through apply semantics (moved anchor, fixed far edge, equal count), editGen staleness, forward regeneration, dismissal lifecycle, ghost hit-test.
  • Full suite 108/108, ESLint 0 errors (3 pre-existing warnings).
  • Verified live on the :8000 testbed with headless Chromium: G proposed 93 barlines on a real feedpak, a ghost-handle click snapped that barline from 4.294 s to the proposed 4.248 s and regenerated 92 suggestions ahead; zero page errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Summary by CodeRabbit

  • New Features
    • Added assisted tempo mapping in Tempo Map mode with Suggest barline fit (or G).
    • Preview likely downbeat corrections as confidence-based dashed ghost poles; accept via the ghost handle for an undoable one-step re-fit.
    • Suggestions respect locked barlines, stop when supporting onsets end, and regenerate forward from the accepted barline.
    • Exiting Tempo Map mode dismisses active suggestions; press Esc to dismiss.
  • Tests
    • Added coverage for suggestion accuracy, confidence, locked barlines, acceptance, dismissal, and lifecycle behavior.

The first Assisted Mapping slice from docs/TEMPO-MAPPING-DESIGN.md: in
Tempo Map mode, G proposes corrected times for every downbeat ahead of
the anchor (selected barline, else bar 1). Each bar is predicted from
the grid's own per-bar spacing (meter changes and pickups carry for
free) scaled by a drift-tracking stretch EMA, snapped to the strongest
onset within a ±12% window, and rendered as a dashed ghost pole whose
alpha is its confidence, with a hollow handle offset BELOW the real
pole handles so the two grab bands never collide.

Proposal-only, per the design's non-negotiables: nothing commits
silently — clicking a ghost handle accepts THROUGH that barline as one
TempoMapCmd (equal beat count; notes ride the reproject; interiors
re-space by original fraction exactly like a pole drag), then the
suggestions ahead regenerate from the newly confirmed anchor — the
seed → suggest → correct loop. Locked barlines are pinned at their own
times, full confidence, and re-anchor the march. Where onsets stop
corroborating (silence / phase break / tempo change) the run stops,
drops its trailing guesses, and the HUD asks for the next human anchor.
Esc dismisses. Proposals are keyed on editGen, so any edit invalidates
them before a stale click can land.

Cycle discipline: src/tempo-suggest.js imports only state/geometry/ui;
the onset list is passed in by input.js (which already imports audio)
and remembered for regeneration; command execution stays in tempo.js.

tests/tempo_suggest.test.mjs (13): drift tracking against a grid-vs-
recording tempo mismatch, silence stop + trailing-guess drop, lock
pinning, accept-through apply (fixed far edge, equal count), editGen
staleness, regeneration, dismissal, ghost hit-test. Suite 108/108,
lint 0 errors. Verified live on the :8000 testbed: G proposed 93 bars,
a ghost click snapped the barline to the proposed onset time and
regenerated 92 ahead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
@coderabbitai

coderabbitai Bot commented Jul 12, 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: 64421925-7ffc-4b1f-881a-cc495688db9a

📥 Commits

Reviewing files that changed from the base of the PR and between 9599561 and 5eef30e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/input.js
  • src/menu-bar.js
  • src/shortcuts.js

📝 Walkthrough

Walkthrough

Adds assisted Tempo Map fitting that proposes onset-aligned barlines, displays them as ghost handles, and applies accepted proposals through undoable tempo re-fitting with lock, dismissal, and stale-edit handling.

Changes

Assisted tempo mapping

Layer / File(s) Summary
Suggestion engine and lifecycle
src/tempo-suggest.js, tests/tempo_suggest.test.mjs, CHANGELOG.md
Computes onset-aligned proposals with drift tracking, confidence, stop conditions, locked-downbeat handling, proportional beat re-spacing, lifecycle state, regeneration, HUD text, and hit testing; tests cover these behaviors and the changelog documents the feature.
Command and shortcut wiring
src/input.js, src/menu-bar.js, src/shortcuts.js
Registers tempoSuggestFit in the Tempo/Grid menu and both shortcut profiles, dispatches the command in Tempo Map mode, validates onset analysis, and supports Escape dismissal.
Ghost overlay and accepted re-fit
src/tempo.js
Renders confidence-based ghost poles and handles, accepts ghost selections through an undoable TempoMapCmd, updates tempo selection and BPM state, regenerates proposals, and dismisses them on mode changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EditorInput
  participant tempoSuggest
  participant TempoMapView
  participant TempoMapCmd
  EditorInput->>tempoSuggest: Compute proposals from analyzed onsets
  tempoSuggest-->>TempoMapView: Return ghost proposals and confidence
  TempoMapView->>tempoSuggest: Hit-test clicked ghost handle
  tempoSuggest-->>TempoMapView: Return proposal index
  TempoMapView->>TempoMapCmd: Apply accepted tempo re-fit
  TempoMapView->>tempoSuggest: Regenerate from accepted barline
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.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: assisted tempo mapping that suggests a barline fit from onsets.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-tempo-suggest-fit

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: 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/tempo-suggest.js`:
- Around line 182-234: The direct S.beats replacement paths in loadCDLC() and
editorApplyCreateResult() must also invalidate cached tempo suggestions. Call
_suggestDismiss() or otherwise bump editGen immediately when swapping S.beats,
alongside clearing S.tempoMapMode, so re-entering tempo mode cannot reuse
proposals from the previous grid.
🪄 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: 6484f951-1121-4a6e-a198-eebf56a9224c

📥 Commits

Reviewing files that changed from the base of the PR and between e02a003 and 9599561.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/input.js
  • src/menu-bar.js
  • src/shortcuts.js
  • src/tempo-suggest.js
  • src/tempo.js
  • tests/tempo_suggest.test.mjs

Comment thread src/tempo-suggest.js
Comment on lines +182 to +234
// ── Module state (proposal-only; keyed on editGen for staleness) ─────
let _sug = null; // { anchorIdx, proposals, stopReason, onsets, gen }

export function _suggestActive() {
return !!(_sug && _sug.gen === editGen && S.tempoMapMode && _sug.proposals.length);
}
export function _suggestProposals() {
return _suggestActive() ? _sug.proposals : [];
}
export function _suggestStopReason() {
return _sug ? _sug.stopReason : 'end';
}
export function _suggestAvgConf() {
if (!_suggestActive()) return 0;
let s = 0;
for (const p of _sug.proposals) s += p.conf;
return s / _sug.proposals.length;
}

export function _suggestDismiss() {
_sug = null;
}

// Compute (or forward-regenerate) proposals from `anchorIdx`, using — and
// remembering — the caller-provided onset list. Returns the proposal count.
export function _suggestCompute(anchorIdx, onsets) {
const list = onsets || (_sug && _sug.onsets) || null;
if (!list || !list.length) { _sug = null; return 0; }
const { proposals, stopReason } = _suggestFitPure(S.beats, list, anchorIdx);
_sug = { anchorIdx, proposals, stopReason, onsets: list, gen: editGen };
return proposals.length;
}

// After an accept bumped editGen, re-key and regenerate forward from the
// newly authoritative downbeat with the remembered onsets.
export function _suggestRegenerateFrom(anchorIdx) {
if (!_sug) return 0;
return _suggestCompute(anchorIdx, _sug.onsets);
}

// Ghost-handle hit test: x within `half` px of a proposal's ghost pole.
// The caller gates y to the ghost-handle band, so suggestions never steal
// the real poles' grab zone. Returns the proposal's beat index, or -1.
export function _suggestHitAt(x, half) {
if (!_suggestActive()) return -1;
const h = half || 7;
let best = -1, bestD = h + 1;
for (const p of _sug.proposals) {
const d = Math.abs(timeToX(p.time) - x);
if (d < bestD) { bestD = d; best = p.i; }
}
return best;
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate editGen, its mutator, and confirm tempo-map commands bump it.
echo "---- editGen references ----"
rg -n 'editGen' -g '*.js' src

echo "---- bumpEditGen definition ----"
rg -n 'function bumpEditGen|bumpEditGen\s*=|export.*bumpEditGen' -g '*.js' src

echo "---- TempoMapCmd / TempoGridCmd definitions ----"
rg -n 'class TempoMapCmd|class TempoGridCmd' -A 25 src

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 6147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- state.js around editGen ----"
sed -n '100,140p' src/state.js

echo "---- bumpEditGen call sites ----"
rg -n 'bumpEditGen\s*\(' src

echo "---- edit history / afterEdit ----"
rg -n 'afterEdit|_afterEdit|editGen' -A 4 -B 4 src/main.js src/state.js src/tempo.js

echo "---- tests importing bumpEditGen ----"
rg -n 'bumpEditGen' tests src -g '*test*' -g '*.mjs' -g '*.js'

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 4396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- history.js around _afterEdit ----"
sed -n '1,160p' src/history.js

echo "---- tempo.js command execution points ----"
rg -n 'new TempoMapCmd|new TempoGridCmd|exec\(\)\s*{' src/tempo.js -A 4 -B 4

echo "---- direct S.beats mutations ----"
rg -n 'S\.beats\s*=' src

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 6559


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- places that assign to S.beats ----"
rg -n 'S\.beats\s*=' src

echo "---- places that create tempo commands ----"
rg -n 'new TempoMapCmd|new TempoGridCmd' src

echo "---- Tempo command classes and any history dispatch nearby ----"
sed -n '1080,1175p' src/tempo.js
echo
sed -n '1910,1965p' src/tempo.js

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 10014


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- file-ops.js around beats assignment ----"
sed -n '1,120p' src/file-ops.js

echo "---- create.js around beats assignment ----"
sed -n '2120,2175p' src/create.js

echo "---- tempo-suggest cleanup hooks ----"
rg -n '_suggestDismiss|tempoMapMode|suggest' src/main.js src/file-ops.js src/create.js src/tempo.js src/history.js

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 13827


Invalidate tempo suggestions when beats are replaced outside history. EditHistory._afterEdit() covers command-based edits, but loadCDLC() and editorApplyCreateResult() assign S.beats directly and only clear S.tempoMapMode. That leaves cached proposals around, so re-entering tempo mode can surface suggestions for the previous grid. Dismiss suggestions or bump editGen when swapping S.beats.

🤖 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-suggest.js` around lines 182 - 234, The direct S.beats replacement
paths in loadCDLC() and editorApplyCreateResult() must also invalidate cached
tempo suggestions. Call _suggestDismiss() or otherwise bump editGen immediately
when swapping S.beats, alongside clearing S.tempoMapMode, so re-entering tempo
mode cannot reuse proposals from the previous grid.

…gest-fit

# Conflicts:
#	CHANGELOG.md
#	src/input.js
@byrongamatos
byrongamatos merged commit 312bb6b into main Jul 12, 2026
3 checks passed
byrongamatos added a commit that referenced this pull request Jul 12, 2026
* feat(editor): undo to last checkpoint (Ctrl+Alt+Z)

Charrette arch 7. Coarse rewind points so a whole tempo-mapping session can be
undone in one keystroke instead of tapping Ctrl+Z through every barline move.

- EditHistory.checkpoint(label) stamps the top-of-undo command (no-op on an
  empty stack); the stamp rides the command object, so it survives redo.
- EditHistory.undoToCheckpoint() undoes through (and including) the nearest
  stamped command and returns { undone, label, foundCheckpoint }. Two graceful
  degradations: no checkpoint in the stack falls back to a single plain undo
  (never a silent whole-session rewind — a checkpoint can be shifted off by
  MAX_UNDO or dropped by reset()); a no-progress guard stops the instant the
  stack stops shrinking, so a refused doUndo (ensureArr / roll-lock) can't spin.
- Surface: Ctrl+Alt+Z (added ahead of the plain Ctrl+Z handler, which doesn't
  exclude Alt) + an Edit-menu row; the status line names what it unwound to.
- Checkpoints stamped at three milestones: Tempo Map entry, #215's suggest-fit
  accept, and the barline lock toggle (lock toggles aren't history events, so
  the stamp records the moment on the current top-of-undo).

tests/undo_checkpoints.test.mjs (real EditHistory over the real S): rewind-
through, single-undo fallback, stamp-survives-redo, no-progress guard, empty
stack — all fail on main. Verified live: Ctrl+Alt+Z routes to the checkpoint
undo (no collision with Ctrl+Z) and a real accent→vibrato→enter-TempoMap→hammer
flow reports "Undid 2 steps back to checkpoint: Tempo Map session." npm test
115 green, lint 0 errors.

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

* fix(editor): checkpoint rewind lands ON the checkpointed state, not one edit past it

undoToCheckpoint() rolled back the stamped command too, so a Tempo-Map-entry
or barline-lock checkpoint (stamped on the last edit BEFORE the milestone)
silently undid one unrelated pre-session edit while the status line claimed a
clean return to the checkpoint.

- checkpoint() now means: the state as of this call. undoToCheckpoint() undoes
  everything above the stamp and stops with the stamped command still applied.
- Pressing while already at a checkpoint walks to the PREVIOUS one, so
  repeated Ctrl+Alt+Z steps back boundary by boundary instead of going inert.
- Suggest-fit stamps BEFORE exec'ing the accept, preserving its intended
  rewind-the-accept-too behaviour under the corrected semantics.
- Status line: a refused first undo no longer gets stomped by 'Nothing to
  undo.', and a partial (refused mid-way) rewind reports the real step count.

Regression tests fail on the previous behaviour (verified by revert).

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>
ChrisBeWithYou pushed a commit that referenced this pull request Jul 12, 2026
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
byrongamatos pushed a commit that referenced this pull request Jul 12, 2026
…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>
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