← All docs
Ideas

Clocks — shared origins, tap tempo, external sync

> Track B. Read after 04-sequence-engine.md.

Two distinct problems that look similar:

1. Shared clock. Multiple sequences should share a startTime so they're in phase. ("Layer the rainbow over the strobe.") TagTable does this — app.lightingClockMode === "shared" with a single lightingMasterClockOrigin.
2. Tempo sync. A sequence's playhead should follow a beat, not wall-clock time. ("Hit the chase on every quarter note.")

We ship the first in v0.6.0, the second as an opt-in for v0.6.1 if there's bandwidth.

Shared clock (v0.6.0)

A handful of named clocks. Each clock has an origin timestamp. Layers using clock X use origin_X as their startTime instead of performance.now().

interface SharedClock {
  id: string;
  name: string;
  origin: number;          // performance.now() at last reset
  bpm?: number;            // optional, for visual beat blink — not used by sequence playback in v0.6.0
}

Default clock: "main". Users can create more ("intro", "bridge") but most won't.

Each pad / sequence opts in:

  • clockId: null — own clock, like every layer today, starts on press.
  • clockId: "main" — uses the named clock; pressing the pad joins the layer at the clock's current beat, NOT from t=0.

UI: a small "Clock" picker in the pad editor. Topbar gets a tap-tempo button + reset for each clock.

Reset behaviour

Resetting a clock restarts every active layer that uses it. TagTable's lightingClockReset (app.js:2945) does this: layers' nextCueIndex = 0, elapsed = 0, fadeState = null.

Why this matters

Without shared clocks, two sequences started 100 ms apart drift visibly within seconds — chase-2 lags chase-1 by 100 ms forever. With them, both layers re-derive their playhead from the same origin every frame.

Tap tempo (v0.6.0)

User clicks a button (or fires an OSC event / MIDI note bound to "tap") four times in rhythm. The rolling average of the last 4 intervals becomes BPM. Stored on the clock as clock.bpm.

function tap(clockId: string): void {
  const now = performance.now();
  const taps = tapHistory.get(clockId) ?? [];
  taps.push(now);
  if (taps.length > 4) taps.shift();
  if (taps.length >= 2) {
    const intervals = taps.slice(1).map((t, i) => t - taps[i]!);
    const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
    const bpm = 60000 / avg;
    if (bpm > 30 && bpm < 300) {
      const clock = clocks.get(clockId)!;
      clock.bpm = Math.round(bpm  10) / 10;
    }
  }
  tapHistory.set(clockId, taps);
}

The BPM does not drive sequence playback in v0.6.0. It's a number we display + use for visual beat indicators on pads (a small accent flash on the pad swatch on every quarter note). Users who want beat-locked sequences set the sequence duration to a beat-aligned value manually. Beat-locked playback is v0.6.1.

External clock sources (v0.6.1, optional)

Three potential sources, ranked by likely demand:

1. MIDI Clock. 24 PPQN messages on midiInput. Easy to receive; midiBus already handles MIDI for inputs. Adapter: every 24th MIDI clock message resets the clock origin to now - (24 (60000/bpm)/24). Hand-wavy but works.
2. OSC /clock/bpm. Listen for an OSC packet at a configured address with a single float arg. Set the clock's BPM directly.
3. MIDI tap. Wire a MIDI note to the tap-tempo handler.

All three live behind the existing input bus. Implementation is small adapter functions wired during useDmxClockSync() (a new hook that subscribes to whatever input source the clock is configured to use).

What you'll write

src/features/sequences/
├── clocks.ts            # SharedClock map + tap tempo
├── useClockSync.ts      # external clock source plumbing
└── ClockBar.tsx         # topbar widget showing BPM + tap button + reset

PadEditorModal.tsx adds the clock picker. ClockBar.tsx slots into Topbar between the existing buttons.

Don't do

  • Don't try to phase-lock to live audio. Out of scope.
  • Don't ship "MIDI clock follow" without testing on a real MIDI clock source. The 24 PPQN average jitter is real and you need a low-pass filter to keep BPM stable.
  • Don't rely on setInterval for clock math. performance.now() is monotonic in the renderer, use it.