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">
<div class="oscilla-score-inner">
<svg id="score">...</svg> <!-- height:100vh, width:auto -->
</div>
</div>
</div>
</div>
<div id="playhead"> <!-- fixed at 50% viewport -->
Critical CSS Requirements
#scoreContainer {
position: fixed;
inset: 0;
overflow: hidden;
}
#scrollStage {
display: block;
width: max-content;
transform-origin: left top;
will-change: transform;
}
#scoreInner {
display: inline-block;
width: max-content;
}
#scoreInner svg {
display: block;
width: auto;
height: 100vh !important;
}
⚠️ CSS Pitfalls
| Problem | Cause | Symptom |
|---|---|---|
SVG position: absolute |
Removes SVG from document flow | Parent containers collapse to 0 width, scale calculations may still work but layout breaks |
transition on transform |
Animates score movement | Visual lag, playhead appears offset during motion |
Missing display: block on SVG |
Inline element whitespace | Unexpected gaps, measurement errors |
max-width constraints |
Limits SVG sizing | Score doesn't scale correctly to viewport |
Debug check: All parent containers should have non-zero width:
const svg = document.querySelector("#scoreInner svg");
const stage = document.getElementById("scrollStage");
const scoreInner = document.getElementById("scoreInner");
// ALL of these should equal the SVG width, NOT zero
console.log("svg:", svg.getBoundingClientRect().width);
console.log("scoreInner:", scoreInner.getBoundingClientRect().width);
console.log("scrollStage:", stage.getBoundingClientRect().width);
Coordinate Extraction — CRITICAL
The Problem
SVG elements can be positioned in many ways:
x,yattributescx,cyattributes (circles)transform="translate(x, y)"- Nested inside transformed
<g>groups shape-insidetext flowing into shapes (Inkscape)- Combinations of all the above
getBBox() and getCTM() do NOT reliably give world coordinates for complex elements like Inkscape text with shape-inside.
✅ Correct Method: Use getBoundingClientRect()
Always extract world coordinates by measuring actual rendered position:
function getWorldX(element, svgElement) {
const svgRect = svgElement.getBoundingClientRect();
const elRect = element.getBoundingClientRect();
// Screen position relative to SVG left edge
const screenX = elRect.x - svgRect.x;
// Convert to world coordinates
const localScale = svgRect.width / window.scoreWidth;
const worldX = screenX / localScale;
return worldX;
}
function getWorldWidth(element, svgElement) {
const svgRect = svgElement.getBoundingClientRect();
const elRect = element.getBoundingClientRect();
const localScale = svgRect.width / window.scoreWidth;
return elRect.width / localScale;
}
❌ Incorrect Methods (DO NOT USE)
// WRONG: getBBox returns local coordinates, CTM doesn't account for all transforms
const bbox = element.getBBox();
const matrix = element.getCTM();
let x = bbox.x + matrix.e; // UNRELIABLE
// WRONG: Parsing transform attribute misses shape-inside offsets
const transform = element.getAttribute("transform");
const match = transform.match(/translate\(([\d.]+)/); // INCOMPLETE
// WRONG: x attribute may not exist (text uses transform instead)
const x = element.x?.baseVal?.value; // MAY BE UNDEFINED
Why This Matters
Inkscape often creates text elements like:
<text id="rehearsal_A"
transform="translate(1764.35, 557.56)"
style="shape-inside:url(#rect122346)">
A
</text>
- The
transformsays X = 1764 - But
shape-insidereferences a rect that adds more offset - Actual rendered X = 2032 (268 units different!)
- This error compounds: elements further right have larger errors
Core Functions
scrollToPlayheadVisual() — oscillaTransport.js
Converts world playheadX to screen position 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 viewportWidth = container.clientWidth;
const halfViewport = viewportWidth / 2;
let translateX = halfViewport - worldPx;
// Clamp at edges...
stage.style.transform = `translate3d(${translateX}px, 0, 0)`;
}
extractScoreElements() — oscillaScoreSetup.js
Extracts cue and rehearsal mark positions. Must use getBoundingClientRect():
export const extractScoreElements = (svgElement) => {
const svgRect = svgElement.getBoundingClientRect();
const localScale = svgRect.width / window.scoreWidth;
const elements = svgElement.querySelectorAll(
"[id^='rehearsal_'], [id^='cue'], [id^='anchor-']"
);
elements.forEach((element) => {
// CORRECT: Use getBoundingClientRect for true position
const elRect = element.getBoundingClientRect();
const screenX = elRect.x - svgRect.x;
const absoluteX = screenX / localScale;
const worldWidth = elRect.width / localScale;
if (element.id.startsWith("rehearsal_")) {
const id = element.id.replace("rehearsal_", "");
newRehearsalMarks[id] = { x: absoluteX };
} else if (element.id.startsWith("cue")) {
newCues.push({ id: element.id, x: absoluteX, width: worldWidth });
}
});
};
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() — world→screen conversion, applies transform |
oscillaScoreSetup.js |
extractScoreElements() — extracts cue/rehearsal world positions |
oscillaSystemRAF.js |
Animation loop, drift correction |
app.js |
WebSocket sync handler |
server.js |
Broadcasts playheadX and scoreWidth to all clients |
styles.css |
SVG sizing (height: 100vh), container layout |
Debugging
Quick Health Check
const svg = document.querySelector("#scoreInner svg");
const viewBox = svg.getAttribute("viewBox").split(" ").map(Number);
console.log("=== Sync Health Check ===");
console.log("ViewBox width:", viewBox[2]);
console.log("window.scoreWidth:", window.scoreWidth);
console.log("Match:", viewBox[2] === window.scoreWidth ? "✅" : "❌");
console.log("\nRendered width:", svg.getBoundingClientRect().width);
console.log("localScale:", window.localScale);
// Expected width from height
const expectedWidth = window.innerHeight * (viewBox[2] / viewBox[3]);
const actualWidth = svg.getBoundingClientRect().width;
console.log("Expected width:", expectedWidth);
console.log("Actual width:", actualWidth);
console.log("Match:", Math.abs(expectedWidth - actualWidth) < 1 ? "✅" : "❌");
Test Rehearsal Mark Alignment
function testRehearsalAlignment(markId) {
const cueEl = document.getElementById('rehearsal_' + markId);
const svg = document.querySelector("#scoreInner svg");
const svgRect = svg.getBoundingClientRect();
const cueRect = cueEl.getBoundingClientRect();
// Get true world position
const screenX = cueRect.x - svgRect.x;
const actualWorldX = screenX / window.localScale;
console.log(`=== Testing rehearsal_${markId} ===`);
console.log("Actual world X:", actualWorldX);
// Jump to it
window.playheadX = actualWorldX;
window.scrollToPlayheadVisual();
// Verify alignment
setTimeout(() => {
const newCueRect = cueEl.getBoundingClientRect();
const playheadEl = document.getElementById('playhead');
const playheadRect = playheadEl.getBoundingClientRect();
const diff = newCueRect.x - playheadRect.x;
console.log("Alignment error (px):", diff);
console.log("Aligned:", Math.abs(diff) < 5 ? "✅" : "❌");
}, 100);
}
// Usage: testRehearsalAlignment('A');
Common Issues
| Symptom | Likely Cause | Check |
|---|---|---|
| Playhead lands behind marks, error increases with position | Wrong coordinate extraction method | Using getBBox()/getCTM() instead of getBoundingClientRect() |
| Works on one device, broken on others | CSS affecting SVG sizing | Check SVG position is not absolute |
| Parent containers have 0 width | SVG removed from document flow | Check for position: absolute/fixed on SVG |
| Score jumps/jitters during playback | Transform transition CSS | Remove any transition on #scrollStage or #score |
scoreWidth doesn't match viewBox |
Server caching old value | Send reset_project_state when loading new score |
Interaction Layer Elements
Any element that can be shared across clients or positioned on the score must use world coordinates. This includes:
Elements That Need World Coordinates
| Element | Properties | Notes |
|---|---|---|
| Markers | placement.x, labelY, fontSize |
Drop markers, structural waypoints |
| Annotations | placement.x, placement.y, style.fontSize, extent |
Text pins, triggers |
| Cues | x, width |
Extracted from SVG |
| Rehearsal marks | x |
Extracted from SVG |
Conversion Pattern
When creating/editing interaction elements:
// CREATE: Convert click position to world coordinates
function getClickPlacement(evt, innerRect) {
const localScale = window.localScale || 1;
const screenX = evt.clientX - innerRect.left;
const screenY = evt.clientY - innerRect.top;
return {
x: screenX / localScale, // Store world coords
y: screenY / localScale
};
}
// RENDER: Convert world coordinates to screen pixels
function positionElement(el, placement) {
const localScale = window.localScale || 1;
el.style.left = `${placement.x * localScale}px`;
el.style.top = `${placement.y * localScale}px`;
}
// DRAG: Convert screen delta to world delta
function onDrag(dx, dy, baseWorldX, baseWorldY) {
const localScale = window.localScale || 1;
return {
x: baseWorldX + (dx / localScale),
y: baseWorldY + (dy / localScale)
};
}
// FONT SIZE: Scale for consistent visual size across clients
function getScaledFontSize(baseFontSize) {
const localScale = window.localScale || 1;
return baseFontSize * localScale;
}
Why Font Size Needs Scaling
If a marker label is set to 24px on a large screen and synced to a small screen:
- Without scaling: Label is 24px on both → takes up more of the score on smaller screen
- With scaling: Label is 24px × localScale → same proportion of score on all screens
The label's edges must align with the same score content on all clients.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Storing screen pixels directly | Elements appear in wrong position on other clients | Divide by localScale before storing |
| Not scaling font size | Text edges don't align across clients | Multiply by localScale at render |
| Forgetting to scale extent/width | Trigger duration bars wrong length | Apply same world↔screen conversion |
Using offsetWidth directly |
Width calculation wrong | Divide by localScale to get world width |
Coordinate System Summary
WORLD SPACE (viewBox units) SCREEN SPACE (pixels)
───────────────────────────── ─────────────────────────
scoreWidth = 40000 localRenderedWidth = 37539px
playheadX = 20000 screenX = 20000 × 0.938 = 18769px
localScale = localRenderedWidth / scoreWidth
= 37539 / 40000
= 0.938
World → Screen: screenX = worldX × localScale
Screen → World: worldX = screenX / localScale
Golden Rule: Always store and sync positions in world coordinates. Convert to screen coordinates only at render time.
Tip: use ← → or ↑ ↓ to navigate the docs