Skip to content

299 add bpm management - #349

Merged
kfxs merged 9 commits into
stagingfrom
299-add-bpm-management
Jul 27, 2026
Merged

299 add bpm management#349
kfxs merged 9 commits into
stagingfrom
299-add-bpm-management

Conversation

@kfxs

@kfxs kfxs commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

kfxs and others added 7 commits July 27, 2026 15:11
Packets 1 and 4 of the BPM management plan.

Packet 1 — tempo editing core:
- new pure src/timeline/tempo/tempoEdits.ts owning every invariant (sort,
  dedupe by time, pinned first event, clamps, id backfill/repair)
- TempoEvent.id, required at runtime and optional in the project tier;
  normalizeRulerLaneState now normalizes instead of passing through, so
  pre-#299 projects gain ids at the single load seam
- editable BPM range [20, 999] — deliberately NOT the 60/200 detection bins
  from beatGridEstimation, which would reject 40 and 240 BPM
- new tempoSlice (setProjectTempo / add / update / removeTempoChange),
  capturing history AFTER the mutation like midiClipSlice
- tempoMap threaded through all 12 history sites; restore falls back to the
  live map so a pre-#299 entry cannot clobber it on undo

Packet 4 — tempo-driven body grid and snapping:
- new pure src/timeline/tempo/barsGrid.ts: explicit bar/beat/subdivision line
  times plus the pixel thresholds the bars ruler also uses, so ruler ticks and
  grid lines can never disagree
- an ENABLED Bars+Beats ruler lane wins the grid (not activeRulerLaneId, which
  is retired as a grid seam); bars replace the time/frame grid
- grid snapping in both snap paths with a pixel-derived threshold, since a
  1/16 at 120 BPM is narrower than the fixed 0.15 s window
- timelineGridSubdivision view preference (localStorage) with the picker in
  the Rulers menu

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Packet 2 of the BPM management plan, plus a Rulers menu UX fix.

Content follows tempo:
- new pure src/timeline/tempo/tempoRemap.ts — remapAcrossMaps,
  trackFollowsTempo, remapMidiClip, remapTimelineClipsForTempo
- MIDI clips, notes and the four CC automation lanes are remapped through
  old map -> bar/beat -> new map; video/audio/image clips keep their seconds
  and their object identity
- durations are never scaled by a factor: ends are remapped and the remapped
  start subtracted, which is the only correct answer across a tempo boundary
- everything is anchored on the clip's content origin, so noteAbsoluteStart
  stays exact and the window cannot drift from its notes
- tempoSlice commits the remapped clips and the new map in one set under one
  snapshot, so a single undo reverts tempo and notes together

TempoMap fix:
- barBeatToSeconds now extrapolates through segment 0 below the first
  segment. With more than one segment every range test failed and it fell
  through to the LAST segment, returning nonsense for bar <= 0 — reachable
  through a left-extended MIDI clip's negative inPoint
- new barBeatToSecondsAt object wrapper

Rulers menu:
- clicking a lane row toggles the lane again (checkmark + highlight); the
  grid resolution moved behind a + expander on the Bars + Beats row and is
  indented under it, so one click never means two things

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Packet 3 plus two model fixes found while testing it in the app.

Tempo lane (Rulers -> Tempo, stacked under Bars + Beats, never selectable as
the active lane):
- flags read "4/4 - BPM = 120"; editing swaps only the value being changed for
  a field, so the rest of the label stays readable
- right-click the lane: Add tempo change / Add time signature change, inserted
  at the nearest bar with the relevant editor already armed and selected
- right-click a flag: Change tempo / Change time signature / Ramp toggle /
  Delete (Delete disabled on the project tempo)
- drag to move, snapped to bars, Alt for free placement; one undo per gesture
- the menu portals to document.body: the lane lives inside .time-ruler, which
  has a transform and two overflow:hidden wrappers, so a fixed-position menu
  was being positioned against the wrong box and clipped away
- dismissal listens in the CAPTURE phase, since the flags themselves stop
  mousedown propagation and a bubble listener never fired

Ramps (plan 3.3 said out of scope; now supported):
- TempoEvent.curve = 'jump' | 'ramp'; 'ramp' reaches the tempo by interpolating
  from the previous event across the interval leading into it
- TempoMap integrates a linearly varying tempo: elapsed beats are quadratic in
  time and the inverse solves that quadratic in closed form, so 60 -> 120 over
  8 s covers 12 beats (the average), not 8
- dashed sloped indicator across the ramped interval plus an arrow on the flag

Model fixes:
- content is anchored to its QUARTER-NOTE position, not to (bar, beat). Bar and
  beat made a pure meter change (4/4 -> 3/4 at the same BPM) drag every note
  sideways even though no note's duration changed
- tempo marks are re-anchored on every edit so they stay on their BAR. They are
  stored in seconds, so a ramp changing the length of the preceding interval
  used to slide a flag placed on bar 11 to bar 11.5. An explicitly set position
  (insert or drag) still wins, so a dragged flag lands where it was dropped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A read-only mirror of the timeline's tempo lane, stacked between the Time and
