Party night, on the beat
A small stage, a crowd, and an 85-second house set at 124 beats a minute. Every kick sends a ring
out from the booth, every clap throws stars from a speaker, the hats spark on the lighting rig, and
each drop fires two confetti cannons, fanned over 80° with spread and aimed in with angle.
Press play, with sound on.
import { each, Motly, rand } from '@motly/gsap';import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
// ---- The score: one list of events that both the synth and the show read. ----
const bpm = 124;const beat = 60 / bpm;const bar = beat * 4;const sixteenth = beat / 4;const sections = [ { name: 'Warm-up', bars: 8 }, { name: 'Build', bars: 8 }, { name: 'Drop', bars: 16 }, { name: 'Break', bars: 4 }, { name: 'Last drop', bars: 8 },];// One chord a bar, round and round: A minor, F, C, G, each with its color.const chords = [ { root: 45, notes: [57, 60, 64], color: '#ff4d8d' }, { root: 41, notes: [57, 60, 65], color: '#8b5cf6' }, { root: 48, notes: [55, 60, 64], color: '#22d3ee' }, { root: 43, notes: [55, 59, 62], color: '#fbbf24' },];
const score = [];let n = 0;let at = 0;for (const section of sections) { section.start = at; const drop = section.name.endsWith('rop'); if (drop) score.push({ type: 'crash', t: at }); if (section.name === 'Build') score.push({ type: 'sweep', t: at, duration: section.bars * bar }); for (let b = 0; b < section.bars; b++, n++) { const t0 = at + b * bar; const chord = chords[n % chords.length]; score.push({ type: 'bar', t: t0, n, chord, section }); if (section.name === 'Warm-up' || section.name === 'Break') { score.push({ type: 'pad', t: t0, notes: chord.notes }); } for (let q = 0; q < 4; q++) { const t = t0 + q * beat; if (section.name !== 'Break') score.push({ type: 'kick', t }); if (section.name !== 'Warm-up' && section.name !== 'Break' && q % 2 === 1) { score.push({ type: 'clap', t, side: q === 1 ? 'left' : 'right' }); } score.push({ type: 'hat', t: t + beat / 2, open: drop }); if (drop) { score.push({ type: 'hat', t: t + sixteenth, open: false }); score.push({ type: 'hat', t: t + sixteenth * 3, open: false }); score.push({ type: 'bass', t: t + beat / 2, root: chord.root }); if (q % 2 === 1) score.push({ type: 'stab', t: t + beat / 2, notes: chord.notes, chord }); } } // The last four bars of the build roll in 8ths, then 16ths, louder and bigger each step. if (section.name === 'Build' && b >= section.bars - 4) { const step = b >= section.bars - 2 ? sixteenth : beat / 2; for (let t = t0; t < t0 + bar - 1e-6; t += step) { const level = (t - (at + (section.bars - 4) * bar)) / (4 * bar); score.push({ type: 'roll', t, level }); } } } at += section.bars * bar;}score.push({ type: 'finale', t: at });score.sort((a, b) => a.t - b.t);const length = at + bar;
// ---- The synth: each event made from oscillators and noise, a moment before it is due. ----
const context = new AudioContext();const limiter = context.createDynamicsCompressor();limiter.connect(context.destination);const noise = context.createBuffer(1, context.sampleRate, context.sampleRate);const samples = noise.getChannelData(0);for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1;// Each play gets its own bus; pausing disconnects it, silencing whatever it had queued.let bus;
const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);const envelope = (t, peak, decay) => { const gain = context.createGain(); gain.gain.setValueAtTime(0.0001, t); gain.gain.exponentialRampToValueAtTime(peak, t + 0.005); gain.gain.exponentialRampToValueAtTime(0.0001, t + decay); gain.connect(bus); return gain;};const filter = (type, frequency, into, q = 1) => { const node = context.createBiquadFilter(); node.type = type; node.frequency.value = frequency; node.Q.value = q; node.connect(into); return node;};const tone = (type, frequency, t, duration, into) => { const osc = context.createOscillator(); osc.type = type; osc.frequency.setValueAtTime(frequency, t); osc.connect(into); osc.start(t); osc.stop(t + duration); return osc;};const hiss = (t, duration, into) => { const source = context.createBufferSource(); source.buffer = noise; source.loop = true; source.connect(into); source.start(t); source.stop(t + duration);};
// Each instrument takes the event and `t`, the audio clock's time it is due at.const instruments = { kick: (_, t) => { const osc = tone('sine', 150, t, 0.5, envelope(t, 1, 0.45)); osc.frequency.exponentialRampToValueAtTime(45, t + 0.12); }, hat: ({ open }, t) => hiss(t, 0.3, filter('highpass', 7000, envelope(t, 0.16, open ? 0.2 : 0.05))), clap: (_, t) => hiss(t, 0.3, filter('bandpass', 1300, envelope(t, 0.7, 0.2), 1.2)), roll: ({ level }, t) => hiss(t, 0.15, filter('bandpass', 1800, envelope(t, 0.1 + 0.5 * level, 0.09))), bass: ({ root }, t) => tone('sawtooth', hz(root), t, beat / 2, filter('lowpass', 420, envelope(t, 0.45, beat * 0.45))), stab: ({ notes }, t) => { const out = filter('lowpass', 2400, envelope(t, 0.2, 0.3)); for (const note of notes) { tone('sawtooth', hz(note) * 1.004, t, 0.35, out); tone('square', hz(note), t, 0.35, out); } }, pad: ({ notes }, t) => { const gain = context.createGain(); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(0.05, t + 0.5); gain.gain.linearRampToValueAtTime(0.0001, t + bar); gain.connect(bus); for (const note of notes) tone('triangle', hz(note), t, bar, gain); }, sweep: ({ duration }, t) => { const gain = context.createGain(); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(0.12, t + duration); gain.connect(bus); const rise = filter('highpass', 300, gain); rise.frequency.setValueAtTime(300, t); rise.frequency.exponentialRampToValueAtTime(8000, t + duration); hiss(t, duration, rise); }, crash: (_, t) => hiss(t, 2.5, filter('highpass', 4500, envelope(t, 0.3, 2.2))), finale: (_, t) => hiss(t, 2.5, filter('highpass', 4500, envelope(t, 0.35, 2.4))),};
// ---- The show: a paused GSAP timeline with a burst for each event, placed at its time. ----
const stage = document.querySelector('.stage');const fixtures = gsap.utils.toArray('.fixture');const unit = Math.min(stage.clientWidth, stage.clientHeight) / 600;const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;const u = (value) => value * unit;
const ring = (color) => ({ kind: 'circle', radius: [u(20), u(170)], fill: 'none', stroke: color, strokeWidth: [u(10), 0], opacity: [0.8, 0], duration: 0.5, restAt: 0.2,});const clap = (color) => ({ kind: 'burst', count: 10, radius: [u(10), u(70)], restAt: 0.3, children: { kind: 'star', points: 4, innerRadius: 0.35, radius: [rand(u(4), u(9)), 0], fill: each([color, '#ffffff']), duration: 0.45, },});const spark = { kind: 'burst', count: 6, radius: [0, u(22)], restAt: 0.3, children: { kind: 'circle', radius: [rand(u(1), u(2.5)), 0], fill: '#ffffff', duration: 0.25 },};const stab = (color) => ({ kind: 'burst', count: 8, radius: [u(40), u(130)], easing: 'ease-out', restAt: 0.3, children: { kind: 'polygon', points: each([3, 4]), radius: [rand(u(5), u(10)), 0], angle: [0, rand(-90, 90)], fill: color, duration: 0.4, },});// The build's roll, from a whisper to a wall: more Children, further out, bigger, each step.const riser = (level) => ({ kind: 'burst', count: 6 + Math.round(level * 18), radius: [0, u(40 + level * 140)], restAt: 0.4, children: { kind: 'swirl', size: 25, direction: each([1, -1]), child: { kind: 'circle', radius: [u(2 + level * 3), 0], fill: '#ffffff', duration: 0.35 }, },});// Confetti in clumps: 20 bursts of 8, so the pieces scatter instead of forming one ring. A cannon// fans its clumps over 80°, aimed with `angle`; left out, they ring the Origin.const confetti = (color, aim) => { // Each piece holds its size for most of its fall, then shrinks away. const piece = { fill: each([color, '#ffffff', '#fbbf24', '#22d3ee']), angle: [0, rand(-360, 360)], scale: [1, 1, 0], duration: rand(1.8, 2.6), }; return { kind: 'burst', count: 20, ...(aim === undefined ? {} : { angle: aim, spread: 80 }), radius: [0, u(260)], easing: 'ease-out', restAt: 0.3, children: { kind: 'burst', count: 8, radius: [0, rand(u(30), u(150))], delay: rand(0, 0.15), children: each([ { kind: 'star', points: 5, radius: rand(u(4), u(8)), ...piece }, { kind: 'polygon', points: 4, radius: rand(u(3), u(6)), ...piece }, { kind: 'circle', radius: rand(u(2), u(4)), ...piece }, ]), }, };};const shell = (color) => ({ kind: 'burst', count: 12, radius: [0, u(90)], restAt: 0.4, children: { kind: 'burst', count: 12, radius: [0, rand(u(40), u(80))], children: { kind: 'circle', radius: [rand(u(1.5), u(3)), 0], fill: each([color, '#ffffff']), duration: rand(0.9, 1.4), }, },});const twinkle = { kind: 'burst', count: 5, radius: [0, rand(u(20), u(60))], restAt: 0.5, children: { kind: 'star', points: 4, innerRadius: 0.3, radius: [0, rand(u(3), u(6)), 0], fill: '#ffffff', delay: rand(0, 0.6), duration: rand(1.2, 2), },};
const show = gsap.timeline({ paused: true });const inStage = { container: stage };const sky = () => ({ x: stage.clientWidth / 2, y: stage.clientHeight * 0.3 });let seed = 0;const fire = (target, spec, t, vars = {}) => show.burst(target, { spec, ...inStage, seed: seed++, ...vars }, t);
for (const event of score) { const { t } = event; if (event.type === 'bar') { show.set(stage, { '--tone': event.chord.color }, t); const swing = event.n % 2 ? 22 : -22; const drop = event.section.name.endsWith('rop'); if (!calm && event.section.name !== 'Break') { show.to( '.beam', { rotate: (i) => (i % 2 ? swing : -swing) * (drop ? 1 : 0.4), duration: bar, ease: 'sine.inOut', }, t, ); } if (calm && t === event.section.start) { // Under reduced motion: one still burst for the whole section, and nothing on the beat. const spec = { ...stab(event.chord.color), count: 12 }; fire('.booth', spec, t, { duration: event.section.bars * bar }); } if (!calm && event.section.name === 'Break') fire(fixtures[event.n % 6], twinkle, t); continue; } if (calm) continue; if (event.type === 'kick') show.shape('.booth', { spec: ring(chordAt(t).color), ...inStage }, t); if (event.type === 'clap') fire(`.speaker.${event.side}`, clap(chordAt(t).color), t); if (event.type === 'hat') fire(fixtures[Math.round(t / sixteenth) % 6], spark, t); if (event.type === 'stab') fire('.booth', stab(event.chord.color), t); if (event.type === 'roll') fire('.booth', riser(event.level), t); if (event.type === 'crash') { // Two cannons, each aimed up and in towards the stage. fire('.speaker.left', confetti(chordAt(t).color, 25), t, { renderer: 'canvas' }); fire('.speaker.right', confetti(chordAt(t).color, -25), t, { renderer: 'canvas' }); fire(sky(), shell(chordAt(t).color), t + beat); } if (event.type === 'finale') { fire(sky(), shell('#ffffff'), t); fire('.booth', confetti('#ff4d8d'), t, { renderer: 'canvas' }); }}show.set({}, {}, length);
function chordAt(t) { return chords[Math.floor(t / bar) % chords.length];}
// ---- Time: the audio clock decides it. Each frame, the show is moved to where the music is. ----
const playButton = document.querySelector('.play');const seek = document.querySelector('.seek');const sectionName = document.querySelector('.section');const position = document.querySelector('.position');seek.max = String(length);
let playing = false;let startedAt = 0;let offset = 0;let cursor = 0;let timer;
const heard = () => (playing ? Math.max(0, context.currentTime - startedAt + offset) : offset);// What is on screen trails the audio clock by the time the sound takes to reach the speakers.const seen = () => Math.max(0, heard() - (playing ? context.outputLatency || context.baseLatency : 0));
// Every 25 ms, synthesize whatever falls due in the next quarter second.function schedule() { const horizon = heard() + 0.25; while (cursor < score.length && score[cursor].t < horizon) { const event = score[cursor++]; instruments[event.type]?.(event, startedAt + event.t - offset); }}
function play() { context.resume(); bus = context.createGain(); bus.gain.value = 0.7; bus.connect(limiter); startedAt = context.currentTime + 0.05; cursor = score.findIndex((event) => event.t >= offset); if (cursor < 0) cursor = score.length; playing = true; schedule(); timer = setInterval(schedule, 25); playButton.textContent = 'Pause'; playButton.setAttribute('aria-pressed', 'true');}
function pause() { offset = heard(); playing = false; clearInterval(timer); bus.disconnect(); playButton.textContent = 'Play'; playButton.setAttribute('aria-pressed', 'false');}
function render(t) { show.time(t); seek.value = String(t); const section = sections.findLast((entry) => entry.start <= t) ?? sections[0]; sectionName.textContent = section.name; const beats = Math.floor(t / beat); position.textContent = `bar ${Math.floor(beats / 4) + 1} · beat ${(beats % 4) + 1}`;}
gsap.ticker.add(() => { if (!playing) return; if (heard() >= length) { pause(); offset = 0; playButton.textContent = 'Play again'; } render(Math.min(seen(), length));});
playButton.addEventListener('click', () => (playing ? pause() : play()));seek.addEventListener('input', () => { const resume = playing; if (resume) pause(); offset = seek.valueAsNumber; render(offset); if (resume) play();});
render(0);<section class="stage" aria-label="A party night, bursting on the beat"> <div class="truss"> <i class="fixture"></i><i class="fixture"></i><i class="fixture"></i> <i class="fixture"></i><i class="fixture"></i><i class="fixture"></i> </div> <div class="beams"> <i class="beam"></i><i class="beam"></i><i class="beam"></i> <i class="beam"></i><i class="beam"></i><i class="beam"></i> </div> <div class="speaker left"></div> <div class="speaker right"></div> <div class="booth"><span>DJ</span></div> <svg class="crowd" viewBox="0 0 1000 140" preserveAspectRatio="none" aria-hidden="true"> <path d="M0 140V96c18-22 38-22 52 0 8-40 30-44 40-2 16-20 36-18 48 4 10-30 34-30 44 0 14-50 30-52 38-4 14-20 38-20 50 2 12-26 32-28 44-2 18-18 34-16 46 6 8-44 28-48 38-4 16-22 36-22 50 0 12-30 32-30 44 0 14-18 32-18 46 2 10-52 28-54 38-6 16-20 34-20 48 2 12-28 32-28 44-2 14-22 36-22 50 2 8-40 28-42 38-2 16-20 36-18 48 4 10-26 32-28 44 0 12-18 30-18 42 4 8-46 30-48 40-4 14-20 30-18 40 2V140Z" /> </svg>
<div class="hud"> <p class="section">Press play</p> <p class="position">bar 1 · beat 1</p> </div> <div class="controls"> <button type="button" class="play" aria-pressed="false">Play</button> <input class="seek" type="range" min="0" max="1" step="0.01" value="0" aria-label="Position in the set" /> </div></section>
<style> body { display: block; place-items: normal; background: #07030d; } .stage { --tone: #ff4d8d; position: relative; height: 100vh; overflow: hidden; background: radial-gradient(ellipse at 50% 100%, #2a1240, #07030d 70%); } .truss { position: absolute; top: 8%; left: 10%; right: 10%; display: flex; justify-content: space-between; height: 4px; background: #2b2536; } .fixture { width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background: var(--tone); box-shadow: 0 0 12px var(--tone); } .beams { position: absolute; top: 8%; left: 10%; right: 10%; display: flex; justify-content: space-between; } .beam { width: 14px; height: 80vh; transform-origin: 50% 0; background: linear-gradient(color-mix(in srgb, var(--tone) 40%, transparent), transparent 80%); filter: blur(6px); opacity: 0.5; } .speaker { position: absolute; bottom: 22%; width: 7%; min-width: 36px; aspect-ratio: 2 / 3; border-radius: 6px; background: radial-gradient(circle at 50% 35%, #3a3346 18%, #16121f 20%); } .left { left: 6%; } .right { right: 6%; } .booth { position: absolute; bottom: 20%; left: 50%; display: grid; place-items: center; width: 22%; min-width: 110px; height: 12%; translate: -50% 0; border-radius: 8px 8px 0 0; background: #16121f; color: #ffffff55; font: 600 0.8rem ui-monospace, monospace; } .crowd { position: absolute; bottom: 0; width: 100%; height: 22%; fill: #030106; } .hud { position: absolute; top: 13%; left: 1.5rem; color: #f2e9ff; } .hud p { margin: 0; } .section { font: 700 1.6rem system-ui, sans-serif; } .position { font: 0.85rem ui-monospace, monospace; opacity: 0.6; } .controls { position: absolute; right: 1.5rem; bottom: 1.25rem; left: 1.5rem; display: flex; gap: 1rem; align-items: center; } .play { min-width: 7.5rem; padding: 0.55rem 1rem; border: 0; border-radius: 999px; background: var(--tone); color: #07030d; font: 700 0.95rem system-ui, sans-serif; cursor: pointer; } .seek { flex: 1; accent-color: var(--tone); }</style>One score, two outputs
Section titled “One score, two outputs”There is no audio file. The set is a list of events, such as a kick at 15.48 seconds or a clap at 15.97, and the same list feeds two things:
- The synth. The Web Audio API makes each event from oscillators and filtered noise a quarter of a second before it is due: sine kicks, noise claps and hats, a sawtooth bass, chord stabs and a riser. Seeking moves where that scheduling starts, so playback can begin from any point at once.
- The show. A paused GSAP timeline gets one motly burst per event, at the event’s time. Specs are built from the event: the stab’s color comes from its chord, and the build’s roll grows step by step, with more Children, further out, as the drop nears.
So the bursts cannot drift from the music: they come from the same numbers.
The audio clock owns time
Section titled “The audio clock owns time”The timeline never plays by itself. On each frame, it is moved to where the music is:
gsap.ticker.add(() => { if (playing) show.time(context.currentTime - startedAt + offset - context.outputLatency);});Pause the music and the show stops; drag the slider and both jump together. (Rendering the whole set into a buffer first would make seeking simpler, but it took about 12 seconds before anything could play.) The audio’s output latency is taken off, so what you see matches what reaches the speakers, not what has only been scheduled.
A burst under @motly/gsap is in the document only while its tween is between its ends, so a
timeline of about 700 bursts costs only the few that are playing. With @motly/core, a custom
Driver could follow the audio clock as well, but a core Timeline holds every Instance at its first
frame before it starts and draws it every frame. That suits a short sequence, not hundreds of
beats.
Reduced motion and flashing
Section titled “Reduced motion and flashing”Bursts on every sixteenth note would flicker even as still frames. Under reduced motion the set still plays, the stage colors still follow the chords, but the beat bursts and swinging beams are left out: each section shows one still burst for its whole length instead.
At any setting, nothing flashes the whole stage. The kick rings, the largest thing on the beat, come about twice a second, under the three flashes a second that WCAG 2.3.1 allows, and the faster hat sparks are a few pixels across.