Found while verifying #42.
The canonical tempo/swing live on the sequence — persist.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
- Note the tempo (120 default).
- Focus the tempo knob, press ArrowUp twice — readout shows 122, unsaved dot stays dark.
- 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.
Found while verifying #42.
The canonical tempo/swing live on the sequence —
persist.ts:144-146writestempo,swing_amountandswing_division, andhydrate.ts:139-140reads them back into the transport store:useTransportStore.bpmis 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:
So the round trip is broken one way: the knob updates the mirror,
sequence.tempois 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 throughcommit()withdirtyKeys; these two are the outliers.Repro
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 addingmarkDirtyto the transport store, which would make the mirror authoritative and contradict §7.9.Worth checking the other plain-
settransport 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.