Animation Synchronization Architecture
Network synchronization for animations (o2p, rotate, scale, color) across multiple connected clients.
Overview
The sync system ensures:
- Late-joining clients see animations at the correct position
- Running animations stay synchronized via periodic drift correction
- Start/stop/pause events are broadcast to all clients
- Leader failover when the leader disconnects
- 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
- First to start animation becomes leader
- Auto-start animations — First client becomes leader after 2-4s random delay
- Late joiners — Always followers (server sends
has_leadermessage) - Failover — If no broadcast received for 15s, follower self-promotes
Leader Responsibilities
- Announce leadership via
leader_announcemessage - Broadcast positions every 3 seconds
- Include: position (
t), raw phase, direction, speed, oscToggle state
Leader Handoff (Visibility Change)
When the leader's tab becomes hidden (minimized, tab switched), it voluntarily steps down:
- Leader detects
visibilitychangeevent withdocument.hidden === true - Calls
stepDownAsLeader():- Sets
_isLeader = false - Clears the sync broadcast interval
- Sends
leader_step_downmessage via WebSocket - Starts failover monitoring (in case no new leader emerges)
- Sets
- Other visible clients receive
oscilla:leader-step-downevent - Visible clients with running animations self-promote after random delay (100-500ms)
- 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
- Leader broadcasts
{ t, phase, dir, speed }for each running animation - Follower syncs speed first (if different)
- Follower compares local
_currentTwith remotet - If drift > 5%, call
setPhase()on the animation loop - 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:
- No restart, no speed resets
- Smooth correction
- Works for o2p, rotate, scale, color
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:
- Server sends
anim_state_snapshotwith all running animations - Client calculates current position from
startedAt,dur,resumeT - Client starts animations at calculated position
- 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:
- Pro: Animations continue running when minimized (for solo use or if handoff fails)
- Pro: Consistent timing, no rAF pauses
- Con: Minimal battery/CPU overhead from AudioContext
- Con: Browser may show "playing audio" indicator
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
- Network latency — Not compensated; assumes low-latency LAN
- Clock drift — Not handled; assumes synchronized system clocks
- Rapid speed changes — May cause brief drift before next sync cycle
- 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