• 01 - CSS: Insects
  • 02 - Critter Models 02
  • 03 - VantaJS Shaders
  • 04 - App Bundle 02
  • 05 - Cathedrals
  • 06 - Board Games
  • 07 - App Bundle 01
  • 08 - Retroscopic
  • 09 - Games 26
  • 10 - Voronoi
  • 11 - Terrain
  • 12 - Critter Models 01
  • 13 - Chromatophores
  • 14 - Conformal Maps
  • 15 - Echo Logix
  • 16 - Music 26
  • 17 - Colour Theory
ARCHIVE
Notes
Number:
Project:
01CSS: Insects
└ CSS Beetle└ CSS Ladybird└ CSS Bluebottle└ CSS Dragonfly
02Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
03VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
04App Bundle 02
└ OwnZones└ Gutindex└ OpenLayers└ SH-101└ Pip└ Great White
05Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
06Board Games
└ Chess└ Carrom└ Connect 4
07App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
01 / 03

SKOODU - OWNZONES INTERACTIVE APP SOURCE CODE

<!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>OwnZones</title>
<meta name="theme-color" content="#cbccd0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="OwnZones">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23cbccd0'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.6'%3E%3Crect x='4' y='4' width='6' height='6'/%3E%3Crect x='13' y='4' width='6' height='6'/%3E%3Crect x='22' y='4' width='6' height='6'/%3E%3Crect x='4' y='13' width='6' height='6'/%3E%3Crect x='22' y='13' width='6' height='6'/%3E%3Crect x='4' y='22' width='6' height='6'/%3E%3Crect x='13' y='22' width='6' height='6'/%3E%3Crect x='22' y='22' width='6' height='6'/%3E%3C/g%3E%3Cpath d='M13 13h6l3 6h-6z' fill='none' stroke='%23141414' stroke-width='1.6'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Syne:wght@700&display=swap" rel="stylesheet">
<style>
    :root {
        --bg: #cbccd0;      /* fog grey */
        --chalk: #ffffff;   /* tiles at rest */
        --ink: #141414;     /* struck tiles, annotation */
        --soft: #55575c;    /* secondary text */
        --muted: #808287;   /* hints */
        --pad-x: 96px;
        --pad-y: 80px;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
        padding-top: env(safe-area-inset-top, 0px);
        padding-bottom: env(safe-area-inset-bottom, 0px);
    }
    html, body { height: 100%; }
    body {
        background: var(--bg);
        overflow: hidden;
        color: var(--ink);
        font-family: 'Courier Prime', 'Courier New', Courier, ui-monospace, monospace;
        -webkit-tap-highlight-color: transparent;
        touch-action: none;                 /* the page owns every gesture: tile slides and track swipes */
        overscroll-behavior: none;          /* no pull-to-refresh, no swipe-to-go-back */
        user-select: none; -webkit-user-select: none;
    }
    html { overscroll-behavior: none; }
    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.05'/%3E%3C/svg%3E");
        pointer-events: none; z-index: 100; opacity: 0.6;
    }

    /* ——— frame ——— */
    .wordmark {
        position: fixed;
        top: calc(var(--pad-y) + env(safe-area-inset-top, 0px));
        left: var(--pad-x);
        font-family: 'Syne', 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
        font-weight: 700; font-size: 14px; letter-spacing: 0.06em;
        z-index: 7;
    }
    .sound {
        position: fixed;
        left: calc(var(--pad-x) - 6px);
        bottom: calc(var(--pad-y) - 6px + env(safe-area-inset-bottom, 0px));
        display: flex; align-items: center; gap: 10px;
        background: none; border: 0; padding: 6px;
        font: inherit; font-size: 12px; color: var(--soft);
        cursor: pointer; z-index: 7;
    }
    .sound i { width: 8px; height: 8px; flex: none; border: 1px solid var(--ink); }
    .sound.on i { background: var(--ink); }
    .sound:focus-visible { outline: 1px dashed var(--ink); outline-offset: 2px; }
    .songs {
        position: fixed;
        top: calc(var(--pad-y) - 5px + env(safe-area-inset-top, 0px));
        right: calc(var(--pad-x) - 6px);
        display: flex; flex-direction: column; align-items: flex-end;
        z-index: 7;
    }
    .songs button {
        display: flex; align-items: center; gap: 10px;
        background: none; border: 0; padding: 2px 6px;
        font: inherit; font-size: 12px; line-height: 1.4;
        color: var(--muted); cursor: pointer;
    }
    .songs button i { width: 6px; height: 6px; border: 1px solid currentColor; flex: none; }
    .songs button:hover { color: var(--ink); }
    .songs button[aria-current="true"] { color: var(--ink); }
    .songs button[aria-current="true"] i { background: var(--ink); border-color: var(--ink); }
    .songs button:focus-visible { outline: 1px dashed var(--ink); outline-offset: 1px; }
    .songs button .name { margin-left: -2px; }

    /* ——— transport: previous · the track (tap for the list) · next ——— */
    .transport {
        position: fixed; left: 50%; top: 0; z-index: 8;
        transform: translate(-50%, -50%);
        display: flex; align-items: center; gap: 2px;
        max-width: calc(100vw - 12px);
    }
    .transport button {
        background: none; border: 0; font: inherit; color: var(--ink);
        cursor: pointer; touch-action: manipulation;
    }
    .transport .now {
        min-height: 44px; min-width: 0; padding: 3px 10px;
        display: flex; flex-direction: column; align-items: center; justify-content: center;
        cursor: pointer;
    }
    .transport .now .main, .transport .now .sub { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .transport .now .main { font-size: 15px; line-height: 20px; }
    .transport .now .sub { font-size: 11.5px; line-height: 15px; color: var(--soft); }
    .transport .step { flex: none; }
    .transport .step {
        width: 44px; height: 44px; display: grid; place-items: center;
        opacity: 0.5; transition: opacity 0.15s ease;
    }
    .transport .step:hover, .transport .step:active { opacity: 1; }
    .transport .step svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; }
    .transport button:focus-visible { outline: 1px dashed var(--ink); outline-offset: -2px; }

    /* ——— the full track list, as a sheet ——— */
    .sheet {
        position: fixed; inset: 0; z-index: 60;
        display: flex; align-items: center; justify-content: center;
        padding: calc(24px + env(safe-area-inset-top, 0px)) 22px calc(24px + env(safe-area-inset-bottom, 0px));
        background: rgba(203, 204, 208, 0.95);
        -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    .sheet[hidden] { display: none; }
    .sheet-panel { width: min(440px, 100%); max-height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; touch-action: pan-y; }
    .sheet-title {
        font-family: 'Syne', 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
        font-weight: 700; font-size: 14px; letter-spacing: 0.06em; margin-bottom: 16px;
    }
    .sheet-list { list-style: none; border-top: 1px solid var(--chalk); }
    .sheet-list button {
        width: 100%; min-height: 54px; padding: 6px 4px;
        display: flex; align-items: center; gap: 14px;
        background: none; border: 0; border-bottom: 1px solid var(--chalk);
        font: inherit; font-size: 16px; color: var(--soft); text-align: left; cursor: pointer;
    }
    .sheet-list button i { width: 8px; height: 8px; border: 1px solid currentColor; flex: none; }
    .sheet-list .num { font-size: 12px; color: var(--muted); width: 20px; flex: none; }
    .sheet-list .what { display: flex; flex-direction: column; gap: 2px; }
    .sheet-list .meta { font-size: 11px; color: var(--muted); }
    .sheet-list button[aria-current="true"] { color: var(--ink); }
    .sheet-list button[aria-current="true"] i { background: var(--ink); border-color: var(--ink); }
    .sheet-list button:focus-visible, .sheet-close:focus-visible { outline: 1px dashed var(--ink); outline-offset: -2px; }
    .sheet-help { margin-top: 14px; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
    .sheet-help span { display: block; }
    .sheet-help .touch { display: none; }
    @media (hover: none) { .sheet-help .keys { display: none; } .sheet-help .touch { display: block; } }
    .sheet-close {
        margin-top: 14px; min-height: 44px; padding: 0 10px;
        background: none; border: 0; font: inherit; font-size: 13px; color: var(--soft); cursor: pointer;
    }
    .hint {
        position: fixed;
        right: var(--pad-x);
        bottom: calc(var(--pad-y) + env(safe-area-inset-bottom, 0px));
        font-size: 12px; line-height: 1.5; color: var(--muted);
        text-align: right;
        z-index: 7;
    }
    .hint span { display: block; }
    .hint .touch { display: none; }
    @media (hover: none) { .hint .keys { display: none; } .hint .touch { display: block; } }

    /* ——— the instrument ——— */
    #stage {
        position: fixed; inset: 0; width: 100%; height: 100%;
        overflow: visible; touch-action: none; display: block; z-index: 2;
    }
    .tile  { fill: none; stroke: var(--chalk); stroke-width: 1.5; stroke-linejoin: miter; }
    .ghost { fill: none; stroke: var(--chalk); stroke-width: 1; stroke-dasharray: 2 4; opacity: 0; }
    .mark  { fill: none; stroke: var(--ink); stroke-width: 1; opacity: 0.3; }
    .hit   { fill: transparent; stroke: none; cursor: pointer; outline: none; }
    .hit:focus-visible { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 2 3; }
    .play-base  { fill: none; stroke: var(--chalk); stroke-width: 1.5; opacity: 0; }
    .play-trace { fill: none; stroke: var(--ink); stroke-width: 2; }
    #stage.playing .play-base { opacity: 1; }
    .play-word {
        font-family: 'Courier Prime', 'Courier New', Courier, ui-monospace, monospace;
        font-size: 13px; fill: var(--ink); text-anchor: middle; pointer-events: none;
    }
    .lead { stroke: var(--ink); stroke-width: 1; visibility: hidden; }
    .play-icon { fill: var(--ink); stroke: none; pointer-events: none; display: none; }
    .ring { fill: none; stroke: var(--ink); stroke-width: 1; pointer-events: none; }
    .trail { fill: none; stroke: #fff; stroke-width: 1.4; pointer-events: none; }
    .crack { fill: none; stroke: #fff; stroke-linejoin: round; stroke-linecap: round; pointer-events: none; }
    /* while the stage is moving, the empty grid draws solid and fainter: dashes are expensive to repaint every frame */
    #ghosts.moving .ghost { stroke-dasharray: none; stroke-opacity: 0.5; }

    .note {
        position: fixed; z-index: 6; pointer-events: none;
        font-size: 15px; line-height: 20px; margin-top: -10px;
        white-space: nowrap;
    }
    .note .sub { display: block; font-size: 11.5px; line-height: 15px; color: var(--soft); }
    .note.right  { text-align: right; }

    /* one callout per tile, shown while that tile is sounding */
    .callout {
        position: fixed; left: 0; top: 0; z-index: 6;
        margin-top: -9px;
        font-size: 13px; line-height: 18px; white-space: nowrap;
        pointer-events: none; visibility: hidden;
        will-change: transform, opacity;
    }
    .note.center { text-align: center; transform: translateX(-50%); }
</style>
</head>
<body>

<p class="wordmark">OwnZones</p>
<nav class="songs" id="songs" aria-label="Songs"></nav>

<svg id="stage" xmlns="http://www.w3.org/2000/svg" aria-label="Tile instrument">
    <g id="ghosts"></g>
    <g id="marks"></g>
    <g id="trails"></g>
    <g id="tiles"></g>
    <g id="rings"></g>
    <g id="cracks"></g>
    <g id="play"></g>
    <g id="leads"></g>
    <g id="hits"></g>
</svg>

<div class="transport" id="transport">
    <button class="step" id="prevTrack" type="button" aria-label="Previous track"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M9 2 L4 7 L9 12"/></svg></button>
    <button class="now" id="trackNow" type="button" aria-haspopup="dialog" aria-label="Show all tracks"><span class="main" id="statusMain"></span><span class="sub" id="statusSub"></span></button>
    <button class="step" id="nextTrack" type="button" aria-label="Next track"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M5 2 L10 7 L5 12"/></svg></button>
</div>

<div class="sheet" id="sheet" hidden>
    <div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheetTitle">
        <p class="sheet-title" id="sheetTitle">OwnZones</p>
        <ol class="sheet-list" id="sheetList"></ol>
        <p class="sheet-help">
            <span class="keys">hover.tiles.to.play</span><span class="keys">space.plays.and.stops</span><span class="keys nav-hint">arrow.keys.change.track</span>
            <span class="touch">slide.across.tiles.to.play</span><span class="touch">tap.play.in.the.centre</span><span class="touch nav-hint">swipe.to.change.track</span>
        </p>
        <button class="sheet-close" id="sheetClose" type="button">close</button>
    </div>
</div>
<div id="callouts" aria-hidden="true"></div>

<button class="sound" id="soundBtn" type="button" aria-pressed="false"><i aria-hidden="true"></i><span id="soundLabel">click.to.wake.audio</span></button>
<p class="hint"><span id="hintTiles">hover.any.tile</span><span class="keys" id="hintSpace">press.space.to.play</span><span class="keys nav-hint">arrow.keys.change.track</span><span class="touch nav-hint">swipe.to.change.track</span></p>

<script>
// ─────────────────────────────────────────────
//  AUDIO ENGINE
// ─────────────────────────────────────────────
let audioCtx = null;
let audioReady = false;
let muted = false;
const MASTER_LEVEL = 0.88;         // a little headroom after the limiter

function getCtx() {
    if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    return audioCtx;
}

// a song can sit in its own reverberant room; songs without one go straight to the master
const rooms = new Map();
function impulse(ctx, seconds, shape, tone = 1) {
    const len = Math.floor(ctx.sampleRate * seconds);
    const buf = ctx.createBuffer(2, len, ctx.sampleRate);
    for (let ch = 0; ch < 2; ch++) {
        const d = buf.getChannelData(ch);
        let smooth = 0;                                   // tone < 1 low-passes the room: a darker space
        for (let i = 0; i < len; i++) {
            smooth += tone * ((Math.random() * 2 - 1) - smooth);
            d[i] = smooth * Math.pow(1 - i / len, shape) * (tone < 1 ? 1.6 : 1);
        }
    }
    return buf;
}
function roomFor(song) {
    if (!song.room) return getMaster();
    let input = rooms.get(song);
    if (!input) {
        const ctx = getCtx();
        input = ctx.createGain();
        const conv = ctx.createConvolver(), wet = ctx.createGain();
        conv.buffer = impulse(ctx, song.room.seconds, song.room.shape, song.room.tone);
        wet.gain.value = song.room.wet;
        input.connect(getMaster());
        input.connect(conv); conv.connect(wet); wet.connect(getMaster());
        rooms.set(song, input);
    }
    return input;
}

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;
        const out = ctx.createGain();
        out.gain.value = muted ? 0 : MASTER_LEVEL;
        lim.connect(out); out.connect(ctx.destination);
        getMaster._node = lim;
        getMaster._out = out;
    }
    return getMaster._node;
}

// ─────────────────────────────────────────────
//  SOUND KIT
//  Small building blocks; every voice takes a start time `t`
//  so the song can be scheduled ahead on the audio clock.
// ─────────────────────────────────────────────
const noiseCache = new WeakMap();
function noiseSource(ctx) {
    let buf = noiseCache.get(ctx);
    if (!buf) {
        buf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
        noiseCache.set(ctx, buf);
    }
    const src = ctx.createBufferSource();
    src.buffer = buf;
    return src;
}

function decay(ctx, t, peak, len) {
    const g = ctx.createGain();
    g.gain.value = 0;
    g.gain.setValueAtTime(peak, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + len);
    return g;
}

function toneHit(ctx, out, t, { type = 'sine', freq, to, glide = 0, peak, len }) {
    const o = ctx.createOscillator();
    o.type = type;
    o.frequency.setValueAtTime(freq, t);
    if (to) o.frequency.exponentialRampToValueAtTime(to, t + glide);
    const g = decay(ctx, t, peak, len);
    o.connect(g); g.connect(out);
    o.start(t); o.stop(t + len + 0.03);
}

function noiseHit(ctx, out, t, { type, freq, Q = 1, peak, len }) {
    const src = noiseSource(ctx);
    const f = ctx.createBiquadFilter();
    f.type = type; f.frequency.value = freq; f.Q.value = Q;
    const g = decay(ctx, t, peak, len);
    src.connect(f); f.connect(g); g.connect(out);
    src.start(t, Math.random() * Math.max(0, 1.9 - len));
    src.stop(t + len + 0.03);
}

// voice families
const leadTone = freq => (ctx, out, t) => toneHit(ctx, out, t, { type: 'triangle', freq, peak: 0.45, len: 0.5 });
const kick  = (freq, to, glide, peak, len) => (ctx, out, t) => toneHit(ctx, out, t, { freq, to, glide, peak, len });
const tom   = (freq, to, glide, peak, len) => (ctx, out, t) => toneHit(ctx, out, t, { type: 'sawtooth', freq, to, glide, peak, len });
const hat   = (cut, len, peak) => (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: cut, peak, len });
const strum = freqs => (ctx, out, t) => freqs.forEach((freq, i) =>
    toneHit(ctx, out, t + i * 0.04, { freq, peak: 0.18, len: 0.66 - i * 0.04 }));
const sawBass = freq => (ctx, out, t) => {
    const o = ctx.createOscillator(), lp = ctx.createBiquadFilter();
    o.type = 'sawtooth'; o.frequency.value = freq;
    lp.type = 'lowpass'; lp.frequency.value = 800; lp.Q.value = 8;
    const g = decay(ctx, t, 0.5, 0.4);
    o.connect(lp); lp.connect(g); g.connect(out);
    o.start(t); o.stop(t + 0.45);
};

// one-off voices (most of these are the original instrument's sounds, unchanged)
const voices = {
    boom: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 75, to: 24, glide: 0.9, peak: 0.62, len: 1.3 });
        toneHit(ctx, out, t, { freq: 45, peak: 0.26, len: 1.0 });
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 900, peak: 0.25, len: 0.08 });
    },
    snare: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'highpass', freq: 1800, peak: 0.42, len: 0.14 });
        toneHit(ctx, out, t, { type: 'triangle', freq: 190, to: 150, glide: 0.08, peak: 0.3, len: 0.09 });
    },
    clap: (ctx, out, t) => {
        for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.011, { type: 'bandpass', freq: 1500, Q: 1.2, peak: 0.4, len: 0.018 });
        noiseHit(ctx, out, t + 0.033, { type: 'bandpass', freq: 1400, peak: 0.3, len: 0.16 });
    },
    wood: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 760, to: 700, glide: 0.05, peak: 0.45, len: 0.07 });
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 1200, Q: 5, peak: 0.25, len: 0.025 });
    },
    crash: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'highpass', freq: 3200, Q: 0.7, peak: 0.3, len: 1.4 });
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 6500, Q: 0.8, peak: 0.15, len: 0.9 });
    },
    cowbell: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter();
        bp.type = 'bandpass'; bp.frequency.value = 800; bp.Q.value = 2.5;
        const g = decay(ctx, t, 0.22, 0.32);
        bp.connect(g); g.connect(out);
        [540, 800].forEach(freq => {
            const o = ctx.createOscillator();
            o.type = 'square'; o.frequency.value = freq;
            o.connect(bp); o.start(t); o.stop(t + 0.35);
        });
    },
    rim: (ctx, out, t) => {
        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 = decay(ctx, t, 0.6, 0.06);
        src.connect(env); env.connect(out);
        src.start(t); src.stop(t + 0.08);
    },
    rise: (ctx, out, t) => {
        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 = decay(ctx, t, 0.3, 0.4);
        src.connect(filter); filter.connect(env); env.connect(out);
        src.start(t); src.stop(t + 0.45);
    },
    zap:  (ctx, out, t) => toneHit(ctx, out, t, { freq: 220, to: 660, glide: 0.3, peak: 0.35, len: 0.35 }),
    fall: (ctx, out, t) => toneHit(ctx, out, t, { freq: 880, to: 110, glide: 0.5, peak: 0.35, len: 0.55 }),
    glide: (ctx, out, t) => toneHit(ctx, out, t, { type: 'triangle', freq: 261.63, to: 523.25, glide: 0.2, peak: 0.35, len: 0.3 }),
    ping: (ctx, out, t) => toneHit(ctx, out, t, { freq: 1318.51, peak: 0.3, len: 0.6 }),
    bell: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 523.25, peak: 0.4, len: 0.8 });
        toneHit(ctx, out, t, { freq: 1046.5, peak: 0.15, len: 0.8 });
    },
    vibrato: (ctx, out, t) => {
        const carrier = ctx.createOscillator(), lfo = ctx.createOscillator(), lfoGain = ctx.createGain();
        lfo.frequency.value = 8; lfoGain.gain.value = 60;
        carrier.type = 'sine'; carrier.frequency.value = 440;
        lfo.connect(lfoGain); lfoGain.connect(carrier.frequency);
        const env = decay(ctx, t, 0.3, 0.5);
        carrier.connect(env); env.connect(out);
        lfo.start(t); carrier.start(t); lfo.stop(t + 0.55); carrier.stop(t + 0.55);
    },
    arp: (ctx, out, t) => [523.25, 659.25, 783.99, 1046.5].forEach((freq, i) =>
        toneHit(ctx, out, t + i * 0.06, { type: 'triangle', freq, peak: 0.22, len: 0.15 })),
    dyad: (ctx, out, t) => [659.25, 830.61].forEach((freq, i) =>
        toneHit(ctx, out, t + i * 0.05, { freq, peak: 0.2, len: 0.55 - i * 0.05 })),
    squareBass: (ctx, out, t) => toneHit(ctx, out, t, { type: 'square', freq: 130.81, peak: 0.25, len: 0.35 }),
    grit: (ctx, out, t) => {
        const osc = ctx.createOscillator(), wave = ctx.createWaveShaper();
        const curve = new Float32Array(257);      // odd length: silence maps exactly to 0
        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;
        const env = decay(ctx, t, 0.6, 0.15);
        osc.connect(wave); wave.connect(env); env.connect(out);
        osc.start(t); osc.stop(t + 0.2);
    },
};

// ─────────────────────────────────────────────
//  KITS: each song brings its own 48 sounds
//  Laid out in rings around the play button, each listed clockwise from its
//  top-left corner. Every tile has its own sound, its own mix level (vel),
//  and its own way of disturbing the grid when it sounds (hit):
//    push > 0 shoves neighbours away, push < 0 pulls them in,
//    plane sweeps the grid in one direction, lift raises tiles toward you.
// ─────────────────────────────────────────────

// Track 1, "Nomo Chant": triangle lead, chords and bass, a full drum kit
const KIT_TESSELLATE = {
    // ring 1: the lead, an A-minor scale climbing clockwise round the centre
    1: [
        ['lead.e4', leadTone(329.63)], ['lead.f4', leadTone(349.23)], ['lead.g4', leadTone(392.0)], ['lead.a4', leadTone(440.0)],
        ['lead.b4', leadTone(493.88)], ['lead.c5', leadTone(523.25)], ['lead.d5', leadTone(587.33)], ['lead.e5', leadTone(659.25)],
    ],
    // ring 2: colour on top, chords down the right, bass climbing round the bottom and left
    2: [
        ['arp.c.major', voices.arp,   { vel: 0.85, hit: { push: 40 } }],
        ['ping.e6',     voices.ping,  { vel: 0.75 }],
        ['bell.c5',     voices.bell,  { vel: 0.85 }],
        ['vibrato.a4',  voices.vibrato, { hit: { push: 15, lift: 35 } }],
        ['glide.c4.c5', voices.glide, { vel: 0.8, hit: { plane: [1, 0], push: 90 } }],
        ['chord.am',    strum([440, 523.25, 659.25]),    { hit: { push: 25, lift: 45 } }],
        ['chord.c',     strum([523.25, 659.25, 783.99]), { hit: { push: 25, lift: 45 } }],
        ['chord.f',     strum([349.23, 440, 523.25]),    { hit: { push: 25, lift: 45 } }],
        ['chord.e',     strum([329.63, 415.3, 493.88]),  { hit: { push: 25, lift: 45 } }],
        ['grit.e2',     voices.grit,   { vel: 0.55, hit: { push: 150 } }],
        ['bass.f2',     sawBass(87.31), { vel: 0.85, hit: { push: 50 } }],
        ['bass.g2',     sawBass(98.0),  { vel: 0.85, hit: { push: 50 } }],
        ['bass.a2',     sawBass(110.0), { vel: 0.85, hit: { push: 50 } }],
        ['bass.c3',     voices.squareBass, { vel: 0.95, hit: { push: 50 } }],
        ['bass.d3',     sawBass(146.83), { vel: 0.85, hit: { push: 50 } }],
        ['dyad.e5.g#5', voices.dyad],
    ],
    // ring 3: kicks in the corners, snares mid-side, hi-hats either side of each corner
    3: [
        ['kick.deep',   kick(100, 28, 0.22, 0.8, 0.32), { hit: { push: 160 } }],
        ['hat.1',       hat(5200, 0.05, 0.45),  { vel: 0.5 }],
        ['rise',        voices.rise,  { vel: 0.7, hit: { plane: [0, -1], push: 80, lift: 60 } }],
        ['rim.click',   voices.rim,   { vel: 0.75, hit: { push: -35 } }],
        ['zap',         voices.zap,   { vel: 0.6, hit: { push: 130 } }],
        ['hat.2',       hat(6000, 0.08, 0.5),   { vel: 0.5 }],
        ['kick.mid',    kick(120, 30, 0.18, 0.7, 0.25), { vel: 0.9, hit: { push: 140 } }],
        ['hat.3',       hat(6800, 0.05, 0.45),  { vel: 0.5 }],
        ['tom.high',    tom(200, 50, 0.12, 0.5, 0.18),  { vel: 0.8, hit: { push: 95 } }],
        ['snare.tight', voices.snare, { vel: 0.8, hit: { push: -70 } }],
        ['tom.mid',     tom(180, 45, 0.16, 0.52, 0.2),  { vel: 0.82, hit: { push: 100 } }],
        ['hat.4',       hat(7600, 0.07, 0.45),  { vel: 0.5 }],
        ['kick.high',   kick(150, 45, 0.12, 0.6, 0.18), { vel: 0.8, hit: { push: 110 } }],
        ['hat.5',       hat(8400, 0.05, 0.42),  { vel: 0.5 }],
        ['tom.low',     tom(160, 40, 0.2, 0.55, 0.22),  { vel: 0.85, hit: { push: 110 } }],
        ['clap',        voices.clap,  { vel: 0.8, hit: { push: -80 } }],
        ['cowbell',     voices.cowbell, { vel: 0.5, hit: { push: 30 } }],
        ['hat.6',       hat(9200, 0.07, 0.42),  { vel: 0.5 }],
        ['boom',        voices.boom,  { hit: { push: 520, reach: 350 } }],
        ['hat.7',       hat(10000, 0.05, 0.4),  { vel: 0.5 }],
        ['fall',        voices.fall,  { vel: 0.5, hit: { plane: [0, 1], push: 150 } }],
        ['rim.wood',    voices.wood,  { vel: 0.6, hit: { push: -30 } }],
        ['crash',       voices.crash, { vel: 0.7, hit: { push: 230, reach: 260 } }],
        ['hat.open',    hat(4600, 0.32, 0.38),  { vel: 0.45 }],
    ],
};

// repeats a voice like a tape echo: each repeat quieter and duller
function echoes(voice, repeats, spacing, fall) {
    return (ctx, out, t) => {
        for (let k = 0; k <= repeats; k++) {
            let dest = out;
            if (k > 0) {
                const g = ctx.createGain(), lp = ctx.createBiquadFilter();
                g.gain.value = Math.pow(fall, k);
                lp.type = 'lowpass'; lp.frequency.value = 5000 / (k + 0.5);
                g.connect(lp); lp.connect(out);
                dest = g;
            }
            voice(ctx, dest, t + k * spacing);
        }
    };
}

const sirenVoice = (ctx, out, t) => {      // a dub siren: a square wave swooping up and down
    const o = ctx.createOscillator(), lp = ctx.createBiquadFilter(), g = ctx.createGain();
    o.type = 'square';
    o.frequency.setValueAtTime(420, t);
    [[0.3, 1150], [0.6, 480], [0.9, 1250], [1.2, 450]].forEach(([d, f]) => o.frequency.exponentialRampToValueAtTime(f, t + d));
    lp.type = 'lowpass'; lp.frequency.value = 2400;
    g.gain.value = 0;
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(0.1, t + 0.03);
    g.gain.setValueAtTime(0.1, t + 1.0);
    g.gain.exponentialRampToValueAtTime(0.001, t + 1.35);
    o.connect(lp); lp.connect(g); g.connect(out);
    o.start(t); o.stop(t + 1.4);
};

// Shared building blocks used by several kits

// a gain that swells in, holds, then dies away
function swell(ctx, t, peak, attack, hold, release) {
    const g = ctx.createGain();
    g.gain.value = 0;
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(peak, t + attack);
    g.gain.setValueAtTime(peak, t + attack + hold);
    g.gain.exponentialRampToValueAtTime(0.001, t + attack + hold + release);
    return g;
}
// filtered noise that can run longer than the noise buffer
function noiseBed(ctx, t, filter, g, stopAt) {
    const src = noiseSource(ctx);
    src.loop = true;
    src.connect(filter); filter.connect(g);
    src.start(t, Math.random() * 1.5); src.stop(stopAt);
}

const drone = (freqs, peak) => (ctx, out, t) => {       // a slow, breathing low string drone, about 7 s
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.Q.value = 1.5;
    lp.frequency.setValueAtTime(300, t);
    lp.frequency.linearRampToValueAtTime(900, t + 3);
    lp.frequency.linearRampToValueAtTime(350, t + 7);
    const g = swell(ctx, t, peak, 1.4, 3.2, 2.6);
    lp.connect(g); g.connect(out);
    freqs.forEach(freq => [-2.5, 2.5].forEach(det => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
        og.gain.value = 0.25;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 7.3);
    }));
};

// struck metal: partials at fixed ratios, each doubled slightly sharp so it beats and shimmers
const metal = (base, ratios, peak, len) => (ctx, out, t) => ratios.forEach((r, i) => {
    toneHit(ctx, out, t, { freq: base * r, peak: peak / (i + 1), len: len / (1 + i * 0.3) });
    toneHit(ctx, out, t, { freq: base * r * 1.003, peak: peak / (i + 1) * 0.6, len: len / (1 + i * 0.3) });
});

// Track 2, "Numerous Under One": drum and bass at 174 bpm in F minor.
const DNB_STEP = 60 / 174 / 4;

// a bright synth pluck with two quick echoes
const pluck = freq => echoes((ctx, out, t) => {
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.Q.value = 4;
    lp.frequency.setValueAtTime(5000, t);
    lp.frequency.exponentialRampToValueAtTime(700, t + 0.18);
    const g = decay(ctx, t, 0.2, 0.35);
    lp.connect(g); g.connect(out);
    [1, 1.006].forEach(mul => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq * mul;
        og.gain.value = 0.5;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 0.4);
    });
}, 2, 3 * DNB_STEP, 0.35);

// reese: two saws detuned against each other, a filter that opens and closes, a clean sub underneath
const reese = freq => (ctx, out, t) => {
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.Q.value = 2;
    lp.frequency.setValueAtTime(250, t);
    lp.frequency.linearRampToValueAtTime(900, t + 0.35);
    lp.frequency.linearRampToValueAtTime(400, t + 0.9);
    const g = ctx.createGain();
    g.gain.value = 0;
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(0.22, t + 0.01);
    g.gain.exponentialRampToValueAtTime(0.12, t + 0.4);
    g.gain.exponentialRampToValueAtTime(0.001, t + 1.0);
    lp.connect(g); g.connect(out);
    [-14, 14].forEach(det => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq * 2; o.detune.value = det;
        og.gain.value = 0.5;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 1.05);
    });
    const sub = ctx.createOscillator(), sg = ctx.createGain();
    sub.type = 'sine'; sub.frequency.value = freq;
    sg.gain.value = 0.7;
    sub.connect(sg); sg.connect(g);
    sub.start(t); sub.stop(t + 1.05);
};

const airPad = freqs => (ctx, out, t) => {              // saws through a soft filter, about 4 s
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.frequency.value = 1400; lp.Q.value = 0.5;
    const g = swell(ctx, t, 0.09, 0.8, 1.8, 1.6);
    lp.connect(g); g.connect(out);
    freqs.forEach(freq => [-7, 7].forEach(det => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
        og.gain.value = 0.2;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 4.3);
    }));
};

const chordStab = freqs => (ctx, out, t) => {
    const bp = ctx.createBiquadFilter();
    bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.8;
    const g = decay(ctx, t, 0.3, 0.22);
    bp.connect(g); g.connect(out);
    freqs.forEach(freq => ['sawtooth', 'square'].forEach(type => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = type; o.frequency.value = freq;
        og.gain.value = 0.18;
        o.connect(og); og.connect(bp);
        o.start(t); o.stop(t + 0.25);
    }));
};

const dnb = {
    kickPunch: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 160, to: 45, glide: 0.08, peak: 0.9, len: 0.28 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 3000, peak: 0.2, len: 0.012 });
    },
    kickSub: (ctx, out, t) => toneHit(ctx, out, t, { freq: 70, to: 35, glide: 0.2, peak: 0.8, len: 0.5 }),
    snare: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 2200, Q: 0.6, peak: 0.5, len: 0.18 });
        toneHit(ctx, out, t, { type: 'triangle', freq: 220, to: 180, glide: 0.05, peak: 0.35, len: 0.1 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 5000, peak: 0.2, len: 0.08 });
    },
    ghost: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 2000, peak: 0.22, len: 0.07 });
        toneHit(ctx, out, t, { type: 'triangle', freq: 230, peak: 0.12, len: 0.04 });
    },
    tom: freq => (ctx, out, t) => toneHit(ctx, out, t, { freq, to: freq * 0.4, glide: 0.15, peak: 0.55, len: 0.22 }),
    ride: (ctx, out, t) => {
        metal(3150, [1, 1.47, 2.09], 0.06, 0.9)(ctx, out, t);
        noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.1, len: 0.5 });
    },
    shaker: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 7000, peak: 0.15, len: 0.05 }),
    tamb: (ctx, out, t) => {
        [6000, 7800, 9400].forEach(freq => toneHit(ctx, out, t, { freq, peak: 0.05, len: 0.1 }));
        noiseHit(ctx, out, t, { type: 'highpass', freq: 8000, peak: 0.12, len: 0.08 });
    },
    riser: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter();
        bp.type = 'bandpass'; bp.Q.value = 3;
        bp.frequency.setValueAtTime(400, t);
        bp.frequency.exponentialRampToValueAtTime(6000, t + 1.3);
        const g = swell(ctx, t, 0.15, 1.2, 0.05, 0.2);
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 1.5);
        toneHit(ctx, out, t, { type: 'sawtooth', freq: 200, to: 1200, glide: 1.3, peak: 0.03, len: 1.4 });
    },
    impact: (ctx, out, t) => {
        voices.boom(ctx, out, t);
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 2000, peak: 0.3, len: 0.5 });
    },
    downlifter: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter();
        bp.type = 'bandpass'; bp.Q.value = 2;
        bp.frequency.setValueAtTime(5000, t);
        bp.frequency.exponentialRampToValueAtTime(300, t + 1.2);
        const g = swell(ctx, t, 0.14, 0.02, 0.3, 1.0);
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 1.4);
        toneHit(ctx, out, t, { freq: 900, to: 80, glide: 1.1, peak: 0.12, len: 1.2 });
    },
    reverse: (ctx, out, t) => {                          // a cymbal played backwards: swelling up, then cut
        const hp = ctx.createBiquadFilter();
        hp.type = 'highpass'; hp.frequency.value = 4000;
        const g = ctx.createGain();
        g.gain.value = 0;
        g.gain.setValueAtTime(0.001, t);
        g.gain.exponentialRampToValueAtTime(0.25, t + 1.1);
        g.gain.linearRampToValueAtTime(0, t + 1.15);
        g.connect(out);
        noiseBed(ctx, t, hp, g, t + 1.2);
    },
    vox: (ctx, out, t) => {                              // a sung "ah": a buzzy tone through two vowel formants
        const src = ctx.createOscillator(), vib = ctx.createOscillator(), vd = ctx.createGain();
        src.type = 'sawtooth'; src.frequency.value = 220;
        vib.frequency.value = 5.5; vd.gain.value = 3;
        vib.connect(vd); vd.connect(src.frequency);
        const g = swell(ctx, t, 0.3, 0.03, 0.25, 0.35);
        g.connect(out);
        [[700, 8, 1], [1220, 10, 0.6]].forEach(([freq, Q, lvl]) => {
            const bp = ctx.createBiquadFilter(), fg = ctx.createGain();
            bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
            fg.gain.value = lvl;
            src.connect(bp); bp.connect(fg); fg.connect(g);
        });
        src.start(t); vib.start(t); src.stop(t + 0.7); vib.stop(t + 0.7);
    },
    laser: echoes((ctx, out, t) =>
        toneHit(ctx, out, t, { type: 'square', freq: 2000, to: 200, glide: 0.25, peak: 0.08, len: 0.28 }), 2, 3 * DNB_STEP, 0.4),
};

const KIT_DNB = {
    // ring 1: plucked lead, the F-minor scale climbing clockwise round the centre
    1: [
        ['lead.f4', pluck(349.23)], ['lead.g4', pluck(392.0)], ['lead.a♭4', pluck(415.3)], ['lead.b♭4', pluck(466.16)],
        ['lead.c5', pluck(523.25)], ['lead.d♭5', pluck(554.37)], ['lead.e♭5', pluck(622.25)], ['lead.f5', pluck(698.46)],
    ].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
    // ring 2: pads along the top, stabs down the right, reese bass climbing round the bottom and left
    2: [
        ['pad.fm',  airPad([174.61, 207.65, 261.63, 311.13]), { hit: { push: 15, lift: 40 } }],
        ['pad.db',  airPad([138.59, 174.61, 207.65, 261.63]), { hit: { push: 15, lift: 40 } }],
        ['pad.ab',  airPad([207.65, 261.63, 311.13, 392.0]),  { hit: { push: 15, lift: 40 } }],
        ['pad.eb',  airPad([155.56, 196.0, 233.08, 277.18]),  { hit: { push: 15, lift: 40 } }],
        ['stab.fm', chordStab([349.23, 415.3, 523.25, 622.25]), { vel: 0.8, hit: { push: 50 } }],
        ['stab.db', chordStab([277.18, 349.23, 415.3, 523.25]), { vel: 0.8, hit: { push: 50 } }],
        ['stab.ab', chordStab([415.3, 523.25, 622.25, 783.99]), { vel: 0.8, hit: { push: 50 } }],
        ['stab.eb', chordStab([311.13, 392.0, 466.16, 554.37]), { vel: 0.8, hit: { push: 50 } }],
        ['reese.f1',  reese(43.65), { hit: { push: 70 } }],
        ['reese.g1',  reese(49.0),  { hit: { push: 70 } }],
        ['reese.a♭1', reese(51.91), { hit: { push: 70 } }],
        ['reese.b♭1', reese(58.27), { hit: { push: 70 } }],
        ['reese.c2',  reese(65.41), { hit: { push: 70 } }],
        ['reese.d♭2', reese(69.3),  { hit: { push: 70 } }],
        ['reese.e♭2', reese(77.78), { hit: { push: 70 } }],
        ['reese.f2',  reese(87.31), { hit: { push: 70 } }],
    ],
    // ring 3: kicks, crash and impact in the corners, snares mid-side, hats round the corners, effects between
    3: [
        ['kick.punch',  dnb.kickPunch,  { hit: { push: 170 } }],
        ['hat.1',       hat(8000, 0.03, 0.3),   { vel: 0.5 }],
        ['tamb',        dnb.tamb,       { vel: 0.5 }],
        ['snare.main',  dnb.snare,      { vel: 0.85, hit: { push: -90 } }],
        ['riser',       dnb.riser,      { vel: 0.8, hit: { plane: [0, -1], push: 60, lift: 60 } }],
        ['hat.2',       hat(9000, 0.035, 0.28), { vel: 0.5 }],
        ['kick.sub',    dnb.kickSub,    { vel: 0.85, hit: { push: 150 } }],
        ['hat.3',       hat(7500, 0.03, 0.3),   { vel: 0.5 }],
        ['tom.high',    dnb.tom(260),   { vel: 0.8, hit: { push: 80 } }],
        ['snare.ghost', dnb.ghost,      { vel: 0.6, hit: { push: -20 } }],
        ['tom.low',     dnb.tom(170),   { vel: 0.85, hit: { push: 100 } }],
        ['hat.4',       hat(10000, 0.03, 0.26), { vel: 0.5 }],
        ['crash',       voices.crash,   { vel: 0.7, hit: { push: 240, reach: 270 } }],
        ['hat.5',       hat(8500, 0.04, 0.28),  { vel: 0.5 }],
        ['reverse',     dnb.reverse,    { vel: 0.8, hit: { push: -120, reach: 400 } }],
        ['snare.clap',  voices.clap,    { vel: 0.7, hit: { push: -70 } }],
        ['downlifter',  dnb.downlifter, { vel: 0.8, hit: { plane: [0, 1], push: 120 } }],
        ['hat.open',    hat(6500, 0.22, 0.28),  { vel: 0.45 }],
        ['impact',      dnb.impact,     { hit: { push: 520, reach: 380 } }],
        ['ride',        dnb.ride,       { vel: 0.5 }],
        ['vox.ah',      dnb.vox,        { vel: 0.7, hit: { push: 30, lift: 30 } }],
        ['snare.rim',   voices.rim,     { vel: 0.6, hit: { push: -30 } }],
        ['laser',       dnb.laser,      { vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
        ['shaker',      dnb.shaker,     { vel: 0.5 }],
    ],
};


// Track 3, "All You Twin": 2000s IDM in E minor at 152 bpm. A warped music box and warm pads
// over acid bass and drill-fast, scattered drums full of digital debris.

// a staircase waveshaper: quantises the signal to a few levels, like a low-bit sampler
const crushCurves = {};
function crushCurve(levels) {
    if (!crushCurves[levels]) {
        const n = 1024, c = new Float32Array(n + 1);              // odd length: silence stays at 0
        for (let i = 0; i <= n; i++) { const x = i / n * 2 - 1; c[i] = Math.round(x * levels) / levels; }
        crushCurves[levels] = c;
    }
    return crushCurves[levels];
}
function crusher(ctx, levels) {
    const w = ctx.createWaveShaper();
    w.curve = crushCurve(levels);
    return w;
}

const musicBox = freq => (ctx, out, t) => {           // a small metal tine on worn tape: bright, wobbling slightly
    const warble = ctx.createOscillator(), depth = ctx.createGain();
    warble.frequency.value = 0.7 + Math.random() * 0.4;
    depth.gain.value = freq * 0.004;
    warble.connect(depth);
    [[1, 0.2, 1.6], [2, 0.04, 0.8], [4.02, 0.05, 0.35]].forEach(([mul, peak, len]) => {
        const o = ctx.createOscillator(), g = decay(ctx, t, peak, len);
        o.type = 'sine'; o.frequency.value = freq * mul;
        depth.connect(o.frequency);
        o.connect(g); g.connect(out);
        o.start(t); o.stop(t + len + 0.05);
    });
    warble.start(t); warble.stop(t + 1.7);
};

const acid = freq => (ctx, out, t) => {               // a squelching resonant bassline note
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.Q.value = 14;
    lp.frequency.setValueAtTime(260, t);
    lp.frequency.exponentialRampToValueAtTime(2400, t + 0.015);
    lp.frequency.exponentialRampToValueAtTime(280, t + 0.2);
    const drive = ctx.createWaveShaper();
    drive.curve = crushCurve(24);
    const g = decay(ctx, t, 0.3, 0.24);
    lp.connect(drive); drive.connect(g); g.connect(out);
    const o = ctx.createOscillator();
    o.type = 'sawtooth'; o.frequency.value = freq;
    o.connect(lp); o.start(t); o.stop(t + 0.27);
};

const warmPad = freqs => (ctx, out, t) => {           // detuned saws, softened, with a slow tape wow
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.frequency.value = 900; lp.Q.value = 0.6;
    const g = swell(ctx, t, 0.13, 0.9, 1.8, 1.8);
    lp.connect(g); g.connect(out);
    const wow = ctx.createOscillator(), wd = ctx.createGain();
    wow.frequency.value = 0.45; wd.gain.value = 9;              // cents
    wow.connect(wd);
    freqs.forEach(freq => [-9, 9].forEach(det => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
        wd.connect(o.detune);
        og.gain.value = 0.17;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 4.6);
    }));
    wow.start(t); wow.stop(t + 4.6);
};

const idm = {
    kickHard: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 150, to: 48, glide: 0.07, peak: 0.75, len: 0.25 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 4000, peak: 0.25, len: 0.008 });
    },
    kickShort: (ctx, out, t) => toneHit(ctx, out, t, { freq: 180, to: 60, glide: 0.04, peak: 0.8, len: 0.12 }),
    kick808: (ctx, out, t) => {
        const drive = ctx.createWaveShaper(), g = decay(ctx, t, 0.5, 1.2);
        drive.curve = crushCurve(40);
        drive.connect(g); g.connect(out);
        const o = ctx.createOscillator();
        o.type = 'sine';
        o.frequency.setValueAtTime(60, t);
        o.frequency.exponentialRampToValueAtTime(42, t + 0.4);
        o.connect(drive); o.start(t); o.stop(t + 1.25);
    },
    subDrop: (ctx, out, t) => {                           // a long falling sub: the floor giving way
        toneHit(ctx, out, t, { freq: 120, to: 25, glide: 1.5, peak: 0.7, len: 1.8 });
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 600, peak: 0.35, len: 0.3 });
    },
    snareCrack: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 3000, peak: 0.5, len: 0.1 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.25, len: 0.05 });
        toneHit(ctx, out, t, { type: 'triangle', freq: 250, peak: 0.2, len: 0.05 });
    },
    snare808: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'highpass', freq: 1500, peak: 0.4, len: 0.2 });
        toneHit(ctx, out, t, { freq: 180, peak: 0.25, len: 0.15 });
        toneHit(ctx, out, t, { freq: 330, peak: 0.15, len: 0.1 });
    },
    clapGlitch: (ctx, out, t) => {                        // a clap that never claps the same way twice
        const n = 2 + Math.floor(Math.random() * 4);
        for (let i = 0; i < n; i++) noiseHit(ctx, out, t + i * (0.006 + Math.random() * 0.012), { type: 'bandpass', freq: 1300 + Math.random() * 600, Q: 1, peak: 0.35, len: 0.02 });
        noiseHit(ctx, out, t + 0.04, { type: 'bandpass', freq: 1200, peak: 0.25, len: 0.12 });
    },
    snareGhost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.15, len: 0.05 }),
    click: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 9000, peak: 0.5, len: 0.004 }),
    click2: (ctx, out, t) => toneHit(ctx, out, t, { type: 'square', freq: 3000, peak: 0.3, len: 0.006 }),
    blip: (ctx, out, t) => toneHit(ctx, out, t, { freq: 2200, peak: 0.25, len: 0.03 }),
    bleep: (ctx, out, t) => {
        toneHit(ctx, out, t, { type: 'square', freq: 1320, peak: 0.12, len: 0.025 });
        toneHit(ctx, out, t + 0.03, { type: 'square', freq: 1760, peak: 0.1, len: 0.03 });
    },
    zap: (ctx, out, t) => toneHit(ctx, out, t, { freq: 4000, to: 150, glide: 0.03, peak: 0.3, len: 0.04 }),
    crush: (ctx, out, t) => {                             // a burst of 2-bit noise
        const src = noiseSource(ctx), cr = crusher(ctx, 2), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.4, 0.12);
        bp.type = 'bandpass'; bp.frequency.value = 1500; bp.Q.value = 0.7;
        src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
        src.start(t, Math.random()); src.stop(t + 0.14);
    },
    static: (ctx, out, t) => {                            // vinyl crackle
        for (let i = 0; i < 12; i++) noiseHit(ctx, out, t + Math.random() * 0.45, { type: 'highpass', freq: 3000, peak: 0.05 + Math.random() * 0.2, len: 0.004 });
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 5000, Q: 0.5, peak: 0.03, len: 0.5 });
    },
    stutter: (ctx, out, t) => {                           // a tiny buffer caught repeating, faster and faster
        let ti = t;
        for (let i = 0; i < 9; i++) {
            noiseHit(ctx, out, ti, { type: 'bandpass', freq: 1800, Q: 2, peak: 0.25, len: 0.02 });
            toneHit(ctx, out, ti, { type: 'square', freq: 440, peak: 0.06, len: 0.02 });
            ti += 0.06 * Math.pow(0.82, i);
        }
    },
    metalPing: (ctx, out, t) => {                         // FM: one oscillator wobbling another at an odd ratio
        const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.16, 0.5);
        car.frequency.value = 1500; mod.frequency.value = 1500 * 1.4757;
        idx.gain.setValueAtTime(1800, t); idx.gain.exponentialRampToValueAtTime(20, t + 0.4);
        mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
        car.start(t); mod.start(t); car.stop(t + 0.55); mod.stop(t + 0.55);
    },
    fmBell: (ctx, out, t) => {
        const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.14, 1.3);
        car.frequency.value = 659.25; mod.frequency.value = 659.25 * 3.5;
        idx.gain.setValueAtTime(1600, t); idx.gain.exponentialRampToValueAtTime(30, t + 1.0);
        mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
        car.start(t); mod.start(t); car.stop(t + 1.35); mod.stop(t + 1.35);
    },
    bitChord: (ctx, out, t) => {                          // an E-minor stab through a 3-bit crusher
        const cr = crusher(ctx, 3), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 0.3);
        lp.type = 'lowpass'; lp.frequency.value = 4000;
        cr.connect(lp); lp.connect(g); g.connect(out);
        [329.63, 392.0, 493.88].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth'; o.frequency.value = freq;
            og.gain.value = 0.3;
            o.connect(og); og.connect(cr);
            o.start(t); o.stop(t + 0.32);
        });
    },
    grainVox: (ctx, out, t) => {                          // a vowel chopped into grains, each at a slightly different pitch
        for (let i = 0; i < 7; i++) {
            const ti = t + i * 0.055, src = ctx.createOscillator(), g = decay(ctx, ti, 0.22, 0.035);
            src.type = 'sawtooth'; src.frequency.value = 180 * Math.pow(2, (Math.floor(Math.random() * 5) - 2) / 12);
            g.connect(out);
            [[600, 7], [1000, 9]].forEach(([freq, Q]) => {
                const bp = ctx.createBiquadFilter();
                bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
                src.connect(bp); bp.connect(g);
            });
            src.start(ti); src.stop(ti + 0.045);
        }
    },
    tapeStop: (ctx, out, t) => {                          // the tape slowing to a halt
        const lp = ctx.createBiquadFilter(), g = ctx.createGain();
        lp.type = 'lowpass';
        lp.frequency.setValueAtTime(3000, t);
        lp.frequency.exponentialRampToValueAtTime(200, t + 0.8);
        g.gain.value = 0;
        g.gain.setValueAtTime(0.2, t);
        g.gain.linearRampToValueAtTime(0, t + 0.85);
        lp.connect(g); g.connect(out);
        [164.81, 246.94, 329.63].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth';
            o.frequency.setValueAtTime(freq, t);
            o.frequency.exponentialRampToValueAtTime(freq * 0.2, t + 0.8);
            og.gain.value = 0.3;
            o.connect(og); og.connect(lp);
            o.start(t); o.stop(t + 0.88);
        });
    },
    reverseSnare: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter(), g = ctx.createGain();
        bp.type = 'bandpass'; bp.frequency.value = 2500; bp.Q.value = 0.6;
        g.gain.value = 0;
        g.gain.setValueAtTime(0.001, t);
        g.gain.exponentialRampToValueAtTime(0.4, t + 0.45);
        g.gain.linearRampToValueAtTime(0, t + 0.47);
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 0.5);
    },
};

const KIT_IDM = {
    // ring 1: music box, E minor climbing clockwise round the centre
    1: [
        ['box.e4', musicBox(329.63)], ['box.f♯4', musicBox(369.99)], ['box.g4', musicBox(392.0)], ['box.a4', musicBox(440.0)],
        ['box.b4', musicBox(493.88)], ['box.c5', musicBox(523.25)], ['box.d5', musicBox(587.33)], ['box.e5', musicBox(659.25)],
    ].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
    // ring 2: acid along the top and down the right, glitch-tonal along the bottom, pads up the left
    2: [
        ['acid.e2', acid(82.41),  { vel: 0.8, hit: { push: 35 } }],
        ['acid.g2', acid(98.0),   { vel: 0.8, hit: { push: 35 } }],
        ['acid.a2', acid(110.0),  { vel: 0.8, hit: { push: 35 } }],
        ['acid.b2', acid(123.47), { vel: 0.8, hit: { push: 35 } }],
        ['acid.d3', acid(146.83), { vel: 0.8, hit: { push: 35 } }],
        ['acid.e3', acid(164.81), { vel: 0.8, hit: { push: 35 } }],
        ['acid.g3', acid(196.0),  { vel: 0.8, hit: { push: 35 } }],
        ['acid.b3', acid(246.94), { vel: 0.8, hit: { push: 35 } }],
        ['bit.chord', idm.bitChord, { vel: 0.8, hit: { push: 140, reach: 300, scatter: true } }],
        ['fm.bell',   idm.fmBell,   { vel: 0.8, hit: { push: 30 } }],
        ['grain.vox', idm.grainVox, { vel: 0.8, hit: { push: 60, reach: 300, lift: 30, scatter: true } }],
        ['tape.stop', idm.tapeStop, { vel: 0.9, hit: { plane: [0, 1], push: 140 } }],
        ['pad.em9',   warmPad([164.81, 196.0, 246.94, 293.66, 369.99]), { hit: { push: 12, lift: 40 } }],
        ['pad.cmaj7', warmPad([130.81, 164.81, 196.0, 246.94]),         { hit: { push: 12, lift: 40 } }],
        ['pad.am9',   warmPad([110.0, 130.81, 164.81, 196.0, 246.94]),  { hit: { push: 12, lift: 40 } }],
        ['pad.bsus',  warmPad([123.47, 164.81, 185.0, 220.0]),          { hit: { push: 12, lift: 40 } }],
    ],
    // ring 3: kicks and the sub drop in the corners, snares mid-side, hats and clicks by the corners, debris between
    3: [
        ['kick.hard',     idm.kickHard,     { hit: { push: 160 } }],
        ['hat.tick',      hat(8500, 0.022, 0.55), { vel: 0.6 }],
        ['zap.small',     idm.zap,          { vel: 0.6, hit: { push: 60, reach: 200, scatter: true } }],
        ['snare.crack',   idm.snareCrack,   { vel: 0.8, hit: { push: -80 } }],
        ['crush',         idm.crush,        { vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
        ['hat.closed',    hat(8000, 0.035, 0.4), { vel: 0.5 }],
        ['kick.short',    idm.kickShort,    { vel: 0.8, hit: { push: 120 } }],
        ['hat.shuffle',   hat(6500, 0.06, 0.35), { vel: 0.5 }],
        ['static',        idm.static,       { vel: 0.6, hit: { push: 90, reach: 300, scatter: true } }],
        ['snare.808',     idm.snare808,     { vel: 0.75, hit: { push: -60 } }],
        ['stutter',       idm.stutter,      { vel: 0.7, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
        ['hat.open',      hat(7000, 0.3, 0.25), { vel: 0.45 }],
        ['kick.808',      idm.kick808,      { vel: 0.85, hit: { push: 200, reach: 300 } }],
        ['click.1',       idm.click,        { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['metal.ping',    idm.metalPing,    { vel: 0.7, hit: { push: 40 } }],
        ['clap.glitch',   idm.clapGlitch,   { vel: 0.7, hit: { push: 120, reach: 250, scatter: true } }],
        ['rim.glitch',    voices.rim,       { vel: 0.6, hit: { push: -30 } }],
        ['click.2',       idm.click2,       { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['sub.drop',      idm.subDrop,      { hit: { push: 480, reach: 380 } }],
        ['blip',          idm.blip,         { vel: 0.6 }],
        ['tick.wood',     voices.wood,      { vel: 0.6, hit: { push: -20 } }],
        ['snare.ghost',   idm.snareGhost,   { vel: 0.5, hit: { push: -15 } }],
        ['reverse.snare', idm.reverseSnare, { vel: 0.8, hit: { push: -110, reach: 380 } }],
        ['bleep',         idm.bleep,        { vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
    ],
};


// Track 4, "Zones": drill and bass in G minor at 174 bpm. All You Twin's glitches on a
// drum and bass frame: chopped crunchy breaks, reese and growl bass, glassy FM plucks.

// pass any voice through a bit-crusher
const crushed = (voice, levels) => (ctx, out, t) => {
    const cr = crusher(ctx, levels);
    cr.connect(out);
    voice(ctx, cr, t);
};

const glassPluck = freq => echoes((ctx, out, t) => {     // a glassy FM pluck with one echo
    const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.26, 0.45);
    car.frequency.value = freq; mod.frequency.value = freq * 2.01;
    idx.gain.setValueAtTime(freq * 3, t);
    idx.gain.exponentialRampToValueAtTime(freq * 0.05, t + 0.25);
    mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
    car.start(t); mod.start(t); car.stop(t + 0.5); mod.stop(t + 0.5);
}, 1, 3 * DNB_STEP, 0.4);

const growl = freq => (ctx, out, t) => {                 // FM bass whose brightness wobbles: a growl
    const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
    const lfo = ctx.createOscillator(), lfoDepth = ctx.createGain();
    car.frequency.value = freq; mod.frequency.value = freq * 2;
    idx.gain.value = freq * 2;
    lfo.frequency.value = 7; lfoDepth.gain.value = freq * 1.5;
    lfo.connect(lfoDepth); lfoDepth.connect(idx.gain);
    mod.connect(idx); idx.connect(car.frequency);
    const cr = crusher(ctx, 10), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.3, 0.6);
    lp.type = 'lowpass'; lp.frequency.value = 1800;
    car.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
    [car, mod, lfo].forEach(o => { o.start(t); o.stop(t + 0.65); });
};

const subTone = freq => (ctx, out, t) => {
    const g = swell(ctx, t, 0.5, 0.01, 0.3, 0.5);
    g.connect(out);
    const o = ctx.createOscillator();
    o.type = 'sine'; o.frequency.value = freq;
    o.connect(g); o.start(t); o.stop(t + 0.85);
};

const crushedChord = (freqs, levels) => (ctx, out, t) => {
    const cr = crusher(ctx, levels), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 0.3);
    lp.type = 'lowpass'; lp.frequency.value = 4000;
    cr.connect(lp); lp.connect(g); g.connect(out);
    freqs.forEach(freq => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq;
        og.gain.value = 0.3;
        o.connect(og); og.connect(cr);
        o.start(t); o.stop(t + 0.32);
    });
};

const stutterChord = freqs => (ctx, out, t) => {        // a chord caught in a skipping buffer
    const bp = ctx.createBiquadFilter();
    bp.type = 'bandpass'; bp.frequency.value = 1600; bp.Q.value = 0.7;
    bp.connect(out);
    let ti = t;
    for (let i = 0; i < 6; i++) {
        const g = decay(ctx, ti, 0.22 * Math.pow(0.8, i), 0.05);
        g.connect(bp);
        freqs.forEach(freq => {
            const o = ctx.createOscillator();
            o.type = 'square'; o.frequency.value = freq;
            o.connect(g); o.start(ti); o.stop(ti + 0.06);
        });
        ti += 0.07 * Math.pow(0.85, i);
    }
};

const tapeStopOf = freqs => (ctx, out, t) => {
    const lp = ctx.createBiquadFilter(), g = ctx.createGain();
    lp.type = 'lowpass';
    lp.frequency.setValueAtTime(3000, t);
    lp.frequency.exponentialRampToValueAtTime(200, t + 0.8);
    g.gain.value = 0;
    g.gain.setValueAtTime(0.2, t);
    g.gain.linearRampToValueAtTime(0, t + 0.85);
    lp.connect(g); g.connect(out);
    freqs.forEach(freq => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth';
        o.frequency.setValueAtTime(freq, t);
        o.frequency.exponentialRampToValueAtTime(freq * 0.2, t + 0.8);
        og.gain.value = 0.3;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 0.88);
    });
};

const KIT_FAULT = {
    // ring 1: glass plucks, G minor climbing clockwise round the centre
    1: [
        ['glass.g4', glassPluck(392.0)], ['glass.a4', glassPluck(440.0)], ['glass.b♭4', glassPluck(466.16)], ['glass.c5', glassPluck(523.25)],
        ['glass.d5', glassPluck(587.33)], ['glass.e♭5', glassPluck(622.25)], ['glass.f5', glassPluck(698.46)], ['glass.g5', glassPluck(783.99)],
    ].map(([id, voice]) => [id, voice, { look: ['shear', 'echo'], vel: 0.9 }]),   // glass plucks: shear, and rings on the echo
    // ring 2: glitch-tonal along the top, sub, growl and acid down the right, reese round the bottom, pads up the left
    2: [
        ['bit.stab',     crushedChord([392.0, 466.16, 587.33], 3), { sweep: true, cam: { yaw: 0.1 }, look: ['flip', 'fill-small'], vel: 0.8, hit: { push: 140, reach: 300, scatter: true } }],
        ['stab.stutter', stutterChord([392.0, 466.16, 587.33]),    { look: ['jitter', 'flip-small'], vel: 0.8, hit: { push: 120, reach: 280, scatter: true } }],
        ['vox.chop',     idm.grainVox,                              { look: ['jitter', 'ring-small'], vel: 0.8, hit: { push: 60, reach: 300, lift: 30, scatter: true } }],
        ['tape.stop',    tapeStopOf([196.0, 293.66, 392.0]),        { cam: { pitch: 0.12 }, look: ['shrink-big', 'fold'], vel: 0.9, hit: { plane: [0, 1], push: 140 } }],
        ['sub.g1',       subTone(49.0),   { cam: { zoom: 0.05 }, look: ['pulse-big', 'fill'], hit: { push: 90 } }],
        ['growl.d2',     growl(73.42),    { cam: { zoom: 0.03 }, look: ['fold', 'jitter'], vel: 0.8, hit: { push: 100, reach: 260, scatter: true } }],
        ['acid.g2',      acid(98.0),      { look: ['shear', 'pulse-small'], vel: 0.8, hit: { push: 35 } }],
        ['acid.d3',      acid(146.83),    { look: ['shear', 'pulse-small'], vel: 0.8, hit: { push: 35 } }],
        ['reese.g1',     reese(49.0),     { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['reese.a1',     reese(55.0),     { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['reese.b♭1',    reese(58.27),    { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['reese.c2',     reese(65.41),    { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['reese.d2',     reese(73.42),    { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['reese.e♭2',    reese(77.78),    { cam: { zoom: 0.025 }, look: ['fold', 'pulse', 'fill-small'], vel: 0.8, hit: { push: 70 } }],
        ['pad.gm9',      warmPad([196.0, 233.08, 293.66, 349.23, 440.0]), { look: ['breathe', 'ring-small'], hit: { push: 12, lift: 40 } }],
        ['pad.e♭maj7',   warmPad([155.56, 196.0, 233.08, 293.66]),        { look: ['breathe', 'ring-small'], hit: { push: 12, lift: 40 } }],
    ],
    // ring 3: kicks, crash and impact in the corners, snares mid-side, hats and debris by the corners, effects between
    3: [
        ['kick.break',   crushed(dnb.kickPunch, 16), { cam: { zoom: 0.035 }, look: ['pulse', 'fill-small'], vel: 0.8, hit: { push: 170 } }],
        ['hat.break',    crushed(hat(7000, 0.05, 0.45), 5), { look: ['jitter-small'], vel: 0.5 }],
        ['zap',          idm.zap,          { cam: { yaw: 0.06 }, look: ['shear', 'spin-small'], vel: 0.6, hit: { push: 60, reach: 200, scatter: true } }],
        ['snare.amen',   crushed(dnb.snare, 10), { cam: { shake: 5 }, look: ['squash', 'jitter-small'], vel: 0.85, hit: { push: -90 } }],
        ['crush',        idm.crush,        { cam: { shake: 7 }, look: ['jitter-big', 'fill'], vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
        ['hat.tick',     hat(8500, 0.022, 0.5), { look: ['jitter-small'], vel: 0.5 }],
        ['kick.sub',     dnb.kickSub,      { cam: { zoom: 0.05 }, look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 150 } }],
        ['hat.open',     hat(6500, 0.22, 0.28), { look: ['jitter', 'ring-small'], vel: 0.45 }],
        ['stutter',      idm.stutter,      { look: ['jitter-big', 'spin'], vel: 0.7, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
        ['snare.crush',  crushed(idm.snareCrack, 4), { cam: { shake: 5 }, look: ['squash', 'jitter'], vel: 0.75, hit: { push: 110, reach: 220, scatter: true } }],
        ['metal.ping',   idm.metalPing,    { look: ['ring', 'flip-small'], vel: 0.7, hit: { push: 40 } }],
        ['ride.crunch',  crushed(dnb.ride, 5), { look: ['jitter-small', 'ring-small'], vel: 0.5 }],
        ['crash.crush',  crushed(voices.crash, 4), { sweep: true, cam: { zoom: -0.06, pitch: 0.1 }, look: ['fill', 'ring-big', 'jitter'], vel: 0.5, hit: { push: 250, reach: 230, scatter: true } }],
        ['click',        idm.click,        { look: ['flip-small'], vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['reverse',      dnb.reverse,      { look: ['shrink-big'], vel: 0.8, hit: { push: -120, reach: 400 } }],
        ['snare.ghost',  idm.snareGhost,   { look: ['squash-small'], vel: 0.5, hit: { push: -15 } }],
        ['riser',        dnb.riser,        { cam: { pitch: -0.08 }, look: ['breathe', 'launch-small'], vel: 0.8, hit: { plane: [0, -1], push: 60, lift: 60 } }],
        ['blip',         idm.blip,         { look: ['flip-small'], vel: 0.6 }],
        ['impact',       dnb.impact,       { sweep: true, cam: { zoom: -0.18, pitch: 0.2 }, look: ['fill-long', 'pulse-big', 'ring-big', 'launch'], hit: { push: 520, reach: 380 } }],
        ['bleep',        idm.bleep,        { look: ['flip-small'], vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
        ['laser',        dnb.laser,        { cam: { yaw: 0.08 }, look: ['shear', 'spin-small'], vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
        ['rim.glitch',   voices.rim,       { look: ['squash-small'], vel: 0.6, hit: { push: -30 } }],
        ['siren',        sirenVoice,        { look: ['breathe', 'shear-small'], vel: 0.6, hit: { plane: [1, 0], push: 70 } }],
        ['static',       idm.static,       { look: ['jitter'], vel: 0.6, hit: { push: 90, reach: 300, scatter: true } }],
    ],
};


// Track 5, "Torque Region": slow, heavy glitch in C phrygian at 84 bpm, half-time,
// with dark drum textures: distorted kicks, crushed snares, struck metal, creaking wood.

// soft clipping: pushes a sound into warm distortion
const driveCurves = {};
function driver(ctx, amount) {
    if (!driveCurves[amount]) {
        const n = 1024, c = new Float32Array(n + 1);
        for (let i = 0; i <= n; i++) { const x = i / n * 2 - 1; c[i] = Math.tanh(amount * x) / Math.tanh(amount); }
        driveCurves[amount] = c;
    }
    const w = ctx.createWaveShaper();
    w.curve = driveCurves[amount];
    return w;
}

const ashBell = freq => (ctx, out, t) => {              // a dark FM bell, two voices beating, a little worn
    const cr = crusher(ctx, 48), g = decay(ctx, t, 0.2, 2.4);
    cr.connect(g); g.connect(out);
    [1, 1.003].forEach(mul => {
        const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
        car.frequency.value = freq * mul; mod.frequency.value = freq * mul * 1.41;
        idx.gain.setValueAtTime(freq * 2, t);
        idx.gain.exponentialRampToValueAtTime(freq * 0.2, t + 2);
        mod.connect(idx); idx.connect(car.frequency); car.connect(cr);
        car.start(t); mod.start(t); car.stop(t + 2.45); mod.stop(t + 2.45);
    });
};

const sludge = freq => (ctx, out, t) => {               // distorted sub bass that slowly opens and closes
    const dr = driver(ctx, 3), lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.085, 0.02, 0.6, 0.9);
    lp.type = 'lowpass'; lp.Q.value = 3;
    lp.frequency.setValueAtTime(280, t);
    lp.frequency.linearRampToValueAtTime(520, t + 0.6);
    lp.frequency.linearRampToValueAtTime(250, t + 1.4);
    dr.connect(lp); lp.connect(g); g.connect(out);
    for (const [type, mul, lvl] of [['sine', 1, 0.7], ['sawtooth', 1, 0.35]]) {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = type; o.frequency.value = freq * mul;
        og.gain.value = lvl;
        o.connect(og); og.connect(dr);
        o.start(t); o.stop(t + 1.55);
    }
};

const choir = freqs => (ctx, out, t) => {               // a dark "oh" sung by many: saws through vowel formants
    const g = swell(ctx, t, 0.2, 1.5, 3, 2.5);
    g.connect(out);
    const f1 = ctx.createBiquadFilter(), f2 = ctx.createBiquadFilter();
    f1.type = 'bandpass'; f1.frequency.value = 500; f1.Q.value = 4;
    f2.type = 'bandpass'; f2.frequency.value = 900; f2.Q.value = 6;
    f1.connect(g); f2.connect(g);
    freqs.forEach(freq => [-8, 0, 8].forEach(det => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
        og.gain.value = 0.35;
        o.connect(og); og.connect(f1); og.connect(f2);
        o.start(t); o.stop(t + 7.1);
    }));
};

const darkTones = {
    bitDrone: (ctx, out, t) => {                         // a low chord ground through a 4-level crusher
        const cr = crusher(ctx, 4), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 1.4);
        lp.type = 'lowpass'; lp.frequency.value = 2200;
        cr.connect(lp); lp.connect(g); g.connect(out);
        [65.41, 98.0, 130.81].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth'; o.frequency.value = freq;
            og.gain.value = 0.3;
            o.connect(og); og.connect(cr);
            o.start(t); o.stop(t + 1.45);
        });
    },
    granule: (ctx, out, t) => {                          // a cloud of tiny grains on the phrygian scale
        const notes = [261.63, 277.18, 311.13, 349.23, 392.0, 415.3, 466.16, 523.25, 554.37, 622.25, 698.46, 783.99];
        for (let i = 0; i < 24; i++) {
            const ti = t + Math.random() * 1.5;
            toneHit(ctx, out, ti, { freq: notes[Math.floor(Math.random() * notes.length)] * (Math.random() < 0.3 ? 2 : 1), peak: 0.06, len: 0.02 + Math.random() * 0.03 });
        }
    },
    tapeDrag: (ctx, out, t) => {                         // a chord slowly dragged down to nothing
        const lp = ctx.createBiquadFilter(), g = ctx.createGain();
        lp.type = 'lowpass';
        lp.frequency.setValueAtTime(2500, t);
        lp.frequency.exponentialRampToValueAtTime(150, t + 2.2);
        g.gain.value = 0;
        g.gain.setValueAtTime(0.18, t);
        g.gain.linearRampToValueAtTime(0, t + 2.3);
        lp.connect(g); g.connect(out);
        [130.81, 155.56, 196.0, 233.08].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth';
            o.frequency.setValueAtTime(freq, t);
            o.frequency.exponentialRampToValueAtTime(freq * 0.3, t + 2.2);
            og.gain.value = 0.3;
            o.connect(og); og.connect(lp);
            o.start(t); o.stop(t + 2.35);
        });
    },
    reverseSwell: (ctx, out, t) => {                     // a pad played backwards, rising out of nothing
        const lp = ctx.createBiquadFilter(), g = ctx.createGain();
        lp.type = 'lowpass';
        lp.frequency.setValueAtTime(300, t);
        lp.frequency.exponentialRampToValueAtTime(2500, t + 1.8);
        g.gain.value = 0;
        g.gain.setValueAtTime(0.001, t);
        g.gain.exponentialRampToValueAtTime(0.15, t + 1.8);
        g.gain.linearRampToValueAtTime(0, t + 1.85);
        lp.connect(g); g.connect(out);
        [138.59, 174.61, 207.65, 261.63].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth'; o.frequency.value = freq;
            og.gain.value = 0.25;
            o.connect(og); og.connect(lp);
            o.start(t); o.stop(t + 1.9);
        });
    },
    darkStab: (ctx, out, t) => {                         // C and D-flat together, overdriven
        const dr = driver(ctx, 6), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.2, 0.5);
        lp.type = 'lowpass'; lp.frequency.value = 1200;
        dr.connect(lp); lp.connect(g); g.connect(out);
        [65.41, 130.81, 138.59, 196.0].forEach(freq => {
            const o = ctx.createOscillator(), og = ctx.createGain();
            o.type = 'sawtooth'; o.frequency.value = freq;
            og.gain.value = 0.25;
            o.connect(og); og.connect(dr);
            o.start(t); o.stop(t + 0.55);
        });
    },
    voxGhost: (ctx, out, t) => {                         // a low moan, sinking as it fades
        const src = ctx.createOscillator(), vib = ctx.createOscillator(), vd = ctx.createGain();
        src.type = 'sawtooth';
        src.frequency.setValueAtTime(110, t);
        src.frequency.exponentialRampToValueAtTime(98, t + 1.5);
        vib.frequency.value = 4.5; vd.gain.value = 2.5;
        vib.connect(vd); vd.connect(src.frequency);
        const g = swell(ctx, t, 0.22, 0.4, 0.5, 0.8);
        g.connect(out);
        [[400, 8, 1], [800, 8, 0.5]].forEach(([freq, Q, lvl]) => {
            const bp = ctx.createBiquadFilter(), fg = ctx.createGain();
            bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
            fg.gain.value = lvl;
            src.connect(bp); bp.connect(fg); fg.connect(g);
        });
        src.start(t); vib.start(t); src.stop(t + 1.75); vib.stop(t + 1.75);
    },
};

const dark = {
    kickDirt: (ctx, out, t) => {
        const dr = driver(ctx, 4), lp = ctx.createBiquadFilter(), level = ctx.createGain();
        lp.type = 'lowpass'; lp.frequency.value = 400;
        level.gain.value = 0.34;                         // distortion flattens the peaks, so pull it back down
        dr.connect(lp); lp.connect(level); level.connect(out);
        toneHit(ctx, dr, t, { freq: 90, to: 38, glide: 0.25, peak: 0.75, len: 0.7 });
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 1500, peak: 0.2, len: 0.02 });
    },
    kickHollow: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 120, to: 70, glide: 0.1, peak: 0.55, len: 0.4 });
        toneHit(ctx, out, t, { type: 'triangle', freq: 60, peak: 0.35, len: 0.45 });
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 300, Q: 4, peak: 0.3, len: 0.2 });
    },
    kickGate: (ctx, out, t) => {                          // a boom cut off sharply, like a gated room
        toneHit(ctx, out, t, { freq: 70, to: 40, glide: 0.2, peak: 0.7, len: 0.3 });
        const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.25, 0.005, 0.25, 0.02);
        lp.type = 'lowpass'; lp.frequency.value = 900;
        g.connect(out);
        noiseBed(ctx, t, lp, g, t + 0.3);
    },
    snareAsh: (ctx, out, t) => {
        const cr = crusher(ctx, 8), lp = ctx.createBiquadFilter();
        lp.type = 'lowpass'; lp.frequency.value = 3500;
        cr.connect(lp); lp.connect(out);
        noiseHit(ctx, cr, t, { type: 'bandpass', freq: 900, Q: 0.7, peak: 0.5, len: 0.35 });
        toneHit(ctx, cr, t, { type: 'triangle', freq: 150, to: 120, glide: 0.08, peak: 0.3, len: 0.15 });
    },
    snareMetal: (ctx, out, t) => {
        metal(420, [1, 1.37, 1.92, 2.61], 0.2, 0.5)(ctx, out, t);
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.3, len: 0.12 });
    },
    clapRust: (ctx, out, t) => {
        const lp = ctx.createBiquadFilter();
        lp.type = 'lowpass'; lp.frequency.value = 2500;
        lp.connect(out);
        for (let i = 0; i < 3; i++) noiseHit(ctx, lp, t + i * 0.014, { type: 'bandpass', freq: 800, Q: 1.2, peak: 0.4, len: 0.025 });
        noiseHit(ctx, lp, t + 0.042, { type: 'bandpass', freq: 750, peak: 0.3, len: 0.3 });
    },
    snareGhost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 1400, peak: 0.15, len: 0.06 }),
    hatChain: (ctx, out, t) => {                          // like a chain dropped onto metal
        [3100, 4300, 5900, 7400].forEach(freq => toneHit(ctx, out, t + Math.random() * 0.006, { freq: freq * (0.98 + Math.random() * 0.04), peak: 0.05, len: 0.08 }));
        noiseHit(ctx, out, t, { type: 'highpass', freq: 5000, peak: 0.1, len: 0.05 });
    },
    hatTick: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 4500, Q: 1.5, peak: 0.35, len: 0.02 }),
    hatSand: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.18, 0.03, 0.02, 0.1);
        bp.type = 'bandpass'; bp.frequency.value = 3500; bp.Q.value = 0.6;
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 0.18);
    },
    hatOpen: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 4000, Q: 0.8, peak: 0.2, len: 0.45 }),
    anvil: metal(780, [1, 2.76, 5.4, 8.93], 0.2, 1.2),
    pipe: (ctx, out, t) => {
        metal(130, [1, 2.76, 5.4], 0.3, 1.6)(ctx, out, t);
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 400, peak: 0.3, len: 0.06 });
    },
    scrape: (ctx, out, t) => {                            // metal dragged across metal
        const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.15, 0.1, 0.5, 0.4), trem = ctx.createGain();
        bp.type = 'bandpass'; bp.Q.value = 6;
        bp.frequency.setValueAtTime(2000, t);
        bp.frequency.exponentialRampToValueAtTime(600, t + 1.0);
        const lfo = ctx.createOscillator(), lg = ctx.createGain();
        lfo.frequency.value = 18; lg.gain.value = 0.5;
        trem.gain.value = 0.5;
        lfo.connect(lg); lg.connect(trem.gain);
        g.connect(trem); trem.connect(out);
        noiseBed(ctx, t, bp, g, t + 1.05);
        lfo.start(t); lfo.stop(t + 1.05);
    },
    creak: (ctx, out, t) => {                             // an old wooden beam taking weight
        const o = ctx.createOscillator(), wob = ctx.createOscillator(), wd = ctx.createGain();
        o.type = 'sawtooth'; o.frequency.value = 60;
        wob.frequency.value = 3; wd.gain.value = 18;
        wob.connect(wd); wd.connect(o.frequency);
        const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.2, 0.1, 0.4, 0.3);
        bp.type = 'bandpass'; bp.frequency.value = 900; bp.Q.value = 8;
        o.connect(bp); bp.connect(g); g.connect(out);
        o.start(t); wob.start(t); o.stop(t + 0.85); wob.stop(t + 0.85);
    },
    knock: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 520, to: 480, glide: 0.02, peak: 0.4, len: 0.06 });
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 900, peak: 0.3, len: 0.03 });
    },
    staticDark: (ctx, out, t) => {
        const lp = ctx.createBiquadFilter();
        lp.type = 'lowpass'; lp.frequency.value = 2500;
        lp.connect(out);
        idm.static(ctx, lp, t);
    },
    crushLow: (ctx, out, t) => {
        const src = noiseSource(ctx), cr = crusher(ctx, 2), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.5, 0.25);
        lp.type = 'lowpass'; lp.frequency.value = 600;
        src.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
        src.start(t, Math.random()); src.stop(t + 0.28);
    },
    stutterSlow: (ctx, out, t) => {
        let ti = t;
        for (let i = 0; i < 8; i++) {
            noiseHit(ctx, out, ti, { type: 'bandpass', freq: 900, Q: 2, peak: 0.3, len: 0.03 });
            toneHit(ctx, out, ti, { type: 'square', freq: 110, peak: 0.08, len: 0.03 });
            ti += 0.12 * Math.pow(0.85, i);
        }
    },
    rumble: (ctx, out, t) => {
        const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.5, 0.3, 0.6, 2.0);
        lp.type = 'lowpass'; lp.frequency.value = 200; lp.Q.value = 0.7;
        g.connect(out);
        noiseBed(ctx, t, lp, g, t + 3.0);
        toneHit(ctx, out, t, { freq: 40, peak: 0.3, len: 2.5 });
    },
    dropVoid: (ctx, out, t) => {                          // the floor falling away
        const dr = driver(ctx, 2);
        dr.connect(out);
        toneHit(ctx, dr, t, { freq: 90, to: 18, glide: 2.5, peak: 0.7, len: 2.8 });
    },
    clickDark: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 2000, Q: 3, peak: 0.4, len: 0.006 }),
    bleepLow: (ctx, out, t) => {
        toneHit(ctx, out, t, { type: 'square', freq: 330, peak: 0.1, len: 0.05 });
        toneHit(ctx, out, t + 0.06, { type: 'square', freq: 247, peak: 0.1, len: 0.06 });
    },
    reverseHit: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter(), g = ctx.createGain();
        bp.type = 'bandpass'; bp.frequency.value = 1200; bp.Q.value = 0.7;
        g.gain.value = 0;
        g.gain.setValueAtTime(0.001, t);
        g.gain.exponentialRampToValueAtTime(0.35, t + 0.6);
        g.gain.linearRampToValueAtTime(0, t + 0.62);
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 0.65);
    },
};

const KIT_UNDERTOW = {
    // ring 1: ash bells, C phrygian climbing clockwise round the centre
    1: [
        ['ash.c4', ashBell(261.63)], ['ash.d♭4', ashBell(277.18)], ['ash.e♭4', ashBell(311.13)], ['ash.f4', ashBell(349.23)],
        ['ash.g4', ashBell(392.0)], ['ash.a♭4', ashBell(415.3)], ['ash.b♭4', ashBell(466.16)], ['ash.c5', ashBell(523.25)],
    ].map(([id, voice]) => [id, voice, { look: ['shear', 'echo'], vel: 0.9 }]),   // ash bells: shear, and rings on the echo
    // ring 2: sludge bass along the top and down the right, glitch-tonal round the bottom, drones and choir up the left
    2: [
        ['sludge.g1',   sludge(49.0),  { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['sludge.a♭1',  sludge(51.91), { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['sludge.c2',   sludge(65.41), { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['sludge.d♭2',  sludge(69.3),  { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['sludge.e♭2',  sludge(77.78), { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['sludge.f2',   sludge(87.31), { cam: { zoom: 0.03 }, look: ['fold', 'pulse', 'fill-small'], hit: { push: 80 } }],
        ['bit.drone',     darkTones.bitDrone,     { look: ['jitter', 'fill-small'], vel: 0.8, hit: { push: 120, reach: 320, scatter: true } }],
        ['granule',       darkTones.granule,      { look: ['jitter', 'ring'], vel: 0.8, hit: { push: 40, reach: 400, lift: 20, scatter: true } }],
        ['tape.drag',     darkTones.tapeDrag,     { cam: { pitch: 0.1 }, look: ['shrink-big', 'fold'], vel: 0.9, hit: { plane: [0, 1], push: 160 } }],
        ['reverse.swell', darkTones.reverseSwell, { look: ['shrink', 'breathe'], vel: 0.8, hit: { push: -90, reach: 420 } }],
        ['dark.stab',     darkTones.darkStab,     { sweep: true, cam: { yaw: 0.1 }, look: ['flip', 'fill'], vel: 0.8, hit: { push: 130, reach: 320, scatter: true } }],
        ['vox.ghost',     darkTones.voxGhost,     { look: ['breathe', 'spin-small'], vel: 0.8, hit: { push: 20, lift: 50 } }],
        ['drone.c',   drone([65.41, 98.0], 0.24),   { look: ['breathe'], vel: 0.9, hit: { push: 10, lift: 35 } }],
        ['drone.d♭',  drone([69.3, 103.83], 0.22),  { look: ['breathe'], vel: 0.9, hit: { push: 10, lift: 35 } }],
        ['choir.cm',  choir([130.81, 155.56, 196.0, 261.63]), { look: ['breathe', 'ring-small'], vel: 0.9, hit: { push: 12, lift: 45 } }],
        ['choir.d♭',  choir([138.59, 174.61, 207.65]),        { look: ['breathe', 'ring-small'], vel: 0.9, hit: { push: 12, lift: 45 } }],
    ],
    // ring 3: kicks, rumble and the void in the corners, snares mid-side, hats and small sounds by the corners, metal and wood between
    3: [
        ['kick.dirt',     dark.kickDirt,    { cam: { zoom: 0.06 }, look: ['pulse-big', 'fill'], hit: { push: 180, reach: 460 } }],
        ['hat.chain',     dark.hatChain,    { look: ['jitter-small'], vel: 0.6, hit: { push: 25, reach: 180, scatter: true } }],
        ['anvil',         dark.anvil,       { sweep: true, cam: { pitch: 0.12 }, look: ['flip', 'fill', 'ring-big'], vel: 0.7, hit: { push: 230, reach: 230, scatter: true } }],
        ['snare.ash',     dark.snareAsh,    { cam: { shake: 5 }, look: ['squash', 'jitter-small'], vel: 0.85, hit: { push: -100 } }],
        ['crush.low',     dark.crushLow,    { sweep: true, cam: { shake: 6 }, look: ['jitter-big', 'fill'], vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
        ['hat.tick',      dark.hatTick,     { look: ['jitter-small'], vel: 0.5 }],
        ['kick.hollow',   dark.kickHollow,  { cam: { zoom: 0.03 }, look: ['pulse', 'fill-small'], vel: 0.8, hit: { push: 110 } }],
        ['hat.sand',      dark.hatSand,     { look: ['jitter-small'], vel: 0.5 }],
        ['pipe',          dark.pipe,        { cam: { zoom: 0.02 }, look: ['ring-big', 'pulse-small'], vel: 0.7, hit: { push: 90, lift: 40 } }],
        ['snare.metal',   dark.snareMetal,  { cam: { shake: 4 }, look: ['squash', 'flip-small'], vel: 0.7, hit: { push: 120, reach: 240, scatter: true } }],
        ['stutter.slow',  dark.stutterSlow, { look: ['jitter-big', 'spin'], vel: 0.7, hit: { push: 70, reach: 400, spin: 2, scatter: true } }],
        ['hat.open',      dark.hatOpen,     { look: ['jitter', 'ring-small'], vel: 0.45 }],
        ['rumble',        dark.rumble,      { cam: { pitch: 0.08 }, look: ['breathe', 'pulse-small', 'fill-long'], vel: 0.8, hit: { push: 140, reach: 600, lift: 80 } }],
        ['click.dark',    dark.clickDark,   { look: ['flip-small'], vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['scrape',        dark.scrape,      { look: ['shear', 'spin-small'], vel: 0.7, hit: { plane: [1, 0], push: 90 } }],
        ['clap.rust',     dark.clapRust,    { cam: { shake: 5 }, look: ['squash', 'jitter'], vel: 0.7, hit: { push: -70 } }],
        ['kick.gate',     dark.kickGate,    { cam: { zoom: 0.04 }, look: ['pulse', 'fill'], vel: 0.85, hit: { push: 150 } }],
        ['bleep.low',     dark.bleepLow,    { look: ['flip-small'], vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
        ['drop.void',     dark.dropVoid,    { sweep: true, cam: { zoom: -0.2, pitch: 0.2 }, look: ['fill-long', 'shrink-big', 'ring-big', 'tumble'], hit: { push: 500, reach: 400 } }],
        ['static.dark',   dark.staticDark,  { look: ['jitter'], vel: 0.6, hit: { push: 80, reach: 300, scatter: true } }],
        ['creak',         dark.creak,       { look: ['spin', 'shear-small'], vel: 0.7, hit: { push: 20, reach: 300, spin: 2.5 } }],
        ['snare.ghost',   dark.snareGhost,  { look: ['squash-small'], vel: 0.5, hit: { push: -15 } }],
        ['reverse.hit',   dark.reverseHit,  { look: ['shrink-big'], vel: 0.8, hit: { push: -120, reach: 380 } }],
        ['knock',         dark.knock,       { look: ['squash-small'], vel: 0.6, hit: { push: -20 } }],
    ],
};


// Track 6, "Symbol Hg": generative, Autechre-minded. Patterns come from rules (Euclidean rhythms,
// a cellular automaton), pitches from the octatonic scale, and gritty FM throughout.

// two-operator FM: a modulator wobbles a carrier's pitch; `index` sets how metallic it sounds
function fmHit(ctx, out, t, { freq, to, glide = 0, ratio, index, indexTo = 1, indexTime, peak, len }) {
    const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, peak, len);
    car.frequency.setValueAtTime(freq, t);
    mod.frequency.setValueAtTime(freq * ratio, t);
    if (to) {
        car.frequency.exponentialRampToValueAtTime(to, t + glide);
        mod.frequency.exponentialRampToValueAtTime(to * ratio, t + glide);
    }
    idx.gain.setValueAtTime(index, t);
    idx.gain.exponentialRampToValueAtTime(Math.max(1, indexTo), t + (indexTime || len));
    mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
    car.start(t); mod.start(t); car.stop(t + len + 0.03); mod.stop(t + len + 0.03);
}
const fm = opts => (ctx, out, t) => fmHit(ctx, out, t, opts);

const ping = freq => (ctx, out, t) => {                 // a struck FM tine with a tiny click
    fmHit(ctx, out, t, { freq, ratio: 3.5, index: freq * 4, indexTo: 1, indexTime: 0.06, peak: 0.32, len: 0.7 });
    noiseHit(ctx, out, t, { type: 'bandpass', freq: freq * 4, Q: 8, peak: 0.1, len: 0.008 });
};

const coldPad = freqs => (ctx, out, t) => {             // a hollow pad whose filter breathes slowly
    const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.14, 2, 2, 2.5);
    lp.type = 'lowpass'; lp.Q.value = 2;
    lp.frequency.setValueAtTime(400, t);
    lp.frequency.linearRampToValueAtTime(1600, t + 3);
    lp.frequency.linearRampToValueAtTime(500, t + 6);
    lp.connect(g); g.connect(out);
    freqs.forEach(freq => [['sine', -6], ['triangle', 6]].forEach(([type, det]) => {
        const o = ctx.createOscillator(), og = ctx.createGain();
        o.type = type; o.frequency.value = freq; o.detune.value = det;
        og.gain.value = 0.35;
        o.connect(og); og.connect(lp);
        o.start(t); o.stop(t + 6.6);
    }));
};

const rule = {
    grainCloud: (ctx, out, t) => {                       // thirty tiny metallic grains
        for (let i = 0; i < 30; i++) {
            fmHit(ctx, out, t + Math.random() * 1.2, { freq: 800 + Math.random() * 2800, ratio: 1 + Math.random() * 3, index: 1500, indexTo: 50, peak: 0.05, len: 0.03 });
        }
    },
    combDrone: (ctx, out, t) => {                        // noise tuned into the harmonics of C
        const g = swell(ctx, t, 0.4, 1.5, 2.5, 2), boost = ctx.createGain();
        boost.gain.value = 5;
        boost.connect(g); g.connect(out);
        [65.41, 130.81, 196.22, 261.63].forEach((freq, i) => {
            const bp = ctx.createBiquadFilter();
            bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 30;
            const lvl = ctx.createGain();
            lvl.gain.value = 1 / (i + 1);
            bp.connect(lvl); lvl.connect(boost);
            noiseBed(ctx, t, bp, lvl, t + 6.1);
        });
    },
    glassHarm: (ctx, out, t) => {                        // high odd harmonics of A, swelling in
        const g = swell(ctx, t, 0.05, 1, 1.5, 2);
        g.connect(out);
        [550, 770, 990, 1210].forEach(freq => {
            const o = ctx.createOscillator();
            o.type = 'sine'; o.frequency.value = freq;
            o.connect(g); o.start(t); o.stop(t + 4.6);
        });
    },
    dataBurst: (ctx, out, t) => {                        // sixteen FM blips, as if reading data aloud
        for (let i = 0; i < 16; i++) {
            fmHit(ctx, out, t + i * 0.03, { freq: 800 + Math.random() * 3200, ratio: pickFrom([1, 1.5, 2, 3.5]), index: 1200, indexTo: 50, peak: 0.08, len: 0.02 });
        }
    },
    resoSweep: (ctx, out, t) => {
        const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.12, 0.1, 0.8, 0.4);
        lp.type = 'lowpass'; lp.Q.value = 18;
        lp.frequency.setValueAtTime(200, t);
        lp.frequency.exponentialRampToValueAtTime(6000, t + 0.6);
        lp.frequency.exponentialRampToValueAtTime(300, t + 1.2);
        g.connect(out);
        noiseBed(ctx, t, lp, g, t + 1.35);
    },
    formantShift: (ctx, out, t) => {                     // a buzz whose vowel slides from "ah" to "ee" to "oo"
        const src = ctx.createOscillator();
        src.type = 'sawtooth'; src.frequency.value = 130.81;
        const g = swell(ctx, t, 0.25, 0.2, 1.0, 0.4);
        g.connect(out);
        [[[800, 300, 350], 8], [[1200, 2300, 800], 10]].forEach(([path, Q]) => {
            const bp = ctx.createBiquadFilter();
            bp.type = 'bandpass'; bp.Q.value = Q;
            bp.frequency.setValueAtTime(path[0], t);
            bp.frequency.linearRampToValueAtTime(path[1], t + 0.75);
            bp.frequency.linearRampToValueAtTime(path[2], t + 1.5);
            src.connect(bp); bp.connect(g);
        });
        src.start(t); src.stop(t + 1.65);
    },
    phaseTone: (ctx, out, t) => {                        // two pairs of nearly matching tones, slowly phasing
        const g = swell(ctx, t, 0.1, 1.5, 1, 1.5);
        g.connect(out);
        [220, 221.3, 330, 331.1].forEach(freq => {
            const o = ctx.createOscillator();
            o.type = 'sine'; o.frequency.value = freq;
            o.connect(g); o.start(t); o.stop(t + 4.1);
        });
    },
    hatRing: (ctx, out, t) => {                          // ring modulation: one square wave switching another's volume
        const a = ctx.createOscillator(), b = ctx.createOscillator(), ring = ctx.createGain(), hp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.25, 0.08);
        a.type = 'square'; a.frequency.value = 540;
        b.type = 'square'; b.frequency.value = 800;
        ring.gain.value = 0;
        b.connect(ring.gain); a.connect(ring);
        hp.type = 'highpass'; hp.frequency.value = 4000;
        ring.connect(hp); hp.connect(g); g.connect(out);
        a.start(t); b.start(t); a.stop(t + 0.1); b.stop(t + 0.1);
    },
    snareGate: (ctx, out, t) => {
        const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.35, 0.002, 0.07, 0.01);
        bp.type = 'bandpass'; bp.frequency.value = 2200;
        g.connect(out);
        noiseBed(ctx, t, bp, g, t + 0.1);
        toneHit(ctx, out, t, { freq: 200, peak: 0.2, len: 0.06 });
    },
    clapDigital: (ctx, out, t) => {
        const cr = crusher(ctx, 6);
        cr.connect(out);
        for (let i = 0; i < 4; i++) noiseHit(ctx, cr, t + i * 0.008, { type: 'bandpass', freq: 1500, peak: 0.35, len: 0.012 });
    },
    crunch: (ctx, out, t) => {
        const src = noiseSource(ctx), cr = crusher(ctx, 3), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.45, 0.06);
        bp.type = 'bandpass'; bp.frequency.value = 900;
        src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
        src.start(t, Math.random()); src.stop(t + 0.08);
    },
    grainHit: (ctx, out, t) => {
        for (let i = 0; i < 6; i++) fmHit(ctx, out, t + Math.random() * 0.05, { freq: 1000 + Math.random() * 2000, ratio: 2.3, index: 900, indexTo: 20, peak: 0.08, len: 0.015 });
    },
    buzz: (ctx, out, t) => {
        const o = ctx.createOscillator(), cr = crusher(ctx, 4), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.3, 0.15);
        o.type = 'square'; o.frequency.value = 55;
        bp.type = 'bandpass'; bp.frequency.value = 700;
        o.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
        o.start(t); o.stop(t + 0.18);
    },
    reverseFm: (ctx, out, t) => {                        // an FM clang played backwards
        const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = ctx.createGain();
        car.frequency.value = 440; mod.frequency.value = 440 * 1.414; idx.gain.value = 900;
        g.gain.value = 0;
        g.gain.setValueAtTime(0.001, t);
        g.gain.exponentialRampToValueAtTime(0.25, t + 0.5);
        g.gain.linearRampToValueAtTime(0, t + 0.52);
        mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
        car.start(t); mod.start(t); car.stop(t + 0.55); mod.stop(t + 0.55);
    },
};
function pickFrom(list) { return list[Math.floor(Math.random() * list.length)]; }

// Grit: soft-clipping saturation (and, if asked, bit-crushing and a low-pass), wrapped round any sound
const satCache = new Map();
function satCurve(amount) {
    if (!satCache.has(amount)) {
        const n = 2048, c = new Float32Array(n), norm = Math.tanh(amount);
        for (let i = 0; i < n; i++) c[i] = Math.tanh((i / (n - 1) * 2 - 1) * amount) / norm;
        satCache.set(amount, c);
    }
    return satCache.get(amount);
}
function grit(voice, { drive = 4, bits = 0, tone = 0, trim = 0.5 } = {}) {        // trim offsets the loudness saturation adds
    return (ctx, out, t) => {
        const head = ctx.createGain(), sh = ctx.createWaveShaper(), level = ctx.createGain();
        sh.curve = satCurve(drive); sh.oversample = '2x';
        head.connect(sh);
        let tail = sh;
        if (bits) { const cr = crusher(ctx, bits); tail.connect(cr); tail = cr; }
        if (tone) { const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = tone; tail.connect(lp); tail = lp; }
        level.gain.value = trim;
        tail.connect(level); level.connect(out);
        voice(ctx, head, t);
    };
}
const gritPing = freq => grit(ping(freq), { drive: 3, bits: 24, tone: 5000 });

// Sub bass: a pure sine you feel (with a small pitch punch at the front), plus a driven copy an octave up
// so the line can still be heard on laptop and phone speakers
const subGrit = freq => (ctx, out, t) => {
    const o = ctx.createOscillator(), g = ctx.createGain();
    o.type = 'sine';
    o.frequency.setValueAtTime(freq * 1.5, t);
    o.frequency.exponentialRampToValueAtTime(freq, t + 0.03);
    g.gain.value = 0;
    g.gain.setValueAtTime(0, t);
    g.gain.linearRampToValueAtTime(0.42, t + 0.008);
    g.gain.setTargetAtTime(0.3, t + 0.05, 0.2);
    g.gain.setTargetAtTime(0, t + 0.75, 0.08);
    o.connect(g); g.connect(out);
    o.start(t); o.stop(t + 1.3);
    const h = ctx.createOscillator(), sh = ctx.createWaveShaper(), lp = ctx.createBiquadFilter(), hg = decay(ctx, t, 0.11, 0.5);
    h.type = 'sine'; h.frequency.value = freq * 2;
    sh.curve = satCurve(8);
    lp.type = 'lowpass'; lp.frequency.value = 1100;
    h.connect(sh); sh.connect(lp); lp.connect(hg); hg.connect(out);
    h.start(t); h.stop(t + 0.55);
};
// a pad with grit, and a sub two octaves under its root
const dirtPad = freqs => (ctx, out, t) => {
    grit(coldPad(freqs), { drive: 4, tone: 2200, trim: 0.4 })(ctx, out, t);
    const g = swell(ctx, t, 0.12, 1.5, 3, 2), o = ctx.createOscillator();
    g.connect(out);
    o.type = 'sine'; o.frequency.value = freqs[0] / 4;
    o.connect(g); o.start(t); o.stop(t + 7);
};

const sub = {
    drop: (ctx, out, t) => {                             // an 808-style boom falling to the floor
        toneHit(ctx, out, t, { freq: 110, to: 30, glide: 1.1, peak: 0.38, len: 1.8 });
        grit((c, o, tt) => toneHit(c, o, tt, { freq: 220, to: 60, glide: 1.1, peak: 0.25, len: 1.2 }), { drive: 10, tone: 1500, trim: 0.3 })(ctx, out, t);
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 2000, peak: 0.2, len: 0.02 });
    },
    phase: (ctx, out, t) => {                            // two subs a fraction apart: a slow throb
        const g = swell(ctx, t, 0.24, 1.2, 1.5, 1.4);
        g.connect(out);
        [32.7, 33.35].forEach(freq => {
            const o = ctx.createOscillator();
            o.type = 'sine'; o.frequency.value = freq;
            o.connect(g); o.start(t); o.stop(t + 4.2);
        });
        const h = ctx.createOscillator(), sh = ctx.createWaveShaper(), lp = ctx.createBiquadFilter(), hg = swell(ctx, t, 0.07, 1.2, 1.5, 1.4);
        h.type = 'sine'; h.frequency.value = 65.41;
        sh.curve = satCurve(6);
        lp.type = 'lowpass'; lp.frequency.value = 700;
        h.connect(sh); sh.connect(lp); lp.connect(hg); hg.connect(out);
        h.start(t); h.stop(t + 4.2);
    },
    drone: (ctx, out, t) => {                            // the comb drone, dirtied, over a deep sine
        grit(rule.combDrone, { drive: 3, tone: 2500, trim: 0.3 })(ctx, out, t);
        const g = swell(ctx, t, 0.2, 1.5, 2.5, 2), o = ctx.createOscillator();
        g.connect(out);
        o.type = 'sine'; o.frequency.value = 32.7;
        o.connect(g); o.start(t); o.stop(t + 6.1);
    },
    kick: (ctx, out, t) => {                             // a long sub kick with a driven knock on top
        toneHit(ctx, out, t, { freq: 130, to: 36, glide: 0.14, peak: 0.6, len: 0.7 });
        grit((c, o, tt) => toneHit(c, o, tt, { freq: 260, to: 70, glide: 0.08, peak: 0.3, len: 0.18 }), { drive: 8, trim: 0.35 })(ctx, out, t);
    },
    clang: (ctx, out, t) => {                            // the big clang, rusted, with a sub boom underneath
        grit(fm({ freq: 330, ratio: 1.414, index: 1500, indexTo: 20, indexTime: 0.6, peak: 0.42, len: 1.0 }), { drive: 5, bits: 10 })(ctx, out, t);
        toneHit(ctx, out, t, { freq: 65, to: 30, glide: 0.8, peak: 0.3, len: 1.2 });
    },
};

const KIT_RULE = {
    // ring 1: gritty FM pings on the octatonic scale (half step, whole step, repeating) round the centre
    1: [
        ['ping.c4', gritPing(261.63)], ['ping.d♭4', gritPing(277.18)], ['ping.e♭4', gritPing(311.13)], ['ping.e4', gritPing(329.63)],
        ['ping.f♯4', gritPing(369.99)], ['ping.g4', gritPing(392.0)], ['ping.a4', gritPing(440.0)], ['ping.b♭4', gritPing(466.16)],
    ].map(([id, voice]) => [id, voice, { look: ['shear', 'ring-small'], vel: 0.8 }]),
    // ring 2: sub bass in minor thirds along the top, gritty textures down the right and bottom, sub sounds and dirty pads up the left
    2: [
        ['sub.c1',  subGrit(32.70), { cam: { zoom: 0.04 }, look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 120, reach: 380 } }],
        ['sub.e♭1', subGrit(38.89), { cam: { zoom: 0.04 }, look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 120, reach: 380 } }],
        ['sub.f♯1', subGrit(46.25), { cam: { zoom: 0.04 }, look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 120, reach: 380 } }],
        ['sub.a1',  subGrit(55.00), { cam: { zoom: 0.04 }, look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 120, reach: 380 } }],
        ['data.burst',   grit(rule.dataBurst, { drive: 8, bits: 5, trim: 0.4 }), { look: ['jitter-big'], vel: 0.8, hit: { push: 120, reach: 300, scatter: true } }],
        ['grit.cloud',   grit(rule.grainCloud, { drive: 6, bits: 8 }), { look: ['jitter', 'ring'], vel: 0.8, hit: { push: 40, reach: 400, scatter: true } }],
        ['sub.drone',    sub.drone, { look: ['breathe', 'spin-small'], vel: 0.8, hit: { push: 10, lift: 30 } }],
        ['glass.crush',  crushed(rule.glassHarm, 8), { look: ['ring', 'flip-small'], vel: 0.8, hit: { push: 10, lift: 40 } }],
        ['reso.grit',    grit(rule.resoSweep, { drive: 7 }), { look: ['spin', 'shear-small'], vel: 0.8, hit: { plane: [1, 0], push: 80 } }],
        ['formant.grit', grit(rule.formantShift, { drive: 6 }), { look: ['spin-big'], vel: 0.8, hit: { push: 60, reach: 350, swirl: -1, centre: true } }],
        ['pluck.rust',   grit(metal(311.13, [1, 2.32, 4.25, 6.63], 0.16, 0.9), { drive: 4, bits: 10 }), { look: ['flip', 'ring-small'], vel: 0.8, hit: { push: 40 } }],
        ['sub.drop',     sub.drop, { cam: { zoom: -0.1 }, look: ['shrink-big', 'fill-long', 'ring-big'], vel: 0.85, hit: { push: 300, reach: 420, lift: 40 } }],
        ['bit.choir',    crushed(choir([130.81, 155.56, 185.0]), 4), { look: ['breathe', 'jitter-small'], vel: 0.8, hit: { push: 12, lift: 45 } }],
        ['sub.phase',    sub.phase, { look: ['breathe', 'pulse-small'], vel: 0.8, hit: { push: 80, reach: 500, swirl: 1, centre: true } }],
        ['dirt.pad.1',   dirtPad([130.81, 155.56, 185.0, 220.0]),  { look: ['breathe'], hit: { push: 12, lift: 40 } }],
        ['dirt.pad.2',   dirtPad([138.59, 164.81, 196.0, 233.08]), { look: ['breathe'], hit: { push: 12, lift: 40 } }],
    ],
    // ring 3: kicks and the rusted clang in the corners, snares mid-side, crushed hats and ticks by the corners, dirty FM metal between
    3: [
        ['kick.sub',     sub.kick, { cam: { zoom: 0.06 }, look: ['pulse', 'fill', 'launch-small'], hit: { push: 170, reach: 320 } }],
        ['hat.crush1',   grit(fm({ freq: 7000, ratio: 1.43, index: 6000, peak: 0.3, len: 0.04 }), { bits: 6 }), { look: ['jitter-small'], vel: 0.5 }],
        ['clang.2',      grit(fm({ freq: 523.25, ratio: 2.76, index: 1500, indexTo: 20, indexTime: 0.4, peak: 0.33, len: 0.7 }), { drive: 4, bits: 16 }), { cam: { yaw: 0.12 }, look: ['flip', 'fill'], vel: 0.7, hit: { push: 140, reach: 260, scatter: true } }],
        ['snare.grit',   grit((ctx, out, t) => {
                             fmHit(ctx, out, t, { freq: 180, ratio: 7.3, index: 2000, indexTo: 100, indexTime: 0.15, peak: 0.35, len: 0.18 });
                             noiseHit(ctx, out, t, { type: 'highpass', freq: 3000, peak: 0.2, len: 0.1 });
                         }, { drive: 6, bits: 16 }), { cam: { shake: 5 }, look: ['squash', 'shrink-small'], vel: 0.8, hit: { push: 110, reach: 230, scatter: true } }],
        ['clang.3',      grit(fm({ freq: 196.0, ratio: 3.17, index: 1200, indexTo: 20, indexTime: 0.5, peak: 0.37, len: 0.9 }), { drive: 4, bits: 16 }), { cam: { pitch: 0.1 }, look: ['flip', 'fill-small', 'launch-small'], vel: 0.7, hit: { push: 160, reach: 300, spin: 2 } }],
        ['hat.crush2',   grit(fm({ freq: 5200, ratio: 2.71, index: 5000, peak: 0.3, len: 0.06 }), { bits: 5 }), { look: ['jitter-small'], vel: 0.5 }],
        ['kick.dist',    grit(fm({ freq: 90, to: 45, glide: 0.08, ratio: 1.6, index: 400, indexTo: 1, indexTime: 0.05, peak: 0.7, len: 0.3 }), { drive: 9, tone: 3000, trim: 0.85 }), { cam: { zoom: 0.04 }, look: ['pulse', 'fill-small'], hit: { push: 170, reach: 300 } }],
        ['hat.dirt',     grit(hat(9000, 0.03, 0.35), { drive: 8, trim: 0.25 }), { look: ['jitter-small'], vel: 0.5 }],
        ['wood.fm',      grit(fm({ freq: 600, ratio: 1, index: 800, indexTo: 1, indexTime: 0.02, peak: 0.36, len: 0.05 }), { drive: 5 }), { look: ['squash-small', 'spin-small'], vel: 0.6, hit: { push: -20 } }],
        ['snare.crush',  crushed((ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 1800, Q: 0.5, peak: 0.4, len: 0.12 }), 5), { cam: { shake: 5 }, look: ['squash', 'jitter-small'], vel: 0.75, hit: { push: -70 } }],
        ['crunch',       grit(rule.crunch, { drive: 4 }), { cam: { shake: 8 }, look: ['jitter-big', 'fill'], vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
        ['hat.ring',     grit(rule.hatRing, { drive: 4 }), { look: ['spin-small'], vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
        ['kick.crush',   crushed((ctx, out, t) => {
                             toneHit(ctx, out, t, { freq: 200, to: 60, glide: 0.02, peak: 0.75, len: 0.08 });
                             noiseHit(ctx, out, t, { type: 'highpass', freq: 5000, peak: 0.25, len: 0.004 });
                         }, 6), { cam: { zoom: 0.02 }, look: ['pulse-small'], vel: 0.85, hit: { push: 110 } }],
        ['tine.crush',   crushed(fm({ freq: 2093, ratio: 3.5, index: 3000, indexTo: 5, indexTime: 0.1, peak: 0.22, len: 0.4 }), 8), { look: ['ring', 'shear-small'], vel: 0.6, hit: { push: 15 } }],
        ['grain.hit',    crushed(rule.grainHit, 6), { look: ['jitter', 'squash-small'], vel: 0.7, hit: { push: 50, reach: 300, scatter: true } }],
        ['snare.gate',   grit(rule.snareGate, { drive: 5 }), { cam: { shake: 3 }, look: ['squash'], vel: 0.8, hit: { push: -90 } }],
        ['buzz',         grit(rule.buzz, { drive: 5 }), { look: ['jitter'], vel: 0.7, hit: { push: 90, reach: 260, scatter: true } }],
        ['click.sample', (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 12000, peak: 0.6, len: 0.002 }), { look: ['jitter-small'], vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['clang.rust',   sub.clang, { cam: { zoom: -0.15, roll: 0.2 }, look: ['fill-long', 'pulse-big', 'ring-big', 'launch'], vel: 0.8, hit: { push: 420, reach: 360, lift: 60 } }],
        ['tick.crush',   crushed((ctx, out, t) => toneHit(ctx, out, t, { freq: 3500, peak: 0.3, len: 0.01 }), 4), { look: ['flip-small'], vel: 0.6 }],
        ['zap.fm',       grit(fm({ freq: 3000, to: 200, glide: 0.08, ratio: 1.5, index: 2000, indexTo: 100, peak: 0.2, len: 0.1 }), { drive: 6 }), { cam: { roll: 0.08 }, look: ['shear', 'spin-small'], vel: 0.6, hit: { plane: [-1, 0], push: 90 } }],
        ['clap.digital', grit(rule.clapDigital, { drive: 5, bits: 4 }), { cam: { shake: 6 }, look: ['squash', 'jitter'], vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
        ['reverse.fm',   grit(rule.reverseFm, { drive: 6, bits: 12 }), { look: ['shrink-big'], vel: 0.8, hit: { push: -130, reach: 400 } }],
        ['rim.fm',       grit(fm({ freq: 1400, ratio: 2.3, index: 1500, indexTo: 10, indexTime: 0.02, peak: 0.32, len: 0.03 }), { drive: 5 }), { look: ['squash-small'], vel: 0.6, hit: { push: -30 } }],
    ],
};
// the gritty kit runs hot; one level for every sound keeps the mix out of the limiter (and taps on tiles match the song)
const atLevel = (voice, level) => (ctx, out, t) => { const g = ctx.createGain(); g.gain.value = level; g.connect(out); voice(ctx, g, t); };
for (const k of [1, 2, 3]) KIT_RULE[k] = KIT_RULE[k].map(([id, voice, opts]) => [id, atLevel(voice, 0.5), opts]);


// Shared with Skoodu: a short FM chord stab and an FM snare
const fmStab = freqs => (ctx, out, t) => {              // a short FM chord stab
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.frequency.value = 5000;
    lp.connect(out);
    freqs.forEach(freq => fmHit(ctx, lp, t, { freq, ratio: 2, index: freq * 3, indexTo: 1, indexTime: 0.1, peak: 0.16, len: 0.22 }));
};

const fmSnare = (ctx, out, t) => {
    fmHit(ctx, out, t, { freq: 180, ratio: 7.3, index: 2000, indexTo: 100, indexTime: 0.15, peak: 0.35, len: 0.18 });
    noiseHit(ctx, out, t, { type: 'highpass', freq: 3000, peak: 0.2, len: 0.1 });
};

// Track 7, "Planet Work": generative drum and bass in the whole-tone scale on E, made digital:
// pulse-wave chip voices, a 4-bit triangle bass, fast chip arpeggios, noise-channel drums,
// a step-sequenced lead, and game-machine blips driven by the automaton.

// pulse waves of any width, built from their harmonics (the classic 8-bit lead and arp tones)
const pulseWaveCache = new WeakMap();
function pulseWave(ctx, duty) {
    let byDuty = pulseWaveCache.get(ctx);
    if (!byDuty) { byDuty = new Map(); pulseWaveCache.set(ctx, byDuty); }
    if (!byDuty.has(duty)) {
        const n = 48, real = new Float32Array(n), imag = new Float32Array(n);
        for (let k = 1; k < n; k++) real[k] = (2 / (k * Math.PI)) * Math.sin(k * Math.PI * duty);
        byDuty.set(duty, ctx.createPeriodicWave(real, imag));
    }
    return byDuty.get(duty);
}
function chipTone(ctx, out, t, { freq, duty = 0.25, peak, len, steps }) {
    const o = ctx.createOscillator(), g = decay(ctx, t, peak, len);
    o.setPeriodicWave(pulseWave(ctx, duty));
    o.frequency.setValueAtTime(freq, t);
    // `steps`: [[time, freq], ...] jumps in pitch, like an 8-bit sound chip (no smooth slides)
    if (steps) steps.forEach(([dt, f]) => o.frequency.setValueAtTime(f, t + dt));
    o.connect(g); g.connect(out);
    o.start(t); o.stop(t + len + 0.03);
}
// a pitch that falls (or rises) in hard steps
const steppedPitch = (from, to, count, span) => Array.from({ length: count }, (_, i) => [span * (i + 1) / count, from * Math.pow(to / from, (i + 1) / count)]);

// a short repeating noise loop: the buzzy, metallic "short mode" of an 8-bit noise channel
const loopNoiseCache = new WeakMap();
function loopNoise(ctx) {
    let buf = loopNoiseCache.get(ctx);
    if (!buf) {
        buf = ctx.createBuffer(1, 127, ctx.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < 127; i++) d[i] = Math.random() < 0.5 ? -1 : 1;
        loopNoiseCache.set(ctx, buf);
    }
    const src = ctx.createBufferSource();
    src.buffer = buf; src.loop = true;
    return src;
}

const chipLead = freq => (ctx, out, t) => chipTone(ctx, out, t, { freq, duty: 0.25, peak: 0.5, len: 0.16 });

const triBass = freq => (ctx, out, t) => {              // a stepped, 4-bit-style triangle, as on old consoles
    const o = ctx.createOscillator(), cr = crusher(ctx, 8), g = decay(ctx, t, 0.34, 0.16);
    o.type = 'triangle'; o.frequency.value = freq;
    o.connect(cr); cr.connect(g); g.connect(out);
    o.start(t); o.stop(t + 0.2);
    toneHit(ctx, out, t, { freq, peak: 0.12, len: 0.14 });
};

const chipArp = (freqs, down) => (ctx, out, t) => {     // a chord played as a blur of notes, the 8-bit way
    const order = down ? [...freqs].reverse() : freqs;
    for (let i = 0; i < 8; i++) chipTone(ctx, out, t + i * 0.03, { freq: order[i % order.length], duty: 0.125, peak: 0.36, len: 0.03 });
};

const digital = {
    sampleHold: (ctx, out, t) => {                       // a filter jumping to a new random place every sixteenth
        const o = ctx.createOscillator(), lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.08, 0.02, 1.2, 0.2);
        o.type = 'square'; o.frequency.value = 164.81;
        lp.type = 'lowpass'; lp.Q.value = 12;
        for (let i = 0; i < 16; i++) lp.frequency.setValueAtTime(300 + Math.random() * 3200, t + i * PLANET_STEP);
        o.connect(lp); lp.connect(g); g.connect(out);
        o.start(t); o.stop(t + 1.45);
    },
    bitSweep: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2637, duty: 0.25, peak: 0.12, len: 0.7, steps: steppedPitch(2637, 165, 16, 0.6) }),
    modem: (ctx, out, t) => {                            // two tones flicking back and forth: data down a phone line
        for (let i = 0; i < 32; i++) toneHit(ctx, out, t + i * 0.015, { type: 'square', freq: i % 2 ? 2200 : 1200, peak: 0.05, len: 0.016 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 4000, peak: 0.03, len: 0.5 });
    },
    pwmPad: (ctx, out, t) => {                           // a pulse pad whose width drifts: E augmented
        const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.1, 0.8, 2, 1.6), flip = ctx.createGain();
        lp.type = 'lowpass'; lp.frequency.value = 1800;
        flip.gain.value = -1;
        lp.connect(g); g.connect(out);
        [164.81, 207.65, 261.63].forEach(freq => {
            const a = ctx.createOscillator(), b = ctx.createOscillator();
            a.type = 'sawtooth'; a.frequency.value = freq;
            b.type = 'sawtooth'; b.frequency.value = freq * 1.003;
            a.connect(lp); b.connect(flip);
            [a, b].forEach(o => { o.start(t); o.stop(t + 4.5); });
        });
        flip.connect(lp);
    },
    dataStream: (ctx, out, t) => {                       // a stream of random bits
        for (let i = 0; i < 32; i++) {
            const ti = t + i * 0.022;
            if (Math.random() < 0.5) noiseHit(ctx, out, ti, { type: 'highpass', freq: 5000, peak: 0.12, len: 0.015 });
            else toneHit(ctx, out, ti, { type: 'square', freq: 1800, peak: 0.05, len: 0.015 });
        }
    },
    kickChip: (ctx, out, t) => {
        const cr = crusher(ctx, 12);
        cr.connect(out);
        toneHit(ctx, cr, t, { type: 'square', freq: 160, to: 45, glide: 0.06, peak: 0.4, len: 0.18 });
        toneHit(ctx, out, t, { freq: 90, to: 40, glide: 0.08, peak: 0.36, len: 0.22 });
    },
    kick808d: (ctx, out, t) => {
        const cr = crusher(ctx, 32);
        cr.connect(out);
        toneHit(ctx, cr, t, { freq: 55, to: 40, glide: 0.3, peak: 0.6, len: 0.8 });
    },
    kickClick: (ctx, out, t) => {
        toneHit(ctx, out, t, { freq: 220, to: 60, glide: 0.015, peak: 0.7, len: 0.06 });
        noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.2, len: 0.003 });
    },
    snareNoise8: (ctx, out, t) => {                      // the noise channel in its buzzy short mode
        const src = loopNoise(ctx), g = decay(ctx, t, 0.3, 0.12);
        src.connect(g); g.connect(out);
        src.start(t); src.stop(t + 0.15);
        noiseHit(ctx, out, t, { type: 'highpass', freq: 2000, peak: 0.2, len: 0.08 });
    },
    snareChip: (ctx, out, t) => {
        noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.35, len: 0.1 });
        chipTone(ctx, out, t, { freq: 220, duty: 0.5, peak: 0.15, len: 0.06, steps: [[0.02, 196], [0.04, 175]] });
    },
    clap8: crushed((ctx, out, t) => {
        for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.011, { type: 'bandpass', freq: 1400, peak: 0.4, len: 0.018 });
        noiseHit(ctx, out, t + 0.033, { type: 'bandpass', freq: 1300, peak: 0.3, len: 0.12 });
    }, 4),
    ghost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 3000, peak: 0.14, len: 0.035 }),
    hatLfsr: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 8000, peak: 0.42, len: 0.03 }),
    hatTick: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 10000, Q: 1.5, peak: 0.4, len: 0.012 }),
    hatOpen: crushed((ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 6500, peak: 0.3, len: 0.25 }), 6),
    hatRatchet: (ctx, out, t) => {
        for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.025, { type: 'highpass', freq: 8500, peak: 0.3 - i * 0.06, len: 0.015 });
    },
    blip1: (ctx, out, t) => toneHit(ctx, out, t, { freq: 2093, peak: 0.25, len: 0.03 }),
    blip2: (ctx, out, t) => chipTone(ctx, out, t, { freq: 3136, duty: 0.125, peak: 0.15, len: 0.03 }),
    chirp: (ctx, out, t) => chipTone(ctx, out, t, { freq: 600, duty: 0.25, peak: 0.13, len: 0.08, steps: steppedPitch(600, 2400, 6, 0.06) }),
    dropDown: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2400, duty: 0.25, peak: 0.13, len: 0.08, steps: steppedPitch(2400, 300, 6, 0.06) }),
    coin: (ctx, out, t) => {                             // the "coin collected" two-note blip
        chipTone(ctx, out, t, { freq: 2093, duty: 0.5, peak: 0.1, len: 0.06 });
        chipTone(ctx, out, t + 0.06, { freq: 2637, duty: 0.5, peak: 0.1, len: 0.22 });
    },
    cowbell: (ctx, out, t) => {
        chipTone(ctx, out, t, { freq: 587.33, duty: 0.5, peak: 0.1, len: 0.12 });
        chipTone(ctx, out, t, { freq: 830.6, duty: 0.5, peak: 0.08, len: 0.12 });
    },
    tom: (from, to) => (ctx, out, t) => chipTone(ctx, out, t, { freq: from, duty: 0.5, peak: 0.3, len: 0.16, steps: steppedPitch(from, to, 5, 0.12) }),
    stabBit: (ctx, out, t) => {
        const cr = crusher(ctx, 3), g = decay(ctx, t, 0.12, 0.25);
        cr.connect(g); g.connect(out);
        [329.63, 415.3, 523.25].forEach(freq => {
            const o = ctx.createOscillator();
            o.setPeriodicWave(pulseWave(ctx, 0.5)); o.frequency.value = freq;
            o.connect(cr); o.start(t); o.stop(t + 0.27);
        });
    },
    zap8: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2000, duty: 0.25, peak: 0.14, len: 0.17, steps: steppedPitch(2000, 100, 10, 0.15) }),
    stutter: (ctx, out, t) => {
        for (let i = 0; i < 8; i++) chipTone(ctx, out, t + i * 0.044, { freq: 880, duty: 0.25, peak: 0.12 * (1 - i / 10), len: 0.02 });
    },
    byteCrush: (ctx, out, t) => {                        // a burst of 1-bit noise
        const src = noiseSource(ctx), cr = crusher(ctx, 1), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.4, 0.1);
        bp.type = 'bandpass'; bp.frequency.value = 1200;
        src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
        src.start(t, Math.random()); src.stop(t + 0.12);
    },
    powerDown: (ctx, out, t) => {                        // the machine powering down: a stepped fall, a sub drop, a crushed burst
        chipTone(ctx, out, t, { freq: 880, duty: 0.5, peak: 0.12, len: 0.85, steps: steppedPitch(880, 55, 16, 0.8) });
        const cr = crusher(ctx, 24);
        cr.connect(out);
        toneHit(ctx, cr, t, { freq: 80, to: 25, glide: 1.2, peak: 0.6, len: 1.4 });
        noiseHit(ctx, out, t, { type: 'lowpass', freq: 1200, peak: 0.25, len: 0.2 });
    },
};

const PLANET_STEP = 60 / 170 / 4;

const KIT_PLANET = {
    // ring 1: a chip lead, the whole-tone scale from E climbing clockwise round the centre
    1: [
        ['chip.e4', 329.63], ['chip.f♯4', 369.99], ['chip.g♯4', 415.3], ['chip.a♯4', 466.16],
        ['chip.c5', 523.25], ['chip.d5', 587.33], ['chip.e5', 659.25], ['chip.f♯5', 739.99],
    ].map(([id, freq]) => [id, chipLead(freq), { vel: 0.85, look: ['pulse-small', 'jitter-small'] }]),   // chip lead: a digital pop
    // ring 2: triangle bass round the top and right, sub and chip arps on the corner and bottom, digital textures up the left
    2: [
        ['tri.e2',  triBass(82.41),  { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['tri.f♯2', triBass(92.5),   { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['tri.g♯2', triBass(103.83), { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['tri.a♯2', triBass(116.54), { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['tri.c3',  triBass(130.81), { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['tri.d3',  triBass(146.83), { look: ['pulse', 'fill-small'], vel: 0.85, hit: { push: 60 } }],
        ['sub.e1',  subTone(41.2),   { cam: { zoom: 0.05 }, look: ['pulse-big', 'fill'], hit: { push: 90 } }],
        ['arp.e+',       chipArp([329.63, 415.3, 523.25], false), { look: ['spin-small', 'ring-small'], vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
        ['arp.e+.down',  chipArp([329.63, 415.3, 523.25], true),  { look: ['spin-small', 'ring-small'], vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
        ['arp.f♯+',      chipArp([369.99, 466.16, 587.33], false), { look: ['spin-small', 'ring-small'], vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
        ['arp.f♯+.down', chipArp([369.99, 466.16, 587.33], true),  { look: ['spin-small', 'ring-small'], vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
        ['sample.hold', digital.sampleHold, { look: ['jitter', 'flip'], vel: 0.8, hit: { push: 70, reach: 350, spin: 2, scatter: true } }],
        ['bit.sweep',   digital.bitSweep,   { cam: { pitch: 0.12 }, look: ['shrink-big', 'ring'], vel: 0.8, hit: { plane: [0, 1], push: 100 } }],
        ['modem',       digital.modem,      { look: ['jitter-big'], vel: 0.7, hit: { push: 90, reach: 300, scatter: true } }],
        ['pwm.pad',     digital.pwmPad,     { look: ['breathe'], hit: { push: 12, lift: 40 } }],
        ['data.stream', digital.dataStream, { look: ['jitter', 'fill-small'], vel: 0.7, hit: { push: 40, reach: 400, scatter: true } }],
    ],
    // ring 3: kicks and the power-down in the corners, snares mid-side, hats and blips by the corners, chip percussion between
    3: [
        ['kick.chip',     digital.kickChip,    { cam: { zoom: 0.035 }, look: ['pulse', 'fill-small'], hit: { push: 160 } }],
        ['hat.lfsr',      digital.hatLfsr,     { look: ['jitter-small'], vel: 0.5 }],
        ['chirp',         digital.chirp,       { look: ['spin'], vel: 0.6, hit: { plane: [0, -1], push: 60 } }],
        ['snare.noise8',  digital.snareNoise8, { cam: { shake: 4 }, look: ['squash', 'jitter-small'], vel: 0.85, hit: { push: -90 } }],
        ['drop.down',     digital.dropDown,    { look: ['spin', 'shrink-small'], vel: 0.6, hit: { plane: [0, 1], push: 60 } }],
        ['hat.tick.d',    digital.hatTick,     { look: ['jitter-small'], vel: 0.5 }],
        ['kick.808d',     digital.kick808d,    { cam: { zoom: 0.07 }, look: ['pulse-big', 'fill', 'launch'], vel: 0.8, hit: { push: 180, reach: 380 } }],
        ['hat.open.d',    digital.hatOpen,     { look: ['jitter', 'ring-small'], vel: 0.45 }],
        ['tom.chip.hi',   digital.tom(330, 180), { cam: { pitch: 0.06 }, look: ['pulse-small', 'fold'], vel: 0.7, hit: { push: 70 } }],
        ['snare.chip',    digital.snareChip,   { cam: { shake: 5, pitch: 0.05 }, look: ['squash'], vel: 0.8, hit: { push: -80 } }],
        ['tom.chip.lo',   digital.tom(220, 110), { cam: { pitch: 0.08 }, look: ['pulse', 'fold'], vel: 0.7, hit: { push: 90 } }],
        ['hat.ratchet',   digital.hatRatchet,  { look: ['jitter'], vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
        ['kick.click',    digital.kickClick,   { cam: { zoom: 0.018 }, look: ['pulse-small'], vel: 0.8, hit: { push: 110 } }],
        ['blip.1',        digital.blip1,       { look: ['flip-small'], vel: 0.6, hit: { push: 25, reach: 150, scatter: true } }],
        ['stab.bit',      digital.stabBit,     { cam: { yaw: 0.16 }, look: ['flip', 'fill-small', 'launch-small'], vel: 0.8, hit: { push: 120, reach: 280, scatter: true } }],
        ['clap.8bit',     digital.clap8,       { cam: { shake: 7 }, look: ['squash', 'jitter'], vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
        ['zap.8',         digital.zap8,        { cam: { roll: 0.12 }, look: ['spin-big'], vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
        ['blip.2',        digital.blip2,       { look: ['flip-small'], vel: 0.6, hit: { push: 25, reach: 150, scatter: true } }],
        ['power.down',    digital.powerDown,   { cam: { zoom: -0.12 }, look: ['fill-long', 'shrink-big', 'ring-big', 'tumble'], hit: { push: 480, reach: 380 } }],
        ['beep.coin',     digital.coin,        { look: ['flip', 'ring-small'], vel: 0.6, hit: { push: 20, twirl: 2 } }],
        ['glitch.stutter', digital.stutter,    { cam: { shake: 6 }, look: ['jitter-big', 'spin', 'tumble-small'], vel: 0.7, hit: { push: 70, reach: 380, spin: 3, scatter: true } }],
        ['snare.ghost.d', digital.ghost,       { look: ['squash-small'], vel: 0.5, hit: { push: -15 } }],
        ['byte.crush',    digital.byteCrush,   { cam: { shake: 10 }, look: ['jitter-big', 'fill', 'tumble'], vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
        ['cowbell.d',     digital.cowbell,     { look: ['shear-small'], vel: 0.5, hit: { push: 20 } }],
    ],
};

// Track 8, "Skoodu": generative again. The octatonic scale on D, FM throughout,
// half-time drum and bass that folds into double-time; rule 30 supplies the chaos.
const R30_STEP = 60 / 172 / 4;

const chime = freq => echoes(fm({ freq, ratio: 1.4, index: freq * 2.5, indexTo: 1, indexTime: 0.4, peak: 0.22, len: 1.0 }), 1, 3 * R30_STEP, 0.35);

const fmGrowl = freq => (ctx, out, t) => {              // neuro bass: FM whose brightness wobbles in sixteenths
    const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
    const lfo = ctx.createOscillator(), lfoDepth = ctx.createGain();
    car.frequency.value = freq * 2; mod.frequency.value = freq * 2;
    idx.gain.value = freq * 4;
    lfo.frequency.value = 1 / R30_STEP;
    lfoDepth.gain.value = freq * 3.6;
    lfo.connect(lfoDepth); lfoDepth.connect(idx.gain);
    mod.connect(idx); idx.connect(car.frequency);
    const cr = crusher(ctx, 16), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.22, 0.45);
    lp.type = 'lowpass'; lp.frequency.value = 1600;
    car.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
    const sub = ctx.createOscillator(), sg = decay(ctx, t, 0.3, 0.5);
    sub.type = 'sine'; sub.frequency.value = freq;
    sub.connect(sg); sg.connect(out);
    [car, mod, lfo, sub].forEach(o => { o.start(t); o.stop(t + 0.55); });
};

const fmHoover = (ctx, out, t) => {                      // a detuned FM stack that swoops up into a diminished chord
    const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.12, 0.05, 0.4, 0.5);
    lp.type = 'lowpass'; lp.frequency.value = 2500;
    lp.connect(g); g.connect(out);
    [146.83, 174.61, 207.65, 246.94].forEach(freq => [-15, 15].forEach(det => {
        const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), og = ctx.createGain();
        car.frequency.setValueAtTime(freq * 0.7, t); car.frequency.exponentialRampToValueAtTime(freq, t + 0.15);
        mod.frequency.setValueAtTime(freq * 0.7, t); mod.frequency.exponentialRampToValueAtTime(freq, t + 0.15);
        car.detune.value = det; mod.detune.value = det;
        idx.gain.value = freq * 1.5;
        og.gain.value = 0.5;
        mod.connect(idx); idx.connect(car.frequency); car.connect(og); og.connect(lp);
        car.start(t); mod.start(t); car.stop(t + 1.0); mod.stop(t + 1.0);
    }));
};

// high harmonics of one note, swelling in slowly
const harmonicPad = (base, harmonics, peak) => (ctx, out, t) => {
    const g = swell(ctx, t, peak, 1.2, 1.8, 2.2);
    g.connect(out);
    harmonics.forEach(h => {
        const o = ctx.createOscillator();
        o.type = 'sine'; o.frequency.value = base * h;
        o.connect(g); o.start(t); o.stop(t + 5.3);
    });
};

const r30 = {
    bitRain: (ctx, out, t) => {                          // a drizzle of crushed noise grains
        const cr = crusher(ctx, 5);
        cr.connect(out);
        for (let i = 0; i < 40; i++) {
            noiseHit(ctx, cr, t + Math.random() * 1.5, { type: 'bandpass', freq: 1500 + Math.random() * 6000, Q: 2, peak: 0.35, len: 0.008 });   // loud enough to survive the crusher's steps
        }
    },
    gear: (ctx, out, t) => {                             // a ratchet: tiny FM teeth, faster and faster
        let ti = t;
        for (let i = 0; i < 8; i++) {
            fmHit(ctx, out, ti, { freq: 2400 + i * 100, ratio: 2.2, index: 1500, indexTo: 30, peak: 0.2, len: 0.012 });
            ti += 0.05 * Math.pow(0.85, i);
        }
    },
};

const KIT_RULE30 = {
    1: [
        ['chime.d4', 293.66], ['chime.e♭4', 311.13], ['chime.f4', 349.23], ['chime.f♯4', 369.99],
        ['chime.g♯4', 415.3], ['chime.a4', 440.0], ['chime.b4', 493.88], ['chime.c5', 523.25],
    ].map(([id, freq]) => [id, chime(freq), { vel: 0.85, look: ['shear', 'echo'] }]),
    2: [
        ['growl.g♯1', fmGrowl(51.91), { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['growl.a1',  fmGrowl(55.0),  { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['growl.b1',  fmGrowl(61.74), { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['growl.d2',  fmGrowl(73.42), { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['growl.e♭2', fmGrowl(77.78), { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['growl.f2',  fmGrowl(87.31), { look: ['fold', 'pulse-small'], vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
        ['sub.d2',      subTone(73.42), { look: ['pulse-big', 'fill'], hit: { push: 90 } }],
        ['hoover.dim',  fmHoover,       { look: ['flip', 'ring-big'], vel: 0.8, hit: { push: 60, reach: 500, lift: 50, swirl: 1, centre: true } }],
        ['stab.dim.d',  fmStab([293.66, 349.23, 415.3, 493.88]), { look: ['flip', 'fill-small'], vel: 0.8, hit: { push: 110, reach: 280, scatter: true } }],
        ['stab.dim.e♭', fmStab([311.13, 369.99, 440.0, 523.25]), { look: ['flip', 'fill-small'], vel: 0.8, hit: { push: 110, reach: 280, scatter: true } }],
        ['data.burst',    rule.dataBurst,    { look: ['jitter-big'], vel: 0.8, hit: { push: 120, reach: 300, scatter: true } }],
        ['grain.cloud',   rule.grainCloud,   { look: ['jitter', 'ring'], vel: 0.8, hit: { push: 40, reach: 400, scatter: true } }],
        ['formant.shift', rule.formantShift, { look: ['spin-big'], vel: 0.8, hit: { push: 60, reach: 350, swirl: -1, centre: true } }],
        ['bit.rain',      r30.bitRain,       { look: ['jitter'], vel: 0.8, hit: { push: 50, reach: 400, scatter: true } }],
        ['cold.pad.d',    coldPad([146.83, 174.61, 207.65, 246.94]), { look: ['breathe'], hit: { push: 12, lift: 40 } }],
        ['glass.pad',     harmonicPad(146.83, [5, 7, 9, 11], 0.05),   { look: ['breathe', 'ring'], hit: { push: 10, lift: 40 } }],
    ],
    3: [
        ['kick.fm',      fm({ freq: 90, to: 45, glide: 0.08, ratio: 1.6, index: 400, indexTo: 1, indexTime: 0.05, peak: 0.6, len: 0.3 }), { look: ['pulse', 'fill-small'], vel: 0.8, hit: { push: 160 } }],
        ['hat.fm1',      fm({ freq: 7000, ratio: 1.43, index: 6000, peak: 0.3, len: 0.04 }), { look: ['jitter-small'], vel: 0.5 }],
        ['clang.2',      fm({ freq: 587.33, ratio: 2.76, index: 1500, indexTo: 20, indexTime: 0.4, peak: 0.3, len: 0.6 }), { look: ['flip', 'fill'], vel: 0.7, hit: { push: 140, reach: 260, scatter: true } }],
        ['snare.fm',     fmSnare,          { look: ['squash', 'shrink-small'], vel: 0.85, hit: { push: -95 } }],
        ['gear',         r30.gear,         { look: ['spin-big'], vel: 0.7, hit: { push: 30, reach: 300, twirl: 7 } }],
        ['hat.fm2',      fm({ freq: 5200, ratio: 2.71, index: 5000, peak: 0.3, len: 0.06 }), { look: ['jitter-small'], vel: 0.5 }],
        ['kick.punch',   dnb.kickPunch,    { look: ['pulse', 'fill'], vel: 0.72, hit: { push: 170 } }],
        ['hat.noise',    hat(9000, 0.03, 0.35), { look: ['jitter-small'], vel: 0.5 }],
        ['rim.fm',       fm({ freq: 1400, ratio: 2.3, index: 1500, indexTo: 10, indexTime: 0.02, peak: 0.32, len: 0.03 }), { look: ['squash-small'], vel: 0.6, hit: { push: -30 } }],
        ['snare.gate',   rule.snareGate,   { look: ['squash'], vel: 0.8, hit: { push: -80 } }],
        ['crunch',       rule.crunch,      { look: ['jitter-big', 'fill'], vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
        ['hat.ring',     rule.hatRing,     { look: ['spin-small'], vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
        ['kick.sub',     dnb.kickSub,      { look: ['pulse-big', 'fill'], vel: 0.85, hit: { push: 150 } }],
        ['ride.fm',      (ctx, out, t) => {
                             fmHit(ctx, out, t, { freq: 3200, ratio: 1.47, index: 3000, indexTo: 200, peak: 0.1, len: 0.8 });
                             noiseHit(ctx, out, t, { type: 'highpass', freq: 7000, peak: 0.06, len: 0.4 });
                         }, { look: ['shear-small', 'ring-small'], vel: 0.5 }],
        ['buzz',         rule.buzz,        { look: ['jitter'], vel: 0.7, hit: { push: 90, reach: 260, scatter: true } }],
        ['clap.digital', rule.clapDigital, { look: ['squash', 'jitter'], vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
        ['zap.fm',       fm({ freq: 3000, to: 200, glide: 0.08, ratio: 1.5, index: 2000, indexTo: 100, peak: 0.2, len: 0.1 }), { look: ['shear', 'spin-small'], vel: 0.6, hit: { plane: [-1, 0], push: 90 } }],
        ['click.sample', (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 12000, peak: 0.6, len: 0.002 }), { look: ['jitter-small'], vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
        ['clang.big',    fm({ freq: 196.0, ratio: 1.414, index: 1800, indexTo: 20, indexTime: 0.8, peak: 0.42, len: 1.3 }), { look: ['fill-long', 'pulse-big', 'ring-big'], vel: 0.8, hit: { push: 440, reach: 380 } }],
        ['snare.ghost',  fm({ freq: 220, ratio: 6.1, index: 1200, indexTo: 50, peak: 0.12, len: 0.05 }), { look: ['squash-small'], vel: 0.5, hit: { push: -15 } }],
        ['stutter',      idm.stutter,      { look: ['jitter-big', 'spin'], vel: 0.6, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
        ['snare.crush',  crushed(fmSnare, 4), { look: ['squash-small', 'jitter'], vel: 0.75, hit: { push: 110, reach: 220, scatter: true } }],
        ['reverse.fm',   rule.reverseFm,   { look: ['shrink-big'], vel: 0.8, hit: { push: -130, reach: 400 } }],
        ['tine',         fm({ freq: 2349, ratio: 3.5, index: 3000, indexTo: 5, indexTime: 0.1, peak: 0.22, len: 0.4 }), { look: ['ring', 'shear-small'], vel: 0.6, hit: { push: 15 } }],
    ],
};

function playSound(id) {
    if (!audioReady) return;
    const tile = TILE[id];
    if (!tile) return;
    try {
        const ctx = getCtx();
        if (ctx.state === 'suspended') ctx.resume();
        tile.voice(ctx, roomFor(cur), ctx.currentTime);
    } catch (e) { /* ignore */ }
}

// ─────────────────────────────────────────────
//  THE SONGS
//  Every note names the tile that plays it, written on a 16-step grid per bar.
//  x = hit, o = ghost (softer) hit. Swing delays every other sixteenth.
// ─────────────────────────────────────────────
function makeWriter(velOf) {
    const events = [], sections = [];
    let cursor = 0;
    const vel = id => velOf[id];
    const hit  = (bar, step, id, len = 1, v, fx) => events.push({ at: bar * 16 + step, id, len, v, fx });   // fx: a stage cue riding on this note
    const grid = (bar, id, pattern, v) => [...pattern].forEach((c, i) => {
        if (c === 'x') hit(bar, i, id, 1, v);
        if (c === 'o') hit(bar, i, id, 1, (v ?? vel(id)) * 0.5);
    });
    const line = (bar, notes) => notes.forEach(([step, id, len, v, fx]) => hit(bar, step, id, len, v, fx));
    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;
    };
    // plays a list of tiles in turn, one per hit, so a pattern walks across them
    const chaser = list => {
        let turn = 0;
        return (bar, pattern, v) => [...pattern].forEach((c, i) => {
            if (c === '.') return;
            const id = list[turn++ % list.length];
            hit(bar, i, id, 1, c === 'o' ? (v ?? vel(id)) * 0.5 : v);
        });
    };
    // n hits spread evenly across `span` steps, swelling from v0 to v1: a drill roll
    const ratchet = (bar, step, id, n, span, v0, v1) => {
        for (let i = 0; i < n; i++) hit(bar, step + span * i / n, id, 1, v0 + (v1 - v0) * (n > 1 ? i / (n - 1) : 1));
    };
    // a hit every `period` steps counted across bars, so threes, fives and sevens drift against the bar
    const cycle = (bar, id, period, offset, v) => {
        for (let st = 0; st < 16; st++) if ((bar * 16 + st - offset) % period === 0) hit(bar, st, id, 1, v);
    };
    // a seeded random source: patterns sound improvised but play identically every time
    const seeded = start => {
        let seed = start;
        const rng = () => {
            seed = (seed + 0x6D2B79F5) | 0;
            let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
            t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
        return { rng, pick: list => list[Math.floor(rng() * list.length)], chance: p => rng() < p };
    };
    // Euclidean rhythm: k hits spread as evenly as possible over n steps, counted from the song's start
    // so patterns of 7, 11 or 13 drift against the bar. A list of tiles is played in turn.
    const euclidPattern = (k, n) => Array.from({ length: n }, (_, i) => Math.floor((i + 1) * k / n) - Math.floor(i * k / n) === 1);
    const turns = new Map();
    const nextOf = list => { const i = (turns.get(list) ?? -1) + 1; turns.set(list, i); return list[i % list.length]; };
    const euclid = (bar, ids, k, n, rot, v, lean = 0) => {
        const pat = euclidPattern(k, n), list = Array.isArray(ids) ? ids : [ids];
        for (let st = 0; st < 16; st++) {
            if (pat[((bar * 16 + st + rot) % n + n) % n]) hit(bar, st + (st % 2 ? lean : 0), nextOf(list), 1, v);
        }
    };
    // a cellular automaton: 16 cells, one per step; each evolve() is the next generation.
    // When the row dies out, a few new cells are seeded so it keeps going.
    const automaton = rng => {
        let cells = Array(16).fill(0);
        cells[8] = 1;
        return {
            evolve(r) {
                cells = cells.map((_, i) => (r >> ((cells[(i + 15) % 16] << 2) | (cells[i] << 1) | cells[(i + 1) % 16])) & 1);
                if (!cells.some(Boolean)) { cells = cells.map(() => (rng() < 0.2 ? 1 : 0)); cells[Math.floor(rng() * 16)] = 1; }
            },
            // live cells fire tiles from the list; `every` = 2 keeps only the cells on even steps
            hits(bar, list, v, every = 1) {
                cells.forEach((on, st) => {
                    if (on && st % every === 0) hit(bar, st, list[(Math.floor(st / 2) + bar) % list.length], 1, v * (0.6 + 0.4 * rng()));
                });
            },
        };
    };
    return {
        hit, grid, line, section, chaser, vel, ratchet, cycle, seeded, euclid, automaton,
        done: () => ({ events: events.sort((a, b) => a.at - b.at), sections, bars: cursor }),
    };
}

const SONGS = [
    {
        title: 'Nomo Chant', meta: 'a minor, 100 bpm', bpm: 100, swing: 0, tail: 0.4,
        kit: KIT_TESSELLATE,
        write({ hit, grid, line, section, chaser }) {
            // the closed hi-hats are played in order, so the beat walks clockwise round the edge
            const chase = chaser(['hat.1', 'hat.2', 'hat.3', 'hat.4', 'hat.5', 'hat.6', 'hat.7']);

    // bass lines walk through the bass tiles; E bars use the gritty E2
    const BASS = {
        A1: [[0, 'bass.a2'], [3, 'bass.a2'], [6, 'bass.c3'], [10, 'bass.a2'], [13, 'bass.g2']],
        A2: [[0, 'bass.a2'], [3, 'bass.a2'], [6, 'bass.d3'], [10, 'bass.c3'], [13, 'bass.a2']],
        C:  [[0, 'bass.c3'], [3, 'bass.c3'], [6, 'bass.g2'], [10, 'bass.c3'], [13, 'bass.d3']],
        E:  [[0, 'grit.e2'], [6, 'grit.e2'], [10, 'grit.e2'], [14, 'bass.f2', 1, 0.6]],
    };
    const PROG = ['A1', 'A2', 'C', 'E'];          // Am | Am | C | E
    const bassline = (bar, k) => BASS[PROG[k % 4]].forEach(([s, id, len = 1, v]) => hit(bar, s, id, len, v));

    // the tune, first time through: the original melody, note for note
    const THEME = [
        [[0,'lead.a4',4], [4,'bell.c5',4], [8,'lead.a4',2], [10,'lead.g4',2], [12,'lead.a4',4]],
        [[0,'ping.e6',6], [6,'lead.g4',2], [8,'lead.f4',4], [12,'lead.g4',4]],
        [[0,'bell.c5',6], [6,'lead.a4',2], [8,'lead.g4',4], [12,'lead.f4',2], [14,'lead.g4',2]],
        [[0,'dyad.e5.g#5',6], [8,'vibrato.a4',4], [12,'dyad.e5.g#5',4]],
        [[0,'lead.a4',4], [4,'bell.c5',4], [8,'ping.e6',4], [12,'bell.c5',4]],
        [[0,'lead.a4',2], [2,'lead.g4',2], [4,'lead.a4',4], [8,'bell.c5',2], [10,'lead.a4',2], [12,'lead.g4',4]],
        [[0,'lead.f4',4], [4,'lead.g4',4], [8,'lead.a4',4], [12,'bell.c5',4]],
        [[0,'dyad.e5.g#5',8], [8,'vibrato.a4',4]],
    ];
    // second time through: the same tune, opened out across the whole lead ring
    const THEME_AGAIN = [
        [[0,'lead.a4',4], [4,'lead.c5',4], [8,'lead.b4',2], [10,'lead.g4',2], [12,'lead.a4',4]],
        [[0,'ping.e6',4], [4,'lead.e5',2], [6,'lead.d5',2], [8,'lead.c5',4], [12,'lead.b4',4]],
        [[0,'lead.c5',6], [6,'lead.e5',2], [8,'lead.d5',4], [12,'lead.c5',2], [14,'lead.g4',2]],
        [[0,'dyad.e5.g#5',6], [8,'vibrato.a4',4], [12,'lead.b4',2], [14,'lead.e4',2]],
        [[0,'lead.a4',4], [4,'lead.c5',4], [8,'lead.e5',4], [12,'ping.e6',4]],
        [[0,'lead.a4',2], [2,'lead.g4',2], [4,'lead.a4',4], [8,'lead.c5',2], [10,'lead.b4',2], [12,'lead.g4',4]],
        [[0,'lead.f4',4], [4,'lead.g4',4], [8,'lead.a4',4], [12,'lead.c5',4]],
        [[0,'dyad.e5.g#5',8], [8,'lead.b4',2], [10,'lead.d5',2]],
    ];

    section('intro', 4, (b, k) => {
        if (k === 0) line(b, [[0,'bass.a2',2], [0,'chord.c',8], [12,'ping.e6',4]]);
        if (k === 1) line(b, [[0,'bass.c3',2], [0,'arp.c.major',4], [8,'bell.c5',8]]);
        if (k === 2) {
            line(b, [[0,'bass.a2',2], [0,'chord.c',8], [8,'ping.e6',4], [12,'bell.c5',4]]);
            chase(b, '..x...x...x...x.', 0.3);
        }
        if (k === 3) {
            line(b, [[0,'grit.e2',2], [0,'dyad.e5.g#5',8], [8,'rise',8], [12,'tom.high'], [14,'tom.low',2]]);
            chase(b, '..x...x...x...x.', 0.42);
        }
    });

    section('groove', 4, (b, k) => {
        grid(b, 'kick.deep', 'x...............');
        grid(b, 'kick.mid',  '......x.x.......');
        grid(b, 'rim.click', '....x...........');
        grid(b, 'snare.tight', '............x...');
        chase(b, '..x...x...x...x.');
        bassline(b, k);
        if (k === 0) hit(b, 0, 'chord.c', 4);
        if (k === 2) hit(b, 0, 'arp.c.major', 4);
        if (k === 3) line(b, [[0,'dyad.e5.g#5',6], [8,'dyad.e5.g#5',4], [12,'tom.high'], [13,'tom.mid',1,0.5], [14,'tom.low',2]]);
    });

    section('theme', 8, (b, k) => {
        grid(b, 'kick.deep', 'x...............');
        grid(b, 'kick.mid',  '......x.x.......');
        grid(b, 'rim.click', '....x...........');
        grid(b, 'snare.tight', '............x...');
        chase(b, 'o.x.o.x.o.x.o...');
        hit(b, 14, 'hat.open');
        bassline(b, k);
        line(b, THEME[k]);
        if (k === 0) hit(b, 0, 'crash', 8);                   // strong enough to crack a few tiles loose
        if (k === 7) line(b, [[12,'fall',4], [14,'tom.low',2]]);
    });

    section('break', 4, (b, k) => {
        const air = '..x.......x.....';
        if (k === 0) { line(b, [[0,'bass.a2',2], [0,'chord.am',8], [8,'vibrato.a4',8]]); chase(b, air, 0.3); }
        if (k === 1) { line(b, [[0,'bass.c3',2], [0,'arp.c.major',4], [4,'bell.c5',4], [8,'ping.e6',4], [12,'fall',4]]); chase(b, air, 0.3); }
        if (k === 2) { line(b, [[0,'bass.f2',2], [0,'chord.f',4], [4,'vibrato.a4',4], [8,'lead.g4',4], [12,'lead.f4',4]]); chase(b, air, 0.35); }
        if (k === 3) {                                         // the build: every snare pulls the grid in
            line(b, [[0,'grit.e2',2], [0,'chord.e',8], [8,'rise',8], [14,'zap',2]]);
            grid(b, 'kick.deep', '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, 'snare.tight', 1, v));
        }
    });

    section('theme.again', 8, (b, k) => {
        grid(b, 'kick.deep', 'x...............');
        grid(b, 'kick.mid',  '......x.x.......');
        grid(b, 'kick.high', '..........x.....');
        grid(b, 'rim.click', '....x...........');
        grid(b, 'clap',      '............x...');
        grid(b, 'rim.wood',  '.......o.......o');
        chase(b, 'o.xo..x.o.xo....');
        hit(b, 14, 'hat.open');
        bassline(b, k);
        line(b, THEME_AGAIN[k]);
        if (k === 0) line(b, [[0,'boom',4], [0,'crash',8,0.5], [0,'rise',4,0.6], [0,'arp.c.major',4]]);   // the drop
        if (k === 1 || k === 5) hit(b, 9, 'cowbell');
        if (k === 2 || k === 6) hit(b, 8, 'chord.c', 4, 0.55);
        if (k === 6) hit(b, 0, 'chord.f', 4, 0.45);
        if (k === 3 || k === 7) hit(b, 0, 'chord.e', 4, 0.5);
        if (k === 4) line(b, [[0,'arp.c.major',4], [0,'crash',8,0.5]]);
        if (k === 5) hit(b, 8, 'arp.c.major', 4, 0.45);
        if (k === 7) line(b, [[12,'glide.c4.c5',4], [12,'tom.high',1,0.7], [13,'tom.mid',1,0.85], [14,'tom.low'], [15,'tom.low']]);
    });

    section('outro', 4, (b, k) => {
        if (k < 3) { hit(b, 0, 'kick.deep', 1, 0.7); chase(b, '..x...x...x...x.', 0.35); }
        if (k === 0) { grid(b, 'bass.a2', 'x..x..x...x..x..', 0.7); line(b, [[0,'chord.c',4], [4,'lead.a4',4], [8,'bell.c5',4], [12,'ping.e6',4]]); }
        if (k === 1) { grid(b, 'bass.c3', 'x..x..x...x..x..', 0.75); line(b, [[4,'lead.g4',4], [8,'lead.f4',4], [12,'lead.g4',4]]); }
        if (k === 2) { grid(b, 'grit.e2', 'x.....x...x.....', 0.45); line(b, [[0,'dyad.e5.g#5',8], [8,'vibrato.a4',8]]); }
        if (k === 3) {                                         // the last chord: A minor
            line(b, [[0,'kick.deep',16,0.75], [0,'bass.a2',16], [0,'chord.am',16,0.6],
                     [0,'vibrato.a4',16], [0,'bell.c5',16], [0,'ping.e6',16], [10,'fall',4,0.5]]);
        }
    });

        },
    },
    {
        title: 'Numerous Under One', meta: 'f minor, 174 bpm, drum & bass', bpm: 174, swing: 0.05, tail: 2.5,
        room: { seconds: 1.6, shape: 3, wet: 0.18 },
        kit: KIT_DNB,
        write({ hit, grid, line, section, chaser }) {
            const hats = chaser(['hat.1', 'hat.2', 'hat.3', 'hat.4', 'hat.5']);

            // an eight-bar loop, two bars per chord: Fm, Db, Ab, Eb
            const CH = ['fm', 'fm', 'db', 'db', 'ab', 'ab', 'eb', 'eb'];
            const chordOf = k => CH[k % 8];

            const BASS = {
                fm: [[[0,'reese.f1',6], [6,'reese.f1',4], [10,'reese.f1',6]],
                     [[0,'reese.f1',6], [6,'reese.a♭1',4], [10,'reese.c2',4], [14,'reese.e♭2',2]]],
                db: [[[0,'reese.d♭2',6], [6,'reese.d♭2',4], [10,'reese.d♭2',6]],
                     [[0,'reese.d♭2',6], [6,'reese.c2',4], [10,'reese.b♭1',4], [14,'reese.a♭1',2]]],
                ab: [[[0,'reese.a♭1',6], [6,'reese.a♭1',4], [10,'reese.a♭1',6]],
                     [[0,'reese.a♭1',6], [6,'reese.b♭1',4], [10,'reese.c2',4], [14,'reese.e♭2',2]]],
                eb: [[[0,'reese.e♭2',6], [6,'reese.e♭2',4], [10,'reese.e♭2',6]],
                     [[0,'reese.e♭2',6], [6,'reese.f2',4], [10,'reese.g1',2], [12,'reese.d♭2',2], [14,'reese.c2',2]]],
            };
            const bass  = (b, k, v) => BASS[chordOf(k)][k % 2].forEach(([s, id, len]) => hit(b, s, id, len, v));
            const pads  = (b, k, v) => { if (k % 2 === 0) hit(b, 0, `pad.${chordOf(k)}`, 32, v); };
            const stabs = (b, k, v) => grid(b, `stab.${chordOf(k)}`, '......x.......x.', v);

            // two-step: kick on one and the and-of-three, snare on two and four, ghost snares between
            const beat = (b, busy) => {
                grid(b, 'kick.punch', busy ? 'x.x.......x.....' : 'x.........x.....');
                grid(b, 'snare.main', '....x.......x...');
                grid(b, 'snare.ghost', '.......o.o....o.');
                hats(b, 'o.x.o.x.o.x.o.x.');
            };
            const fill = b => line(b, [[12,'tom.high'], [13,'tom.high',1,0.7], [14,'tom.low'], [15,'tom.low',1,0.9]]);

            const HOOK = [
                [[0,'lead.c5',4], [4,'lead.e♭5',2], [6,'lead.f5',4], [10,'lead.e♭5',2], [12,'lead.c5',4]],
                [[0,'lead.a♭4',2], [2,'lead.b♭4',2], [4,'lead.c5',6], [12,'lead.b♭4',2], [14,'lead.a♭4',2]],
                [[0,'lead.f4',4], [4,'lead.a♭4',2], [6,'lead.c5',4], [10,'lead.d♭5',2], [12,'lead.c5',4]],
                [[0,'lead.a♭4',6], [8,'lead.f4',4], [12,'lead.g4',4]],
                [[0,'lead.e♭5',4], [4,'lead.c5',2], [6,'lead.e♭5',4], [10,'lead.f5',2], [12,'lead.e♭5',4]],
                [[0,'lead.c5',2], [2,'lead.d♭5',2], [4,'lead.c5',4], [8,'lead.b♭4',4], [12,'lead.a♭4',4]],
                [[0,'lead.g4',4], [4,'lead.b♭4',2], [6,'lead.e♭5',4], [10,'lead.d♭5',2], [12,'lead.b♭4',4]],
                [[0,'lead.g4',2], [2,'lead.a♭4',2], [4,'lead.b♭4',4], [8,'lead.c5',8]],
            ];
            const ARPS = {
                fm: ['lead.f4', 'lead.a♭4', 'lead.c5', 'lead.e♭5'],
                db: ['lead.f4', 'lead.a♭4', 'lead.c5', 'lead.d♭5'],
                ab: ['lead.a♭4', 'lead.c5', 'lead.e♭5', 'lead.f5'],
                eb: ['lead.g4', 'lead.b♭4', 'lead.d♭5', 'lead.e♭5'],
            };
            const arp = (b, k, v) => [0, 1, 2, 3, 2, 1, 2, 3].forEach((n, i) => hit(b, i * 2, ARPS[chordOf(k)][n], 2, v));

            section('intro', 8, (b, k) => {
                pads(b, k, 0.8);
                if (k < 4) { grid(b, 'ride', 'x...x...x...x...', 0.35); grid(b, 'shaker', '.o.o.o.o.o.o.o.o'); }
                if (k === 1 || k === 3) hit(b, 8, 'vox.ah', 4);
                if (k >= 4) {
                    hats(b, 'o.x.o.x.o.x.o.x.', 0.35);
                    grid(b, 'snare.rim', '....x.......x...');
                    grid(b, 'kick.sub', 'x.........x.....', 0.6);
                }
                if (k === 5) grid(b, 'tamb', '..x...x...x...x.');
                if (k === 6) hit(b, 0, 'riser', 16);
                if (k === 7) {                                                 // the grid is drawn in before the drop
                    hit(b, 4, 'reverse', 12);
                    [8, 9, 10, 11, 12, 13, 14, 15].forEach((s, i) => hit(b, s, 'snare.main', 1, 0.3 + i * 0.08));
                }
            });

            const drop = (name, second) => section(name, 16, (b, k) => {
                if (k === 0) line(b, [[0,'impact',8], [0,'crash',8]]);       // and blown apart
                if (k === 8) hit(b, 0, 'crash', 8);
                beat(b, k % 4 === 3);
                bass(b, k);
                pads(b, k, 0.55);
                if (second) { grid(b, 'ride', 'x...x...x...x...', 0.35); grid(b, 'snare.clap', '....x.......x...', 0.6); }
                if (k >= 4) grid(b, 'shaker', '.o.o.o.o.o.o.o.o', 0.4);
                if (k % 2 === 1) hit(b, 14, 'hat.open');
                const hookFirst = !second;
                if ((k < 8) === hookFirst) line(b, HOOK[k % 8]); else { arp(b, k, 0.75); stabs(b, k, 0.7); }
                if (k === 4 || k === 12) hit(b, 8, 'laser', 4);
                if (k === 10 || k === 14) hit(b, 0, 'vox.ah', 4);
                if (k === 7 || k === 15) fill(b);
            });
            drop('drop', false);

            section('break', 8, (b, k) => {                                  // drums out, pads and melody
                if (k === 0) line(b, [[0,'downlifter',14], [0,'crash',8,0.5]]);
                pads(b, k, 1);
                grid(b, 'ride', 'x...x...x...x...', 0.3);
                if (k % 2 === 0) hit(b, 0, 'kick.sub', 1, 0.7);
                line(b, HOOK[k].map(([s, id, len]) => [s, id, len, 0.6]));
                if (k === 3 || k === 7) hit(b, 8, 'vox.ah', 4);
                if (k >= 4) { grid(b, 'snare.rim', '....x.......x...', 0.5); hats(b, '..x...x...x...x.', 0.3); }
            });

            section('build', 4, (b, k) => {                                  // a snare roll that keeps pulling inward
                pads(b, k, 0.7);
                grid(b, 'kick.punch', 'x...x...x...x...', 0.8);
                if (k === 0 || k === 2) hit(b, 0, 'reese.f1', 16, 0.8);
                const roll = ['....x.......x...', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.', 'xxxxxxxxxxxxxxxx'][k];
                [...roll].forEach((c, i) => { if (c === 'x') hit(b, i, 'snare.main', 1, 0.35 + 0.15 * k + i * 0.012); });
                if (k === 1) hit(b, 12, 'laser', 4);
                if (k === 2) hit(b, 0, 'riser', 16);
                if (k === 3) hit(b, 4, 'reverse', 12);
            });

            drop('drop.again', true);

            section('outro', 8, (b, k) => {
                pads(b, k, 0.8);
                if (k < 4) {
                    grid(b, 'kick.punch', 'x.........x.....', 0.7);
                    grid(b, 'snare.main', '....x.......x...', 0.7);
                    hats(b, 'o.x.o.x.o.x.o.x.', 0.35);
                    bass(b, k, 0.8);
                }
                if (k === 4) line(b, [[0,'downlifter',14], [0,'crash',8,0.5]]);
                if (k >= 4 && k < 7) grid(b, 'ride', 'x...x...x...x...', 0.25);
                if (k === 6) hit(b, 8, 'vox.ah', 4, 0.5);
                if (k === 7) line(b, [[0,'kick.sub',4], [0,'reese.f1',16,0.7], [0,'crash',8,0.4], [0,'pad.fm',32]]);
            });
        },
    },
    {
        title: 'All You Twin', meta: 'e minor, 152 bpm, idm', bpm: 152, swing: 0, tail: 2,
        room: { seconds: 1.2, shape: 4, wet: 0.15 },
        kit: KIT_IDM,
        write({ hit, grid, line, section }) {
            // a seeded random generator: the beat sounds improvised but plays identically every time
            let seed = 2001;
            const rng = () => {
                seed = (seed + 0x6D2B79F5) | 0;
                let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
                t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
                return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
            };
            const pick = list => list[Math.floor(rng() * list.length)];
            const chance = p => rng() < p;
            const anyHalfStep = () => Math.floor(rng() * 32) / 2;

            // n hits spread evenly across `span` steps, swelling from v0 to v1: the drill roll
            const ratchet = (b, step, id, n, span, v0, v1) => {
                for (let i = 0; i < n; i++) hit(b, step + span * i / n, id, 1, v0 + (v1 - v0) * (n > 1 ? i / (n - 1) : 1));
            };
            // a hit every `period` steps counted across bars, so threes, fives and sevens drift against the bar
            const cycle = (b, id, period, offset, v) => {
                for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, id, 1, v);
            };

            // Em9 | Cmaj7 | Am9 | Bsus, two bars each
            const CH = ['em9', 'em9', 'cmaj7', 'cmaj7', 'am9', 'am9', 'bsus', 'bsus'];
            const pads = (b, k, v) => { if (k % 2 === 0) hit(b, 0, `pad.${CH[k % 8]}`, 32, v); };

            const MELODY = [
                [[0,'box.b4',2], [2,'box.e5',2], [4,'box.d5',2], [6,'box.b4',2], [8,'box.g4',4], [12,'box.a4',2], [14,'box.b4',2]],
                [[0,'box.g4',6], [6,'box.f♯4',2], [8,'box.e4',8]],
                [[0,'box.c5',2], [2,'box.e5',2], [4,'box.d5',2], [6,'box.c5',2], [8,'box.b4',4], [12,'box.g4',4]],
                [[0,'box.a4',6], [6,'box.b4',2], [8,'box.g4',8]],
                [[0,'box.e5',2], [2,'box.c5',2], [4,'box.a4',2], [6,'box.c5',2], [8,'box.b4',4], [12,'box.a4',2], [14,'box.g4',2]],
                [[0,'box.a4',8], [8,'box.e4',4], [12,'box.g4',4]],
                [[0,'box.f♯4',2], [2,'box.a4',2], [4,'box.b4',4], [8,'box.d5',2], [10,'box.b4',2], [12,'box.a4',4]],
                [[0,'box.a4',4], [4,'box.f♯4',4], [8,'box.b4',8]],
            ];
            const melody = (b, k, v) => MELODY[k % 8].forEach(([st, id, len]) => hit(b, st, id, len, v));

            const ACID = [
                [[0,'acid.e2'], [2,'acid.e3'], [3,'acid.e2'], [6,'acid.g2'], [8,'acid.e2'], [10,'acid.b2'], [11,'acid.d3'], [14,'acid.e3']],
                [[0,'acid.e2'], [1,'acid.e2'], [4,'acid.g3'], [6,'acid.e3'], [7,'acid.b2'], [10,'acid.a2'], [12,'acid.g2'], [14,'acid.b3']],
            ];
            const acidBar = (b, k) => ACID[k % 2].forEach(([st, id], i) => hit(b, st, id, 1, i === 0 ? 0.95 : 0.5 + rng() * 0.35));

            const KICKS = ['x.....x...x.....', 'x..x......x..x..', 'x.......x.x.....', 'x..x..x.....x...', 'x.x.......x...x.', 'x.....x.x..x....'];
            const SNARES = ['....x.......x...', '....x.....x.....', '....x..x....x...', '.....x......x...'];
            const DEBRIS = ['click.1', 'click.2', 'blip', 'bleep', 'zap.small', 'metal.ping', 'rim.glitch', 'tick.wood', 'static', 'stutter'];

            // one bar of scattered drill'n'bass
            const drill = (b, density) => {
                [...pick(KICKS)].forEach((c, st) => { if (c === 'x') hit(b, st + (chance(0.15) ? 0.5 : 0), 'kick.hard'); });
                if (chance(0.5)) hit(b, pick([3, 7, 11, 13.5]), 'kick.short', 1, 0.7);
                grid(b, 'snare.crack', pick(SNARES));
                for (let r = 0; r < density; r++) ratchet(b, pick([6, 10, 12, 14]), 'snare.crack', pick([3, 4, 6, 8]), pick([1, 2]), 0.25, 0.9);
                for (let st = 0; st < 16; st++) {
                    if (chance(0.7)) hit(b, st, 'hat.tick', 1, 0.35 + rng() * 0.35);
                    if (chance(0.15)) hit(b, st + 0.5, 'snare.ghost', 1, 0.3 + rng() * 0.3);
                }
                if (chance(0.3)) ratchet(b, pick([2, 6, 9]), 'hat.closed', 3, 1, 0.3, 0.5);
                const n = 1 + Math.floor(rng() * 3);
                for (let i = 0; i < n; i++) hit(b, anyHalfStep(), pick(DEBRIS), 1, 0.4 + rng() * 0.3);
            };

            section('lullaby', 8, (b, k) => {                                // a worn music box over warm pads
                pads(b, k, 0.8);
                melody(b, k, 0.9);
                if (k === 0 || k === 4) hit(b, 0, 'static', 4, 0.4);
                if (chance(0.6)) hit(b, Math.floor(rng() * 16), 'click.1', 1, 0.35);
                if (k === 3) hit(b, 14, 'tick.wood', 1, 0.5);
                if (k === 6) hit(b, 8, 'fm.bell', 8, 0.5);
                if (k === 7) { hit(b, 12, 'reverse.snare', 4); ratchet(b, 14, 'blip', 4, 2, 0.3, 0.6); }
            });

            section('splinter', 8, (b, k) => {                               // the beat arrives in pieces
                pads(b, k, 0.6);
                if (k < 4) melody(b, k, 0.65);
                grid(b, 'kick.hard', pick(KICKS));
                grid(b, 'snare.crack', pick(SNARES), 0.75);
                cycle(b, 'hat.tick', 5, 0, 0.4);                              // fives against the four-beat bar
                cycle(b, 'hat.closed', 3, 1, 0.25);                           // and threes
                for (let i = 0; i < 2; i++) hit(b, anyHalfStep(), pick(['click.1', 'click.2']), 1, 0.45);
                if (k >= 4) acidBar(b, k);
                if (k % 2 === 1) ratchet(b, 14, 'snare.crack', 6, 2, 0.3, 0.9);
                if (k === 0) hit(b, 0, 'crush');                              // the first splinter
                if (k === 2 || k === 6) hit(b, 7, 'bleep');
                if (k === 3) hit(b, 10, 'zap.small');
            });

            section('drill', 12, (b, k) => {                                 // full drill'n'bass
                pads(b, k, 0.5);
                drill(b, k < 6 ? 1 : 2);
                acidBar(b, k);
                if (k % 4 === 0) hit(b, 0, 'bit.chord');
                if (k % 4 === 3) { hit(b, 12, 'snare.808'); hit(b, 13.5, 'snare.808', 1, 0.6); }
                if (k % 2 === 1) hit(b, 12, 'clap.glitch');
                if (k % 4 === 2) hit(b, 14, 'hat.open');
                if (k === 4 || k === 8) hit(b, 0, 'kick.808', 8);
                if (k === 6) hit(b, 8, 'grain.vox', 4);
                if (k >= 8) melody(b, k, 0.55);
            });

            section('collapse', 4, (b, k) => {                               // the tape stops and the floor drops out
                if (k === 0) line(b, [[0,'tape.stop',8], [0,'sub.drop',12], [10,'stutter',4]]);
                if (k > 0) {
                    hit(b, 0, 'kick.808', 8);
                    hit(b, 8, 'snare.808');
                    grid(b, 'hat.shuffle', '..x...x...x...x.', 0.4);
                }
                if (k === 1) line(b, [[0,'pad.am9',32,0.7], [4,'grain.vox',4]]);
                if (k === 2) line(b, [[0,'pad.bsus',32,0.7], [6,'fm.bell',8,0.6], [12,'static',4,0.5]]);
                if (k === 3) { hit(b, 12, 'reverse.snare', 4); ratchet(b, 14, 'snare.crack', 8, 2, 0.3, 1); }
            });

            section('rebuild', 12, (b, k) => {                               // the melody returns over the scattered beat
                pads(b, k, 0.55);
                melody(b, k, 0.8);
                drill(b, k % 2);
                acidBar(b, k + 1);
                cycle(b, 'hat.shuffle', 7, 3, 0.35);                          // sevens
                if (k % 4 === 0) hit(b, 0, 'bit.chord', 1, 0.7);
                if (k === 0) hit(b, 0, 'crush');
                if (k % 2 === 1) hit(b, 12, 'clap.glitch', 1, 0.6);
                if (k === 6) hit(b, 0, 'kick.808', 8);
                if (k === 11) {
                    ratchet(b, 8, 'snare.crack', 12, 8, 0.25, 1);
                    hit(b, 12, 'reverse.snare', 4);
                    hit(b, 15, 'crush');
                }
            });

            section('lullaby.again', 8, (b, k) => {                          // the beat dissolves back into the music box
                pads(b, k, 0.75);
                melody(b, k, 0.85);
                if (k < 4) {
                    hit(b, 0, 'kick.short', 1, 0.5);
                    cycle(b, 'hat.tick', 5, 0, 0.25 - k * 0.04);
                    if (chance(0.7)) hit(b, anyHalfStep(), pick(['click.1', 'click.2', 'blip']), 1, 0.35);
                }
                if (k === 2) hit(b, 8, 'fm.bell', 8, 0.45);
                if (k === 4) hit(b, 0, 'static', 4, 0.35);
                if (k === 7) line(b, [[8,'tape.stop',8,0.8], [8,'static',4,0.3]]);   // and the tape winds down
            });
        },
    },
    {
        title: 'Zones', meta: 'g minor, 174 bpm, drill & bass', bpm: 174, swing: 0, tail: 2, smooth: true,   // smooth: every move eases, but the grid can still break
        room: { seconds: 1.0, shape: 4, wet: 0.14 },
        kit: KIT_FAULT,
        write({ hit, grid, line, section, chaser, ratchet, cycle, seeded }) {
            const { rng, pick, chance } = seeded(2002);
            const halfStep = () => Math.floor(rng() * 32) / 2;
            const hats = chaser(['hat.break', 'hat.tick']);

            // Gm | Eb | Cm | D, two bars each
            const CH = ['gm', 'gm', 'eb', 'eb', 'cm', 'cm', 'd', 'd'];
            const BASS = {
                gm: [[[0,'reese.g1',6], [6,'reese.g1',4], [10,'reese.g1',6]],
                     [[0,'reese.g1',6], [6,'reese.b♭1',4], [10,'reese.d2',4], [14,'reese.g1',2]]],
                eb: [[[0,'reese.e♭2',6], [6,'reese.e♭2',4], [10,'reese.e♭2',6]],
                     [[0,'reese.e♭2',6], [6,'reese.d2',4], [10,'reese.c2',4], [14,'reese.b♭1',2]]],
                cm: [[[0,'reese.c2',6], [6,'reese.c2',4], [10,'reese.c2',6]],
                     [[0,'reese.c2',6], [6,'reese.d2',4], [10,'reese.e♭2',4], [14,'reese.d2',2]]],
                d:  [[[0,'reese.d2',6], [6,'reese.d2',4], [10,'reese.d2',6]],
                     [[0,'reese.d2',6], [6,'reese.a1',4], [10,'reese.d2',2], [12,'reese.c2',2], [14,'reese.a1',2]]],
            };
            const ZONE_OF = { gm: 0, eb: 1, cm: 2, d: 3 };                      // each chord has a zone of the grid
            const bass = (b, k, v) => BASS[CH[k % 8]][k % 2].forEach(([st, id, len], i) => hit(b, st, id, len, v, i === 0 && k % 2 === 0 ? { zone: ZONE_OF[CH[k % 8]] } : undefined));
            const pads = (b, k, v) => {
                const p = { 0: 'pad.gm9', 2: 'pad.e♭maj7', 4: 'pad.gm9', 6: 'pad.e♭maj7' }[k % 8];
                if (p) hit(b, 0, p, 32, v);
            };

            const LEAD = [
                [[0,'glass.g4',2], [2,'glass.b♭4',2], [4,'glass.d5',2], [6,'glass.f5',2], [8,'glass.d5',4], [12,'glass.b♭4',2], [14,'glass.c5',2]],
                [[0,'glass.d5',4], [4,'glass.c5',2], [6,'glass.b♭4',2], [8,'glass.a4',8]],
                [[0,'glass.g4',2], [2,'glass.b♭4',2], [4,'glass.e♭5',2], [6,'glass.d5',2], [8,'glass.b♭4',4], [12,'glass.g4',4]],
                [[0,'glass.b♭4',4], [4,'glass.c5',2], [6,'glass.d5',2], [8,'glass.e♭5',8]],
                [[0,'glass.c5',2], [2,'glass.e♭5',2], [4,'glass.g5',2], [6,'glass.f5',2], [8,'glass.e♭5',4], [12,'glass.d5',2], [14,'glass.c5',2]],
                [[0,'glass.e♭5',4], [4,'glass.d5',4], [8,'glass.c5',8]],
                [[0,'glass.a4',2], [2,'glass.d5',2], [4,'glass.a4',2], [6,'glass.d5',2], [8,'glass.c5',4], [12,'glass.b♭4',4]],
                [[0,'glass.a4',4], [4,'glass.b♭4',4], [8,'glass.a4',4], [12,'glass.d5',4]],
            ];
            const lead = (b, k, v, drop = 0) => LEAD[k % 8].forEach(([st, id, len]) => { if (!chance(drop)) hit(b, st, id, len, v); });
            const ARPS = {
                gm: ['glass.g4', 'glass.b♭4', 'glass.d5', 'glass.f5'],
                eb: ['glass.g4', 'glass.b♭4', 'glass.e♭5', 'glass.d5'],
                cm: ['glass.g4', 'glass.c5', 'glass.e♭5', 'glass.g5'],
                d:  ['glass.a4', 'glass.c5', 'glass.d5', 'glass.f5'],
            };
            const arp = (b, k, v) => [0, 1, 2, 3, 2, 1, 2, 3].forEach((n, i) => hit(b, i * 2, ARPS[CH[k % 8]][n], 2, v));

            // chopped breakbeat patterns: kick, snare, ghost snare
            const BREAKS = [
                { k: 'x.x.......x.....', s: '....x.......x...', g: '.......o.o....o.' },
                { k: 'x.........xx....', s: '....x.......x..x', g: '..o....o......o.' },
                { k: 'x.x.......x.....', s: '....x..x....x...', g: '......o.......o.' },
                { k: 'x.........x...x.', s: '....x.......x.x.', g: '.o.....o.o......' },
            ];
            const DEBRIS = ['click', 'blip', 'bleep', 'zap', 'metal.ping', 'static', 'stutter', 'crush'];

            // one bar of chopped break; heat 0..1 is how badly it splinters
            const breakBar = (b, k, heat) => {
                const br = BREAKS[k % 4 === 3 ? pick([1, 3]) : pick([0, 2])];
                [...br.k].forEach((c, st) => { if (c === 'x') hit(b, st + (chance(0.12 * heat) ? 0.5 : 0), 'kick.break'); });
                grid(b, 'snare.amen', br.s);
                grid(b, 'snare.ghost', br.g);
                hats(b, 'x.x.x.x.x.x.x.x.', 0.45);
                for (let st = 1; st < 16; st += 2) if (chance(0.35)) hit(b, st, 'hat.tick', 1, 0.25 + rng() * 0.25);
                if (chance(0.4 + 0.5 * heat)) ratchet(b, pick([6, 10, 14]), 'snare.crush', pick([3, 4, 6, 8]), pick([1, 2]), 0.25, 0.85);
                const n = Math.floor(heat * 3 + rng() * 2);
                for (let i = 0; i < n; i++) hit(b, halfStep(), pick(DEBRIS.slice(0, heat > 0.6 ? 8 : 6)), 1, 0.35 + rng() * 0.3);
            };

            // ── more building blocks for the long form ──
            const ROOT = { gm: 'reese.g1', eb: 'reese.e♭2', cm: 'reese.c2', d: 'reese.d2' };
            const SHIFT = ['cm', 'cm', 'gm', 'gm', 'eb', 'eb', 'd', 'd'];            // the zone shift: the same chords in a new order
            const PAD_OF = { gm: 'pad.gm9', eb: 'pad.e♭maj7', cm: 'pad.e♭maj7', d: 'pad.gm9' };
            const bassOn = (b, k, v, prog = CH) => BASS[prog[k % 8]][k % 2].forEach(([st, id, len], i) => hit(b, st, id, len, v, i === 0 && k % 2 === 0 ? { zone: ZONE_OF[prog[k % 8]] } : undefined));
            const padsOn = (b, k, v, prog) => { if (k % 2 === 0) hit(b, 0, PAD_OF[prog[k % 8]], 32, v); };
            const arpOn = (b, k, v, prog, contour) => contour.forEach((n, i) => hit(b, i * 2, ARPS[prog[k % 8]][n], 2, v));
            // drill: half-time kicks that skip, the snare on three, rolling triplet hats, sudden rolls
            const drill = (b, k, heat, snareFx) => {
                grid(b, 'kick.sub', ['x......x..x.....', 'x.........x..x..', 'x......x...x....', 'x.x.......x.....'][k % 4], 0.72);
                hit(b, 8, 'snare.amen', 1, 0.85, snareFx);
                grid(b, 'snare.ghost', '...o......o..o..', 0.35);
                for (let i = 0; i < 12; i++) hit(b, i * 4 / 3, i % 3 === 0 ? 'hat.break' : 'hat.tick', 1, i % 3 === 0 ? 0.42 : 0.28);
                if (chance(0.3 + 0.4 * heat)) ratchet(b, pick([5, 11, 13]), 'snare.crush', pick([3, 6]), 2, 0.3, 0.8);
            };
            const acidLine = b => line(b, [[8,'acid.g2'], [10,'acid.d3'], [11,'acid.g2',1,0.6], [14,'acid.d3']]);
            // stage cues: a quake blows the grid apart and it falls back together; a crack slides it along a fault; a press flattens it
            const quake = (b, o = {}) => {
                line(b, [[0, 'impact', 8, o.v, { form: { cloud: o.cloud ?? 0.9, ...(o.form || {}) }, over: 0, energy: o.energy, sweep: true, cam: o.cam }], [0, 'crash.crush', 8, o.v ? o.v * 0.5 : undefined], [0, 'sub.g1', 8, o.v]]);
                hit(b + 1, 0, 'kick.sub', 1, 0.7, { form: { cloud: 0 }, over: 1.5 });
            };
            const crack = (dir, amount = 0.8) => ({ pulse: { fault: dir * amount }, attack: 0.04, hold: 0.1, release: 0.4 });
            const press = (amount = 0.35) => ({ pulse: { press: amount }, attack: 0.03, release: 0.35 });

            // 1 ── glass: plucks and pads, the beat at a distance
            section('glass', 8, (b, k) => {
                pads(b, k, 0.8);
                if (k < 4) lead(b, k, 0.7);
                if (k === 0) hit(b, 0, 'static', 4, 0.4, { energy: 0.15 });
                for (let i = 0; i < 2; i++) if (chance(0.7)) hit(b, halfStep(), pick(['click', 'blip']), 1, 0.35);
                if (k === 2) hit(b, 10, 'zap');
                if (k === 3) line(b, [[14,'blip'], [15,'bleep']]);
                if (k >= 4) {
                    grid(b, 'kick.sub', 'x.........x.....', 0.6);
                    grid(b, 'rim.glitch', '....x.......x...', 0.6);
                    cycle(b, 'hat.tick', 5, 0, 0.35);
                }
                if (k === 5) hit(b, 8, 'vox.chop', 4);
                if (k === 6) hit(b, 8, 'vox.chop', 4, 0.6);
                if (k === 7) line(b, [[8,'metal.ping'], [12,'blip'], [14,'bleep']]);
            });

            // 2 ── tremor: the ground starts to move. Sub kick and long reese in half-time under glass arpeggios;
            //      the first small cracks run through the grid on the rim
            section('tremor', 8, (b, k) => {
                pads(b, k, 0.6);
                if (k === 0) hit(b, 0, 'sub.g1', 16, 0.7, { energy: 0.35, over: 4 });
                grid(b, 'kick.sub', k < 4 ? 'x.........x.....' : 'x.........x..x..', 0.75);
                hit(b, 8, 'rim.glitch', 1, 0.7, k % 2 ? crack(k % 4 === 1 ? 1 : -1, 0.45) : undefined);
                if (k % 2 === 0) hit(b, 0, ROOT[CH[k % 8]], 12, 0.65);
                arp(b, k, 0.45);
                cycle(b, 'hat.tick', 3, 0, 0.3);
                if (k >= 4) hats(b, '..x...x...x...x.', 0.35);
                if (k === 2) hit(b, 4, 'vox.chop', 4);
                if (k === 5) hit(b, 0, 'siren', 8, 0.5);
                if (k === 6) hit(b, 0, 'riser', 16);
                if (k === 7) {
                    hit(b, 4, 'reverse', 12);
                    ratchet(b, 8, 'snare.crush', 8, 6, 0.3, 0.8);
                    ratchet(b, 14, 'snare.crush', 6, 2, 0.6, 1);
                }
            });

            // 3, 7 ── the drops: a quake opens each; the stabs crack the grid along fault lines
            const drop = (name, second) => section(name, 16, (b, k) => {
                if (k === 0) quake(b, { energy: second ? 0.85 : 0.7, form: second ? { tunnel: 0 } : {} });
                if (k === 8) hit(b, 0, 'crash.crush', 8, undefined, second ? undefined : { energy: 0.78, over: 1 });
                breakBar(b, k, second ? 0.8 + 0.2 * (k / 15) : (k < 8 ? 0.4 : 0.7));
                bass(b, k);
                pads(b, k, second ? 0.45 : 0.5);
                if (second) grid(b, 'ride.crunch', 'x...x...x...x...', 0.4);
                if (k % 2 === 1) hit(b, 14, 'hat.open');
                const hookFirst = !second;
                if ((k < 8) === hookFirst) lead(b, k, 0.8); else arp(b, k, 0.7);
                if (k % 4 === 0 && (second || k >= 8)) hit(b, 0, 'bit.stab', 1, 0.75, k > 0 ? crack(k % 8 === 0 ? 1 : -1, 0.7) : undefined);
                if (k % 4 === (second ? 1 : 2) && k >= (second ? 0 : 8)) hit(b, 6, 'stab.stutter', 1, 0.7, second ? press(0.3) : undefined);
                if (k % 4 === (second ? 1 : 3)) acidLine(b);
                if (k % (second ? 4 : 8) === (second ? 3 : 7)) hit(b, 12, 'growl.d2', 4);
                if (k === 2 && !second) hit(b, 15, 'crush');
                if (k === 4) hit(b, 8, 'laser', 4);
                if (k === (second ? 12 : 10)) hit(b, 0, 'siren', 8);
                if (second && (k === 6 || k === 14)) hit(b, 4, 'vox.chop', 4);
            });
            drop('fault', false);

            // 4 ── splinter.break: the break shatters into pure glitch
            section('splinter.break', 8, (b, k) => {
                pads(b, k, 0.8);
                if (k % 2 === 0) hit(b, 0, 'sub.g1', 16, 0.8, k === 0 ? { energy: 0.45, over: 2 } : undefined);
                lead(b, k, 0.6, 0.3);                                         // the melody, with pieces missing
                cycle(b, 'hat.tick', 5, 0, 0.4);
                cycle(b, 'hat.break', 7, 2, 0.3);
                grid(b, 'kick.sub', pick(['x.........x.....', 'x......x........', 'x.....x...x.....']), 0.7);
                for (let r = 0; r < 1 + (k % 2); r++) ratchet(b, pick([4, 8, 12]), 'snare.crush', pick([4, 6, 8]), pick([1, 2]), 0.25, 0.8);
                for (let i = 0; i < 2; i++) hit(b, halfStep(), pick(DEBRIS.slice(0, 6)), 1, 0.4);
                if (k === 0 || k === 4) hit(b, 0, 'bit.stab', 1, 0.7);
                if (k === 1) hit(b, 8, 'stutter');
                if (k === 2 || k === 6) hit(b, 4, 'vox.chop', 4);
                if (k === 3) hit(b, 6, 'metal.ping');
            });

            // 5 ── drift.zone: a liquid zone. The beat falls to half-time, the glass melody floats over long pads,
            //      a siren echoes, the ride rolls; the grid rolls in a slow wave
            section('drift.zone', 20, (b, k) => {
                if (k === 0) line(b, [[0, 'pad.gm9', 32, 0.75, { form: { wave: 0.5 }, over: 4, energy: 0.3 }], [0, 'sub.g1', 16, 0.7]]);
                else pads(b, k, 0.7);
                if (k >= 4) {
                    grid(b, 'kick.sub', 'x.........x.....', 0.7);
                    hit(b, 8, 'snare.amen', 1, 0.55);
                    grid(b, 'snare.ghost', '..o....o.o....o.', 0.3);
                }
                if (k >= 8) grid(b, 'ride.crunch', 'x.x.x.x.x.x.x.x.', 0.3);
                cycle(b, 'hat.tick', 3, 1, 0.25);
                if (k >= 4 && k % 2 === 0) hit(b, 0, ROOT[CH[k % 8]], 12, 0.6);
                lead(b, k, 0.55, 0.15);
                if (k % 8 === 3) hit(b, 0, 'siren', 8, 0.45);
                if (k % 8 === 6) hit(b, 4, 'vox.chop', 4, 0.6);
                if (k === 10 || k === 14) acidLine(b);
                if (k === 16) hit(b, 0, 'riser', 16, 0.7, { form: { wave: 0 }, over: 4, energy: 0.5 });
                if (k === 19) { hit(b, 4, 'reverse', 12); ratchet(b, 12, 'snare.crush', 6, 4, 0.4, 0.9); }
            });

            // 6 ── build: rolls that keep pulling inward, the grid stretching into a tunnel
            section('build', 4, (b, k) => {
                pads(b, k, 0.7);
                grid(b, 'kick.break', 'x...x...x...x...', 0.8);
                const roll = ['....x.......x...', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.', 'xxxxxxxxxxxx....'][k];
                [...roll].forEach((c, i) => { if (c === 'x') hit(b, i, 'snare.amen', 1, 0.35 + 0.15 * k + i * 0.012); });
                [0, 4, 8, 12].forEach(st => { if (k >= 1 && chance(0.6)) ratchet(b, st + 2, 'snare.crush', 2 + k, 1, 0.3, 0.7); });
                if (k === 0 || k === 2) hit(b, 0, 'reese.g1', 16, 0.8, k === 0 ? { form: { tunnel: 0.6 }, over: 4, energy: 0.6 } : undefined);
                if (k === 2) hit(b, 0, 'riser', 16);
                if (k === 3) { hit(b, 4, 'reverse', 12); hit(b, 12, 'tape.stop', 4); }
            });

            drop('fault.again', true);

            // 8 ── zone.shift: the zone shifts. The same chords in a new order, drill drums with triplet hats;
            //      the grid is wrung like a cloth one way, then wrenched the other; the stutter chords crack it
            section('zone.shift', 24, (b, k) => {
                const ch = SHIFT[k % 8];
                if (k === 0) line(b, [[0, 'tape.stop', 8, 0.8, { form: { twist: 0.6 }, over: 4, energy: 0.7 }], [0, 'sub.g1', 16, 0.6]]);
                drill(b, k, 0.4 + 0.4 * k / 23, k % 4 === 2 ? press(0.3) : undefined);
                bassOn(b, k, 0.68, SHIFT);
                padsOn(b, k, 0.45, SHIFT);
                arpOn(b, k, 0.55, SHIFT, k < 12 ? [0, 2, 1, 3, 2, 0, 3, 1] : [3, 1, 2, 0, 3, 2, 1, 0]);
                if (k % 4 === 0) hit(b, 0, 'bit.stab', 1, 0.75);
                if (k % 4 === 1) hit(b, 6, 'stab.stutter', 1, 0.7, crack(k % 8 === 1 ? 1 : -1, 0.7));
                if (k % 4 === 3) acidLine(b);
                if (k % 8 === 7) hit(b, 12, 'growl.d2', 4);
                if (k % 8 === 6) hit(b, 4, 'vox.chop', 4);
                if (k === 8) hit(b, 0, 'crash.crush', 8, 0.6, { form: { twist: -0.6 }, over: 3 });   // wrenched the other way
                if (k === 12) hit(b, 8, 'laser', 4);
                if (k === 16) hit(b, 0, 'tape.stop', 4, 0.6, { form: { twist: 0 }, over: 4 });
                if (k === 20) hit(b, 0, 'siren', 8);
                if (k === 23) ratchet(b, 8, 'snare.crush', 8, 8, 0.4, 0.9);
            });

            // 9 ── sub.zone: underground. Almost nothing but sub kicks, reese and rim, the columns pumping like pistons
            section('sub.zone', 16, (b, k) => {
                if (k === 0) line(b, [[0, 'sub.g1', 16, 0.6, { form: { pistons: 0.7 }, over: 2, energy: 0.55 }], [0, 'growl.d2', 4, 0.7]]);
                else if (k % 2 === 0) hit(b, 0, 'sub.g1', 16, 0.45);
                grid(b, 'kick.sub', ['x.........x.....', 'x......x..x.....'][k % 2], 0.7);
                hit(b, 8, 'rim.glitch', 1, 0.8);
                if (k >= 4) hit(b, 8, 'snare.amen', 1, 0.5);
                if (k % 2 === 0) hit(b, 0, ROOT[CH[k % 8]], 14, 0.55);
                cycle(b, 'hat.tick', 5, k, 0.3);
                if (k >= 8) grid(b, 'hat.open', '..x...x...x...x.', 0.3);
                if (k % 4 === 3) acidLine(b);
                if (k % 8 === 5) hit(b, 12, 'growl.d2', 4);
                if (k === 6) hit(b, 0, 'vox.chop', 4);
                if (k === 10) hit(b, 0, 'siren', 8, 0.5);
                if (k === 12) hit(b, 0, 'riser', 16, 0.8, { form: { pistons: 0 }, over: 2, energy: 0.8 });
                if (k >= 12) {
                    grid(b, 'kick.break', 'x...x...x...x...', 0.5 + 0.08 * (k - 12));
                    [...['....x.......x...', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.', 'xxxxxxxxxxxx....'][k - 12]].forEach((c, i) => { if (c === 'x') hit(b, i, 'snare.amen', 1, 0.35 + 0.1 * (k - 12)); });
                }
                if (k === 15) { hit(b, 4, 'reverse', 12); ratchet(b, 12, 'snare.crush', 6, 4, 0.5, 1); }
            });

            // 10 ── glitch.storm: the climax. The break at full splinter, cracks on every stutter chord, two quakes and a press
            section('glitch.storm', 20, (b, k) => {
                if (k === 0) quake(b, { energy: 1, cam: { zoom: -0.3, pitch: 0.3 } });
                if (k === 10) quake(b, { cloud: 0.6, v: 0.7 });
                breakBar(b, k, 1);
                bassOn(b, k);
                grid(b, 'ride.crunch', 'x.x.x.x.x.x.x.x.', 0.4);
                if (k % 2 === 1) hit(b, 14, 'hat.open');
                hit(b, 6, 'stab.stutter', 1, 0.65, crack(k % 2 ? 1 : -1, 0.7));
                if (k % 2 === 0 && k !== 0 && k !== 10) hit(b, 0, 'bit.stab', 1, 0.8);
                if (k % 8 < 4) arp(b, k, 0.65); else lead(b, k, 0.7, 0.25);
                for (let i = 0; i < 3; i++) hit(b, halfStep(), pick(DEBRIS), 1, 0.4);
                if (k % 4 === 3) ratchet(b, 12, 'snare.crush', 8, 4, 0.4, 1);
                if (k % 4 === 1) acidLine(b);
                if (k % 4 === 3) hit(b, 12, 'growl.d2', 4);
                if (k === 6 || k === 14) hit(b, 0, 'tape.stop', 4, 0.6);
                if (k === 8) line(b, [[0, 'crash.crush', 8, undefined, { pulse: { press: 0.45 }, attack: 0.03, release: 0.5 }], [0, 'stutter']]);
                if (k === 16) hit(b, 0, 'siren', 8);
                if (k === 19) { hit(b, 0, 'laser', 4); ratchet(b, 8, 'snare.crush', 16, 8, 0.4, 1); }
            });

            // 11 ── eye.zone: the eye of the storm. Only glass, pads and sub; the grid sinks into a tunnel
            section('eye.zone', 12, (b, k) => {
                if (k === 0) line(b, [[0, 'pad.e♭maj7', 32, 0.7, { form: { tunnel: 0.7 }, over: 6, energy: 0.15 }], [0, 'tape.stop', 8, 0.7]]);
                else pads(b, k + 2, 0.65);
                lead(b, k, 0.5, 0.35);
                if (k % 2 === 0) hit(b, 0, 'sub.g1', 16, 0.5);
                cycle(b, 'click', 7, 0, 0.3);
                if (k % 4 === 2) hit(b, 8, 'metal.ping', 1, 0.5);
                if (k === 5) hit(b, 4, 'vox.chop', 4, 0.5);
                if (k === 8) hit(b, 0, 'siren', 8, 0.4);
                if (k >= 8) grid(b, 'kick.sub', 'x...............', 0.55);
            });

            // 12 ── build.ii: the snare fills in faster each bar to a roll of thirty-seconds; the tunnel stretches all the way
            section('build.ii', 8, (b, k) => {
                if (k === 0) hit(b, 0, 'riser', 16, 0.8, { form: { tunnel: 1 }, over: 8, energy: 0.75 });
                pads(b, k, 0.6);
                grid(b, 'kick.break', k < 4 ? 'x.......x.......' : 'x...x...x...x...', 0.8);
                if (k < 7) { const n = [2, 4, 4, 8, 8, 12, 16][k]; for (let i = 0; i < n; i++) hit(b, i * 16 / n, 'snare.amen', 1, 0.3 + 0.08 * k); }
                else ratchet(b, 0, 'snare.amen', 32, 16, 0.35, 1);
                if (k % 2 === 0) hit(b, 0, ROOT[CH[k % 8]], 12, 0.7);
                arp(b, k, 0.35 + 0.04 * k);
                if (k === 4) hit(b, 0, 'riser', 16, 0.8);
                if (k === 6) hit(b, 0, 'siren', 8, 0.6);
                if (k === 7) hit(b, 12, 'tape.stop', 4);
            });

            // 13 ── final.fault: the last drop, everything at once. Two quakes, fault cracks on the stabs, presses on the crashes
            section('final.fault', 32, (b, k) => {
                if (k === 0) quake(b, { energy: 1, form: { tunnel: 0 }, cam: { zoom: -0.4, pitch: 0.4 } });
                if (k === 16) quake(b, { cloud: 0.8, v: 0.75 });
                breakBar(b, k, 0.85 + 0.15 * Math.min(1, k / 16));
                bassOn(b, k);
                pads(b, k, 0.45);
                grid(b, 'ride.crunch', 'x...x...x...x...', 0.4);
                if (k % 2 === 1) hit(b, 14, 'hat.open');
                if (k % 16 < 8) lead(b, k, 0.85); else arp(b, k, 0.75);
                if (k % 4 === 0 && k % 16 !== 0) hit(b, 0, 'bit.stab', 1, 0.8, crack(k % 8 === 4 ? 1 : -1, 0.8));
                if (k % 4 === 1) { hit(b, 6, 'stab.stutter', 1, 0.7); acidLine(b); }
                if (k % 4 === 3) hit(b, 12, 'growl.d2', 4);
                if (k % 8 === 6) hit(b, 4, 'vox.chop', 4);
                if (k === 12 || k === 28) hit(b, 0, 'siren', 8);
                if (k === 8 || k === 24) hit(b, 0, 'crash.crush', 8, 0.6, press(0.4));
                if (k === 20) hit(b, 8, 'laser', 4);
                if (k === 31) { hit(b, 4, 'reverse', 12); ratchet(b, 8, 'snare.crush', 8, 6, 0.4, 1); }
            });

            // 14 ── aftershock: the break thins and the quakes come smaller each time; the grid settles to the floor in layers
            section('aftershock', 16, (b, k) => {
                if (k === 0) line(b, [[0, 'impact', 8, 0.8, { energy: 0.5, over: 4 }], [0, 'crash.crush', 8, 0.5]]);
                if (k < 10) breakBar(b, k, 0.5 - k * 0.04);
                if (k < 8) bassOn(b, k, 0.85 - k * 0.04);
                pads(b, k, 0.7);
                if (k >= 4) lead(b, k, 0.6, 0.2);
                if (k === 4 || k === 8 || k === 12) hit(b, 0, 'sub.g1', 8, 0.8 - (k - 4) * 0.08, crack(k === 8 ? -1 : 1, 0.6 - (k - 4) * 0.06));
                if (k === 6) hit(b, 0, 'siren', 8, 0.4);
                if (k === 9) hit(b, 4, 'vox.chop', 4, 0.5, { form: { settle: 0.75 }, over: 7, energy: 0.2 });
                if (k >= 10) { cycle(b, 'hat.tick', 5, 0, 0.25); if (chance(0.7)) hit(b, halfStep(), pick(['click', 'blip']), 1, 0.35); }
            });

            // 15 ── outro: glass and clicks, and a last tape stop
            section('outro', 8, (b, k) => {
                pads(b, k, 0.7 - k * 0.04);
                lead(b, k, 0.6 - k * 0.04, 0.2 + k * 0.05);
                cycle(b, 'hat.tick', 5, 0, 0.22);
                if (chance(0.6)) hit(b, halfStep(), pick(['click', 'blip']), 1, 0.3);
                if (k % 2 === 0) hit(b, 0, 'sub.g1', 16, 0.45);
                if (k === 4) hit(b, 0, 'crash.crush', 8, 0.35);
                if (k === 7) line(b, [[8,'tape.stop',8,0.8], [8,'static',4,0.3]]);
            });
        },
    },
    {
        title: 'Torque Region', meta: 'c phrygian, 84 bpm, dark half-time', bpm: 84, swing: 0.15, tail: 6, smooth: true,   // smooth: every move eases, but the grid can still break
        room: { seconds: 4.5, shape: 2.2, wet: 0.2, tone: 0.18 },       // a large, dark space
        kit: KIT_UNDERTOW,
        write({ hit, grid, line, section, chaser, ratchet, cycle, seeded }) {
            const { rng, pick, chance } = seeded(1987);
            const halfStep = () => Math.floor(rng() * 32) / 2;
            const hats = chaser(['hat.chain', 'hat.tick']);

            // C, C, D-flat, C: the flattened second pulls everything down
            const CH = ['c', 'c', 'd♭', 'c'];
            const RIFFS = {
                c: [[[0,'sludge.c2',4], [6,'sludge.c2',2], [8,'sludge.e♭2',2], [10,'sludge.c2',2], [12,'sludge.g1',4]],
                    [[0,'sludge.c2',4], [6,'sludge.d♭2',2], [8,'sludge.c2',4], [12,'sludge.a♭1',2], [14,'sludge.g1',2]]],
                'd♭': [[[0,'sludge.d♭2',4], [6,'sludge.d♭2',2], [8,'sludge.f2',2], [10,'sludge.e♭2',2], [12,'sludge.d♭2',4]]],
            };
            const riff = (b, ch, k, v) => { const set = RIFFS[ch]; set[k % set.length].forEach(([st, id, len]) => hit(b, st, id, len, v)); };
            const droneOn = (b, ch, v) => hit(b, 0, ch === 'd♭' ? 'drone.d♭' : 'drone.c', 32, v);

            const BELLS = [
                [[0,'ash.g4',6], [6,'ash.a♭4',2], [8,'ash.g4',8]],
                [[0,'ash.e♭4',4], [4,'ash.f4',4], [8,'ash.d♭4',8]],
                [[0,'ash.f4',6], [6,'ash.a♭4',2], [8,'ash.c5',4], [12,'ash.b♭4',4]],
                [[0,'ash.a♭4',4], [4,'ash.g4',4], [8,'ash.c4',8]],
            ];
            const bells = (b, k, v, drop = 0) => BELLS[k % 4].forEach(([st, id, len]) => { if (!chance(drop)) hit(b, st, id, len, v); });

            const TEXTURE = ['anvil', 'pipe', 'knock', 'creak', 'click.dark', 'static.dark', 'bleep.low', 'scrape'];

            // the half-time beat: the kick on one, the heavy snare on three, the hats dragging behind
            const trudge = (b, heat) => {
                grid(b, 'kick.dirt', pick(['x.........x.....', 'x..........x....', 'x.........x..x..']));
                if (chance(0.4)) hit(b, pick([6, 7, 13.5]), 'kick.hollow', 1, 0.6);
                hit(b, 8, 'snare.ash');
                grid(b, 'snare.ghost', pick(['...o......o...o.', '.o.....o.....o..']));
                hats(b, 'x.x.x.x.x.x.x.x.', 0.4);
                if (chance(0.3 + heat * 0.5)) ratchet(b, pick([4, 12, 14]), 'hat.tick', 3, 1, 0.3, 0.5);
                if (chance(heat)) ratchet(b, pick([10, 12, 14]), 'snare.metal', pick([3, 4, 6]), 2, 0.3, 0.8);
                for (let i = 0; i < 1 + Math.floor(heat * 2); i++) hit(b, halfStep(), pick(TEXTURE.slice(0, heat > 0.5 ? 8 : 5)), 1, 0.4 + rng() * 0.3);
            };

            // the bells answering themselves: the same phrase a few steps later, quieter
            const bellEcho = (b, k, v, shift) => BELLS[k % 4].forEach(([st, id, len]) => { if (st + shift < 16) hit(b, st + shift, id, len, v); });

            // 1 ── descent: sinking into the dark. Drone, rumble, bells; the D-flat choir darkens it before the beat.
            section('descent', 8, (b, k) => {
                if (k % 2 === 0) droneOn(b, 'c', 0.9);
                if (k === 0) line(b, [[0,'rumble',16,undefined,{ energy: 0.25 }], [4,'granule',8]]);   // energy: how hard everything moves
                if (k === 1) { bells(b, 0, 0.7); hit(b, 8, 'creak'); }
                if (k === 2) { hit(b, 0, 'choir.cm', 32, 0.8); bells(b, 1, 0.7); hit(b, 12, 'knock'); }
                if (k === 3) { bells(b, 2, 0.7); hit(b, 0, 'static.dark', 4, 0.4); }
                if (k === 4) { hit(b, 0, 'vox.ghost', 16); bells(b, 3, 0.7); hit(b, 8, 'granule', 8); }
                if (k === 5) { bells(b, 0, 0.65, 0.3); hit(b, 4, 'pipe', 1, 0.6); hit(b, 12, 'creak', 1, 0.6); }
                if (k === 6) { hit(b, 0, 'choir.d♭', 32, 0.75); bells(b, 1, 0.65, 0.3); cycle(b, 'click.dark', 5, 0, 0.3); }
                if (k === 7) {
                    line(b, [[0,'pipe'], [4,'reverse.swell',12]]);
                    grid(b, 'hat.sand', '..x...x...x...x.', 0.4);
                    ratchet(b, 13, 'click.dark', 4, 3, 0.3, 0.6);
                }
            });

            // 2 ── trudge: the beat, heavy and slow
            section('trudge', 12, (b, k) => {
                const ch = CH[k % 4];
                if (k % 2 === 0) droneOn(b, ch, 0.7);
                riff(b, ch, k);
                trudge(b, 0.25 + 0.02 * k);
                if (k >= 2) bells(b, k, 0.8, k >= 8 ? 0.15 : 0);
                if (k === 0) hit(b, 0, 'anvil', 1, undefined, { energy: 0.55 });   // strong enough to crack its neighbours
                if (k === 1) hit(b, 7, 'kick.hollow', 1, 0.7);
                if (k >= 4) hit(b, 8, 'clap.rust', 1, 0.6);
                if (k % 4 === 3) hit(b, 14, 'hat.open');
                cycle(b, 'hat.sand', 3, 1, 0.25);
                if (k === 6) hit(b, 0, 'vox.ghost', 16, 0.7);
                if (k === 8) hit(b, 0, 'anvil', 1, 0.8);
                if (k === 10) hit(b, 0, 'choir.cm', 32, 0.6);
                if (k === 11) { hit(b, 10, 'reverse.hit', 4); ratchet(b, 12, 'snare.ghost', 4, 4, 0.3, 0.7); }
            });

            // 3 ── sink: the snare drops out. Only the sludge and the kick grind on, sinking from C to D-flat;
            //      the grid stretches down into a tunnel
            section('sink', 8, (b, k) => {
                const ch = k < 4 ? 'c' : 'd♭';
                if (k === 0) line(b, [[0,'drone.c',32,0.8,{ form: { tunnel: 0.8 }, over: 6, energy: 0.4 }], [0,'tape.drag',12,0.8]]);
                if (k === 4) hit(b, 0, 'drone.d♭', 32, 0.8);
                riff(b, ch, k, 0.9);
                grid(b, 'kick.dirt', k % 2 ? 'x.......x.......' : 'x.........x.....', 0.8);
                if (k >= 2) hit(b, 8, 'kick.gate', 1, 0.5);
                cycle(b, 'hat.tick', 5, k, 0.25);
                if (k % 2 === 1) hit(b, 12, 'creak', 1, 0.5);
                if (k === 2) hit(b, 0, 'vox.ghost', 16, 0.75);
                if (k === 3 || k === 6) bells(b, k, 0.55, 0.5);
                if (k === 5) hit(b, 4, 'granule', 8);
                if (k === 6) hit(b, 0, 'choir.d♭', 32, 0.7);
                if (k === 7) line(b, [[4,'reverse.swell',12], [12,'reverse.hit',4,0.8,{ form: { tunnel: 0 }, over: 0.5 }]]);
            });

            // 4 ── fracture: the glitches break through
            section('fracture', 8, (b, k) => {
                const ch = k % 2 ? 'c' : 'd♭';
                if (k % 2 === 0) droneOn(b, 'd♭', 0.7);
                riff(b, ch, k);
                trudge(b, 0.7);
                bells(b, k, 0.7, 0.35);                                       // the melody, pieces missing
                if (k % 3 === 0) hit(b, 0, 'bit.drone', 8);
                if (k % 2 === 1) { hit(b, 6, 'dark.stab', 1, undefined, { pulse: { fault: k % 4 === 1 ? 0.8 : -0.8 }, attack: 0.04, hold: 0.1, release: 0.35 }); hit(b, 10.5, 'dark.stab', 1, 0.6); }
                if (k === 0) hit(b, 3, 'bleep.low', 1, undefined, { energy: 0.75, over: 1, sweep: { angle: -0.55 } });
                if (k === 1) hit(b, 15, 'crush.low');
                if (k === 2) hit(b, 8, 'stutter.slow', 4);
                if (k === 3) hit(b, 0, 'granule', 8);
                if (k === 4) hit(b, 7, 'crush.low');
                if (k === 5) hit(b, 4, 'scrape', 6);
                if (k === 6) { hit(b, 2, 'static.dark', 4, 0.6); hit(b, 12, 'stutter.slow', 4, 0.8); }
                if (k === 7) { hit(b, 4, 'crush.low'); ratchet(b, 12, 'snare.metal', 6, 4, 0.3, 0.9); }
            });

            // 5 ── void: the floor gives way
            section('void', 4, (b, k) => {
                if (k === 0) line(b, [[0,'drop.void',16,undefined,{ energy: 0.5, over: 2 }], [0,'tape.drag',12], [0,'choir.d♭',32]]);
                if (k > 0) hit(b, 0, 'kick.gate');
                if (k === 1) line(b, [[8,'pipe'], [4,'vox.ghost',16], [12,'static.dark',4,0.5]]);
                if (k === 2) line(b, [[0,'choir.cm',32], [8,'granule',8], [10,'kick.gate',1,0.6]]);
                if (k === 3) {
                    line(b, [[2,'reverse.swell',14], [12,'reverse.hit',4]]);
                    ratchet(b, 14, 'snare.metal', 6, 2, 0.3, 0.9);
                }
            });

            // 6 ── undercurrent: currents in the dark. Pipe, knock, anvil and creak cycle in sevens, threes, elevens
            //      and fives against each other; the bells answer themselves; the beat slowly comes back. The grid rolls.
            section('undercurrent', 16, (b, k) => {
                if (k === 0) line(b, [[0,'rumble',16,0.7,{ form: { wave: 0.55 }, over: 4, energy: 0.35 }], [0,'drone.c',32,0.7]]);
                if (k % 4 === 0 && k > 0) droneOn(b, k % 8 === 4 ? 'd♭' : 'c', 0.7);
                cycle(b, 'pipe', 7, 0, 0.45);
                cycle(b, 'knock', 3, 1, 0.4);
                if (k >= 2) cycle(b, 'anvil', 11, 4, 0.35);
                if (k >= 4) cycle(b, 'creak', 5, 2, 0.4);
                bells(b, k, 0.6, 0.4);                                        // the call
                if (k >= 2) bellEcho(b, k, 0.4, 3);                           // the answer
                if (k >= 6) hit(b, 0, 'kick.gate', 1, 0.6);
                if (k >= 10) hit(b, 8, 'snare.ash', 1, 0.5);
                if (k >= 8) riff(b, CH[k % 4], k, 0.7);
                if (k >= 12) { hats(b, 'x.x.x.x.x.x.x.x.', 0.3); hit(b, 8, 'clap.rust', 1, 0.5); }
                if (k === 3) hit(b, 0, 'vox.ghost', 16, 0.7);
                if (k === 5) hit(b, 4, 'granule', 8);
                if (k === 7) hit(b, 0, 'choir.cm', 32, 0.6);
                if (k === 9) hit(b, 0, 'bit.drone', 8, 0.6);
                if (k === 12) hit(b, 0, 'tape.drag', 12, 0.6, { form: { wave: 0 }, over: 3, energy: 0.6 });
                if (k === 15) { line(b, [[0,'scrape',6], [8,'reverse.swell',8], [12,'reverse.hit',4]]); ratchet(b, 12, 'snare.metal', 6, 4, 0.3, 0.9); }
            });

            // 7 ── trudge.again: the heaviest pass so far
            section('trudge.again', 8, (b, k) => {
                const ch = CH[k % 4];
                if (k % 2 === 0) droneOn(b, ch, 0.75);
                riff(b, ch, k);
                trudge(b, 0.6 + 0.05 * k);
                hit(b, 8, 'clap.rust', 1, 0.7, { pulse: { press: 0.35 }, attack: 0.03, release: 0.3 });   // the press comes down
                bells(b, k, 0.85);
                grid(b, 'hat.chain', 'x.xxx.xxx.xxx.xx', 0.3);
                hit(b, 14, 'hat.open', 1, 0.4);
                if (k === 0) line(b, [[0,'rumble',16], [0,'anvil',1,undefined,{ energy: 0.85, sweep: { angle: Math.PI - 0.35, dur: 1.5 } }]]);
                if (k === 3) hit(b, 0, 'anvil', 1, 0.8);
                if (k % 2 === 0) hit(b, 12, 'dark.stab', 1, 0.7);
                if (k === 4) hit(b, 0, 'bit.drone', 8);
                if (k === 6) hit(b, 8, 'stutter.slow', 4);
                if (k === 7) { hit(b, 4, 'scrape', 6); ratchet(b, 12, 'snare.metal', 6, 4, 0.3, 0.9); }
            });

            // 8 ── torque: the climax, the whole machine under strain. Dark stabs every bar, extra kicks. The grid is wrung like a cloth,
            //      its columns pump like pistons, halfway through it is wrenched the other way, then presses and faults hit it
            section('torque', 16, (b, k) => {
                const ch = k % 8 < 4 ? CH[k % 4] : (k % 2 ? 'c' : 'd♭');
                if (k === 0) line(b, [[0,'drop.void',16,0.7,{ form: { twist: 0.8 }, over: 2, energy: 0.95, sweep: { angle: Math.PI / 2 + 0.25, dur: 1.6 } }], [0,'anvil']]);
                if (k % 2 === 0) droneOn(b, ch, 0.75);
                riff(b, ch, k);
                trudge(b, 0.85);
                grid(b, 'kick.hollow', '......x.......x.', 0.7);
                hit(b, 8, 'clap.rust', 1, 0.9, k >= 10 ? { pulse: { press: 0.3 }, attack: 0.03, release: 0.3 } : undefined);
                if (k === 2) hit(b, 0, 'pipe', 1, 0.6, { form: { pistons: 0.75 }, over: 1 });
                grid(b, 'hat.chain', 'x.xxx.xxx.xxx.xx', 0.32);
                bells(b, k, 0.85);
                if (k % 2 === 0) hit(b, 6, 'dark.stab', 1, 0.9, k >= 12 ? { pulse: { fault: k % 4 === 0 ? 0.9 : -0.9 }, attack: 0.04, hold: 0.1, release: 0.35 } : undefined); else hit(b, 10.5, 'dark.stab', 1, 0.75);
                if (k % 4 === 1) hit(b, 0, 'bit.drone', 8, 0.6);
                if (k % 4 === 3) hit(b, 12, 'crush.low');
                if (k === 4 || k === 12) hit(b, 4, 'scrape', 6);
                if (k === 6 || k === 14) hit(b, 8, 'stutter.slow', 4);
                if (k === 8) line(b, [[0,'anvil',1,undefined,{ form: { twist: -0.8 }, over: 2, sweep: { angle: -Math.PI / 2 - 0.4, dur: 1.6 } }], [0,'rumble',16,0.7]]);   // wrenched the other way
                if (k === 10) hit(b, 0, 'vox.ghost', 16, 0.6);
                if (k === 13) hit(b, 0, 'choir.d♭', 32, 0.6);
                if (k === 15) { hit(b, 0, 'tape.drag', 12, 0.9, { form: { twist: 0, pistons: 0 }, over: 1 }); ratchet(b, 8, 'snare.metal', 8, 8, 0.3, 1); }
            });

            // 9 ── abyss: the floor gives way again, further down; the grid is blown apart and slowly falls back together
            section('abyss', 4, (b, k) => {
                if (k === 0) line(b, [[0,'drop.void',16,1,{ form: { cloud: 0.8 }, over: 0, energy: 0.45, cam: { zoom: -0.25, pitch: 0.3 }, sweep: { angle: 0.3, dur: 2, strength: 0.7 } }], [0,'choir.cm',32], [0,'rumble',16]]);
                if (k === 1) line(b, [[0,'kick.gate',1,0.8,{ form: { cloud: 0 }, over: 3 }], [4,'vox.ghost',16], [12,'static.dark',4,0.5]]);
                if (k === 2) line(b, [[0,'granule',8], [8,'pipe'], [10,'kick.gate',1,0.6]]);
                if (k === 3) line(b, [[2,'reverse.swell',14], [12,'reverse.hit',4]]);
            });

            // 10 ── sediment: everything settles to the bottom, layer by layer, ending on C; the grid itself sinks into strata
            section('sediment', 12, (b, k) => {
                if (k % 2 === 0) droneOn(b, k >= 4 && k < 8 ? 'd♭' : 'c', 0.8 - k * 0.03);
                bells(b, k, 0.7 - k * 0.03, k >= 6 ? 0.3 + (k - 6) * 0.1 : 0);
                if (k < 6) {
                    hit(b, 0, 'kick.dirt', 1, 0.6 - k * 0.05);
                    hit(b, 8, 'snare.ash', 1, 0.5 - k * 0.05);
                    cycle(b, 'hat.chain', 5, 0, 0.3);
                }
                if (k < 4) riff(b, 'c', k, 0.6);
                if (k === 0) hit(b, 0, 'anvil', 1, 0.6, { energy: 0.3, over: 2 });
                if (k === 1) hit(b, 4, 'creak', 1, 0.5, { form: { settle: 0.85 }, over: 10 });
                if (k === 2) line(b, [[0,'choir.cm',32,0.7], [4,'granule',8]]);
                if (k === 5) hit(b, 0, 'vox.ghost', 16, 0.5);
                if (k === 7) line(b, [[0,'choir.d♭',32,0.55], [8,'creak',1,0.4]]);
                if (k === 9) line(b, [[0,'rumble',16,0.6], [8,'tape.drag',8,0.8], [8,'static.dark',4,0.3]]);
                if (k === 11) line(b, [[0,'pipe',1,0.5], [8,'knock',1,0.4]]);
            });
        },
    },
    {
        title: 'Symbol Hg', meta: 'octatonic on c, 104 bpm, generative, gritty sub', bpm: 104, swing: 0, tail: 4, tight: true,   // tight: the grid holds its shape; tiles flip in place
        room: { seconds: 2.4, shape: 3, wet: 0.15, tone: 0.4 },
        kit: KIT_RULE,
        // A system boots up out of the depths, runs its rules, overloads and recedes again.
        // Rhythms come from Euclidean patterns and cellular automata; stage cues ride on notes.
        // The grid never breaks formation: tiles flip like cards, and the automaton paints its generations on them.
        write({ hit, line, section, seeded }) {
            const { rng } = seeded(90);

            // Euclidean rhythm: k hits spread as evenly as possible over n steps
            const pattern = (k, n) => Array.from({ length: n }, (_, i) => Math.floor((i + 1) * k / n) - Math.floor(i * k / n) === 1);
            // lists of tiles are played in turn, each list keeping its own place
            const turns = new Map();
            const next = list => { const i = (turns.get(list) ?? -1) + 1; turns.set(list, i); return list[i % list.length]; };
            // a pattern of length n laid across the bar, counted from the song's start, so it drifts against the 16-step bar;
            // `lean` nudges the off-beats early or late
            const euclid = (b, ids, k, n, rot, v, lean = 0) => {
                const pat = pattern(k, n), list = Array.isArray(ids) ? ids : [ids];
                for (let st = 0; st < 16; st++) {
                    if (pat[((b * 16 + st + rot) % n + n) % n]) hit(b, st + (st % 2 ? lean : 0), next(list), 1, v);
                }
            };
            // a hit every `period` steps counted across bars: threes, fours and fives drift against each other
            const cycle = (b, id, period, offset, v) => {
                for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, id, 1, v);
            };
            // n hits spread across `span` steps, swelling from v0 to v1
            const roll = (b, step, id, n, span, v0, v1) => {
                for (let i = 0; i < n; i++) hit(b, step + span * i / n, id, 1, v0 + (v1 - v0) * (n > 1 ? i / (n - 1) : 1));
            };

            // a row of 16 cells; each bar is the next generation of a cellular automaton.
            // rule 90 grows fractal, self-similar patterns; rule 30 grows chaos
            let cells = Array(16).fill(0);
            cells[8] = 1;
            const evolve = r => {
                cells = cells.map((_, i) => (r >> ((cells[(i + 15) % 16] << 2) | (cells[i] << 1) | cells[(i + 1) % 16])) & 1);
                if (!cells.some(Boolean)) {                                   // when the row dies out, a few new cells are seeded
                    cells = cells.map(() => (rng() < 0.2 ? 1 : 0));
                    cells[Math.floor(rng() * 16)] = 1;
                }
            };
            const cellHits = (b, list, v, lean = 0) => cells.forEach((on, st) => {
                if (on) hit(b, st + (st % 2 ? lean : 0), list[(Math.floor(st / 2) + b) % list.length], 1, v * (0.6 + 0.4 * rng()));
            });
            const leanAt = (b, depth) => depth * Math.sin(b * 0.7);           // micro-timing that sways ahead of and behind the grid
            // paint the current generation onto one row of the grid: live cells flip to their ink side
            const paint = (b, row, gap = 0.5, axis = 'x') => hit(b, 0, 'click.sample', 1, 0.25, { flip: { pattern: 'row', row, cells: [...cells], gap, dur: 2, axis } });

            // the pings walk the inner ring in jumps of `stride`, one every `period` steps
            const PINGS = ['ping.c4', 'ping.d♭4', 'ping.e♭4', 'ping.e4', 'ping.f♯4', 'ping.g4', 'ping.a4', 'ping.b♭4'];
            let pingTurn = 0;
            const pings = (b, period, stride, v, offset = 0) => {
                for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, PINGS[(pingTurn++ * stride) % 8], 2, v);
            };
            const SUBS = ['sub.c1', 'sub.e♭1', 'sub.f♯1', 'sub.a1'];          // minor thirds: the scale's own symmetry
            const HATS_A = ['hat.crush1', 'hat.crush2', 'hat.dirt'];
            const HATS_B = ['hat.crush1', 'hat.crush2', 'hat.dirt', 'hat.ring'];
            const TICKS = ['click.sample', 'tick.crush'];
            const METAL_A = ['clang.2', 'wood.fm', 'rim.fm', 'tine.crush'];
            const METAL_B = ['clang.2', 'clang.3', 'wood.fm', 'rim.fm', 'tine.crush', 'grain.hit'];

            // 1 ── init: the system boots. The grid comes into focus out of a deep tunnel.
            section('init', 8, (b, k) => {
                if (k === 0) line(b, [[0, 'dirt.pad.1', 24, 0.8, { form: { tunnel: 1 }, over: 0, energy: 0.15, flip: { pattern: 'all', mode: 'back', dur: 0.2 } }], [0, 'sub.drone', 24, 0.8]]);
                if (k === 1) hit(b, 0, 'tick.crush', 1, 0.4, { form: { tunnel: 0 }, over: 7, flip: { pattern: 'random', spread: 96, dur: 3, mode: 'front' } });
                if (k === 4) hit(b, 0, 'dirt.pad.2', 24, 0.7);
                pings(b, 13, 3, 0.5);
                euclid(b, TICKS, 3, 11, k, 0.4);
                if (k === 2) hit(b, 4, 'glass.crush', 16);
                if (k === 3) hit(b, 0, 'sub.phase', 16, 0.8);
                if (k === 5) hit(b, 8, 'grit.cloud', 8);
                if (k >= 6) hit(b, 0, 'kick.sub', 1, 0.7);
                if (k === 7) line(b, [[0, 'reso.grit', 8], [12, 'reverse.fm', 4]]);
            });

            // 2 ── lattice: rule 90 drives the metal; the sub line enters, then the snares. A ripple runs through the grid.
            section('lattice', 16, (b, k) => {
                const lean = leanAt(b, 0.2);
                evolve(90);
                if (k >= 2 && k < 15) paint(b, (k - 2) % 7);
                if (k % 4 === 0) hit(b, 0, 'dirt.pad.1', 24, 0.55);
                euclid(b, 'kick.sub', 3, 8, 0, 0.95, lean);
                if (k >= 8) euclid(b, 'snare.grit', 2, 7, 3, 0.8, lean);
                euclid(b, HATS_A, 7, 12, k, 0.45, lean);
                cellHits(b, METAL_A, 0.5, lean);
                if (k >= 4) euclid(b, SUBS, 3, 16, 2, 0.85, lean);
                pings(b, 3, 3, 0.55);
                if (k === 0) hit(b, 0, 'clang.rust', 1, 0.9, { energy: 0.4, over: 2 });
                if (k === 6) hit(b, 0, 'sub.phase', 16, 0.7);
                if (k === 8) hit(b, 0, 'snare.gate', 1, 0.7, { energy: 0.5, over: 2, flip: { pattern: 'diag', gap: 0.5, dur: 3, mode: 'spin', axis: 'y' } });
                if (k === 12) hit(b, 4, 'grit.cloud', 8);
                if (k === 15) hit(b, 0, 'reverse.fm', 4, 0.8, { flip: { pattern: 'rings', reverse: true, gap: 2, dur: 3, mode: 'front' } });
                if (k % 2 === 1) hit(b, 15, 'hat.ring', 1, 0.5);
            });

            // 3 ── pressure: half-time and sub-heavy. Every sub drop forces the grid's doors open; the sub line closes them.
            section('pressure', 12, (b, k) => {
                const lean = leanAt(b, 0.15);
                evolve(90);
                if (k === 0) hit(b, 0, 'dirt.pad.2', 24, 0.55, { energy: 0.6, over: 1 });
                if (k === 6) hit(b, 0, 'dirt.pad.1', 24, 0.55);
                hit(b, 0, 'kick.sub', 1, 1);
                hit(b, 10, 'kick.sub', 1, 0.7);
                hit(b, 8, k % 2 ? 'snare.crush' : 'snare.grit', 1, 0.85);
                if (k % 2 === 0) hit(b, 0, 'sub.drop', 8, 0.85, { flip: { pattern: 'rings', gap: 1, dur: 3, mode: 'spin', axis: 'x' } });
                else [[0, 0], [6, 1], [12, 2]].forEach(([st, i]) => hit(b, st, SUBS[((k >> 1) + i) % 4], 4, 0.8, st === 0 ? { flip: { pattern: 'checker', odd: k % 4 === 1, dur: 2, mode: 'toggle', axis: 'y' } } : undefined));
                euclid(b, HATS_A, 5, 16, k * 3, 0.4, lean);
                cellHits(b, ['rim.fm', 'wood.fm', 'tick.crush'], 0.4, lean);
                pings(b, 7, 5, 0.45);
                if (k === 3 || k === 9) hit(b, 0, 'formant.grit', 12, 0.8);
                if (k === 5) hit(b, 8, 'buzz', 2);
                if (k === 11) line(b, [[0, 'crunch'], [8, 'data.burst', 4], [12, 'reverse.fm', 4, undefined, { flip: { pattern: 'sweepX', gap: 0.5, dur: 2, mode: 'front' } }]]);
            });

            // 4 ── mutation: rule 30 turns everything chaotic and dense. The grid scatters, then goes into orbit.
            section('mutation', 16, (b, k) => {
                const lean = leanAt(b, 0.35);
                evolve(30);
                paint(b, (k * 3) % 7, 0.25, k % 2 ? 'y' : 'x');
                euclid(b, k % 2 ? 'kick.dist' : 'kick.sub', 3 + ((k % 8) >> 1), 8 + (k % 3), 0, 0.72, lean);
                euclid(b, ['snare.grit', 'snare.crush'], 2 + (k % 3), 7, k, 0.7, lean);
                euclid(b, HATS_B, 5 + (k % 8), 13, 2 * k, 0.45, lean);
                cellHits(b, METAL_B, 0.45, lean);
                euclid(b, SUBS, 4 + (k % 3), 16, k, 0.55, lean);
                pings(b, 5, 3, 0.55, 1);
                if (k % 4 === 0) hit(b, 0, 'dirt.pad.2', 24, 0.5);
                if (k === 0) hit(b, 0, 'data.burst', 4, 0.9, { energy: 0.8, flip: { pattern: 'random', spread: 4, dur: 1.5, mode: 'toggle' } });
                if (k === 1) hit(b, 0, 'kick.dist', 1, 0.9);
                if (k === 2 || k === 10) hit(b, 8, 'grit.cloud', 8);
                if (k === 3 || k === 11) hit(b, 4, 'reso.grit', 8);
                if (k === 4 || k === 12) hit(b, 0, 'formant.grit', 12);
                if (k === 5 || k === 13) hit(b, 0, 'crunch');
                if (k === 6 || k === 14) hit(b, 10, 'buzz', 2);
                if (k === 8) hit(b, 0, 'sub.drop', 8, 0.9, { flip: { pattern: 'rings', gap: 1.5, dur: 4, mode: 'spin', axis: 'x' } });
                if (k === 15) line(b, [[0, 'data.burst', 4], [12, 'reverse.fm', 4, 0.9, { flip: { pattern: 'sweepX', gap: 0.5, dur: 2, mode: 'front' } }], [15, 'crunch']]);
            });

            // 5 ── degrade: the layers fall away one at a time; the grid coils into a slow spiral
            section('degrade', 8, (b, k) => {
                const lean = leanAt(b, 0.4);
                evolve(30);
                if (k === 0) hit(b, 0, 'bit.choir', 24, 0.7, { energy: 0.3, over: 2, flip: { pattern: 'random', spread: 104, dur: 6, mode: 'back', axis: 'x' } });
                if (k < 5) euclid(b, 'kick.crush', 3, 8, k, 0.85, lean);
                if (k < 4) euclid(b, 'snare.gate', 2, 9, 1, 0.75, lean);
                if (k < 7) cellHits(b, ['tick.crush', 'click.sample', 'grain.hit'], 0.45 - k * 0.04, lean);
                if (k < 3) euclid(b, SUBS, 3, 16, 0, 0.75, lean);
                pings(b, 7 - (k % 3), 5, 0.5);
                if (k === 2) hit(b, 4, 'formant.grit', 12, 0.7);
                if (k === 3) for (let i = 0; i < 5; i++) hit(b, 8 + i / 3, 'tick.crush', 1, 0.3 + i * 0.1);   // a triplet stutter
                if (k === 4) hit(b, 0, 'sub.drop', 8, 0.7);
                if (k === 5) hit(b, 0, 'dirt.pad.2', 24, 0.55);
                if (k === 7) line(b, [[0, 'zap.fm'], [8, 'reverse.fm', 8, 0.8, { flip: { pattern: 'diag', reverse: true, gap: 1, dur: 3, mode: 'front' } }], [12, 'clap.digital']]);
            });

            // 6 ── sub.terrain: underground. The sub throbs, the pings answer each other in canon,
            //      wood, rim and ticks cycle in threes, fives and fours. The grid slowly orbits.
            section('sub.terrain', 16, (b, k) => {
                const lean = leanAt(b, 0.25);
                evolve(90);
                if (k === 0) line(b, [[0, 'sub.phase', 16, 0.85, { energy: 0.45, over: 2, flip: { pattern: 'spiral', gap: 4, dur: 8, mode: 'spin', axis: 'y' } }], [0, 'sub.drone', 24, 0.6]]);
                if (k % 4 === 0 && k > 0) hit(b, 0, 'sub.phase', 16, 0.75, { flip: { pattern: 'spiral', reverse: k % 8 === 4, gap: 4, dur: 8, mode: 'spin', axis: k % 8 === 4 ? 'x' : 'y' } });
                if (k === 8) hit(b, 0, 'sub.drone', 24, 0.6);
                if (k % 8 === 4) hit(b, 0, 'dirt.pad.1', 24, 0.5);
                cycle(b, 'wood.fm', 3, 0, 0.5);
                if (k >= 4) cycle(b, 'rim.fm', 5, 2, 0.45);
                if (k >= 8) cycle(b, 'tick.crush', 4, 1, 0.35);
                pings(b, 6, 3, 0.5);                                          // the call
                if (k >= 2) pings(b, 6, 5, 0.4, 3);                           // the answer: three steps later, a different stride
                if (k >= 6) { hit(b, 0, 'kick.sub', 1, 0.85); hit(b, 10, 'kick.sub', 1, 0.6); }
                if (k >= 10) { hit(b, 8, 'snare.gate', 1, 0.7); euclid(b, HATS_A, 5, 12, k, 0.4, lean); euclid(b, SUBS, 2, 16, 4, 0.75, lean); }
                if (k === 12) hit(b, 0, 'glass.crush', 16, 0.8, { flip: { pattern: 'checker', dur: 6, mode: 'back', axis: 'x' } });
                if (k === 14) hit(b, 4, 'grit.cloud', 8);
            });

            // 7 ── fracture: the build. Crushed snares fill in and the grid stretches into a tunnel.
            section('fracture', 8, (b, k) => {
                const lean = leanAt(b, 0.1);
                evolve(30);
                if (k === 0) hit(b, 0, 'reso.grit', 8, 0.8, { form: { tunnel: 1 }, over: 8, energy: 0.8, flip: { pattern: 'all', mode: 'front', dur: 2 } });
                hit(b, 0, 'kick.dist', 1, 0.9, k > 0 ? { flip: { pattern: k % 2 ? 'sweepX' : 'sweepY', reverse: k % 4 >= 2, gap: 2 - k * 0.2, dur: 2 - k * 0.15, mode: 'spin', axis: k % 2 ? 'y' : 'x' } } : undefined);
                if (k >= 4) hit(b, 8, 'kick.dist', 1, 0.8);
                euclid(b, 'snare.crush', [2, 3, 4, 5, 6, 8, 10, 12][k], 16, 0, 0.4 + 0.07 * k);
                euclid(b, HATS_B, 6 + k, 16, k, 0.4, lean);
                cellHits(b, ['grain.hit', 'tick.crush', 'clang.3'], 0.4, lean);
                if (k % 2 === 0) hit(b, 0, SUBS[(k >> 1) % 4], 8, 0.8);
                pings(b, 4 - (k >> 2), 3, 0.5);
                if (k === 2) hit(b, 0, 'data.burst', 4);
                if (k === 4) hit(b, 0, 'formant.grit', 12);
                if (k === 6) hit(b, 0, 'buzz', 2);
                if (k === 7) { roll(b, 0, 'snare.gate', 16, 12, 0.35, 1); hit(b, 12, 'reverse.fm', 4); }
            });

            // 8 ── re.assembly: everything returns, rotated into new places. The rusted clang blows the grid apart;
            //      it rebuilds in a wave, then a spiral. Rule 90 and rule 30 take turns every four bars.
            section('re.assembly', 24, (b, k) => {
                const lean = leanAt(b, 0.25);
                evolve(Math.floor(k / 4) % 2 ? 30 : 90);
                if (k >= 2 && k !== 8 && k !== 16) paint(b, k % 7, Math.floor(k / 4) % 2 ? 0.25 : 0.5, Math.floor(k / 4) % 2 ? 'y' : 'x');
                if (k === 0) hit(b, 0, 'clang.rust', 1, 1, { form: { tunnel: 0 }, over: 0.5, energy: 0.95, cam: { zoom: -0.3, roll: 0.5 }, flip: { pattern: 'rings', gap: 0.5, dur: 2, mode: 'spin', axis: 'x' } });
                if (k === 1) hit(b, 0, 'sub.drop', 8, 0.85);
                if (k % 4 === 0 && k > 0) hit(b, 0, 'dirt.pad.1', 24, 0.5);
                euclid(b, ['kick.sub', 'kick.dist'], 5, 12, 1, 0.85, lean);
                euclid(b, ['snare.grit', 'snare.gate', 'clap.digital'], 3, 8, 4, 0.8, lean);
                euclid(b, ['hat.crush2', 'hat.dirt', 'hat.crush1'], 9, 16, k, 0.45, lean);
                cellHits(b, ['clang.2', 'clang.3', 'wood.fm', 'rim.fm', 'tine.crush', 'pluck.rust'], 0.5, lean);
                euclid(b, SUBS, 5, 16, 3, 0.62, lean);
                pings(b, 3, 5, 0.6);
                if (k === 6) hit(b, 0, 'pluck.rust', 1, 0.8);
                if (k === 8) hit(b, 0, 'sub.phase', 16, 0.7, { flip: { pattern: 'diag', gap: 0.5, dur: 2, mode: 'spin', axis: 'y' } });
                if (k === 12) hit(b, 0, 'data.burst', 4, 0.8, { flip: { pattern: 'random', spread: 6, dur: 1.5, mode: 'toggle' } });
                if (k === 16) hit(b, 0, 'clang.rust', 1, 0.9, { cam: { shake: 10 }, flip: { pattern: 'spiral', gap: 1, dur: 2, mode: 'spin', axis: 'x' } });
                if (k === 22) hit(b, 0, 'formant.grit', 12, 0.8, { flip: { pattern: 'sweepY', gap: 1, dur: 3, mode: 'front' } });
                if (k === 10 || k === 18) line(b, [[0, 'crunch'], [8, 'buzz', 2]]);
                if (k === 14) hit(b, 0, 'grit.cloud', 8);
                if (k === 20) hit(b, 4, 'reso.grit', 8);
                if (k === 23) { for (let i = 0; i < 8; i++) hit(b, 8 + i * 0.5, 'snare.gate', 1, 0.3 + i * 0.09); hit(b, 12, 'reverse.fm', 4); }
            });

            // 9 ── collapse: the system overloads. Dense and crushed, the grid spinning in rings;
            //      then everything drops away but the sub.
            section('collapse', 12, (b, k) => {
                const lean = leanAt(b, 0.35);
                evolve(30);
                if (k === 0) hit(b, 0, 'crunch', 1, 1, { energy: 1, cam: { shake: 12 }, flip: { pattern: 'checker', dur: 1, mode: 'toggle', axis: 'y' } });
                if (k < 8) {
                    euclid(b, ['kick.dist', 'kick.sub'], 6, 12, k, 0.92, lean);
                    euclid(b, ['snare.crush', 'clap.digital', 'snare.grit'], 4, 9, k, 0.85, lean);
                    euclid(b, HATS_B, 11, 16, k, 0.45, lean);
                    cellHits(b, ['grain.hit', 'clang.2', 'clang.3', 'tick.crush', 'buzz'], 0.55, lean);
                    euclid(b, SUBS, 6, 16, k, 0.72, lean);
                    pings(b, 2, 3, 0.5);
                    if (k % 2 === 1) hit(b, 0, 'data.burst', 4, 0.7);
                    if (k % 4 === 2) hit(b, 0, 'sub.drop', 8, 0.8);
                    if (k !== 4) hit(b, 8, 'click.sample', 1, 0.25, { flip: { pattern: 'random', spread: 8, dur: 1, mode: 'toggle', axis: k % 2 ? 'x' : 'y' } });
                }
                if (k === 4) hit(b, 0, 'clang.rust', 1, 0.9, { flip: { pattern: 'all', dur: 1.5, mode: 'spin', axis: 'x' } });
                if (k === 5) hit(b, 0, 'kick.dist', 1, 0.9);
                if (k === 7) roll(b, 0, 'snare.crush', 24, 16, 0.4, 1);
                if (k === 8) line(b, [[0, 'sub.drop', 8, 1, { energy: 0.25, over: 2, cam: { zoom: -0.25 }, flip: { pattern: 'rings', reverse: true, gap: 3, dur: 4, mode: 'back' } }], [0, 'bit.choir', 24, 0.6]]);
                if (k >= 9) { hit(b, 0, SUBS[k % 4], 8, 0.75); pings(b, 8, 3, 0.4); }
                if (k === 10) hit(b, 0, 'sub.phase', 16, 0.7);
            });

            // 10 ── halt: the system winds down to a last click, receding into the depths it came from
            section('halt', 10, (b, k) => {
                pings(b, 4 + (k >> 1), 3, 0.55 - k * 0.04);
                if (k === 0) line(b, [[0, 'dirt.pad.1', 24, 0.7, { form: { tunnel: 1 }, over: 9, energy: 0.1, flip: { pattern: 'random', spread: 64, dur: 4, mode: 'front', axis: 'y' } }], [0, 'sub.drone', 24, 0.6]]);
                if (k === 4) line(b, [[0, 'dirt.pad.2', 24, 0.55], [0, 'sub.phase', 16, 0.5]]);
                if (k < 4) euclid(b, TICKS, 3, 11, k, 0.35);
                if (k < 3) hit(b, 0, 'kick.sub', 1, 0.6 - k * 0.12);
                if (k === 2) hit(b, 0, 'glass.crush', 16, 0.7);
                if (k === 6) hit(b, 4, 'formant.grit', 12, 0.4, { flip: { pattern: 'random', spread: 48, dur: 4, mode: 'back', axis: 'x' } });
                if (k === 9) line(b, [[0, 'pluck.rust'], [8, 'click.sample', 1, 0.6]]);
            });
        },
    },
    {
        title: 'Planet Work', meta: 'whole-tone on e, 170 bpm, digital drum & bass', bpm: 170, swing: 0, tail: 3,
        room: { seconds: 1.2, shape: 4, wet: 0.12, tone: 0.8 },
        kit: KIT_PLANET,
        // A machine powers up, goes into orbit, works the planet's surface, rides out a storm and shuts down.
        // Stage cues ride on notes: formations reshape the grid, energy sets how hard everything moves.
        write({ hit, grid, line, section, ratchet, seeded, euclid, automaton }) {
            const { rng, pick, chance } = seeded(8);
            const ca = automaton(rng);

            // the chords: E, G-sharp, C, D, two bars each. `turn` starts the cycle elsewhere (1 = on G-sharp) for a new key centre
            const ROOTS = ['e', 'e', 'g♯', 'g♯', 'c', 'c', 'd', 'd'];
            const root = (k, turn = 0) => ROOTS[(k + 2 * turn) % 8];
            const ARP = { 'e': 'arp.e+', 'g♯': 'arp.e+.down', 'c': 'arp.f♯+.down', 'd': 'arp.f♯+' };

            // a step-sequenced lead: a motif clocked through a gate pattern, moved with the chord and rotated every four bars
            const CHIP = ['chip.e4', 'chip.f♯4', 'chip.g♯4', 'chip.a♯4', 'chip.c5', 'chip.d5', 'chip.e5', 'chip.f♯5'];
            const SHIFT = { 'e': 0, 'g♯': 2, 'c': 4, 'd': 5 };
            const MOTIF   = [0, 2, 4, 2, 6, 4, 2, 1];      // the original
            const MOTIF_B = [4, 6, 7, 6, 4, 2, 0, 2];      // the surface: higher, arching down
            const MOTIF_C = [0, 4, 1, 5, 2, 6, 3, 7];      // the storm: leaping fourths that climb
            const MOTIF_D = [7, 6, 4, 2, 0, 2, 4, 6];      // falling and rising back: for the quiet passages
            const GATES = ['x.xx.xx.x.xx.xx.', 'x.x.xx.xx.x.xx.x', 'xx.x.xx.x.xx.x.x'];
            let motifStep = 0;
            const lead = (b, k, v, gate, motif = MOTIF, turn = 0) => {
                const shift = SHIFT[root(k, turn)], rot = Math.floor(k / 4);
                [...gate].forEach((c, st) => {
                    if (c !== 'x') return;
                    hit(b, st, CHIP[(motif[(motifStep + rot) % 8] + shift) % 8], 1, v * (st % 4 === 0 ? 1 : 0.75));
                    motifStep++;
                });
            };

            // the bass as a sixteenth-note sequencer line; digits pick root (0), third (1) or passing note (2)
            const BASSNOTES = {
                'e':  ['tri.e2', 'tri.g♯2', 'tri.f♯2'],
                'g♯': ['tri.g♯2', 'tri.c3', 'tri.a♯2'],
                'c':  ['tri.c3', 'tri.e2', 'tri.d3'],
                'd':  ['tri.d3', 'tri.f♯2', 'tri.c3'],
            };
            const BASSSEQ = ['0.00.0.10.00.2.0', '0.0.00.10.0.20.0'];
            const bass = (b, k, v = 1, turn = 0) => {
                const notes = BASSNOTES[root(k, turn)];
                [...BASSSEQ[k % 2]].forEach((c, st) => { if (c !== '.') hit(b, st, notes[+c], 1, v * (st % 4 === 0 ? 1 : 0.7)); });
            };
            const arps = (b, k, pattern, v, turn = 0) => grid(b, ARP[root(k, turn)], pattern, v);

            const BLIPS = ['blip.1', 'blip.2', 'chirp', 'drop.down', 'cowbell.d', 'beep.coin'];

            // tight, quantized two-step; the automaton fires game-machine blips around it
            const frame = (b, k, heat) => {
                grid(b, 'kick.chip', 'x.........x.....');
                grid(b, 'snare.noise8', '....x.......x...');
                if (heat > 0.6) grid(b, 'clap.8bit', '............x...', 0.6);
                if (chance(0.25 + 0.3 * heat)) euclid(b, 'kick.click', 2, 16, k * 3 + 5, 0.7);
                euclid(b, 'snare.ghost.d', 3 + Math.round(heat * 3), 16, k, 0.5);
                grid(b, 'hat.lfsr', 'xoxoxoxoxoxoxoxo', 0.45);
                if (heat > 0.5) euclid(b, 'hat.tick.d', 5, 12, k, 0.35);
                if (k % 2 === 1) hit(b, 14, 'hat.open.d', 1, 0.5);
                if (chance(0.3 + 0.3 * heat)) hit(b, pick([3, 7, 11, 15]), 'hat.ratchet', 1, 0.5);
                ca.evolve(110);
                ca.hits(b, BLIPS, 0.4 * (0.6 + heat), heat > 0.55 ? 1 : 2);
                if (k % 2 === 1) ratchet(b, 14, 'snare.noise8', 4, 2, 0.35, 0.85);   // a machine fill every other bar
                if (k % 4 === 3) [12, 13, 14, 15].forEach((st, i) => hit(b, st, i < 2 ? 'tom.chip.hi' : 'tom.chip.lo', 1, 0.6 + i * 0.1));
            };
            // half-time: kick on one, snare on three; the snare can carry a cue
            const halfFrame = (b, k, heat, snareFx) => {
                grid(b, 'kick.808d', 'x.........x.....', 0.75);
                hit(b, 8, 'snare.chip', 1, 0.75, snareFx);
                if (k % 2 === 1) hit(b, 14, 'snare.ghost.d', 1, 0.5);
                grid(b, 'hat.lfsr', 'x.x.x.x.x.x.x.x.', 0.4);
                euclid(b, 'hat.tick.d', 3 + Math.round(heat * 4), 16, k * 5, 0.35);
                if (k % 4 === 3) hit(b, 12, 'hat.ratchet', 1, 0.5);
            };
            // an explosion: the grid is blown apart, then falls back together over the next bar and a half
            const blast = (b, fx = {}) => {
                line(b, [[0, 'power.down', 8, 1, { form: { cloud: 1, ...(fx.form || {}) }, over: 0, energy: fx.energy, cam: fx.cam || { zoom: -0.3, roll: 0.5 } }], [0, 'sub.e1', 8]]);
                hit(b + 1, 0, 'kick.808d', 1, 0.8, { form: { cloud: 0 }, over: 1.5 });
            };

            // 1 ── boot.sequence: the machine starts up. The grid begins as a single flat line and slowly unfolds.
            section('boot.sequence', 16, (b, k) => {
                if (k === 0) line(b, [[0, 'pwm.pad', 24, 0.8, { form: { line: 1 }, over: 0, energy: 0.15 }], [0, 'data.stream', 8]]);
                if (k === 2) hit(b, 0, 'sample.hold', 8, 0.8, { form: { line: 0 }, over: 13 });
                if (k === 4 || k === 8 || k === 12) hit(b, 0, 'pwm.pad', 24, 0.8);
                if (k >= 4) lead(b, k, 0.4 + 0.01 * k, k < 8 ? '..x...x...x...x.' : 'x.x...x.x.x...x.');
                if (k === 6 || k === 10) hit(b, 8, 'sample.hold', 8);
                if (k === 5) hit(b, 4, 'modem', 4);
                if (k === 9) hit(b, 0, 'data.stream', 8);
                if (k >= 8) {
                    grid(b, 'kick.808d', 'x...............', 0.7);
                    grid(b, 'snare.chip', '........x.......', 0.6);
                    grid(b, 'hat.lfsr', '..x...x...x...x.', 0.35);
                }
                if (k === 8) hit(b, 0, 'sub.e1', 16, 0.7, { energy: 0.3, over: 4 });
                if (k >= 12) bass(b, k, 0.5);
                if (k === 14) hit(b, 0, 'bit.sweep', 8, 1, { cam: { roll: 0.35 } });
                if (k === 15) { ratchet(b, 0, 'snare.ghost.d', 8, 8, 0.2, 0.5); hit(b, 8, 'chirp'); }
            });

            // 2 ── ignition: the engines light. The grid stretches into a tunnel as the snare fills in.
            section('ignition', 8, (b, k) => {
                if (k === 0) hit(b, 0, 'bit.sweep', 8, 0.9, { form: { tunnel: 0.9 }, over: 8, energy: 0.55 });
                grid(b, 'kick.chip', k < 4 ? 'x.......x.......' : 'x...x...x...x...', 0.8);
                euclid(b, 'snare.noise8', [2, 3, 4, 6, 8, 8, 12, 16][k], 16, 0, 0.35 + 0.07 * k);
                grid(b, 'hat.lfsr', k < 4 ? 'x.x.x.x.x.x.x.x.' : 'xxxxxxxxxxxxxxxx', 0.3 + 0.03 * k);
                bass(b, k, 0.55 + 0.04 * k);
                lead(b, k, 0.45, GATES[k % 3]);
                if (k % 2 === 1) arps(b, k, 'x...............', 0.6);
                if (k === 4) hit(b, 0, 'modem', 4);
                if (k === 6) hit(b, 0, 'zap.8', 1, 0.8, { cam: { pitch: 0.3 } });
                if (k === 7) { ratchet(b, 8, 'snare.noise8', 16, 8, 0.4, 1); hit(b, 12, 'chirp'); }
            });

            // 3 ── orbit: the first drop. A blast opens it; halfway through, the grid goes into orbit.
            section('orbit', 32, (b, k) => {
                const heat = k < 16 ? 0.4 + 0.15 * (k / 15) : 0.55 + 0.2 * ((k - 16) / 15);
                if (k === 0) blast(b, { form: { tunnel: 0 }, energy: 0.75 });
                if (k === 8) hit(b, 0, 'byte.crush', 1, 1);
                frame(b, k, heat);
                bass(b, k);
                if (k % 2 === 0 && k > 0) hit(b, 0, 'sub.e1', 16, 0.6);
                if (k % 4 === 0 && k !== 0 && k !== 16) hit(b, 0, 'pwm.pad', 24, 0.45);
                arps(b, k, k < 16 ? '......x.......x.' : '..x...x...x...x.', 0.6);
                if (k < 8 || (k >= 16 && k < 24)) lead(b, k, 0.55, GATES[k % 3]);
                else { if (k % 2 === 0) hit(b, 0, 'stab.bit', 1, 0.7); lead(b, k, 0.45, '..x.x...x.x...x.'); }
                if (k === 12) hit(b, 0, 'sample.hold', 8, 0.8, { form: { wave: 0.8 }, over: 1 });
                if (k === 15) hit(b, 0, 'drop.down', 1, 0.7, { form: { wave: 0 }, over: 1 });
                if (k === 16) hit(b, 0, 'pwm.pad', 24, 0.5, { form: { ring: 0.55 }, over: 2, spin: 0.9, energy: 0.85 });
                if (k === 24) hit(b, 0, 'bit.sweep', 8, 0.8, { form: { ring: 1 }, over: 2, spin: 1.4 });
                if (k === 30) hit(b, 0, 'zap.8', 1, 0.8, { form: { ring: 0 }, over: 2 });
                if (k === 6 || k === 22) hit(b, 8, 'modem', 4);
                if (k === 10 || k === 26) hit(b, 0, 'data.stream', 8);
                if (k >= 16) { euclid(b, 'cowbell.d', 5, 16, k, 0.4); if (k % 4 === 1) hit(b, 6, 'zap.8'); }
                if (k === 31) { ratchet(b, 8, 'snare.noise8', 8, 8, 0.4, 0.9); hit(b, 8, 'bit.sweep', 8); }
            });

            // 4 ── low.power: the beat powers down; the grid drifts in a slow spiral
            section('low.power', 16, (b, k) => {
                if (k === 0) line(b, [[0, 'pwm.pad', 24, 0.65, { form: { spiral: 0.85 }, over: 4, spin: 0.25, energy: 0.25 }], [0, 'sub.e1', 16, 0.55]]);
                if (k % 2 === 0 && k > 0) line(b, [[0, 'sub.e1', 16, 0.45], [0, 'kick.808d', 1, 0.55]]);
                if (k === 4 || k === 8 || k === 12) hit(b, 0, 'pwm.pad', 24, 0.6);
                grid(b, 'kick.click', 'x...x...x...x...', 0.3);
                if (k >= 4) hit(b, 8, 'snare.chip', 1, 0.45);
                ca.evolve(90);                                                    // rule 90: calmer, self-similar
                ca.hits(b, BLIPS, 0.3, 2);
                euclid(b, 'hat.tick.d', 5, 13, k, 0.28);
                lead(b, k, 0.42, k < 8 ? 'x...x...x.x...x.' : 'x...x.x...x...x.', MOTIF_D);
                if (k % 2 === 1) line(b, [[0, 'tri.e2', 4, 0.6], [8, 'tri.g♯2', 4, 0.55]]);
                if (k === 3 || k === 11) hit(b, 4, 'sample.hold', 8);
                if (k === 6) hit(b, 0, 'data.stream', 8);
                if (k === 8) hit(b, 0, 'arp.e+', 1, 0.6, { form: { spiral: 0.4, wave: 0.5 }, over: 4 });
                if (k === 9) hit(b, 8, 'modem', 4);
                if (k === 13) hit(b, 12, 'glitch.stutter', 4);
                if (k === 15) hit(b, 8, 'drop.down', 1, 0.7, { form: { spiral: 0, wave: 0 }, over: 1 });
            });

            // 5 ── telemetry: a half-time digital groove. The lead calls, the blips answer, and the grid's doors open on the snare.
            section('telemetry', 24, (b, k) => {
                const heat = 0.4 + 0.4 * k / 23;
                const doors = { 8: 0.8, 12: 0, 16: 1, 18: 0, 20: 1, 22: 0 };
                const snareFx = k in doors ? { form: { split: doors[k] }, over: doors[k] ? 0.25 : 0.5 } : undefined;
                if (k === 0) line(b, [[0, 'modem', 4, 0.8, { energy: 0.55, over: 2 }]]);
                halfFrame(b, k, heat, snareFx);
                const notes = BASSNOTES[root(k)];
                [0, 2, 4, 6, 8, 10, 12, 14].forEach((st, i) => { if (i !== 5) hit(b, st, notes[i === 3 || i === 7 ? 1 : 0], 1, st % 4 === 0 ? 0.85 : 0.6); });
                if (k % 2 === 0) lead(b, k, 0.5, 'x.xx.x.x........', MOTIF_B);
                else [[8, 'blip.1'], [10, 'blip.2'], [11, 'blip.1'], [13, k % 4 === 1 ? 'beep.coin' : 'chirp']].forEach(([st, id]) => hit(b, st, id, 1, 0.55));
                if (k >= 8) arps(b, k, '....x.......x...', 0.5);
                if (k % 8 === 4) hit(b, 0, 'sample.hold', 8);
                if (k % 8 === 6) hit(b, 4, 'glitch.stutter', 4);
                if (k === 10 || k === 19) hit(b, 0, 'data.stream', 8);
                if (k % 4 === 0) hit(b, 0, 'pwm.pad', 24, 0.4);
                if (k >= 12) { ca.evolve(110); ca.hits(b, ['tom.chip.hi', 'tom.chip.lo', 'kick.click', 'cowbell.d'], 0.45, 2); }
                if (k === 23) hit(b, 12, 'zap.8', 1, 0.8);
            });

            // 6 ── overclock: rolls speed up to thirty-seconds; the tunnel stretches all the way
            section('overclock', 8, (b, k) => {
                if (k === 0) hit(b, 0, 'bit.sweep', 8, 0.8, { form: { tunnel: 1 }, over: 8, energy: 0.85 });
                grid(b, 'kick.chip', k < 4 ? 'x.......x.......' : 'x...x...x...x...', 0.8);
                if (k < 6) euclid(b, 'snare.noise8', [4, 6, 8, 12, 12, 16][k], 16, 0, 0.4 + 0.08 * k);
                else ratchet(b, 0, 'snare.noise8', 32, 16, 0.35 + 0.3 * (k - 6), 0.7 + 0.3 * (k - 6));
                grid(b, 'hat.lfsr', 'xxxxxxxxxxxxxxxx', 0.3 + 0.05 * k);
                bass(b, k, 0.7);
                if (k === 2) hit(b, 0, 'chirp');
                if (k === 4) hit(b, 0, 'modem', 4);
                if (k === 6) hit(b, 0, 'zap.8', 1, 0.9, { cam: { roll: 0.4 } });
                if (k === 7) hit(b, 0, 'glitch.stutter', 4, 0.8);
            });

            // 7 ── surface: the second drop, in a new key centre (starting on G-sharp) with a new motif.
            //      The surface rolls in waves, a crush blows it apart, and the doors snap open on the stabs.
            section('surface', 32, (b, k) => {
                const heat = 0.6 + 0.3 * Math.min(1, k / 24);
                if (k === 0) blast(b, { form: { tunnel: 0 }, energy: 0.9, cam: { zoom: -0.35, pitch: 0.45 } });
                if (k === 17) hit(b, 0, 'kick.808d', 1, 0.8, { form: { cloud: 0 }, over: 1.5 });
                frame(b, k, heat);
                bass(b, k, 1, 1);
                if (k % 2 === 0 && k > 0) hit(b, 0, 'sub.e1', 16, 0.6);
                if (k % 4 === 0 && k > 0) hit(b, 0, 'pwm.pad', 24, 0.45);
                arps(b, k, '..x...x...x...x.', 0.6, 1);
                if (k < 8 || k >= 24) lead(b, k, 0.55, GATES[k % 3], MOTIF_B, 1);
                else {
                    lead(b, k, 0.45, '..x.x...x.x...x.', MOTIF_B, 1);
                    if (k % 2 === 0) hit(b, 0, 'stab.bit', 1, 0.75, k >= 20 ? { form: { split: k % 4 === 0 ? 0.9 : 0 }, over: 0.25 } : undefined);
                }
                euclid(b, 'cowbell.d', 5, 16, k, 0.42);
                if (k === 8) hit(b, 0, 'sample.hold', 8, 0.8, { form: { wave: 0.7 }, over: 2 });
                if (k === 15) hit(b, 8, 'drop.down', 1, 0.7, { form: { wave: 0 }, over: 1 });
                if (k === 16) hit(b, 0, 'byte.crush', 1, 1, { form: { cloud: 0.75 }, over: 0, cam: { shake: 12, roll: 0.3 } });
                if (k % 4 === 1) hit(b, 6, 'zap.8');
                if (k === 6 || k === 22) hit(b, 8, 'modem', 4);
                if (k === 12 || k === 28) hit(b, 0, 'data.stream', 8);
                if (k === 10) hit(b, 8, 'glitch.stutter', 4);
                if (k === 31) { hit(b, 0, 'bit.sweep', 8); ratchet(b, 8, 'snare.noise8', 16, 8, 0.4, 1); }
            });

            // 8 ── storm: the climax. Rule 30 throws the drums into chaos, the lead leaps, the rings spin hard.
            section('storm', 24, (b, k) => {
                if (k === 0) line(b, [[0, 'byte.crush', 1, 1, { form: { split: 0, ring: 0.7, wave: 0.4 }, over: 1, spin: 2.2, energy: 1, cam: { shake: 14, roll: 0.5 } }], [0, 'sub.e1', 16, 0.7]]);
                grid(b, 'kick.chip', 'x.....x...x.....', 0.95);
                grid(b, 'kick.808d', 'x...............', 0.6);
                grid(b, 'snare.noise8', '....x.......x...', 0.95);
                ca.evolve(30);                                                    // rule 30: chaos
                ca.hits(b, ['snare.ghost.d', 'tom.chip.hi', 'kick.click', 'tom.chip.lo', 'clap.8bit', 'hat.ratchet'], 0.55, 1);
                grid(b, 'hat.lfsr', 'xxxxxxxxxxxxxxxx', 0.38);
                bass(b, k);
                if (k % 2 === 0 && k > 0) hit(b, 0, 'sub.e1', 16, 0.7);
                if (k % 4 === 0 && k > 0) hit(b, 0, 'pwm.pad', 24, 0.5);
                lead(b, k, 0.6, GATES[(k + 1) % 3], MOTIF_C);
                arps(b, k, 'x.x.x.x.x.x.x.x.', 0.5);
                if (k % 4 === 2) hit(b, 0, 'stab.bit', 1, 0.8);
                if (k % 4 === 3) hit(b, 8, 'modem', 4, 0.6);
                if (k % 8 === 5) hit(b, 4, 'glitch.stutter', 4);
                if (k % 2 === 1) hit(b, 10, 'zap.8', 1, 0.5);
                if (k === 8) hit(b, 0, 'power.down', 8, 0.9, { form: { cloud: 0.6 }, over: 0, spin: 3 });
                if (k === 9) hit(b, 0, 'bit.sweep', 8, 0.8, { form: { cloud: 0 }, over: 1 });
                if (k === 16) hit(b, 0, 'byte.crush', 1, 1, { form: { ring: 1, wave: 0.6 }, over: 1, spin: 3.6, cam: { shake: 16 } });
                if (k === 23) ratchet(b, 0, 'snare.noise8', 32, 16, 0.5, 1);
            });

            // 9 ── eye: the eye of the storm. Near silence; the rings almost stop turning.
            section('eye', 8, (b, k) => {
                if (k === 0) line(b, [[0, 'pwm.pad', 24, 0.55, { form: { wave: 0, ring: 1 }, over: 1, spin: 0.08, energy: 0.1 }], [0, 'sub.e1', 16, 0.3]]);
                if (k === 4) hit(b, 0, 'pwm.pad', 24, 0.5);
                lead(b, k, 0.4, 'x.......x.......', MOTIF_D);
                if (k === 2) hit(b, 0, 'sample.hold', 8, 0.6);
                if (k === 5) hit(b, 8, 'data.stream', 8, 0.5);
                if (k >= 6) grid(b, 'kick.808d', k === 6 ? 'x...............' : 'x.......x.......', 0.6);
                if (k === 6) hit(b, 0, 'bit.sweep', 8, 0.8, { form: { ring: 0, tunnel: 0.8 }, over: 2, energy: 0.7 });
                if (k === 7) { ratchet(b, 0, 'snare.noise8', 16, 16, 0.2, 1); hit(b, 12, 'chirp'); }
            });

            // 10 ── final.drop: home on E, everything at once. Two blasts, a spiral, the doors, and a last climb.
            section('final.drop', 32, (b, k) => {
                const heat = k < 28 ? 0.8 + 0.2 * Math.min(1, k / 16) : 1;
                if (k === 0) blast(b, { form: { tunnel: 0 }, energy: 1, cam: { zoom: -0.4, roll: 0.6, pitch: 0.4 } });
                if (k === 24) blast(b, { form: { spiral: 0, split: 0 }, cam: { zoom: -0.3, yaw: 0.5 } });
                frame(b, k, heat);
                bass(b, k);
                if (k % 2 === 0 && k !== 0 && k !== 24) hit(b, 0, 'sub.e1', 16, 0.6);
                if (k % 4 === 0 && k !== 0 && k !== 24) hit(b, 0, 'pwm.pad', 24, 0.5);
                arps(b, k, '..x...x...x...x.', 0.6);
                lead(b, k, 0.55, GATES[k % 3], k < 16 ? MOTIF : MOTIF_B);
                if (k % 2 === 0) hit(b, 0, 'stab.bit', 1, 0.75, k >= 20 && k < 24 ? { form: { split: k % 4 === 0 ? 1 : 0 }, over: 0.25 } : undefined);
                euclid(b, 'cowbell.d', 5, 16, k, 0.45);
                if (k % 4 === 1) hit(b, 6, 'zap.8');
                if (k === 8) hit(b, 0, 'sample.hold', 8, 0.8, { form: { wave: 0.6 }, over: 1 });
                if (k === 12) hit(b, 8, 'drop.down', 1, 0.7, { form: { wave: 0 }, over: 1 });
                if (k === 16) hit(b, 0, 'byte.crush', 1, 1, { form: { spiral: 1 }, over: 1, spin: 2, cam: { shake: 12 } });
                if (k === 20) hit(b, 0, 'glitch.stutter', 4, 0.8, { form: { spiral: 0 }, over: 1 });
                if (k === 6 || k === 22) hit(b, 8, 'modem', 4);
                if (k === 10 || k === 26) hit(b, 0, 'data.stream', 8);
                if (k === 28) hit(b, 0, 'bit.sweep', 8, 0.9, { form: { tunnel: 0.7 }, over: 4 });
                if (k >= 28) grid(b, 'kick.808d', 'x...x...x...x...', 0.5 + 0.08 * (k - 28));
                if (k === 31) { ratchet(b, 8, 'snare.noise8', 16, 8, 0.4, 1); hit(b, 8, 'zap.8', 1, 0.9, { cam: { roll: 0.5 } }); }
            });

            // 11 ── shutdown: parts switch off one by one, and the grid folds back into the line it came from
            section('shutdown', 16, (b, k) => {
                if (k === 0) line(b, [[0, 'power.down', 8, 0.9, { form: { tunnel: 0 }, over: 1, energy: 0.5, cam: { zoom: -0.25 } }], [0, 'sub.e1', 16, 0.7]]);
                if (k === 2) hit(b, 0, 'pwm.pad', 24, 0.7, { form: { line: 1 }, over: 13, energy: 0.1 });
                if (k < 8) { frame(b, k, 0.5 - k * 0.06); bass(b, k, 0.9 - k * 0.06); }
                else if (k < 12) {
                    grid(b, 'kick.808d', 'x.......x.......', 0.6);
                    grid(b, 'snare.chip', '........x.......', 0.5);
                    grid(b, 'hat.lfsr', '..x...x...x...x.', 0.3);
                }
                if (k === 8 || k === 12) hit(b, 0, 'pwm.pad', 24, 0.6);
                if (k < 12) lead(b, k, 0.5 - k * 0.025, k < 8 ? GATES[k % 3] : '..x...x...x...x.');
                else lead(b, k, 0.3, 'x.......x.......', MOTIF_D);
                if (k === 5) hit(b, 8, 'modem', 4, 0.6);
                if (k === 9) hit(b, 0, 'data.stream', 8, 0.5);
                if (k === 11) hit(b, 0, 'sample.hold', 8, 0.5);
                if (k === 13) hit(b, 8, 'glitch.stutter', 4, 0.5);
                if (k === 15) line(b, [[0, 'beep.coin', 1, 0.7], [8, 'drop.down', 1, 0.6], [12, 'kick.click', 1, 0.5]]);
            });
        },
    },
    {
        title: 'Skoodu', meta: 'octatonic on d, 172 bpm, generative half-time', bpm: 172, swing: 0, tail: 2.5,
        room: { seconds: 1.8, shape: 3, wet: 0.15, tone: 0.45 },
        kit: KIT_RULE30,
        write({ hit, grid, line, section, ratchet, seeded, euclid, automaton }) {
            const { rng, pick, chance } = seeded(30);
            const ca = automaton(rng);

            const CHIMES = ['chime.d4', 'chime.e♭4', 'chime.f4', 'chime.f♯4', 'chime.g♯4', 'chime.a4', 'chime.b4', 'chime.c5'];
            let chimeTurn = 0;
            const chimes = (b, period, stride, v, offset = 0) => {
                for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, CHIMES[(chimeTurn++ * stride) % 8], 2, v);
            };

            const gene = [0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0];
            const mutate = n => {
                for (let i = 0; i < n; i++) { const j = 1 + Math.floor(rng() * 15); if (j !== 8) gene[j] ^= 1; }
            };
            const ghosts = (b, v) => gene.forEach((on, st) => { if (on) hit(b, st, 'snare.ghost', 1, v * (0.6 + 0.4 * rng())); });

            const ROOTS = ['growl.d2', 'growl.f2', 'growl.g♯1', 'growl.b1'];
            const PASS = { 'growl.d2': 'growl.e♭2', 'growl.f2': 'growl.e♭2', 'growl.g♯1': 'growl.a1', 'growl.b1': 'growl.a1' };
            const GROWLS = ['......x...x.x...', '...x....x....x..', '......x..x....x.', '..x.....x.x.....'];
            const bassBar = (b, k, v, turn = 0) => {
                const root = ROOTS[(Math.floor(k / 2) + turn) % 4];
                hit(b, 0, root, 6, v);
                [...pick(GROWLS)].forEach((c, st) => { if (c === 'x') hit(b, st, chance(0.35) ? PASS[root] : root, 2, v); });
            };
            const pads = (b, k, v) => { if (k % 4 === 0) hit(b, 0, k % 8 === 0 ? 'cold.pad.d' : 'glass.pad', 24, v); };
            const stabs = (b, k, v) => {
                const id = k % 4 < 2 ? 'stab.dim.d' : 'stab.dim.e♭';
                hit(b, 6, id, 1, v);
                if (chance(0.4)) hit(b, 11, id, 1, v * 0.7);
            };

            const HATS = ['hat.fm1', 'hat.fm2', 'hat.noise'];
            const METAL = ['clang.2', 'tine', 'rim.fm', 'click.sample'];
            const TICKS = ['click.sample', 'tine'];

            const half = (b, k, heat, rule = 30) => {
                grid(b, 'kick.punch', pick(['x...............', 'x.........x.....', 'x......x........']));
                hit(b, 8, 'snare.fm');
                mutate(1 + Math.round(heat * 2));
                ghosts(b, 0.5);
                euclid(b, HATS, 5 + (k % 4), 16, k, 0.45);
                ca.evolve(rule);
                ca.hits(b, METAL, 0.45 * (0.5 + heat), heat > 0.6 ? 1 : 2);
                if (k % 4 === 3) ratchet(b, pick([12, 14]), 'snare.crush', pick([4, 6]), 2, 0.3, 0.85);
            };
            const double = (b, k, heat, rule = 30) => {
                grid(b, 'kick.punch', 'x.........x.....');
                if (chance(0.5)) euclid(b, 'kick.fm', 2, 16, k * 5 + 3, 0.7);
                grid(b, 'snare.fm', '....x.......x...');
                if (heat > 0.6) hit(b, 12, 'clap.digital', 1, 0.6);
                mutate(2);
                ghosts(b, 0.45);
                euclid(b, HATS, 9 + (k % 4), 16, k * 2, 0.45);
                ca.evolve(rule);
                ca.hits(b, METAL, 0.5 * (0.5 + heat), heat > 0.7 ? 1 : 2);
                if (k % 4 === 3) ratchet(b, pick([12, 14]), 'snare.crush', pick([6, 8]), 2, 0.3, 0.95);
            };

            section('seed', 8, (b, k) => {
                if (k === 0) hit(b, 0, 'glass.pad', 24, 0.8);
                if (k === 4) hit(b, 0, 'cold.pad.d', 24, 0.8);
                chimes(b, 11, 3, 0.5);
                euclid(b, TICKS, 3, 13, k, 0.35);
                if (k === 1) hit(b, 4, 'bit.rain', 12);
                if (k === 3) hit(b, 8, 'gear');
                if (k >= 4) {
                    grid(b, 'kick.sub', 'x...............', 0.7);
                    grid(b, 'snare.gate', '........x.......', 0.6);
                    euclid(b, HATS, 4, 12, k, 0.35);
                }
                if (k === 6) hit(b, 0, 'grain.cloud', 8);
                if (k === 7) {
                    line(b, [[0,'data.burst',4], [10,'reverse.fm',6]]);
                    ratchet(b, 12, 'snare.crush', 8, 4, 0.3, 1);
                }
            });

            section('halftime', 16, (b, k) => {
                if (k === 0) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch']]);
                half(b, k, k < 8 ? 0.35 : 0.6);
                bassBar(b, k);
                pads(b, k, 0.5);
                if (k < 8) chimes(b, 2, 3, 0.5); else { stabs(b, k, 0.7); chimes(b, 3, 5, 0.45); }
                if (k === 4) hit(b, 0, 'formant.shift', 12);
                if (k === 6 || k === 14) hit(b, 12, 'gear');
                if (k === 8) hit(b, 0, 'hoover.dim', 8);
                if (k === 10) hit(b, 8, 'data.burst', 4);
                if (k === 12) hit(b, 0, 'sub.d2', 16);
                if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
                if (k === 15) hit(b, 10, 'reverse.fm', 6);
            });

            section('cascade', 8, (b, k) => {
                if (k % 2 === 0) line(b, [[0,'sub.d2',16,0.8], [0,'kick.sub',1,0.7]]);
                if (k === 0) hit(b, 0, 'cold.pad.d', 24, 0.8);
                if (k === 4) hit(b, 0, 'glass.pad', 24, 0.8);
                hit(b, 8, 'snare.gate', 1, 0.5);
                ca.evolve(30);
                ca.hits(b, ['clang.2', 'tine', 'rim.fm', 'click.sample', 'gear', 'stutter'], 0.4);
                euclid(b, HATS, 5, 13, k, 0.3);
                chimes(b, 3, 5, 0.55);
                if (k === 1) hit(b, 0, 'bit.rain', 12);
                if (k === 3) hit(b, 4, 'grain.cloud', 8);
                if (k === 5) hit(b, 0, 'formant.shift', 12);
                if (k === 7) hit(b, 8, 'stutter', 4);
            });

            section('build', 4, (b, k) => {
                grid(b, 'kick.punch', 'x...x...x...x...', 0.8);
                euclid(b, 'snare.gate', [4, 6, 8, 12][k], 16, 0, 0.4 + 0.15 * k);
                if (k === 0 || k === 2) hit(b, 0, 'growl.d2', 8, 0.8);
                chimes(b, 4 - (k >> 1), 3, 0.5);
                if (k === 1) hit(b, 0, 'hoover.dim', 8, 0.8);
                if (k === 3) { ratchet(b, 12, 'snare.crush', 8, 4, 0.4, 1); hit(b, 10, 'reverse.fm', 6); }
            });

            section('doubletime', 16, (b, k) => {
                const heat = 0.6 + 0.4 * k / 15;
                if (k === 0) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch']]);
                if (k === 8) hit(b, 0, 'crunch');
                double(b, k, heat);
                bassBar(b, k);
                pads(b, k, 0.45);
                grid(b, 'ride.fm', 'x...x...x...x...', 0.4);
                if (k < 8) { stabs(b, k, 0.7); chimes(b, 3, 5, 0.45); } else chimes(b, 2, 3, 0.5);
                if (k === 4 || k === 12) hit(b, 0, 'hoover.dim', 8);
                if (k === 2 || k === 10) hit(b, 4, 'zap.fm');
                if (k % 4 === 1) hit(b, 10, 'buzz', 2);
                if (k === 6 || k === 14) hit(b, 12, 'gear');
                if (k === 9) hit(b, 8, 'data.burst', 4);
                if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
            });

            section('drift', 24, (b, k) => {
                if (k % 8 === 0) hit(b, 0, 'cold.pad.d', 24, 0.7);
                if (k % 8 === 4) hit(b, 0, 'glass.pad', 24, 0.75);
                if (k % 4 === 0) hit(b, 0, 'sub.d2', 16, 0.6);
                chimes(b, k < 12 ? 5 : 3, k < 12 ? 3 : 5, 0.5);
                ca.evolve(90);
                ca.hits(b, ['tine', 'click.sample', 'rim.fm'], 0.3, 2);
                euclid(b, HATS, 3, 13, k, 0.25);
                if (k % 6 === 2) hit(b, 0, 'bit.rain', 12, 0.6);
                if (k === 4 || k === 16) hit(b, 0, 'hoover.dim', 8, 0.6);
                if (k === 8 || k === 20) hit(b, 8, 'gear');
                if (k === 10) hit(b, 0, 'formant.shift', 12);
                if (k === 14) hit(b, 4, 'grain.cloud', 8);
                if (k >= 16) { grid(b, 'kick.sub', 'x...............', 0.55); hit(b, 8, 'snare.gate', 1, 0.45); }
                if (k === 23) { hit(b, 10, 'reverse.fm', 6); ratchet(b, 12, 'snare.crush', 6, 4, 0.3, 0.8); }
            });

            section('mutation', 24, (b, k) => {
                if (k === 0) line(b, [[0,'clang.2'], [0,'kick.sub',4]]);
                half(b, k, 0.45 + 0.35 * k / 23, k % 8 < 4 ? 30 : 110);
                mutate(1);
                bassBar(b, k, 1, 1);
                pads(b, k, 0.45);
                if (k % 8 < 4) chimes(b, 2, 5, 0.45); else { stabs(b, k, 0.65); chimes(b, 3, 7, 0.4); }
                if (k % 8 === 6) hit(b, 12, 'gear');
                if (k === 8 || k === 20) hit(b, 0, 'hoover.dim', 8);
                if (k === 12) hit(b, 8, 'data.burst', 4);
                if (k % 4 === 3) hit(b, 15, 'zap.fm', 1, 0.5);
                if (k === 16) hit(b, 0, 'crunch');
                if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
            });

            section('lift', 8, (b, k) => {
                grid(b, 'kick.punch', k < 4 ? 'x.......x.......' : 'x...x...x...x...', 0.8);
                euclid(b, 'snare.gate', [2, 3, 4, 5, 6, 8, 10, 12][k], 16, 0, 0.35 + 0.08 * k);
                if (k % 2 === 0) hit(b, 0, 'growl.d2', 8, 0.7);
                chimes(b, 4 - Math.floor(k / 3), 3, 0.5);
                euclid(b, HATS, 4 + k, 16, k, 0.35);
                if (k === 2) hit(b, 0, 'bit.rain', 12, 0.6);
                if (k === 4) hit(b, 0, 'hoover.dim', 8, 0.8);
                if (k === 6) hit(b, 0, 'data.burst', 4);
                if (k === 7) { ratchet(b, 8, 'snare.crush', 12, 8, 0.35, 1); hit(b, 10, 'reverse.fm', 6); }
            });

            section('doubletime.ii', 32, (b, k) => {
                const heat = 0.65 + 0.35 * Math.min(1, k / 24);
                if (k === 0 || k === 16) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch']]);
                if (k === 8 || k === 24) hit(b, 0, 'crunch');
                double(b, k, heat, k % 8 < 4 ? 30 : 110);
                bassBar(b, k, 1, k < 16 ? 1 : 2);
                pads(b, k, 0.45);
                grid(b, 'ride.fm', k < 16 ? 'x...x...x...x...' : 'x.x.x.x.x.x.x.x.', 0.35);
                const phase = Math.floor(k / 8);
                if (phase === 0) chimes(b, 2, 3, 0.5);
                if (phase === 1) { stabs(b, k, 0.7); chimes(b, 3, 5, 0.45); }
                if (phase === 2) chimes(b, 2, 7, 0.5);
                if (phase === 3) { stabs(b, k, 0.75); chimes(b, 2, 5, 0.5); }
                if (k % 8 === 4) hit(b, 0, 'hoover.dim', 8);
                if (k % 4 === 1) hit(b, 10, 'buzz', 2);
                if (k % 8 === 2) hit(b, 4, 'zap.fm');
                if (k % 8 === 6) hit(b, 12, 'gear');
                if (k === 12 || k === 28) hit(b, 0, 'formant.shift', 12);
                if (k === 20) hit(b, 8, 'data.burst', 4);
                if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
                if (k === 31) hit(b, 10, 'reverse.fm', 6);
            });

            section('cascade.ii', 16, (b, k) => {
                if (k % 2 === 0) line(b, [[0,'sub.d2',16,0.75], [0,'kick.sub',1,0.7]]);
                if (k % 8 === 0) hit(b, 0, k === 0 ? 'glass.pad' : 'cold.pad.d', 24, 0.8);
                hit(b, 8, 'snare.gate', 1, 0.5);
                ca.evolve(k % 2 ? 90 : 30);
                ca.hits(b, ['clang.2', 'tine', 'rim.fm', 'click.sample', 'gear', 'stutter'], 0.4);
                euclid(b, HATS, 5 + (k % 3), 13, k, 0.3);
                chimes(b, 3, k < 8 ? 5 : 7, 0.5);
                if (k >= 8) bassBar(b, k, 0.6, 2);
                if (k === 3) hit(b, 0, 'bit.rain', 12);
                if (k === 6) hit(b, 0, 'formant.shift', 12);
                if (k === 10) hit(b, 4, 'grain.cloud', 8);
                if (k === 12) hit(b, 0, 'hoover.dim', 8, 0.7);
                if (k === 15) { hit(b, 8, 'stutter', 4); ratchet(b, 12, 'snare.crush', 8, 4, 0.4, 1); }
            });

            section('gap', 1, b => {
                line(b, [[0,'stutter',4,0.6], [8,'reverse.fm',6], [8,'data.burst',4,0.5]]);
            });

            section('final.run', 24, (b, k) => {
                const heat = k < 20 ? 1 : 1 - (k - 19) * 0.15;
                if (k === 0) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch'], [0,'hoover.dim',8]]);
                if (k === 12) hit(b, 0, 'crunch');
                double(b, k, heat, 30);
                bassBar(b, k);
                pads(b, k, 0.5);
                grid(b, 'ride.fm', 'x.x.x.x.x.x.x.x.', 0.35);
                if (k < 8) { stabs(b, k, 0.75); chimes(b, 2, 3, 0.5); }
                else if (k < 16) chimes(b, 2, 5, 0.55);
                else { stabs(b, k, 0.6); chimes(b, 2, 7, 0.5); }
                if (k % 8 === 4) hit(b, 0, 'hoover.dim', 8);
                if (k % 4 === 1) hit(b, 10, 'buzz', 2);
                if (k % 4 === 2) hit(b, 4, 'zap.fm');
                if (k % 8 === 6) hit(b, 12, 'gear');
                if (k === 10) hit(b, 8, 'data.burst', 4);
                if (k === 18) hit(b, 0, 'formant.shift', 12);
                if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
                if (k === 23) hit(b, 10, 'reverse.fm', 6);
            });

            section('decay', 8, (b, k) => {
                if (k < 4) { half(b, k, 0.35 - k * 0.07); bassBar(b, k, 0.8); }
                if (k === 0) hit(b, 0, 'cold.pad.d', 24, 0.7);
                if (k === 4) hit(b, 0, 'glass.pad', 24, 0.7);
                if (k >= 4) {
                    chimes(b, 4 + (k - 4), 3, 0.5 - (k - 4) * 0.06);
                    euclid(b, TICKS, 3, 11, k, 0.35);
                }
                if (k === 5) hit(b, 0, 'bit.rain', 12, 0.6);
                if (k === 7) line(b, [[0,'gear',1,0.7], [8,'chime.d4',8,0.6]]);
            });
        },
    },
];

// write every song once, up front
SONGS.forEach(song => {
    song.slug = song.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
    song.velOf = {};
    for (const k of [1, 2, 3]) song.kit[k].forEach(([id, , opts = {}]) => { song.velOf[id] = opts.vel ?? 1; });
    const w = makeWriter(song.velOf);
    song.write(w);
    Object.assign(song, w.done());
    song.step = 60 / song.bpm / 4;
    song.barLen = song.step * 16;
    song.length = song.bars * song.barLen;
});
let cur = SONGS[0];
// swing only nudges whole odd sixteenths; notes placed between steps keep their exact time
const timeOf = (song, at) => (at + (Number.isInteger(at) && at % 2 === 1 ? song.swing : 0)) * song.step;

// ─────────────────────────────────────────────
//  BUILDING THE GRID
// ─────────────────────────────────────────────
const N = 7, MID = 3;
const CELL = 64, GAP = 8, PITCH = CELL + GAP, HALF = CELL / 2;
const GRID_HALF = (N * PITCH - GAP) / 2;
const SHEAR_MAX = 0.82 * CELL;
const FOCAL = 1000;
const WAVE_SPEED = 1100;            // grid units per second
const BREAK_FORCE = 165;            // a shove harder than this knocks a tile loose

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const touchy = matchMedia('(hover: none)').matches;
const MOTION = reduceMotion ? 0.3 : 1;

const NS = 'http://www.w3.org/2000/svg';
const $ = id => document.getElementById(id);
function svgEl(tag, attrs, parent) {
    const n = document.createElementNS(NS, tag);
    for (const k in attrs) n.setAttribute(k, attrs[k]);
    if (parent) parent.appendChild(n);
    return n;
}
function setRect(el, x, y, w, h) {
    el.setAttribute('x', x.toFixed(1)); el.setAttribute('y', y.toFixed(1));
    el.setAttribute('width', w.toFixed(1)); el.setAttribute('height', h.toFixed(1));
}
const rand = (a, b) => a + Math.random() * (b - a);
const sign = () => (Math.random() < 0.5 ? -1 : 1);

// cells of ring k, clockwise from its top-left corner
function ringCells(k) {
    const cells = [], lo = MID - k, hi = MID + k;
    for (let c = lo; c <= hi; c++) cells.push([lo, c]);
    for (let r = lo + 1; r <= hi; r++) cells.push([r, hi]);
    for (let c = hi - 1; c >= lo; c--) cells.push([hi, c]);
    for (let r = hi - 1; r > lo; r--) cells.push([r, lo]);
    return cells;
}

const stageEl = $('stage');
const ghostsG = $('ghosts'), marksG = $('marks'), tilesG = $('tiles'), playG = $('play'), hitsG = $('hits');
const leadsG = $('leads'), calloutsEl = $('callouts');

const tiles = [];
const TILE = {};
const ringOrder = { 1: [], 2: [], 3: [] };
for (let k = 1; k <= 3; k++) {
    ringCells(k).forEach(([r, c]) => {
        const t = {
            id: '', voice: null, vel: 1, hitDef: null,     // filled in by the current song's kit
            r, c, ring: k,
            hx: (c - MID) * PITCH, hy: (r - MID) * PITCH,
            ghost: svgEl('rect', { class: 'ghost' }, ghostsG),
            poly: svgEl('polygon', { class: 'tile' }, tilesG),
            hit: svgEl('rect', {
                class: 'hit', tabindex: 0, role: 'button', 'data-tile': tiles.length,
            }, hitsG),
            // position offset, depth, tilt, shear: each a spring with a velocity
            dx: 0, dy: 0, z: 0, vx: 0, vy: 0, vz: 0,
            rx: 0, ry: 0, rz: 0, wx: 0, wy: 0, wz: 0,
            shear: 0, vs: 0, amp: 0, ink: 0,
            loose: false, looseSince: 0, recallUntil: 0,
            manual: false, keyUntil: 0, songUntil: -1, lastSound: 0,
            sc: 0, vsc: 0, sq: 0, vsq: 0, jit: 0, fillv: 0, fillTau: 0.25, look: ['shear'],   // swell, squash, jitter, ink fill
            fa: 0, fv: 0, fT: null, fw: 8, fAxis: 'y',          // flip: the card's angle, its speed, the angle it is heading for, and how briskly
            resting: true, pts: [[0, 0], [0, 0], [0, 0], [0, 0]],
            // callout: label visibility, on-screen position, measured width, which margin it uses
            glow: 0, shown: false, lx: 0, ly: 0, tx: 0, ty: 0, w: 0, side: '', row: null,
            note: Object.assign(document.createElement('p'), { className: 'callout' }),
            line: svgEl('line', { class: 'lead' }, leadsG),
        };
        calloutsEl.appendChild(t.note);
        tiles.push(t);
        ringOrder[k].push(t);
    });
}

// give every tile its sound, name and behaviour from a song's kit
function applyKit(song) {
    for (const k of [1, 2, 3]) ringOrder[k].forEach((t, i) => {
        const [id, voice, opts = {}] = song.kit[k][i];
        Object.assign(t, { id, voice, vel: opts.vel ?? 1, hitDef: opts.hit || null, look: opts.look || ['shear'], cam: opts.cam || null, accent: !!opts.sweep, sheen: 0 });
        t.note.textContent = id;
        t.hit.setAttribute('aria-label', `Play ${id}`);
    });
    for (const key in TILE) delete TILE[key];
    for (const t of tiles) TILE[t.id] = t;
}
applyKit(cur);
const tileAtCell = (r, c) => tiles.find(t => t.r === r && t.c === c);

const playBase  = svgEl('rect', { class: 'play-base' }, playG);
const playTrace = svgEl('rect', { class: 'play-trace' }, playG);
const playWord  = svgEl('text', { class: 'play-word' }, playG);
playWord.textContent = 'play';
const playIcon  = svgEl('path', { class: 'play-icon' }, playG);
const playHit = svgEl('rect', {
    class: 'hit', tabindex: 0, role: 'button', 'data-play': '1',
}, hitsG);

const waves = [];

function emitWave(w) {
    if (reduceMotion) return;
    w.t0 = performance.now() / 1000;
    waves.push(w);
    wake();
}

function applyWaves(nowS, prevS, nowMs) {
    for (let i = waves.length - 1; i >= 0; i--) {
        const w = waves[i];
        const r0 = (prevS - w.t0) * WAVE_SPEED, r1 = (nowS - w.t0) * WAVE_SPEED;
        for (const t of tiles) {
            if (t === w.src) continue;
            let d, ux, uy;
            if (w.plane) {
                d = (t.hx - w.x) * w.plane[0] + (t.hy - w.y) * w.plane[1];
                ux = w.plane[0]; uy = w.plane[1];
            } else {
                const ex = t.hx - w.x, ey = t.hy - w.y;
                d = Math.hypot(ex, ey);
                if (d < 1) continue;
                ux = ex / d; uy = ey / d;
            }
            if (d <= r0 || d > r1) continue;            // the wavefront reaches this tile now
            const before = smoothOn() ? KICK_CHANNELS.map(c => t[c]) : null;
            if (w.scatter) { const a = rand(0, Math.PI * 2); ux = Math.cos(a); uy = Math.sin(a); }   // splinter: each tile shoved its own way
            if (w.swirl) { const sx = -uy * Math.sign(w.swirl), sy = ux * Math.sign(w.swirl); ux = sx; uy = sy; }   // swirl: pushed around the source, not away
            const imp = w.push * (w.plane ? 1 : Math.exp(-d / w.reach));
            const hold = cur.tight ? 0.22 : 1;          // a tight grid barely shifts: the wave turns tiles rather than moving them
            t.vx += ux * imp * hold; t.vy += uy * imp * hold;
            const turn = smoothOn() ? 0.5 : 1;
            t.wy += ux * imp * 0.01 * turn; t.wx -= uy * imp * 0.01 * turn;
            t.vs += imp * 0.3;
            if (w.lift) t.vz += w.lift * rand(0.5, 1);
            if (w.spin) { t.wy += w.spin; t.wx += w.spin * 0.35; }
            if (w.twirl) t.wz += w.twirl * (w.plane ? 1 : Math.exp(-d / w.reach));     // twirl: turned in place, like a cog
            if (!cur.tight && !t.loose && Math.abs(imp) > BREAK_FORCE) breakLoose(t, Math.abs(imp), ux, uy, nowMs);
            if (before) deferKicks(t, before);
            t.resting = false;
        }
        if (r0 > 1000) waves.splice(i, 1);
    }
}

function breakLoose(t, force, ux, uy, nowMs) {
    t.loose = true;
    t.looseSince = nowMs;
    t.vx += ux * force * 0.4; t.vy += uy * force * 0.4;
    t.vz += force * rand(0.3, 0.8);
    t.wx += sign() * force * rand(0.01, 0.025);
    t.wy += sign() * force * rand(0.01, 0.025);
    t.wz += sign() * force * rand(0.003, 0.008);
}

// ─────────────────────────────────────────────
//  STRIKING A TILE
// ─────────────────────────────────────────────
// How a tile moves when its own sound plays. Each tile has a list of these in its kit entry;
// a "-small" or "-big" suffix scales the move, and "fill-long" lets the ink linger.
const LOOKS = {
    shear:   (t, g, k) => { t.amp = SHEAR_MAX * g * k * MOTION; t.vs += t.amp * 12; },   // slides into a parallelogram while held
    pulse:   (t, g, k) => { t.vsc += 4.5 * g * k * MOTION; },                           // swells outward and bounces back
    shrink:  (t, g, k) => { t.vsc -= 4 * g * k * MOTION; },                             // pulls inward
    squash:  (t, g, k) => { t.vsq += 6.5 * g * k * MOTION; },                           // flattens and springs back up
    flip:    (t, g, k) => { if (smoothOn()) return cardTurn(t, Math.random() < 0.5 ? 'x' : 'y', 0.45 + 0.2 * k); t.wy += 15 * g * k * MOTION * sign(); },   // turns edge-on and back (a whole card turn in a tight grid)
    fold:    (t, g, k) => { t.wx += 7 * g * k * MOTION * sign(); },                     // tips forward
    spin:    (t, g, k) => { t.wz += 11 * g * k * MOTION * sign(); },                    // turns in place
    jitter:  (t, g, k) => { if (smoothOn()) { t.wz += 1.6 * g * k * MOTION * sign(); return; } if (!reduceMotion) t.jit = Math.max(t.jit, 5 * g * k); },   // shakes like a bad signal (a small twist in a tight grid)
    fill:    (t, g, k, size) => {                                                       // floods with ink, then drains
        t.fillv = Math.max(t.fillv, Math.min(0.9, 0.7 * g * k));
        t.fillTau = size === 'long' ? 0.9 : 0.25;
    },
    breathe: (t, g) => { t.wx += 2.5 * sign() * MOTION; t.fillv = Math.max(t.fillv, 0.22 * g); t.fillTau = 1.6; },
    launch:  (t, g, k) => { t.vz += (smoothOn() ? 1100 : 2600) * g * k * MOTION; },     // leaps toward you and drops back
    tumble:  (t, g, k) => { if (smoothOn()) return cardTurn(t, 'x', 0.6); t.wx += 10 * g * k * MOTION * sign(); t.wy += 10 * g * k * MOTION * sign(); t.wz += 8 * g * k * MOTION * sign(); },
    ring:    (t, g, k) => spawnRing(t, 0, k, g),                                        // an outline ripples outward
    echo:    (t, g) => { spawnRing(t, 0, 1, g); spawnRing(t, 3 * cur.step * 1000, 1, g * 0.7); },   // a second ring on the echo
};

// in a tight grid, a hit that would tumble a tile turns its card over once instead, smoothly
function cardTurn(t, axis, dur) { startFlip(t, { mode: 'spin', axis: t.fT === null ? axis : t.fAxis, dur, turns: 1 }, performance.now() / 1000); }

// A sudden kick (speed from nothing to full in one frame) reads as a jolt. In a tight grid the kicks
// from hits and waves are held back and fed in over about 50 ms, so every move accelerates smoothly.
const KICK_CHANNELS = ['vx', 'vy', 'vz', 'wx', 'wy', 'wz', 'vsc', 'vsq', 'vs'];
const KICK_TIME = 0.05;
const smoothOn = () => !!(cur.tight || cur.smooth);   // a tight grid is always smooth; a smooth one can still break apart
function deferKicks(t, before) {
    KICK_CHANNELS.forEach((c, i) => {
        const d = t[c] - before[i];
        if (!d) return;
        t[c] = before[i];
        t.pend = t.pend || {};
        t.pend[c] = (t.pend[c] || 0) + d;
    });
}
function feedKicks(t, h) {
    const share = 1 - Math.exp(-h / KICK_TIME * 2.2);
    let left = 0;
    for (const c in t.pend) {
        const give = t.pend[c] * share;
        t[c] += give; t.pend[c] -= give;
        if (Math.abs(t.pend[c]) < 1e-4) delete t.pend[c]; else left++;
    }
    if (!left) t.pend = null;
}

function strike(t, gain) {
    if (t.accent && player.playing) sweepOnAccent(performance.now() / 1000);
    const drama = 0.75 + 0.6 * stage.energy;           // calm passages move gently, the drops throw tiles about
    const g = Math.min(1, Math.max(0.35, gain)) * drama;
    if (t.cam) kickCamera(t.cam, Math.min(1, gain / t.vel));
    t.amp = 0;
    t.ink = 1;
    t.resting = false;
    tilesG.appendChild(t.poly);     // the struck tile draws on top
    const before = smoothOn() ? KICK_CHANNELS.map(c => t[c]) : null;
    for (const look of t.look) {
        const [name, size] = look.split('-');
        const k = size === 'small' ? 0.5 : size === 'big' ? 1.6 : 1;
        (LOOKS[name] || LOOKS.shear)(t, g, k, size);
    }
    if (before) deferKicks(t, before);

    const h = t.hitDef;
    if (h) {
        const k = gain / t.vel;     // a ghost note disturbs less than a full hit
        if (h.plane) {
            emitWave({ plane: h.plane, x: -h.plane[0] * 320, y: -h.plane[1] * 320, push: h.push * k * drama, lift: h.lift });
        } else {
            emitWave({ x: h.centre ? 0 : t.hx, y: h.centre ? 0 : t.hy, push: h.push * k * drama, reach: h.reach || 420, lift: h.lift,
                       scatter: h.scatter, swirl: h.swirl, spin: h.spin, twirl: h.twirl, src: h.centre ? null : t });
        }
    }
    wake();
}

const ringsG = $('rings');
const ringPool = [], rings = [];
function spawnRing(t, delayMs, k, g) {
    if (rings.length > 60) return;
    const el = ringPool.pop() || svgEl('polygon', { class: 'ring' }, ringsG);
    el.style.display = 'none';
    rings.push({
        el, cx: (t.pts[0][0] + t.pts[2][0]) / 2, cy: (t.pts[0][1] + t.pts[2][1]) / 2, rz: t.rz,
        start: performance.now() + delayMs, life: 520 * (0.7 + 0.3 * k), grow: 1.4 * k, peak: 0.55 * g, half: HALF * S,
    });
    wake();
}
function stepRings(nowMs) {
    for (let i = rings.length - 1; i >= 0; i--) {
        const r = rings[i], p = (nowMs - r.start) / r.life;
        if (p < 0) continue;
        if (p >= 1) { r.el.style.display = 'none'; ringPool.push(r.el); rings.splice(i, 1); continue; }
        const h = r.half * (1 + r.grow * (1 - (1 - p) * (1 - p)));
        const c = Math.cos(r.rz), sn = Math.sin(r.rz);
        r.el.setAttribute('points', CORNERS.map(([x, y]) => {
            const lx = x / HALF * h, ly = y / HALF * h;
            return `${(r.cx + lx * c - ly * sn).toFixed(1)},${(r.cy + lx * sn + ly * c).toFixed(1)}`;
        }).join(' '));
        r.el.style.display = '';
        r.el.style.opacity = (r.peak * (1 - p)).toFixed(3);
    }
    return rings.length > 0;
}

const COOLDOWN = 90;
function soundTile(t) {
    const now = performance.now();
    if (now - t.lastSound < COOLDOWN) return;
    t.lastSound = now;
    playSound(t.id);
}

let S = 1, CX = 0, CY = 0, wide = true, playPerimeter = 4 * CELL;
const CORNERS = [[-HALF, -HALF], [HALF, -HALF], [HALF, HALF], [-HALF, HALF]];
const FRONT_SIGN = Math.sign(CORNERS.reduce((sum, a, i) => { const b = CORNERS[(i + 1) % 4]; return sum + a[0] * b[1] - b[0] * a[1]; }, 0));

function drawTile(t) {
    const cz = Math.cos(t.rz), sz = Math.sin(t.rz);
    const ax = t.rx + (t.fAxis === 'x' ? t.fa : 0), ay = t.ry + (t.fAxis === 'y' ? t.fa : 0);   // a flip turns the card about x or y
    const cxr = Math.cos(ax), sxr = Math.sin(ax);
    const cyr = Math.cos(ay), syr = Math.sin(ay);
    const sx = 1 + t.sc + t.sq * 0.6, sy = 1 + t.sc - t.sq;          // swell, and squash (wider as it flattens)
    const jx = t.jit ? (Math.random() * 2 - 1) * t.jit : 0, jy = t.jit ? (Math.random() * 2 - 1) * t.jit : 0;
    let attr = '';
    for (let i = 0; i < 4; i++) {
        const lx = CORNERS[i][0] * sx, ly = CORNERS[i][1] * sy;
        const x = lx + t.shear * (ly / CELL), y = ly;       // top edge slides left, bottom right
        const x1 = x * cz - y * sz, y1 = x * sz + y * cz;
        const y2 = y1 * cxr, z2 = y1 * sxr;
        const x3 = x1 * cyr + z2 * syr, z3 = -x1 * syr + z2 * cyr;
        const wx = t.hx + t.dx + jx + x3, wy = t.hy + t.dy + jy + y2, wz = t.z + z3;
        // the camera rolls, pitches and swings the whole grid, then zooms and shakes it
        const xr = wx * CAM.cr - wy * CAM.sr, yr = wx * CAM.sr + wy * CAM.cr;
        const yp = yr * CAM.cp - wz * CAM.sp, zp = yr * CAM.sp + wz * CAM.cp;
        const xq = xr * CAM.cy + zp * CAM.sy, zq = -xr * CAM.sy + zp * CAM.cy;
        const p = FOCAL / Math.max(150, FOCAL - zq) * CAM.k;
        const X = CX + CAM.shx + xq * p * S, Y = CY + CAM.shy + yp * p * S;
        t.pts[i][0] = X; t.pts[i][1] = Y;
        attr += `${X.toFixed(1)},${Y.toFixed(1)} `;
    }
    t.poly.setAttribute('points', attr);
    const c = Math.round(255 - 235 * t.ink);
    t.poly.style.stroke = `rgb(${c},${c},${c})`;
    t.poly.style.strokeWidth = (1.5 + 0.6 * t.ink + 0.7 * t.sheen).toFixed(2);
    // which way the card faces: its outline runs the other way round on screen when it shows its back
    let area = 0;
    for (let i = 0; i < 4; i++) { const a = t.pts[i], b = t.pts[(i + 1) % 4]; area += a[0] * b[1] - b[0] * a[1]; }
    const back = area * FRONT_SIGN < 0;
    const fill = back ? Math.max(0.82, t.fillv) : t.fillv;
    if (fill > 0.01) t.poly.style.fill = `rgba(20, 20, 20, ${(fill * (0.85 - (back ? 0.25 * t.sheen : 0))).toFixed(3)})`;
    else t.poly.style.fill = t.sheen > 0.01 ? `rgba(255, 255, 255, ${(0.2 * t.sheen).toFixed(3)})` : 'none';   // the sweep's light on a face-up card

    // the tile's empty home shows as a dashed ghost once it has wandered off
    const disp = Math.hypot(t.dx, t.dy) + Math.abs(t.z) * 0.3 + (Math.abs(t.rx) + Math.abs(t.ry) + Math.abs(t.rz)) * 30;
    t.ghost.style.opacity = Math.min(0.75, Math.max(0, (disp - 8) / 45, 0.45 * t.sheen)).toFixed(2);
}

const transportEl = $('transport');
const statusMain = $('statusMain'), statusSub = $('statusSub');
const hintTiles = $('hintTiles');
const SLOT = 19;
const geo = { left: 0, right: 0, top: { left: 0, right: 0 }, bottom: 0, gridTop: 0, gridBottom: 0, minX: 0, maxX: 0, topRows: 3 };

function idleSoundText() {
    if (!audioReady) return touchy ? 'tap.once.to.wake' : 'click.once.then.hover';
    return touchy ? 'slide.across.tiles' : 'hover.any.tile';
}

function setLine(line, x1, y1, x2, y2) {
    line.setAttribute('x1', x1.toFixed(1)); line.setAttribute('y1', y1.toFixed(1));
    line.setAttribute('x2', x2.toFixed(1)); line.setAttribute('y2', y2.toFixed(1));
}

function placeNote(el, x, y, align) {
    el.classList.toggle('right', align === 'right');
    el.classList.toggle('center', align === 'center');
    el.style.top = `${y}px`;
    if (align === 'right') { el.style.right = `${window.innerWidth - x}px`; el.style.left = 'auto'; }
    else { el.style.left = `${x}px`; el.style.right = 'auto'; }
}

const centreX = t => (t.pts[0][0] + t.pts[1][0] + t.pts[2][0] + t.pts[3][0]) / 4;
const centreY = t => (t.pts[0][1] + t.pts[1][1] + t.pts[2][1] + t.pts[3][1]) / 4;

function anchorOn(t, ax, ay) {
    let best = t.pts[0], bd = Infinity;
    for (let i = 0; i < 4; i++) {
        const a = t.pts[i], b = t.pts[(i + 1) % 4];
        for (const p of [a, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]]) {
            const d = (p[0] - ax) ** 2 + (p[1] - ay) ** 2;
            if (d < bd) { bd = d; best = p; }
        }
    }
    return best;
}

function placeColumn(list, side) {
    const top = geo.top[side];
    list.forEach(t => { t.want = Math.min(geo.bottom, Math.max(top, centreY(t))); });
    list.sort((a, b) => (a.shown ? a.ly : a.want) - (b.shown ? b.ly : b.want));
    let y = -Infinity;
    for (const t of list) { t.ty = Math.max(t.want, y + SLOT); y = t.ty; }
    let limit = geo.bottom;
    for (let i = list.length - 1; i >= 0; i--) { list[i].ty = Math.min(list[i].ty, limit); limit = list[i].ty - SLOT; }
    for (const t of list) t.tx = Math.max(4, Math.min(window.innerWidth - 4 - t.w, side === 'left' ? geo.left - t.w : geo.right));
}

function packRow(row) {
    let cursor = geo.minX;
    for (const t of row) { t.tx = Math.max(t.want - t.w / 2, cursor); cursor = t.tx + t.w + 10; }
    let limit = geo.maxX;
    for (let i = row.length - 1; i >= 0; i--) { row[i].tx = Math.min(row[i].tx, limit - row[i].w); limit = row[i].tx - 10; }
    let floor = geo.minX;
    for (const t of row) { t.tx = Math.max(t.tx, floor); floor = t.tx + t.w + 10; }
}
function placeRows(list, side) {
    list.forEach(t => { t.want = centreX(t); });
    const rows = [[], [], []];
    const room = geo.maxX - geo.minX;
    const used = r => rows[r].reduce((sum, t) => sum + t.w + 10, 0);
    for (const t of list) if (t.shown && t.row != null) rows[t.row].push(t);
    for (const t of list) {
        if (t.shown && t.row != null) continue;
        const lastRow = (side === 'top' ? geo.topRows : 3) - 1;
        let r = 0;
        while (r < lastRow && used(r) + t.w + 10 > room) r++;
        t.row = r;
        rows[r].push(t);
    }
    rows.forEach((row, r) => {
        row.sort((a, b) => (a.shown ? a.lx + a.w / 2 : a.want) - (b.shown ? b.lx + b.w / 2 : b.want));
        packRow(row);
        const y = side === 'top' ? geo.gridTop - 24 - r * 20 : geo.gridBottom + 24 + r * 20;
        row.forEach(t => { t.ty = y; });
    });
}

function drawCallout(t) {
    t.note.style.transform = `translate(${t.lx.toFixed(1)}px, ${t.ly.toFixed(1)}px)`;
    const o = Math.min(1, t.glow * 1.6);
    t.note.style.opacity = o.toFixed(2);
    let ex, ey;
    if (t.side === 'left')       { ex = t.lx + t.w + 8; ey = t.ly; }
    else if (t.side === 'right') { ex = t.lx - 8;       ey = t.ly; }
    else if (t.side === 'top')   { ex = t.lx + t.w / 2; ey = t.ly + 10; }
    else                         { ex = t.lx + t.w / 2; ey = t.ly - 11; }
    const [sx, sy] = anchorOn(t, ex, ey);
    setLine(t.line, sx, sy, ex, ey);
    t.line.style.opacity = (0.55 * o).toFixed(2);
}

function stepCallouts(dt) {
    const k = 1 - Math.exp(-dt / 0.03);
    const groups = {};
    for (const t of tiles) {
        if (t.glow > 0.03) (groups[t.side] ||= []).push(t);
        else if (t.shown) {
            t.shown = false;
            t.row = null;
            t.note.style.visibility = 'hidden';
            t.line.style.visibility = 'hidden';
        }
    }
    let moving = false;
    for (const side in groups) {
        const list = groups[side];
        if (wide) placeColumn(list, side); else placeRows(list, side);
        for (const t of list) {
            if (!t.shown) {
                t.shown = true;
                t.lx = t.tx; t.ly = t.ty;
                t.note.style.visibility = 'visible';
                t.line.style.visibility = 'visible';
            } else {
                t.lx += (t.tx - t.lx) * k;
                t.ly += (t.ty - t.ly) * k;
            }
            if (Math.abs(t.tx - t.lx) > 0.3 || Math.abs(t.ty - t.ly) > 0.3) moving = true;
            drawCallout(t);
        }
    }
    return moving;
}

function measureCallouts() {
    for (const t of tiles) t.w = t.note.offsetWidth;
}

let rafId = null, lastFrame = 0, lastWaveTime = 0;
const visualQueue = [];

function wake() {
    if (rafId) return;
    lastFrame = performance.now();
    lastWaveTime = lastFrame / 1000;
    rafId = requestAnimationFrame(frame);
}

// ─────────────────────────────────────────────
//  STAGE
//  The big moves. Notes in the score can carry stage cues (fx): formations that
//  reshape the whole grid, the energy of the passage, and camera moves. Drums move
//  the camera too, harder as the energy rises; at high energy, fast tiles leave afterimages.
// ─────────────────────────────────────────────
const stage = {
    forms: {},                          // formation → { from, to, t0, dur }: its weight, easing from one value to another
    phase: 0, spin: 0.35,               // rings and spirals turn at `spin` radians a second
    later: [],                          // formations a pulse cue will let go of
    energy: 0.3, eFrom: 0.3, eTo: 0.3, eT0: 0, eDur: 0,
    cam: { zoom: 0, vzoom: 0, roll: 0, vroll: 0, pitch: 0, vpitch: 0, yaw: 0, vyaw: 0, sx: 0, vsx: 0, sy: 0, vsy: 0 },
    active: false, camWasMoving: false, ready: false,
};
const CAM = { cr: 1, sr: 0, cp: 1, sp: 0, cy: 1, sy: 0, k: 1, shx: 0, shy: 0 };
const smooth = x => x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x);
// where we are in the music, in beats (so the pistons pump in time)
const beatNow = () => (player.playing && audioCtx ? audioCtx.currentTime - player.startTime : performance.now() / 1000) / (60 / cur.bpm);
const formWeight = (f, nowS) => f.from + (f.to - f.from) * smooth(f.dur > 0 ? (nowS - f.t0) / f.dur : 1);
const energyAt = nowS => stage.eFrom + (stage.eTo - stage.eFrom) * smooth(stage.eDur > 0 ? (nowS - stage.eT0) / stage.eDur : 1);

function rampForm(kind, to, secs, nowS) {
    const f = stage.forms[kind];
    stage.forms[kind] = { from: f ? formWeight(f, nowS) : 0, to, t0: nowS, dur: secs };
}
function releaseStage(secs, keepFlips) {
    const nowS = performance.now() / 1000;
    clearZone(nowS);
    stage.later.length = 0;
    flipQueue.length = 0;
    if (!keepFlips) queueFlips({ pattern: 'random', spread: 6, dur: 3, mode: 'front' }, nowS);
    for (const kind in stage.forms) rampForm(kind, 0, secs, nowS);
    stage.eFrom = energyAt(nowS); stage.eTo = 0.3; stage.eT0 = nowS; stage.eDur = secs;
    if (secs === 0) {
        sweeps.length = 0;
        cracks.length = 0;
        if (typeof crackPool !== 'undefined') for (const c of crackPool) c.style.display = 'none';
        zone.to = -1; zone.from = -1;
        for (const k in stage.cam) stage.cam[k] = 0;
        CAM.cr = 1; CAM.sr = 0; CAM.cp = 1; CAM.sp = 0; CAM.cy = 1; CAM.sy = 0; CAM.k = 1; CAM.shx = 0; CAM.shy = 0;
        for (const g of [ghostsG, marksG, playG]) g.removeAttribute('transform');
        stage.active = false;
        ghostsG.classList.remove('moving');
        for (const t of tiles) {
            t.fx = 0; t.fy = 0; t.fz = 0;
            t.fa = 0; t.fv = 0; t.fT = null; t.pend = null;
            t.sheen = 0; t.sheenDirty = false;
        }
    }
}

// a cue from the score: { form: { ring: 1, ... }, over: bars, spin, energy, cam: { zoom, roll, pitch, yaw, shake } }
function applyFx(fx, nowS) {
    const secs = (fx.over ?? 0) * cur.barLen;
    if (fx.form) for (const kind in fx.form) rampForm(kind, fx.form[kind], secs, nowS);
    if (fx.spin !== undefined) stage.spin = fx.spin;
    if (fx.energy !== undefined) { stage.eFrom = energyAt(nowS); stage.eTo = fx.energy; stage.eT0 = nowS; stage.eDur = secs; }
    if (fx.cam) kickCamera(fx.cam, 1, true);
    if (fx.flip) [].concat(fx.flip).forEach(f => queueFlips(f, nowS));
    if (fx.sweep) startSweep(nowS, fx.sweep === true ? {} : fx.sweep);
    // { pulse: { press: 0.4 }, attack, hold, release }: in fast, held a moment, out again (times in bars)
    if (fx.pulse && fx.pulse.fault) faultCrack(Math.sign(fx.pulse.fault));
    if (fx.form && (fx.form.cloud || 0) >= 0.5) { shatterCracks(); startSweep(nowS, { radial: true, dur: 0.9, strength: 0.9 }); }
    if (fx.zone !== undefined) setZone(fx.zone, nowS);
    if (fx.pulse) for (const kind in fx.pulse) {
        const up = (fx.attack ?? 0.05) * cur.barLen;
        rampForm(kind, fx.pulse[kind], up, nowS);
        stage.later.push({ at: nowS + up + (fx.hold ?? 0) * cur.barLen, kind, dur: (fx.release ?? 0.4) * cur.barLen });
    }
    wake();
}

// the camera springs back to rest after every push; drums push harder as the energy rises
function kickCamera(c, scale, fromScore) {
    if (reduceMotion) return;
    const cam = stage.cam, e = (fromScore ? 1 : 0.35 + 0.9 * stage.energy) * scale;
    if (c.zoom)  cam.vzoom  += c.zoom * 9 * e;
    if (c.roll)  cam.vroll  += c.roll * 3.6 * e * sign();
    if (c.pitch) cam.vpitch += c.pitch * 4.2 * e * sign();
    if (c.yaw)   cam.vyaw   += c.yaw * 4.2 * e * sign();
    if (c.shake) {
        const sway = smoothOn() ? 0.3 : 1;        // a tight grid sways gently rather than shaking
        cam.vsx += c.shake * 28 * e * sign() * sway; cam.vsy += c.shake * 20 * e * sign() * sway;
    }
    wake();
}

function initStage() {
    let i = 0, seed = 7;
    const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
    for (const k of [1, 2, 3]) for (const t of ringOrder[k]) {
        t.order = i++;
        t.angle = Math.atan2(t.hy, t.hx);
        // where this tile flies in an explosion: always outward from the centre, at a random depth
        const a = t.angle + (rnd() - 0.5) * 1.2, r = 180 + rnd() * 260;
        t.cloud = [Math.cos(a) * r, Math.sin(a) * r, (rnd() - 0.35) * 520];
    }
    for (const t of tiles) { t.fx = 0; t.fy = 0; t.fz = 0; }
    stage.ready = true;
}

// where each formation puts a tile, in grid units around the centre
const RING_R = [0, 100, 184, 270];
function formTarget(kind, t, nowS) {
    switch (kind) {
        case 'line':   return [t.hx, 0, 0];                                      // every row collapses into one: a flatline
        case 'ring': {                                                           // concentric rings, alternate rings turning opposite ways
            const a = t.angle + (t.ring % 2 ? 1 : -1) * stage.phase * (1 + 0.3 * (3 - t.ring)), R = RING_R[t.ring];
            return [R * Math.cos(a), R * Math.sin(a), 0];
        }
        case 'spiral': {
            const a = t.order * 0.43 + stage.phase, R = 26 + t.order * 5.6;
            return [R * Math.cos(a), R * Math.sin(a), 0];
        }
        case 'split':  return t.hx < 0 ? [t.hx - 150, t.hy, 0] : t.hx > 0 ? [t.hx + 150, t.hy, 0] : [0, t.hy * 1.75, 0];   // doors opening
        case 'tunnel': return [t.hx * 1.08, t.hy * 1.08, (t.ring - 1.4) * 240];  // the outer rings come toward you
        case 'wave': {                                                           // a wave rolling across the columns
            const ph = t.hx / 72 * 1.05 - nowS * 5.2;
            return [t.hx, t.hy + 70 * Math.sin(ph), 110 * Math.cos(ph)];
        }
        case 'cloud':  return t.cloud;                                           // blown apart
        case 'twist': {                                                          // wrung like a cloth: each row turned a little further, top and bottom opposite
            const a = (t.hy / GRID_HALF) * 0.95;
            return [t.hx * Math.cos(a), t.hy, t.hx * Math.sin(a) * 1.2];
        }
        case 'pistons': {                                                        // columns pumping toward you and away, neighbours opposite, on the beat
            const ph = beatNow() * Math.PI / (cur.bpm > 120 ? 2 : 1) + (t.c % 2) * Math.PI;   // once a bar at fast tempos, so they never get frantic
            return [t.hx, t.hy, 150 * Math.sin(ph)];
        }
        case 'press':  return [t.hx * 1.06, t.hy * 0.55, -60];                   // squeezed flat: the rows crushed together
        case 'fault': {                                                          // split along a diagonal fault, the halves sliding past each other
            const side = (t.hx + t.hy >= 0 ? 1 : -1) * 55;
            return [t.hx + side, t.hy - side, 0];
        }
        case 'settle': {                                                         // everything sinks to the floor of the grid, in layers
            const layer = 6 - t.r;
            return [t.hx * 1.05, GRID_HALF - 32 - layer * 22, -layer * 18];
        }
    }
    return [t.hx, t.hy, 0];
}

function camSpring(c, p, v, w, z, h) { c[v] += (-w * w * c[p] - 2 * z * w * c[v]) * h; c[p] += c[v] * h; }

function stepStage(dt, nowS) {
    if (!stage.ready) initStage();
    stage.phase += stage.spin * dt;
    stage.energy = energyAt(nowS);
    for (let i = stage.later.length - 1; i >= 0; i--) {
        const l = stage.later[i];
        if (l.at <= nowS) { rampForm(l.kind, 0, l.dur, nowS); stage.later.splice(i, 1); }
    }

    const live = [];
    for (const kind in stage.forms) {
        const f = stage.forms[kind], w = formWeight(f, nowS);
        if (f.to === 0 && nowS - f.t0 >= f.dur) { delete stage.forms[kind]; continue; }
        if (Math.abs(w) > 0.0005 || nowS - f.t0 < f.dur) live.push([kind, w * MOTION]);   // a ramp still under way counts, even from zero
    }
    for (const t of tiles) {
        let ox = 0, oy = 0, oz = 0;
        for (const [kind, w] of live) {
            const [x, y, z] = formTarget(kind, t, nowS);
            ox += w * (x - t.hx); oy += w * (y - t.hy); oz += w * z;
        }
        t.fx = ox; t.fy = oy; t.fz = oz;
    }

    const c = stage.cam, n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
    for (let i = 0; i < n; i++) {
        camSpring(c, 'zoom', 'vzoom', 7.5, 0.5, h);
        camSpring(c, 'roll', 'vroll', 3.6, 0.42, h);
        camSpring(c, 'pitch', 'vpitch', 4.2, 0.38, h);
        camSpring(c, 'yaw', 'vyaw', 4.2, 0.38, h);
        camSpring(c, 'sx', 'vsx', smoothOn() ? 8 : 28, smoothOn() ? 0.75 : 0.22, h);
        camSpring(c, 'sy', 'vsy', smoothOn() ? 8 : 28, smoothOn() ? 0.75 : 0.22, h);
    }
    c.zoom = Math.max(-0.45, Math.min(0.6, c.zoom));
    c.roll = Math.max(-1.2, Math.min(1.2, c.roll));
    c.pitch = Math.max(-1.0, Math.min(1.0, c.pitch));
    c.yaw = Math.max(-1.0, Math.min(1.0, c.yaw));
    const moving = Math.abs(c.zoom) + Math.abs(c.roll) + Math.abs(c.pitch) + Math.abs(c.yaw) > 2e-4
                || Math.abs(c.sx) + Math.abs(c.sy) > 0.05
                || Math.abs(c.vzoom) + Math.abs(c.vroll) + Math.abs(c.vpitch) + Math.abs(c.vyaw) > 2e-3;
    if (!moving) for (const key in c) c[key] = 0;
    CAM.cr = Math.cos(c.roll); CAM.sr = Math.sin(c.roll);
    CAM.cp = Math.cos(c.pitch); CAM.sp = Math.sin(c.pitch);
    CAM.cy = Math.cos(c.yaw); CAM.sy = Math.sin(c.yaw);
    CAM.k = 1 + c.zoom; CAM.shx = c.sx; CAM.shy = c.sy;

    // the empty grid, the corner marks and the play button ride the camera too (in the flat)
    if (moving || stage.camWasMoving) {
        const tr = moving ? `translate(${(CX + c.sx).toFixed(1)} ${(CY + c.sy).toFixed(1)}) scale(${CAM.k.toFixed(4)}) rotate(${(c.roll * 180 / Math.PI).toFixed(2)}) translate(${-CX} ${-CY})` : '';
        for (const g of [ghostsG, marksG, playG]) tr ? g.setAttribute('transform', tr) : g.removeAttribute('transform');
    }
    stage.camWasMoving = moving;
    const active = live.length > 0 || moving;
    if (active !== stage.active) ghostsG.classList.toggle('moving', active);
    stage.active = active;
    return stage.active;
}

// afterimages: at high energy, a fast tile leaves fading copies of itself behind
const trailsG = $('trails');
const trailPool = [], trails = [];
function stepTrails(nowMs) {
    if (stage.energy >= 0.7 && !reduceMotion) {
        for (const t of tiles) {
            const speed = Math.abs(t.vx) + Math.abs(t.vy) + Math.abs(t.vz) * 0.4 + (Math.abs(t.wx) + Math.abs(t.wy) + Math.abs(t.wz)) * 40;
            if (speed > 800 && nowMs - (t.lastTrail || 0) > 80 && trails.length < 40) {
                const el = trailPool.pop() || svgEl('polygon', { class: 'trail' }, trailsG);
                el.setAttribute('points', t.poly.getAttribute('points'));
                el.style.display = '';
                trails.push({ el, start: nowMs, life: 300, peak: Math.min(0.55, speed / 2200) });
                t.lastTrail = nowMs;
            }
        }
    }
    for (let i = trails.length - 1; i >= 0; i--) {
        const r = trails[i], p = (nowMs - r.start) / r.life;
        if (p >= 1) { r.el.style.display = 'none'; trailPool.push(r.el); trails.splice(i, 1); continue; }
        r.el.style.strokeOpacity = (r.peak * (1 - p) * (1 - p)).toFixed(3);   // fading the stroke alone is far cheaper than element opacity
    }
    return trails.length > 0;
}

// ─────────────────────────────────────────────
//  FLIPS
//  Every tile is a two-sided card: its back face is solid ink. A score cue
//  ({ flip: { pattern, mode, axis, gap, dur, ... } }) flips cards across the grid in a
//  pattern, each card starting `gap` steps after the one before; the grid itself never moves.
//    pattern: all, sweepX, sweepY, rings, diag, checker, spiral, random, row (driven by `cells`), col
//    mode:    toggle (default), back, front, spin (a full turn, same face)
// ─────────────────────────────────────────────
const TAU = Math.PI * 2;
const flipQueue = [];

function flipOrder(t, f) {                         // when this card flips, in units of `gap` (null: not part of the pattern)
    const r = t.r, c = t.c;
    switch (f.pattern) {
        case 'all':     return 0;
        case 'sweepX':  return f.reverse ? 6 - c : c;
        case 'sweepY':  return f.reverse ? 6 - r : r;
        case 'rings':   return f.reverse ? 3 - t.ring : t.ring - 1;
        case 'diag':    return f.reverse ? 12 - (r + c) : r + c;
        case 'checker': return (r + c) % 2 === (f.odd ? 1 : 0) ? 0 : null;
        case 'spiral':  return (f.reverse ? 47 - t.order : t.order) / 6;
        case 'random':  return Math.random() * (f.spread ?? 8);
        case 'row':     return r === f.row ? c : null;
        case 'col':     return c === f.col ? r : null;
    }
    return 0;
}
function queueFlips(f, nowS) {
    if (!stage.ready) initStage();
    const stepS = cur.step;
    for (const t of tiles) {
        const o = flipOrder(t, f);
        if (o === null || !isFinite(o)) continue;
        // a row cue paints a generation of the automaton: live cells face down (ink), dead cells face up
        const mode = f.cells ? (f.cells[Math.min(15, Math.round(t.c * 15 / 6))] ? 'back' : 'front') : (f.mode || 'toggle');
        flipQueue.push({ t, at: nowS + o * (f.gap ?? 1) * stepS, dur: (f.dur ?? 2) * stepS, mode, axis: f.axis || 'y', turns: f.turns || 1 });
    }
    flipQueue.sort((a, b) => a.at - b.at);
    wake();
}
function flipTarget(t, mode, turns) {              // the angle a flip ends on: always flat, face up or face down
    // measured from where the card is heading (a flip already under way), so it never ends tilted
    const base = t.fT !== null ? t.fT : Math.round(t.fa / Math.PI) * Math.PI;
    const back = Math.cos(base) < 0, extra = turns > 1 ? TAU : 0;
    switch (mode) {
        case 'back':  return back ? base + extra : base + Math.PI + extra;
        case 'front': return back ? base + Math.PI + extra : base + extra;
        case 'spin':  return base + TAU;
        default:      return base + Math.PI + extra;    // toggle
    }
}
function startFlip(t, q, nowS) {
    if (t.fT === null && q.axis !== t.fAxis) {     // at rest, a card can change axis: its back looks the same either way
        t.fa = Math.cos(t.fa) < 0 ? Math.PI : 0;
        t.fAxis = q.axis;
    }
    const to = flipTarget(t, q.mode, q.turns);
    if (Math.abs(to - t.fa) < 1e-3 && t.fT === null) return;
    if (reduceMotion) { t.fa = ((to % TAU) + TAU) % TAU; t.fv = 0; t.fT = null; t.resting = false; return; }
    t.fT = to;
    // a longer flip is a softer spring; very short flips are lengthened so nothing snaps
    t.fw = Math.max(4, Math.min(11, 5.5 / Math.max(0.3, q.dur)));
    t.resting = false;
}
function stepFlips(nowS) {
    let n = 0;
    while (n < flipQueue.length && flipQueue[n].at <= nowS) { startFlip(flipQueue[n].t, flipQueue[n], nowS); n++; }
    if (n) flipQueue.splice(0, n);
    let any = flipQueue.length > 0;
    const dt = Math.min(0.05, Math.max(0.001, nowS - (stepFlips.last || nowS)));
    stepFlips.last = nowS;
    const substeps = Math.max(1, Math.ceil(dt * 240)), h = dt / substeps;
    for (const t of tiles) {
        if (t.fT === null) continue;
        for (let i = 0; i < substeps; i++) {                 // a nearly critically damped spring: it eases in, eases out, overshoots only a hair
            t.fv += (-t.fw * t.fw * (t.fa - t.fT) - 2 * 0.86 * t.fw * t.fv) * h;
            t.fa += t.fv * h;
        }
        if (Math.abs(t.fa - t.fT) < 2e-3 && Math.abs(t.fv) < 2e-2) {
            const wrap = Math.floor(t.fT / TAU) * TAU;   // settle exactly, and keep the numbers small
            t.fa = t.fT - wrap; t.fv = 0; t.fT = null;
        } else any = true;
        t.resting = false;
    }
    return any;
}

// ─────────────────────────────────────────────
//  SWEEPS
//  At moments of intensity a soft band of light passes over the grid: each tile it crosses
//  takes a faint sheen, and the empty grid glows under it. Nothing moves; it is only light.
//  A sweep comes from a heavy accent when the energy is high (kit option sweep: true),
//  or from a score cue (fx.sweep).
// ─────────────────────────────────────────────
const sweeps = [];
const SWEEP_ANGLES = [-0.55, Math.PI - 0.35, Math.PI / 2 + 0.25, -Math.PI / 2 - 0.4, 0.3, Math.PI + 0.6];   // each one from somewhere else
let sweepTurn = 0, lastSweep = -100;
function startSweep(nowS, o = {}) {
    if (sweeps.length >= 3 && !o.radial) return;
    sweeps.push({ t0: nowS, dur: o.dur ?? 1.2, angle: o.angle ?? SWEEP_ANGLES[sweepTurn++ % SWEEP_ANGLES.length], strength: o.strength ?? 1, radial: !!o.radial });
    lastSweep = nowS;
    wake();
}
function sweepOnAccent(nowS) {
    // only when the music is intense, and not too often
    if (stage.energy >= 0.7 && nowS - lastSweep > 4.5) startSweep(nowS, { strength: 0.8 });
}
function stepSweeps(nowS) {
    for (let i = sweeps.length - 1; i >= 0; i--) if (nowS - sweeps[i].t0 > sweeps[i].dur) sweeps.splice(i, 1);
    const reach = GRID_HALF * 1.45;
    for (const t of tiles) {
        let light = 0;
        for (const w of sweeps) {
            const p = (nowS - w.t0) / w.dur, fade = Math.min(1, p * 6, (1 - p) * 6);   // no light pops in or out
            if (w.radial) {                                                             // a ring of light racing outward from the centre
                const u = Math.hypot(t.hx, t.hy) / (GRID_HALF * 1.42), front = -0.15 + 1.4 * (1 - Math.pow(1 - p, 2));
                light = Math.max(light, w.strength * fade * Math.exp(-Math.pow((u - front) / 0.13, 2)));
            } else {
                const front = -1.35 + 2.7 * smooth(p);                                  // the band crosses from edge to edge, easing in and out
                const u = (t.hx * Math.cos(w.angle) + t.hy * Math.sin(w.angle)) / reach;  // where this tile sits along the sweep
                light = Math.max(light, w.strength * fade * Math.exp(-Math.pow((u - front) / 0.24, 2)));
            }
        }
        light = Math.max(light, zoneGlow(t, nowS));
        t.sheenDirty = Math.abs(light - t.sheen) > 0.004 || (light === 0 && t.sheen !== 0);
        t.sheen = light < 0.004 ? 0 : light;
    }
    return sweeps.length > 0 || zoneBusy(nowS);
}

// ─────────────────────────────────────────────
//  ZONES
//  The grid is four zones, one for each chord of the progression. When the bass moves to a new chord,
//  its zone takes a faint glow and the last one fades: the light travels round the grid with the harmony.
// ─────────────────────────────────────────────
const ZONE_DIRS = [[-1, -1], [1, -1], [1, 1], [-1, 1]];          // g minor top left, e-flat top right, c minor bottom right, d bottom left
const zone = { from: -1, to: -1, t0: 0, fade: 0.9, level: 0.8 };
function setZone(i, nowS) { if (i === zone.to) return; zone.from = zone.to; zone.to = i; zone.t0 = nowS; wake(); }
function clearZone(nowS) { if (zone.to < 0) return; zone.from = zone.to; zone.to = -1; zone.t0 = nowS; wake(); }
function zoneShape(i, t) {                                        // how much of the zone's light this tile catches: strongest in its corner
    if (i < 0) return 0;
    const [dx, dy] = ZONE_DIRS[i];
    return Math.max(0, (t.hx * dx + t.hy * dy) / (GRID_HALF * 1.45) + 0.12);
}
function zoneGlow(t, nowS) {
    const p = Math.min(1, (nowS - zone.t0) / zone.fade), mix = smooth(p);
    return zone.level * (zoneShape(zone.to, t) * mix + zoneShape(zone.from, t) * (1 - mix));
}
const zoneBusy = nowS => nowS - zone.t0 < zone.fade + 0.1;

// ─────────────────────────────────────────────
//  CRACKS
//  When the grid cracks along a fault, a jagged line of light draws itself along the fault and fades.
//  A quake sends cracks out from the centre in every direction.
// ─────────────────────────────────────────────
const cracksG = $('cracks');
const cracks = [], crackPool = [];
function project(wx, wy, wz = 0) {                                // a point on the grid, through the camera, to the screen
    const xr = wx * CAM.cr - wy * CAM.sr, yr = wx * CAM.sr + wy * CAM.cr;
    const yp = yr * CAM.cp - wz * CAM.sp, zp = yr * CAM.sp + wz * CAM.cp;
    const xq = xr * CAM.cy + zp * CAM.sy, zq = -xr * CAM.sy + zp * CAM.cy;
    const p = FOCAL / Math.max(150, FOCAL - zq) * CAM.k;
    return [CX + CAM.shx + xq * p * S, CY + CAM.shy + yp * p * S];
}
function jagged(x0, y0, x1, y1, n, rough) {                       // a broken line between two points
    const pts = [[x0, y0]], len = Math.hypot(x1 - x0, y1 - y0), nx = -(y1 - y0) / len, ny = (x1 - x0) / len;
    for (let i = 1; i < n; i++) {
        const f = i / n, off = (Math.random() * 2 - 1) * rough * Math.sin(Math.PI * f);
        pts.push([x0 + (x1 - x0) * f + nx * off, y0 + (y1 - y0) * f + ny * off]);
    }
    pts.push([x1, y1]);
    return pts;
}
function addCrack(pts, o = {}) {
    if (cracks.length >= 14 || reduceMotion) return;
    const el = crackPool.pop() || svgEl('polyline', { class: 'crack' }, cracksG);
    el.style.display = '';
    cracks.push({ el, pts, t0: performance.now() / 1000 + (o.delay || 0), draw: o.draw ?? 0.12, life: o.life ?? 0.95, width: o.width ?? 1.8, peak: o.peak ?? 0.85 });
    wake();
}
function faultCrack(dir) {                                        // along the diagonal the grid slides on, with a few forks
    const r = GRID_HALF * 1.12, main = jagged(-r, r, r, -r, 16, 16);
    addCrack(main, { width: 2 });
    for (let i = 0; i < 3; i++) {
        const [x, y] = main[3 + Math.floor(Math.random() * 10)], a = Math.atan2(-1, 1) + (Math.random() < 0.5 ? 1 : -1) * (0.6 + Math.random() * 0.5) + (dir < 0 ? Math.PI : 0);
        const L = 40 + Math.random() * 70;
        addCrack(jagged(x, y, x + Math.cos(a) * L, y + Math.sin(a) * L, 5, 7), { width: 1.2, delay: 0.06, peak: 0.6 });
    }
}
function shatterCracks() {                                         // a quake: cracks shoot out from the centre
    const n = 6 + Math.floor(Math.random() * 3), base = Math.random() * Math.PI * 2;
    for (let i = 0; i < n; i++) {
        const a = base + i * (Math.PI * 2 / n) + (Math.random() - 0.5) * 0.5, L = GRID_HALF * (0.8 + Math.random() * 0.5);
        addCrack(jagged(Math.cos(a) * 30, Math.sin(a) * 30, Math.cos(a) * L, Math.sin(a) * L, 10, 14), { width: 1.6, draw: 0.16, life: 1.1, delay: i * 0.015 });
    }
}
function stepCracks(nowS) {
    for (let i = cracks.length - 1; i >= 0; i--) {
        const c = cracks[i], age = nowS - c.t0;
        if (age < 0) { c.el.style.display = 'none'; continue; }
        if (age >= c.life) { c.el.style.display = 'none'; crackPool.push(c.el); cracks.splice(i, 1); continue; }
        const shown = Math.min(1, age / c.draw) * (c.pts.length - 1);  // it draws itself along its length, then fades
        const whole = Math.floor(shown), pts = c.pts.slice(0, whole + 1);
        if (whole < c.pts.length - 1) {
            const f = shown - whole, [ax, ay] = c.pts[whole], [bx, by] = c.pts[whole + 1];
            pts.push([ax + (bx - ax) * f, ay + (by - ay) * f]);
        }
        c.el.setAttribute('points', pts.map(([x, y]) => project(x, y).map(v => v.toFixed(1)).join(',')).join(' '));
        c.el.style.display = '';
        c.el.style.strokeWidth = c.width;
        c.el.style.strokeOpacity = (c.peak * (1 - smooth((age - c.draw) / (c.life - c.draw)))).toFixed(3);
    }
    return cracks.length > 0;
}

function springTo(t, p, v, target, w, z, h) {   // like spring, but toward a target instead of home
    t[v] += (-w * w * (t[p] - target) - 2 * z * w * t[v]) * h;
    t[p] += t[v] * h;
}

function spring(t, p, v, w, z, h) {           // every spring pulls toward home (0)
    t[v] += (-w * w * t[p] - 2 * z * w * t[v]) * h;
    t[p] += t[v] * h;
}

function stepTile(t, dt, audioNow, nowMs) {
    const held = t.manual || nowMs < t.keyUntil || audioNow < t.songUntil;
    if (t.resting && !held && !stage.active) return false;

    // a loose tile re-attaches once it has drifted back close to home and stopped tumbling
    if (t.loose && nowMs - t.looseSince > 700) {
        const near = Math.hypot(t.dx, t.dy) < 10 && Math.abs(t.z) < 12 && Math.hypot(t.vx, t.vy) < 40
                  && Math.abs(t.rx) + Math.abs(t.ry) + Math.abs(t.rz) < 0.25;
        const tooLong = nowMs - t.looseSince > 9000;
        if (near || tooLong) { t.loose = false; if (tooLong) t.recallUntil = nowMs + 900; }
    }
    const recalling = nowMs < t.recallUntil;

    // loose tiles hang on a slack spring; attached tiles are held taut
    const pw = t.loose ? 1.8 : recalling ? 6 : cur.tight ? 22 : 13, pz = t.loose ? 0.35 : recalling ? 0.8 : cur.tight ? 0.5 : 0.32;
    const aw = t.loose ? 1.3 : recalling ? 5 : smoothOn() ? 9 : 11,  az = t.loose ? 0.35 : recalling ? 0.8 : smoothOn() ? 0.75 : 0.45;
    const sw = held ? (smoothOn() ? 15 : 38) : (smoothOn() ? 11 : 15), sz = held ? (smoothOn() ? 0.9 : 0.62) : (smoothOn() ? 0.85 : 0.4);

    const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
    for (let i = 0; i < n; i++) {
        if (t.pend) feedKicks(t, h);
        springTo(t, 'dx', 'vx', t.fx || 0, pw, pz, h);      // home, or wherever the formation wants this tile
        springTo(t, 'dy', 'vy', t.fy || 0, pw, pz, h);
        springTo(t, 'z',  'vz', t.fz || 0, pw, pz, h);
        spring(t, 'rx', 'wx', aw, az, h);
        spring(t, 'ry', 'wy', aw, az, h);
        spring(t, 'rz', 'wz', aw, az, h);
        spring(t, 'sc', 'vsc', smoothOn() ? 11 : 16, smoothOn() ? 0.6 : 0.28, h);   // swell and shrink bounce back (settle calmly in a tight grid)
        spring(t, 'sq', 'vsq', smoothOn() ? 13 : 20, smoothOn() ? 0.6 : 0.3, h);    // so does a squash
        // shear springs toward the held amount while the note sounds
        const target = held ? t.amp : 0;
        t.vs += (sw * sw * (target - t.shear) - 2 * sz * sw * t.vs) * h;
        t.shear += t.vs * h;
    }
    t.ink = held ? 1 : t.ink * Math.exp(-dt / 0.09);
    t.glow = held ? 1 : t.glow * Math.exp(-dt / 0.22);   // the label lingers a moment after the note
    t.jit *= Math.exp(-dt / 0.12);
    t.fillv *= Math.exp(-dt / t.fillTau);

    if (!held && !t.loose && !recalling && !stage.active && t.glow < 0.03
        && Math.abs(t.dx) < 0.05 && Math.abs(t.dy) < 0.05 && Math.abs(t.z) < 0.05
        && Math.abs(t.vx) < 0.5 && Math.abs(t.vy) < 0.5 && Math.abs(t.vz) < 0.5
        && Math.abs(t.rx) < 5e-4 && Math.abs(t.ry) < 5e-4 && Math.abs(t.rz) < 5e-4
        && Math.abs(t.wx) < 5e-3 && Math.abs(t.wy) < 5e-3 && Math.abs(t.wz) < 5e-3
        && Math.abs(t.shear) < 0.05 && Math.abs(t.vs) < 0.5 && t.ink < 0.01
        && Math.abs(t.sc) < 0.002 && Math.abs(t.vsc) < 0.02 && Math.abs(t.sq) < 0.002 && Math.abs(t.vsq) < 0.02
        && t.jit < 0.05 && t.fillv < 0.01 && t.fT === null && !t.pend) {
        t.dx = t.dy = t.z = t.vx = t.vy = t.vz = 0;
        t.rx = t.ry = t.rz = t.wx = t.wy = t.wz = 0;
        t.shear = t.vs = t.ink = t.glow = 0;
        t.sc = t.vsc = t.sq = t.vsq = t.jit = t.fillv = 0;
        t.resting = true;
    } else {
        t.resting = false;
    }
    drawTile(t);
    return !t.resting;
}

function frame(nowMs) {
    const dt = Math.min(0.05, Math.max(0.001, (nowMs - lastFrame) / 1000));
    lastFrame = nowMs;
    const nowS = nowMs / 1000;
    const audioNow = audioCtx ? audioCtx.currentTime - outputLatency(audioCtx) : 0;

    while (visualQueue.length && visualQueue[0].t <= audioNow) {
        const v = visualQueue.shift();
        if (v.fx) applyFx(v.fx, nowS);               // stage cues always apply, even for notes missed while the tab was hidden
        if (audioNow > v.until) continue;            // missed while the tab was hidden
        v.tile.songUntil = v.until;
        strike(v.tile, v.gain);
    }

    applyWaves(nowS, lastWaveTime, nowMs);
    lastWaveTime = nowS;
    let busy = stepStage(dt, nowS);
    busy = stepFlips(nowS) || busy;
    busy = stepSweeps(nowS) || busy;
    busy = stepCracks(nowS) || busy;
    busy = waves.length > 0 || busy;

    for (const t of tiles) busy = stepTile(t, dt, audioNow, nowMs) || busy;
    for (const t of tiles) if (t.sheenDirty && t.resting) drawTile(t);   // a still tile still catches the light
    busy = stepRings(nowMs) || busy;
    busy = stepTrails(nowMs) || busy;
    busy = stepCallouts(dt) || busy;

    if (player.playing) { updateReadout(audioNow); busy = true; }
    if (visualQueue.length) busy = true;

    rafId = busy ? requestAnimationFrame(frame) : null;
}

let manualTile = null;
let pressing = false;

function tileAt(clientX, clientY) {
    const el = document.elementFromPoint(clientX, clientY);
    const idx = el && el.getAttribute && el.getAttribute('data-tile');
    return idx != null ? tiles[+idx] : null;
}

function playByHand(t) {
    strike(t, t.vel);
    soundTile(t);
}

function setManual(t) {
    if (t === manualTile) return;
    if (manualTile) manualTile.manual = false;
    manualTile = t;
    if (t) { t.manual = true; playByHand(t); }
    wake();
}

function track(e) {
    if (e.pointerType === 'mouse' || pressing) setManual(tileAt(e.clientX, e.clientY));
}

stageEl.addEventListener('pointerdown', e => {
    unlockAudio();
    if (e.pointerType !== 'mouse') {
        pressing = true;
        if (e.target.hasPointerCapture && e.target.hasPointerCapture(e.pointerId)) {
            e.target.releasePointerCapture(e.pointerId);
        }
    }
    track(e);
});
stageEl.addEventListener('pointermove', track);
['pointerup', 'pointercancel'].forEach(type => stageEl.addEventListener(type, e => {
    if (e.pointerType !== 'mouse') { pressing = false; setManual(null); }
}));
stageEl.addEventListener('pointerleave', () => { pressing = false; setManual(null); });

hitsG.addEventListener('keydown', e => {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    const idx = e.target.getAttribute('data-tile');
    if (idx == null) return;
    e.preventDefault();
    const t = tiles[+idx];
    t.keyUntil = performance.now() + 220;
    playByHand(t);
});

const soundBtn = $('soundBtn'), soundLabel = $('soundLabel');
let wakingPress = false;

function refreshSoundStatus() {
    soundLabel.textContent = !audioReady ? (touchy ? 'tap.to.wake.audio' : 'click.to.wake.audio') : muted ? 'sound.off' : 'sound.on';
    soundBtn.classList.toggle('on', audioReady && !muted);
    soundBtn.setAttribute('aria-pressed', String(audioReady && !muted));
}

let silentClip = null;
function startSilentClip() {
    try {
        if (!silentClip) {
            silentClip = new Audio('data:audio/wav;base64,UklGRkQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==');
            silentClip.loop = true;
            silentClip.setAttribute('playsinline', '');
        }
        const p = silentClip.play();
        if (p && p.catch) p.catch(() => {});
    } catch (e) { }
}

function unlockAudio() {
    if (audioReady) return;
    const ctx = getCtx();
    getMaster();
    if (ctx.state === 'suspended') ctx.resume();
    startSilentClip();
    audioReady = true;
    refreshSoundStatus();
    hintTiles.textContent = idleSoundText();
}

['pointerdown', 'touchstart', 'keydown'].forEach(evt =>
    document.addEventListener(evt, unlockAudio, { passive: true })
);

['pointerdown', 'keydown'].forEach(evt => soundBtn.addEventListener(evt, () => { wakingPress = !audioReady; }));
soundBtn.addEventListener('click', () => {
    if (wakingPress) { wakingPress = false; return; }
    muted = !muted;
    getMaster._out.gain.setTargetAtTime(muted ? 0 : MASTER_LEVEL, getCtx().currentTime, 0.02);
    refreshSoundStatus();
});

const hintSpace = $('hintSpace');
const player = { playing: false, starting: false, startTime: 0, endTime: 0, idx: 0, bus: null, timer: null };
const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

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 < cur.events.length) {
        const ev = cur.events[player.idx];
        const t = player.startTime + timeOf(cur, ev.at);
        if (t > horizon) break;
        player.idx++;

        const tile = TILE[ev.id];
        const gain = ev.v ?? tile.vel;
        const out = ctx.createGain();
        out.gain.value = gain;
        out.connect(player.bus);
        tile.voice(ctx, out, t);
        visualQueue.push({ t, tile, gain, fx: ev.fx, until: t + Math.max(0.11, ev.len * cur.step - 0.03) });
    }
    if (player.idx >= cur.events.length && ctx.currentTime >= player.endTime) finish();
}

let lastReadout = '';
function updateReadout(audioNow) {
    const elapsed = Math.min(cur.length, Math.max(0, audioNow - player.startTime));
    const bar = Math.min(cur.bars - 1, Math.floor(elapsed / cur.barLen));
    const sec = cur.sections.find(s => bar >= s.from && bar < s.to);
    const text = `${sec ? sec.name : ''}|${fmt(elapsed)} / ${fmt(cur.length)}`;
    if (text !== lastReadout) {
        lastReadout = text;
        statusMain.textContent = cur.title;
        statusSub.textContent = `${sec ? sec.name : ''}  ${fmt(elapsed)} / ${fmt(cur.length)}`;
    }
    playTrace.style.strokeDasharray = `${(elapsed / cur.length * playPerimeter).toFixed(2)} ${playPerimeter.toFixed(2)}`;
}

function drawPlayIcon() {
    const r = Math.max(3, HALF * S * 0.32), on = stageEl.classList.contains('playing');
    playIcon.setAttribute('d', on
        ? `M${CX - r * 0.8} ${CY - r * 0.8}h${r * 1.6}v${r * 1.6}h${-r * 1.6}z`
        : `M${CX - r * 0.7} ${CY - r}L${CX + r} ${CY}L${CX - r * 0.7} ${CY + r}z`);
}

function setPlayingUI(on) {
    stageEl.classList.toggle('playing', on);
    playWord.textContent = on ? 'stop' : 'play';
    drawPlayIcon();
    playHit.setAttribute('aria-label', on ? `Stop ${cur.title}` : `Play ${cur.title}`);
    hintSpace.textContent = on ? 'press.space.to.stop' : 'press.space.to.play';
    if (!on) {
        statusMain.textContent = cur.title;
        statusSub.textContent = `${cur.meta ? cur.meta + ', ' : (cur.bpm ? cur.bpm + ' bpm, ' : '')}${fmt(cur.length)}`;
        playTrace.style.strokeDasharray = '';
        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) { }
    player.starting = false;

    player.bus = ctx.createGain();
    player.bus.gain.value = 0.9;
    player.bus.connect(roomFor(cur));
    player.startTime = ctx.currentTime + 0.12;
    player.endTime = player.startTime + cur.length + cur.tail;
    player.idx = 0;
    player.playing = true;
    flipQueue.length = 0;
    queueFlips({ pattern: 'all', mode: 'front', dur: 2 }, performance.now() / 1000);   // every card starts face up
    holdScreen(true);
    setPlayingUI(true);

    scheduleAhead();
    player.timer = setInterval(scheduleAhead, 25);
    wake();
}

let wakeLock = null;
async function holdScreen(on) {
    try {
        if (on && !wakeLock && 'wakeLock' in navigator) {
            wakeLock = await navigator.wakeLock.request('screen');
            wakeLock.addEventListener('release', () => { wakeLock = null; });
        } else if (!on && wakeLock) {
            await wakeLock.release();
            wakeLock = null;
        }
    } catch (e) { wakeLock = null; }
}

document.addEventListener('visibilitychange', () => {
    if (document.hidden) { if (silentClip) silentClip.pause(); return; }
    if (audioReady) {
        startSilentClip();
        if (audioCtx && audioCtx.state !== 'running') audioCtx.resume();
    }
    if (player.playing) holdScreen(true);
});

function halt() {
    holdScreen(false);
    clearInterval(player.timer);
    player.timer = null;
    player.playing = false;
}

function stop() {
    if (!player.playing) return;
    halt();
    const bus = player.bus;
    bus.gain.setTargetAtTime(0, getCtx().currentTime, 0.02);
    setTimeout(() => bus.disconnect(), 400);
    visualQueue.length = 0;
    tiles.forEach(t => { t.songUntil = -1; });
    releaseStage(0.9);
    setPlayingUI(false);
    wake();
}

function finish() {
    halt();
    const bus = player.bus;
    setTimeout(() => bus.disconnect(), 1500);
    releaseStage(2.5, true);                         // the song's last flips stay where it left them
    setPlayingUI(false);
    wake();
}

function toggle() { player.playing ? stop() : play(); }

const songsNav = $('songs');
SONGS.forEach((song, i) => {
    const b = document.createElement('button');
    b.type = 'button';
    b.innerHTML = `<i aria-hidden="true"></i><span>${String(i + 1).padStart(2, '0')}</span><span class="name">${song.title}</span>`;
    b.setAttribute('aria-label', song.title);
    b.addEventListener('click', () => selectSong(i));
    songsNav.appendChild(b);
});
function refreshNav() {
    [...songsNav.children].forEach((b, i) => b.setAttribute('aria-current', String(SONGS[i] === cur)));
    if (typeof sheetList !== 'undefined') [...sheetList.querySelectorAll('button')].forEach((b, i) => b.setAttribute('aria-current', String(SONGS[i] === cur)));
    document.title = `${cur.title} · OwnZones`;
}

const sheetEl = $('sheet'), sheetList = $('sheetList');
SONGS.forEach((song, i) => {
    const li = document.createElement('li'), b = document.createElement('button');
    b.type = 'button';
    b.innerHTML = `<i aria-hidden="true"></i><span class="num">${String(i + 1).padStart(2, '0')}</span>`
                + `<span class="what"><span>${song.title}</span><span class="meta">${song.meta}, ${fmt(song.length)}</span></span>`;
    b.addEventListener('click', () => { selectSong(i); closeSheet(); });
    li.appendChild(b); sheetList.appendChild(li);
});
let sheetOpener = null;
function openSheet() {
    sheetOpener = document.activeElement;
    sheetEl.hidden = false;
    const current = sheetList.querySelector('[aria-current="true"]') || sheetList.querySelector('button');
    current.focus();
}
function closeSheet() {
    if (sheetEl.hidden) return;
    sheetEl.hidden = true;
    if (sheetOpener && sheetOpener.focus) sheetOpener.focus();
}
$('sheetClose').addEventListener('click', closeSheet);
sheetEl.addEventListener('click', e => { if (e.target === sheetEl) closeSheet(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });

const stepTrack = d => selectSong(SONGS.indexOf(cur) + d);
document.documentElement.classList.toggle('single', SONGS.length < 2);
if (SONGS.length < 2) $('trackNow').setAttribute('aria-label', 'Track details and instructions');
$('prevTrack').addEventListener('click', () => stepTrack(-1));
$('nextTrack').addEventListener('click', () => stepTrack(1));
$('trackNow').addEventListener('click', () => { if (!swipedRecently()) openSheet(); });

let swipe = null, lastSwipe = 0;
const swipedRecently = () => performance.now() - lastSwipe < 400;
document.addEventListener('pointerdown', e => {
    swipe = null;
    if (e.pointerType === 'mouse' || !sheetEl.hidden) return;
    if (tileAt(e.clientX, e.clientY) || (e.target.closest && e.target.closest('[data-play], .sound, .step'))) return;
    swipe = { x: e.clientX, y: e.clientY, t: performance.now() };
}, { passive: true });
document.addEventListener('pointerup', e => {
    if (!swipe) return;
    const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y, dt = performance.now() - swipe.t;
    swipe = null;
    if (Math.abs(dx) > 60 && Math.abs(dx) > 2 * Math.abs(dy) && dt < 700) {
        lastSwipe = performance.now();
        stepTrack(dx < 0 ? 1 : -1);
    }
}, { passive: true });

function selectSong(i, notifyParent = true) {
    const next = SONGS[(i + SONGS.length) % SONGS.length];
    if (next === cur) return;
    const resume = player.playing;
    if (player.playing) stop();
    cur = next;
    applyKit(cur);
    measureCallouts();
    refreshNav();
    setPlayingUI(false);
    releaseStage(0);
    emitWave({ x: 0, y: 0, push: 0, reach: 420, spin: 6, lift: 30 });
    wake();
    if (notifyParent) {
        try {
            window.parent.postMessage({ type: 'URL_CHANGE', slug: cur.slug, title: cur.title }, '*');
        } catch (e) {}
    }
    if (resume) play();
}

function selectSongBySlug(slug, notifyParent = false) {
    const idx = SONGS.findIndex(s => s.slug === slug);
    if (idx !== -1 && SONGS[idx] !== cur) {
        selectSong(idx, notifyParent);
    }
}
window.selectSongBySlug = selectSongBySlug;
window.selectSong = selectSong;

window.addEventListener('message', e => {
    if (e.data && e.data.type === 'SELECT_TRACK' && e.data.slug) {
        selectSongBySlug(e.data.slug, false);
    }
});

document.addEventListener('keydown', e => {
    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    e.preventDefault();
    selectSong(SONGS.indexOf(cur) + (e.key === 'ArrowRight' ? 1 : -1));
});

playHit.addEventListener('click', toggle);
playHit.addEventListener('keydown', e => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
document.addEventListener('keydown', e => {
    if (e.code !== 'Space' || e.repeat) return;
    if (e.target.closest && e.target.closest('.hit, button')) return;
    e.preventDefault();
    toggle();
});

// ─────────────────────────────────────────────
//  LAYOUT
//  The grid sits dead centre. On wide screens the song label sits to its left
//  and the sound label to its right; on narrow screens above and below.
// ─────────────────────────────────────────────
function layout() {
    const vw = window.innerWidth, vh = window.innerHeight;
    wide = vw >= 760;
    const padX = Math.max(18, Math.min(96, vw * 0.06));
    const padY = wide ? Math.max(14, Math.min(80, vh * 0.07)) : Math.max(14, Math.min(40, vh * 0.035));
    document.documentElement.style.setProperty('--pad-x', `${padX}px`);
    document.documentElement.style.setProperty('--pad-y', `${padY}px`);
    stageEl.setAttribute('viewBox', `0 0 ${vw} ${vh}`);

    CX = vw / 2; CY = vh / 2;
    const gw = 2 * GRID_HALF;
    // room needed below the grid: the transport (and on phones the label rows and the corner text)
    const short = wide && vh < 560;                         // a phone held sideways
    const below = wide ? (short ? padY + 50 : padY + 70) : padY + 74 + 60 + 34;
    S = wide
        ? Math.min((vw - 2 * padX - 2 * 220) / gw, (vh / 2 - below) / GRID_HALF, 1.25)
        : Math.min((vw - 56) / gw, (vh / 2 - below) / GRID_HALF, 1);
    S = Math.max(0.35, S);

    const g = GRID_HALF * S, h = HALF * S, cell = CELL * S, pitch = PITCH * S;
    for (const t of tiles) {
        const x = CX + t.hx * S, y = CY + t.hy * S;
        setRect(t.ghost, x - h, y - h, cell, cell);
        setRect(t.hit, x - pitch / 2, y - pitch / 2, pitch, pitch);
    }
    setRect(playBase, CX - h, CY - h, cell, cell);
    setRect(playTrace, CX - h, CY - h, cell, cell);
    setRect(playHit, CX - pitch / 2, CY - pitch / 2, pitch, pitch);
    playWord.setAttribute('x', CX.toFixed(1));
    playWord.setAttribute('y', (CY + 4.5).toFixed(1));
    playWord.style.display = '';
    if (playIcon) playIcon.style.display = 'none';
    playPerimeter = 4 * cell;

    // registration marks just outside the grid's four corners
    marksG.innerHTML = '';
    const m = g + (wide ? 16 : 11), arm = wide ? 5 : 4;
    for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
        const x = CX + sx * m, y = CY + sy * m;
        svgEl('path', { class: 'mark', d: `M${x - arm} ${y}H${x + arm}M${x} ${y - arm}V${y + arm}` }, marksG);
    }

    // the transport sits just clear of the grid (and of the label rows on phones); the margins belong to the callouts
    transportEl.style.top = `${wide ? CY + g + (short ? 26 : 44) : CY + g + 104}px`;
    Object.assign(geo, {
        left: CX - g - 48, right: CX + g + 48,
        top: { left: padY + 34, right: Math.max(padY + 34, songsNav.getBoundingClientRect().bottom + 16) },
        bottom: vh - padY - 34,
        gridTop: CY - g, gridBottom: CY + g,
        minX: 12, maxX: vw - 12,
    });
    geo.topRows = Math.max(1, Math.min(3, Math.floor((CY - g - 24 - (songsNav.getBoundingClientRect().bottom + 12)) / 20) + 1));
    for (const t of tiles) {
        t.side = wide ? (t.hx < 0 || (t.hx === 0 && t.hy > 0) ? 'left' : 'right')
                      : (t.hy < 0 || (t.hy === 0 && t.hx < 0) ? 'top' : 'bottom');
        t.shown = false; t.row = null;                // re-place every callout at the new size
    }
    measureCallouts();
    tiles.forEach(drawTile);
    stepCallouts(0);
}

// ─────────────────────────────────────────────
//  START
// ─────────────────────────────────────────────
hintTiles.textContent = idleSoundText();
refreshSoundStatus();
refreshNav();
setPlayingUI(false);
layout();
if (window.__INITIAL_TRACK__) {
    selectSongBySlug(window.__INITIAL_TRACK__, false);
} else {
    try {
        const urlParams = new URLSearchParams(window.location.search);
        const trackParam = urlParams.get('track');
        if (trackParam) selectSongBySlug(trackParam, false);
    } catch (e) {}
}
window.addEventListener('resize', layout);
window.addEventListener('orientationchange', () => setTimeout(layout, 120));
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measureCallouts);
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.