Synchronized sound
This advanced example shows two ways to coordinate generated sound with playhtml. Use an event for a sound that should happen once, or shared data for a generated loop that people need to join, pause, and resume.
If you already have an MP3, WAV, or another audio file, start with Shared audio file.
- Keep this page open in your normal window.
- Copy the private-window link, then paste it into a private or incognito window.
- Interact in either window and watch the other one update.
Copy the code
Both versions use the same shared data and behavior. The live playground runs the Vanilla HTML version.
Save this as index.html, or open it in the playground to
test and change it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Synchronized sound with playhtml</title>
<style>
:root {
color: #3d3833;
background: #f7f3ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; padding: clamp(1rem, 5vw, 4rem); }
main { width: min(760px, 100%); margin: 0 auto; }
h1 { color: #274b9e; font-size: clamp(2.4rem, 8vw, 5rem); line-height: 0.95; margin: 0 0 1rem; }
.intro { max-width: 62ch; font-size: 1.05rem; line-height: 1.5; }
.audio-unlock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 1.5rem 0;
padding: 1rem;
border: 2px solid #3d3833;
background: #ffe95c;
box-shadow: 5px 5px 0 #3d3833;
}
.audio-unlock p { margin: 0; flex: 1 1 260px; }
.panel {
margin-top: 1.25rem;
padding: clamp(1rem, 4vw, 2rem);
border: 2px solid #3d3833;
background: #fffdf8;
box-shadow: 7px 7px 0 #3d3833;
}
.eyebrow { margin: 0; color: #5e5751; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h2 { margin: 0.35rem 0 0.6rem; font-size: 1.45rem; }
.panel-copy { margin: 0 0 1rem; line-height: 1.5; }
button {
appearance: none;
border: 2px solid #3d3833;
border-radius: 0;
background: #fff;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: 750;
padding: 0.7rem 1rem;
box-shadow: 3px 3px 0 #3d3833;
}
button:hover { background: #ffe95c; }
button:active { translate: 2px 2px; box-shadow: 1px 1px 0 #3d3833; }
.primary { background: #274b9e; color: #fff; }
.primary:hover { background: #355fae; }
.controls { display: flex; flex-wrap: wrap; gap: 0.7rem; }
.timeline { margin-top: 1.25rem; }
.timeline-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
.time { font-variant-numeric: tabular-nums; font-weight: 750; }
.track { height: 18px; overflow: hidden; border: 2px solid #3d3833; background: #d9d3ca; }
.progress { width: 0%; height: 100%; background: #274b9e; }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; margin-top: 0.55rem; }
.step { height: 34px; display: grid; place-items: center; border: 2px solid #3d3833; background: #fff; font-size: 0.78rem; font-weight: 800; }
.step[data-active="true"] { background: #ffe95c; }
.local-status { margin: 0.8rem 0 0; min-height: 1.5em; color: #5e5751; }
code { padding: 0.1rem 0.25rem; background: #eae5db; }
</style>
</head>
<body>
<main>
<h1>Synchronized sound</h1>
<p class="intro">
A one-shot cue reaches people who are here now. The loop stores a shared
play/pause timeline, so people who arrive later join at the current beat.
</p>
<section id="sound-transport" can-play>
<div class="audio-unlock">
<button type="button" data-action="enable-audio">Enable audio</button>
<p><strong>Do this in every window.</strong> Browsers require a local click before a page can make sound.</p>
</div>
<div class="panel">
<p class="eyebrow">Transient event</p>
<h2>One-shot cue</h2>
<p class="panel-copy">This chime plays once for everyone currently connected. It is not replayed for late joiners.</p>
<button class="primary" type="button" data-action="send-cue">Send chime</button>
</div>
<div class="panel">
<p class="eyebrow">Persistent shared data</p>
<h2>Four-beat loop</h2>
<p class="panel-copy">Play, pause, and position are shared. Each window generates the tones locally from the same timeline.</p>
<div class="controls">
<button class="primary" type="button" data-action="toggle-transport">Play loop</button>
<button type="button" data-action="restart-transport">Restart</button>
</div>
<div class="timeline">
<div class="timeline-head">
<strong data-transport-state>Paused</strong>
<span class="time" data-position>0.0 / 2.0s</span>
</div>
<div class="track" role="progressbar" aria-label="Loop position" aria-valuemin="0" aria-valuemax="2000" aria-valuenow="0">
<div class="progress" data-progress></div>
</div>
<div class="steps" aria-hidden="true">
<span class="step" data-step="0">C</span>
<span class="step" data-step="1">E</span>
<span class="step" data-step="2">G</span>
<span class="step" data-step="3">C</span>
</div>
</div>
</div>
<p class="local-status" data-audio-status role="status">Audio is off in this window.</p>
</section>
</main>
<script type="module">
import { playhtml } from "playhtml";
const CUE_EVENT = "synchronized-sound-cue";
const STEP_MS = 500;
const LOOP_MS = 2000;
const PATTERN = [261.63, 329.63, 392.0, 523.25];
const soundTransport = document.getElementById("sound-transport");
let audioContext = null;
let scheduledTransportStart = null;
let nextStepToSchedule = 0;
const activePatternGains = new Set();
function loopPositionMs(data, nowMs) {
const elapsedMs = data.isPlaying
? nowMs - data.startedAtMs
: data.positionMs;
return ((elapsedMs % LOOP_MS) + LOOP_MS) % LOOP_MS;
}
function setAudioStatus(message) {
soundTransport.querySelector("[data-audio-status]").textContent = message;
}
function playTone(frequency, startTime, duration, volume, patternTone = false) {
if (!audioContext || audioContext.state !== "running") return;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const start = Math.max(audioContext.currentTime, startTime);
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(volume, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain).connect(audioContext.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
if (patternTone) activePatternGains.add(gain);
oscillator.addEventListener("ended", () => {
activePatternGains.delete(gain);
oscillator.disconnect();
gain.disconnect();
}, { once: true });
}
function silencePattern() {
if (!audioContext) return;
for (const gain of activePatternGains) {
gain.gain.cancelScheduledValues(audioContext.currentTime);
gain.gain.setValueAtTime(0.0001, audioContext.currentTime);
}
activePatternGains.clear();
}
function playCue(payload) {
const frequency = Number(payload && payload.frequency) || 880;
if (!audioContext || audioContext.state !== "running") {
setAudioStatus("A chime arrived. Enable audio to hear future sounds in this window.");
return;
}
playTone(frequency, audioContext.currentTime + 0.01, 0.28, 0.16);
setAudioStatus("Chime received in this window.");
}
async function enableAudio() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
setAudioStatus("Web Audio is not available in this browser.");
return;
}
if (!audioContext) audioContext = new AudioContextClass();
await audioContext.resume();
document.querySelector("[data-action='enable-audio']").textContent = "Audio enabled";
setAudioStatus("Audio is enabled in this window.");
scheduledTransportStart = null;
}
function renderTimeline(element, data, nowMs) {
const positionMs = loopPositionMs(data, nowMs);
const progress = element.querySelector("[data-progress]");
const track = progress.parentElement;
const step = Math.floor(positionMs / STEP_MS) % PATTERN.length;
progress.style.width = String((positionMs / LOOP_MS) * 100) + "%";
track.setAttribute("aria-valuenow", String(Math.round(positionMs)));
element.querySelector("[data-position]").textContent = (positionMs / 1000).toFixed(1) + " / 2.0s";
element.querySelector("[data-transport-state]").textContent = data.isPlaying ? "Playing" : "Paused";
element.querySelector("[data-action='toggle-transport']").textContent = data.isPlaying ? "Pause loop" : "Play loop";
for (const marker of element.querySelectorAll("[data-step]")) {
marker.dataset.active = String(Number(marker.dataset.step) === step);
}
}
function schedulePattern(data, nowMs) {
if (!audioContext || audioContext.state !== "running" || !data.isPlaying) {
if (scheduledTransportStart !== null) silencePattern();
scheduledTransportStart = null;
return;
}
if (scheduledTransportStart !== data.startedAtMs) {
silencePattern();
scheduledTransportStart = data.startedAtMs;
const elapsedMs = Math.max(0, nowMs - data.startedAtMs);
const currentStep = Math.floor(elapsedMs / STEP_MS);
playTone(
PATTERN[currentStep % PATTERN.length],
audioContext.currentTime + 0.01,
0.18,
0.09,
true,
);
nextStepToSchedule = currentStep + 1;
}
const scheduleThroughMs = nowMs + 120;
let beatAtMs = data.startedAtMs + nextStepToSchedule * STEP_MS;
while (beatAtMs <= scheduleThroughMs) {
if (beatAtMs >= nowMs - 20) {
const delaySeconds = Math.max(0, beatAtMs - nowMs) / 1000;
playTone(
PATTERN[nextStepToSchedule % PATTERN.length],
audioContext.currentTime + delaySeconds,
0.18,
0.09,
true,
);
}
nextStepToSchedule += 1;
beatAtMs = data.startedAtMs + nextStepToSchedule * STEP_MS;
}
}
soundTransport.defaultData = {
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
};
soundTransport.updateElement = ({ element, data }) => {
renderTimeline(element, data, Date.now());
};
soundTransport.onClick = (event, { data, setData }) => {
const button = event.target.closest("[data-action]");
if (!button) return;
if (button.dataset.action === "enable-audio") {
void enableAudio();
return;
}
if (button.dataset.action === "send-cue") {
playhtml.dispatchPlayEvent({
type: CUE_EVENT,
eventPayload: { frequency: 880 },
});
return;
}
const nowMs = Date.now();
if (button.dataset.action === "toggle-transport") {
const positionMs = loopPositionMs(data, nowMs);
setData((draft) => {
draft.isPlaying = !data.isPlaying;
draft.positionMs = positionMs;
draft.startedAtMs = nowMs - positionMs;
});
}
if (button.dataset.action === "restart-transport") {
setData((draft) => {
draft.positionMs = 0;
draft.startedAtMs = nowMs;
});
}
};
soundTransport.onMount = ({ getData, getElement }) => {
let animationFrame = 0;
const tick = () => {
const nowMs = Date.now();
const data = getData();
renderTimeline(getElement(), data, nowMs);
schedulePattern(data, nowMs);
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(animationFrame);
silencePattern();
};
};
await playhtml.init({
developmentMode: true,
events: {
[CUE_EVENT]: {
type: CUE_EVENT,
onEvent: playCue,
},
},
});
</script>
</body>
</html>
Start with a React + TypeScript project, install the packages below,
then replace src/App.tsx with the component.
npm install playhtml @playhtml/react // ABOUTME: Broadcasts one-shot tones and shares a generated loop transport.
// ABOUTME: Keeps Web Audio permission and scheduling local to each browser.
import { useCallback, useEffect, useRef, useState } from "react";
import {
PlayProvider,
usePlayContext,
withSharedState,
} from "@playhtml/react";
type TransportData = {
isPlaying: boolean;
startedAtMs: number;
positionMs: number;
};
const CUE_EVENT = "synchronized-sound-cue";
const STEP_MS = 500;
const LOOP_MS = 2000;
const PATTERN = [261.63, 329.63, 392, 523.25];
function loopPositionMs(data: TransportData, nowMs: number): number {
const elapsedMs = data.isPlaying
? nowMs - data.startedAtMs
: data.positionMs;
return ((elapsedMs % LOOP_MS) + LOOP_MS) % LOOP_MS;
}
const SharedSound = withSharedState<TransportData>(
{
id: "sound-transport",
defaultData: {
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
},
},
function SharedSoundView({ data, setData }) {
const {
dispatchPlayEvent,
registerPlayEventListener,
removePlayEventListener,
} = usePlayContext();
const dataRef = useRef(data);
const audioContextRef = useRef<AudioContext | null>(null);
const scheduledStartRef = useRef<number | null>(null);
const nextStepRef = useRef(0);
const patternGainsRef = useRef(new Set<GainNode>());
const [audioEnabled, setAudioEnabled] = useState(false);
const [audioStatus, setAudioStatus] = useState(
"Audio is off in this window.",
);
const [positionMs, setPositionMs] = useState(0);
dataRef.current = data;
const playTone = useCallback((
frequency: number,
startTime: number,
duration: number,
volume: number,
patternTone = false,
) => {
const audioContext = audioContextRef.current;
if (!audioContext || audioContext.state !== "running") return;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const start = Math.max(audioContext.currentTime, startTime);
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(volume, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain).connect(audioContext.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
if (patternTone) patternGainsRef.current.add(gain);
oscillator.addEventListener("ended", () => {
patternGainsRef.current.delete(gain);
oscillator.disconnect();
gain.disconnect();
}, { once: true });
}, []);
const silencePattern = useCallback(() => {
const audioContext = audioContextRef.current;
if (!audioContext) return;
for (const gain of patternGainsRef.current) {
gain.gain.cancelScheduledValues(audioContext.currentTime);
gain.gain.setValueAtTime(0.0001, audioContext.currentTime);
}
patternGainsRef.current.clear();
}, []);
const playCue = useCallback(() => {
const audioContext = audioContextRef.current;
if (!audioContext || audioContext.state !== "running") {
setAudioStatus(
"A chime arrived. Enable audio to hear future sounds in this window.",
);
return;
}
playTone(880, audioContext.currentTime + 0.01, 0.28, 0.16);
setAudioStatus("Chime received in this window.");
}, [playTone]);
useEffect(() => {
const listenerId = registerPlayEventListener(CUE_EVENT, {
onEvent: playCue,
});
return () => removePlayEventListener(CUE_EVENT, listenerId);
}, [
playCue,
registerPlayEventListener,
removePlayEventListener,
]);
const schedulePattern = useCallback((
transport: TransportData,
nowMs: number,
) => {
const audioContext = audioContextRef.current;
if (
!audioContext ||
audioContext.state !== "running" ||
!transport.isPlaying
) {
if (scheduledStartRef.current !== null) silencePattern();
scheduledStartRef.current = null;
return;
}
if (scheduledStartRef.current !== transport.startedAtMs) {
silencePattern();
scheduledStartRef.current = transport.startedAtMs;
const elapsedMs = Math.max(0, nowMs - transport.startedAtMs);
const currentStep = Math.floor(elapsedMs / STEP_MS);
playTone(
PATTERN[currentStep % PATTERN.length],
audioContext.currentTime + 0.01,
0.18,
0.09,
true,
);
nextStepRef.current = currentStep + 1;
}
const scheduleThroughMs = nowMs + 120;
let beatAtMs =
transport.startedAtMs + nextStepRef.current * STEP_MS;
while (beatAtMs <= scheduleThroughMs) {
if (beatAtMs >= nowMs - 20) {
const delaySeconds = Math.max(0, beatAtMs - nowMs) / 1000;
playTone(
PATTERN[nextStepRef.current % PATTERN.length],
audioContext.currentTime + delaySeconds,
0.18,
0.09,
true,
);
}
nextStepRef.current += 1;
beatAtMs =
transport.startedAtMs + nextStepRef.current * STEP_MS;
}
}, [playTone, silencePattern]);
useEffect(() => {
let animationFrame = 0;
const tick = () => {
const nowMs = Date.now();
const transport = dataRef.current;
setPositionMs(loopPositionMs(transport, nowMs));
schedulePattern(transport, nowMs);
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(animationFrame);
silencePattern();
void audioContextRef.current?.close();
};
}, [schedulePattern, silencePattern]);
async function enableAudio() {
const AudioContextClass =
window.AudioContext ||
(window as typeof window & {
webkitAudioContext?: typeof AudioContext;
}).webkitAudioContext;
if (!AudioContextClass) {
setAudioStatus("Web Audio is not available in this browser.");
return;
}
if (!audioContextRef.current) {
audioContextRef.current = new AudioContextClass();
}
await audioContextRef.current.resume();
scheduledStartRef.current = null;
setAudioEnabled(true);
setAudioStatus("Audio is enabled in this window.");
}
function toggleTransport() {
const nowMs = Date.now();
const position = loopPositionMs(data, nowMs);
setData((draft) => {
draft.isPlaying = !data.isPlaying;
draft.positionMs = position;
draft.startedAtMs = nowMs - position;
});
}
function restartTransport() {
const nowMs = Date.now();
setData((draft) => {
draft.positionMs = 0;
draft.startedAtMs = nowMs;
});
}
const activeStep = Math.floor(positionMs / STEP_MS) % PATTERN.length;
return (
<section id="sound-transport">
<div className="audio-unlock">
<button type="button" onClick={() => void enableAudio()}>
{audioEnabled ? "Audio enabled" : "Enable audio"}
</button>
<p>
<strong>Do this in every window.</strong> Browsers require a local
click before a page can make sound.
</p>
</div>
<div className="panel">
<p className="eyebrow">Transient event</p>
<h2>One-shot cue</h2>
<p className="panel-copy">
This chime plays once for everyone currently connected. It is not
replayed for late joiners.
</p>
<button
className="primary"
type="button"
onClick={() => dispatchPlayEvent({ type: CUE_EVENT })}
>
Send chime
</button>
</div>
<div className="panel">
<p className="eyebrow">Persistent shared data</p>
<h2>Four-beat loop</h2>
<p className="panel-copy">
Play, pause, and position are shared. Each window generates the
tones locally from the same timeline.
</p>
<div className="controls">
<button
className="primary"
type="button"
onClick={toggleTransport}
>
{data.isPlaying ? "Pause loop" : "Play loop"}
</button>
<button type="button" onClick={restartTransport}>Restart</button>
</div>
<div className="timeline">
<div className="timeline-head">
<strong>
{data.isPlaying ? "Playing" : "Paused"}
</strong>
<span className="time">
{(positionMs / 1000).toFixed(1)} / 2.0s
</span>
</div>
<div
className="track"
role="progressbar"
aria-label="Loop position"
aria-valuemin={0}
aria-valuemax={LOOP_MS}
aria-valuenow={Math.round(positionMs)}
>
<div
className="progress"
style={{ width: (positionMs / LOOP_MS) * 100 + "%" }}
/>
</div>
<div className="steps" aria-hidden="true">
{["C", "E", "G", "C"].map((note, index) => (
<span
className="step"
data-active={index === activeStep}
key={index}
>
{note}
</span>
))}
</div>
</div>
</div>
<p className="local-status" role="status">{audioStatus}</p>
</section>
);
},
);
export default function App() {
return (
<PlayProvider initOptions={{ developmentMode: true }}>
<main>
<h1>Synchronized sound</h1>
<p className="intro">
A one-shot cue reaches people who are here now. The loop stores a
shared timeline, so late joiners start on the current beat.
</p>
<SharedSound />
</main>
<style>{`
:root {
color: #3d3833;
background: #f7f3ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; }
#root { min-height: 100vh; padding: clamp(1rem, 5vw, 4rem); }
main { width: min(760px, 100%); margin: 0 auto; }
h1 {
margin: 0 0 1rem;
color: #274b9e;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 0.95;
}
.intro { max-width: 62ch; font-size: 1.05rem; line-height: 1.5; }
.audio-unlock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 1.5rem 0;
padding: 1rem;
border: 2px solid #3d3833;
background: #ffe95c;
box-shadow: 5px 5px 0 #3d3833;
}
.audio-unlock p { margin: 0; flex: 1 1 260px; }
.panel {
margin-top: 1.25rem;
padding: clamp(1rem, 4vw, 2rem);
border: 2px solid #3d3833;
background: #fffdf8;
box-shadow: 7px 7px 0 #3d3833;
}
.eyebrow {
margin: 0;
color: #5e5751;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h2 { margin: 0.35rem 0 0.6rem; font-size: 1.45rem; }
.panel-copy { margin: 0 0 1rem; line-height: 1.5; }
button {
appearance: none;
padding: 0.7rem 1rem;
border: 2px solid #3d3833;
border-radius: 0;
background: #fff;
color: inherit;
box-shadow: 3px 3px 0 #3d3833;
cursor: pointer;
font: inherit;
font-weight: 750;
}
button:hover { background: #ffe95c; }
button:active {
translate: 2px 2px;
box-shadow: 1px 1px 0 #3d3833;
}
.primary { background: #274b9e; color: #fff; }
.primary:hover { background: #355fae; }
.controls { display: flex; flex-wrap: wrap; gap: 0.7rem; }
.timeline { margin-top: 1.25rem; }
.timeline-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.time { font-variant-numeric: tabular-nums; font-weight: 750; }
.track {
height: 18px;
overflow: hidden;
border: 2px solid #3d3833;
background: #d9d3ca;
}
.progress { height: 100%; background: #274b9e; }
.steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
margin-top: 0.55rem;
}
.step {
display: grid;
height: 34px;
border: 2px solid #3d3833;
background: #fff;
place-items: center;
font-size: 0.78rem;
font-weight: 800;
}
.step[data-active="true"] { background: #ffe95c; }
.local-status {
min-height: 1.5em;
margin: 0.8rem 0 0;
color: #5e5751;
}
`}</style>
</PlayProvider>
);
} Choose the sound behavior
Section titled “Choose the sound behavior”| Behavior | PlayHTML primitive | Late joiners | Tradeoff |
|---|---|---|---|
| One-shot chime | Event | Do not hear an earlier chime | Good for reactions and effects, but it does not preserve history. |
| Four-beat loop | Shared element data | Join at the current position | Preserves transport state, but each device still generates and schedules its own audio. |
The example uses the Web Audio API to generate every tone in the browser. It does not download or stream an audio file.
Enable audio in every window
Section titled “Enable audio in every window”Browsers block sound until a person interacts with the page. Enable audio creates and resumes a local AudioContext, so every open window must click it once.
The audio-enabled setting is intentionally not shared. One person cannot grant another browser permission to make sound.
Send a one-shot cue
Section titled “Send a one-shot cue”Send chime dispatches a synchronized-sound-cue event. Everyone currently connected receives the event and generates the same short tone locally.
The event is not stored. A person who opens the page after the click does not hear it. See Events for the complete event API and other transient-signal examples.
Share a play/pause timeline
Section titled “Share a play/pause timeline”The loop keeps only the transport state in shared data:
{
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
}
When someone presses Play loop, the example stores a wall-clock start time. Every window calculates the current position from Date.now() - startedAtMs. Someone who joins late therefore starts on the current beat instead of beat one.
The local scheduler looks ahead by a short interval and recalculates each upcoming beat from the shared start time. This corrects ordinary JavaScript timer drift instead of letting timing errors accumulate. It is appropriate for shared music controls and playful sound effects, but it is not sample-accurate audio networking: device clock differences and network delay can still produce a small offset.
Shared writes happen only when a person presses Play, Pause, or Restart. The animation and audio-scheduling loop reads the current data without writing it back, so a remote update cannot start a self-triggering write loop. See Data essentials for the shared-data rules used here.
Test in two windows
Section titled “Test in two windows”Use the live example above so both windows join the room shown in its toolbar.
- Click Enable audio in the normal browser window.
- Click Copy private-window link. Open a private or incognito window and paste the copied URL.
- Click Enable audio in the private window too.
- Click Send chime in either window. Both windows should play one chime. Reloading the other window should not replay it.
- Click Play loop. Both windows should show the same highlighted beat and nearly the same position.
- Click Pause loop in the other window. Both timelines should stop at that shared position.
- Start the loop again, wait a few beats, then reload the private window. After clicking Enable audio again, it should jump to the current position and join the next beat instead of restarting from zero.
If the controls synchronize but one window is silent, audio is still locked in that window. Click Enable audio there again.
Related
Section titled “Related”- Events: broadcast transient signals that are not replayed.
- Data essentials: choose between shared data, presence, local state, and events.
- Custom elements: build a
can-playelement with your own data and event handlers.