← Mike Higgins Home
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>