Custom Code Experiment •
OwnZones
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; }
/* ——— 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; }
.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="tiles"></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">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">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">arrow.keys.change.track</span><span class="touch">swipe.to.change.track</span></p>
<script>
// ─────────────────────────────────────────────
// AUDIO ENGINE
// ─────────────────────────────────────────────
let audioCtx = null;
let audioReady = false;
let muted = false;
const MASTER_LEVEL = 0.88; // a little headroom after the limiter
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
// a song can sit in its own reverberant room; songs without one go straight to the master
const rooms = new Map();
function impulse(ctx, seconds, shape, tone = 1) {
const len = Math.floor(ctx.sampleRate * seconds);
const buf = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
let smooth = 0; // tone < 1 low-passes the room: a darker space
for (let i = 0; i < len; i++) {
smooth += tone * ((Math.random() * 2 - 1) - smooth);
d[i] = smooth * Math.pow(1 - i / len, shape) * (tone < 1 ? 1.6 : 1);
}
}
return buf;
}
function roomFor(song) {
if (!song.room) return getMaster();
let input = rooms.get(song);
if (!input) {
const ctx = getCtx();
input = ctx.createGain();
const conv = ctx.createConvolver(), wet = ctx.createGain();
conv.buffer = impulse(ctx, song.room.seconds, song.room.shape, song.room.tone);
wet.gain.value = song.room.wet;
input.connect(getMaster());
input.connect(conv); conv.connect(wet); wet.connect(getMaster());
rooms.set(song, input);
}
return input;
}
function getMaster() {
const ctx = getCtx();
if (!getMaster._node) {
const lim = ctx.createDynamicsCompressor();
lim.threshold.value = -6;
lim.knee.value = 3;
lim.ratio.value = 12;
lim.attack.value = 0.001;
lim.release.value = 0.1;
const out = ctx.createGain();
out.gain.value = muted ? 0 : MASTER_LEVEL;
lim.connect(out); out.connect(ctx.destination);
getMaster._node = lim;
getMaster._out = out;
}
return getMaster._node;
}
// ─────────────────────────────────────────────
// SOUND KIT
// Small building blocks; every voice takes a start time `t`
// so the song can be scheduled ahead on the audio clock.
// ─────────────────────────────────────────────
const noiseCache = new WeakMap();
function noiseSource(ctx) {
let buf = noiseCache.get(ctx);
if (!buf) {
buf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
noiseCache.set(ctx, buf);
}
const src = ctx.createBufferSource();
src.buffer = buf;
return src;
}
function decay(ctx, t, peak, len) {
const g = ctx.createGain();
g.gain.value = 0;
g.gain.setValueAtTime(peak, t);
g.gain.exponentialRampToValueAtTime(0.001, t + len);
return g;
}
function toneHit(ctx, out, t, { type = 'sine', freq, to, glide = 0, peak, len }) {
const o = ctx.createOscillator();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (to) o.frequency.exponentialRampToValueAtTime(to, t + glide);
const g = decay(ctx, t, peak, len);
o.connect(g); g.connect(out);
o.start(t); o.stop(t + len + 0.03);
}
function noiseHit(ctx, out, t, { type, freq, Q = 1, peak, len }) {
const src = noiseSource(ctx);
const f = ctx.createBiquadFilter();
f.type = type; f.frequency.value = freq; f.Q.value = Q;
const g = decay(ctx, t, peak, len);
src.connect(f); f.connect(g); g.connect(out);
src.start(t, Math.random() * Math.max(0, 1.9 - len));
src.stop(t + len + 0.03);
}
// voice families
const leadTone = freq => (ctx, out, t) => toneHit(ctx, out, t, { type: 'triangle', freq, peak: 0.45, len: 0.5 });
const kick = (freq, to, glide, peak, len) => (ctx, out, t) => toneHit(ctx, out, t, { freq, to, glide, peak, len });
const tom = (freq, to, glide, peak, len) => (ctx, out, t) => toneHit(ctx, out, t, { type: 'sawtooth', freq, to, glide, peak, len });
const hat = (cut, len, peak) => (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: cut, peak, len });
const strum = freqs => (ctx, out, t) => freqs.forEach((freq, i) =>
toneHit(ctx, out, t + i * 0.04, { freq, peak: 0.18, len: 0.66 - i * 0.04 }));
const sawBass = freq => (ctx, out, t) => {
const o = ctx.createOscillator(), lp = ctx.createBiquadFilter();
o.type = 'sawtooth'; o.frequency.value = freq;
lp.type = 'lowpass'; lp.frequency.value = 800; lp.Q.value = 8;
const g = decay(ctx, t, 0.5, 0.4);
o.connect(lp); lp.connect(g); g.connect(out);
o.start(t); o.stop(t + 0.45);
};
// one-off voices (most of these are the original instrument's sounds, unchanged)
const voices = {
boom: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 75, to: 24, glide: 0.9, peak: 0.62, len: 1.3 });
toneHit(ctx, out, t, { freq: 45, peak: 0.26, len: 1.0 });
noiseHit(ctx, out, t, { type: 'lowpass', freq: 900, peak: 0.25, len: 0.08 });
},
snare: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'highpass', freq: 1800, peak: 0.42, len: 0.14 });
toneHit(ctx, out, t, { type: 'triangle', freq: 190, to: 150, glide: 0.08, peak: 0.3, len: 0.09 });
},
clap: (ctx, out, t) => {
for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.011, { type: 'bandpass', freq: 1500, Q: 1.2, peak: 0.4, len: 0.018 });
noiseHit(ctx, out, t + 0.033, { type: 'bandpass', freq: 1400, peak: 0.3, len: 0.16 });
},
wood: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 760, to: 700, glide: 0.05, peak: 0.45, len: 0.07 });
noiseHit(ctx, out, t, { type: 'bandpass', freq: 1200, Q: 5, peak: 0.25, len: 0.025 });
},
crash: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'highpass', freq: 3200, Q: 0.7, peak: 0.3, len: 1.4 });
noiseHit(ctx, out, t, { type: 'bandpass', freq: 6500, Q: 0.8, peak: 0.15, len: 0.9 });
},
cowbell: (ctx, out, t) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 800; bp.Q.value = 2.5;
const g = decay(ctx, t, 0.22, 0.32);
bp.connect(g); g.connect(out);
[540, 800].forEach(freq => {
const o = ctx.createOscillator();
o.type = 'square'; o.frequency.value = freq;
o.connect(bp); o.start(t); o.stop(t + 0.35);
});
},
rim: (ctx, out, t) => {
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / 200);
const src = ctx.createBufferSource(); src.buffer = buf;
const env = decay(ctx, t, 0.6, 0.06);
src.connect(env); env.connect(out);
src.start(t); src.stop(t + 0.08);
},
rise: (ctx, out, t) => {
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.4, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = ctx.createBufferSource(); src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(400, t);
filter.frequency.exponentialRampToValueAtTime(2000, t + 0.4);
filter.Q.value = 3;
const env = decay(ctx, t, 0.3, 0.4);
src.connect(filter); filter.connect(env); env.connect(out);
src.start(t); src.stop(t + 0.45);
},
zap: (ctx, out, t) => toneHit(ctx, out, t, { freq: 220, to: 660, glide: 0.3, peak: 0.35, len: 0.35 }),
fall: (ctx, out, t) => toneHit(ctx, out, t, { freq: 880, to: 110, glide: 0.5, peak: 0.35, len: 0.55 }),
glide: (ctx, out, t) => toneHit(ctx, out, t, { type: 'triangle', freq: 261.63, to: 523.25, glide: 0.2, peak: 0.35, len: 0.3 }),
ping: (ctx, out, t) => toneHit(ctx, out, t, { freq: 1318.51, peak: 0.3, len: 0.6 }),
bell: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 523.25, peak: 0.4, len: 0.8 });
toneHit(ctx, out, t, { freq: 1046.5, peak: 0.15, len: 0.8 });
},
vibrato: (ctx, out, t) => {
const carrier = ctx.createOscillator(), lfo = ctx.createOscillator(), lfoGain = ctx.createGain();
lfo.frequency.value = 8; lfoGain.gain.value = 60;
carrier.type = 'sine'; carrier.frequency.value = 440;
lfo.connect(lfoGain); lfoGain.connect(carrier.frequency);
const env = decay(ctx, t, 0.3, 0.5);
carrier.connect(env); env.connect(out);
lfo.start(t); carrier.start(t); lfo.stop(t + 0.55); carrier.stop(t + 0.55);
},
arp: (ctx, out, t) => [523.25, 659.25, 783.99, 1046.5].forEach((freq, i) =>
toneHit(ctx, out, t + i * 0.06, { type: 'triangle', freq, peak: 0.22, len: 0.15 })),
dyad: (ctx, out, t) => [659.25, 830.61].forEach((freq, i) =>
toneHit(ctx, out, t + i * 0.05, { freq, peak: 0.2, len: 0.55 - i * 0.05 })),
squareBass: (ctx, out, t) => toneHit(ctx, out, t, { type: 'square', freq: 130.81, peak: 0.25, len: 0.35 }),
grit: (ctx, out, t) => {
const osc = ctx.createOscillator(), wave = ctx.createWaveShaper();
const curve = new Float32Array(257); // odd length: silence maps exactly to 0
for (let i = 0; i < 257; i++) {
const x = (i * 2) / 256 - 1;
curve[i] = (Math.PI + 200) * x / (Math.PI + 200 * Math.abs(x));
}
wave.curve = curve;
osc.type = 'sawtooth'; osc.frequency.value = 80;
const env = decay(ctx, t, 0.6, 0.15);
osc.connect(wave); wave.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.2);
},
};
// ─────────────────────────────────────────────
// KITS: each song brings its own 48 sounds
// Laid out in rings around the play button, each listed clockwise from its
// top-left corner. Every tile has its own sound, its own mix level (vel),
// and its own way of disturbing the grid when it sounds (hit):
// push > 0 shoves neighbours away, push < 0 pulls them in,
// plane sweeps the grid in one direction, lift raises tiles toward you.
// ─────────────────────────────────────────────
// Track 1, "Nomo Chant": triangle lead, chords and bass, a full drum kit
const KIT_TESSELLATE = {
// ring 1: the lead, an A-minor scale climbing clockwise round the centre
1: [
['lead.e4', leadTone(329.63)], ['lead.f4', leadTone(349.23)], ['lead.g4', leadTone(392.0)], ['lead.a4', leadTone(440.0)],
['lead.b4', leadTone(493.88)], ['lead.c5', leadTone(523.25)], ['lead.d5', leadTone(587.33)], ['lead.e5', leadTone(659.25)],
],
// ring 2: colour on top, chords down the right, bass climbing round the bottom and left
2: [
['arp.c.major', voices.arp, { vel: 0.85, hit: { push: 40 } }],
['ping.e6', voices.ping, { vel: 0.75 }],
['bell.c5', voices.bell, { vel: 0.85 }],
['vibrato.a4', voices.vibrato, { hit: { push: 15, lift: 35 } }],
['glide.c4.c5', voices.glide, { vel: 0.8, hit: { plane: [1, 0], push: 90 } }],
['chord.am', strum([440, 523.25, 659.25]), { hit: { push: 25, lift: 45 } }],
['chord.c', strum([523.25, 659.25, 783.99]), { hit: { push: 25, lift: 45 } }],
['chord.f', strum([349.23, 440, 523.25]), { hit: { push: 25, lift: 45 } }],
['chord.e', strum([329.63, 415.3, 493.88]), { hit: { push: 25, lift: 45 } }],
['grit.e2', voices.grit, { vel: 0.55, hit: { push: 150 } }],
['bass.f2', sawBass(87.31), { vel: 0.85, hit: { push: 50 } }],
['bass.g2', sawBass(98.0), { vel: 0.85, hit: { push: 50 } }],
['bass.a2', sawBass(110.0), { vel: 0.85, hit: { push: 50 } }],
['bass.c3', voices.squareBass, { vel: 0.95, hit: { push: 50 } }],
['bass.d3', sawBass(146.83), { vel: 0.85, hit: { push: 50 } }],
['dyad.e5.g#5', voices.dyad],
],
// ring 3: kicks in the corners, snares mid-side, hi-hats either side of each corner
3: [
['kick.deep', kick(100, 28, 0.22, 0.8, 0.32), { hit: { push: 160 } }],
['hat.1', hat(5200, 0.05, 0.45), { vel: 0.5 }],
['rise', voices.rise, { vel: 0.7, hit: { plane: [0, -1], push: 80, lift: 60 } }],
['rim.click', voices.rim, { vel: 0.75, hit: { push: -35 } }],
['zap', voices.zap, { vel: 0.6, hit: { push: 130 } }],
['hat.2', hat(6000, 0.08, 0.5), { vel: 0.5 }],
['kick.mid', kick(120, 30, 0.18, 0.7, 0.25), { vel: 0.9, hit: { push: 140 } }],
['hat.3', hat(6800, 0.05, 0.45), { vel: 0.5 }],
['tom.high', tom(200, 50, 0.12, 0.5, 0.18), { vel: 0.8, hit: { push: 95 } }],
['snare.tight', voices.snare, { vel: 0.8, hit: { push: -70 } }],
['tom.mid', tom(180, 45, 0.16, 0.52, 0.2), { vel: 0.82, hit: { push: 100 } }],
['hat.4', hat(7600, 0.07, 0.45), { vel: 0.5 }],
['kick.high', kick(150, 45, 0.12, 0.6, 0.18), { vel: 0.8, hit: { push: 110 } }],
['hat.5', hat(8400, 0.05, 0.42), { vel: 0.5 }],
['tom.low', tom(160, 40, 0.2, 0.55, 0.22), { vel: 0.85, hit: { push: 110 } }],
['clap', voices.clap, { vel: 0.8, hit: { push: -80 } }],
['cowbell', voices.cowbell, { vel: 0.5, hit: { push: 30 } }],
['hat.6', hat(9200, 0.07, 0.42), { vel: 0.5 }],
['boom', voices.boom, { hit: { push: 520, reach: 350 } }],
['hat.7', hat(10000, 0.05, 0.4), { vel: 0.5 }],
['fall', voices.fall, { vel: 0.5, hit: { plane: [0, 1], push: 150 } }],
['rim.wood', voices.wood, { vel: 0.6, hit: { push: -30 } }],
['crash', voices.crash, { vel: 0.7, hit: { push: 230, reach: 260 } }],
['hat.open', hat(4600, 0.32, 0.38), { vel: 0.45 }],
],
};
// repeats a voice like a tape echo: each repeat quieter and duller
function echoes(voice, repeats, spacing, fall) {
return (ctx, out, t) => {
for (let k = 0; k <= repeats; k++) {
let dest = out;
if (k > 0) {
const g = ctx.createGain(), lp = ctx.createBiquadFilter();
g.gain.value = Math.pow(fall, k);
lp.type = 'lowpass'; lp.frequency.value = 5000 / (k + 0.5);
g.connect(lp); lp.connect(out);
dest = g;
}
voice(ctx, dest, t + k * spacing);
}
};
}
const sirenVoice = (ctx, out, t) => { // a dub siren: a square wave swooping up and down
const o = ctx.createOscillator(), lp = ctx.createBiquadFilter(), g = ctx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(420, t);
[[0.3, 1150], [0.6, 480], [0.9, 1250], [1.2, 450]].forEach(([d, f]) => o.frequency.exponentialRampToValueAtTime(f, t + d));
lp.type = 'lowpass'; lp.frequency.value = 2400;
g.gain.value = 0;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.1, t + 0.03);
g.gain.setValueAtTime(0.1, t + 1.0);
g.gain.exponentialRampToValueAtTime(0.001, t + 1.35);
o.connect(lp); lp.connect(g); g.connect(out);
o.start(t); o.stop(t + 1.4);
};
// Shared building blocks used by several kits
// a gain that swells in, holds, then dies away
function swell(ctx, t, peak, attack, hold, release) {
const g = ctx.createGain();
g.gain.value = 0;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(peak, t + attack);
g.gain.setValueAtTime(peak, t + attack + hold);
g.gain.exponentialRampToValueAtTime(0.001, t + attack + hold + release);
return g;
}
// filtered noise that can run longer than the noise buffer
function noiseBed(ctx, t, filter, g, stopAt) {
const src = noiseSource(ctx);
src.loop = true;
src.connect(filter); filter.connect(g);
src.start(t, Math.random() * 1.5); src.stop(stopAt);
}
const drone = (freqs, peak) => (ctx, out, t) => { // a slow, breathing low string drone, about 7 s
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 1.5;
lp.frequency.setValueAtTime(300, t);
lp.frequency.linearRampToValueAtTime(900, t + 3);
lp.frequency.linearRampToValueAtTime(350, t + 7);
const g = swell(ctx, t, peak, 1.4, 3.2, 2.6);
lp.connect(g); g.connect(out);
freqs.forEach(freq => [-2.5, 2.5].forEach(det => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
og.gain.value = 0.25;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 7.3);
}));
};
// struck metal: partials at fixed ratios, each doubled slightly sharp so it beats and shimmers
const metal = (base, ratios, peak, len) => (ctx, out, t) => ratios.forEach((r, i) => {
toneHit(ctx, out, t, { freq: base * r, peak: peak / (i + 1), len: len / (1 + i * 0.3) });
toneHit(ctx, out, t, { freq: base * r * 1.003, peak: peak / (i + 1) * 0.6, len: len / (1 + i * 0.3) });
});
// Track 2, "Numerous Under One": drum and bass at 174 bpm in F minor.
const DNB_STEP = 60 / 174 / 4;
// a bright synth pluck with two quick echoes
const pluck = freq => echoes((ctx, out, t) => {
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 4;
lp.frequency.setValueAtTime(5000, t);
lp.frequency.exponentialRampToValueAtTime(700, t + 0.18);
const g = decay(ctx, t, 0.2, 0.35);
lp.connect(g); g.connect(out);
[1, 1.006].forEach(mul => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq * mul;
og.gain.value = 0.5;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 0.4);
});
}, 2, 3 * DNB_STEP, 0.35);
// reese: two saws detuned against each other, a filter that opens and closes, a clean sub underneath
const reese = freq => (ctx, out, t) => {
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 2;
lp.frequency.setValueAtTime(250, t);
lp.frequency.linearRampToValueAtTime(900, t + 0.35);
lp.frequency.linearRampToValueAtTime(400, t + 0.9);
const g = ctx.createGain();
g.gain.value = 0;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.22, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.12, t + 0.4);
g.gain.exponentialRampToValueAtTime(0.001, t + 1.0);
lp.connect(g); g.connect(out);
[-14, 14].forEach(det => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq * 2; o.detune.value = det;
og.gain.value = 0.5;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 1.05);
});
const sub = ctx.createOscillator(), sg = ctx.createGain();
sub.type = 'sine'; sub.frequency.value = freq;
sg.gain.value = 0.7;
sub.connect(sg); sg.connect(g);
sub.start(t); sub.stop(t + 1.05);
};
const airPad = freqs => (ctx, out, t) => { // saws through a soft filter, about 4 s
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 1400; lp.Q.value = 0.5;
const g = swell(ctx, t, 0.09, 0.8, 1.8, 1.6);
lp.connect(g); g.connect(out);
freqs.forEach(freq => [-7, 7].forEach(det => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
og.gain.value = 0.2;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 4.3);
}));
};
const chordStab = freqs => (ctx, out, t) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.8;
const g = decay(ctx, t, 0.3, 0.22);
bp.connect(g); g.connect(out);
freqs.forEach(freq => ['sawtooth', 'square'].forEach(type => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = type; o.frequency.value = freq;
og.gain.value = 0.18;
o.connect(og); og.connect(bp);
o.start(t); o.stop(t + 0.25);
}));
};
const dnb = {
kickPunch: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 160, to: 45, glide: 0.08, peak: 0.9, len: 0.28 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 3000, peak: 0.2, len: 0.012 });
},
kickSub: (ctx, out, t) => toneHit(ctx, out, t, { freq: 70, to: 35, glide: 0.2, peak: 0.8, len: 0.5 }),
snare: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'bandpass', freq: 2200, Q: 0.6, peak: 0.5, len: 0.18 });
toneHit(ctx, out, t, { type: 'triangle', freq: 220, to: 180, glide: 0.05, peak: 0.35, len: 0.1 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 5000, peak: 0.2, len: 0.08 });
},
ghost: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'bandpass', freq: 2000, peak: 0.22, len: 0.07 });
toneHit(ctx, out, t, { type: 'triangle', freq: 230, peak: 0.12, len: 0.04 });
},
tom: freq => (ctx, out, t) => toneHit(ctx, out, t, { freq, to: freq * 0.4, glide: 0.15, peak: 0.55, len: 0.22 }),
ride: (ctx, out, t) => {
metal(3150, [1, 1.47, 2.09], 0.06, 0.9)(ctx, out, t);
noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.1, len: 0.5 });
},
shaker: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 7000, peak: 0.15, len: 0.05 }),
tamb: (ctx, out, t) => {
[6000, 7800, 9400].forEach(freq => toneHit(ctx, out, t, { freq, peak: 0.05, len: 0.1 }));
noiseHit(ctx, out, t, { type: 'highpass', freq: 8000, peak: 0.12, len: 0.08 });
},
riser: (ctx, out, t) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = 3;
bp.frequency.setValueAtTime(400, t);
bp.frequency.exponentialRampToValueAtTime(6000, t + 1.3);
const g = swell(ctx, t, 0.15, 1.2, 0.05, 0.2);
g.connect(out);
noiseBed(ctx, t, bp, g, t + 1.5);
toneHit(ctx, out, t, { type: 'sawtooth', freq: 200, to: 1200, glide: 1.3, peak: 0.03, len: 1.4 });
},
impact: (ctx, out, t) => {
voices.boom(ctx, out, t);
noiseHit(ctx, out, t, { type: 'lowpass', freq: 2000, peak: 0.3, len: 0.5 });
},
downlifter: (ctx, out, t) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = 2;
bp.frequency.setValueAtTime(5000, t);
bp.frequency.exponentialRampToValueAtTime(300, t + 1.2);
const g = swell(ctx, t, 0.14, 0.02, 0.3, 1.0);
g.connect(out);
noiseBed(ctx, t, bp, g, t + 1.4);
toneHit(ctx, out, t, { freq: 900, to: 80, glide: 1.1, peak: 0.12, len: 1.2 });
},
reverse: (ctx, out, t) => { // a cymbal played backwards: swelling up, then cut
const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 4000;
const g = ctx.createGain();
g.gain.value = 0;
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(0.25, t + 1.1);
g.gain.linearRampToValueAtTime(0, t + 1.15);
g.connect(out);
noiseBed(ctx, t, hp, g, t + 1.2);
},
vox: (ctx, out, t) => { // a sung "ah": a buzzy tone through two vowel formants
const src = ctx.createOscillator(), vib = ctx.createOscillator(), vd = ctx.createGain();
src.type = 'sawtooth'; src.frequency.value = 220;
vib.frequency.value = 5.5; vd.gain.value = 3;
vib.connect(vd); vd.connect(src.frequency);
const g = swell(ctx, t, 0.3, 0.03, 0.25, 0.35);
g.connect(out);
[[700, 8, 1], [1220, 10, 0.6]].forEach(([freq, Q, lvl]) => {
const bp = ctx.createBiquadFilter(), fg = ctx.createGain();
bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
fg.gain.value = lvl;
src.connect(bp); bp.connect(fg); fg.connect(g);
});
src.start(t); vib.start(t); src.stop(t + 0.7); vib.stop(t + 0.7);
},
laser: echoes((ctx, out, t) =>
toneHit(ctx, out, t, { type: 'square', freq: 2000, to: 200, glide: 0.25, peak: 0.08, len: 0.28 }), 2, 3 * DNB_STEP, 0.4),
};
const KIT_DNB = {
// ring 1: plucked lead, the F-minor scale climbing clockwise round the centre
1: [
['lead.f4', pluck(349.23)], ['lead.g4', pluck(392.0)], ['lead.a♭4', pluck(415.3)], ['lead.b♭4', pluck(466.16)],
['lead.c5', pluck(523.25)], ['lead.d♭5', pluck(554.37)], ['lead.e♭5', pluck(622.25)], ['lead.f5', pluck(698.46)],
].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
// ring 2: pads along the top, stabs down the right, reese bass climbing round the bottom and left
2: [
['pad.fm', airPad([174.61, 207.65, 261.63, 311.13]), { hit: { push: 15, lift: 40 } }],
['pad.db', airPad([138.59, 174.61, 207.65, 261.63]), { hit: { push: 15, lift: 40 } }],
['pad.ab', airPad([207.65, 261.63, 311.13, 392.0]), { hit: { push: 15, lift: 40 } }],
['pad.eb', airPad([155.56, 196.0, 233.08, 277.18]), { hit: { push: 15, lift: 40 } }],
['stab.fm', chordStab([349.23, 415.3, 523.25, 622.25]), { vel: 0.8, hit: { push: 50 } }],
['stab.db', chordStab([277.18, 349.23, 415.3, 523.25]), { vel: 0.8, hit: { push: 50 } }],
['stab.ab', chordStab([415.3, 523.25, 622.25, 783.99]), { vel: 0.8, hit: { push: 50 } }],
['stab.eb', chordStab([311.13, 392.0, 466.16, 554.37]), { vel: 0.8, hit: { push: 50 } }],
['reese.f1', reese(43.65), { hit: { push: 70 } }],
['reese.g1', reese(49.0), { hit: { push: 70 } }],
['reese.a♭1', reese(51.91), { hit: { push: 70 } }],
['reese.b♭1', reese(58.27), { hit: { push: 70 } }],
['reese.c2', reese(65.41), { hit: { push: 70 } }],
['reese.d♭2', reese(69.3), { hit: { push: 70 } }],
['reese.e♭2', reese(77.78), { hit: { push: 70 } }],
['reese.f2', reese(87.31), { hit: { push: 70 } }],
],
// ring 3: kicks, crash and impact in the corners, snares mid-side, hats round the corners, effects between
3: [
['kick.punch', dnb.kickPunch, { hit: { push: 170 } }],
['hat.1', hat(8000, 0.03, 0.3), { vel: 0.5 }],
['tamb', dnb.tamb, { vel: 0.5 }],
['snare.main', dnb.snare, { vel: 0.85, hit: { push: -90 } }],
['riser', dnb.riser, { vel: 0.8, hit: { plane: [0, -1], push: 60, lift: 60 } }],
['hat.2', hat(9000, 0.035, 0.28), { vel: 0.5 }],
['kick.sub', dnb.kickSub, { vel: 0.85, hit: { push: 150 } }],
['hat.3', hat(7500, 0.03, 0.3), { vel: 0.5 }],
['tom.high', dnb.tom(260), { vel: 0.8, hit: { push: 80 } }],
['snare.ghost', dnb.ghost, { vel: 0.6, hit: { push: -20 } }],
['tom.low', dnb.tom(170), { vel: 0.85, hit: { push: 100 } }],
['hat.4', hat(10000, 0.03, 0.26), { vel: 0.5 }],
['crash', voices.crash, { vel: 0.7, hit: { push: 240, reach: 270 } }],
['hat.5', hat(8500, 0.04, 0.28), { vel: 0.5 }],
['reverse', dnb.reverse, { vel: 0.8, hit: { push: -120, reach: 400 } }],
['snare.clap', voices.clap, { vel: 0.7, hit: { push: -70 } }],
['downlifter', dnb.downlifter, { vel: 0.8, hit: { plane: [0, 1], push: 120 } }],
['hat.open', hat(6500, 0.22, 0.28), { vel: 0.45 }],
['impact', dnb.impact, { hit: { push: 520, reach: 380 } }],
['ride', dnb.ride, { vel: 0.5 }],
['vox.ah', dnb.vox, { vel: 0.7, hit: { push: 30, lift: 30 } }],
['snare.rim', voices.rim, { vel: 0.6, hit: { push: -30 } }],
['laser', dnb.laser, { vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
['shaker', dnb.shaker, { vel: 0.5 }],
],
};
// Track 3, "All You Twin": 2000s IDM in E minor at 152 bpm. A warped music box and warm pads
// over acid bass and drill-fast, scattered drums full of digital debris.
// a staircase waveshaper: quantises the signal to a few levels, like a low-bit sampler
const crushCurves = {};
function crushCurve(levels) {
if (!crushCurves[levels]) {
const n = 1024, c = new Float32Array(n + 1); // odd length: silence stays at 0
for (let i = 0; i <= n; i++) { const x = i / n * 2 - 1; c[i] = Math.round(x * levels) / levels; }
crushCurves[levels] = c;
}
return crushCurves[levels];
}
function crusher(ctx, levels) {
const w = ctx.createWaveShaper();
w.curve = crushCurve(levels);
return w;
}
const musicBox = freq => (ctx, out, t) => { // a small metal tine on worn tape: bright, wobbling slightly
const warble = ctx.createOscillator(), depth = ctx.createGain();
warble.frequency.value = 0.7 + Math.random() * 0.4;
depth.gain.value = freq * 0.004;
warble.connect(depth);
[[1, 0.2, 1.6], [2, 0.04, 0.8], [4.02, 0.05, 0.35]].forEach(([mul, peak, len]) => {
const o = ctx.createOscillator(), g = decay(ctx, t, peak, len);
o.type = 'sine'; o.frequency.value = freq * mul;
depth.connect(o.frequency);
o.connect(g); g.connect(out);
o.start(t); o.stop(t + len + 0.05);
});
warble.start(t); warble.stop(t + 1.7);
};
const acid = freq => (ctx, out, t) => { // a squelching resonant bassline note
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 14;
lp.frequency.setValueAtTime(260, t);
lp.frequency.exponentialRampToValueAtTime(2400, t + 0.015);
lp.frequency.exponentialRampToValueAtTime(280, t + 0.2);
const drive = ctx.createWaveShaper();
drive.curve = crushCurve(24);
const g = decay(ctx, t, 0.3, 0.24);
lp.connect(drive); drive.connect(g); g.connect(out);
const o = ctx.createOscillator();
o.type = 'sawtooth'; o.frequency.value = freq;
o.connect(lp); o.start(t); o.stop(t + 0.27);
};
const warmPad = freqs => (ctx, out, t) => { // detuned saws, softened, with a slow tape wow
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 900; lp.Q.value = 0.6;
const g = swell(ctx, t, 0.13, 0.9, 1.8, 1.8);
lp.connect(g); g.connect(out);
const wow = ctx.createOscillator(), wd = ctx.createGain();
wow.frequency.value = 0.45; wd.gain.value = 9; // cents
wow.connect(wd);
freqs.forEach(freq => [-9, 9].forEach(det => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
wd.connect(o.detune);
og.gain.value = 0.17;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 4.6);
}));
wow.start(t); wow.stop(t + 4.6);
};
const idm = {
kickHard: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 150, to: 48, glide: 0.07, peak: 0.75, len: 0.25 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 4000, peak: 0.25, len: 0.008 });
},
kickShort: (ctx, out, t) => toneHit(ctx, out, t, { freq: 180, to: 60, glide: 0.04, peak: 0.8, len: 0.12 }),
kick808: (ctx, out, t) => {
const drive = ctx.createWaveShaper(), g = decay(ctx, t, 0.5, 1.2);
drive.curve = crushCurve(40);
drive.connect(g); g.connect(out);
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(60, t);
o.frequency.exponentialRampToValueAtTime(42, t + 0.4);
o.connect(drive); o.start(t); o.stop(t + 1.25);
},
subDrop: (ctx, out, t) => { // a long falling sub: the floor giving way
toneHit(ctx, out, t, { freq: 120, to: 25, glide: 1.5, peak: 0.7, len: 1.8 });
noiseHit(ctx, out, t, { type: 'lowpass', freq: 600, peak: 0.35, len: 0.3 });
},
snareCrack: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'bandpass', freq: 3000, peak: 0.5, len: 0.1 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.25, len: 0.05 });
toneHit(ctx, out, t, { type: 'triangle', freq: 250, peak: 0.2, len: 0.05 });
},
snare808: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'highpass', freq: 1500, peak: 0.4, len: 0.2 });
toneHit(ctx, out, t, { freq: 180, peak: 0.25, len: 0.15 });
toneHit(ctx, out, t, { freq: 330, peak: 0.15, len: 0.1 });
},
clapGlitch: (ctx, out, t) => { // a clap that never claps the same way twice
const n = 2 + Math.floor(Math.random() * 4);
for (let i = 0; i < n; i++) noiseHit(ctx, out, t + i * (0.006 + Math.random() * 0.012), { type: 'bandpass', freq: 1300 + Math.random() * 600, Q: 1, peak: 0.35, len: 0.02 });
noiseHit(ctx, out, t + 0.04, { type: 'bandpass', freq: 1200, peak: 0.25, len: 0.12 });
},
snareGhost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.15, len: 0.05 }),
click: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 9000, peak: 0.5, len: 0.004 }),
click2: (ctx, out, t) => toneHit(ctx, out, t, { type: 'square', freq: 3000, peak: 0.3, len: 0.006 }),
blip: (ctx, out, t) => toneHit(ctx, out, t, { freq: 2200, peak: 0.25, len: 0.03 }),
bleep: (ctx, out, t) => {
toneHit(ctx, out, t, { type: 'square', freq: 1320, peak: 0.12, len: 0.025 });
toneHit(ctx, out, t + 0.03, { type: 'square', freq: 1760, peak: 0.1, len: 0.03 });
},
zap: (ctx, out, t) => toneHit(ctx, out, t, { freq: 4000, to: 150, glide: 0.03, peak: 0.3, len: 0.04 }),
crush: (ctx, out, t) => { // a burst of 2-bit noise
const src = noiseSource(ctx), cr = crusher(ctx, 2), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.4, 0.12);
bp.type = 'bandpass'; bp.frequency.value = 1500; bp.Q.value = 0.7;
src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
src.start(t, Math.random()); src.stop(t + 0.14);
},
static: (ctx, out, t) => { // vinyl crackle
for (let i = 0; i < 12; i++) noiseHit(ctx, out, t + Math.random() * 0.45, { type: 'highpass', freq: 3000, peak: 0.05 + Math.random() * 0.2, len: 0.004 });
noiseHit(ctx, out, t, { type: 'bandpass', freq: 5000, Q: 0.5, peak: 0.03, len: 0.5 });
},
stutter: (ctx, out, t) => { // a tiny buffer caught repeating, faster and faster
let ti = t;
for (let i = 0; i < 9; i++) {
noiseHit(ctx, out, ti, { type: 'bandpass', freq: 1800, Q: 2, peak: 0.25, len: 0.02 });
toneHit(ctx, out, ti, { type: 'square', freq: 440, peak: 0.06, len: 0.02 });
ti += 0.06 * Math.pow(0.82, i);
}
},
metalPing: (ctx, out, t) => { // FM: one oscillator wobbling another at an odd ratio
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.16, 0.5);
car.frequency.value = 1500; mod.frequency.value = 1500 * 1.4757;
idx.gain.setValueAtTime(1800, t); idx.gain.exponentialRampToValueAtTime(20, t + 0.4);
mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
car.start(t); mod.start(t); car.stop(t + 0.55); mod.stop(t + 0.55);
},
fmBell: (ctx, out, t) => {
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.14, 1.3);
car.frequency.value = 659.25; mod.frequency.value = 659.25 * 3.5;
idx.gain.setValueAtTime(1600, t); idx.gain.exponentialRampToValueAtTime(30, t + 1.0);
mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
car.start(t); mod.start(t); car.stop(t + 1.35); mod.stop(t + 1.35);
},
bitChord: (ctx, out, t) => { // an E-minor stab through a 3-bit crusher
const cr = crusher(ctx, 3), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 0.3);
lp.type = 'lowpass'; lp.frequency.value = 4000;
cr.connect(lp); lp.connect(g); g.connect(out);
[329.63, 392.0, 493.88].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq;
og.gain.value = 0.3;
o.connect(og); og.connect(cr);
o.start(t); o.stop(t + 0.32);
});
},
grainVox: (ctx, out, t) => { // a vowel chopped into grains, each at a slightly different pitch
for (let i = 0; i < 7; i++) {
const ti = t + i * 0.055, src = ctx.createOscillator(), g = decay(ctx, ti, 0.22, 0.035);
src.type = 'sawtooth'; src.frequency.value = 180 * Math.pow(2, (Math.floor(Math.random() * 5) - 2) / 12);
g.connect(out);
[[600, 7], [1000, 9]].forEach(([freq, Q]) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
src.connect(bp); bp.connect(g);
});
src.start(ti); src.stop(ti + 0.045);
}
},
tapeStop: (ctx, out, t) => { // the tape slowing to a halt
const lp = ctx.createBiquadFilter(), g = ctx.createGain();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(3000, t);
lp.frequency.exponentialRampToValueAtTime(200, t + 0.8);
g.gain.value = 0;
g.gain.setValueAtTime(0.2, t);
g.gain.linearRampToValueAtTime(0, t + 0.85);
lp.connect(g); g.connect(out);
[164.81, 246.94, 329.63].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(freq, t);
o.frequency.exponentialRampToValueAtTime(freq * 0.2, t + 0.8);
og.gain.value = 0.3;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 0.88);
});
},
reverseSnare: (ctx, out, t) => {
const bp = ctx.createBiquadFilter(), g = ctx.createGain();
bp.type = 'bandpass'; bp.frequency.value = 2500; bp.Q.value = 0.6;
g.gain.value = 0;
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(0.4, t + 0.45);
g.gain.linearRampToValueAtTime(0, t + 0.47);
g.connect(out);
noiseBed(ctx, t, bp, g, t + 0.5);
},
};
const KIT_IDM = {
// ring 1: music box, E minor climbing clockwise round the centre
1: [
['box.e4', musicBox(329.63)], ['box.f♯4', musicBox(369.99)], ['box.g4', musicBox(392.0)], ['box.a4', musicBox(440.0)],
['box.b4', musicBox(493.88)], ['box.c5', musicBox(523.25)], ['box.d5', musicBox(587.33)], ['box.e5', musicBox(659.25)],
].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
// ring 2: acid along the top and down the right, glitch-tonal along the bottom, pads up the left
2: [
['acid.e2', acid(82.41), { vel: 0.8, hit: { push: 35 } }],
['acid.g2', acid(98.0), { vel: 0.8, hit: { push: 35 } }],
['acid.a2', acid(110.0), { vel: 0.8, hit: { push: 35 } }],
['acid.b2', acid(123.47), { vel: 0.8, hit: { push: 35 } }],
['acid.d3', acid(146.83), { vel: 0.8, hit: { push: 35 } }],
['acid.e3', acid(164.81), { vel: 0.8, hit: { push: 35 } }],
['acid.g3', acid(196.0), { vel: 0.8, hit: { push: 35 } }],
['acid.b3', acid(246.94), { vel: 0.8, hit: { push: 35 } }],
['bit.chord', idm.bitChord, { vel: 0.8, hit: { push: 140, reach: 300, scatter: true } }],
['fm.bell', idm.fmBell, { vel: 0.8, hit: { push: 30 } }],
['grain.vox', idm.grainVox, { vel: 0.8, hit: { push: 60, reach: 300, lift: 30, scatter: true } }],
['tape.stop', idm.tapeStop, { vel: 0.9, hit: { plane: [0, 1], push: 140 } }],
['pad.em9', warmPad([164.81, 196.0, 246.94, 293.66, 369.99]), { hit: { push: 12, lift: 40 } }],
['pad.cmaj7', warmPad([130.81, 164.81, 196.0, 246.94]), { hit: { push: 12, lift: 40 } }],
['pad.am9', warmPad([110.0, 130.81, 164.81, 196.0, 246.94]), { hit: { push: 12, lift: 40 } }],
['pad.bsus', warmPad([123.47, 164.81, 185.0, 220.0]), { hit: { push: 12, lift: 40 } }],
],
// ring 3: kicks and the sub drop in the corners, snares mid-side, hats and clicks by the corners, debris between
3: [
['kick.hard', idm.kickHard, { hit: { push: 160 } }],
['hat.tick', hat(8500, 0.022, 0.55), { vel: 0.6 }],
['zap.small', idm.zap, { vel: 0.6, hit: { push: 60, reach: 200, scatter: true } }],
['snare.crack', idm.snareCrack, { vel: 0.8, hit: { push: -80 } }],
['crush', idm.crush, { vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
['hat.closed', hat(8000, 0.035, 0.4), { vel: 0.5 }],
['kick.short', idm.kickShort, { vel: 0.8, hit: { push: 120 } }],
['hat.shuffle', hat(6500, 0.06, 0.35), { vel: 0.5 }],
['static', idm.static, { vel: 0.6, hit: { push: 90, reach: 300, scatter: true } }],
['snare.808', idm.snare808, { vel: 0.75, hit: { push: -60 } }],
['stutter', idm.stutter, { vel: 0.7, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
['hat.open', hat(7000, 0.3, 0.25), { vel: 0.45 }],
['kick.808', idm.kick808, { vel: 0.85, hit: { push: 200, reach: 300 } }],
['click.1', idm.click, { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['metal.ping', idm.metalPing, { vel: 0.7, hit: { push: 40 } }],
['clap.glitch', idm.clapGlitch, { vel: 0.7, hit: { push: 120, reach: 250, scatter: true } }],
['rim.glitch', voices.rim, { vel: 0.6, hit: { push: -30 } }],
['click.2', idm.click2, { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['sub.drop', idm.subDrop, { hit: { push: 480, reach: 380 } }],
['blip', idm.blip, { vel: 0.6 }],
['tick.wood', voices.wood, { vel: 0.6, hit: { push: -20 } }],
['snare.ghost', idm.snareGhost, { vel: 0.5, hit: { push: -15 } }],
['reverse.snare', idm.reverseSnare, { vel: 0.8, hit: { push: -110, reach: 380 } }],
['bleep', idm.bleep, { vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
],
};
// Track 4, "Zones": drill and bass in G minor at 174 bpm. All You Twin's glitches on a
// drum and bass frame: chopped crunchy breaks, reese and growl bass, glassy FM plucks.
// pass any voice through a bit-crusher
const crushed = (voice, levels) => (ctx, out, t) => {
const cr = crusher(ctx, levels);
cr.connect(out);
voice(ctx, cr, t);
};
const glassPluck = freq => echoes((ctx, out, t) => { // a glassy FM pluck with one echo
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = decay(ctx, t, 0.26, 0.45);
car.frequency.value = freq; mod.frequency.value = freq * 2.01;
idx.gain.setValueAtTime(freq * 3, t);
idx.gain.exponentialRampToValueAtTime(freq * 0.05, t + 0.25);
mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
car.start(t); mod.start(t); car.stop(t + 0.5); mod.stop(t + 0.5);
}, 1, 3 * DNB_STEP, 0.4);
const growl = freq => (ctx, out, t) => { // FM bass whose brightness wobbles: a growl
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
const lfo = ctx.createOscillator(), lfoDepth = ctx.createGain();
car.frequency.value = freq; mod.frequency.value = freq * 2;
idx.gain.value = freq * 2;
lfo.frequency.value = 7; lfoDepth.gain.value = freq * 1.5;
lfo.connect(lfoDepth); lfoDepth.connect(idx.gain);
mod.connect(idx); idx.connect(car.frequency);
const cr = crusher(ctx, 10), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.3, 0.6);
lp.type = 'lowpass'; lp.frequency.value = 1800;
car.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
[car, mod, lfo].forEach(o => { o.start(t); o.stop(t + 0.65); });
};
const subTone = freq => (ctx, out, t) => {
const g = swell(ctx, t, 0.5, 0.01, 0.3, 0.5);
g.connect(out);
const o = ctx.createOscillator();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); o.start(t); o.stop(t + 0.85);
};
const crushedChord = (freqs, levels) => (ctx, out, t) => {
const cr = crusher(ctx, levels), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 0.3);
lp.type = 'lowpass'; lp.frequency.value = 4000;
cr.connect(lp); lp.connect(g); g.connect(out);
freqs.forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq;
og.gain.value = 0.3;
o.connect(og); og.connect(cr);
o.start(t); o.stop(t + 0.32);
});
};
const stutterChord = freqs => (ctx, out, t) => { // a chord caught in a skipping buffer
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 1600; bp.Q.value = 0.7;
bp.connect(out);
let ti = t;
for (let i = 0; i < 6; i++) {
const g = decay(ctx, ti, 0.22 * Math.pow(0.8, i), 0.05);
g.connect(bp);
freqs.forEach(freq => {
const o = ctx.createOscillator();
o.type = 'square'; o.frequency.value = freq;
o.connect(g); o.start(ti); o.stop(ti + 0.06);
});
ti += 0.07 * Math.pow(0.85, i);
}
};
const tapeStopOf = freqs => (ctx, out, t) => {
const lp = ctx.createBiquadFilter(), g = ctx.createGain();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(3000, t);
lp.frequency.exponentialRampToValueAtTime(200, t + 0.8);
g.gain.value = 0;
g.gain.setValueAtTime(0.2, t);
g.gain.linearRampToValueAtTime(0, t + 0.85);
lp.connect(g); g.connect(out);
freqs.forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(freq, t);
o.frequency.exponentialRampToValueAtTime(freq * 0.2, t + 0.8);
og.gain.value = 0.3;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 0.88);
});
};
const KIT_FAULT = {
// ring 1: glass plucks, G minor climbing clockwise round the centre
1: [
['glass.g4', glassPluck(392.0)], ['glass.a4', glassPluck(440.0)], ['glass.b♭4', glassPluck(466.16)], ['glass.c5', glassPluck(523.25)],
['glass.d5', glassPluck(587.33)], ['glass.e♭5', glassPluck(622.25)], ['glass.f5', glassPluck(698.46)], ['glass.g5', glassPluck(783.99)],
].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
// ring 2: glitch-tonal along the top, sub, growl and acid down the right, reese round the bottom, pads up the left
2: [
['bit.stab', crushedChord([392.0, 466.16, 587.33], 3), { vel: 0.8, hit: { push: 140, reach: 300, scatter: true } }],
['stab.stutter', stutterChord([392.0, 466.16, 587.33]), { vel: 0.8, hit: { push: 120, reach: 280, scatter: true } }],
['vox.chop', idm.grainVox, { vel: 0.8, hit: { push: 60, reach: 300, lift: 30, scatter: true } }],
['tape.stop', tapeStopOf([196.0, 293.66, 392.0]), { vel: 0.9, hit: { plane: [0, 1], push: 140 } }],
['sub.g1', subTone(49.0), { hit: { push: 90 } }],
['growl.d2', growl(73.42), { vel: 0.8, hit: { push: 100, reach: 260, scatter: true } }],
['acid.g2', acid(98.0), { vel: 0.8, hit: { push: 35 } }],
['acid.d3', acid(146.83), { vel: 0.8, hit: { push: 35 } }],
['reese.g1', reese(49.0), { vel: 0.8, hit: { push: 70 } }],
['reese.a1', reese(55.0), { vel: 0.8, hit: { push: 70 } }],
['reese.b♭1', reese(58.27), { vel: 0.8, hit: { push: 70 } }],
['reese.c2', reese(65.41), { vel: 0.8, hit: { push: 70 } }],
['reese.d2', reese(73.42), { vel: 0.8, hit: { push: 70 } }],
['reese.e♭2', reese(77.78), { vel: 0.8, hit: { push: 70 } }],
['pad.gm9', warmPad([196.0, 233.08, 293.66, 349.23, 440.0]), { hit: { push: 12, lift: 40 } }],
['pad.e♭maj7', warmPad([155.56, 196.0, 233.08, 293.66]), { hit: { push: 12, lift: 40 } }],
],
// ring 3: kicks, crash and impact in the corners, snares mid-side, hats and debris by the corners, effects between
3: [
['kick.break', crushed(dnb.kickPunch, 16), { vel: 0.8, hit: { push: 170 } }],
['hat.break', crushed(hat(7000, 0.05, 0.45), 5), { vel: 0.5 }],
['zap', idm.zap, { vel: 0.6, hit: { push: 60, reach: 200, scatter: true } }],
['snare.amen', crushed(dnb.snare, 10), { vel: 0.85, hit: { push: -90 } }],
['crush', idm.crush, { vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
['hat.tick', hat(8500, 0.022, 0.5), { vel: 0.5 }],
['kick.sub', dnb.kickSub, { vel: 0.85, hit: { push: 150 } }],
['hat.open', hat(6500, 0.22, 0.28), { vel: 0.45 }],
['stutter', idm.stutter, { vel: 0.7, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
['snare.crush', crushed(idm.snareCrack, 4), { vel: 0.75, hit: { push: 110, reach: 220, scatter: true } }],
['metal.ping', idm.metalPing, { vel: 0.7, hit: { push: 40 } }],
['ride.crunch', crushed(dnb.ride, 5), { vel: 0.5 }],
['crash.crush', crushed(voices.crash, 4), { vel: 0.5, hit: { push: 250, reach: 230, scatter: true } }],
['click', idm.click, { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['reverse', dnb.reverse, { vel: 0.8, hit: { push: -120, reach: 400 } }],
['snare.ghost', idm.snareGhost, { vel: 0.5, hit: { push: -15 } }],
['riser', dnb.riser, { vel: 0.8, hit: { plane: [0, -1], push: 60, lift: 60 } }],
['blip', idm.blip, { vel: 0.6 }],
['impact', dnb.impact, { hit: { push: 520, reach: 380 } }],
['bleep', idm.bleep, { vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
['laser', dnb.laser, { vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
['rim.glitch', voices.rim, { vel: 0.6, hit: { push: -30 } }],
['siren', sirenVoice, { vel: 0.6, hit: { plane: [1, 0], push: 70 } }],
['static', idm.static, { vel: 0.6, hit: { push: 90, reach: 300, scatter: true } }],
],
};
// Track 5, "Torque Region": slow, heavy glitch in C phrygian at 84 bpm, half-time,
// with dark drum textures: distorted kicks, crushed snares, struck metal, creaking wood.
// soft clipping: pushes a sound into warm distortion
const driveCurves = {};
function driver(ctx, amount) {
if (!driveCurves[amount]) {
const n = 1024, c = new Float32Array(n + 1);
for (let i = 0; i <= n; i++) { const x = i / n * 2 - 1; c[i] = Math.tanh(amount * x) / Math.tanh(amount); }
driveCurves[amount] = c;
}
const w = ctx.createWaveShaper();
w.curve = driveCurves[amount];
return w;
}
const ashBell = freq => (ctx, out, t) => { // a dark FM bell, two voices beating, a little worn
const cr = crusher(ctx, 48), g = decay(ctx, t, 0.2, 2.4);
cr.connect(g); g.connect(out);
[1, 1.003].forEach(mul => {
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
car.frequency.value = freq * mul; mod.frequency.value = freq * mul * 1.41;
idx.gain.setValueAtTime(freq * 2, t);
idx.gain.exponentialRampToValueAtTime(freq * 0.2, t + 2);
mod.connect(idx); idx.connect(car.frequency); car.connect(cr);
car.start(t); mod.start(t); car.stop(t + 2.45); mod.stop(t + 2.45);
});
};
const sludge = freq => (ctx, out, t) => { // distorted sub bass that slowly opens and closes
const dr = driver(ctx, 3), lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.085, 0.02, 0.6, 0.9);
lp.type = 'lowpass'; lp.Q.value = 3;
lp.frequency.setValueAtTime(280, t);
lp.frequency.linearRampToValueAtTime(520, t + 0.6);
lp.frequency.linearRampToValueAtTime(250, t + 1.4);
dr.connect(lp); lp.connect(g); g.connect(out);
for (const [type, mul, lvl] of [['sine', 1, 0.7], ['sawtooth', 1, 0.35]]) {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = type; o.frequency.value = freq * mul;
og.gain.value = lvl;
o.connect(og); og.connect(dr);
o.start(t); o.stop(t + 1.55);
}
};
const choir = freqs => (ctx, out, t) => { // a dark "oh" sung by many: saws through vowel formants
const g = swell(ctx, t, 0.2, 1.5, 3, 2.5);
g.connect(out);
const f1 = ctx.createBiquadFilter(), f2 = ctx.createBiquadFilter();
f1.type = 'bandpass'; f1.frequency.value = 500; f1.Q.value = 4;
f2.type = 'bandpass'; f2.frequency.value = 900; f2.Q.value = 6;
f1.connect(g); f2.connect(g);
freqs.forEach(freq => [-8, 0, 8].forEach(det => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq; o.detune.value = det;
og.gain.value = 0.35;
o.connect(og); og.connect(f1); og.connect(f2);
o.start(t); o.stop(t + 7.1);
}));
};
const darkTones = {
bitDrone: (ctx, out, t) => { // a low chord ground through a 4-level crusher
const cr = crusher(ctx, 4), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.12, 1.4);
lp.type = 'lowpass'; lp.frequency.value = 2200;
cr.connect(lp); lp.connect(g); g.connect(out);
[65.41, 98.0, 130.81].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq;
og.gain.value = 0.3;
o.connect(og); og.connect(cr);
o.start(t); o.stop(t + 1.45);
});
},
granule: (ctx, out, t) => { // a cloud of tiny grains on the phrygian scale
const notes = [261.63, 277.18, 311.13, 349.23, 392.0, 415.3, 466.16, 523.25, 554.37, 622.25, 698.46, 783.99];
for (let i = 0; i < 24; i++) {
const ti = t + Math.random() * 1.5;
toneHit(ctx, out, ti, { freq: notes[Math.floor(Math.random() * notes.length)] * (Math.random() < 0.3 ? 2 : 1), peak: 0.06, len: 0.02 + Math.random() * 0.03 });
}
},
tapeDrag: (ctx, out, t) => { // a chord slowly dragged down to nothing
const lp = ctx.createBiquadFilter(), g = ctx.createGain();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(2500, t);
lp.frequency.exponentialRampToValueAtTime(150, t + 2.2);
g.gain.value = 0;
g.gain.setValueAtTime(0.18, t);
g.gain.linearRampToValueAtTime(0, t + 2.3);
lp.connect(g); g.connect(out);
[130.81, 155.56, 196.0, 233.08].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(freq, t);
o.frequency.exponentialRampToValueAtTime(freq * 0.3, t + 2.2);
og.gain.value = 0.3;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 2.35);
});
},
reverseSwell: (ctx, out, t) => { // a pad played backwards, rising out of nothing
const lp = ctx.createBiquadFilter(), g = ctx.createGain();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(300, t);
lp.frequency.exponentialRampToValueAtTime(2500, t + 1.8);
g.gain.value = 0;
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(0.15, t + 1.8);
g.gain.linearRampToValueAtTime(0, t + 1.85);
lp.connect(g); g.connect(out);
[138.59, 174.61, 207.65, 261.63].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq;
og.gain.value = 0.25;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 1.9);
});
},
darkStab: (ctx, out, t) => { // C and D-flat together, overdriven
const dr = driver(ctx, 6), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.2, 0.5);
lp.type = 'lowpass'; lp.frequency.value = 1200;
dr.connect(lp); lp.connect(g); g.connect(out);
[65.41, 130.81, 138.59, 196.0].forEach(freq => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = freq;
og.gain.value = 0.25;
o.connect(og); og.connect(dr);
o.start(t); o.stop(t + 0.55);
});
},
voxGhost: (ctx, out, t) => { // a low moan, sinking as it fades
const src = ctx.createOscillator(), vib = ctx.createOscillator(), vd = ctx.createGain();
src.type = 'sawtooth';
src.frequency.setValueAtTime(110, t);
src.frequency.exponentialRampToValueAtTime(98, t + 1.5);
vib.frequency.value = 4.5; vd.gain.value = 2.5;
vib.connect(vd); vd.connect(src.frequency);
const g = swell(ctx, t, 0.22, 0.4, 0.5, 0.8);
g.connect(out);
[[400, 8, 1], [800, 8, 0.5]].forEach(([freq, Q, lvl]) => {
const bp = ctx.createBiquadFilter(), fg = ctx.createGain();
bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = Q;
fg.gain.value = lvl;
src.connect(bp); bp.connect(fg); fg.connect(g);
});
src.start(t); vib.start(t); src.stop(t + 1.75); vib.stop(t + 1.75);
},
};
const dark = {
kickDirt: (ctx, out, t) => {
const dr = driver(ctx, 4), lp = ctx.createBiquadFilter(), level = ctx.createGain();
lp.type = 'lowpass'; lp.frequency.value = 400;
level.gain.value = 0.34; // distortion flattens the peaks, so pull it back down
dr.connect(lp); lp.connect(level); level.connect(out);
toneHit(ctx, dr, t, { freq: 90, to: 38, glide: 0.25, peak: 0.75, len: 0.7 });
noiseHit(ctx, out, t, { type: 'lowpass', freq: 1500, peak: 0.2, len: 0.02 });
},
kickHollow: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 120, to: 70, glide: 0.1, peak: 0.55, len: 0.4 });
toneHit(ctx, out, t, { type: 'triangle', freq: 60, peak: 0.35, len: 0.45 });
noiseHit(ctx, out, t, { type: 'bandpass', freq: 300, Q: 4, peak: 0.3, len: 0.2 });
},
kickGate: (ctx, out, t) => { // a boom cut off sharply, like a gated room
toneHit(ctx, out, t, { freq: 70, to: 40, glide: 0.2, peak: 0.7, len: 0.3 });
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.25, 0.005, 0.25, 0.02);
lp.type = 'lowpass'; lp.frequency.value = 900;
g.connect(out);
noiseBed(ctx, t, lp, g, t + 0.3);
},
snareAsh: (ctx, out, t) => {
const cr = crusher(ctx, 8), lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 3500;
cr.connect(lp); lp.connect(out);
noiseHit(ctx, cr, t, { type: 'bandpass', freq: 900, Q: 0.7, peak: 0.5, len: 0.35 });
toneHit(ctx, cr, t, { type: 'triangle', freq: 150, to: 120, glide: 0.08, peak: 0.3, len: 0.15 });
},
snareMetal: (ctx, out, t) => {
metal(420, [1, 1.37, 1.92, 2.61], 0.2, 0.5)(ctx, out, t);
noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.3, len: 0.12 });
},
clapRust: (ctx, out, t) => {
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 2500;
lp.connect(out);
for (let i = 0; i < 3; i++) noiseHit(ctx, lp, t + i * 0.014, { type: 'bandpass', freq: 800, Q: 1.2, peak: 0.4, len: 0.025 });
noiseHit(ctx, lp, t + 0.042, { type: 'bandpass', freq: 750, peak: 0.3, len: 0.3 });
},
snareGhost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 1400, peak: 0.15, len: 0.06 }),
hatChain: (ctx, out, t) => { // like a chain dropped onto metal
[3100, 4300, 5900, 7400].forEach(freq => toneHit(ctx, out, t + Math.random() * 0.006, { freq: freq * (0.98 + Math.random() * 0.04), peak: 0.05, len: 0.08 }));
noiseHit(ctx, out, t, { type: 'highpass', freq: 5000, peak: 0.1, len: 0.05 });
},
hatTick: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 4500, Q: 1.5, peak: 0.35, len: 0.02 }),
hatSand: (ctx, out, t) => {
const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.18, 0.03, 0.02, 0.1);
bp.type = 'bandpass'; bp.frequency.value = 3500; bp.Q.value = 0.6;
g.connect(out);
noiseBed(ctx, t, bp, g, t + 0.18);
},
hatOpen: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 4000, Q: 0.8, peak: 0.2, len: 0.45 }),
anvil: metal(780, [1, 2.76, 5.4, 8.93], 0.2, 1.2),
pipe: (ctx, out, t) => {
metal(130, [1, 2.76, 5.4], 0.3, 1.6)(ctx, out, t);
noiseHit(ctx, out, t, { type: 'lowpass', freq: 400, peak: 0.3, len: 0.06 });
},
scrape: (ctx, out, t) => { // metal dragged across metal
const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.15, 0.1, 0.5, 0.4), trem = ctx.createGain();
bp.type = 'bandpass'; bp.Q.value = 6;
bp.frequency.setValueAtTime(2000, t);
bp.frequency.exponentialRampToValueAtTime(600, t + 1.0);
const lfo = ctx.createOscillator(), lg = ctx.createGain();
lfo.frequency.value = 18; lg.gain.value = 0.5;
trem.gain.value = 0.5;
lfo.connect(lg); lg.connect(trem.gain);
g.connect(trem); trem.connect(out);
noiseBed(ctx, t, bp, g, t + 1.05);
lfo.start(t); lfo.stop(t + 1.05);
},
creak: (ctx, out, t) => { // an old wooden beam taking weight
const o = ctx.createOscillator(), wob = ctx.createOscillator(), wd = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = 60;
wob.frequency.value = 3; wd.gain.value = 18;
wob.connect(wd); wd.connect(o.frequency);
const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.2, 0.1, 0.4, 0.3);
bp.type = 'bandpass'; bp.frequency.value = 900; bp.Q.value = 8;
o.connect(bp); bp.connect(g); g.connect(out);
o.start(t); wob.start(t); o.stop(t + 0.85); wob.stop(t + 0.85);
},
knock: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 520, to: 480, glide: 0.02, peak: 0.4, len: 0.06 });
noiseHit(ctx, out, t, { type: 'bandpass', freq: 900, peak: 0.3, len: 0.03 });
},
staticDark: (ctx, out, t) => {
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 2500;
lp.connect(out);
idm.static(ctx, lp, t);
},
crushLow: (ctx, out, t) => {
const src = noiseSource(ctx), cr = crusher(ctx, 2), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.5, 0.25);
lp.type = 'lowpass'; lp.frequency.value = 600;
src.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
src.start(t, Math.random()); src.stop(t + 0.28);
},
stutterSlow: (ctx, out, t) => {
let ti = t;
for (let i = 0; i < 8; i++) {
noiseHit(ctx, out, ti, { type: 'bandpass', freq: 900, Q: 2, peak: 0.3, len: 0.03 });
toneHit(ctx, out, ti, { type: 'square', freq: 110, peak: 0.08, len: 0.03 });
ti += 0.12 * Math.pow(0.85, i);
}
},
rumble: (ctx, out, t) => {
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.5, 0.3, 0.6, 2.0);
lp.type = 'lowpass'; lp.frequency.value = 200; lp.Q.value = 0.7;
g.connect(out);
noiseBed(ctx, t, lp, g, t + 3.0);
toneHit(ctx, out, t, { freq: 40, peak: 0.3, len: 2.5 });
},
dropVoid: (ctx, out, t) => { // the floor falling away
const dr = driver(ctx, 2);
dr.connect(out);
toneHit(ctx, dr, t, { freq: 90, to: 18, glide: 2.5, peak: 0.7, len: 2.8 });
},
clickDark: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 2000, Q: 3, peak: 0.4, len: 0.006 }),
bleepLow: (ctx, out, t) => {
toneHit(ctx, out, t, { type: 'square', freq: 330, peak: 0.1, len: 0.05 });
toneHit(ctx, out, t + 0.06, { type: 'square', freq: 247, peak: 0.1, len: 0.06 });
},
reverseHit: (ctx, out, t) => {
const bp = ctx.createBiquadFilter(), g = ctx.createGain();
bp.type = 'bandpass'; bp.frequency.value = 1200; bp.Q.value = 0.7;
g.gain.value = 0;
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(0.35, t + 0.6);
g.gain.linearRampToValueAtTime(0, t + 0.62);
g.connect(out);
noiseBed(ctx, t, bp, g, t + 0.65);
},
};
const KIT_UNDERTOW = {
// ring 1: ash bells, C phrygian climbing clockwise round the centre
1: [
['ash.c4', ashBell(261.63)], ['ash.d♭4', ashBell(277.18)], ['ash.e♭4', ashBell(311.13)], ['ash.f4', ashBell(349.23)],
['ash.g4', ashBell(392.0)], ['ash.a♭4', ashBell(415.3)], ['ash.b♭4', ashBell(466.16)], ['ash.c5', ashBell(523.25)],
].map(([id, voice]) => [id, voice, { vel: 0.9 }]),
// ring 2: sludge bass along the top and down the right, glitch-tonal round the bottom, drones and choir up the left
2: [
['sludge.g1', sludge(49.0), { hit: { push: 80 } }],
['sludge.a♭1', sludge(51.91), { hit: { push: 80 } }],
['sludge.c2', sludge(65.41), { hit: { push: 80 } }],
['sludge.d♭2', sludge(69.3), { hit: { push: 80 } }],
['sludge.e♭2', sludge(77.78), { hit: { push: 80 } }],
['sludge.f2', sludge(87.31), { hit: { push: 80 } }],
['bit.drone', darkTones.bitDrone, { vel: 0.8, hit: { push: 120, reach: 320, scatter: true } }],
['granule', darkTones.granule, { vel: 0.8, hit: { push: 40, reach: 400, lift: 20, scatter: true } }],
['tape.drag', darkTones.tapeDrag, { vel: 0.9, hit: { plane: [0, 1], push: 160 } }],
['reverse.swell', darkTones.reverseSwell, { vel: 0.8, hit: { push: -90, reach: 420 } }],
['dark.stab', darkTones.darkStab, { vel: 0.8, hit: { push: 130, reach: 320, scatter: true } }],
['vox.ghost', darkTones.voxGhost, { vel: 0.8, hit: { push: 20, lift: 50 } }],
['drone.c', drone([65.41, 98.0], 0.24), { vel: 0.9, hit: { push: 10, lift: 35 } }],
['drone.d♭', drone([69.3, 103.83], 0.22), { vel: 0.9, hit: { push: 10, lift: 35 } }],
['choir.cm', choir([130.81, 155.56, 196.0, 261.63]), { vel: 0.9, hit: { push: 12, lift: 45 } }],
['choir.d♭', choir([138.59, 174.61, 207.65]), { vel: 0.9, hit: { push: 12, lift: 45 } }],
],
// ring 3: kicks, rumble and the void in the corners, snares mid-side, hats and small sounds by the corners, metal and wood between
3: [
['kick.dirt', dark.kickDirt, { hit: { push: 180, reach: 460 } }],
['hat.chain', dark.hatChain, { vel: 0.6, hit: { push: 25, reach: 180, scatter: true } }],
['anvil', dark.anvil, { vel: 0.7, hit: { push: 230, reach: 230, scatter: true } }],
['snare.ash', dark.snareAsh, { vel: 0.85, hit: { push: -100 } }],
['crush.low', dark.crushLow, { vel: 0.8, hit: { push: 240, reach: 200, scatter: true } }],
['hat.tick', dark.hatTick, { vel: 0.5 }],
['kick.hollow', dark.kickHollow, { vel: 0.8, hit: { push: 110 } }],
['hat.sand', dark.hatSand, { vel: 0.5 }],
['pipe', dark.pipe, { vel: 0.7, hit: { push: 90, lift: 40 } }],
['snare.metal', dark.snareMetal, { vel: 0.7, hit: { push: 120, reach: 240, scatter: true } }],
['stutter.slow', dark.stutterSlow, { vel: 0.7, hit: { push: 70, reach: 400, spin: 2, scatter: true } }],
['hat.open', dark.hatOpen, { vel: 0.45 }],
['rumble', dark.rumble, { vel: 0.8, hit: { push: 140, reach: 600, lift: 80 } }],
['click.dark', dark.clickDark, { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['scrape', dark.scrape, { vel: 0.7, hit: { plane: [1, 0], push: 90 } }],
['clap.rust', dark.clapRust, { vel: 0.7, hit: { push: -70 } }],
['kick.gate', dark.kickGate, { vel: 0.85, hit: { push: 150 } }],
['bleep.low', dark.bleepLow, { vel: 0.6, hit: { push: 20, reach: 200, scatter: true } }],
['drop.void', dark.dropVoid, { hit: { push: 500, reach: 400 } }],
['static.dark', dark.staticDark, { vel: 0.6, hit: { push: 80, reach: 300, scatter: true } }],
['creak', dark.creak, { vel: 0.7, hit: { push: 20, reach: 300, spin: 2.5 } }],
['snare.ghost', dark.snareGhost, { vel: 0.5, hit: { push: -15 } }],
['reverse.hit', dark.reverseHit, { vel: 0.8, hit: { push: -120, reach: 380 } }],
['knock', dark.knock, { vel: 0.6, hit: { push: -20 } }],
],
};
// Track 6, "Symbol Hg": generative, Autechre-minded. Patterns come from rules (Euclidean rhythms,
// a cellular automaton), pitches from the octatonic scale, and nearly every sound is FM.
// 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 fmBass = freq => (ctx, out, t) => { // a rubbery FM bass, modulated an octave below itself
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 900;
lp.connect(out);
fmHit(ctx, lp, t, { freq, ratio: 0.5, index: freq * 3, indexTo: freq * 0.5, indexTime: 0.25, peak: 0.32, len: 0.6 });
};
const coldPad = freqs => (ctx, out, t) => { // a hollow pad whose filter breathes slowly
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.14, 2, 2, 2.5);
lp.type = 'lowpass'; lp.Q.value = 2;
lp.frequency.setValueAtTime(400, t);
lp.frequency.linearRampToValueAtTime(1600, t + 3);
lp.frequency.linearRampToValueAtTime(500, t + 6);
lp.connect(g); g.connect(out);
freqs.forEach(freq => [['sine', -6], ['triangle', 6]].forEach(([type, det]) => {
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = type; o.frequency.value = freq; o.detune.value = det;
og.gain.value = 0.35;
o.connect(og); og.connect(lp);
o.start(t); o.stop(t + 6.6);
}));
};
const rule = {
grainCloud: (ctx, out, t) => { // thirty tiny metallic grains
for (let i = 0; i < 30; i++) {
fmHit(ctx, out, t + Math.random() * 1.2, { freq: 800 + Math.random() * 2800, ratio: 1 + Math.random() * 3, index: 1500, indexTo: 50, peak: 0.05, len: 0.03 });
}
},
combDrone: (ctx, out, t) => { // noise tuned into the harmonics of C
const g = swell(ctx, t, 0.4, 1.5, 2.5, 2), boost = ctx.createGain();
boost.gain.value = 5;
boost.connect(g); g.connect(out);
[65.41, 130.81, 196.22, 261.63].forEach((freq, i) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 30;
const lvl = ctx.createGain();
lvl.gain.value = 1 / (i + 1);
bp.connect(lvl); lvl.connect(boost);
noiseBed(ctx, t, bp, lvl, t + 6.1);
});
},
glassHarm: (ctx, out, t) => { // high odd harmonics of A, swelling in
const g = swell(ctx, t, 0.05, 1, 1.5, 2);
g.connect(out);
[550, 770, 990, 1210].forEach(freq => {
const o = ctx.createOscillator();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); o.start(t); o.stop(t + 4.6);
});
},
dataBurst: (ctx, out, t) => { // sixteen FM blips, as if reading data aloud
for (let i = 0; i < 16; i++) {
fmHit(ctx, out, t + i * 0.03, { freq: 800 + Math.random() * 3200, ratio: pickFrom([1, 1.5, 2, 3.5]), index: 1200, indexTo: 50, peak: 0.08, len: 0.02 });
}
},
resoSweep: (ctx, out, t) => {
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.12, 0.1, 0.8, 0.4);
lp.type = 'lowpass'; lp.Q.value = 18;
lp.frequency.setValueAtTime(200, t);
lp.frequency.exponentialRampToValueAtTime(6000, t + 0.6);
lp.frequency.exponentialRampToValueAtTime(300, t + 1.2);
g.connect(out);
noiseBed(ctx, t, lp, g, t + 1.35);
},
formantShift: (ctx, out, t) => { // a buzz whose vowel slides from "ah" to "ee" to "oo"
const src = ctx.createOscillator();
src.type = 'sawtooth'; src.frequency.value = 130.81;
const g = swell(ctx, t, 0.25, 0.2, 1.0, 0.4);
g.connect(out);
[[[800, 300, 350], 8], [[1200, 2300, 800], 10]].forEach(([path, Q]) => {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = Q;
bp.frequency.setValueAtTime(path[0], t);
bp.frequency.linearRampToValueAtTime(path[1], t + 0.75);
bp.frequency.linearRampToValueAtTime(path[2], t + 1.5);
src.connect(bp); bp.connect(g);
});
src.start(t); src.stop(t + 1.65);
},
phaseTone: (ctx, out, t) => { // two pairs of nearly matching tones, slowly phasing
const g = swell(ctx, t, 0.1, 1.5, 1, 1.5);
g.connect(out);
[220, 221.3, 330, 331.1].forEach(freq => {
const o = ctx.createOscillator();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); o.start(t); o.stop(t + 4.1);
});
},
hatRing: (ctx, out, t) => { // ring modulation: one square wave switching another's volume
const a = ctx.createOscillator(), b = ctx.createOscillator(), ring = ctx.createGain(), hp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.25, 0.08);
a.type = 'square'; a.frequency.value = 540;
b.type = 'square'; b.frequency.value = 800;
ring.gain.value = 0;
b.connect(ring.gain); a.connect(ring);
hp.type = 'highpass'; hp.frequency.value = 4000;
ring.connect(hp); hp.connect(g); g.connect(out);
a.start(t); b.start(t); a.stop(t + 0.1); b.stop(t + 0.1);
},
snareGate: (ctx, out, t) => {
const bp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.35, 0.002, 0.07, 0.01);
bp.type = 'bandpass'; bp.frequency.value = 2200;
g.connect(out);
noiseBed(ctx, t, bp, g, t + 0.1);
toneHit(ctx, out, t, { freq: 200, peak: 0.2, len: 0.06 });
},
clapDigital: (ctx, out, t) => {
const cr = crusher(ctx, 6);
cr.connect(out);
for (let i = 0; i < 4; i++) noiseHit(ctx, cr, t + i * 0.008, { type: 'bandpass', freq: 1500, peak: 0.35, len: 0.012 });
},
crunch: (ctx, out, t) => {
const src = noiseSource(ctx), cr = crusher(ctx, 3), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.45, 0.06);
bp.type = 'bandpass'; bp.frequency.value = 900;
src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
src.start(t, Math.random()); src.stop(t + 0.08);
},
grainHit: (ctx, out, t) => {
for (let i = 0; i < 6; i++) fmHit(ctx, out, t + Math.random() * 0.05, { freq: 1000 + Math.random() * 2000, ratio: 2.3, index: 900, indexTo: 20, peak: 0.08, len: 0.015 });
},
buzz: (ctx, out, t) => {
const o = ctx.createOscillator(), cr = crusher(ctx, 4), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.3, 0.15);
o.type = 'square'; o.frequency.value = 55;
bp.type = 'bandpass'; bp.frequency.value = 700;
o.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
o.start(t); o.stop(t + 0.18);
},
reverseFm: (ctx, out, t) => { // an FM clang played backwards
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), g = ctx.createGain();
car.frequency.value = 440; mod.frequency.value = 440 * 1.414; idx.gain.value = 900;
g.gain.value = 0;
g.gain.setValueAtTime(0.001, t);
g.gain.exponentialRampToValueAtTime(0.25, t + 0.5);
g.gain.linearRampToValueAtTime(0, t + 0.52);
mod.connect(idx); idx.connect(car.frequency); car.connect(g); g.connect(out);
car.start(t); mod.start(t); car.stop(t + 0.55); mod.stop(t + 0.55);
},
};
function pickFrom(list) { return list[Math.floor(Math.random() * list.length)]; }
const KIT_RULE = {
// ring 1: FM pings on the octatonic scale (half step, whole step, repeating) round the centre
1: [
['ping.c4', ping(261.63)], ['ping.d♭4', ping(277.18)], ['ping.e♭4', ping(311.13)], ['ping.e4', ping(329.63)],
['ping.f♯4', ping(369.99)], ['ping.g4', ping(392.0)], ['ping.a4', ping(440.0)], ['ping.b♭4', ping(466.16)],
].map(([id, voice]) => [id, voice, { vel: 0.85 }]),
// ring 2: FM bass in minor thirds along the top, textures down the right and bottom, pads up the left
2: [
['fmbass.c2', fmBass(65.41), { hit: { push: 75 } }],
['fmbass.e♭2', fmBass(77.78), { hit: { push: 75 } }],
['fmbass.f♯2', fmBass(92.5), { hit: { push: 75 } }],
['fmbass.a2', fmBass(110.0), { hit: { push: 75 } }],
['data.burst', rule.dataBurst, { vel: 0.8, hit: { push: 120, reach: 300, scatter: true } }],
['grain.cloud', rule.grainCloud, { vel: 0.8, hit: { push: 40, reach: 400, scatter: true } }],
['comb.drone', rule.combDrone, { vel: 0.8, hit: { push: 10, lift: 30 } }],
['glass.harm', rule.glassHarm, { vel: 0.8, hit: { push: 10, lift: 40 } }],
['reso.sweep', rule.resoSweep, { vel: 0.8, hit: { plane: [1, 0], push: 80 } }],
['formant.shift', rule.formantShift, { vel: 0.8, hit: { push: 60, reach: 350, swirl: -1, centre: true } }],
['pluck.metal', metal(311.13, [1, 2.32, 4.25, 6.63], 0.16, 0.9), { vel: 0.8, hit: { push: 40 } }],
['fm.bell.low', fm({ freq: 130.81, ratio: 1.4, index: 400, indexTo: 20, peak: 0.25, len: 2 }), { vel: 0.8, hit: { push: 60, lift: 30 } }],
['bit.choir', crushed(choir([130.81, 155.56, 185.0]), 6), { vel: 0.8, hit: { push: 12, lift: 45 } }],
['phase.tone', rule.phaseTone, { vel: 0.8, hit: { push: 80, reach: 500, swirl: 1, centre: true } }],
['cold.pad.1', coldPad([130.81, 155.56, 185.0, 220.0]), { hit: { push: 12, lift: 40 } }],
['cold.pad.2', coldPad([138.59, 164.81, 196.0, 233.08]), { hit: { push: 12, lift: 40 } }],
],
// ring 3: kicks and the big clang in the corners, snares mid-side, hats and ticks by the corners, FM metal and debris between
3: [
['kick.round', kick(110, 50, 0.06, 0.65, 0.22), { hit: { push: 150 } }],
['hat.fm1', fm({ freq: 7000, ratio: 1.43, index: 6000, peak: 0.3, len: 0.04 }), { vel: 0.5 }],
['clang.2', fm({ freq: 523.25, ratio: 2.76, index: 1500, indexTo: 20, indexTime: 0.4, peak: 0.33, len: 0.7 }), { vel: 0.7, hit: { push: 140, reach: 260, scatter: true } }],
['snare.fm', (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 });
}, { vel: 0.8, hit: { push: 110, reach: 230, scatter: true } }],
['clang.3', fm({ freq: 196.0, ratio: 3.17, index: 1200, indexTo: 20, indexTime: 0.5, peak: 0.37, len: 0.9 }), { vel: 0.7, hit: { push: 160, reach: 300, spin: 2 } }],
['hat.fm2', fm({ freq: 5200, ratio: 2.71, index: 5000, peak: 0.3, len: 0.06 }), { vel: 0.5 }],
['kick.fm', fm({ freq: 90, to: 45, glide: 0.08, ratio: 1.6, index: 400, indexTo: 1, indexTime: 0.05, peak: 0.7, len: 0.3 }), { hit: { push: 170, reach: 300 } }],
['hat.noise', hat(9000, 0.03, 0.35), { vel: 0.5 }],
['wood.fm', fm({ freq: 600, ratio: 1, index: 800, indexTo: 1, indexTime: 0.02, peak: 0.36, len: 0.05 }), { vel: 0.6, hit: { push: -20 } }],
['snare.noise', (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 1800, Q: 0.5, peak: 0.4, len: 0.12 }), { vel: 0.75, hit: { push: -70 } }],
['crunch', rule.crunch, { vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
['hat.ring', rule.hatRing, { vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
['kick.click', (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 });
}, { vel: 0.85, hit: { push: 110 } }],
['tine', fm({ freq: 2093, ratio: 3.5, index: 3000, indexTo: 5, indexTime: 0.1, peak: 0.22, len: 0.4 }), { vel: 0.6, hit: { push: 15 } }],
['grain.hit', rule.grainHit, { vel: 0.7, hit: { push: 50, reach: 300, scatter: true } }],
['snare.gate', rule.snareGate, { vel: 0.8, hit: { push: -90 } }],
['buzz', rule.buzz, { 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 }), { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['clang.1', fm({ freq: 330, ratio: 1.414, index: 1500, indexTo: 20, indexTime: 0.6, peak: 0.42, len: 1.0 }), { vel: 0.8, hit: { push: 260, reach: 240, scatter: true } }],
['tick.sine', (ctx, out, t) => toneHit(ctx, out, t, { freq: 3500, peak: 0.3, len: 0.01 }), { vel: 0.6 }],
['zap.fm', fm({ freq: 3000, to: 200, glide: 0.08, ratio: 1.5, index: 2000, indexTo: 100, peak: 0.2, len: 0.1 }), { vel: 0.6, hit: { plane: [-1, 0], push: 90 } }],
['clap.digital', rule.clapDigital, { vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
['reverse.fm', rule.reverseFm, { vel: 0.8, hit: { push: -130, reach: 400 } }],
['rim.fm', fm({ freq: 1400, ratio: 2.3, index: 1500, indexTo: 10, indexTime: 0.02, peak: 0.32, len: 0.03 }), { vel: 0.6, hit: { push: -30 } }],
],
};
// Shared with Skoodu: a short FM chord stab and an FM snare
const fmStab = freqs => (ctx, out, t) => { // a short FM chord stab
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 5000;
lp.connect(out);
freqs.forEach(freq => fmHit(ctx, lp, t, { freq, ratio: 2, index: freq * 3, indexTo: 1, indexTime: 0.1, peak: 0.16, len: 0.22 }));
};
const fmSnare = (ctx, out, t) => {
fmHit(ctx, out, t, { freq: 180, ratio: 7.3, index: 2000, indexTo: 100, indexTime: 0.15, peak: 0.35, len: 0.18 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 3000, peak: 0.2, len: 0.1 });
};
// Track 7, "Planet Work": generative drum and bass in the whole-tone scale on E, made digital:
// pulse-wave chip voices, a 4-bit triangle bass, fast chip arpeggios, noise-channel drums,
// a step-sequenced lead, and game-machine blips driven by the automaton.
// pulse waves of any width, built from their harmonics (the classic 8-bit lead and arp tones)
const pulseWaveCache = new WeakMap();
function pulseWave(ctx, duty) {
let byDuty = pulseWaveCache.get(ctx);
if (!byDuty) { byDuty = new Map(); pulseWaveCache.set(ctx, byDuty); }
if (!byDuty.has(duty)) {
const n = 48, real = new Float32Array(n), imag = new Float32Array(n);
for (let k = 1; k < n; k++) real[k] = (2 / (k * Math.PI)) * Math.sin(k * Math.PI * duty);
byDuty.set(duty, ctx.createPeriodicWave(real, imag));
}
return byDuty.get(duty);
}
function chipTone(ctx, out, t, { freq, duty = 0.25, peak, len, steps }) {
const o = ctx.createOscillator(), g = decay(ctx, t, peak, len);
o.setPeriodicWave(pulseWave(ctx, duty));
o.frequency.setValueAtTime(freq, t);
// `steps`: [[time, freq], ...] jumps in pitch, like an 8-bit sound chip (no smooth slides)
if (steps) steps.forEach(([dt, f]) => o.frequency.setValueAtTime(f, t + dt));
o.connect(g); g.connect(out);
o.start(t); o.stop(t + len + 0.03);
}
// a pitch that falls (or rises) in hard steps
const steppedPitch = (from, to, count, span) => Array.from({ length: count }, (_, i) => [span * (i + 1) / count, from * Math.pow(to / from, (i + 1) / count)]);
// a short repeating noise loop: the buzzy, metallic "short mode" of an 8-bit noise channel
const loopNoiseCache = new WeakMap();
function loopNoise(ctx) {
let buf = loopNoiseCache.get(ctx);
if (!buf) {
buf = ctx.createBuffer(1, 127, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < 127; i++) d[i] = Math.random() < 0.5 ? -1 : 1;
loopNoiseCache.set(ctx, buf);
}
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
return src;
}
const chipLead = freq => (ctx, out, t) => chipTone(ctx, out, t, { freq, duty: 0.25, peak: 0.5, len: 0.16 });
const triBass = freq => (ctx, out, t) => { // a stepped, 4-bit-style triangle, as on old consoles
const o = ctx.createOscillator(), cr = crusher(ctx, 8), g = decay(ctx, t, 0.34, 0.16);
o.type = 'triangle'; o.frequency.value = freq;
o.connect(cr); cr.connect(g); g.connect(out);
o.start(t); o.stop(t + 0.2);
toneHit(ctx, out, t, { freq, peak: 0.12, len: 0.14 });
};
const chipArp = (freqs, down) => (ctx, out, t) => { // a chord played as a blur of notes, the 8-bit way
const order = down ? [...freqs].reverse() : freqs;
for (let i = 0; i < 8; i++) chipTone(ctx, out, t + i * 0.03, { freq: order[i % order.length], duty: 0.125, peak: 0.36, len: 0.03 });
};
const digital = {
sampleHold: (ctx, out, t) => { // a filter jumping to a new random place every sixteenth
const o = ctx.createOscillator(), lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.08, 0.02, 1.2, 0.2);
o.type = 'square'; o.frequency.value = 164.81;
lp.type = 'lowpass'; lp.Q.value = 12;
for (let i = 0; i < 16; i++) lp.frequency.setValueAtTime(300 + Math.random() * 3200, t + i * PLANET_STEP);
o.connect(lp); lp.connect(g); g.connect(out);
o.start(t); o.stop(t + 1.45);
},
bitSweep: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2637, duty: 0.25, peak: 0.12, len: 0.7, steps: steppedPitch(2637, 165, 16, 0.6) }),
modem: (ctx, out, t) => { // two tones flicking back and forth: data down a phone line
for (let i = 0; i < 32; i++) toneHit(ctx, out, t + i * 0.015, { type: 'square', freq: i % 2 ? 2200 : 1200, peak: 0.05, len: 0.016 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 4000, peak: 0.03, len: 0.5 });
},
pwmPad: (ctx, out, t) => { // a pulse pad whose width drifts: E augmented
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.1, 0.8, 2, 1.6), flip = ctx.createGain();
lp.type = 'lowpass'; lp.frequency.value = 1800;
flip.gain.value = -1;
lp.connect(g); g.connect(out);
[164.81, 207.65, 261.63].forEach(freq => {
const a = ctx.createOscillator(), b = ctx.createOscillator();
a.type = 'sawtooth'; a.frequency.value = freq;
b.type = 'sawtooth'; b.frequency.value = freq * 1.003;
a.connect(lp); b.connect(flip);
[a, b].forEach(o => { o.start(t); o.stop(t + 4.5); });
});
flip.connect(lp);
},
dataStream: (ctx, out, t) => { // a stream of random bits
for (let i = 0; i < 32; i++) {
const ti = t + i * 0.022;
if (Math.random() < 0.5) noiseHit(ctx, out, ti, { type: 'highpass', freq: 5000, peak: 0.12, len: 0.015 });
else toneHit(ctx, out, ti, { type: 'square', freq: 1800, peak: 0.05, len: 0.015 });
}
},
kickChip: (ctx, out, t) => {
const cr = crusher(ctx, 12);
cr.connect(out);
toneHit(ctx, cr, t, { type: 'square', freq: 160, to: 45, glide: 0.06, peak: 0.4, len: 0.18 });
toneHit(ctx, out, t, { freq: 90, to: 40, glide: 0.08, peak: 0.36, len: 0.22 });
},
kick808d: (ctx, out, t) => {
const cr = crusher(ctx, 32);
cr.connect(out);
toneHit(ctx, cr, t, { freq: 55, to: 40, glide: 0.3, peak: 0.6, len: 0.8 });
},
kickClick: (ctx, out, t) => {
toneHit(ctx, out, t, { freq: 220, to: 60, glide: 0.015, peak: 0.7, len: 0.06 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 6000, peak: 0.2, len: 0.003 });
},
snareNoise8: (ctx, out, t) => { // the noise channel in its buzzy short mode
const src = loopNoise(ctx), g = decay(ctx, t, 0.3, 0.12);
src.connect(g); g.connect(out);
src.start(t); src.stop(t + 0.15);
noiseHit(ctx, out, t, { type: 'highpass', freq: 2000, peak: 0.2, len: 0.08 });
},
snareChip: (ctx, out, t) => {
noiseHit(ctx, out, t, { type: 'bandpass', freq: 2500, peak: 0.35, len: 0.1 });
chipTone(ctx, out, t, { freq: 220, duty: 0.5, peak: 0.15, len: 0.06, steps: [[0.02, 196], [0.04, 175]] });
},
clap8: crushed((ctx, out, t) => {
for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.011, { type: 'bandpass', freq: 1400, peak: 0.4, len: 0.018 });
noiseHit(ctx, out, t + 0.033, { type: 'bandpass', freq: 1300, peak: 0.3, len: 0.12 });
}, 4),
ghost: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 3000, peak: 0.14, len: 0.035 }),
hatLfsr: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 8000, peak: 0.42, len: 0.03 }),
hatTick: (ctx, out, t) => noiseHit(ctx, out, t, { type: 'bandpass', freq: 10000, Q: 1.5, peak: 0.4, len: 0.012 }),
hatOpen: crushed((ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 6500, peak: 0.3, len: 0.25 }), 6),
hatRatchet: (ctx, out, t) => {
for (let i = 0; i < 3; i++) noiseHit(ctx, out, t + i * 0.025, { type: 'highpass', freq: 8500, peak: 0.3 - i * 0.06, len: 0.015 });
},
blip1: (ctx, out, t) => toneHit(ctx, out, t, { freq: 2093, peak: 0.25, len: 0.03 }),
blip2: (ctx, out, t) => chipTone(ctx, out, t, { freq: 3136, duty: 0.125, peak: 0.15, len: 0.03 }),
chirp: (ctx, out, t) => chipTone(ctx, out, t, { freq: 600, duty: 0.25, peak: 0.13, len: 0.08, steps: steppedPitch(600, 2400, 6, 0.06) }),
dropDown: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2400, duty: 0.25, peak: 0.13, len: 0.08, steps: steppedPitch(2400, 300, 6, 0.06) }),
coin: (ctx, out, t) => { // the "coin collected" two-note blip
chipTone(ctx, out, t, { freq: 2093, duty: 0.5, peak: 0.1, len: 0.06 });
chipTone(ctx, out, t + 0.06, { freq: 2637, duty: 0.5, peak: 0.1, len: 0.22 });
},
cowbell: (ctx, out, t) => {
chipTone(ctx, out, t, { freq: 587.33, duty: 0.5, peak: 0.1, len: 0.12 });
chipTone(ctx, out, t, { freq: 830.6, duty: 0.5, peak: 0.08, len: 0.12 });
},
tom: (from, to) => (ctx, out, t) => chipTone(ctx, out, t, { freq: from, duty: 0.5, peak: 0.3, len: 0.16, steps: steppedPitch(from, to, 5, 0.12) }),
stabBit: (ctx, out, t) => {
const cr = crusher(ctx, 3), g = decay(ctx, t, 0.12, 0.25);
cr.connect(g); g.connect(out);
[329.63, 415.3, 523.25].forEach(freq => {
const o = ctx.createOscillator();
o.setPeriodicWave(pulseWave(ctx, 0.5)); o.frequency.value = freq;
o.connect(cr); o.start(t); o.stop(t + 0.27);
});
},
zap8: (ctx, out, t) => chipTone(ctx, out, t, { freq: 2000, duty: 0.25, peak: 0.14, len: 0.17, steps: steppedPitch(2000, 100, 10, 0.15) }),
stutter: (ctx, out, t) => {
for (let i = 0; i < 8; i++) chipTone(ctx, out, t + i * 0.044, { freq: 880, duty: 0.25, peak: 0.12 * (1 - i / 10), len: 0.02 });
},
byteCrush: (ctx, out, t) => { // a burst of 1-bit noise
const src = noiseSource(ctx), cr = crusher(ctx, 1), bp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.4, 0.1);
bp.type = 'bandpass'; bp.frequency.value = 1200;
src.connect(cr); cr.connect(bp); bp.connect(g); g.connect(out);
src.start(t, Math.random()); src.stop(t + 0.12);
},
powerDown: (ctx, out, t) => { // the machine powering down: a stepped fall, a sub drop, a crushed burst
chipTone(ctx, out, t, { freq: 880, duty: 0.5, peak: 0.12, len: 0.85, steps: steppedPitch(880, 55, 16, 0.8) });
const cr = crusher(ctx, 24);
cr.connect(out);
toneHit(ctx, cr, t, { freq: 80, to: 25, glide: 1.2, peak: 0.6, len: 1.4 });
noiseHit(ctx, out, t, { type: 'lowpass', freq: 1200, peak: 0.25, len: 0.2 });
},
};
const PLANET_STEP = 60 / 170 / 4;
const KIT_PLANET = {
// ring 1: a chip lead, the whole-tone scale from E climbing clockwise round the centre
1: [
['chip.e4', 329.63], ['chip.f♯4', 369.99], ['chip.g♯4', 415.3], ['chip.a♯4', 466.16],
['chip.c5', 523.25], ['chip.d5', 587.33], ['chip.e5', 659.25], ['chip.f♯5', 739.99],
].map(([id, freq]) => [id, chipLead(freq), { vel: 0.85 }]),
// ring 2: triangle bass round the top and right, sub and chip arps on the corner and bottom, digital textures up the left
2: [
['tri.e2', triBass(82.41), { vel: 0.85, hit: { push: 60 } }],
['tri.f♯2', triBass(92.5), { vel: 0.85, hit: { push: 60 } }],
['tri.g♯2', triBass(103.83), { vel: 0.85, hit: { push: 60 } }],
['tri.a♯2', triBass(116.54), { vel: 0.85, hit: { push: 60 } }],
['tri.c3', triBass(130.81), { vel: 0.85, hit: { push: 60 } }],
['tri.d3', triBass(146.83), { vel: 0.85, hit: { push: 60 } }],
['sub.e1', subTone(41.2), { hit: { push: 90 } }],
['arp.e+', chipArp([329.63, 415.3, 523.25], false), { vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
['arp.e+.down', chipArp([329.63, 415.3, 523.25], true), { vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
['arp.f♯+', chipArp([369.99, 466.16, 587.33], false), { vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
['arp.f♯+.down', chipArp([369.99, 466.16, 587.33], true), { vel: 0.8, hit: { push: 50, reach: 260, scatter: true } }],
['sample.hold', digital.sampleHold, { vel: 0.8, hit: { push: 70, reach: 350, spin: 2, scatter: true } }],
['bit.sweep', digital.bitSweep, { vel: 0.8, hit: { plane: [0, 1], push: 100 } }],
['modem', digital.modem, { vel: 0.7, hit: { push: 90, reach: 300, scatter: true } }],
['pwm.pad', digital.pwmPad, { hit: { push: 12, lift: 40 } }],
['data.stream', digital.dataStream, { vel: 0.7, hit: { push: 40, reach: 400, scatter: true } }],
],
// ring 3: kicks and the power-down in the corners, snares mid-side, hats and blips by the corners, chip percussion between
3: [
['kick.chip', digital.kickChip, { hit: { push: 160 } }],
['hat.lfsr', digital.hatLfsr, { vel: 0.5 }],
['chirp', digital.chirp, { vel: 0.6, hit: { plane: [0, -1], push: 60 } }],
['snare.noise8', digital.snareNoise8, { vel: 0.85, hit: { push: -90 } }],
['drop.down', digital.dropDown, { vel: 0.6, hit: { plane: [0, 1], push: 60 } }],
['hat.tick.d', digital.hatTick, { vel: 0.5 }],
['kick.808d', digital.kick808d, { vel: 0.8, hit: { push: 180, reach: 380 } }],
['hat.open.d', digital.hatOpen, { vel: 0.45 }],
['tom.chip.hi', digital.tom(330, 180), { vel: 0.7, hit: { push: 70 } }],
['snare.chip', digital.snareChip, { vel: 0.8, hit: { push: -80 } }],
['tom.chip.lo', digital.tom(220, 110), { vel: 0.7, hit: { push: 90 } }],
['hat.ratchet', digital.hatRatchet, { vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
['kick.click', digital.kickClick, { vel: 0.8, hit: { push: 110 } }],
['blip.1', digital.blip1, { vel: 0.6, hit: { push: 25, reach: 150, scatter: true } }],
['stab.bit', digital.stabBit, { vel: 0.8, hit: { push: 120, reach: 280, scatter: true } }],
['clap.8bit', digital.clap8, { vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
['zap.8', digital.zap8, { vel: 0.6, hit: { plane: [1, 0], push: 90 } }],
['blip.2', digital.blip2, { vel: 0.6, hit: { push: 25, reach: 150, scatter: true } }],
['power.down', digital.powerDown, { hit: { push: 480, reach: 380 } }],
['beep.coin', digital.coin, { vel: 0.6, hit: { push: 20, twirl: 2 } }],
['glitch.stutter', digital.stutter, { vel: 0.7, hit: { push: 70, reach: 380, spin: 3, scatter: true } }],
['snare.ghost.d', digital.ghost, { vel: 0.5, hit: { push: -15 } }],
['byte.crush', digital.byteCrush, { vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
['cowbell.d', digital.cowbell, { vel: 0.5, hit: { push: 20 } }],
],
};
// Track 8, "Skoodu": generative again. The octatonic scale on D, FM throughout,
// half-time drum and bass that folds into double-time; rule 30 supplies the chaos.
const R30_STEP = 60 / 172 / 4;
const chime = freq => echoes(fm({ freq, ratio: 1.4, index: freq * 2.5, indexTo: 1, indexTime: 0.4, peak: 0.22, len: 1.0 }), 1, 3 * R30_STEP, 0.35);
const fmGrowl = freq => (ctx, out, t) => { // neuro bass: FM whose brightness wobbles in sixteenths
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain();
const lfo = ctx.createOscillator(), lfoDepth = ctx.createGain();
car.frequency.value = freq * 2; mod.frequency.value = freq * 2;
idx.gain.value = freq * 4;
lfo.frequency.value = 1 / R30_STEP;
lfoDepth.gain.value = freq * 3.6;
lfo.connect(lfoDepth); lfoDepth.connect(idx.gain);
mod.connect(idx); idx.connect(car.frequency);
const cr = crusher(ctx, 16), lp = ctx.createBiquadFilter(), g = decay(ctx, t, 0.22, 0.45);
lp.type = 'lowpass'; lp.frequency.value = 1600;
car.connect(cr); cr.connect(lp); lp.connect(g); g.connect(out);
const sub = ctx.createOscillator(), sg = decay(ctx, t, 0.3, 0.5);
sub.type = 'sine'; sub.frequency.value = freq;
sub.connect(sg); sg.connect(out);
[car, mod, lfo, sub].forEach(o => { o.start(t); o.stop(t + 0.55); });
};
const fmHoover = (ctx, out, t) => { // a detuned FM stack that swoops up into a diminished chord
const lp = ctx.createBiquadFilter(), g = swell(ctx, t, 0.12, 0.05, 0.4, 0.5);
lp.type = 'lowpass'; lp.frequency.value = 2500;
lp.connect(g); g.connect(out);
[146.83, 174.61, 207.65, 246.94].forEach(freq => [-15, 15].forEach(det => {
const car = ctx.createOscillator(), mod = ctx.createOscillator(), idx = ctx.createGain(), og = ctx.createGain();
car.frequency.setValueAtTime(freq * 0.7, t); car.frequency.exponentialRampToValueAtTime(freq, t + 0.15);
mod.frequency.setValueAtTime(freq * 0.7, t); mod.frequency.exponentialRampToValueAtTime(freq, t + 0.15);
car.detune.value = det; mod.detune.value = det;
idx.gain.value = freq * 1.5;
og.gain.value = 0.5;
mod.connect(idx); idx.connect(car.frequency); car.connect(og); og.connect(lp);
car.start(t); mod.start(t); car.stop(t + 1.0); mod.stop(t + 1.0);
}));
};
// high harmonics of one note, swelling in slowly
const harmonicPad = (base, harmonics, peak) => (ctx, out, t) => {
const g = swell(ctx, t, peak, 1.2, 1.8, 2.2);
g.connect(out);
harmonics.forEach(h => {
const o = ctx.createOscillator();
o.type = 'sine'; o.frequency.value = base * h;
o.connect(g); o.start(t); o.stop(t + 5.3);
});
};
const r30 = {
bitRain: (ctx, out, t) => { // a drizzle of crushed noise grains
const cr = crusher(ctx, 5);
cr.connect(out);
for (let i = 0; i < 40; i++) {
noiseHit(ctx, cr, t + Math.random() * 1.5, { type: 'bandpass', freq: 1500 + Math.random() * 6000, Q: 2, peak: 0.35, len: 0.008 }); // loud enough to survive the crusher's steps
}
},
gear: (ctx, out, t) => { // a ratchet: tiny FM teeth, faster and faster
let ti = t;
for (let i = 0; i < 8; i++) {
fmHit(ctx, out, ti, { freq: 2400 + i * 100, ratio: 2.2, index: 1500, indexTo: 30, peak: 0.2, len: 0.012 });
ti += 0.05 * Math.pow(0.85, i);
}
},
};
const KIT_RULE30 = {
// ring 1: FM chimes on the octatonic scale from D (half step, whole step), with a short echo
1: [
['chime.d4', 293.66], ['chime.e♭4', 311.13], ['chime.f4', 349.23], ['chime.f♯4', 369.99],
['chime.g♯4', 415.3], ['chime.a4', 440.0], ['chime.b4', 493.88], ['chime.c5', 523.25],
].map(([id, freq]) => [id, chime(freq), { vel: 0.85 }]),
// ring 2: growl bass round the top and right, sub, hoover and stabs along the bottom, textures and pads up the left
2: [
['growl.g♯1', fmGrowl(51.91), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['growl.a1', fmGrowl(55.0), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['growl.b1', fmGrowl(61.74), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['growl.d2', fmGrowl(73.42), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['growl.e♭2', fmGrowl(77.78), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['growl.f2', fmGrowl(87.31), { vel: 0.8, hit: { push: 80, twirl: 1.5 } }],
['sub.d2', subTone(73.42), { hit: { push: 90 } }],
['hoover.dim', fmHoover, { vel: 0.8, hit: { push: 60, reach: 500, lift: 50, swirl: 1, centre: true } }],
['stab.dim.d', fmStab([293.66, 349.23, 415.3, 493.88]), { vel: 0.8, hit: { push: 110, reach: 280, scatter: true } }],
['stab.dim.e♭', fmStab([311.13, 369.99, 440.0, 523.25]), { vel: 0.8, hit: { push: 110, reach: 280, scatter: true } }],
['data.burst', rule.dataBurst, { vel: 0.8, hit: { push: 120, reach: 300, scatter: true } }],
['grain.cloud', rule.grainCloud, { vel: 0.8, hit: { push: 40, reach: 400, scatter: true } }],
['formant.shift', rule.formantShift, { vel: 0.8, hit: { push: 60, reach: 350, swirl: -1, centre: true } }],
['bit.rain', r30.bitRain, { vel: 0.8, hit: { push: 50, reach: 400, scatter: true } }],
['cold.pad.d', coldPad([146.83, 174.61, 207.65, 246.94]), { hit: { push: 12, lift: 40 } }],
['glass.pad', harmonicPad(146.83, [5, 7, 9, 11], 0.05), { hit: { push: 10, lift: 40 } }],
],
// ring 3: kicks and the big clang in the corners, snares mid-side, hats and ticks by the corners, metal and debris between
3: [
['kick.fm', fm({ freq: 90, to: 45, glide: 0.08, ratio: 1.6, index: 400, indexTo: 1, indexTime: 0.05, peak: 0.6, len: 0.3 }), { vel: 0.8, hit: { push: 160 } }],
['hat.fm1', fm({ freq: 7000, ratio: 1.43, index: 6000, peak: 0.3, len: 0.04 }), { vel: 0.5 }],
['clang.2', fm({ freq: 587.33, ratio: 2.76, index: 1500, indexTo: 20, indexTime: 0.4, peak: 0.3, len: 0.6 }), { vel: 0.7, hit: { push: 140, reach: 260, scatter: true } }],
['snare.fm', fmSnare, { vel: 0.85, hit: { push: -95 } }],
['gear', r30.gear, { vel: 0.7, hit: { push: 30, reach: 300, twirl: 7 } }],
['hat.fm2', fm({ freq: 5200, ratio: 2.71, index: 5000, peak: 0.3, len: 0.06 }), { vel: 0.5 }],
['kick.punch', dnb.kickPunch, { vel: 0.72, hit: { push: 170 } }],
['hat.noise', hat(9000, 0.03, 0.35), { vel: 0.5 }],
['rim.fm', fm({ freq: 1400, ratio: 2.3, index: 1500, indexTo: 10, indexTime: 0.02, peak: 0.32, len: 0.03 }), { vel: 0.6, hit: { push: -30 } }],
['snare.gate', rule.snareGate, { vel: 0.8, hit: { push: -80 } }],
['crunch', rule.crunch, { vel: 0.8, hit: { push: 250, reach: 210, scatter: true } }],
['hat.ring', rule.hatRing, { vel: 0.5, hit: { push: 20, reach: 180, scatter: true } }],
['kick.sub', dnb.kickSub, { vel: 0.85, hit: { push: 150 } }],
['ride.fm', (ctx, out, t) => {
fmHit(ctx, out, t, { freq: 3200, ratio: 1.47, index: 3000, indexTo: 200, peak: 0.1, len: 0.8 });
noiseHit(ctx, out, t, { type: 'highpass', freq: 7000, peak: 0.06, len: 0.4 });
}, { vel: 0.5 }],
['buzz', rule.buzz, { vel: 0.7, hit: { push: 90, reach: 260, scatter: true } }],
['clap.digital', rule.clapDigital, { vel: 0.7, hit: { push: 100, reach: 240, scatter: true } }],
['zap.fm', fm({ freq: 3000, to: 200, glide: 0.08, ratio: 1.5, index: 2000, indexTo: 100, peak: 0.2, len: 0.1 }), { vel: 0.6, hit: { plane: [-1, 0], push: 90 } }],
['click.sample', (ctx, out, t) => noiseHit(ctx, out, t, { type: 'highpass', freq: 12000, peak: 0.6, len: 0.002 }), { vel: 0.6, hit: { push: 40, reach: 150, scatter: true } }],
['clang.big', fm({ freq: 196.0, ratio: 1.414, index: 1800, indexTo: 20, indexTime: 0.8, peak: 0.42, len: 1.3 }), { vel: 0.8, hit: { push: 440, reach: 380 } }],
['snare.ghost', fm({ freq: 220, ratio: 6.1, index: 1200, indexTo: 50, peak: 0.12, len: 0.05 }), { vel: 0.5, hit: { push: -15 } }],
['stutter', idm.stutter, { vel: 0.6, hit: { push: 70, reach: 400, spin: 3, scatter: true } }],
['snare.crush', crushed(fmSnare, 4), { vel: 0.75, hit: { push: 110, reach: 220, scatter: true } }],
['reverse.fm', rule.reverseFm, { vel: 0.8, hit: { push: -130, reach: 400 } }],
['tine', fm({ freq: 2349, ratio: 3.5, index: 3000, indexTo: 5, indexTime: 0.1, peak: 0.22, len: 0.4 }), { vel: 0.6, hit: { push: 15 } }],
],
};
function playSound(id) {
if (!audioReady) return;
const tile = TILE[id];
if (!tile) return;
try {
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
tile.voice(ctx, roomFor(cur), ctx.currentTime);
} catch (e) { /* ignore */ }
}
// ─────────────────────────────────────────────
// THE SONGS
// Every note names the tile that plays it, written on a 16-step grid per bar.
// x = hit, o = ghost (softer) hit. Swing delays every other sixteenth.
// ─────────────────────────────────────────────
function makeWriter(velOf) {
const events = [], sections = [];
let cursor = 0;
const vel = id => velOf[id];
const hit = (bar, step, id, len = 1, v) => events.push({ at: bar * 16 + step, id, len, v });
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]) => hit(bar, step, id, len, v));
const section = (name, bars, fn) => {
sections.push({ name, from: cursor, to: cursor + bars });
for (let k = 0; k < bars; k++) fn(cursor + k, k);
cursor += bars;
};
// plays a list of tiles in turn, one per hit, so a pattern walks across them
const chaser = list => {
let turn = 0;
return (bar, pattern, v) => [...pattern].forEach((c, i) => {
if (c === '.') return;
const id = list[turn++ % list.length];
hit(bar, i, id, 1, c === 'o' ? (v ?? vel(id)) * 0.5 : v);
});
};
// n hits spread evenly across `span` steps, swelling from v0 to v1: a drill roll
const ratchet = (bar, step, id, n, span, v0, v1) => {
for (let i = 0; i < n; i++) hit(bar, step + span * i / n, id, 1, v0 + (v1 - v0) * (n > 1 ? i / (n - 1) : 1));
};
// a hit every `period` steps counted across bars, so threes, fives and sevens drift against the bar
const cycle = (bar, id, period, offset, v) => {
for (let st = 0; st < 16; st++) if ((bar * 16 + st - offset) % period === 0) hit(bar, st, id, 1, v);
};
// a seeded random source: patterns sound improvised but play identically every time
const seeded = start => {
let seed = start;
const rng = () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return { rng, pick: list => list[Math.floor(rng() * list.length)], chance: p => rng() < p };
};
// Euclidean rhythm: k hits spread as evenly as possible over n steps, counted from the song's start
// so patterns of 7, 11 or 13 drift against the bar. A list of tiles is played in turn.
const euclidPattern = (k, n) => Array.from({ length: n }, (_, i) => Math.floor((i + 1) * k / n) - Math.floor(i * k / n) === 1);
const turns = new Map();
const nextOf = list => { const i = (turns.get(list) ?? -1) + 1; turns.set(list, i); return list[i % list.length]; };
const euclid = (bar, ids, k, n, rot, v, lean = 0) => {
const pat = euclidPattern(k, n), list = Array.isArray(ids) ? ids : [ids];
for (let st = 0; st < 16; st++) {
if (pat[((bar * 16 + st + rot) % n + n) % n]) hit(bar, st + (st % 2 ? lean : 0), nextOf(list), 1, v);
}
};
// a cellular automaton: 16 cells, one per step; each evolve() is the next generation.
// When the row dies out, a few new cells are seeded so it keeps going.
const automaton = rng => {
let cells = Array(16).fill(0);
cells[8] = 1;
return {
evolve(r) {
cells = cells.map((_, i) => (r >> ((cells[(i + 15) % 16] << 2) | (cells[i] << 1) | cells[(i + 1) % 16])) & 1);
if (!cells.some(Boolean)) { cells = cells.map(() => (rng() < 0.2 ? 1 : 0)); cells[Math.floor(rng() * 16)] = 1; }
},
// live cells fire tiles from the list; `every` = 2 keeps only the cells on even steps
hits(bar, list, v, every = 1) {
cells.forEach((on, st) => {
if (on && st % every === 0) hit(bar, st, list[(Math.floor(st / 2) + bar) % list.length], 1, v * (0.6 + 0.4 * rng()));
});
},
};
};
return {
hit, grid, line, section, chaser, vel, ratchet, cycle, seeded, euclid, automaton,
done: () => ({ events: events.sort((a, b) => a.at - b.at), sections, bars: cursor }),
};
}
const SONGS = [
{
title: 'Nomo Chant', meta: 'a minor, 100 bpm', bpm: 100, swing: 0, tail: 0.4,
kit: KIT_TESSELLATE,
write({ hit, grid, line, section, chaser }) {
// the closed hi-hats are played in order, so the beat walks clockwise round the edge
const chase = chaser(['hat.1', 'hat.2', 'hat.3', 'hat.4', 'hat.5', 'hat.6', 'hat.7']);
// bass lines walk through the bass tiles; E bars use the gritty E2
const BASS = {
A1: [[0, 'bass.a2'], [3, 'bass.a2'], [6, 'bass.c3'], [10, 'bass.a2'], [13, 'bass.g2']],
A2: [[0, 'bass.a2'], [3, 'bass.a2'], [6, 'bass.d3'], [10, 'bass.c3'], [13, 'bass.a2']],
C: [[0, 'bass.c3'], [3, 'bass.c3'], [6, 'bass.g2'], [10, 'bass.c3'], [13, 'bass.d3']],
E: [[0, 'grit.e2'], [6, 'grit.e2'], [10, 'grit.e2'], [14, 'bass.f2', 1, 0.6]],
};
const PROG = ['A1', 'A2', 'C', 'E']; // Am | Am | C | E
const bassline = (bar, k) => BASS[PROG[k % 4]].forEach(([s, id, len = 1, v]) => hit(bar, s, id, len, v));
// the tune, first time through: the original melody, note for note
const THEME = [
[[0,'lead.a4',4], [4,'bell.c5',4], [8,'lead.a4',2], [10,'lead.g4',2], [12,'lead.a4',4]],
[[0,'ping.e6',6], [6,'lead.g4',2], [8,'lead.f4',4], [12,'lead.g4',4]],
[[0,'bell.c5',6], [6,'lead.a4',2], [8,'lead.g4',4], [12,'lead.f4',2], [14,'lead.g4',2]],
[[0,'dyad.e5.g#5',6], [8,'vibrato.a4',4], [12,'dyad.e5.g#5',4]],
[[0,'lead.a4',4], [4,'bell.c5',4], [8,'ping.e6',4], [12,'bell.c5',4]],
[[0,'lead.a4',2], [2,'lead.g4',2], [4,'lead.a4',4], [8,'bell.c5',2], [10,'lead.a4',2], [12,'lead.g4',4]],
[[0,'lead.f4',4], [4,'lead.g4',4], [8,'lead.a4',4], [12,'bell.c5',4]],
[[0,'dyad.e5.g#5',8], [8,'vibrato.a4',4]],
];
// second time through: the same tune, opened out across the whole lead ring
const THEME_AGAIN = [
[[0,'lead.a4',4], [4,'lead.c5',4], [8,'lead.b4',2], [10,'lead.g4',2], [12,'lead.a4',4]],
[[0,'ping.e6',4], [4,'lead.e5',2], [6,'lead.d5',2], [8,'lead.c5',4], [12,'lead.b4',4]],
[[0,'lead.c5',6], [6,'lead.e5',2], [8,'lead.d5',4], [12,'lead.c5',2], [14,'lead.g4',2]],
[[0,'dyad.e5.g#5',6], [8,'vibrato.a4',4], [12,'lead.b4',2], [14,'lead.e4',2]],
[[0,'lead.a4',4], [4,'lead.c5',4], [8,'lead.e5',4], [12,'ping.e6',4]],
[[0,'lead.a4',2], [2,'lead.g4',2], [4,'lead.a4',4], [8,'lead.c5',2], [10,'lead.b4',2], [12,'lead.g4',4]],
[[0,'lead.f4',4], [4,'lead.g4',4], [8,'lead.a4',4], [12,'lead.c5',4]],
[[0,'dyad.e5.g#5',8], [8,'lead.b4',2], [10,'lead.d5',2]],
];
section('intro', 4, (b, k) => {
if (k === 0) line(b, [[0,'bass.a2',2], [0,'chord.c',8], [12,'ping.e6',4]]);
if (k === 1) line(b, [[0,'bass.c3',2], [0,'arp.c.major',4], [8,'bell.c5',8]]);
if (k === 2) {
line(b, [[0,'bass.a2',2], [0,'chord.c',8], [8,'ping.e6',4], [12,'bell.c5',4]]);
chase(b, '..x...x...x...x.', 0.3);
}
if (k === 3) {
line(b, [[0,'grit.e2',2], [0,'dyad.e5.g#5',8], [8,'rise',8], [12,'tom.high'], [14,'tom.low',2]]);
chase(b, '..x...x...x...x.', 0.42);
}
});
section('groove', 4, (b, k) => {
grid(b, 'kick.deep', 'x...............');
grid(b, 'kick.mid', '......x.x.......');
grid(b, 'rim.click', '....x...........');
grid(b, 'snare.tight', '............x...');
chase(b, '..x...x...x...x.');
bassline(b, k);
if (k === 0) hit(b, 0, 'chord.c', 4);
if (k === 2) hit(b, 0, 'arp.c.major', 4);
if (k === 3) line(b, [[0,'dyad.e5.g#5',6], [8,'dyad.e5.g#5',4], [12,'tom.high'], [13,'tom.mid',1,0.5], [14,'tom.low',2]]);
});
section('theme', 8, (b, k) => {
grid(b, 'kick.deep', 'x...............');
grid(b, 'kick.mid', '......x.x.......');
grid(b, 'rim.click', '....x...........');
grid(b, 'snare.tight', '............x...');
chase(b, 'o.x.o.x.o.x.o...');
hit(b, 14, 'hat.open');
bassline(b, k);
line(b, THEME[k]);
if (k === 0) hit(b, 0, 'crash', 8); // strong enough to crack a few tiles loose
if (k === 7) line(b, [[12,'fall',4], [14,'tom.low',2]]);
});
section('break', 4, (b, k) => {
const air = '..x.......x.....';
if (k === 0) { line(b, [[0,'bass.a2',2], [0,'chord.am',8], [8,'vibrato.a4',8]]); chase(b, air, 0.3); }
if (k === 1) { line(b, [[0,'bass.c3',2], [0,'arp.c.major',4], [4,'bell.c5',4], [8,'ping.e6',4], [12,'fall',4]]); chase(b, air, 0.3); }
if (k === 2) { line(b, [[0,'bass.f2',2], [0,'chord.f',4], [4,'vibrato.a4',4], [8,'lead.g4',4], [12,'lead.f4',4]]); chase(b, air, 0.35); }
if (k === 3) { // the build: every snare pulls the grid in
line(b, [[0,'grit.e2',2], [0,'chord.e',8], [8,'rise',8], [14,'zap',2]]);
grid(b, 'kick.deep', 'x...x...x...x...', 0.8);
[[8, 0.3], [10, 0.4], [12, 0.5], [13, 0.6], [14, 0.8], [15, 1]]
.forEach(([s, v]) => hit(b, s, 'snare.tight', 1, v));
}
});
section('theme.again', 8, (b, k) => {
grid(b, 'kick.deep', 'x...............');
grid(b, 'kick.mid', '......x.x.......');
grid(b, 'kick.high', '..........x.....');
grid(b, 'rim.click', '....x...........');
grid(b, 'clap', '............x...');
grid(b, 'rim.wood', '.......o.......o');
chase(b, 'o.xo..x.o.xo....');
hit(b, 14, 'hat.open');
bassline(b, k);
line(b, THEME_AGAIN[k]);
if (k === 0) line(b, [[0,'boom',4], [0,'crash',8,0.5], [0,'rise',4,0.6], [0,'arp.c.major',4]]); // the drop
if (k === 1 || k === 5) hit(b, 9, 'cowbell');
if (k === 2 || k === 6) hit(b, 8, 'chord.c', 4, 0.55);
if (k === 6) hit(b, 0, 'chord.f', 4, 0.45);
if (k === 3 || k === 7) hit(b, 0, 'chord.e', 4, 0.5);
if (k === 4) line(b, [[0,'arp.c.major',4], [0,'crash',8,0.5]]);
if (k === 5) hit(b, 8, 'arp.c.major', 4, 0.45);
if (k === 7) line(b, [[12,'glide.c4.c5',4], [12,'tom.high',1,0.7], [13,'tom.mid',1,0.85], [14,'tom.low'], [15,'tom.low']]);
});
section('outro', 4, (b, k) => {
if (k < 3) { hit(b, 0, 'kick.deep', 1, 0.7); chase(b, '..x...x...x...x.', 0.35); }
if (k === 0) { grid(b, 'bass.a2', 'x..x..x...x..x..', 0.7); line(b, [[0,'chord.c',4], [4,'lead.a4',4], [8,'bell.c5',4], [12,'ping.e6',4]]); }
if (k === 1) { grid(b, 'bass.c3', 'x..x..x...x..x..', 0.75); line(b, [[4,'lead.g4',4], [8,'lead.f4',4], [12,'lead.g4',4]]); }
if (k === 2) { grid(b, 'grit.e2', 'x.....x...x.....', 0.45); line(b, [[0,'dyad.e5.g#5',8], [8,'vibrato.a4',8]]); }
if (k === 3) { // the last chord: A minor
line(b, [[0,'kick.deep',16,0.75], [0,'bass.a2',16], [0,'chord.am',16,0.6],
[0,'vibrato.a4',16], [0,'bell.c5',16], [0,'ping.e6',16], [10,'fall',4,0.5]]);
}
});
},
},
{
title: 'Numerous Under One', meta: 'f minor, 174 bpm, drum & bass', bpm: 174, swing: 0.05, tail: 2.5,
room: { seconds: 1.6, shape: 3, wet: 0.18 },
kit: KIT_DNB,
write({ hit, grid, line, section, chaser }) {
const hats = chaser(['hat.1', 'hat.2', 'hat.3', 'hat.4', 'hat.5']);
// an eight-bar loop, two bars per chord: Fm, Db, Ab, Eb
const CH = ['fm', 'fm', 'db', 'db', 'ab', 'ab', 'eb', 'eb'];
const chordOf = k => CH[k % 8];
const BASS = {
fm: [[[0,'reese.f1',6], [6,'reese.f1',4], [10,'reese.f1',6]],
[[0,'reese.f1',6], [6,'reese.a♭1',4], [10,'reese.c2',4], [14,'reese.e♭2',2]]],
db: [[[0,'reese.d♭2',6], [6,'reese.d♭2',4], [10,'reese.d♭2',6]],
[[0,'reese.d♭2',6], [6,'reese.c2',4], [10,'reese.b♭1',4], [14,'reese.a♭1',2]]],
ab: [[[0,'reese.a♭1',6], [6,'reese.a♭1',4], [10,'reese.a♭1',6]],
[[0,'reese.a♭1',6], [6,'reese.b♭1',4], [10,'reese.c2',4], [14,'reese.e♭2',2]]],
eb: [[[0,'reese.e♭2',6], [6,'reese.e♭2',4], [10,'reese.e♭2',6]],
[[0,'reese.e♭2',6], [6,'reese.f2',4], [10,'reese.g1',2], [12,'reese.d♭2',2], [14,'reese.c2',2]]],
};
const bass = (b, k, v) => BASS[chordOf(k)][k % 2].forEach(([s, id, len]) => hit(b, s, id, len, v));
const pads = (b, k, v) => { if (k % 2 === 0) hit(b, 0, `pad.${chordOf(k)}`, 32, v); };
const stabs = (b, k, v) => grid(b, `stab.${chordOf(k)}`, '......x.......x.', v);
// two-step: kick on one and the and-of-three, snare on two and four, ghost snares between
const beat = (b, busy) => {
grid(b, 'kick.punch', busy ? 'x.x.......x.....' : 'x.........x.....');
grid(b, 'snare.main', '....x.......x...');
grid(b, 'snare.ghost', '.......o.o....o.');
hats(b, 'o.x.o.x.o.x.o.x.');
};
const fill = b => line(b, [[12,'tom.high'], [13,'tom.high',1,0.7], [14,'tom.low'], [15,'tom.low',1,0.9]]);
const HOOK = [
[[0,'lead.c5',4], [4,'lead.e♭5',2], [6,'lead.f5',4], [10,'lead.e♭5',2], [12,'lead.c5',4]],
[[0,'lead.a♭4',2], [2,'lead.b♭4',2], [4,'lead.c5',6], [12,'lead.b♭4',2], [14,'lead.a♭4',2]],
[[0,'lead.f4',4], [4,'lead.a♭4',2], [6,'lead.c5',4], [10,'lead.d♭5',2], [12,'lead.c5',4]],
[[0,'lead.a♭4',6], [8,'lead.f4',4], [12,'lead.g4',4]],
[[0,'lead.e♭5',4], [4,'lead.c5',2], [6,'lead.e♭5',4], [10,'lead.f5',2], [12,'lead.e♭5',4]],
[[0,'lead.c5',2], [2,'lead.d♭5',2], [4,'lead.c5',4], [8,'lead.b♭4',4], [12,'lead.a♭4',4]],
[[0,'lead.g4',4], [4,'lead.b♭4',2], [6,'lead.e♭5',4], [10,'lead.d♭5',2], [12,'lead.b♭4',4]],
[[0,'lead.g4',2], [2,'lead.a♭4',2], [4,'lead.b♭4',4], [8,'lead.c5',8]],
];
const ARPS = {
fm: ['lead.f4', 'lead.a♭4', 'lead.c5', 'lead.e♭5'],
db: ['lead.f4', 'lead.a♭4', 'lead.c5', 'lead.d♭5'],
ab: ['lead.a♭4', 'lead.c5', 'lead.e♭5', 'lead.f5'],
eb: ['lead.g4', 'lead.b♭4', 'lead.d♭5', 'lead.e♭5'],
};
const arp = (b, k, v) => [0, 1, 2, 3, 2, 1, 2, 3].forEach((n, i) => hit(b, i * 2, ARPS[chordOf(k)][n], 2, v));
section('intro', 8, (b, k) => {
pads(b, k, 0.8);
if (k < 4) { grid(b, 'ride', 'x...x...x...x...', 0.35); grid(b, 'shaker', '.o.o.o.o.o.o.o.o'); }
if (k === 1 || k === 3) hit(b, 8, 'vox.ah', 4);
if (k >= 4) {
hats(b, 'o.x.o.x.o.x.o.x.', 0.35);
grid(b, 'snare.rim', '....x.......x...');
grid(b, 'kick.sub', 'x.........x.....', 0.6);
}
if (k === 5) grid(b, 'tamb', '..x...x...x...x.');
if (k === 6) hit(b, 0, 'riser', 16);
if (k === 7) { // the grid is drawn in before the drop
hit(b, 4, 'reverse', 12);
[8, 9, 10, 11, 12, 13, 14, 15].forEach((s, i) => hit(b, s, 'snare.main', 1, 0.3 + i * 0.08));
}
});
const drop = (name, second) => section(name, 16, (b, k) => {
if (k === 0) line(b, [[0,'impact',8], [0,'crash',8]]); // and blown apart
if (k === 8) hit(b, 0, 'crash', 8);
beat(b, k % 4 === 3);
bass(b, k);
pads(b, k, 0.55);
if (second) { grid(b, 'ride', 'x...x...x...x...', 0.35); grid(b, 'snare.clap', '....x.......x...', 0.6); }
if (k >= 4) grid(b, 'shaker', '.o.o.o.o.o.o.o.o', 0.4);
if (k % 2 === 1) hit(b, 14, 'hat.open');
const hookFirst = !second;
if ((k < 8) === hookFirst) line(b, HOOK[k % 8]); else { arp(b, k, 0.75); stabs(b, k, 0.7); }
if (k === 4 || k === 12) hit(b, 8, 'laser', 4);
if (k === 10 || k === 14) hit(b, 0, 'vox.ah', 4);
if (k === 7 || k === 15) fill(b);
});
drop('drop', false);
section('break', 8, (b, k) => { // drums out, pads and melody
if (k === 0) line(b, [[0,'downlifter',14], [0,'crash',8,0.5]]);
pads(b, k, 1);
grid(b, 'ride', 'x...x...x...x...', 0.3);
if (k % 2 === 0) hit(b, 0, 'kick.sub', 1, 0.7);
line(b, HOOK[k].map(([s, id, len]) => [s, id, len, 0.6]));
if (k === 3 || k === 7) hit(b, 8, 'vox.ah', 4);
if (k >= 4) { grid(b, 'snare.rim', '....x.......x...', 0.5); hats(b, '..x...x...x...x.', 0.3); }
});
section('build', 4, (b, k) => { // a snare roll that keeps pulling inward
pads(b, k, 0.7);
grid(b, 'kick.punch', 'x...x...x...x...', 0.8);
if (k === 0 || k === 2) hit(b, 0, 'reese.f1', 16, 0.8);
const roll = ['....x.......x...', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.', 'xxxxxxxxxxxxxxxx'][k];
[...roll].forEach((c, i) => { if (c === 'x') hit(b, i, 'snare.main', 1, 0.35 + 0.15 * k + i * 0.012); });
if (k === 1) hit(b, 12, 'laser', 4);
if (k === 2) hit(b, 0, 'riser', 16);
if (k === 3) hit(b, 4, 'reverse', 12);
});
drop('drop.again', true);
section('outro', 8, (b, k) => {
pads(b, k, 0.8);
if (k < 4) {
grid(b, 'kick.punch', 'x.........x.....', 0.7);
grid(b, 'snare.main', '....x.......x...', 0.7);
hats(b, 'o.x.o.x.o.x.o.x.', 0.35);
bass(b, k, 0.8);
}
if (k === 4) line(b, [[0,'downlifter',14], [0,'crash',8,0.5]]);
if (k >= 4 && k < 7) grid(b, 'ride', 'x...x...x...x...', 0.25);
if (k === 6) hit(b, 8, 'vox.ah', 4, 0.5);
if (k === 7) line(b, [[0,'kick.sub',4], [0,'reese.f1',16,0.7], [0,'crash',8,0.4], [0,'pad.fm',32]]);
});
},
},
{
title: 'All You Twin', meta: 'e minor, 152 bpm, idm', bpm: 152, swing: 0, tail: 2,
room: { seconds: 1.2, shape: 4, wet: 0.15 },
kit: KIT_IDM,
write({ hit, grid, line, section }) {
// a seeded random generator: the beat sounds improvised but plays identically every time
let seed = 2001;
const rng = () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const pick = list => list[Math.floor(rng() * list.length)];
const chance = p => rng() < p;
const anyHalfStep = () => Math.floor(rng() * 32) / 2;
// n hits spread evenly across `span` steps, swelling from v0 to v1: the drill roll
const ratchet = (b, step, id, n, span, v0, v1) => {
for (let i = 0; i < n; i++) hit(b, step + span * i / n, id, 1, v0 + (v1 - v0) * (n > 1 ? i / (n - 1) : 1));
};
// a hit every `period` steps counted across bars, so threes, fives and sevens drift against the bar
const cycle = (b, id, period, offset, v) => {
for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, id, 1, v);
};
// Em9 | Cmaj7 | Am9 | Bsus, two bars each
const CH = ['em9', 'em9', 'cmaj7', 'cmaj7', 'am9', 'am9', 'bsus', 'bsus'];
const pads = (b, k, v) => { if (k % 2 === 0) hit(b, 0, `pad.${CH[k % 8]}`, 32, v); };
const MELODY = [
[[0,'box.b4',2], [2,'box.e5',2], [4,'box.d5',2], [6,'box.b4',2], [8,'box.g4',4], [12,'box.a4',2], [14,'box.b4',2]],
[[0,'box.g4',6], [6,'box.f♯4',2], [8,'box.e4',8]],
[[0,'box.c5',2], [2,'box.e5',2], [4,'box.d5',2], [6,'box.c5',2], [8,'box.b4',4], [12,'box.g4',4]],
[[0,'box.a4',6], [6,'box.b4',2], [8,'box.g4',8]],
[[0,'box.e5',2], [2,'box.c5',2], [4,'box.a4',2], [6,'box.c5',2], [8,'box.b4',4], [12,'box.a4',2], [14,'box.g4',2]],
[[0,'box.a4',8], [8,'box.e4',4], [12,'box.g4',4]],
[[0,'box.f♯4',2], [2,'box.a4',2], [4,'box.b4',4], [8,'box.d5',2], [10,'box.b4',2], [12,'box.a4',4]],
[[0,'box.a4',4], [4,'box.f♯4',4], [8,'box.b4',8]],
];
const melody = (b, k, v) => MELODY[k % 8].forEach(([st, id, len]) => hit(b, st, id, len, v));
const ACID = [
[[0,'acid.e2'], [2,'acid.e3'], [3,'acid.e2'], [6,'acid.g2'], [8,'acid.e2'], [10,'acid.b2'], [11,'acid.d3'], [14,'acid.e3']],
[[0,'acid.e2'], [1,'acid.e2'], [4,'acid.g3'], [6,'acid.e3'], [7,'acid.b2'], [10,'acid.a2'], [12,'acid.g2'], [14,'acid.b3']],
];
const acidBar = (b, k) => ACID[k % 2].forEach(([st, id], i) => hit(b, st, id, 1, i === 0 ? 0.95 : 0.5 + rng() * 0.35));
const KICKS = ['x.....x...x.....', 'x..x......x..x..', 'x.......x.x.....', 'x..x..x.....x...', 'x.x.......x...x.', 'x.....x.x..x....'];
const SNARES = ['....x.......x...', '....x.....x.....', '....x..x....x...', '.....x......x...'];
const DEBRIS = ['click.1', 'click.2', 'blip', 'bleep', 'zap.small', 'metal.ping', 'rim.glitch', 'tick.wood', 'static', 'stutter'];
// one bar of scattered drill'n'bass
const drill = (b, density) => {
[...pick(KICKS)].forEach((c, st) => { if (c === 'x') hit(b, st + (chance(0.15) ? 0.5 : 0), 'kick.hard'); });
if (chance(0.5)) hit(b, pick([3, 7, 11, 13.5]), 'kick.short', 1, 0.7);
grid(b, 'snare.crack', pick(SNARES));
for (let r = 0; r < density; r++) ratchet(b, pick([6, 10, 12, 14]), 'snare.crack', pick([3, 4, 6, 8]), pick([1, 2]), 0.25, 0.9);
for (let st = 0; st < 16; st++) {
if (chance(0.7)) hit(b, st, 'hat.tick', 1, 0.35 + rng() * 0.35);
if (chance(0.15)) hit(b, st + 0.5, 'snare.ghost', 1, 0.3 + rng() * 0.3);
}
if (chance(0.3)) ratchet(b, pick([2, 6, 9]), 'hat.closed', 3, 1, 0.3, 0.5);
const n = 1 + Math.floor(rng() * 3);
for (let i = 0; i < n; i++) hit(b, anyHalfStep(), pick(DEBRIS), 1, 0.4 + rng() * 0.3);
};
section('lullaby', 8, (b, k) => { // a worn music box over warm pads
pads(b, k, 0.8);
melody(b, k, 0.9);
if (k === 0 || k === 4) hit(b, 0, 'static', 4, 0.4);
if (chance(0.6)) hit(b, Math.floor(rng() * 16), 'click.1', 1, 0.35);
if (k === 3) hit(b, 14, 'tick.wood', 1, 0.5);
if (k === 6) hit(b, 8, 'fm.bell', 8, 0.5);
if (k === 7) { hit(b, 12, 'reverse.snare', 4); ratchet(b, 14, 'blip', 4, 2, 0.3, 0.6); }
});
section('splinter', 8, (b, k) => { // the beat arrives in pieces
pads(b, k, 0.6);
if (k < 4) melody(b, k, 0.65);
grid(b, 'kick.hard', pick(KICKS));
grid(b, 'snare.crack', pick(SNARES), 0.75);
cycle(b, 'hat.tick', 5, 0, 0.4); // fives against the four-beat bar
cycle(b, 'hat.closed', 3, 1, 0.25); // and threes
for (let i = 0; i < 2; i++) hit(b, anyHalfStep(), pick(['click.1', 'click.2']), 1, 0.45);
if (k >= 4) acidBar(b, k);
if (k % 2 === 1) ratchet(b, 14, 'snare.crack', 6, 2, 0.3, 0.9);
if (k === 0) hit(b, 0, 'crush'); // the first splinter
if (k === 2 || k === 6) hit(b, 7, 'bleep');
if (k === 3) hit(b, 10, 'zap.small');
});
section('drill', 12, (b, k) => { // full drill'n'bass
pads(b, k, 0.5);
drill(b, k < 6 ? 1 : 2);
acidBar(b, k);
if (k % 4 === 0) hit(b, 0, 'bit.chord');
if (k % 4 === 3) { hit(b, 12, 'snare.808'); hit(b, 13.5, 'snare.808', 1, 0.6); }
if (k % 2 === 1) hit(b, 12, 'clap.glitch');
if (k % 4 === 2) hit(b, 14, 'hat.open');
if (k === 4 || k === 8) hit(b, 0, 'kick.808', 8);
if (k === 6) hit(b, 8, 'grain.vox', 4);
if (k >= 8) melody(b, k, 0.55);
});
section('collapse', 4, (b, k) => { // the tape stops and the floor drops out
if (k === 0) line(b, [[0,'tape.stop',8], [0,'sub.drop',12], [10,'stutter',4]]);
if (k > 0) {
hit(b, 0, 'kick.808', 8);
hit(b, 8, 'snare.808');
grid(b, 'hat.shuffle', '..x...x...x...x.', 0.4);
}
if (k === 1) line(b, [[0,'pad.am9',32,0.7], [4,'grain.vox',4]]);
if (k === 2) line(b, [[0,'pad.bsus',32,0.7], [6,'fm.bell',8,0.6], [12,'static',4,0.5]]);
if (k === 3) { hit(b, 12, 'reverse.snare', 4); ratchet(b, 14, 'snare.crack', 8, 2, 0.3, 1); }
});
section('rebuild', 12, (b, k) => { // the melody returns over the scattered beat
pads(b, k, 0.55);
melody(b, k, 0.8);
drill(b, k % 2);
acidBar(b, k + 1);
cycle(b, 'hat.shuffle', 7, 3, 0.35); // sevens
if (k % 4 === 0) hit(b, 0, 'bit.chord', 1, 0.7);
if (k === 0) hit(b, 0, 'crush');
if (k % 2 === 1) hit(b, 12, 'clap.glitch', 1, 0.6);
if (k === 6) hit(b, 0, 'kick.808', 8);
if (k === 11) {
ratchet(b, 8, 'snare.crack', 12, 8, 0.25, 1);
hit(b, 12, 'reverse.snare', 4);
hit(b, 15, 'crush');
}
});
section('lullaby.again', 8, (b, k) => { // the beat dissolves back into the music box
pads(b, k, 0.75);
melody(b, k, 0.85);
if (k < 4) {
hit(b, 0, 'kick.short', 1, 0.5);
cycle(b, 'hat.tick', 5, 0, 0.25 - k * 0.04);
if (chance(0.7)) hit(b, anyHalfStep(), pick(['click.1', 'click.2', 'blip']), 1, 0.35);
}
if (k === 2) hit(b, 8, 'fm.bell', 8, 0.45);
if (k === 4) hit(b, 0, 'static', 4, 0.35);
if (k === 7) line(b, [[8,'tape.stop',8,0.8], [8,'static',4,0.3]]); // and the tape winds down
});
},
},
{
title: 'Zones', meta: 'g minor, 174 bpm, drill & bass', bpm: 174, swing: 0, tail: 2,
room: { seconds: 1.0, shape: 4, wet: 0.14 },
kit: KIT_FAULT,
write({ hit, grid, line, section, chaser, ratchet, cycle, seeded }) {
const { rng, pick, chance } = seeded(2002);
const halfStep = () => Math.floor(rng() * 32) / 2;
const hats = chaser(['hat.break', 'hat.tick']);
// Gm | Eb | Cm | D, two bars each
const CH = ['gm', 'gm', 'eb', 'eb', 'cm', 'cm', 'd', 'd'];
const BASS = {
gm: [[[0,'reese.g1',6], [6,'reese.g1',4], [10,'reese.g1',6]],
[[0,'reese.g1',6], [6,'reese.b♭1',4], [10,'reese.d2',4], [14,'reese.g1',2]]],
eb: [[[0,'reese.e♭2',6], [6,'reese.e♭2',4], [10,'reese.e♭2',6]],
[[0,'reese.e♭2',6], [6,'reese.d2',4], [10,'reese.c2',4], [14,'reese.b♭1',2]]],
cm: [[[0,'reese.c2',6], [6,'reese.c2',4], [10,'reese.c2',6]],
[[0,'reese.c2',6], [6,'reese.d2',4], [10,'reese.e♭2',4], [14,'reese.d2',2]]],
d: [[[0,'reese.d2',6], [6,'reese.d2',4], [10,'reese.d2',6]],
[[0,'reese.d2',6], [6,'reese.a1',4], [10,'reese.d2',2], [12,'reese.c2',2], [14,'reese.a1',2]]],
};
const bass = (b, k, v) => BASS[CH[k % 8]][k % 2].forEach(([st, id, len]) => hit(b, st, id, len, v));
const pads = (b, k, v) => {
const p = { 0: 'pad.gm9', 2: 'pad.e♭maj7', 4: 'pad.gm9', 6: 'pad.e♭maj7' }[k % 8];
if (p) hit(b, 0, p, 32, v);
};
const LEAD = [
[[0,'glass.g4',2], [2,'glass.b♭4',2], [4,'glass.d5',2], [6,'glass.f5',2], [8,'glass.d5',4], [12,'glass.b♭4',2], [14,'glass.c5',2]],
[[0,'glass.d5',4], [4,'glass.c5',2], [6,'glass.b♭4',2], [8,'glass.a4',8]],
[[0,'glass.g4',2], [2,'glass.b♭4',2], [4,'glass.e♭5',2], [6,'glass.d5',2], [8,'glass.b♭4',4], [12,'glass.g4',4]],
[[0,'glass.b♭4',4], [4,'glass.c5',2], [6,'glass.d5',2], [8,'glass.e♭5',8]],
[[0,'glass.c5',2], [2,'glass.e♭5',2], [4,'glass.g5',2], [6,'glass.f5',2], [8,'glass.e♭5',4], [12,'glass.d5',2], [14,'glass.c5',2]],
[[0,'glass.e♭5',4], [4,'glass.d5',4], [8,'glass.c5',8]],
[[0,'glass.a4',2], [2,'glass.d5',2], [4,'glass.a4',2], [6,'glass.d5',2], [8,'glass.c5',4], [12,'glass.b♭4',4]],
[[0,'glass.a4',4], [4,'glass.b♭4',4], [8,'glass.a4',4], [12,'glass.d5',4]],
];
const lead = (b, k, v, drop = 0) => LEAD[k % 8].forEach(([st, id, len]) => { if (!chance(drop)) hit(b, st, id, len, v); });
const ARPS = {
gm: ['glass.g4', 'glass.b♭4', 'glass.d5', 'glass.f5'],
eb: ['glass.g4', 'glass.b♭4', 'glass.e♭5', 'glass.d5'],
cm: ['glass.g4', 'glass.c5', 'glass.e♭5', 'glass.g5'],
d: ['glass.a4', 'glass.c5', 'glass.d5', 'glass.f5'],
};
const arp = (b, k, v) => [0, 1, 2, 3, 2, 1, 2, 3].forEach((n, i) => hit(b, i * 2, ARPS[CH[k % 8]][n], 2, v));
// chopped breakbeat patterns: kick, snare, ghost snare
const BREAKS = [
{ k: 'x.x.......x.....', s: '....x.......x...', g: '.......o.o....o.' },
{ k: 'x.........xx....', s: '....x.......x..x', g: '..o....o......o.' },
{ k: 'x.x.......x.....', s: '....x..x....x...', g: '......o.......o.' },
{ k: 'x.........x...x.', s: '....x.......x.x.', g: '.o.....o.o......' },
];
const DEBRIS = ['click', 'blip', 'bleep', 'zap', 'metal.ping', 'static', 'stutter', 'crush'];
// one bar of chopped break; heat 0..1 is how badly it splinters
const breakBar = (b, k, heat) => {
const br = BREAKS[k % 4 === 3 ? pick([1, 3]) : pick([0, 2])];
[...br.k].forEach((c, st) => { if (c === 'x') hit(b, st + (chance(0.12 * heat) ? 0.5 : 0), 'kick.break'); });
grid(b, 'snare.amen', br.s);
grid(b, 'snare.ghost', br.g);
hats(b, 'x.x.x.x.x.x.x.x.', 0.45);
for (let st = 1; st < 16; st += 2) if (chance(0.35)) hit(b, st, 'hat.tick', 1, 0.25 + rng() * 0.25);
if (chance(0.4 + 0.5 * heat)) ratchet(b, pick([6, 10, 14]), 'snare.crush', pick([3, 4, 6, 8]), pick([1, 2]), 0.25, 0.85);
const n = Math.floor(heat * 3 + rng() * 2);
for (let i = 0; i < n; i++) hit(b, halfStep(), pick(DEBRIS.slice(0, heat > 0.6 ? 8 : 6)), 1, 0.35 + rng() * 0.3);
};
section('glass', 8, (b, k) => { // plucks and pads, the beat at a distance
pads(b, k, 0.8);
if (k < 4) lead(b, k, 0.7);
if (k === 0) hit(b, 0, 'static', 4, 0.4);
for (let i = 0; i < 2; i++) if (chance(0.7)) hit(b, halfStep(), pick(['click', 'blip']), 1, 0.35);
if (k === 2) hit(b, 10, 'zap');
if (k === 3) line(b, [[14,'blip'], [15,'bleep']]);
if (k >= 4) {
grid(b, 'kick.sub', 'x.........x.....', 0.6);
grid(b, 'rim.glitch', '....x.......x...', 0.6);
cycle(b, 'hat.tick', 5, 0, 0.35);
}
if (k === 5) hit(b, 8, 'vox.chop', 4);
if (k === 6) hit(b, 0, 'riser', 16);
if (k === 7) {
hit(b, 4, 'reverse', 12);
ratchet(b, 8, 'snare.crush', 8, 6, 0.3, 0.8);
ratchet(b, 14, 'snare.crush', 6, 2, 0.6, 1);
}
});
const drop = (name, second) => section(name, 16, (b, k) => {
if (k === 0) line(b, [[0,'impact',8], [0,'crash.crush',8], [0,'sub.g1',8]]);
if (k === 8) hit(b, 0, 'crash.crush', 8);
breakBar(b, k, second ? 0.8 + 0.2 * (k / 15) : (k < 8 ? 0.4 : 0.7));
bass(b, k);
pads(b, k, second ? 0.45 : 0.5);
if (second) grid(b, 'ride.crunch', 'x...x...x...x...', 0.4);
if (k % 2 === 1) hit(b, 14, 'hat.open');
const hookFirst = !second;
if ((k < 8) === hookFirst) lead(b, k, 0.8); else arp(b, k, 0.7);
if (k % 4 === 0 && (second || k >= 8)) hit(b, 0, 'bit.stab', 1, 0.75);
if (k % 4 === (second ? 1 : 2) && k >= (second ? 0 : 8)) hit(b, 6, 'stab.stutter', 1, 0.7);
if (k % 4 === (second ? 1 : 3)) line(b, [[8,'acid.g2'], [10,'acid.d3'], [11,'acid.g2',1,0.6], [14,'acid.d3']]);
if (k % (second ? 4 : 8) === (second ? 3 : 7)) hit(b, 12, 'growl.d2', 4);
if (k === 2 && !second) hit(b, 15, 'crush');
if (k === 4) hit(b, 8, 'laser', 4);
if (k === (second ? 12 : 10)) hit(b, 0, 'siren', 8);
if (second && (k === 6 || k === 14)) hit(b, 4, 'vox.chop', 4);
});
drop('fault', false);
section('splinter.break', 8, (b, k) => { // the break shatters into pure glitch
pads(b, k, 0.8);
if (k % 2 === 0) hit(b, 0, 'sub.g1', 16, 0.8);
lead(b, k, 0.6, 0.3); // the melody, with pieces missing
cycle(b, 'hat.tick', 5, 0, 0.4);
cycle(b, 'hat.break', 7, 2, 0.3);
grid(b, 'kick.sub', pick(['x.........x.....', 'x......x........', 'x.....x...x.....']), 0.7);
for (let r = 0; r < 1 + (k % 2); r++) ratchet(b, pick([4, 8, 12]), 'snare.crush', pick([4, 6, 8]), pick([1, 2]), 0.25, 0.8);
for (let i = 0; i < 2; i++) hit(b, halfStep(), pick(DEBRIS.slice(0, 6)), 1, 0.4);
if (k === 0 || k === 4) hit(b, 0, 'bit.stab', 1, 0.7);
if (k === 1) hit(b, 8, 'stutter');
if (k === 2 || k === 6) hit(b, 4, 'vox.chop', 4);
if (k === 3) hit(b, 6, 'metal.ping');
});
section('build', 4, (b, k) => { // rolls that keep pulling inward
pads(b, k, 0.7);
grid(b, 'kick.break', 'x...x...x...x...', 0.8);
const roll = ['....x.......x...', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.', 'xxxxxxxxxxxx....'][k];
[...roll].forEach((c, i) => { if (c === 'x') hit(b, i, 'snare.amen', 1, 0.35 + 0.15 * k + i * 0.012); });
[0, 4, 8, 12].forEach(st => { if (k >= 1 && chance(0.6)) ratchet(b, st + 2, 'snare.crush', 2 + k, 1, 0.3, 0.7); });
if (k === 0 || k === 2) hit(b, 0, 'reese.g1', 16, 0.8);
if (k === 2) hit(b, 0, 'riser', 16);
if (k === 3) { hit(b, 4, 'reverse', 12); hit(b, 12, 'tape.stop', 4); }
});
drop('fault.again', true);
section('outro', 8, (b, k) => { // the break dissolves into clicks and glass
pads(b, k, 0.75);
if (k < 4) { breakBar(b, k, 0.5 - k * 0.1); bass(b, k, 0.8); }
if (k >= 4) {
lead(b, k, 0.7);
cycle(b, 'hat.tick', 5, 0, 0.25);
if (chance(0.7)) hit(b, halfStep(), pick(['click', 'blip']), 1, 0.35);
}
if (k === 4) hit(b, 0, 'crash.crush', 8, 0.5);
if (k === 7) line(b, [[8,'tape.stop',8,0.8], [8,'static',4,0.3]]);
});
},
},
{
title: 'Torque Region', meta: 'c phrygian, 84 bpm, dark half-time', bpm: 84, swing: 0.15, tail: 6,
room: { seconds: 4.5, shape: 2.2, wet: 0.2, tone: 0.18 }, // a large, dark space
kit: KIT_UNDERTOW,
write({ hit, grid, line, section, chaser, ratchet, cycle, seeded }) {
const { rng, pick, chance } = seeded(1987);
const halfStep = () => Math.floor(rng() * 32) / 2;
const hats = chaser(['hat.chain', 'hat.tick']);
// C, C, D-flat, C: the flattened second pulls everything down
const CH = ['c', 'c', 'd♭', 'c'];
const RIFFS = {
c: [[[0,'sludge.c2',4], [6,'sludge.c2',2], [8,'sludge.e♭2',2], [10,'sludge.c2',2], [12,'sludge.g1',4]],
[[0,'sludge.c2',4], [6,'sludge.d♭2',2], [8,'sludge.c2',4], [12,'sludge.a♭1',2], [14,'sludge.g1',2]]],
'd♭': [[[0,'sludge.d♭2',4], [6,'sludge.d♭2',2], [8,'sludge.f2',2], [10,'sludge.e♭2',2], [12,'sludge.d♭2',4]]],
};
const riff = (b, ch, k, v) => { const set = RIFFS[ch]; set[k % set.length].forEach(([st, id, len]) => hit(b, st, id, len, v)); };
const droneOn = (b, ch, v) => hit(b, 0, ch === 'd♭' ? 'drone.d♭' : 'drone.c', 32, v);
const BELLS = [
[[0,'ash.g4',6], [6,'ash.a♭4',2], [8,'ash.g4',8]],
[[0,'ash.e♭4',4], [4,'ash.f4',4], [8,'ash.d♭4',8]],
[[0,'ash.f4',6], [6,'ash.a♭4',2], [8,'ash.c5',4], [12,'ash.b♭4',4]],
[[0,'ash.a♭4',4], [4,'ash.g4',4], [8,'ash.c4',8]],
];
const bells = (b, k, v, drop = 0) => BELLS[k % 4].forEach(([st, id, len]) => { if (!chance(drop)) hit(b, st, id, len, v); });
const TEXTURE = ['anvil', 'pipe', 'knock', 'creak', 'click.dark', 'static.dark', 'bleep.low', 'scrape'];
// the half-time beat: the kick on one, the heavy snare on three, the hats dragging behind
const trudge = (b, heat) => {
grid(b, 'kick.dirt', pick(['x.........x.....', 'x..........x....', 'x.........x..x..']));
if (chance(0.4)) hit(b, pick([6, 7, 13.5]), 'kick.hollow', 1, 0.6);
hit(b, 8, 'snare.ash');
grid(b, 'snare.ghost', pick(['...o......o...o.', '.o.....o.....o..']));
hats(b, 'x.x.x.x.x.x.x.x.', 0.4);
if (chance(0.3 + heat * 0.5)) ratchet(b, pick([4, 12, 14]), 'hat.tick', 3, 1, 0.3, 0.5);
if (chance(heat)) ratchet(b, pick([10, 12, 14]), 'snare.metal', pick([3, 4, 6]), 2, 0.3, 0.8);
for (let i = 0; i < 1 + Math.floor(heat * 2); i++) hit(b, halfStep(), pick(TEXTURE.slice(0, heat > 0.5 ? 8 : 5)), 1, 0.4 + rng() * 0.3);
};
section('descent', 6, (b, k) => { // sinking into the dark: drone, rumble, bells
if (k % 2 === 0) droneOn(b, 'c', 0.9);
if (k === 0) line(b, [[0,'rumble',16], [4,'granule',8]]);
if (k === 1) { bells(b, 0, 0.7); hit(b, 8, 'creak'); }
if (k === 2) { hit(b, 0, 'choir.cm', 32, 0.8); bells(b, 1, 0.7); hit(b, 12, 'knock'); }
if (k === 3) { bells(b, 2, 0.7); hit(b, 0, 'static.dark', 4, 0.4); }
if (k === 4) { hit(b, 0, 'vox.ghost', 16); bells(b, 3, 0.7); hit(b, 8, 'granule', 8); }
if (k === 5) {
line(b, [[0,'pipe'], [4,'reverse.swell',12]]);
grid(b, 'hat.sand', '..x...x...x...x.', 0.4);
ratchet(b, 13, 'click.dark', 4, 3, 0.3, 0.6);
}
});
section('trudge', 8, (b, k) => { // the beat, heavy and slow
const ch = CH[k % 4];
if (k % 2 === 0) droneOn(b, ch, 0.7);
riff(b, ch, k);
trudge(b, 0.25);
if (k >= 2) bells(b, k, 0.8);
if (k === 0) hit(b, 0, 'anvil'); // strong enough to crack its neighbours
if (k === 1) hit(b, 7, 'kick.hollow', 1, 0.7);
if (k >= 4) hit(b, 8, 'clap.rust', 1, 0.6);
if (k % 4 === 3) hit(b, 14, 'hat.open');
cycle(b, 'hat.sand', 3, 1, 0.25);
});
section('fracture', 6, (b, k) => { // the glitches break through
const ch = k % 2 ? 'c' : 'd♭';
if (k % 2 === 0) droneOn(b, 'd♭', 0.7);
riff(b, ch, k);
trudge(b, 0.7);
bells(b, k, 0.7, 0.35); // the melody, pieces missing
if (k % 3 === 0) hit(b, 0, 'bit.drone', 8);
if (k % 2 === 1) { hit(b, 6, 'dark.stab'); hit(b, 10.5, 'dark.stab', 1, 0.6); }
if (k === 0) hit(b, 3, 'bleep.low');
if (k === 1) hit(b, 15, 'crush.low');
if (k === 2) hit(b, 8, 'stutter.slow', 4);
if (k === 3) hit(b, 0, 'granule', 8);
if (k === 4) hit(b, 7, 'crush.low');
if (k === 5) hit(b, 4, 'scrape', 6);
});
section('void', 4, (b, k) => { // the floor gives way
if (k === 0) line(b, [[0,'drop.void',16], [0,'tape.drag',12], [0,'choir.d♭',32]]);
if (k > 0) hit(b, 0, 'kick.gate');
if (k === 1) line(b, [[8,'pipe'], [4,'vox.ghost',16], [12,'static.dark',4,0.5]]);
if (k === 2) line(b, [[0,'choir.cm',32], [8,'granule',8], [10,'kick.gate',1,0.6]]);
if (k === 3) {
line(b, [[2,'reverse.swell',14], [12,'reverse.hit',4]]);
ratchet(b, 14, 'snare.metal', 6, 2, 0.3, 0.9);
}
});
section('trudge.again', 6, (b, k) => { // the heaviest pass
const ch = CH[k % 4];
if (k % 2 === 0) droneOn(b, ch, 0.75);
riff(b, ch, k);
trudge(b, 0.6 + 0.08 * k);
hit(b, 8, 'clap.rust', 1, 0.7);
bells(b, k, 0.85);
grid(b, 'hat.chain', 'x.xxx.xxx.xxx.xx', 0.3);
hit(b, 14, 'hat.open', 1, 0.4);
if (k === 0) line(b, [[0,'rumble',16], [0,'anvil']]);
if (k === 3) hit(b, 0, 'anvil', 1, 0.8);
if (k % 2 === 0) hit(b, 12, 'dark.stab', 1, 0.7);
if (k === 4) hit(b, 0, 'bit.drone', 8);
});
section('sediment', 4, (b, k) => { // everything settles to the bottom
droneOn(b, 'c', 0.8);
bells(b, k, 0.7);
if (k < 2) {
hit(b, 0, 'kick.dirt', 1, 0.6);
hit(b, 8, 'snare.ash', 1, 0.5);
cycle(b, 'hat.chain', 5, 0, 0.3);
}
if (k === 2) line(b, [[0,'choir.cm',32,0.7], [4,'granule',8]]);
if (k === 3) line(b, [[0,'rumble',16,0.6], [8,'tape.drag',8,0.8], [8,'static.dark',4,0.3]]);
});
},
},
{
title: 'Symbol Hg', meta: 'octatonic on c, 104 bpm, generative', bpm: 104, swing: 0, tail: 3,
room: { seconds: 2.2, shape: 3, wet: 0.16, tone: 0.5 },
kit: KIT_RULE,
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]; };
// lay a pattern of length n 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 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);
// on a ring of 16, rule 90 eventually cancels itself out; when the row dies, a few new cells are seeded
if (!cells.some(Boolean)) {
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())); // the cell-to-tile mapping rotates each bar
});
// micro-timing that sways slowly ahead of and behind the grid
const leanAt = (b, depth) => depth * Math.sin(b * 0.7);
// 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 BASSES = ['fmbass.c2', 'fmbass.e♭2', 'fmbass.f♯2', 'fmbass.a2']; // minor thirds: the scale's own symmetry
const HATS_A = ['hat.fm1', 'hat.fm2', 'hat.noise'];
const HATS_B = ['hat.fm1', 'hat.fm2', 'hat.noise', 'hat.ring'];
const TICKS = ['click.sample', 'tick.sine'];
section('init', 6, (b, k) => { // the system boots: pads, pings, ticks
if (k === 0) hit(b, 0, 'cold.pad.1', 24, 0.8);
if (k === 3) hit(b, 0, 'comb.drone', 24, 0.8);
pings(b, 13, 3, 0.55);
euclid(b, TICKS, 3, 11, k, 0.4);
if (k === 1) hit(b, 4, 'glass.harm', 16);
if (k === 4) hit(b, 8, 'grain.cloud', 8);
if (k === 5) line(b, [[0,'reso.sweep',8], [12,'reverse.fm',4]]);
});
section('lattice', 8, (b, k) => { // rules running: rule 90 drives the metal
const lean = leanAt(b, 0.2);
evolve(90);
if (k % 4 === 0) hit(b, 0, 'cold.pad.1', 24, 0.6);
euclid(b, 'kick.round', 3, 8, 0, 1, lean);
euclid(b, 'snare.fm', 2, 7, 3, 0.8, lean);
euclid(b, HATS_A, 7, 12, k, 0.5, lean);
cellHits(b, ['clang.2', 'wood.fm', 'rim.fm', 'tine'], 0.5, lean);
euclid(b, BASSES, 5, 16, 2, 0.85, lean);
pings(b, 3, 3, 0.6);
if (k === 0) hit(b, 0, 'clang.1'); // strong enough to crack its neighbours
if (k === 4) hit(b, 0, 'phase.tone', 16); // sets the lattice turning
if (k % 2 === 1) hit(b, 15, 'hat.ring', 1, 0.5);
});
section('mutation', 8, (b, k) => { // rule 30: the patterns turn chaotic and dense
const lean = leanAt(b, 0.35);
evolve(30);
euclid(b, k % 2 ? 'kick.fm' : 'kick.round', 3 + (k >> 1), 8 + (k % 3), 0, 1, lean);
euclid(b, ['snare.fm', 'snare.noise'], 2 + (k % 3), 7, k, 0.8, lean);
euclid(b, HATS_B, 5 + k, 13, 2 * k, 0.5, lean);
cellHits(b, ['clang.2', 'clang.3', 'wood.fm', 'rim.fm', 'tine', 'grain.hit'], 0.55, lean);
euclid(b, BASSES, 5 + (k % 3), 16, k, 0.85, lean);
pings(b, 5, 3, 0.6, 1);
if (k % 4 === 0) hit(b, 0, 'cold.pad.2', 24, 0.55);
if (k === 0) hit(b, 0, 'data.burst', 4);
if (k === 2) hit(b, 8, 'grain.cloud', 8);
if (k === 3) hit(b, 4, 'reso.sweep', 8);
if (k === 4) hit(b, 0, 'formant.shift', 12); // turns the lattice the other way
if (k === 5) hit(b, 0, 'crunch');
if (k === 6) hit(b, 10, 'buzz', 2);
if (k === 7) line(b, [[0,'data.burst',4], [12,'reverse.fm',4], [15,'crunch']]);
});
section('degrade', 6, (b, k) => { // the layers fall away one at a time
const lean = leanAt(b, 0.4);
evolve(30);
if (k < 4) euclid(b, 'kick.click', 3, 8, k, 0.9, lean);
if (k < 3) euclid(b, 'snare.gate', 2, 9, 1, 0.8, lean);
if (k < 5) cellHits(b, ['tick.sine', 'click.sample', 'grain.hit'], 0.45 - k * 0.05, lean);
if (k < 2) euclid(b, BASSES, 4, 16, 0, 0.8, lean);
pings(b, 7 - (k % 3), 5, 0.55);
if (k === 0) hit(b, 0, 'bit.choir', 24, 0.7);
if (k === 1) hit(b, 4, 'formant.shift', 12);
if (k === 2) for (let i = 0; i < 5; i++) hit(b, 8 + i / 3, 'tick.sine', 1, 0.3 + i * 0.1); // a triplet stutter
if (k === 3) hit(b, 0, 'fm.bell.low', 8);
if (k === 4) hit(b, 0, 'cold.pad.2', 24, 0.6);
if (k === 5) line(b, [[0,'zap.fm'], [8,'reverse.fm',8], [12,'clap.digital']]);
});
section('re.assembly', 8, (b, k) => { // everything returns, rotated into new places
const lean = leanAt(b, 0.25);
evolve(k < 4 ? 90 : 30);
if (k % 4 === 0) hit(b, 0, 'cold.pad.1', 24, 0.6);
euclid(b, ['kick.round', 'kick.fm'], 5, 12, 1, 1, lean);
euclid(b, ['snare.fm', 'snare.gate', 'clap.digital'], 3, 8, 4, 0.8, lean);
euclid(b, ['hat.fm2', 'hat.noise', 'hat.fm1'], 9, 16, k, 0.5, lean);
cellHits(b, ['clang.2', 'clang.3', 'wood.fm', 'rim.fm', 'tine', 'pluck.metal'], 0.55, lean);
euclid(b, BASSES, 7, 16, 3, 0.85, lean);
pings(b, 3, 5, 0.65);
if (k === 0) hit(b, 0, 'clang.1');
if (k === 2) hit(b, 0, 'fm.bell.low', 8);
if (k === 4) hit(b, 0, 'phase.tone', 16);
if (k === 5) hit(b, 0, 'data.burst', 4);
if (k === 6) line(b, [[0,'crunch'], [8,'buzz',2]]);
if (k === 7) {
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);
}
});
section('halt', 4, (b, k) => { // the system winds down to a last click
pings(b, 4 + k, 3, 0.6 - k * 0.08);
if (k === 0) line(b, [[0,'cold.pad.1',24,0.7], [0,'comb.drone',24,0.6]]);
if (k < 2) euclid(b, TICKS, 3, 11, k, 0.35);
if (k === 2) hit(b, 0, 'glass.harm', 16, 0.8);
if (k === 3) line(b, [[0,'pluck.metal'], [8,'click.sample',1,0.6]]);
});
},
},
{
title: 'Planet Work', meta: 'whole-tone on e, 170 bpm, digital drum & bass', bpm: 170, swing: 0, tail: 2,
room: { seconds: 1.2, shape: 4, wet: 0.12, tone: 0.8 },
kit: KIT_PLANET,
write({ hit, grid, line, section, ratchet, seeded, euclid, automaton }) {
const { rng, pick, chance } = seeded(8);
const ca = automaton(rng);
// the chords: E, G-sharp, C, D, two bars each; the whole-tone scale has only two augmented triads, so the arps alternate up and down
const ROOTS = ['e', 'e', 'g♯', 'g♯', 'c', 'c', 'd', 'd'];
const ARP = { 'e': 'arp.e+', 'g♯': 'arp.e+.down', 'c': 'arp.f♯+.down', 'd': 'arp.f♯+' };
// a step-sequenced lead: one motif clocked through a gate pattern, moved with the chord and rotated every four bars
const CHIP = ['chip.e4', 'chip.f♯4', 'chip.g♯4', 'chip.a♯4', 'chip.c5', 'chip.d5', 'chip.e5', 'chip.f♯5'];
const SHIFT = { 'e': 0, 'g♯': 2, 'c': 4, 'd': 5 };
const MOTIF = [0, 2, 4, 2, 6, 4, 2, 1];
const GATES = ['x.xx.xx.x.xx.xx.', 'x.x.xx.xx.x.xx.x', 'xx.x.xx.x.xx.x.x'];
let motifStep = 0;
const lead = (b, k, v, gate) => {
const shift = SHIFT[ROOTS[k % 8]], rot = Math.floor(k / 4);
[...gate].forEach((c, st) => {
if (c !== 'x') return;
hit(b, st, CHIP[(MOTIF[(motifStep + rot) % 8] + shift) % 8], 1, v * (st % 4 === 0 ? 1 : 0.75));
motifStep++;
});
};
// the bass as a sixteenth-note sequencer line; digits pick root (0), third (1) or passing note (2), accented on the beat
const BASSNOTES = {
'e': ['tri.e2', 'tri.g♯2', 'tri.f♯2'],
'g♯': ['tri.g♯2', 'tri.c3', 'tri.a♯2'],
'c': ['tri.c3', 'tri.e2', 'tri.d3'],
'd': ['tri.d3', 'tri.f♯2', 'tri.c3'],
};
const BASSSEQ = ['0.00.0.10.00.2.0', '0.0.00.10.0.20.0'];
const bass = (b, k, v = 1) => {
const notes = BASSNOTES[ROOTS[k % 8]];
[...BASSSEQ[k % 2]].forEach((c, st) => { if (c !== '.') hit(b, st, notes[+c], 1, v * (st % 4 === 0 ? 1 : 0.7)); });
};
const BLIPS = ['blip.1', 'blip.2', 'chirp', 'drop.down', 'cowbell.d', 'beep.coin'];
// tight, quantized two-step; the automaton fires game-machine blips around it
const frame = (b, k, heat) => {
grid(b, 'kick.chip', 'x.........x.....');
grid(b, 'snare.noise8', '....x.......x...');
if (heat > 0.6) grid(b, 'clap.8bit', '............x...', 0.6);
if (chance(0.25 + 0.3 * heat)) euclid(b, 'kick.click', 2, 16, k * 3 + 5, 0.7);
euclid(b, 'snare.ghost.d', 3 + Math.round(heat * 3), 16, k, 0.5);
grid(b, 'hat.lfsr', 'xoxoxoxoxoxoxoxo', 0.45); // a steady sixteenth machine hat
if (heat > 0.5) euclid(b, 'hat.tick.d', 5, 12, k, 0.35);
if (k % 2 === 1) hit(b, 14, 'hat.open.d', 1, 0.5);
if (chance(0.3 + 0.3 * heat)) hit(b, pick([3, 7, 11, 15]), 'hat.ratchet', 1, 0.5);
ca.evolve(110);
ca.hits(b, BLIPS, 0.4 * (0.6 + heat), heat > 0.55 ? 1 : 2);
if (k % 2 === 1) ratchet(b, 14, 'snare.noise8', 4, 2, 0.35, 0.85); // a machine fill every other bar
if (k % 4 === 3) [12, 13, 14, 15].forEach((st, i) => hit(b, st, i < 2 ? 'tom.chip.hi' : 'tom.chip.lo', 1, 0.6 + i * 0.1));
};
section('boot.sequence', 8, (b, k) => { // the machine starts up
if (k === 0) line(b, [[0,'pwm.pad',24,0.8], [0,'data.stream',8]]);
if (k === 4) hit(b, 0, 'pwm.pad', 24, 0.8);
if (k >= 2) lead(b, k, 0.45, '..x...x...x...x.');
if (k === 1 || k === 5) hit(b, 8, 'sample.hold', 8);
if (k === 3) hit(b, 4, 'modem', 4);
if (k >= 4) {
grid(b, 'kick.808d', 'x...............', 0.7);
grid(b, 'snare.chip', '........x.......', 0.6);
grid(b, 'hat.lfsr', '..x...x...x...x.', 0.35);
bass(b, k, 0.5);
}
if (k === 6) hit(b, 0, 'bit.sweep', 8);
if (k === 7) { ratchet(b, 8, 'snare.noise8', 16, 8, 0.25, 0.9); hit(b, 14, 'chirp'); } // a thirty-second-note roll
});
const drop = (name, second) => section(name, 16, (b, k) => {
const heat = second ? 0.6 + 0.4 * k / 15 : (k < 8 ? 0.35 : 0.55);
if (k === 0) line(b, [[0,'power.down',8], [0,'sub.e1',8]]); // the power-down blows the grid open
if (k === 8) hit(b, 0, 'byte.crush');
frame(b, k, heat);
bass(b, k);
if (k % 2 === 0 && k > 0) hit(b, 0, 'sub.e1', 16, 0.6); // weight under the staccato bass
if (k % 4 === 0) hit(b, 0, 'pwm.pad', 24, 0.45);
grid(b, ARP[ROOTS[k % 8]], second ? '..x...x...x...x.' : '......x.......x.', 0.6);
if ((k < 8) !== second) lead(b, k, 0.55, GATES[k % 3]);
else { if (k % 2 === 0) hit(b, 0, 'stab.bit', 1, 0.7); lead(b, k, 0.45, '..x.x...x.x...x.'); }
if (k === 4 || k === 12) hit(b, 0, 'sample.hold', 8);
if (k === 6 || k === 14) hit(b, 8, 'modem', 4);
if (k === 10) hit(b, 0, 'data.stream', 8);
if (second) {
euclid(b, 'cowbell.d', 5, 16, k, 0.45);
if (k % 4 === 1) hit(b, 6, 'zap.8');
if (k === 9) hit(b, 8, 'glitch.stutter', 4);
}
if (k === 15) hit(b, 8, 'bit.sweep', 8);
});
drop('drop', false);
section('low.power', 8, (b, k) => { // the beat powers down; the sequence keeps ticking
if (k % 2 === 0) line(b, [[0,'sub.e1',16,0.8], [0,'kick.808d',1,0.7]]);
if (k === 0 || k === 4) hit(b, 0, 'pwm.pad', 24, 0.8);
grid(b, 'kick.click', 'x...x...x...x...', 0.35);
hit(b, 8, 'snare.chip', 1, 0.5);
ca.evolve(110);
ca.hits(b, BLIPS, 0.35, 2);
euclid(b, 'hat.tick.d', 5, 13, k, 0.3);
lead(b, k, 0.45, 'x...x...x.x...x.');
if (k % 2 === 1) line(b, [[0,'tri.e2',4,0.7], [8,'tri.g♯2',4,0.6]]);
if (k === 1) hit(b, 0, 'data.stream', 8);
if (k === 3) hit(b, 4, 'sample.hold', 8);
if (k === 5) hit(b, 8, 'modem', 4);
if (k === 6) hit(b, 12, 'glitch.stutter', 4);
});
section('overclock', 4, (b, k) => { // the rolls speed up: sixteenths, then thirty-seconds
grid(b, 'kick.chip', 'x...x...x...x...', 0.8);
if (k < 3) euclid(b, 'snare.noise8', [6, 8, 12][k], 16, 0, 0.4 + 0.15 * k);
else ratchet(b, 0, 'snare.noise8', 32, 16, 0.3, 1);
grid(b, 'hat.lfsr', 'xxxxxxxxxxxxxxxx', 0.3 + 0.1 * k);
bass(b, k * 2, 0.7);
if (k === 1) hit(b, 0, 'chirp');
if (k === 2) line(b, [[0,'bit.sweep',8], [8,'zap.8']]);
if (k === 3) hit(b, 8, 'modem', 4);
});
drop('drop.again', true);
section('shutdown', 8, (b, k) => { // the sequence runs down to a last coin
if (k < 4) { frame(b, k, 0.4 - k * 0.08); bass(b, k, 0.8); }
if (k === 0 || k === 4) hit(b, 0, 'pwm.pad', 24, 0.7);
if (k >= 4) {
lead(b, k, 0.45 - (k - 4) * 0.06, '..x...x...x...x.');
euclid(b, ['blip.1', 'blip.2'], 3, 11, k, 0.35);
}
if (k === 5) hit(b, 0, 'data.stream', 8, 0.6);
if (k === 7) line(b, [[0,'beep.coin',1,0.7], [8,'drop.down',1,0.6], [12,'kick.click',1,0.5]]);
});
},
},
{
title: 'Skoodu', meta: 'octatonic on d, 172 bpm, generative half-time', bpm: 172, swing: 0, tail: 2.5,
room: { seconds: 1.8, shape: 3, wet: 0.15, tone: 0.45 },
kit: KIT_RULE30,
write({ hit, grid, line, section, ratchet, seeded, euclid, automaton }) {
const { rng, pick, chance } = seeded(30);
const ca = automaton(rng);
// the chimes walk the inner ring in jumps of `stride`, one every `period` steps
const CHIMES = ['chime.d4', 'chime.e♭4', 'chime.f4', 'chime.f♯4', 'chime.g♯4', 'chime.a4', 'chime.b4', 'chime.c5'];
let chimeTurn = 0;
const chimes = (b, period, stride, v, offset = 0) => {
for (let st = 0; st < 16; st++) if ((b * 16 + st - offset) % period === 0) hit(b, st, CHIMES[(chimeTurn++ * stride) % 8], 2, v);
};
// the ghost-snare pattern is a gene: each bar a few of its steps flip, so the break slowly evolves
const gene = [0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0];
const mutate = n => {
for (let i = 0; i < n; i++) { const j = 1 + Math.floor(rng() * 15); if (j !== 8) gene[j] ^= 1; }
};
const ghosts = (b, v) => gene.forEach((on, st) => { if (on) hit(b, st, 'snare.ghost', 1, v * (0.6 + 0.4 * rng())); });
// the bass moves round D's own minor-third axis, two bars on each: D, F, G-sharp, B
const ROOTS = ['growl.d2', 'growl.f2', 'growl.g♯1', 'growl.b1'];
const PASS = { 'growl.d2': 'growl.e♭2', 'growl.f2': 'growl.e♭2', 'growl.g♯1': 'growl.a1', 'growl.b1': 'growl.a1' };
const GROWLS = ['......x...x.x...', '...x....x....x..', '......x..x....x.', '..x.....x.x.....'];
const bassBar = (b, k, v) => {
const root = ROOTS[Math.floor(k / 2) % 4];
hit(b, 0, root, 6, v);
[...pick(GROWLS)].forEach((c, st) => { if (c === 'x') hit(b, st, chance(0.35) ? PASS[root] : root, 2, v); });
};
const pads = (b, k, v) => { if (k % 4 === 0) hit(b, 0, k % 8 === 0 ? 'cold.pad.d' : 'glass.pad', 24, v); };
const stabs = (b, k, v) => {
const id = k % 4 < 2 ? 'stab.dim.d' : 'stab.dim.e♭';
hit(b, 6, id, 1, v);
if (chance(0.4)) hit(b, 11, id, 1, v * 0.7);
};
const HATS = ['hat.fm1', 'hat.fm2', 'hat.noise'];
const METAL = ['clang.2', 'tine', 'rim.fm', 'click.sample'];
const TICKS = ['click.sample', 'tine'];
// half-time: kick on one, snare on three; the gene and the automaton fill in around them
const half = (b, k, heat) => {
grid(b, 'kick.punch', pick(['x...............', 'x.........x.....', 'x......x........']));
hit(b, 8, 'snare.fm');
mutate(1 + Math.round(heat * 2));
ghosts(b, 0.5);
euclid(b, HATS, 5 + (k % 4), 16, k, 0.45);
ca.evolve(30);
ca.hits(b, METAL, 0.45 * (0.5 + heat), heat > 0.6 ? 1 : 2);
if (k % 4 === 3) ratchet(b, pick([12, 14]), 'snare.crush', pick([4, 6]), 2, 0.3, 0.85);
};
// double-time: the full two-step frame
const double = (b, k, heat) => {
grid(b, 'kick.punch', 'x.........x.....');
if (chance(0.5)) euclid(b, 'kick.fm', 2, 16, k * 5 + 3, 0.7);
grid(b, 'snare.fm', '....x.......x...');
if (heat > 0.6) hit(b, 12, 'clap.digital', 1, 0.6);
mutate(2);
ghosts(b, 0.45);
euclid(b, HATS, 9 + (k % 4), 16, k * 2, 0.45);
ca.evolve(30);
ca.hits(b, METAL, 0.5 * (0.5 + heat), heat > 0.7 ? 1 : 2);
if (k % 4 === 3) ratchet(b, pick([12, 14]), 'snare.crush', pick([6, 8]), 2, 0.3, 0.95);
};
section('seed', 8, (b, k) => { // a glassy pad, chimes, a gear turning
if (k === 0) hit(b, 0, 'glass.pad', 24, 0.8);
if (k === 4) hit(b, 0, 'cold.pad.d', 24, 0.8);
chimes(b, 11, 3, 0.5);
euclid(b, TICKS, 3, 13, k, 0.35);
if (k === 1) hit(b, 4, 'bit.rain', 12);
if (k === 3) hit(b, 8, 'gear');
if (k >= 4) {
grid(b, 'kick.sub', 'x...............', 0.7);
grid(b, 'snare.gate', '........x.......', 0.6);
euclid(b, HATS, 4, 12, k, 0.35);
}
if (k === 6) hit(b, 0, 'grain.cloud', 8);
if (k === 7) {
line(b, [[0,'data.burst',4], [10,'reverse.fm',6]]);
ratchet(b, 12, 'snare.crush', 8, 4, 0.3, 1);
}
});
section('halftime', 16, (b, k) => { // heavy half-time; the growl twists the tiles
if (k === 0) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch']]);
half(b, k, k < 8 ? 0.35 : 0.6);
bassBar(b, k);
pads(b, k, 0.5);
if (k < 8) chimes(b, 2, 3, 0.5); else { stabs(b, k, 0.7); chimes(b, 3, 5, 0.45); }
if (k === 4) hit(b, 0, 'formant.shift', 12);
if (k === 6 || k === 14) hit(b, 12, 'gear');
if (k === 8) hit(b, 0, 'hoover.dim', 8);
if (k === 10) hit(b, 8, 'data.burst', 4);
if (k === 12) hit(b, 0, 'sub.d2', 16);
if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
if (k === 15) hit(b, 10, 'reverse.fm', 6);
});
section('cascade', 8, (b, k) => { // rule 30 runs on its own: chaos in the metal
if (k % 2 === 0) line(b, [[0,'sub.d2',16,0.8], [0,'kick.sub',1,0.7]]);
if (k === 0) hit(b, 0, 'cold.pad.d', 24, 0.8);
if (k === 4) hit(b, 0, 'glass.pad', 24, 0.8);
hit(b, 8, 'snare.gate', 1, 0.5);
ca.evolve(30);
ca.hits(b, ['clang.2', 'tine', 'rim.fm', 'click.sample', 'gear', 'stutter'], 0.4);
euclid(b, HATS, 5, 13, k, 0.3);
chimes(b, 3, 5, 0.55);
if (k === 1) hit(b, 0, 'bit.rain', 12);
if (k === 3) hit(b, 4, 'grain.cloud', 8);
if (k === 5) hit(b, 0, 'formant.shift', 12);
if (k === 7) hit(b, 8, 'stutter', 4);
});
section('build', 4, (b, k) => { // the half-time folds into double-time
grid(b, 'kick.punch', 'x...x...x...x...', 0.8);
euclid(b, 'snare.gate', [4, 6, 8, 12][k], 16, 0, 0.4 + 0.15 * k);
if (k === 0 || k === 2) hit(b, 0, 'growl.d2', 8, 0.8);
chimes(b, 4 - (k >> 1), 3, 0.5);
if (k === 1) hit(b, 0, 'hoover.dim', 8, 0.8);
if (k === 3) { ratchet(b, 12, 'snare.crush', 8, 4, 0.4, 1); hit(b, 10, 'reverse.fm', 6); }
});
section('doubletime', 16, (b, k) => { // full two-step, getting hotter
const heat = 0.6 + 0.4 * k / 15;
if (k === 0) line(b, [[0,'clang.big',8], [0,'kick.sub',4], [0,'crunch']]);
if (k === 8) hit(b, 0, 'crunch');
double(b, k, heat);
bassBar(b, k);
pads(b, k, 0.45);
grid(b, 'ride.fm', 'x...x...x...x...', 0.4);
if (k < 8) { stabs(b, k, 0.7); chimes(b, 3, 5, 0.45); } else chimes(b, 2, 3, 0.5);
if (k === 4 || k === 12) hit(b, 0, 'hoover.dim', 8);
if (k === 2 || k === 10) hit(b, 4, 'zap.fm');
if (k % 4 === 1) hit(b, 10, 'buzz', 2);
if (k === 6 || k === 14) hit(b, 12, 'gear');
if (k === 9) hit(b, 8, 'data.burst', 4);
if (k % 2 === 1) hit(b, 14, 'hat.ring', 1, 0.5);
});
section('decay', 8, (b, k) => { // back to half-time, thinning to a last chime
if (k < 4) { half(b, k, 0.35 - k * 0.07); bassBar(b, k, 0.8); }
if (k === 0) hit(b, 0, 'cold.pad.d', 24, 0.7);
if (k === 4) hit(b, 0, 'glass.pad', 24, 0.7);
if (k >= 4) {
chimes(b, 4 + (k - 4), 3, 0.5 - (k - 4) * 0.06);
euclid(b, TICKS, 3, 11, k, 0.35);
}
if (k === 5) hit(b, 0, 'bit.rain', 12, 0.6);
if (k === 7) line(b, [[0,'gear',1,0.7], [8,'chime.d4',8,0.6]]);
});
},
},
];
// write every song once, up front
SONGS.forEach(song => {
song.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,
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 });
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 shears itself, 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
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));
t.vx += ux * imp; t.vy += uy * imp;
t.wy += ux * imp * 0.01; t.wx -= uy * imp * 0.01;
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 (!t.loose && Math.abs(imp) > BREAK_FORCE) breakLoose(t, Math.abs(imp), ux, uy, nowMs);
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
// ─────────────────────────────────────────────
function strike(t, gain) {
t.amp = SHEAR_MAX * Math.min(1, Math.max(0.35, gain)) * MOTION;
t.vs += t.amp * 12; // a kick of velocity so repeated hits visibly jolt
t.ink = 1;
t.resting = false;
tilesG.appendChild(t.poly); // the struck tile draws on top
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, lift: h.lift });
} else {
emitWave({ x: h.centre ? 0 : t.hx, y: h.centre ? 0 : t.hy, push: h.push * k, 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 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]];
function drawTile(t) {
const cz = Math.cos(t.rz), sz = Math.sin(t.rz);
const cxr = Math.cos(t.rx), sxr = Math.sin(t.rx);
const cyr = Math.cos(t.ry), syr = Math.sin(t.ry);
let attr = '';
for (let i = 0; i < 4; i++) {
const [lx, ly] = CORNERS[i];
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 + x3, wy = t.hy + t.dy + y2, wz = t.z + z3;
const p = FOCAL / Math.max(150, FOCAL - wz);
const X = CX + wx * p * S, Y = CY + wy * 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);
// 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);
}
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) 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 : 13, pz = t.loose ? 0.35 : recalling ? 0.8 : 0.32;
const aw = t.loose ? 1.3 : recalling ? 5 : 11, az = t.loose ? 0.35 : recalling ? 0.8 : 0.45;
const sw = held ? 38 : 15, sz = held ? 0.62 : 0.4;
const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
for (let i = 0; i < n; i++) {
spring(t, 'dx', 'vx', pw, pz, h);
spring(t, 'dy', 'vy', pw, pz, h);
spring(t, 'z', 'vz', pw, pz, h);
spring(t, 'rx', 'wx', aw, az, h);
spring(t, 'ry', 'wy', aw, az, h);
spring(t, 'rz', 'wz', aw, az, h);
// 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
if (!held && !t.loose && !recalling && 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) {
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.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 (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 = waves.length > 0;
for (const t of tiles) busy = stepTile(t, dt, audioNow, 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, unless a media element is playing.
// A looping, silent clip puts the page in "media playback" mode so the instrument can always be heard.
let silentClip = null;
function startSilentClip() {
try {
if (!silentClip) {
silentClip = new Audio('data:audio/wav;base64,UklGRkQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==');
silentClip.loop = true;
silentClip.setAttribute('playsinline', '');
}
const p = silentClip.play();
if (p && p.catch) p.catch(() => {});
} catch (e) { /* ignore */ }
}
function unlockAudio() {
if (audioReady) return;
const ctx = getCtx();
getMaster();
if (ctx.state === 'suspended') ctx.resume();
startSilentClip();
audioReady = true;
refreshSoundStatus();
hintTiles.textContent = idleSoundText();
}
['pointerdown', 'touchstart', 'keydown'].forEach(evt =>
document.addEventListener(evt, unlockAudio, { passive: true })
);
// 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, 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;
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 (silentClip) silentClip.pause(); return; }
if (audioReady) {
startSilentClip();
if (audioCtx && audioCtx.state !== 'running') audioCtx.resume();
}
if (player.playing) holdScreen(true);
});
function halt() {
holdScreen(false);
clearInterval(player.timer);
player.timer = null;
player.playing = false;
}
function stop() { // 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; });
setPlayingUI(false);
wake();
}
function finish() { // reached the end of the song
halt();
const bus = player.bus;
setTimeout(() => bus.disconnect(), 1500);
setPlayingUI(false);
}
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);
$('prevTrack').addEventListener('click', () => stepTrack(-1));
$('nextTrack').addEventListener('click', () => stepTrack(1));
$('trackNow').addEventListener('click', () => { if (!swipedRecently()) openSheet(); });
// 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, the transport, the hint
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));
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 = cell < 36; // too small for the word: show a symbol instead
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: 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>