Bars rulers so tempo sits directly above the bars it drives.

- follows the timeline's Rulers -> Tempo toggle, so one setting drives both
  views instead of a second hidden one (and it is already project-persisted
  through rulerLanes, from #257)
- same labels, ramp arrow and dashed sloped indicator as the timeline lane;
  flags are pointer-events:none so they never swallow ruler scrubbing
- built as a separate inline-styled renderer rather than reusing
  TempoRulerLane: the piano roll is a detached popup, and in dev the app CSS is
  injected as <style> tags that are not mirrored into it, so Timeline.css
  classes do not exist there
- PIANO_ROLL_RULER_H replaced by pianoRollRulerHeight(laneCount), since the
  ruler is no longer a fixed two lanes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a MIDI track defaulted to Track controls. The instrument is what you
actually reach for on a MIDI track — volume/pan already live in the mixer — so
it now opens there. Audio tracks still open on Track effects and every other
track type on Track controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Packets 5 and 6 of the BPM management plan.

Engine:
- src/engine/audio/metronomeVoice.ts — one oscillator + gain with a ~40 ms
  exponential decay; 1000 Hz at full level on the downbeat, 800 Hz at 0.7
  otherwise. No assets, no sample loading. A click whose time has already
  passed fires immediately rather than being dropped
- src/services/audio/metronomeScheduler.ts — the look-ahead pattern from
  midiPlaybackScheduler: 25 ms timer, 0.12 s window, timeline<->context anchor,
  re-anchor on a >0.25 s seek, silence at non-1x speed, dedup so a beat inside
  two windows fires once
- beat times come from iterateBarBeatLines, so the click tracks meter, mid-window
  tempo changes and RAMPS with no new math
- routing per plan 3.4: its own GainNode straight to AudioContext.destination,
  never a registered node route, so it cannot reach master metering, the
  FX/limiter chain or an export; an isExporting guard sits on top

UI:
- MetronomeButton after the Rulers menu: the button toggles, a separate caret
  opens a popover with volume and Every beat / Bars only
- metronomeEnabled / metronomeVolume / metronomeMode are per-user localStorage
  view state like snappingEnabled, never project content (plan 3.6). Pulled
  forward from Packet 6 into Packet 5 because the engine needs something to read

Count-in is deliberately NOT included. A real pre-count sounds the click for N
bars while content stays silent, which needs a new transport phase rather than
a playhead roll-back; play() has nowhere for "running but not advancing". The
plan allows shipping the toggle and volume first rather than bending playback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Packet 7, plus three architecture-ratchet fixes the full chain surfaced from the
metronome packets (which were fast-committed without checks).

Docs:
- new docs/Features/Tempo-And-Metronome.md — what you can do, then the three
  ideas the feature rests on, each written around WHY, since each was wrong at
  least once while building: BPM sets duration while the meter only groups; MIDI
  is musical and media is linear; a tempo mark is a musical object
- Timeline-Rulers.md gets a superseded-in-part banner and its two now-false
  claims corrected in place: it no longer "ships no grid/snap behavior", and the
  click-to-select active-lane seam was retired in favour of "an enabled bars
  lane wins the grid"
- Features/README.md indexes the new page; ongoing/README.md marks the plan
  complete
- the plan doc gains per-packet landing notes and a new section 11 recording
  where the shipped feature diverges from it (ramps, quarter-note anchoring,
  musical re-anchoring, the smaller deviations, and the two bugs that
  mutation-testing our own tests caught)

Ratchet fixes:
- the metronome bootstrap moved from useLayerSync (which is under a hard LOC
  cap it pushed over) into editorBoot, where the other app-wide services start.
  A global audio service was never part of layer sync
- metronomeScheduler registered in the getState access policy at 4 hits, the
  same class as midiPlaybackScheduler; baselines bumped with the reason
- kept the registry file inside its own 300-line budget

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kfxs and others added 2 commits July 27, 2026 17:46
The architecture registry budget test measures split(/\r?\n/).length, so a
newline-terminated file scores content lines + 1. Both staging and this
branch sat at 299 content lines = exactly 300, so the merge of the two
(staging's muscriptor adapter grant + this branch's metronomeScheduler
entry) scored 301 and failed CI, even though neither branch fails alone.

Drop the per-packet bookkeeping comments left over from the closed
Complete Refactor (packets 231/267/291/306/307/342/343/345, the
dockStore-folder note, and the multi-line ratchet log in the baselines);
that history lives in git and in docs/completed/refactor/. No entries,
paths or baseline numbers change, so the file is 281 lines with real
headroom instead of zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict in src/architecture/getStateAccessPolicy.ts resolved by keeping
this branch's trimmed baseline block and combining both sides' numbers:
staging's allowedAdapterPathCount 26 (muscriptor adapter grant) plus this
branch's metronomeScheduler entry on top of staging's ledger
(237 -> 238 files, 674 -> 678 hits).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kfxs
kfxs merged commit 9ccf0fe into staging Jul 27, 2026
3 checks passed
@Sportinger
Sportinger deleted the 299-add-bpm-management branch July 28, 2026 07:00
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.

1 participant