Skip to content

Tempo and swing edits are never persisted — setBpm/setSwing write only the derived transport mirror #93

Description

@BootBlock

Found while verifying #42.

The canonical tempo/swing live on the sequencepersist.ts:144-146 writes tempo, swing_amount and swing_division, and hydrate.ts:139-140 reads them back into the transport store:

transport.setBpm(activeSeq?.tempo ?? projectRow.bpm_default);
if (activeSeq) transport.setSwing(activeSeq.swingAmount, activeSeq.swingDivision);

useTransportStore.bpm is explicitly documented as the derived mirror — // effective tempo (follows the active sequence, spec §7.9).

But the transport-bar knobs write to that mirror and nothing else:

// src/ui/shell/TransportBar.tsx:136,147
onCommit={(value) => transport().setBpm(value)}
onCommit={(value) => transport().setSwing(value)}
// src/store/useTransportStore.ts:79-86 — plain `set`, no commit(), no markDirty
setBpm: (bpm) => set({ bpm: clamp(bpm, BPM_RANGE[0], BPM_RANGE[1]) }),
setSwing: (amount, division) => set((state) => ({ ... })),

So the round trip is broken one way: the knob updates the mirror, sequence.tempo is never touched, autosave is never armed, and the next hydrate overwrites the mirror from the untouched sequence row.

Impact

Change the tempo or swing, reload, and the edit is silently gone. The unsaved dot never lights, so nothing warns the user — and the #42 unload guard cannot help here either, since it keys off modifiedSinceLastSave, which these edits never set. Every other persisted control (mixer, program, sequence) goes through commit() with dirtyKeys; these two are the outliers.

Repro

  1. Note the tempo (120 default).
  2. Focus the tempo knob, press ArrowUp twice — readout shows 122, unsaved dot stays dark.
  3. Reload. Tempo is back to 120.

Observed against a preview build in Edge; this is how I first noticed it, because a tempo nudge would not arm the autosave debounce I was trying to test.

Suggested fix

Route both through the sequence store so they commit against the active sequence with dirtyKeys: [dirtyKey.sequence(id)], and let the transport mirror follow as it does on hydrate — rather than adding markDirty to the transport store, which would make the mirror authoritative and contradict §7.9.

Worth checking the other plain-set transport fields in the same pass (loopEnabled/loopStartTick/loopEndTick, metronome, count-in, record mode) for which are meant to be per-sequence persisted state and which are genuinely session-only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    autosavebugSomething isn't workingdata-lossCan lose or silently corrupt the user's worksequencerScheduler, timing, recording, and automation (spec 7)storageOPFS, SQLite, repositories, and persistence (spec 9)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions