feat(editor): tempo range operations — half/double/flatten/re-fit (tempo PR 8) - #232
Conversation
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
The onset-fit march (Suggest ▸ G) now reads music the way a transcriber
would — it stops guessing where the audio stops agreeing instead of
snapping the grid onto syncopations or a single bad transient. All in
the pure fence (src/tempo-suggest.js); _suggestFitPure's signature is
unchanged (opts bag), so PR 8 can still build on it.
- c1 beat-relative window: the snap window is now a fraction of the
BEAT (gridInt/beatsInBar), not the whole bar — the old ±12%-of-bar was
±0.48 beat in 4/4 and scaled with meter, so a syncopation could be
snapped as the downbeat. New _suggestBeatWinPure; still floored at 25ms.
- c2 one-bar comb corroboration (_suggestCombPure): a candidate downbeat
is scored by the onset support of ALL the bar's implied beats, not the
single downbeat onset.
- c6 confidence is a PRODUCT of comb × continuity × consistency, so a
bare-downbeat or off-tempo bar reads as less certain than a fully-
played, steady one.
- c3 median stretch (_suggestMedianPure) resists one bad snap the way a
plain EMA (α=0.35) could not; a single correction > 25% of a bar STOPS
(stopDetail 'tempo-jump') rather than dragging the whole grid.
- c4 phase: a snap implying ~half/double the bar is a metric-phase
ambiguity (halftime backbeat, double-time hat) — stop ('phase').
- c5 miss classification (_suggestAnyOnsetInPure): a bar with NO onset is
silence — stop ('silence'); a bar whose onsets just miss the downbeat is
sustained — keep marching. Trailing-drop now keys on a miss flag, not
confidence, so a real low-comb hit survives.
- c7 named refusals: out-of-range tempo (outside 40-300 BPM) stops
('bpm-range'), and _suggestHudTextPure names each stop reason in the HUD
(_suggestStopDetail wired through tempo.js).
tests/tempo_suggest.test.mjs: +7 fixtures, one per failure mode, each
verified to FAIL on the pre-hardening engine (c1 grabs the syncopation,
c2 scores equal confidence, c3-c7 lack the stops). The existing drift
fixture was updated to a fully-played onset stream so the new comb
confidence is exercised (deliberate pin update). 118 JS green, lint
0-err. Verified live: Suggest on AC/DC proposed 96 barlines, ghosts
render, no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
…mpo PR 8) Three operations over the barline multi-selection (5a), each a small pure + one command + a right-click row, each declaring its time contract. - Half-time / Double-time the range (TempoGridCmd — times HOLD, beats re-lift, so audio positions hold): _tempoHalveRangePure demotes every second downbeat inside the range (odd bar count → remainder reported); _tempoDoubleRangePure promotes the interior beat nearest each bar's time midpoint (a one-beat bar has nothing to split → skipped + reported). - Flatten range to a steady tempo (TempoMapCmd — times MOVE, notes follow): _tempoFlattenRangePure re-times the beats between the range ends to uniform per-beat spacing, both ends pinned (no tail shift), interior locks honoured by running the sub-range through _respaceWithLocksPure. - Re-fit the range to onsets: _suggestFitPure gains opts.toIdx — the march stops cleanly at the range's last downbeat (stopDetail 'bound'), never proposing past it. G with a multi-selection anchors at the range's first downbeat and bounds at its last; the accept path is unchanged. Wired into the tempo-map context menu (the three rows show when 2+ barlines are selected) and the Suggest (G) handler. _suggestCompute remembers the opts so a forward-regenerate after an accept stays in range. New tests/tempo_range_ops.test.mjs (12): the range resolver; half/double keep every second fixed; half→double is identity on an even range; a one-beat bar is skipped; flatten's uniform interior + pinned ends + tail untouched + interior-lock hold; each command round-trips exec→undo→redo (flatten's note rides); the bounded re-fit never proposes past the range (and the unbounded one does). 120 JS green, lint 0-err. Verified live: range re-fit "Suggested a fit for the selected range (96 barlines)", right-click ▸ Half-time "merged 52 barlines — audio positions hold". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
📝 WalkthroughWalkthroughTempo Map multi-selection now supports half-time, double-time, and flatten operations. Tempo suggestion fitting can also be constrained to the selected downbeat range, with option persistence during regeneration and coverage from unit and integration tests. ChangesTempo range editing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Editor
participant TempoSelection
participant SuggestionEngine
participant TempoGrid
Editor->>TempoSelection: obtain selected downbeat range
Editor->>SuggestionEngine: compute suggestions with range bound
SuggestionEngine->>SuggestionEngine: stop proposals at toIdx
SuggestionEngine-->>Editor: return bounded suggestions
Editor->>TempoGrid: apply selected-range tempo operation
TempoGrid-->>Editor: refresh tempo map and status
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
eb8fd3b to
5c6910b
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/tempo_range_ops.test.mjs (1)
138-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest title promises redo coverage the body doesn't exercise.
Title says "exec → undo → redo" but the test never calls
S.history.doRedo()or asserts anything about it, unlike the half-time test above (125-136) which fully round-trips. Since the PR objective specifically claims "command undo/redo coverage," this gap should be closed to actually verify redo forTempoGridCmdvia double-time.✅ Proposed fix
_tempoDoubleRange(); assert.deepStrictEqual(dbTimes(S.beats), [0, 2, 4, 6, 8], 'split at midpoints'); assert.strictEqual(S.history.undo.length, 1); S.history.doUndo(); assert.deepStrictEqual(dbTimes(S.beats), [0, 4, 8], 'undo restores the barlines'); + S.history.doRedo(); + assert.deepStrictEqual(dbTimes(S.beats), [0, 2, 4, 6, 8], 'redo re-applies'); });🤖 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_range_ops.test.mjs` around lines 138 - 146, Extend the test titled “double-time round-trips exec → undo → redo” to call S.history.doRedo() after verifying undo, then assert that S.beats returns to the doubled grid [0, 2, 4, 6, 8]. Keep the existing execution and undo assertions unchanged.
🤖 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/tempo_range_ops.test.mjs`:
- Around line 138-146: Extend the test titled “double-time round-trips exec →
undo → redo” to call S.history.doRedo() after verifying undo, then assert that
S.beats returns to the doubled grid [0, 2, 4, 6, 8]. Keep the existing execution
and undo assertions unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 573799fc-b5cd-4f07-9a7a-02d11728df73
📒 Files selected for processing (5)
src/input.jssrc/tempo-suggest.jssrc/tempo.jstests/tempo_range_ops.test.mjstests/tempo_suggest.test.mjs
What & why
Tempo-track PR 8 (charrette arch 2 / trans P5). Three operations over a selected barline range in Tempo Map mode — the tools for reshaping a whole passage's grid at once. Each declares its time contract (the charrette's declared-contract rule).
The three ops
TempoGridCmd, so times HOLD (the audio positions and note seconds don't move; only the barline grid changes → the tempo halves or doubles)._tempoHalveRangePuredemotes every second downbeat (an odd bar count leaves the last bar unpaired — reported)._tempoDoubleRangePurepromotes the interior beat nearest each bar's time midpoint (a one-beat bar has nothing to split — skipped + reported).TempoMapCmd, so times MOVE and the notes follow._tempoFlattenRangePurere-times the beats between the two ends to uniform per-beat spacing, both ends pinned (no tail shift); interior locked barlines hold their time (the sub-range runs through_respaceWithLocksPure)._suggestFitPuregainsopts.toIdx: the onset march stops cleanly at the range's last downbeat (stopDetail: 'bound'), never proposing past it. Pressing G with a multi-selection anchors at the range's first downbeat and bounds at its last; the accept path is unchanged.Wired into the tempo-map context menu (the three rows appear when 2+ barlines are selected) and the Suggest (G) handler.
Verification
npm test— 120 pass. Newtests/tempo_range_ops.test.mjs(12): the range resolver; half/double keep every second fixed; half→double is identity on an even range; a one-beat bar is skipped; flatten's uniform interior + pinned ends + untouched tail + interior-lock hold; each command round-trips exec → undo → redo (flatten's note rides the retimed grid); the bounded re-fit never proposes past the range (and the unbounded one does).npm run lint— 0 errors (3 pre-existing ratchet warnings).🤖 Generated with Claude Code
Summary by CodeRabbit