HatchLing-TuneBox / globe_template.html
taellinglin's picture
MIDI patches: a colour per channel, a timbre per patch family
90dd634 verified
Raw History Blame Contribute Delete
37.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__ · Ling globe</title>
<style>
:root{--ground:#000;--ink:#7b8797;--text:#c9d1dc;--panel:rgba(0,0,0,.78);--rule:#2a313b;
--asm:#eb6466;--lit:#f99b38;--bind:#dbb138;--ctl:#59b371;--call:#508fdf;--fn:#7369d1;--mod:#b169d5}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--ground);color:var(--text);overflow:hidden;
font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
canvas.drag{cursor:grabbing}
.lg{fill:none;stroke:var(--ink)}
.lg-asm{stroke:var(--asm)}.lg-lit{stroke:var(--lit)}.lg-bind{stroke:var(--bind)}.lg-ctl{stroke:var(--ctl)}
.lg-call{stroke:var(--call)}.lg-fn{stroke:var(--fn)}.lg-mod{stroke:var(--mod)}.lg-ink{stroke:var(--ink)}
.panel{position:fixed;background:var(--panel);border:1px solid var(--rule);padding:10px 12px;max-width:min(320px,calc(100vw - 24px))}
#head{left:12px;top:12px}
#head h1{margin:6px 0 2px;font-size:13px;font-weight:600;letter-spacing:.02em}
#head p{margin:0 0 4px;color:var(--ink)}
#keys{left:12px;bottom:64px}
#keys summary{cursor:pointer;color:var(--ink);list-style:none}
#keys summary::-webkit-details-marker{display:none}
#keys ul{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:5px}
#keys li{display:grid;grid-template-columns:100px 1fr;gap:8px;align-items:center;line-height:1.25}
#keys b{font-weight:600}
#keys span{color:var(--ink)}
#ctl{right:12px;top:12px;display:grid;grid-template-columns:1fr 1fr;gap:6px;width:230px}
button,.file{font:inherit;color:var(--text);background:transparent;border:1px solid var(--ink);padding:6px 8px;cursor:pointer;text-align:center}
button:hover,.file:hover{border-color:var(--text)}
button:focus-visible,.file:focus-within,#bar:focus-visible{outline:2px solid var(--call);outline-offset:2px}
button[aria-pressed="true"]{border-color:var(--mod);color:var(--mod)}
.file input{position:absolute;opacity:0;width:1px;height:1px}
#read{right:12px;bottom:64px;min-width:230px}
#read h2{margin:0 0 4px;font-size:13px;font-weight:600}
#read dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:2px 10px}
#read dt{color:var(--ink)}#read dd{margin:0;font-variant-numeric:tabular-nums}
#play{left:12px;right:12px;bottom:12px;max-width:none;display:grid;grid-template-columns:auto auto 1fr auto;gap:10px;align-items:center}
#bar{height:14px;border:1px solid var(--ink);position:relative;cursor:pointer}
#bar i{position:absolute;left:0;top:0;bottom:0;width:0;background:var(--mod);opacity:.55}
#chans{margin:4px 0 0;line-height:1.5;color:var(--ink)}#chans b{font-weight:600}
#tune{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38vw}
#fail{position:fixed;inset:0;display:none;place-items:center;text-align:center;padding:24px}
@media (max-width:760px){#keys{display:none}#ctl{width:170px}#read{left:12px;right:12px;bottom:64px}}
</style>
</head>
<body>
<canvas id="gl" role="img" aria-label="A globe packed with dials and tiles showing __ABOUT__. Drag to spin it."></canvas>
<div id="fail"><p>This globe needs WebGL 2, which this browser does not offer.</p></div>
<section class="panel" id="head" aria-label="About"><div id="title"></div><h1>__TITLE__</h1><p id="about"></p><p>Drag to spin · scroll or pinch to zoom · click a dial to send a pulse</p><p id="chans" aria-label="Channels and patches"></p></section>
<section class="panel" id="ctl" aria-label="Controls">
<button id="bSee" aria-pressed="false">See through</button><button id="bSpin" aria-pressed="true">Turn slowly</button>
<button id="bWire" aria-pressed="true">Wires</button><button id="bGround" aria-pressed="false">White ground</button>
<label class="file" id="lMidi">Load MIDI or sound<input id="fMidi" type="file" accept=".mid,.midi,audio/*"></label><button id="bReset">Reset view</button>
</section>
<details class="panel" id="keys"><summary>The seven keys: what each dial does with a pulse</summary><ul id="legend"></ul></details>
<section class="panel" id="read" aria-live="polite" hidden><h2 id="rName"></h2><dl id="rBody"></dl></section>
<section class="panel" id="play" aria-label="Tune">
<button id="bPlay" aria-pressed="true">Pause</button><button id="bSound" aria-pressed="false">Sound</button><button id="bSrc" hidden>Hear: MIDI synth</button>
<div id="bar" role="slider" tabindex="0" aria-label="Position in the tune" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i></i></div>
<span id="tune"></span>
</section>
<script>
'use strict';
const D = "__DATA__";
const $ = id => document.getElementById(id);
const b64 = (s, T) => { const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return new T(u.buffer); };
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------------------------------------------------------------- the scene
const EL = b64(D.elements, Float32Array), NE = D.n_elements, ES = 20;
const TM = D.templates.map(t => ({ n: t.n, seg: b64(t.seg, Int16Array), meta: b64(t.meta, Uint8Array) }));
const nodes = D.nodes, NN = nodes.length;
const KEYS = D.keys;
let total = 0; for (let e = 0; e < NE; e++) total += TM[EL[e * ES + 14]].n;
const segBuf = new Float32Array(total * 4), metaBuf = new Float32Array(total * 4);
{ let k = 0;
for (let e = 0; e < NE; e++) { const t = TM[EL[e * ES + 14]];
for (let i = 0; i < t.n; i++, k++) {
segBuf[k * 4] = t.seg[i * 4] / 8192; segBuf[k * 4 + 1] = t.seg[i * 4 + 1] / 8192; segBuf[k * 4 + 2] = t.seg[i * 4 + 2] / 8192; segBuf[k * 4 + 3] = t.seg[i * 4 + 3] / 8192;
metaBuf[k * 4] = e; metaBuf[k * 4 + 1] = t.meta[i * 3]; metaBuf[k * 4 + 2] = t.meta[i * 3 + 1] / 127; metaBuf[k * 4 + 3] = t.meta[i * 3 + 2] / 255; } } }
// wires: one instance per segment, with where it sits along its wire
const WP = b64(D.wire_pts, Float32Array); let wtotal = 0; for (const w of D.wires) wtotal += w[1] - 1;
const wA = new Float32Array(wtotal * 3), wB = new Float32Array(wtotal * 3), wM = new Float32Array(wtotal * 4);
{ let k = 0;
for (const [at, n, sig, hue, kind] of D.wires) for (let i = 0; i < n - 1; i++, k++) {
for (let c = 0; c < 3; c++) { wA[k * 3 + c] = WP[(at + i) * 3 + c]; wB[k * 3 + c] = WP[(at + i + 1) * 3 + c]; }
wM[k * 4] = (i + 0.5) / (n - 1); wM[k * 4 + 1] = sig; wM[k * 4 + 2] = hue; wM[k * 4 + 3] = kind; } }
// ---------------------------------------------------------------- WebGL
const cv = $('gl'), gl = cv.getContext('webgl2', { antialias: true, alpha: false, powerPreference: 'high-performance' });
if (!gl) { $('fail').style.display = 'grid'; throw new Error('no webgl2'); }
const COMMON = `#version 300 es
precision highp float; precision highp sampler2D;
uniform mat3 uRot; uniform vec2 uView; uniform float uFocal, uDist, uLine, uThrough, uSel, uTime, uSolid;
uniform vec3 uPal[7], uLit[7]; uniform vec3 uHi;
uniform sampler2D uNode;
vec4 nd(int i, int k){ return texelFetch(uNode, ivec2((i % 256) * 4 + k, i / 256), 0); }
vec4 proj(vec3 v){ return vec4(uFocal * v.x * uView.y / uView.x, uFocal * v.y, 0.0, uDist - v.z); }
vec4 thick(vec4 pa, vec4 pb, vec2 corner, float w){
vec2 sa = pa.xy / pa.w * uView * 0.5, sb = pb.xy / pb.w * uView * 0.5; vec2 d = sb - sa; float l = length(d);
d = l > 1e-5 ? d / l : vec2(1.0, 0.0); vec2 n = vec2(-d.y, d.x);
vec4 p = mix(pa, pb, corner.x); vec2 off = (n * corner.y + d * (corner.x * 2.0 - 1.0) * 0.5) * w * 0.5;
p.xy += off / (uView * 0.5) * p.w; return p; }
vec3 hueShift(vec3 c, float a){ const vec3 k = vec3(0.57735); float ca = cos(a), sa = sin(a); return c * ca + cross(k, c) * sa + k * dot(k, c) * (1.0 - ca); }
`;
const VS_EL = COMMON + `
layout(location=0) in vec2 aCorner; layout(location=1) in vec4 aSeg; layout(location=2) in vec4 aMeta;
uniform sampler2D uElem; out vec4 vCol;
vec4 el(int i, int k){ return texelFetch(uElem, ivec2((i % 200) * 5 + k, i / 200), 0); }
vec2 rot2(vec2 p, float a){ float c = cos(a), s = sin(a); return vec2(c * p.x - s * p.y, s * p.x + c * p.y); }
void main(){
int ei = int(aMeta.x + 0.5), part = int(aMeta.y + 0.5); float thr = aMeta.z, wgt = aMeta.w;
vec4 t0 = el(ei, 0), t1 = el(ei, 1), t2 = el(ei, 2), t3 = el(ei, 3), t4 = el(ei, 4);
vec3 c = t0.xyz, e1 = t1.xyz, e2 = t2.xyz; float s = t0.w, sig = t2.w, kind = t3.x, dir = t3.y, isNode = t3.w;
int hue = int(t1.w + 0.5);
float lvl = 1.0, ang = 0.0, flash = 0.0, hsh = 0.0, sat = 1.0, swp = 0.0, tes = 0.0, scl = 1.0, alpha = 1.0, bright = 0.0;
bool wired = sig >= 0.0;
if (wired) { int n = int(sig + 0.5); vec4 a = nd(n, 0), b = nd(n, 1), d = nd(n, 2);
lvl = a.x; ang = a.y; flash = a.z; hsh = a.w; sat = b.x; swp = b.y; tes = b.z; scl = b.w; alpha = d.x; bright = d.y; }
vec2 qa = aSeg.xy, qb = aSeg.zw; float aT = 1.0;
if (kind < 0.5) { // a dial: its rings turn, each at its own rate and sense
if (isNode > 0.5 && part >= 1 && part <= 5) {
float m = part == 1 ? 1.0 : part == 2 ? -1.7 : part == 3 ? 2.6 : part == 4 ? -3.7 : -0.6;
qa = rot2(qa, ang * dir * m); qb = rot2(qb, ang * dir * m); }
} else if (wired) { // a meter tile: it shrinks on a pulse, splits in four, and can change places
s *= scl;
if (t4.y > 1.5) { aT = tes * (1.0 - step(0.02, swp)); s *= 0.6 + 0.4 * tes; }
else if (t4.y > 0.5) aT = 1.0 - tes;
if (t4.x >= 0.0 && swp > 0.001) { int pj = int(t4.x + 0.5); vec4 p0 = el(pj, 0), p1 = el(pj, 1), p2 = el(pj, 2);
vec3 cm = mix(c, p0.xyz, swp); e1 = normalize(mix(e1, p1.xyz, swp)); e2 = normalize(mix(e2, p2.xyz, swp));
c = uSolid > 0.5 ? cm + normalize(cross(e1, e2)) * 0.07 * sin(3.14159 * swp) : normalize(cm) * (1.0 + 0.07 * sin(3.14159 * swp)); s = mix(s, p0.w, swp); }
}
vec3 A = uRot * (c + s * (qa.x * e1 - qa.y * e2)), B = uRot * (c + s * (qb.x * e1 - qb.y * e2));
vec3 N = uRot * normalize(cross(e1, e2)); float facing = dot(N, normalize(vec3(0.0, 0.0, uDist) - N));
float aF = facing > 0.0 ? smoothstep(0.0, 0.12, facing) * 0.45 + 0.55 : uThrough * 0.13;
vec3 base = uPal[hue], L = uLit[hue], col = base; float dim = 1.0;
if (wired) {
float on = 0.0;
if (part == 6) { on = step(thr, lvl); dim = mix(0.34, 1.0, on); }
else if (part >= 1 && part <= 4 && thr <= 1.0) on = step(thr, lvl);
else if (part == 7) on = step(0.5, lvl);
else if (part == 8 || part == 5) on = flash;
col = mix(base, L, clamp(max(on, bright), 0.0, 1.0));
col = hueShift(col, hsh); float g = dot(col, vec3(0.299, 0.587, 0.114)); col = clamp(mix(vec3(g), col, sat), 0.0, 1.0);
col = mix(col, uHi, 0.55 * flash * step(7.5, float(part)));
if (sig == uSel) col = mix(col, uHi, 0.45);
}
float a = alpha * dim * aF * aT * mix(0.8, 1.0, wgt);
vCol = vec4(col, a);
gl_Position = a < 0.004 ? vec4(2.0, 2.0, 2.0, 1.0) : thick(proj(A), proj(B), aCorner, uLine * mix(0.72, 1.0, wgt));
}`;
const VS_W = COMMON + `
layout(location=0) in vec2 aCorner; layout(location=1) in vec3 aA; layout(location=2) in vec3 aB; layout(location=3) in vec4 aM;
uniform float uHop, uShow; out vec4 vCol;
void main(){
int n = int(aM.y + 0.5), hue = int(aM.z + 0.5); float t = aM.x, kind = aM.w; vec4 d = nd(n, 2), a0 = nd(n, 0);
vec3 A = uRot * aA, B = uRot * aB; vec3 N = normalize(A); float facing = dot(N, normalize(vec3(0.0, 0.0, uDist) - A));
float aF = facing > 0.0 ? 1.0 : uThrough * 0.13;
float q = (uTime - d.z) / (uHop * (kind > 0.5 ? 0.6 : 1.0)); // where the last pulse is along the wire
float pulse = kind > 2.5 ? a0.x : d.w * (1.0 - smoothstep(0.0, 0.2, abs(t - q))) * step(-0.2, q) * step(q, 1.2);
vec3 col = mix(uPal[hue] * 0.0 + uPal[hue], uLit[hue], clamp(pulse, 0.0, 1.0)); col = mix(col, uHi, 0.6 * clamp(pulse, 0.0, 1.0));
if (aM.y == uSel) col = mix(col, uHi, 0.45);
float a = aF * uShow * mix(kind > 2.5 ? 0.9 : 0.5, 1.0, clamp(pulse, 0.0, 1.0));
vCol = vec4(col, a);
gl_Position = a < 0.004 ? vec4(2.0, 2.0, 2.0, 1.0) : thick(proj(A), proj(B), aCorner, uLine * (kind > 2.5 ? 1.5 : 1.0 + 0.9 * clamp(pulse, 0.0, 1.0)));
}`;
const FS = `#version 300 es
precision mediump float; in vec4 vCol; out vec4 o; void main(){ o = vCol; }`;
function program(vs, fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s); }
gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const name = gl.getActiveUniform(p, i).name.replace('[0]', ''); u[name] = gl.getUniformLocation(p, name); }
return { p, u };
}
const pEl = program(VS_EL, FS), pW = program(VS_W, FS);
const quad = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, -1, 0, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
function inst(loc, data, size) { const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(loc, 1); }
const vaoEl = gl.createVertexArray(); gl.bindVertexArray(vaoEl);
gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
inst(1, segBuf, 4); inst(2, metaBuf, 4);
const vaoW = gl.createVertexArray(); gl.bindVertexArray(vaoW);
gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
inst(1, wA, 3); inst(2, wB, 3); inst(3, wM, 4);
gl.bindVertexArray(null);
function tex(w, h, data) { const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); return t; }
const eRows = Math.ceil(NE / 200), eData = new Float32Array(1000 * eRows * 4); eData.set(EL.subarray(0, NE * ES));
const texEl = tex(1000, eRows, eData);
const nRows = Math.max(1, Math.ceil(NN / 256)), nData = new Float32Array(1024 * nRows * 4), texNode = tex(1024, nRows, nData);
// ---------------------------------------------------------------- the circuit
// A pulse arrives at a dial; what the dial does follows its key; then the pulse runs on along the dial's wires.
const HOP = D.rules.hop, TICK = D.rules.tick, GAIN = D.rules.gain, LATE = D.rules.late; // the same rules the Ling export uses
const S = nodes.map((n, i) => ({ i, role: KEYS[n.role], level: 0, angle: Math.random() * 6.283, target: 0, flash: 0, hue: 0, sat: KEYS[n.role] === 'mod' ? 0.55 : 1,
swap: 0, swapTo: 0, tess: 0, scale: 0, alpha: 1, bright: 0, fireT: -9, fireV: 0, latch: false, ticks: 0, nextTick: 0, tickV: 0, lastPitch: null, seen: -1, pulses: 0, sum: 0 }));
for (const s of S) s.target = s.angle;
let queue = [], pulseId = 0, simT = 0;
function send(n, v, pitch, t, dur, id, hop, ch = -1) { if (v >= 0.07 && hop < 14) queue.push({ t, n, v, pitch, dur, id, hop, ch }); }
function forward(s, v, pitch, t, dur, id, hop, ch) { const r = nodes[s.i].role; for (const o of nodes[s.i].out) send(o, v * GAIN[r], pitch, t + LATE[r], dur, id, hop + 1, ch); }
function fire(ev) {
const s = S[ev.n], { v, pitch, t, dur, id, hop, ch } = ev;
if (s.seen === id) return; s.seen = id; // one pulse reaches a dial once
s.fireT = t; s.fireV = v; s.level = Math.max(s.level, v); s.sum += v; s.scale = 1; s.pulses++;
if (v > 0.8) s.tess = 1; // driven hard: its meter tiles split in four
switch (s.role) {
case 'asm': s.flash = v; break; // strobe
case 'lit': s.bright = Math.max(s.bright, v); break; // lamp
case 'bind': s.latch = !s.latch; s.swapTo = s.latch ? 1 : 0; s.flash = 0.5 * v; break; // latch
case 'ctl': s.ticks = Math.max(1, Math.min(16, Math.round((dur || 0.25) / TICK))); s.tickV = v; s.nextTick = t; break; // timer
case 'call': s.hue = (pitch % 2 ? 1 : -1) * (0.6 + 0.9 * v); break; // relay
case 'fn': { const d = s.lastPitch === null ? 0 : pitch - s.lastPitch; s.lastPitch = pitch; // actuator
s.target += (d === 0 ? nodes[s.i].dir * 0.25 : Math.sign(d) * (0.35 + 0.22 * Math.min(Math.abs(d), 7))); break; }
default: s.sat = 1.35; // bus
}
if (ch >= 0 && s.role !== 'call' && chHue[ch] !== undefined) s.hue = (1 + chHue[ch]) * 0.8; // lit in the colour of the channel that sent the pulse
forward(s, v, pitch, t, dur, id, hop, ch); // then on along its wires: how strongly and how late follows its key
}
function pulse(n, v, pitch, dur) { send(n, v, pitch === undefined ? 60 : pitch, simT, dur || 0.3, ++pulseId, 0); }
function step(dt) {
queue.sort((a, b) => a.t - b.t);
while (queue.length && queue[0].t <= simT) fire(queue.shift());
const k = tau => Math.exp(-dt / tau);
for (const s of S) {
if (s.ticks > 0 && simT >= s.nextTick) { // a timer's tick: one twelfth of a turn
s.ticks--; s.nextTick += TICK; s.target += nodes[s.i].dir * Math.PI / 6; s.flash = 0.7; s.level = Math.max(s.level, s.tickV);
}
s.level *= k(s.role === 'bind' && s.latch ? 30 : 0.9); if (s.role === 'bind' && s.latch) s.level = Math.max(s.level, 0.72);
s.sum *= k(0.9); s.flash *= k(0.1); s.bright *= k(1.6); s.hue *= k(0.8); s.scale *= k(0.16); s.tess = Math.max(0, s.tess - dt / 1.4);
s.sat = s.role === 'mod' ? 0.55 + (s.sat - 0.55) * k(0.6) : 1;
s.swap += (s.swapTo - s.swap) * (1 - k(0.22));
if (s.role === 'ctl') s.angle += (s.target - s.angle) * (1 - k(0.025));
else if (s.role === 'fn') s.angle += (s.target - s.angle) * (1 - k(0.14));
else s.angle += dt * (0.1 + 2.6 * s.sum);
s.alpha = s.role === 'asm' ? 0.5 + 0.5 * Math.max(s.flash, s.level) : 0.82 + 0.18 * s.level;
const o = s.i * 16, te = s.tess; const tes = te > 0.85 ? (1 - te) / 0.15 : Math.min(1, te / 0.3);
nData[o] = s.level; nData[o + 1] = s.angle; nData[o + 2] = s.flash; nData[o + 3] = s.hue;
nData[o + 4] = s.sat; nData[o + 5] = s.swap; nData[o + 6] = tes; nData[o + 7] = 1 - 0.2 * s.scale;
nData[o + 8] = s.alpha; nData[o + 9] = s.bright * 0.9; nData[o + 10] = s.fireT; nData[o + 11] = s.fireV;
}
}
// ---------------------------------------------------------------- MIDI: read a file, and choose a dial for each note
function parseMidi(buf) {
const d = new DataView(buf); let p = 0;
const str = n => { let s = ''; for (let i = 0; i < n; i++) s += String.fromCharCode(d.getUint8(p++)); return s; };
if (str(4) !== 'MThd') throw new Error('not a MIDI file');
const hl = d.getUint32(p); p += 4; p += 2; const ntr = d.getUint16(p); p += 2; const div = d.getUint16(p); p += 2; p += hl - 6;
if (div & 0x8000) throw new Error('SMPTE time is not supported');
const tempos = [], raw = [], progs = [];
for (let tr = 0; tr < ntr && p < d.byteLength - 8; tr++) {
if (str(4) !== 'MTrk') break; const len = d.getUint32(p); p += 4; const end = p + len; let tick = 0, run = 0; const on = {};
const vlq = () => { let v = 0, b; do { b = d.getUint8(p++); v = (v << 7) | (b & 127); } while (b & 128); return v; };
while (p < end) {
tick += vlq(); let st = d.getUint8(p);
if (st & 128) { p++; if (st < 0xf0) run = st; } else st = run;
const hi = st & 0xf0, ch = st & 15;
if (st === 0xff) { const type = d.getUint8(p++), l = vlq(); if (type === 0x51) tempos.push([tick, (d.getUint8(p) << 16) | (d.getUint8(p + 1) << 8) | d.getUint8(p + 2)]); p += l; }
else if (st === 0xf0 || st === 0xf7) { p += vlq(); }
else if (hi === 0x90 || hi === 0x80) { const note = d.getUint8(p++), vel = d.getUint8(p++), key = ch * 128 + note;
if (hi === 0x90 && vel > 0) on[key] = [tick, vel];
else if (on[key]) { raw.push([on[key][0], tick, note, on[key][1] / 127, ch]); delete on[key]; } }
else if (hi === 0xc0 || hi === 0xd0) { if (hi === 0xc0) progs.push([tick, ch, d.getUint8(p)]); p += 1; } else p += 2;
}
p = end;
}
tempos.sort((a, b) => a[0] - b[0]); if (!tempos.length || tempos[0][0] > 0) tempos.unshift([0, 500000]);
const sec = tick => { let t = 0, last = 0, us = 500000; for (const [tk, u] of tempos) { if (tk >= tick) break; t += (tk - last) * us / div / 1e6; last = tk; us = u; } return t + (tick - last) * us / div / 1e6; };
progs.sort((x, y) => x[0] - y[0]);
const patchAt = (tick, ch) => { if (ch === 9) return 128; let cur = 0; for (const [t, c, pr] of progs) { if (t > tick) break; if (c === ch) cur = pr; } return cur; }; // the patch selected on the channel then
return raw.map(([a, b, n, v, c]) => [sec(a), Math.max(0.05, sec(b) - sec(a)), n, v, c, patchAt(a, c)]).sort((x, y) => x[0] - y[0]);
}
let notes = [], songLen = 1, lo = 48, hi = 84, nextNote = 0, songT = 0, playing = true;
const strobes = S.filter(s => s.role === 'asm').map(s => s.i);
let chHue = {};
function channelHues(list) { // as gm.channel_hues: drums red, the others one colour each in the order they first sound
const first = {}; for (const n of list) if (first[n[4]] === undefined) first[n[4]] = n[0];
const hues = {}; let k = 0;
for (const ch of Object.keys(first).map(Number).sort((a, b) => (a === 9 ? -1 : b === 9 ? 1 : first[a] - first[b]))) { if (ch === 9) hues[ch] = 0; else { k++; hues[ch] = k % 7; } }
return hues;
}
function renderChannels(list) {
const prog = {}, count = {}; for (const n of list) { count[n[4]] = (count[n[4]] || 0) + 1; if (prog[n[4]] === undefined) prog[n[4]] = n[4] === 9 ? 128 : (n[5] || 0); }
const nm = p => p >= 128 ? 'Drum kit' : (D.gm.names[p] || 'patch ' + p);
$('chans').innerHTML = Object.keys(count).map(Number).sort((a, b) => a - b).map(ch =>
'<span style="white-space:nowrap"><b style="color:var(--' + KEYS[chHue[ch]] + ')">●</b> ' + (ch + 1) + ' ' + nm(prog[ch]) + '</span>').join(' &nbsp; ');
}
function setTune(list, name) {
notes = list; songLen = Math.max(1, ...list.map(n => n[0] + n[1])) + 0.6; nextNote = 0; songT = 0; queue = [];
const ps = list.filter(n => n[4] !== 9).map(n => n[2]); lo = Math.min(...ps, 127); hi = Math.max(...ps, 0); if (hi <= lo) hi = lo + 1;
chHue = channelHues(list); renderChannels(list);
$('tune').textContent = name + ' · ' + list.length + ' notes';
}
// low notes enter near the root, high notes far from it; drums enter at the strobes
const dialFor = n => n[4] === 9 && strobes.length ? strobes[n[2] % strobes.length] : Math.min(NN - 1, Math.round((n[2] - lo) / (hi - lo) * (NN - 1) * 0.3));
// ---------------------------------------------------------------- sound: a plain synth, so the tune can be heard in step
let ac = null, master = null, sound = false, noise = null;
function voice(n, when) {
const [t, dur, pitch, vel, ch] = n, g = ac.createGain(); g.connect(master);
if (ch === 9) { const src = ac.createBufferSource(); src.buffer = noise; const f = ac.createBiquadFilter(); f.type = pitch < 40 ? 'lowpass' : 'highpass'; f.frequency.value = pitch < 40 ? 180 : 4000 + pitch * 40;
src.connect(f); f.connect(g); g.gain.setValueAtTime(vel * 0.5, when); g.gain.exponentialRampToValueAtTime(0.001, when + 0.14); src.start(when); src.stop(when + 0.16); return; }
const [wave, att, tau, rel, cut] = D.gm.timbres[Math.min(127, n[5] || 0) >> 3] || D.gm.timbres[0]; // the family's timbre: wave, attack, decay, release, lowpass
const o = ac.createOscillator(); o.type = wave; o.frequency.value = 440 * Math.pow(2, (pitch - 69) / 12);
if (cut) { const f = ac.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = cut; o.connect(f); f.connect(g); } else o.connect(g);
const top = vel * (wave === 'sawtooth' || wave === 'square' ? 0.12 : 0.22);
g.gain.setValueAtTime(0.0001, when); g.gain.linearRampToValueAtTime(top, when + Math.max(att, 0.004));
if (tau) g.gain.setTargetAtTime(0.0001, when + att, tau); else { g.gain.setValueAtTime(top, when + Math.max(dur, att)); g.gain.setTargetAtTime(0.0001, when + Math.max(dur, att), rel / 3); }
o.start(when); o.stop(when + dur + rel + 0.15);
}
function soundOn() {
if (!ac) { ac = new (window.AudioContext || window.webkitAudioContext)(); master = ac.createGain(); master.gain.value = 0.7; master.connect(ac.destination);
noise = ac.createBuffer(1, ac.sampleRate * 0.2, ac.sampleRate); const ch = noise.getChannelData(0); for (let i = 0; i < ch.length; i++) ch[i] = Math.random() * 2 - 1; }
ac.resume();
}
// the recording, when there is one: it plays instead of the synth, kept in step with the tune
const au = new Audio(); au.preload = 'auto'; let hear = 'midi', haveAudio = false, audioName = '';
function syncSrc() {
const b = $('bSrc'); b.hidden = !haveAudio; $('play').style.gridTemplateColumns = haveAudio ? 'auto auto auto 1fr auto' : '';
b.textContent = 'Hear: ' + (hear === 'recording' ? 'recording' : 'MIDI synth'); b.setAttribute('aria-pressed', hear === 'recording' ? 'true' : 'false');
if (!(sound && hear === 'recording' && haveAudio)) au.pause();
}
function setAudio(src, name) { au.src = src; haveAudio = true; audioName = name || 'recording'; hear = 'recording'; syncSrc(); }
$('bSrc').addEventListener('click', () => { hear = hear === 'recording' ? 'midi' : 'recording'; syncSrc(); });
function advance(dt) { // move the tune on, firing the notes that fall due
if (!playing) { au.pause(); return; }
songT += dt; if (songT >= songLen) { songT = 0; nextNote = 0; if (haveAudio) au.currentTime = 0; }
while (nextNote < notes.length && notes[nextNote][0] <= songT) {
const n = notes[nextNote++]; send(dialFor(n), 0.35 + 0.65 * n[3], n[2], simT, n[1], ++pulseId, 0, n[4]);
if (sound && ac && hear === 'midi') voice(n, ac.currentTime + 0.02);
}
if (sound && hear === 'recording' && haveAudio) {
if (au.paused && songT < (au.duration || 1e9)) { au.currentTime = songT; au.play().catch(() => {}); }
else if (Math.abs(au.currentTime - songT) > 0.25) au.currentTime = songT;
}
}
// ---------------------------------------------------------------- view: turning, zoom, picking
let R = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // row-major rotation, globe -> view
const mul = (a, b) => { const o = new Array(9); for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j]; return o; };
const rx = a => [1, 0, 0, 0, Math.cos(a), -Math.sin(a), 0, Math.sin(a), Math.cos(a)], ry = a => [Math.cos(a), 0, Math.sin(a), 0, 1, 0, -Math.sin(a), 0, Math.cos(a)];
let dist = 3.5, vx = 0, vy = 0, spin = !reduced, through = 0, wiresOn = 1, sel = -1, dark = true, idle = 0;
const FOCAL = 2.9, ptr = new Map(); let moved = 0, pinch = 0;
cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); ptr.set(e.pointerId, [e.clientX, e.clientY]); moved = 0; cv.classList.add('drag'); vx = vy = 0;
if (ptr.size === 2) { const [a, b] = [...ptr.values()]; pinch = Math.hypot(a[0] - b[0], a[1] - b[1]); } });
cv.addEventListener('pointermove', e => { const p = ptr.get(e.pointerId); if (!p) return;
const dx = e.clientX - p[0], dy = e.clientY - p[1]; ptr.set(e.pointerId, [e.clientX, e.clientY]); moved += Math.abs(dx) + Math.abs(dy);
if (ptr.size === 2) { const [a, b] = [...ptr.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1]); if (pinch) dist = Math.min(7, Math.max(1.5, dist * pinch / d)); pinch = d; return; }
const k = 0.0052 * dist / 3.5; R = mul(ry(dx * k), mul(rx(dy * k), R)); vx = dx * k; vy = dy * k; idle = 0; });
const up = e => { ptr.delete(e.pointerId); cv.classList.remove('drag'); pinch = 0; if (moved < 5 && e.type === 'pointerup') pick(e.clientX, e.clientY); };
cv.addEventListener('pointerup', up); cv.addEventListener('pointercancel', up);
cv.addEventListener('wheel', e => { e.preventDefault(); dist = Math.min(7, Math.max(1.5, dist * Math.exp(e.deltaY * 0.0012))); }, { passive: false });
addEventListener('keydown', e => { if (e.target !== document.body && e.target !== cv) return; const k = 0.08;
if (e.key === 'ArrowLeft') R = mul(ry(-k), R); else if (e.key === 'ArrowRight') R = mul(ry(k), R); else if (e.key === 'ArrowUp') R = mul(rx(-k), R); else if (e.key === 'ArrowDown') R = mul(rx(k), R);
else if (e.key === '+' || e.key === '=') dist = Math.max(1.5, dist * 0.9); else if (e.key === '-') dist = Math.min(7, dist * 1.1); else if (e.key === ' ') $('bPlay').click(); else return; e.preventDefault(); idle = 0; });
function pick(px, py) {
const w = cv.clientWidth, h = cv.clientHeight, x = (px / w * 2 - 1) * w / h / FOCAL, y = (1 - py / h * 2) / FOCAL;
const dl = Math.hypot(x, y, 1), d = [x / dl, y / dl, -1 / dl], b = d[2] * dist, disc = b * b - (dist * dist - 1);
if (disc < 0) { select(-1); return; }
const t = -b - Math.sqrt(disc), q = [d[0] * t, d[1] * t, dist + d[2] * t], g = [R[0] * q[0] + R[3] * q[1] + R[6] * q[2], R[1] * q[0] + R[4] * q[1] + R[7] * q[2], R[2] * q[0] + R[5] * q[1] + R[8] * q[2]];
let best = -1, bd = -2;
for (let e = 0; e < NE; e++) { const o = e * ES, sig = EL[o + 11]; if (sig < 0) continue;
const l = Math.hypot(EL[o], EL[o + 1], EL[o + 2]), dot = (EL[o] * g[0] + EL[o + 1] * g[1] + EL[o + 2] * g[2]) / l;
if (dot > Math.cos(Math.atan(EL[o + 3]) * 1.25) && dot > bd) { bd = dot; best = sig; } }
select(best); if (best >= 0) pulse(best, 1, 60 + (best % 24), 0.5);
}
function select(n) { sel = n; const r = $('read'); if (n < 0) { r.hidden = true; return; } r.hidden = false; showNode(); }
function showNode() {
if (sel < 0) return; const n = nodes[sel], s = S[sel], key = KEYS[n.role], lg = D.legend[n.role];
$('rName').textContent = (n.name ? n.name + ' · ' : '') + 'dial ' + sel + (n.root ? ' · root' : '');
const row = (a, b) => '<dt>' + a + '</dt><dd>' + b + '</dd>';
$('rBody').innerHTML = row('key', key + ' · ' + lg.role) + row('does', lg.does) + row('level', s.level.toFixed(2)) + row('pulses', s.pulses) +
row('feeds', n.out.length + ' dial' + (n.out.length === 1 ? '' : 's')) + row('fed by', n.inputs) + row('meters', n.tiles.length + ' tile' + (n.tiles.length === 1 ? '' : 's')) +
(key === 'bind' ? row('state', s.latch ? 'set' : 'clear') : '') + row('size', n.deg + '°');
}
// ---------------------------------------------------------------- page
function theme() {
const t = D.themes[dark ? 'black' : 'white'], st = document.documentElement.style;
st.setProperty('--ground', t.ground); st.setProperty('--ink', t.ink); st.setProperty('--text', dark ? '#c9d1dc' : '#1d2530');
st.setProperty('--panel', dark ? 'rgba(0,0,0,.78)' : 'rgba(255,255,255,.86)'); st.setProperty('--rule', dark ? '#2a313b' : '#c5ccd6');
KEYS.forEach((k, i) => st.setProperty('--' + k, t.hue[i]));
}
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255);
$('title').innerHTML = D.title_svg;
$('about').textContent = D.counts.dials + ' dials, ' + D.counts.tiles + ' tiles, ' + D.counts.nodes + ' signal dials, ' + D.counts.wires + ' wires. ' + D.algorithm + ' ' + D.sha3_256.slice(0, 12) + '…';
$('legend').innerHTML = D.legend.map(l => '<li>' + l.svg + '<div><b>' + l.key + ' · ' + l.role + '</b><br><span>' + l.does + '</span></div></li>').join('');
const toggle = (id, fn) => $(id).addEventListener('click', () => { const b = $(id), on = b.getAttribute('aria-pressed') !== 'true'; b.setAttribute('aria-pressed', on); fn(on); });
toggle('bSee', on => through = on ? 1 : 0); toggle('bSpin', on => spin = on); toggle('bWire', on => wiresOn = on ? 1 : 0);
toggle('bGround', on => { dark = !on; theme(); });
toggle('bPlay', on => { playing = on; $('bPlay').textContent = on ? 'Pause' : 'Play'; });
toggle('bSound', on => { sound = on; if (on) soundOn(); else if (ac) ac.suspend(); syncSrc(); });
$('bReset').addEventListener('click', () => { R = [1, 0, 0, 0, 1, 0, 0, 0, 1]; dist = 3.5; vx = vy = 0; });
async function loadMidi(file) {
if (/\.(wav|mp3|ogg|oga|flac|m4a|aac|opus)$/i.test(file.name) || ((file.type || '').startsWith('audio/') && !/midi/.test(file.type))) { setAudio(URL.createObjectURL(file), file.name); $('tune').textContent = file.name + ' (recording) · ' + notes.length + ' notes'; return; }
try { setTune(parseMidi(await file.arrayBuffer()), file.name); } catch (err) { $('tune').textContent = 'Could not read ' + file.name + ': ' + err.message; } }
$('fMidi').addEventListener('change', e => { if (e.target.files[0]) loadMidi(e.target.files[0]); });
addEventListener('dragover', e => e.preventDefault()); addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadMidi(f); });
const seek = f => { songT = Math.max(0, Math.min(1, f)) * songLen; if (haveAudio) au.currentTime = songT; nextNote = notes.findIndex(n => n[0] >= songT); if (nextNote < 0) nextNote = notes.length; queue = []; };
$('bar').addEventListener('click', e => { const r = $('bar').getBoundingClientRect(); seek((e.clientX - r.left) / r.width); });
$('bar').addEventListener('keydown', e => { if (e.key === 'ArrowLeft') seek(songT / songLen - 0.05); else if (e.key === 'ArrowRight') seek(songT / songLen + 0.05); });
if (D.midi) { try { setTune(parseMidi(b64(D.midi, Uint8Array).buffer), D.midi_name); } catch (err) { setTune(D.tune, D.tune_name || 'signature tune'); } } else setTune(D.tune, D.tune_name || 'signature tune');
if (D.audio) { setAudio(D.audio, D.audio_name); $('tune').textContent += ' · hear the recording or the synth'; }
theme(); if (innerWidth >= 1200) $('keys').open = true;
// sensor plates: a strobe's first meter tile fires its dial when you turn it to face you
const plates = S.filter(s => s.role === 'asm' && nodes[s.i].tiles.length).map(s => ({ n: s.i, o: nodes[s.i].tiles[0] * ES, was: 0 }));
let last = performance.now(), frames = 0;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now; simT += dt; idle += dt;
const dpr = Math.min(window.devicePixelRatio || 1, 2), w = Math.round(cv.clientWidth * dpr), h = Math.round(cv.clientHeight * dpr);
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
if (!ptr.size) { if (Math.abs(vx) + Math.abs(vy) > 1e-5) { R = mul(ry(vx), mul(rx(vy), R)); vx *= 0.95; vy *= 0.95; } else if (spin && idle > 2.5) R = mul(ry(dt * 0.07), R); }
for (const p of plates) { const f = R[6] * EL[p.o] + R[7] * EL[p.o + 1] + R[8] * EL[p.o + 2]; if (f > 0.93 && p.was <= 0.93) pulse(p.n, 0.75, 72, 0.2); p.was = f; }
advance(dt); step(dt);
gl.bindTexture(gl.TEXTURE_2D, texNode); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1024, nRows, gl.RGBA, gl.FLOAT, nData);
const t = D.themes[dark ? 'black' : 'white'], g = rgb(t.ground);
gl.viewport(0, 0, w, h); gl.clearColor(g[0], g[1], g[2], 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const pal = new Float32Array(t.hue.flatMap(rgb)), lit = new Float32Array(t.lit.flatMap(rgb)), Rc = new Float32Array([R[0], R[3], R[6], R[1], R[4], R[7], R[2], R[5], R[8]]);
for (const [pr, vao, count] of [[pEl, vaoEl, total], [pW, vaoW, wtotal]]) {
gl.useProgram(pr.p); const u = pr.u;
gl.uniformMatrix3fv(u.uRot, false, Rc); gl.uniform2f(u.uView, w, h); gl.uniform1f(u.uFocal, FOCAL); gl.uniform1f(u.uDist, dist);
gl.uniform1f(u.uLine, Math.max(1, 1.15 * dpr * Math.min(2.2, Math.sqrt(3.5 / dist)))); gl.uniform1f(u.uThrough, through); gl.uniform1f(u.uSel, sel); gl.uniform1f(u.uTime, simT); if (u.uSolid) gl.uniform1f(u.uSolid, D.solid ? 1 : 0);
gl.uniform3fv(u.uPal, pal); gl.uniform3fv(u.uLit, lit); gl.uniform3f(u.uHi, dark ? 1 : 0, dark ? 1 : 0, dark ? 1 : 0);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texNode); gl.uniform1i(u.uNode, 0);
if (u.uElem) { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texEl); gl.uniform1i(u.uElem, 1); }
if (u.uHop) { gl.uniform1f(u.uHop, HOP); gl.uniform1f(u.uShow, wiresOn); }
gl.bindVertexArray(vao); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count);
}
if (++frames % 6 === 0) { const f = songT / songLen; $('bar').firstElementChild.style.width = (f * 100).toFixed(1) + '%'; $('bar').setAttribute('aria-valuenow', Math.round(f * 100)); showNode(); }
window.__frames = frames;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>