Oscilla Multi-Client Visual Synchronization

Overview

Oscilla synchronizes a scrolling SVG score across multiple clients so the playhead is always over the same score content regardless of screen size or aspect ratio.

Core principle: Sync only world coordinates (playheadX). Each client computes its own pixel positions locally.


Key Terms

Term Definition
scoreWidth SVG width in viewBox units (world space). Shared by all clients.
playheadX Playback position in world units. This is what gets synced.
localRenderedWidth Actual pixel width of SVG on this client
localScale localRenderedWidth / scoreWidth — computed per client

How It Works

Server broadcasts:  playheadX = 10000  (world units)

iPad (1024px tall viewport):
  SVG renders 8000px wide → localScale = 0.195
  screenX = 10000 × 0.195 = 1950px

Desktop (1200px tall viewport):
  SVG renders 9400px wide → localScale = 0.229
  screenX = 10000 × 0.229 = 2290px

Both show the SAME score content under the playhead.

DOM Structure

<div id="scoreContainer">      <!-- overflow:hidden, no native scroll -->
  <div id="scrollStage">       <!-- translated via transform -->
    <div id="scoreInner">
      <svg>...</svg>           <!-- height:100vh, width:auto -->
    </div>
  </div>
</div>
<div id="playhead">            <!-- fixed at 50% viewport -->

Core Functions

scrollToPlayheadVisual() — oscillaTransport.js

The main sync function. Measures local SVG width and applies transform:

export function scrollToPlayheadVisual() {
  const container = window.scoreContainer;
  const stage = document.getElementById("scrollStage");
  const svg = stage?.querySelector("svg");
  if (!container || !stage || !svg || !window.scoreWidth) return;

  container.scrollLeft = 0;
  container.scrollTop = 0;

  const localRenderedWidth = svg.getBoundingClientRect().width;
  const localScale = localRenderedWidth / window.scoreWidth;
  
  window.localScale = localScale;
  window.localRenderedWidth = localRenderedWidth;

  const worldPx = window.playheadX * localScale;
  const pad = container.clientWidth / 2;
  const translateX = pad - worldPx;

  stage.style.transform = `translate3d(${translateX}px, 0, 0)`;
}

Sync Message Handler — app.js

Receives server state, stores world values, triggers visual update:

case "sync": {
  window.scoreWidth = state.scoreWidth;
  window.duration = state.duration;
  window.elapsedTime = state.elapsedTime;
  window.isPlaying = state.isPlaying;
  window.serverSyncPlayheadX = state.playheadX;
  
  scrollToPlayheadVisual();
}

Animation Loop — app.js

Advances playhead locally, applies drift correction, updates visual:

window.animate = (currentTime) => {
  // Advance playhead in world units
  window.playheadX += (dt * speedMultiplier / duration) * scoreWidth;
  
  // Drift correction against server
  if (window.serverSyncPlayheadX != null) {
    const drift = window.serverSyncPlayheadX - window.playheadX;
    if (Math.abs(drift) > scoreWidth * 0.05) {
      window.playheadX = window.serverSyncPlayheadX; // snap
    } else {
      window.playheadX += drift * 1.3 * dt; // smooth
    }
  }
  
  scrollToPlayheadVisual();
  requestAnimationFrame(window.animate);
};

Critical CSS

#scoreContainer {
  position: fixed;
  inset: 0;
  overflow: hidden;
}

#scrollStage {
  width: max-content;
  transform-origin: left top;
  will-change: transform;
}

#scoreInner {
  display: inline-block;
  width: max-content;
}

#scoreInner svg {
  width: auto;
  height: 100vh !important;
}

Server State — server.js

let sharedState = {
  playheadX: 0,           // world units — the key sync value
  scoreWidth: null,       // world units — from SVG viewBox
  elapsedTime: 0,
  duration: null,
  isPlaying: false,
  speedMultiplier: 1.0,
  startTimestamp: null
};

Server broadcasts state at ~4Hz. Clients receive playheadX and convert to local pixels.


File Reference

File Role
oscillaTransport.js scrollToPlayheadVisual() — computes local scale, applies transform
app.js Sync handler, animation loop, drift correction
server.js Broadcasts playheadX and scoreWidth to all clients
styles.css SVG sizing (height: 100vh), transform properties

Debugging

// Run in console on each client:
console.log("scoreWidth:", window.scoreWidth);           // should match
console.log("playheadX:", window.playheadX);             // should be close
console.log("localScale:", window.localScale);           // will differ (OK)
console.log("localRenderedWidth:", window.localRenderedWidth); // will differ (OK)
Symptom Check
Playheads offset scoreWidth same on all clients?
Transform not applied localRenderedWidth > 0?
Playhead jumps serverSyncPlayheadX valid?

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