Spaces:
Running on Zero
Running on Zero
Download globe_template.html from taellinglin/HatchLing-TuneBox: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/spaces/taellinglin/HatchLing-TuneBox/resolve/main/globe_template.html
- Command line
-
hf download hf://spaces/taellinglin/HatchLing-TuneBox/globe_template.html
-
curl -L -o globe_template.html https://huggingface.co/spaces/taellinglin/HatchLing-TuneBox/resolve/main/globe_template.html
37.4 kB
| <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> | |
| ; | |
| 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(' '); | |
| } | |
| 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> | |