Custom Code Experiment •
Cluster
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>Tessellate · a song for the Organic Cluster</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ——— colour tokens: light by default, the original dark palette for dark mode ——— */
:root {
--bg: #e6eaf1;
--tile-base: #c3d0e1;
--text: #4d6f9c;
--prompt: #3b6497;
--track: rgba(77, 111, 156, 0.22);
--shadow-rest: rgba(28, 44, 70, 0.26);
--shadow-up: rgba(28, 44, 70, 0.42);
--shadow-mid: rgba(28, 44, 70, 0.34);
--text-shadow: none;
--grain: 0.22;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0a0a0f;
--tile-base: #1e3a5f;
--text: #4a7ab5;
--prompt: #8bb3dc;
--track: rgba(74, 122, 181, 0.25);
--shadow-rest: rgba(0, 0, 0, 0.55);
--shadow-up: rgba(0, 0, 0, 0.8);
--shadow-mid: rgba(0, 0, 0, 0.72);
--text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
--grain: 0.35;
}
}
:root[data-theme="dark"] {
--bg: #0a0a0f;
--tile-base: #1e3a5f;
--text: #4a7ab5;
--prompt: #8bb3dc;
--track: rgba(74, 122, 181, 0.25);
--shadow-rest: rgba(0, 0, 0, 0.55);
--shadow-up: rgba(0, 0, 0, 0.8);
--shadow-mid: rgba(0, 0, 0, 0.72);
--text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
--grain: 0.35;
}
:root {
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
html { height: 100%; background: var(--bg); scroll-padding-top: env(safe-area-inset-top, 0px); }
body {
background: var(--bg);
width: 100%; height: 100%;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
font-family: 'Syne', 'Avenir Next', 'Segoe UI', system-ui, sans-serif;
-webkit-tap-highlight-color: transparent;
}
body::before {
content: '';
position: fixed; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none; z-index: 100; opacity: var(--grain);
}
p.label {
position: fixed;
top: calc(1.5em + env(safe-area-inset-top, 0px)); left: 1.5em;
color: var(--text); font-size: clamp(0.75em, 2vw, 0.95em);
letter-spacing: 0.2em; text-transform: uppercase; z-index: 10;
}
#scene {
perspective: 1000px; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
transform: translateY(-1.5rem) scale(var(--fit, 1));
}
#cluster {
position: relative; transform-style: preserve-3d;
transform: rotateX(58deg) rotateZ(45deg);
width: 620px; height: 620px;
flex: none; /* never let flexbox squeeze the cluster on narrow screens */
}
.tile { position: absolute; transform-style: preserve-3d; cursor: pointer; outline: none; }
.tile svg { width: 100%; height: 100%; overflow: visible; display: block; }
.tile .face {
transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1), filter 0.45s ease;
transform: translateZ(0px);
filter: drop-shadow(6px 8px 10px var(--shadow-rest));
will-change: transform, filter;
}
.tile .face polygon { transition: fill 0.4s ease; fill: var(--tile-base); }
/* struck by the song player: fast attack, the slow release comes from the base rule */
.tile.auto .face {
transform: translateZ(42px);
filter: drop-shadow(16px 20px 16px var(--shadow-up)) brightness(1.08);
transition-duration: 0.09s;
}
.tile.auto .face polygon { transition-duration: 0.05s; }
.tile.hovered .face { transform: translateZ(48px); filter: drop-shadow(18px 22px 18px var(--shadow-up)); }
.tile.active .face { transform: translateZ(32px); filter: drop-shadow(12px 16px 14px var(--shadow-mid)); }
.tile:focus-visible .face polygon { stroke: var(--prompt); stroke-width: 2px; }
/* ——— minimal text prompt: no box, just words ——— */
.audio-prompt {
position: fixed;
bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%);
color: var(--prompt);
font-size: clamp(0.9rem, 3.5vw, 1.2rem);
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
z-index: 50;
pointer-events: none;
white-space: nowrap;
text-shadow: var(--text-shadow);
opacity: 0.9;
transition: opacity 0.6s ease;
}
.audio-prompt.gone { opacity: 0; }
/* ——— song transport: same plain-text language as the prompt ——— */
.transport {
position: fixed;
left: 50%;
bottom: calc(1.3rem + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
z-index: 60;
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
color: var(--prompt);
}
.transport button {
font: inherit; color: inherit; background: none; border: 0;
padding: 0.3rem 0.6rem; cursor: pointer;
display: flex; align-items: center; gap: 0.65em;
font-size: clamp(0.8rem, 2.8vw, 0.95rem);
letter-spacing: 0.16em; text-transform: uppercase;
text-shadow: var(--text-shadow);
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.transport button:focus-visible { outline: 1px solid var(--prompt); outline-offset: 3px; }
.transport button svg { width: 0.75em; height: 0.75em; fill: currentColor; flex: none; }
.transport .stop-icon { display: none; }
.transport.playing .play-icon { display: none; }
.transport.playing .stop-icon { display: block; }
.track {
width: min(240px, 62vw); height: 1px;
background: var(--track);
position: relative; overflow: hidden;
}
.track i {
position: absolute; inset: 0;
background: var(--prompt);
transform-origin: left center;
transform: scaleX(0);
}
.meta {
width: min(240px, 62vw);
display: flex; justify-content: space-between; gap: 1rem;
color: var(--text);
font-size: 0.66rem; letter-spacing: 0.12em;
opacity: 0.8; white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.instructions {
position: fixed;
bottom: calc(1.5em + env(safe-area-inset-bottom, 0px)); right: 1.5em;
color: var(--text); font-size: clamp(0.6em, 1.8vw, 0.78em);
letter-spacing: 0.12em; text-transform: uppercase;
text-align: right; z-index: 10; opacity: 0.6;
line-height: 1.7;
}
@media (max-width: 560px) {
.audio-prompt { font-size: 0.8rem; bottom: calc(5.2rem + env(safe-area-inset-bottom, 0px)); }
.transport { bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 820px) {
.instructions { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tile.auto .face { transform: translateZ(0px); filter: drop-shadow(6px 8px 10px var(--shadow-rest)) brightness(1.08); }
}
</style>
</head>
<body>
<p class="label">Organic Cluster</p>
<div id="scene"><div id="cluster"></div></div>
<!-- plain text message: only the text, no button styling -->
<div class="audio-prompt">Click to Activate Audio</div>
<div class="transport" id="transport">
<button type="button" id="playBtn" aria-label="Play the song Tessellate">
<svg class="play-icon" viewBox="0 0 10 10" aria-hidden="true"><path d="M1 0.5 L9.5 5 L1 9.5 Z"/></svg>
<svg class="stop-icon" viewBox="0 0 10 10" aria-hidden="true"><rect x="1" y="1" width="8" height="8"/></svg>
<span id="playLabel">Play “Tessellate”</span>
</button>
<div class="track"><i id="progress"></i></div>
<div class="meta"><span id="sectionName">A minor, 100 bpm</span><span id="clock">1:16</span></div>
</div>
<div class="instructions">Hover a tile to play it<br>Space starts or stops the song</div>
<script>
// ─────────────────────────────────────────────
// AUDIO ENGINE
// ─────────────────────────────────────────────
let audioCtx = null;
let audioReady = false;
const promptEl = document.querySelector('.audio-prompt');
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function unlockAudio() {
if (audioReady) return;
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
audioReady = true;
promptEl.classList.add('gone');
}
['pointerdown', 'touchstart', 'keydown'].forEach(evt =>
document.addEventListener(evt, unlockAudio, { once: false, passive: true })
);
function getMaster() {
const ctx = getCtx();
if (!getMaster._node) {
const lim = ctx.createDynamicsCompressor();
lim.threshold.value = -6;
lim.knee.value = 3;
lim.ratio.value = 12;
lim.attack.value = 0.001;
lim.release.value = 0.1;
lim.connect(ctx.destination);
getMaster._node = lim;
}
return getMaster._node;
}
// Every voice takes a start time `t`, so the song player can schedule notes
// ahead on the audio clock. Hovering a tile just passes ctx.currentTime.
const soundDefs = {
'#c0392b': (ctx, out, t) => { // kick
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.frequency.setValueAtTime(120, t);
osc.frequency.exponentialRampToValueAtTime(30, t + 0.18);
env.gain.setValueAtTime(0.7, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.3);
},
'#e74c3c': (ctx, out, t) => { // high tom
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, t);
osc.frequency.exponentialRampToValueAtTime(50, t + 0.12);
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.2);
},
'#e67e22': (ctx, out, t) => { // G4
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 392;
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.55);
},
'#f39c12': (ctx, out, t) => { // A4
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 440;
env.gain.setValueAtTime(0.45, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.5);
},
'#f1c40f': (ctx, out, t) => { // C5 bell
[523.25, 1046.5].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
env.gain.setValueAtTime(i === 0 ? 0.4 : 0.15, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.85);
});
},
'#2ecc71': (ctx, out, t) => { // noise riser
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.4, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(400, t);
filter.frequency.exponentialRampToValueAtTime(2000, t + 0.4);
filter.Q.value = 3;
const env = ctx.createGain();
env.gain.setValueAtTime(0.3, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
src.connect(filter); filter.connect(env); env.connect(out);
src.start(t); src.stop(t + 0.45);
},
'#27ae60': (ctx, out, t) => { // zap up
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, t);
osc.frequency.exponentialRampToValueAtTime(660, t + 0.3);
env.gain.setValueAtTime(0.35, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.4);
},
'#1abc9c': (ctx, out, t) => { // C major strum
[523.25, 659.25, 783.99].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
env.gain.setValueAtTime(0.18, t + i * 0.04);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.7);
osc.connect(env); env.connect(out);
osc.start(t + i * 0.04);
osc.stop(t + 0.75);
});
},
'#16a085': (ctx, out, t) => { // F4
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 349.23;
env.gain.setValueAtTime(0.4, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.65);
},
'#3498db': (ctx, out, t) => { // A2 bass
const osc = ctx.createOscillator();
const env = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.value = 800; filter.Q.value = 8;
osc.type = 'sawtooth';
osc.frequency.value = 110;
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
osc.connect(filter); filter.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.45);
},
'#2980b9': (ctx, out, t) => { // C3 bass
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'square';
osc.frequency.value = 130.81;
env.gain.setValueAtTime(0.25, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.4);
},
'#9b59b6': (ctx, out, t) => { // falling laser
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, t);
osc.frequency.exponentialRampToValueAtTime(110, t + 0.5);
env.gain.setValueAtTime(0.35, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.55);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.6);
},
'#8e44ad': (ctx, out, t) => { // A4 with vibrato
const carrier = ctx.createOscillator();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
const env = ctx.createGain();
lfo.frequency.value = 8;
lfoGain.gain.value = 60;
carrier.type = 'sine';
carrier.frequency.value = 440;
lfo.connect(lfoGain); lfoGain.connect(carrier.frequency);
env.gain.setValueAtTime(0.3, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
carrier.connect(env); env.connect(out);
lfo.start(t); carrier.start(t); lfo.stop(t + 0.55); carrier.stop(t + 0.55);
},
'#e91e8c': (ctx, out, t) => { // hi-hat
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.08, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'highpass'; filter.frequency.value = 6000;
const env = ctx.createGain();
env.gain.setValueAtTime(0.5, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
src.connect(filter); filter.connect(env); env.connect(out);
src.start(t); src.stop(t + 0.1);
},
'#c2185b': (ctx, out, t) => { // rim click
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / 200);
const src = ctx.createBufferSource(); src.buffer = buf;
const env = ctx.createGain();
env.gain.setValueAtTime(0.6, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
src.connect(env); env.connect(out);
src.start(t); src.stop(t + 0.08);
},
'#00bcd4': (ctx, out, t) => { // C major sparkle arpeggio
[523.25, 659.25, 783.99, 1046.5].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const ti = t + i * 0.06;
env.gain.setValueAtTime(0.22, ti);
env.gain.exponentialRampToValueAtTime(0.001, ti + 0.15);
osc.connect(env); env.connect(out);
osc.start(ti); osc.stop(ti + 0.2);
});
},
'#0097a7': (ctx, out, t) => { // C4 → C5 glide
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(261.63, t);
osc.frequency.exponentialRampToValueAtTime(523.25, t + 0.2);
env.gain.setValueAtTime(0.35, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.35);
},
'#ff5722': (ctx, out, t) => { // gritty ~E2 bass stab
const osc = ctx.createOscillator();
const wave = ctx.createWaveShaper();
const env = ctx.createGain();
// odd-length curve so silence maps exactly to 0 (an even length leaks a DC offset)
const curve = new Float32Array(257);
for (let i = 0; i < 257; i++) {
const x = (i * 2) / 256 - 1;
curve[i] = (Math.PI + 200) * x / (Math.PI + 200 * Math.abs(x));
}
wave.curve = curve;
osc.type = 'sawtooth'; osc.frequency.value = 80;
env.gain.value = 0;
env.gain.setValueAtTime(0.6, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
osc.connect(wave); wave.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.2);
},
'#bf360c': (ctx, out, t) => { // low tom
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(160, t);
osc.frequency.exponentialRampToValueAtTime(40, t + 0.2);
env.gain.setValueAtTime(0.55, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.25);
},
'#76c442': (ctx, out, t) => { // E6 ping
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = 1318.51;
env.gain.setValueAtTime(0.3, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.65);
},
'#4caf50': (ctx, out, t) => { // E5 + G#5 (the E major colour)
[659.25, 830.61].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = freq;
env.gain.setValueAtTime(0.2, t + i * 0.05);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.55);
osc.connect(env); env.connect(out);
osc.start(t + i * 0.05);
osc.stop(t + 0.6);
});
},
};
function playSound(colorKey) {
if (!audioReady) return;
const fn = soundDefs[colorKey];
if (!fn) return;
try {
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
fn(ctx, getMaster(), ctx.currentTime);
} catch(e) { /* ignore */ }
}
// ─────────────────────────────────────────────
// THE SONG: "Tessellate", A minor, 100 bpm
// Written on a 16-step grid. x = hit, o = ghost (softer) hit.
// ─────────────────────────────────────────────
const BPM = 100;
const STEP = 60 / BPM / 4; // one sixteenth note = 0.15 s
// Each tile colour, named by the sound it makes
const SOUND = {
kick: '#c0392b', tom: '#e74c3c', tomLo: '#bf360c', rim: '#c2185b', hat: '#e91e8c',
bassA: '#3498db', bassC: '#2980b9', grit: '#ff5722',
F4: '#16a085', G4: '#e67e22', A4: '#f39c12', A4v: '#8e44ad', C5: '#f1c40f',
E6: '#76c442', Emaj: '#4caf50', chord: '#1abc9c', arp: '#00bcd4',
glide: '#0097a7', rise: '#2ecc71', zap: '#27ae60', fall: '#9b59b6',
};
// Mix level for each voice when the song plays it
const VEL = {
kick: 1, tom: 0.8, tomLo: 0.85, rim: 0.75, hat: 0.5,
bassA: 0.85, bassC: 0.95, grit: 0.55,
F4: 1, G4: 1, A4: 1, A4v: 1, C5: 0.85, E6: 0.75, Emaj: 1, chord: 1, arp: 0.85,
glide: 0.8, rise: 0.7, zap: 0.6, fall: 0.5,
};
const SONG = (() => {
const events = [];
const sections = [];
let cursor = 0;
const hit = (bar, step, s, len = 1, v) => events.push({ at: bar * 16 + step, s, len, v });
const grid = (bar, s, pattern, v) => [...pattern].forEach((c, i) => {
if (c === 'x') hit(bar, i, s, 1, v);
if (c === 'o') hit(bar, i, s, 1, (v ?? VEL[s] ?? 1) * 0.5);
});
const line = (bar, notes) => notes.forEach(([step, s, len]) => hit(bar, step, s, len));
const section = (name, bars, fn) => {
sections.push({ name, from: cursor, to: cursor + bars });
for (let k = 0; k < bars; k++) fn(cursor + k, k);
cursor += bars;
};
const D = {
kick: 'x.....x.x.......',
kick2: 'x.....x.x.x.....',
rim: '....x.......x...',
rim2: '....x..o....x..o',
hatOff: '..x...x...x...x.',
hat8: 'o.x.o.x.o.x.o.x.',
hat16: 'o.x.o.xoo.x.o.xo',
bass: 'x..x..x...x..x..',
bassE: 'x.....x...x.....',
};
// Am | Am | C | E — the gritty ~80 Hz stab stands in for an E bass note
const PROG = ['A', 'A', 'C', 'E'];
const bassline = (bar, chord, v) =>
chord === 'E' ? grid(bar, 'grit', D.bassE, v)
: grid(bar, chord === 'A' ? 'bassA' : 'bassC', D.bass, v);
// The tune: [step, sound, length in steps]
const THEME = [
[[0,'A4',4], [4,'C5',4], [8,'A4',2], [10,'G4',2], [12,'A4',4]],
[[0,'E6',6], [6,'G4',2], [8,'F4',4], [12,'G4',4]],
[[0,'C5',6], [6,'A4',2], [8,'G4',4], [12,'F4',2], [14,'G4',2]],
[[0,'Emaj',6], [8,'A4v',4], [12,'Emaj',4]],
[[0,'A4',4], [4,'C5',4], [8,'E6',4], [12,'C5',4]],
[[0,'A4',2], [2,'G4',2], [4,'A4',4], [8,'C5',2], [10,'A4',2], [12,'G4',4]],
[[0,'F4',4], [4,'G4',4], [8,'A4',4], [12,'C5',4]],
[[0,'Emaj',8], [8,'A4v',4]],
];
section('Intro', 4, (b, k) => {
if (k === 0) { hit(b, 0, 'bassA', 2); hit(b, 0, 'chord', 8); hit(b, 12, 'E6', 4); }
if (k === 1) { hit(b, 0, 'bassC', 2); hit(b, 0, 'arp', 4); hit(b, 8, 'C5', 8); }
if (k === 2) {
hit(b, 0, 'bassA', 2); hit(b, 0, 'chord', 8); hit(b, 8, 'E6', 4); hit(b, 12, 'C5', 4);
grid(b, 'hat', D.hatOff, 0.3);
}
if (k === 3) {
hit(b, 0, 'grit', 2); hit(b, 0, 'Emaj', 8);
grid(b, 'hat', D.hatOff, 0.42);
hit(b, 8, 'rise', 8); hit(b, 12, 'tom'); hit(b, 14, 'tomLo', 2);
}
});
section('Groove', 4, (b, k) => {
grid(b, 'kick', D.kick); grid(b, 'rim', D.rim); grid(b, 'hat', D.hatOff);
bassline(b, PROG[k]);
if (k === 0) hit(b, 0, 'chord', 4);
if (k === 2) hit(b, 0, 'arp', 4);
if (k === 3) {
hit(b, 0, 'Emaj', 6); hit(b, 8, 'Emaj', 4);
hit(b, 12, 'tom'); hit(b, 13, 'tom', 1, 0.5); hit(b, 14, 'tomLo', 2);
}
});
section('Theme', 8, (b, k) => {
grid(b, 'kick', D.kick); grid(b, 'rim', D.rim); grid(b, 'hat', D.hat8);
bassline(b, PROG[k % 4]);
line(b, THEME[k]);
if (k === 7) { hit(b, 12, 'fall', 4); hit(b, 14, 'tomLo', 2); }
});
section('Break', 4, (b, k) => {
const air = '..x.......x.....';
if (k === 0) { hit(b, 0, 'bassA', 2); hit(b, 0, 'chord', 8); hit(b, 8, 'A4v', 8); grid(b, 'hat', air, 0.3); }
if (k === 1) {
hit(b, 0, 'bassC', 2); hit(b, 0, 'arp', 4); hit(b, 4, 'C5', 4); hit(b, 8, 'E6', 4);
hit(b, 12, 'fall', 4); grid(b, 'hat', air, 0.3);
}
if (k === 2) {
hit(b, 0, 'bassA', 2); hit(b, 0, 'chord', 4);
line(b, [[4,'A4v',4], [8,'G4',4], [12,'F4',4]]);
grid(b, 'hat', air, 0.35);
}
if (k === 3) { // the build
hit(b, 0, 'grit', 2); hit(b, 0, 'Emaj', 8);
grid(b, 'kick', 'x...x...x...x...', 0.8);
[[8, 0.3], [10, 0.4], [12, 0.5], [13, 0.6], [14, 0.8], [15, 1]]
.forEach(([s, v]) => hit(b, s, 'rim', 1, v));
hit(b, 8, 'rise', 8); hit(b, 14, 'zap', 2);
}
});
section('Theme again', 8, (b, k) => {
grid(b, 'kick', D.kick2); grid(b, 'rim', D.rim2); grid(b, 'hat', D.hat16);
bassline(b, PROG[k % 4]);
line(b, THEME[k]);
if (k === 0) { hit(b, 0, 'rise', 4, 0.6); hit(b, 0, 'arp', 4); }
if (k === 4) hit(b, 0, 'arp', 4);
if (k === 5) hit(b, 8, 'arp', 4, 0.45);
if (k === 2 || k === 6) hit(b, 8, 'chord', 4, 0.55);
if (k === 7) {
hit(b, 12, 'glide', 4);
hit(b, 12, 'tom', 1, 0.7); hit(b, 13, 'tom', 1, 0.85);
hit(b, 14, 'tomLo'); hit(b, 15, 'tomLo');
}
});
section('Outro', 4, (b, k) => {
if (k < 3) { hit(b, 0, 'kick'); grid(b, 'hat', D.hatOff, 0.35); }
if (k === 0) { grid(b, 'bassA', D.bass, 0.7); line(b, [[0,'chord',4], [4,'A4',4], [8,'C5',4], [12,'E6',4]]); }
if (k === 1) { grid(b, 'bassC', D.bass, 0.75); line(b, [[4,'G4',4], [8,'F4',4], [12,'G4',4]]); }
if (k === 2) { grid(b, 'grit', D.bassE, 0.45); line(b, [[0,'Emaj',8], [8,'A4v',8]]); }
if (k === 3) { // final A minor chord: A, C, E over the kick
['kick', 'bassA', 'A4v', 'C5', 'E6'].forEach(s => hit(b, 0, s, 16));
hit(b, 10, 'fall', 4, 0.5);
}
});
events.sort((a, b) => a.at - b.at);
return { events, sections, bars: cursor, steps: cursor * 16 };
})();
const SONG_LENGTH = SONG.steps * STEP;
// ─────────────────────────────────────────────
// TILE DATA & RENDERING
// ─────────────────────────────────────────────
const palette = [
'#c0392b','#e74c3c','#e67e22','#f39c12','#f1c40f',
'#2ecc71','#27ae60','#1abc9c','#16a085','#3498db',
'#2980b9','#9b59b6','#8e44ad','#e91e8c','#c2185b',
'#00bcd4','#0097a7','#ff5722','#bf360c','#76c442','#4caf50',
];
const tiles = [
{ x:225, y:210, w:115, h:105, pts:[[0.1,0.05],[0.9,0],[1,0.2],[0.95,0.85],[0.8,1],[0.05,0.95],[0,0.5]] },
{ x:330, y:195, w:100, h:115, pts:[[0.05,0],[0.95,0.1],[1,0.4],[0.9,1],[0.1,0.95],[0,0.6],[0.05,0.2]] },
{ x:248, y:308, w:110, h:100, pts:[[0.15,0],[0.9,0.05],[1,0.5],[0.85,1],[0.1,0.95],[0,0.4]] },
{ x:348, y:298, w: 95, h:110, pts:[[0,0.1],[0.8,0],[1,0.3],[1,0.8],[0.7,1],[0.05,0.9]] },
{ x:235, y:115, w: 95, h: 98, pts:[[0.2,0],[0.85,0.05],[1,0.5],[0.8,1],[0.1,0.9],[0,0.3]] },
{ x:322, y: 98, w:110, h: 98, pts:[[0.05,0.1],[0.9,0],[1,0.35],[0.95,0.9],[0.5,1],[0,0.7]] },
{ x:145, y:130, w: 95, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:425, y:112, w: 82, h: 90, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.1,0.95],[0,0.4]] },
{ x: 90, y:215, w:100, h: 95, pts:[[0.1,0],[0.95,0.05],[1,0.55],[0.85,1],[0.05,0.9],[0,0.4]] },
{ x: 82, y:305, w: 92, h:108, pts:[[0,0.05],[0.8,0],[1,0.4],[0.9,1],[0.15,0.95],[0.05,0.5]] },
{ x:155, y:220, w: 80, h: 92, pts:[[0.1,0.05],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:440, y:210, w: 95, h:100, pts:[[0.1,0],[0.9,0.1],[1,0.5],[0.85,1],[0,0.9],[0.05,0.2]] },
{ x:432, y:305, w: 88, h: 95, pts:[[0.05,0],[0.95,0.05],[1,0.6],[0.7,1],[0.05,0.95],[0,0.35]] },
{ x:358, y:398, w: 92, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:230, y:402, w:110, h: 90, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:332, y:392, w:100, h: 95, pts:[[0,0.1],[0.85,0],[1,0.45],[0.9,0.95],[0.2,1],[0.05,0.6]] },
{ x:135, y:392, w: 98, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.75,1],[0,0.88],[0.05,0.3]] },
{ x:170, y:115, w: 72, h: 78, pts:[[0.15,0],[0.9,0.1],[1,0.6],[0.75,1],[0,0.85],[0.05,0.2]] },
{ x:430, y: 98, w: 68, h: 72, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:108, y:118, w: 65, h: 72, pts:[[0,0.15],[0.8,0],[1,0.4],[0.85,0.9],[0.2,1],[0.05,0.55]] },
{ x: 58, y:195, w: 68, h: 75, pts:[[0.1,0],[0.9,0.05],[1,0.55],[0.8,1],[0,0.9],[0.05,0.3]] },
{ x: 55, y:390, w: 78, h: 72, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.95],[0,0.4]] },
{ x:450, y:390, w: 72, h: 75, pts:[[0,0.1],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0.05,0.3]] },
{ x:148, y:395, w: 88, h: 72, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,0.98],[0.05,0.9],[0,0.4]] },
{ x:160, y:308, w: 78, h: 88, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.05,0.9],[0,0.35]] },
{ x:445, y:305, w: 68, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,1],[0,0.88],[0.05,0.3]] },
{ x: 58, y:278, w: 62, h: 80, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.8,1],[0.05,0.9],[0,0.3]] },
{ x:505, y:250, w: 65, h: 78, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0,0.88],[0.05,0.25]] },
{ x:290, y: 42, w: 80, h: 68, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:200, y: 42, w: 72, h: 70, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.78,1],[0.05,0.9],[0,0.35]] },
{ x:382, y: 42, w: 70, h: 68, pts:[[0.1,0.05],[0.88,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:260, y:488, w: 88, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:348, y:482, w: 80, h: 68, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.9],[0,0.4]] },
{ x:168, y:482, w: 78, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,0.98],[0.05,0.9],[0,0.35]] },
];
const cluster = document.getElementById('cluster');
const tileColors = tiles.map((_, i) => palette[i % palette.length]);
for (let i = tileColors.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tileColors[i], tileColors[j]] = [tileColors[j], tileColors[i]];
}
const leaveTimers = new Map();
const LEAVE_DELAY = 80;
const soundCooldowns = new Map();
const SOUND_COOLDOWN = 180;
function tryPlaySound(div, color) {
const now = Date.now();
const last = soundCooldowns.get(div) || 0;
if (now - last < SOUND_COOLDOWN) return;
soundCooldowns.set(div, now);
playSound(color);
}
// Each tile can be lit by a hover, a touch, a key press or the song player;
// sync() merges those so one never switches off another.
const tileStates = [];
const tilesByColor = new Map();
function sync(st) {
st.div.classList.toggle('hovered', st.hovered || st.keyed);
st.div.classList.toggle('active', st.touched);
st.div.classList.toggle('auto', st.auto);
const lit = st.hovered || st.keyed || st.touched || st.fading || st.auto;
st.poly.style.fill = lit ? st.color : '';
}
tiles.forEach((t, i) => {
const div = document.createElement('div');
div.className = 'tile';
div.style.left = t.x + 'px';
div.style.top = t.y + 'px';
div.style.width = t.w + 'px';
div.style.height = t.h + 'px';
div.setAttribute('tabindex', '0');
const points = t.pts.map(([px, py]) => `${px * t.w},${py * t.h}`).join(' ');
const hoverCol = tileColors[i];
div.innerHTML = `
<div class="face">
<svg viewBox="0 0 ${t.w} ${t.h}" xmlns="http://www.w3.org/2000/svg">
<polygon points="${points}" />
</svg>
</div>`;
const st = {
div, poly: div.querySelector('polygon'), color: hoverCol,
hovered: false, keyed: false, touched: false, fading: false,
auto: false, autoUntil: 0, fadeTimer: null, keyTimer: null,
};
tileStates.push(st);
if (!tilesByColor.has(hoverCol)) tilesByColor.set(hoverCol, []);
tilesByColor.get(hoverCol).push(st);
div.addEventListener('pointerenter', () => {
if (leaveTimers.has(div)) { clearTimeout(leaveTimers.get(div)); leaveTimers.delete(div); }
if (!st.hovered) {
st.hovered = true;
sync(st);
tryPlaySound(div, hoverCol);
}
});
div.addEventListener('pointerleave', () => {
if (leaveTimers.has(div)) clearTimeout(leaveTimers.get(div));
const timer = setTimeout(() => {
st.hovered = false;
sync(st);
leaveTimers.delete(div);
}, LEAVE_DELAY);
leaveTimers.set(div, timer);
});
div.addEventListener('touchstart', e => {
e.preventDefault();
clearTimeout(st.fadeTimer);
st.touched = true; st.fading = false;
sync(st);
tryPlaySound(div, hoverCol);
}, { passive: false });
div.addEventListener('touchend', e => {
e.preventDefault();
st.touched = false; st.fading = true;
sync(st);
clearTimeout(st.fadeTimer);
st.fadeTimer = setTimeout(() => { st.fading = false; sync(st); }, 420);
}, { passive: false });
div.addEventListener('touchcancel', () => {
st.touched = false; st.fading = false;
sync(st);
});
div.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
st.keyed = true;
sync(st);
tryPlaySound(div, hoverCol);
clearTimeout(st.keyTimer);
st.keyTimer = setTimeout(() => { st.keyed = false; sync(st); }, 440);
}
});
cluster.appendChild(div);
});
let lastTouch = 0;
document.addEventListener('touchend', e => {
if (e.target.closest && e.target.closest('.transport')) return;
const now = Date.now();
if (now - lastTouch < 300) e.preventDefault();
lastTouch = now;
}, false);
// ─────────────────────────────────────────────
// SONG PLAYER
// Audio is scheduled slightly ahead on the audio clock (tight timing);
// the tiles are raised on animation frames when each note is actually heard.
// ─────────────────────────────────────────────
const transportEl = document.getElementById('transport');
const playBtn = document.getElementById('playBtn');
const playLabel = document.getElementById('playLabel');
const progressEl = document.getElementById('progress');
const sectionEl = document.getElementById('sectionName');
const clockEl = document.getElementById('clock');
const player = { playing: false, starting: false, startTime: 0, endTime: 0, idx: 0, bus: null, timer: null };
const visualQueue = [];
const litTiles = new Set();
const roundRobin = new Map();
let rafId = null;
const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const IDLE_META = ['A minor, 100 bpm', fmt(SONG_LENGTH)];
function pickTile(color) {
const list = tilesByColor.get(color);
if (!list) return null;
const i = roundRobin.get(color) || 0;
roundRobin.set(color, (i + 1) % list.length);
return list[i % list.length];
}
function outputLatency(ctx) {
return Math.min(0.2, (ctx.outputLatency || 0) + (ctx.baseLatency || 0));
}
function scheduleAhead() {
const ctx = getCtx();
const horizon = ctx.currentTime + (document.hidden ? 1.5 : 0.15);
while (player.idx < SONG.events.length) {
const ev = SONG.events[player.idx];
const t = player.startTime + ev.at * STEP;
if (t > horizon) break;
player.idx++;
const color = SOUND[ev.s];
const out = ctx.createGain();
out.gain.value = ev.v ?? VEL[ev.s] ?? 1;
out.connect(player.bus);
soundDefs[color](ctx, out, t);
const st = pickTile(color);
if (st) visualQueue.push({ t, until: t + Math.max(0.11, ev.len * STEP - 0.03), st });
}
if (player.idx >= SONG.events.length && ctx.currentTime >= player.endTime) finish();
}
function releaseTile(st) {
st.auto = false;
sync(st);
litTiles.delete(st);
}
function frame() {
const ctx = getCtx();
const now = ctx.currentTime - outputLatency(ctx);
while (visualQueue.length && visualQueue[0].t <= now) {
const v = visualQueue.shift();
if (now > v.until) continue; // missed while the tab was hidden
v.st.auto = true;
v.st.autoUntil = v.until;
sync(v.st);
litTiles.add(v.st);
}
for (const st of litTiles) if (st.autoUntil <= now) releaseTile(st);
if (player.playing) updateReadout(ctx);
if (player.playing || visualQueue.length || litTiles.size) rafId = requestAnimationFrame(frame);
else rafId = null;
}
let lastReadout = '';
function updateReadout(ctx) {
const elapsed = Math.min(SONG_LENGTH, Math.max(0, ctx.currentTime - outputLatency(ctx) - player.startTime));
const bar = Math.min(SONG.bars - 1, Math.floor(elapsed / STEP / 16));
const sec = SONG.sections.find(s => bar >= s.from && bar < s.to);
const text = `${sec.name}|${fmt(elapsed)} / ${fmt(SONG_LENGTH)}`;
if (text !== lastReadout) {
lastReadout = text;
sectionEl.textContent = sec.name;
clockEl.textContent = `${fmt(elapsed)} / ${fmt(SONG_LENGTH)}`;
}
progressEl.style.transform = `scaleX(${(elapsed / SONG_LENGTH).toFixed(4)})`;
}
function setIdleUI() {
transportEl.classList.remove('playing');
playLabel.textContent = 'Play “Tessellate”';
playBtn.setAttribute('aria-label', 'Play the song Tessellate');
sectionEl.textContent = IDLE_META[0];
clockEl.textContent = IDLE_META[1];
progressEl.style.transform = 'scaleX(0)';
lastReadout = '';
}
async function play() {
if (player.playing || player.starting) return;
player.starting = true;
unlockAudio();
const ctx = getCtx();
try { if (ctx.state !== 'running') await ctx.resume(); } catch (e) { /* ignore */ }
player.starting = false;
player.bus = ctx.createGain();
player.bus.gain.value = 0.9;
player.bus.connect(getMaster());
player.startTime = ctx.currentTime + 0.12;
player.endTime = player.startTime + SONG_LENGTH + 0.4;
player.idx = 0;
player.playing = true;
roundRobin.clear();
transportEl.classList.add('playing');
playLabel.textContent = 'Stop';
playBtn.setAttribute('aria-label', 'Stop the song');
scheduleAhead();
player.timer = setInterval(scheduleAhead, 25);
if (!rafId) rafId = requestAnimationFrame(frame);
}
function halt() {
clearInterval(player.timer);
player.timer = null;
player.playing = false;
}
function stop() { // stopped by the listener
if (!player.playing) return;
halt();
const ctx = getCtx();
const bus = player.bus;
bus.gain.setTargetAtTime(0, ctx.currentTime, 0.02); // silence notes already scheduled
setTimeout(() => bus.disconnect(), 400);
visualQueue.length = 0;
[...litTiles].forEach(releaseTile);
setIdleUI();
}
function finish() { // reached the end of the song
halt();
const bus = player.bus;
setTimeout(() => bus.disconnect(), 1500);
setIdleUI();
}
function toggle() { player.playing ? stop() : play(); }
playBtn.addEventListener('click', toggle);
document.addEventListener('keydown', e => {
if (e.code !== 'Space' || e.repeat) return;
if (e.target.closest && e.target.closest('.tile, button')) return;
e.preventDefault();
toggle();
});
// ─────────────────────────────────────────────
// FIT THE CLUSTER TO SMALL SCREENS
// ─────────────────────────────────────────────
const scene = document.getElementById('scene');
function fit() {
const s = Math.min(1, (window.innerWidth - 32) / 580, (window.innerHeight - 200) / 380);
scene.style.setProperty('--fit', Math.max(0.3, s).toFixed(3));
}
fit();
window.addEventListener('resize', fit);
setIdleUI();
</script>
</body>
</html>