Animation Synchronization Architecture

Network synchronization for animations (o2p, rotate, scale, color) across multiple connected clients.


Overview

The sync system ensures:

  1. Late-joining clients see animations at the correct position
  2. Running animations stay synchronized via periodic drift correction
  3. Start/stop/pause events are broadcast to all clients
  4. Leader failover when the leader disconnects
  5. Speed sync keeps animations at the same rate

Key Files

File Role
animSync.js Leader election, position broadcasts, drift correction
animShared.js Armed state sync, visibility sync, remote handling
oscillaObserver.js Viewport-based pause/resume (respects sync leader)
socket.js WebSocket message routing, sync mode initialization
server.js Animation state persistence, leader tracking, message relay

Configuration

Constants in animSync.js:

const SYNC_INTERVAL_MS = 3000;   // 3 seconds between broadcasts
const DRIFT_THRESHOLD = 0.05;    // 5% drift triggers correction
const FAILOVER_TIMEOUT_MS = 15000; // 15s without broadcast = leader gone

Leader/Follower Architecture

One client acts as leader and broadcasts positions. Others are followers.

Leader Election

  1. First to start animation becomes leader
  2. Auto-start animations — First client becomes leader after 2-4s random delay
  3. Late joiners — Always followers (server sends has_leader message)
  4. Failover — If no broadcast received for 15s, follower self-promotes

Leader Responsibilities

Leader Handoff (Visibility Change)

When the leader's tab becomes hidden (minimized, tab switched), it voluntarily steps down:

  1. Leader detects visibilitychange event with document.hidden === true
  2. Calls stepDownAsLeader():
    • Sets _isLeader = false
    • Clears the sync broadcast interval
    • Sends leader_step_down message via WebSocket
    • Starts failover monitoring (in case no new leader emerges)
  3. Other visible clients receive oscilla:leader-step-down event
  4. Visible clients with running animations self-promote after random delay (100-500ms)
  5. Random delay prevents multiple clients claiming leadership simultaneously
// In animSync.js
document.addEventListener("visibilitychange", () => {
    if (document.hidden && _isLeader) {
        stepDownAsLeader();
    }
});

Why this matters: Browsers throttle or pause requestAnimationFrame for hidden tabs. A hidden leader would broadcast stale positions, causing followers to drift or jump.


Message Types

Message Direction Purpose
arm_sync Client → Server → Clients Start/stop/pause state changes
anim_pos_sync Leader → Server → Followers Periodic position broadcasts
anim_state_snapshot Server → Late Joiner Current animation states
leader_announce Client → Server Declare leadership
has_leader Server → Client Tell new client a leader exists
leader_step_down Client → Server → Clients Leader voluntarily yields (e.g., tab hidden)
visibility_sync Client → Server → Clients Element show/hide (respects viewSyncMode)

Drift Correction

How It Works

  1. Leader broadcasts { t, phase, dir, speed } for each running animation
  2. Follower syncs speed first (if different)
  3. Follower compares local _currentT with remote t
  4. If drift > 5%, call setPhase() on the animation loop
  5. Position updates on next animation frame

Speed Sync

Speed is synced before position correction. Without matching speeds, position corrections are useless—animations immediately drift apart again.

if (posData.speed != null && cfg.speedMultiplier !== posData.speed) {
    cfg.speedMultiplier = posData.speed;
}

setPhase vs Restart

Old method: Restart animation at new position (caused speed binding resets)

New method: Call setPhase(phase) directly on animation loop:

Raw Phase vs globalT

For o2p with custom start/end positions:

// globalT is mapped: globalT = start + phase * (end - start)
// Raw phase is 0-1 regardless of start/end
cfg._currentT = globalT;  // For display/OSC
cfg._phase = phase;       // For sync (used by setPhase)

Both are broadcast; phase is used for setPhase(), t for drift calculation.


Animation-Specific Notes

rotate.js

Rotate tracks currentAngle separately from loop phase. The setPhase wrapper converts:

animLoop.setPhase = (p) => {
    originalSetPhase(p);
    currentAngle = p * 360;
    animEl.style.transform = `rotate(${currentAngle}deg)`;
};

o2p.js

Uses el._o2pAnim for the animation loop. Tracks both _currentT (globalT) and _phase (raw).

scale.js / color.js

Use phase directly in onTick, so standard setPhase works.


Visibility and Observer

Leader Hidden Elements

When the leader hides an element locally, the animation must keep running for sync broadcasts. The oscillaObserver.js checks isSyncSource():

function isSyncSource(entry) {
    if (!isLeader()) return false;
    if (cfg?.sync === false || cfg?.sync === 0) return false;
    return true;
}

Sync-enabled animations on the leader are never paused by the observer.

viewSyncMode

Controls whether visibility changes sync between clients. Set in Preferences → Settings:

Mode Behavior
local Visibility changes stay local (default for performers)
broadcast Send changes to all clients (conductor mode)
receive Receive others' changes but don't send (follower mode)
// In animShared.js
if (window.viewSyncMode === "local" || window.viewSyncMode === "receive") return;

Late-Join Synchronization

When a new client connects:

  1. Server sends anim_state_snapshot with all running animations
  2. Client calculates current position from startedAt, dur, resumeT
  3. Client starts animations at calculated position
  4. Client stays as follower

Server-Side State

// Leader tracking
let syncLeaderWs = null;

// Animation state for late-join
animStateMap[uid] = {
    state: "running" | "paused",
    resumeT: 0.0,
    dur: 2.0,
    startedAt: Date.now(),
    lastKnownT: 0.5
};

Opting Out

Use sync:0 to disable synchronization:

rotate(dur:2, sync:0)
o2p(path:#myPath, dur:5, sync:0)

Debugging

Filter console with [SYNC]:

[SYNC] This client is now LEADER
[SYNC] LEADER broadcast: 2 animations o2p_a:0.456, rot_b:0.789
[SYNC] FOLLOWER: o2p_a speed 0.50 -> 1.25
[SYNC] FOLLOWER: o2p_a local=0.234 remote=0.456 drift=22.2%
[SYNC] FOLLOWER: o2p_a CORRECTING drift 22.2%
[SYNC] FOLLOWER: o2p_a phase adjusted to 0.456

Preventing rAF Throttling (Silent Audio Keepalive)

Browsers throttle requestAnimationFrame callbacks when tabs are hidden (minimized or in background). This breaks animation loops. Two mechanisms address this:

1. Leader Handoff (Primary)

When a tab becomes hidden, the leader hands off to a visible client (see above). This ensures the sync source is always a tab with active rAF.

2. Silent Audio Keepalive (Secondary)

Browsers don't throttle tabs that are "playing audio." A silent oscillator keeps the AudioContext active without producing sound:

// In audioShared.js
function startSilentKeepalive() {
    const osc = sharedAudioCtx.createOscillator();
    const gain = sharedAudioCtx.createGain();
    gain.gain.value = 0;  // Silent
    osc.connect(gain);
    gain.connect(sharedAudioCtx.destination);
    osc.start();
}

Tradeoffs:

The keepalive starts automatically when the AudioContext is first unlocked (user interaction). It's a defensive measure—leader handoff is the primary solution for multi-client sync.


Known Limitations

  1. Network latency — Not compensated; assumes low-latency LAN
  2. Clock drift — Not handled; assumes synchronized system clocks
  3. Rapid speed changes — May cause brief drift before next sync cycle
  4. Race conditions on join/reload — Occasionally a client may jump to start or cause leader to jump forward (hard to reproduce, likely timing-dependent)

Tip: use ← → or ↑ ↓ to navigate the docs