Custom Code Experiment •
Symbol Hg
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>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; }
/* compact (set by the layout): the corner list gives way to the transport, the sound toggle moves up top */
html.compact .songs { display: none; }
html.compact .sound {
top: calc(var(--pad-y) - 7px + env(safe-area-inset-top, 0px)); bottom: auto;
left: auto; right: calc(var(--pad-x) - 6px);
}
/* tight (set by the layout): no room for the instructions; they live in the track list instead */
html.tight .hint { display: none; }
/* single (set when there is only one track): nothing to switch between */
html.single .songs, html.single .nav-hint { display: none !important; }
html.single .transport .step { visibility: hidden; }
/* text-free: only the track name and the line beneath it are shown */
.wordmark, .hint, .play-word, #soundLabel { display: none !important; }
.transport .now { cursor: default; }
/* ——— 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;
}
.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; }
/* 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="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)();
audioCtx.addEventListener('statechange', onAudioState);
}
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);
}
const kick = (freq, to, glide, peak, len) => (ctx, out, t) => toneHit(ctx, out, t, { freq, to, glide, peak, len });
const hat = (cut, len, peak) => (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: cut, peak, len });
// 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);
}
// 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) });
});
// 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;
}
// 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 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);
}));
};
// 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);
},
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]);
function playSound(id) {
const tile = TILE[id];
if (!tile) return;
try {
const ctx = getCtx();
if (ctx.state !== 'running') { // not awake yet: keep the sound for the moment audio starts
pendingSound = { id, at: performance.now() };
unlockAudio();
return;
}
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: '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]]);
});
},
},
];
// write every song once, up front
SONGS.forEach(song => {
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 });
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); // used instead of the word when tiles are small
const playHit = svgEl('rect', {
class: 'hit', tabindex: 0, role: 'button', 'data-play': '1',
}, hitsG);
// ─────────────────────────────────────────────
// DISTURBANCES
// The only things that move tiles: a struck tile moves in its own way (its looks),
// and its hit sends a wave out from where it sits. Nothing else is scripted.
// ─────────────────────────────────────────────
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 = cur.tight ? 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 = cur.tight ? 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 (cur.tight) 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 (cur.tight) { 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 += (cur.tight ? 1100 : 2600) * g * k * MOTION; }, // leaps toward you and drops back
tumble: (t, g, k) => { if (cur.tight) 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;
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) {
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 = cur.tight ? 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);
}
// ─────────────────────────────────────────────
// DRAWING
// Each tile is a square in 3D: sheared in its own plane, tilted, pushed in
// depth, then projected with a little perspective about the grid's centre.
// ─────────────────────────────────────────────
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).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;
t.poly.style.fill = fill > 0.01 ? `rgba(20, 20, 20, ${(fill * 0.85).toFixed(3)})` : 'none';
// 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)).toFixed(2);
}
// ─────────────────────────────────────────────
// CALLOUTS
// Every sounding tile gets its own label and leader line, so notes that
// play together are all pointed out together. Wide screens stack labels in
// a column either side of the grid; narrow screens use rows above and below.
// ─────────────────────────────────────────────
const transportEl = $('transport');
const statusMain = $('statusMain'), statusSub = $('statusSub');
const hintTiles = $('hintTiles');
const SLOT = 19; // vertical spacing between stacked labels
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;
// the nearest corner or edge midpoint of a tile, as it is drawn right now
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;
}
// wide: a column beside the grid, each label as close to its tile's height as the others allow
function placeColumn(list, side) {
const top = geo.top[side]; // the right column starts below the song list
list.forEach(t => { t.want = Math.min(geo.bottom, Math.max(top, centreY(t))); });
// labels already on screen keep their order, so two labels can never slide through each other
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));
}
// narrow: rows above or below the grid, each label as close to its tile's column as the others allow
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; // never push a label off the left edge
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);
// a label keeps its row while it is showing; a new one takes the nearest row with space
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; // rows above the grid stop short of the song list
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; // where the line meets the label
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) { // a new label appears in place; others glide aside
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;
}
// ─────────────────────────────────────────────
// ANIMATION
// ─────────────────────────────────────────────
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
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);
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;
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;
}
// 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));
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 = cur.tight ? 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
}
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);
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) live.push([kind, w * MOTION]);
}
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', cur.tight ? 8 : 28, cur.tight ? 0.75 : 0.22, h);
camSpring(c, 'sy', 'vsy', cur.tight ? 8 : 28, cur.tight ? 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;
}
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 : cur.tight ? 9 : 11, az = t.loose ? 0.35 : recalling ? 0.8 : cur.tight ? 0.75 : 0.45;
const sw = held ? (cur.tight ? 15 : 38) : (cur.tight ? 11 : 15), sz = held ? (cur.tight ? 0.9 : 0.62) : (cur.tight ? 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, pw, pz, h); // home, or wherever the formation wants this tile
springTo(t, 'dy', 'vy', t.fy, pw, pz, h);
springTo(t, 'z', 'vz', t.fz, 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', cur.tight ? 11 : 16, cur.tight ? 0.6 : 0.28, h); // swell and shrink bounce back (settle calmly in a tight grid)
spring(t, 'sq', 'vsq', cur.tight ? 13 : 20, cur.tight ? 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 = waves.length > 0 || busy;
for (const t of tiles) busy = stepTile(t, dt, audioNow, nowMs) || busy;
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;
}
// ─────────────────────────────────────────────
// PLAYING BY HAND
// Mouse: hover. Touch: press and slide across tiles.
// ─────────────────────────────────────────────
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);
});
// ─────────────────────────────────────────────
// WAKING AND MUTING AUDIO
// ─────────────────────────────────────────────
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));
}
// iPhones mute web audio when the ringer switch is on silent. Newer Safari can be told this page is
// media playback; older Safari needs a media element playing. That element plays a silent stream
// from the page's own audio (no file or web address, so a host's security rules can't block it);
// an embedded silent clip is only the fallback.
let keepAlive = null;
function startKeepAlive() {
try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch (e) { /* ignore */ }
try {
if (!keepAlive) {
keepAlive = document.createElement('audio');
keepAlive.setAttribute('playsinline', '');
try { keepAlive.srcObject = getCtx().createMediaStreamDestination().stream; }
catch (e) {
keepAlive.src = 'data:audio/wav;base64,UklGRkQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==';
keepAlive.loop = true;
}
}
const p = keepAlive.play();
if (p && p.catch) p.catch(() => {});
} catch (e) { /* ignore */ }
}
// Browsers only let sound start during a real gesture, and they disagree on which events count
// (Safari counts a finger lifting, not landing). So every kind of press tries, until audio is running.
let pendingSound = null, primed = false;
function onAudioState() {
const running = audioCtx && audioCtx.state === 'running';
if (running && !audioReady) {
audioReady = true;
hintTiles.textContent = idleSoundText();
if (pendingSound && performance.now() - pendingSound.at < 600) { // the tap that woke the audio still sounds
const t = TILE[pendingSound.id];
if (t) try { t.voice(audioCtx, roomFor(cur), audioCtx.currentTime); } catch (e) { /* ignore */ }
}
pendingSound = null;
}
refreshSoundStatus();
}
function unlockAudio() {
const ctx = getCtx();
getMaster();
if (ctx.state !== 'running') {
const r = ctx.resume();
if (r && r.catch) r.catch(() => {});
}
if (!primed) { // a one-sample silent sound started inside the gesture finishes waking Safari
try {
const s = ctx.createBufferSource();
s.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
s.connect(ctx.destination);
s.start(0);
primed = true;
} catch (e) { /* ignore */ }
}
startKeepAlive();
onAudioState();
}
['pointerdown', 'pointerup', 'mousedown', 'touchend', 'click', 'keydown'].forEach(evt =>
document.addEventListener(evt, () => { if (!audioCtx || audioCtx.state !== 'running') unlockAudio(); }, { passive: true })
);
// the press that wakes audio shouldn't also mute it
['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();
});
// ─────────────────────────────────────────────
// SONG PLAYER
// Audio is scheduled a little ahead on the audio clock; each note's own
// tile strikes on the animation frame where that note is actually heard.
// ─────────────────────────────────────────────
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.name}|${fmt(elapsed)} / ${fmt(cur.length)}`;
if (text !== lastReadout) {
lastReadout = text;
statusMain.textContent = cur.title; // the track name stays; the section and time sit beneath it
statusSub.textContent = `${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.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) { /* ignore */ }
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();
}
// keep the phone's screen on while a track plays
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; }
}
// coming back to the page (after locking the phone or switching apps): wake the audio and the screen lock again
document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (keepAlive) keepAlive.pause(); return; }
if (audioReady) {
startKeepAlive();
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() { // stopped by the listener
if (!player.playing) return;
halt();
const bus = player.bus;
bus.gain.setTargetAtTime(0, getCtx().currentTime, 0.02); // silence notes already scheduled
setTimeout(() => bus.disconnect(), 400);
visualQueue.length = 0;
tiles.forEach(t => { t.songUntil = -1; });
releaseStage(0.9);
setPlayingUI(false);
wake();
}
function finish() { // reached the end of the song
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(); }
// ─────────────────────────────────────────────
// CHOOSING A SONG
// Each song re-tunes all 48 tiles to its own kit, marked by a ripple
// running out from the centre.
// ─────────────────────────────────────────────
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));
// the track name is display only; no panel of text opens from it
// swipe left or right anywhere off the grid to change track
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) {
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);
emitWave({ x: 0, y: 0, push: 0, reach: 420, spin: 6, lift: 30 });
wake();
if (resume) play();
}
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.
// ─────────────────────────────────────────────
const rootEl = document.documentElement;
const hintEl = document.querySelector('.hint'), wordmarkEl = document.querySelector('.wordmark');
const smallOrTouch = matchMedia('(max-width: 759px), (max-height: 560px), (pointer: coarse)');
const force = { compact: false, tight: false };
// Two ways to arrange the page; whichever allows the bigger grid wins.
// rows: labels in rows above and below the grid, the transport under them (portrait screens)
// columns: labels in columns either side, the transport just under the grid (landscape and desktop)
function layout() {
const vw = window.innerWidth, vh = window.innerHeight;
const gw = 2 * GRID_HALF;
const tight = force.tight || vw < 640 || vh < 440;
rootEl.classList.toggle('tight', tight);
const hintRoom = tight ? 0 : hintEl.offsetHeight + 12;
const padYR = Math.max(12, Math.min(40, vh * 0.035));
const aboveR = padYR + 105; // the top bar, then up to three label rows
const belowR = 122 + hintRoom + padYR; // three label rows, then the track name and its line
const SR = Math.min((vw - 40) / gw, (vh / 2 - Math.max(aboveR, belowR)) / GRID_HALF, 1.1);
const padXC = Math.max(14, Math.min(96, vw * 0.06)), padYC = Math.max(12, Math.min(80, vh * 0.07));
const col = Math.min(220, Math.max(130, vw * 0.17)); // room for a column of labels each side
const SC = Math.min((vw - 2 * padXC - 2 * col) / gw, (vh / 2 - (padYC + 58)) / GRID_HALF, 1.25);
wide = SC >= SR;
const compact = force.compact || smallOrTouch.matches || !wide;
rootEl.classList.toggle('compact', compact);
const padX = wide ? padXC : Math.max(14, Math.min(40, vw * 0.05));
const padY = wide ? padYC : padYR;
rootEl.style.setProperty('--pad-x', `${padX}px`);
rootEl.style.setProperty('--pad-y', `${padY}px`);
stageEl.setAttribute('viewBox', `0 0 ${vw} ${vh}`);
CX = vw / 2; CY = vh / 2;
S = Math.max(0.2, wide ? SC : SR);
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));
const tiny = true; // a symbol, never the word
playWord.style.display = tiny ? 'none' : '';
playIcon.style.display = tiny ? 'block' : 'none';
drawPlayIcon();
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, in the rows layout)
transportEl.style.top = `${wide ? CY + g + 36 : CY + g + 100}px`;
// what sits along the top: the wordmark, and the song list or the sound toggle on the right
const navBottom = compact ? 0 : songsNav.getBoundingClientRect().bottom;
const soundBottom = compact ? soundBtn.getBoundingClientRect().bottom : 0;
const topBar = Math.max(wordmarkEl.getBoundingClientRect().bottom, soundBottom, navBottom);
Object.assign(geo, {
left: CX - g - 48, right: CX + g + 48,
top: { left: Math.max(padY + 20, wordmarkEl.getBoundingClientRect().bottom + 16), right: Math.max(padY + 34, navBottom + 16, soundBottom + 16) },
bottom: vh - padY - 34,
gridTop: CY - g, gridBottom: CY + g,
minX: 8, maxX: vw - 8,
});
geo.topRows = Math.max(1, Math.min(3, Math.floor((CY - g - 24 - (topBar + 10)) / 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);
}
// After laying out, check that nothing collides; if something does, make room and lay out again.
function collisions() {
const box = el => el.getBoundingClientRect();
const shown = el => getComputedStyle(el).display !== 'none';
const hit = (a, b) => a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1;
const g = GRID_HALF * S;
const grid = { left: CX - g, top: CY - g, right: CX + g, bottom: CY + g };
const tr = box(transportEl), sound = box(soundBtn), word = box(wordmarkEl);
const needs = { compact: false, tight: false };
if (shown(hintEl)) {
const hint = box(hintEl);
if (hit(hint, tr) || hit(hint, sound) || hit(hint, grid) || hit(hint, word)) needs.tight = true;
}
if (hit(sound, tr) || hit(sound, grid) || hit(sound, word)) needs.compact = true;
if (shown(songsNav)) {
const nav = box(songsNav);
if (hit(nav, grid) || hit(nav, tr) || hit(nav, word) || nav.bottom > window.innerHeight) needs.compact = true;
}
return needs;
}
function relayout() {
force.compact = false; force.tight = false;
layout();
for (let pass = 0; pass < 3; pass++) {
const needs = collisions();
if (!needs.compact && !needs.tight) break;
force.compact = force.compact || needs.compact;
force.tight = force.tight || needs.tight;
layout();
}
}
// ─────────────────────────────────────────────
// START
// ─────────────────────────────────────────────
hintTiles.textContent = idleSoundText();
refreshSoundStatus();
refreshNav();
setPlayingUI(false);
relayout();
window.addEventListener('resize', relayout);
window.addEventListener('orientationchange', () => setTimeout(relayout, 120));
if (document.fonts && document.fonts.ready) document.fonts.ready.then(relayout);
</script>
</body>
</html>