← Mike Higgins Home
Custom Code Experiment •

Synth

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, viewport-fit=cover">
<title>Glitch synth loader</title>
<style>
  :root {
    box-sizing: border-box;
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    --page: #EDEDED;
    --ring-track: rgba(0, 0, 0, 0.12);
    --accent: #FFC21A;
    --void: #0A0A0A;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --page: #141414;
      --ring-track: rgba(255, 255, 255, 0.12);
    }
  }
  :root[data-theme="dark"] {
    --page: #141414;
    --ring-track: rgba(255, 255, 255, 0.12);
  }
  *, *::before, *::after { box-sizing: inherit; }
  html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
  body {
    margin: 0;
    height: 100%;
    background: var(--page);
    display: grid;
    place-items: center;
  }

  .orb {
    position: relative;
    width: min(260px, 70vw);
    aspect-ratio: 1;
  }

  /* Animated yellow ring */
  .ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    transform: rotate(-90deg);
  }
  .ring circle { fill: none; stroke-width: 3; }
  .ring .track { stroke: var(--ring-track); }
  .ring .arc {
    stroke: var(--accent);
    stroke-linecap: round;
    stroke-dasharray: 90 400;
    animation: sweep 1.6s cubic-bezier(.6,.1,.3,.9) infinite;
    transform-origin: 50% 50%;
  }
  @keyframes sweep {
    0%   { stroke-dasharray: 20 400; transform: rotate(0deg); }
    50%  { stroke-dasharray: 160 400; }
    100% { stroke-dasharray: 20 400; transform: rotate(360deg); }
  }

  /* Circular viewport */
  .window {
    position: absolute;
    inset: 12px;
    border-radius: 50%;
    overflow: hidden;
    background: radial-gradient(circle at 50% 45%, #262626 0%, var(--void) 70%);
    box-shadow: inset 0 0 40px rgba(0,0,0,.8), inset 0 0 0 1px rgba(255,255,255,.2);
  }
  .window canvas { display: block; width: 100%; height: 100%; }
  /* faint oscilloscope graticule */
  .window::before {
    content: "";
    position: absolute;
    inset: 0;
    opacity: .08;
    background:
      linear-gradient(to right, transparent calc(50% - 1px), #fff calc(50% - 1px) 50%, transparent 50%),
      linear-gradient(to bottom, transparent calc(50% - 1px), #fff calc(50% - 1px) 50%, transparent 50%),
      repeating-linear-gradient(to right, transparent 0 calc(12.5% - 1px), rgba(255,255,255,.5) calc(12.5% - 1px) 12.5%),
      repeating-linear-gradient(to bottom, transparent 0 calc(12.5% - 1px), rgba(255,255,255,.5) calc(12.5% - 1px) 12.5%);
    pointer-events: none;
  }
  /* scanlines */
  .window::after {
    content: "";
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.28) 2px 3px);
    pointer-events: none;
  }

  @media (prefers-reduced-motion: reduce) {
    .ring .arc { animation-duration: 4s; }
  }
</style>
</head>
<body>
  <div class="orb" role="status" aria-label="Loading">
    <svg class="ring" viewBox="0 0 100 100" aria-hidden="true">
      <circle class="track" cx="50" cy="50" r="48"/>
      <circle class="arc" cx="50" cy="50" r="48"/>
    </svg>
    <div class="window">
      <canvas id="synth" aria-hidden="true"></canvas>
    </div>
  </div>

<script>
(() => {
  const canvas = document.getElementById('synth');
  const ctx = canvas.getContext('2d');
  const buf = document.createElement('canvas');
  const bctx = buf.getContext('2d');
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const WHITE = '#FFFFFF';
  const KEYS  = '#D6D6D6';
  const PRINT = '#9A9A9A';
  const BODY  = '#8C8C8C';
  const GHOST_A = '#8C8C8C', GHOST_B = '#E0E0E0';
  const LED = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#FFC21A';

  let W = 0, H = 0, dpr = 1;
  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    const r = canvas.getBoundingClientRect();
    W = Math.round(r.width * dpr); H = Math.round(r.height * dpr);
    canvas.width = buf.width = W;
    canvas.height = buf.height = H;
  }
  new ResizeObserver(resize).observe(canvas);
  resize();

  const lerp = (a, b, t) => a + (b - a) * t;
  const smooth = (t) => t * t * (3 - 2 * t);

  // ---------- Synth geometry ----------
  // Model space: x runs along the keyboard, y is up, z points toward the player.
  const BX = 1.0, CHEEK = 0.05;          // half width between the end cheeks; cheek thickness
  const ZF = 0.5, ZB = -0.5, YBOT = -0.12;
  const KEY_X0 = -0.74, KEY_X1 = 0.98;   // keybed; the pitch and mod wheels sit to its left
  const WHITE_N = 15;                     // two octaves, C to C
  const KW = (KEY_X1 - KEY_X0) / WHITE_N;
  const KEY_ZF = 0.49, KEY_ZB = -0.02;    // key front, and the hinge at the back
  const KEY_T = 0.035;                    // depth of a white key's front lip
  const BLK_W = KW * 0.58, BLK_H = 0.045, BLK_ZF = KEY_ZB + 0.62 * (KEY_ZF - KEY_ZB);
  const DIP = 0.032;                      // how far a played key's front edge drops
  const BLACK_AFTER = [0, 1, 3, 4, 5, 7, 8, 10, 11, 12]; // white keys that have a black key to their right
  const hasBlack = new Set(BLACK_AFTER);

  // Control panel: a plane sloping up toward the back of the instrument.
  const PZ0 = KEY_ZB, PY0 = 0.07, PY1 = 0.19;
  const PL = Math.hypot(ZB - PZ0, PY1 - PY0);
  const PV = [0, (PY1 - PY0) / PL, (ZB - PZ0) / PL];   // unit vector up the slope
  const PN = [0, -PV[2], PV[1]];                         // panel normal
  // a = across the panel, b = distance up the slope, h = height off the surface
  const panel = (a, b, h = 0) => [a, PY0 + PV[1] * b + PN[1] * h, PZ0 + PV[2] * b + PN[2] * h];
  const polar = (a, b, r, th, h = 0) => panel(a + Math.cos(th) * r, b + Math.sin(th) * r, h);
  const ring = (a, b, r, h, n = 18) => Array.from({ length: n + 1 }, (_, k) => polar(a, b, r, 2 * Math.PI * k / n, h));
  const rect = (a0, b0, a1, b1, h = 0) =>
    [panel(a0, b0, h), panel(a1, b0, h), panel(a1, b1, h), panel(a0, b1, h), panel(a0, b0, h)];
  const knobAngle = (v) => Math.PI / 2 + (1 - 2 * v) * (5 * Math.PI / 6);  // 7 o'clock .. 5 o'clock

  const statics = [];
  const add = (pts, color, w, extra) => statics.push({ pts, color, w, ...extra });

  // Wooden end cheeks: a side profile extruded into a thin slab
  const PROFILE = [ // [z, y]
    [ZF, YBOT], [ZF, 0.025], [ZF - 0.035, 0.06], [KEY_ZB + 0.015, 0.066],
    [KEY_ZB - 0.015, 0.1], [ZB + 0.03, PY1 + 0.025], [ZB, PY1 - 0.005], [ZB, YBOT]
  ];
  for (const side of [-1, 1]) {
    const xi = side * BX, xo = side * (BX + CHEEK);
    const face = (x) => [...PROFILE, PROFILE[0]].map(([z, y]) => [x, y, z]);
    add(face(xo), BODY, 1.5);
    add(face(xi), BODY, 1.0, { dim: true });
    for (const [z, y] of PROFILE) add([[xi, y, z], [xo, y, z]], BODY, 1.0);
  }

  // Chassis
  add([[-BX, -KEY_T - 0.006, ZF], [BX, -KEY_T - 0.006, ZF]], BODY, 1.2);   // front rail
  add([[-BX, YBOT, ZF], [BX, YBOT, ZF]], BODY, 1.2);
  add([[-BX, YBOT, ZB], [BX, YBOT, ZB]], BODY, 1.0, { dim: true });
  add([[-BX, PY1, ZB], [BX, PY1, ZB]], BODY, 1.2);                         // panel, back edge
  add([[-BX, PY0, PZ0], [BX, PY0, PZ0]], BODY, 1.2);                       // panel, front edge
  add([[-BX, 0, PZ0], [BX, 0, PZ0]], BODY, 0.9, { dim: true });            // where the keys tuck in
  add([[-BX, 0, KEY_ZF], [KEY_X0 - 0.012, 0, KEY_ZF], [KEY_X0 - 0.012, 0, PZ0]], BODY, 0.9, { dim: true });

  // Pitch and mod wheels (their ridges are drawn per frame so they can roll)
  const WHEELS = [-0.925, -0.835];
  const WR = 0.085, WCY = -0.035, WCZ = 0.24, WT = 0.02;
  const WA0 = Math.asin(-WCY / WR);                      // where a wheel sinks into the bay
  const wheelPt = (x, th) => [x, WCY + Math.sin(th) * WR, WCZ + Math.cos(th) * WR];
  for (const c of WHEELS) {
    const hz = Math.cos(WA0) * WR + 0.012, hx = WT + 0.012;
    add([[c - hx, 0, WCZ - hz], [c + hx, 0, WCZ - hz], [c + hx, 0, WCZ + hz], [c - hx, 0, WCZ + hz], [c - hx, 0, WCZ - hz]],
        BODY, 0.9, { dim: true });
    for (const s of [-1, 1]) {
      add(Array.from({ length: 15 }, (_, k) => wheelPt(c + s * WT, WA0 + (Math.PI - 2 * WA0) * k / 14)), KEYS, 1.1);
    }
  }

  // Panel: scope screen, sequencer LEDs, knobs, ADSR sliders and their printed labels
  const SCR = { a0: -0.93, a1: -0.55, b0: 0.08, b1: 0.42 };
  add(rect(SCR.a0, SCR.b0, SCR.a1, SCR.b1, 0.004), BODY, 1.1);
  add(rect(SCR.a0 + 0.02, SCR.b0 + 0.02, SCR.a1 - 0.02, SCR.b1 - 0.02, 0.002), PRINT, 0.8, { dim: true });
  add([panel(SCR.a0 + 0.035, (SCR.b0 + SCR.b1) / 2, 0.002), panel(SCR.a1 - 0.035, (SCR.b0 + SCR.b1) / 2, 0.002)],
      PRINT, 0.7, { dim: true });
  add([panel(SCR.a0 + 0.06, SCR.b1 + 0.035), panel(SCR.a0 + 0.16, SCR.b1 + 0.035)], PRINT, 0.8, { dim: true });

  for (const a of [-0.475, 0.665]) add([panel(a, 0.03), panel(a, PL - 0.03)], PRINT, 0.8, { dim: true });

  const LEDS = Array.from({ length: 8 }, (_, i) => -0.4 + i * 0.72 / 7);
  const LED_B = 0.052, LED_S = 0.015;
  for (const a of LEDS) add(rect(a - LED_S, LED_B - LED_S, a + LED_S, LED_B + LED_S, 0.003), PRINT, 0.8, { dim: true });

  const KNOBS = [];
  [-0.37, -0.15, 0.07, 0.29].forEach((a, i) => {
    KNOBS.push({ a, b: 0.37, r: 0.058, h: 0.04, v: [0, 0.62, 0.3, 0.8][i] });
    KNOBS.push({ a, b: 0.17, r: 0.058, h: 0.04, v: [0.45, 0.2, 0.7, 0.55][i] });
  });
  const SHAPE = KNOBS[0];                                    // waveform selector
  const CUTOFF = { a: 0.53, b: 0.255, r: 0.105, h: 0.06 };   // filter cutoff
  KNOBS.push(CUTOFF);
  for (const k of KNOBS) {
    add(ring(k.a, k.b, k.r, 0.012), BODY, 1.0);
    add(ring(k.a, k.b, k.r * 0.72, k.h), KEYS, k === CUTOFF ? 1.3 : 1.0);
    const lb = k.b + k.r + (k === CUTOFF ? 0.07 : 0.03);
    add([panel(k.a - 0.035, lb), panel(k.a + 0.035, lb)], PRINT, 0.8, { dim: true });
  }
  for (let i = 0; i <= 10; i++) {          // scale around the cutoff knob
    const th = knobAngle(i / 10);
    add([polar(CUTOFF.a, CUTOFF.b, CUTOFF.r + 0.015, th), polar(CUTOFF.a, CUTOFF.b, CUTOFF.r + 0.034, th)],
        PRINT, 0.8, { dim: true });
  }
  for (const th of [0, Math.PI]) {         // sides of the cutoff knob
    add([polar(CUTOFF.a, CUTOFF.b, CUTOFF.r, th, 0.012), polar(CUTOFF.a, CUTOFF.b, CUTOFF.r * 0.72, th, CUTOFF.h)], BODY, 0.9);
  }
  for (let i = 0; i < 4; i++) {            // four detents on the waveform selector
    const th = knobAngle(i / 3);
    add([polar(SHAPE.a, SHAPE.b, SHAPE.r + 0.012, th), polar(SHAPE.a, SHAPE.b, SHAPE.r + 0.026, th)],
        PRINT, 0.8, { dim: true });
  }

  const SLIDERS = [0.74, 0.8, 0.86, 0.92].map((a, i) => ({ a, v: [0.12, 0.5, 0.7, 0.35][i] }));
  const SL_B0 = 0.1, SL_B1 = 0.39;
  for (const s of SLIDERS) {
    add([panel(s.a, SL_B0, 0.002), panel(s.a, SL_B1, 0.002)], PRINT, 0.9);
    add([panel(s.a - 0.018, SL_B1 + 0.045), panel(s.a + 0.018, SL_B1 + 0.045)], PRINT, 0.8, { dim: true });
  }

  // ---------- Keys ----------
  const whitePress = new Float32Array(WHITE_N);
  const blackPress = new Float32Array(WHITE_N);
  // Keys hinge at the back, so the front edge drops the most
  const dipPt = (x, y, z, p) => [x, y - p * DIP * (z - KEY_ZB) / (KEY_ZF - KEY_ZB), z];

  function addKeys(out) {
    const gap = 0.007, notchZ = BLK_ZF + 0.008, clear = BLK_W / 2 + 0.006;
    for (let i = 0; i < WHITE_N; i++) {
      const p = whitePress[i], lit = p > 0.02, color = lit ? WHITE : KEYS;
      const xl = KEY_X0 + i * KW + gap / 2, xr = KEY_X0 + (i + 1) * KW - gap / 2;
      // Top outline, notched around neighbouring black keys
      const top = [[xl, KEY_ZF], [xr, KEY_ZF]];
      if (hasBlack.has(i)) { const xn = KEY_X0 + (i + 1) * KW - clear; top.push([xr, notchZ], [xn, notchZ], [xn, KEY_ZB]); }
      else top.push([xr, KEY_ZB]);
      if (hasBlack.has(i - 1)) { const xn = KEY_X0 + i * KW + clear; top.push([xn, KEY_ZB], [xn, notchZ], [xl, notchZ]); }
      else top.push([xl, KEY_ZB]);
      top.push([xl, KEY_ZF]);
      out.push({ pts: top.map(([x, z]) => dipPt(x, 0, z, p)), color, w: lit ? 1.4 : 0.9,
                 fill: lit ? 0.16 * p : 0, glow: lit ? 0.25 * p : 0 });
      out.push({ pts: [dipPt(xl, 0, KEY_ZF, p), dipPt(xl, -KEY_T, KEY_ZF, p), dipPt(xr, -KEY_T, KEY_ZF, p), dipPt(xr, 0, KEY_ZF, p)],
                 color, w: 0.9, dim: !lit });
    }
    for (const i of BLACK_AFTER) {
      const p = blackPress[i], lit = p > 0.02, color = lit ? WHITE : KEYS;
      const cx = KEY_X0 + (i + 1) * KW, x0 = cx - BLK_W / 2, x1 = cx + BLK_W / 2, inset = 0.009, tf = BLK_ZF - 0.022;
      const top = [[x0 + inset, KEY_ZB], [x0 + inset, tf], [x1 - inset, tf], [x1 - inset, KEY_ZB]]
        .map(([x, z]) => dipPt(x, BLK_H, z, p));
      out.push({ pts: [...top, top[0]], color, w: lit ? 1.4 : 1.1, fill: lit ? 0.2 * p : 0.06, glow: lit ? 0.25 * p : 0 });
      out.push({ pts: [top[1], dipPt(x0, 0, BLK_ZF, p), dipPt(x1, 0, BLK_ZF, p), top[2]], color, w: 1.0 });
    }
  }

  // ---------- Oscillator ----------
  // Each waveform is a set of harmonic amplitudes. Morphing blends them, the big knob's
  // resonant low-pass shapes them, and the sum is drawn on the panel's little screen.
  const N = 200, K = 16, Q = 1.25, CYC0 = 2.2;
  const SHAPES = [
    (k) => (k === 1 ? 1 : 0),                                                              // sine
    (k) => (k % 2 ? 8 / (Math.PI * Math.PI) * (((k - 1) / 2) % 2 ? -1 : 1) / (k * k) : 0), // triangle
    (k) => 2 / Math.PI * (k % 2 ? 1 : -1) / k,                                             // saw
    (k) => (k % 2 ? 4 / Math.PI / k : 0)                                                   // square
  ].map((f) => Float32Array.from({ length: K + 1 }, (_, k) => (k ? f(k) : 0)));
  const wave = new Float32Array(N + 1);
  const coef = new Float32Array(K + 1);

  function sampleWave(cycles, phase, from, to, mix, cutoff) {
    for (let k = 1; k <= K; k++) {
      const r = k / cutoff;
      coef[k] = lerp(SHAPES[from][k], SHAPES[to][k], mix) / Math.sqrt((1 - r * r) ** 2 + (r / Q) ** 2);
    }
    let peak = 1e-6;
    for (let i = 0; i <= N; i++) {
      const x = 2 * Math.PI * cycles * (i / N - 0.5) + phase;
      let v = 0;
      for (let k = 1; k <= K; k++) if (coef[k]) v += coef[k] * Math.sin(k * x);
      wave[i] = v;
      peak = Math.max(peak, Math.abs(v));
    }
    for (let i = 0; i <= N; i++) wave[i] /= peak;
  }

  function addPanel(out, s) {
    for (const k of KNOBS) {
      const v = k === SHAPE ? s.shapeV : k === CUTOFF ? s.cutV : k.v;
      const th = knobAngle(v), r = k.r * 0.72;
      out.push({ pts: [polar(k.a, k.b, r * 0.2, th, k.h), polar(k.a, k.b, r, th, k.h)], color: WHITE, w: k === CUTOFF ? 1.7 : 1.3 });
    }
    SLIDERS.forEach((sl, i) => {
      const b = lerp(SL_B0, SL_B1, i === 2 ? s.sustainV : sl.v);
      out.push({ pts: rect(sl.a - 0.02, b - 0.014, sl.a + 0.02, b + 0.014, 0.022), color: KEYS, w: 1.0 });
      out.push({ pts: [panel(sl.a - 0.02, b, 0.022), panel(sl.a + 0.02, b, 0.022)], color: WHITE, w: 0.9 });
    });
    // Sequencer step
    const a = LEDS[s.step];
    out.push({ pts: rect(a - LED_S, LED_B - LED_S, a + LED_S, LED_B + LED_S, 0.004), color: LED, w: 1.0, fill: 0.9, glow: 0.55 });
    // Mini scope on the screen
    const mid = (SCR.b0 + SCR.b1) / 2, amp = 0.11 * s.level, pts = [];
    for (let i = 0; i <= N; i += 4) pts.push(panel(lerp(SCR.a0 + 0.035, SCR.a1 - 0.035, i / N), mid + wave[i] * amp, 0.005));
    out.push({ pts, color: WHITE, w: 1.0, glow: 0.3 });
    // Wheel ridges
    WHEELS.forEach((c, j) => {
      const turn = j === 0 ? s.pitch : s.mod;
      for (let k = 0; k < 22; k++) {
        const th = ((turn + k * 2 * Math.PI / 22) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
        if (th > WA0 + 0.05 && th < Math.PI - WA0 - 0.05) {
          out.push({ pts: [wheelPt(c - WT, th), wheelPt(c + WT, th)], color: KEYS, w: 0.8, dim: true });
        }
      }
    });
  }

  // ---------- Sequencer ----------
  // Cm – Ab – Eb – Bb: a bass note held through each bar, an arpeggio on top.
  // Notes are semitones above the lowest C on the keyboard.
  const STEP = reduced ? 0.5 : 0.24;
  const PROG = [
    { bass: 0,  arp: [12, 15, 19, 24, 19, 15, 12, 15] },
    { bass: 8,  arp: [12, 15, 20, 24, 20, 15, 12, 15] },
    { bass: 3,  arp: [15, 19, 22, 19, 15, 19, 22, 19] },
    { bass: 10, arp: [14, 17, 22, 17, 14, 17, 22, 17] }
  ];
  const env = (p, a, h, r) => (p < a ? p / a : p < h ? 1 : p < r ? 1 - (p - h) / (r - h) : 0);
  function playhead(t) {
    const n = Math.floor(t / STEP), p = t / STEP - n;
    const bar = Math.floor(n / 8) % PROG.length, step = n % 8, barP = (step + p) / 8;
    return {
      bar, step, barP,
      arp: PROG[bar].arp[step], arpEnv: env(p, 0.08, 0.55, 0.85),
      bass: PROG[bar].bass, bassEnv: env(barP, 0.015, 0.94, 0.99)
    };
  }
  const WHITE_PC = [0, 2, 4, 5, 7, 9, 11], BLACK_PC = { 1: 0, 3: 1, 6: 3, 8: 4, 10: 5 };
  function press(note, amount) {
    const oct = Math.floor(note / 12), pc = note % 12, w = WHITE_PC.indexOf(pc);
    if (w >= 0) whitePress[oct * 7 + w] = Math.max(whitePress[oct * 7 + w], amount);
    else blackPress[oct * 7 + BLACK_PC[pc]] = Math.max(blackPress[oct * 7 + BLACK_PC[pc]], amount);
  }

  // ---------- Projection ----------
  const CENTER_Y = 0.0, SCALE = 0.33, PERSP = 4.2;
  const view = (rotY, tilt, bob) =>
    ({ cr: Math.cos(rotY), sr: Math.sin(rotY), ct: Math.cos(tilt), st: Math.sin(tilt), bob });
  function project(p, v, jitter) {
    let x = p[0], y = p[1] - CENTER_Y + v.bob, z = p[2];
    if (jitter) { x += (Math.random() - .5) * jitter; y += (Math.random() - .5) * jitter; }
    const x1 = x * v.cr + z * v.sr, z1 = -x * v.sr + z * v.cr;
    const y1 = y * v.ct - z1 * v.st, z2 = y * v.st + z1 * v.ct;
    const s = W * SCALE * PERSP / (PERSP - z2);
    return [W / 2 + x1 * s, H / 2 - y1 * s, z2];
  }

  function strokeAll(polys, v, color, alpha, ox, oy, jitter) {
    for (const poly of polys) {
      bctx.beginPath();
      let depth = 0;
      for (let i = 0; i < poly.pts.length; i++) {
        const [sx, sy, sz] = project(poly.pts[i], v, jitter);
        depth += sz;
        if (i === 0) bctx.moveTo(sx + ox, sy + oy); else bctx.lineTo(sx + ox, sy + oy);
      }
      depth /= poly.pts.length;
      const fade = 0.5 + 0.5 * Math.min(1, Math.max(0, (depth + 1.2) / 2.4));
      const a = alpha * fade * (poly.dim ? 0.55 : 1);
      bctx.strokeStyle = bctx.fillStyle = color || poly.color;
      if (poly.fill) { bctx.globalAlpha = a * poly.fill; bctx.fill(); }
      if (poly.glow) { bctx.globalAlpha = a * poly.glow; bctx.lineWidth = poly.w * 4 * dpr; bctx.stroke(); }
      bctx.globalAlpha = a;
      bctx.lineWidth = poly.w * dpr;
      bctx.stroke();
    }
    bctx.globalAlpha = 1;
  }

  // ---------- Loop ----------
  let glitch = 0, glitchStrength = 0;
  let rot = 0, last = performance.now();
  let frozenRot = null;
  let cycles = CYC0, phase = 0, level = 0;

  function frame(now) {
    if (W === 0 || H === 0) {
      resize();
      if (W === 0 || H === 0) { last = now; requestAnimationFrame(frame); return; }
    }
    const dt = Math.min(50, now - last) / 1000; last = now;
    rot += dt * (reduced ? 0.35 : 0.85);

    if (!reduced && glitch <= 0 && Math.random() < 0.025) {
      glitch = 4 + Math.floor(Math.random() * 10);
      glitchStrength = 0.4 + Math.random() * 0.6;
      if (Math.random() < 0.4) frozenRot = rot + (Math.random() - .5) * 1.2;
    }
    const g = glitch > 0;
    if (g) glitch--; else frozenRot = null;

    const t = now / 1000;
    const bob = Math.sin(t * 2.2) * 0.04;
    const tilt = 0.62 + Math.sin(t * 0.9) * 0.08;
    const synthView = view(frozenRot ?? rot, tilt, bob);

    // Play the arpeggio on the keys...
    const m = playhead(t);
    whitePress.fill(0); blackPress.fill(0);
    press(m.bass, m.bassEnv);
    press(m.arp, m.arpEnv);

    // ...and let the oscillator follow it
    const bend = 0.5 * Math.pow(Math.sin(t * 0.45), 15);           // the odd pitch-wheel flick
    const target = CYC0 * Math.pow(2, (m.arp - 12 + bend * 4) / 12);
    cycles += (target - cycles) * (1 - Math.exp(-dt / 0.035));      // portamento
    phase += dt * 2 * Math.PI * (reduced ? 0.4 : 1.2);
    level = Math.max(m.arpEnv, level * Math.exp(-dt / 0.1));
    const from = (m.bar + 3) % 4, to = m.bar, mix = smooth(Math.min(1, m.barP / 0.08));
    const cutV = 0.5 + 0.42 * Math.sin(t * 0.55);
    sampleWave(cycles, phase, from, to, mix, 1.3 * Math.pow(9 / 1.3, cutV));

    const synth = statics.slice();
    addKeys(synth);
    addPanel(synth, {
      shapeV: lerp(from, to, mix) / 3, cutV, sustainV: 0.62 + 0.18 * Math.sin(t * 0.8),
      step: m.step, level: 0.45 + 0.55 * level, pitch: bend, mod: 1.4 + 0.6 * Math.sin(t * 0.7)
    });

    bctx.clearRect(0, 0, W, H);
    bctx.lineJoin = 'round'; bctx.lineCap = 'round';
    bctx.globalCompositeOperation = 'lighter';
    const drawScene = (color, alpha, ox, oy, jitter) => strokeAll(synth, synthView, color, alpha, ox, oy, jitter);
    const baseJit = reduced ? 0 : 0.005;
    if (g) {
      const off = (4 + Math.random() * 8) * dpr * glitchStrength;
      drawScene(GHOST_A, 0.7, -off, 0, 0.03 * glitchStrength);
      drawScene(GHOST_B, 0.6, off, (Math.random() - .5) * 2 * dpr, 0.03 * glitchStrength);
    }
    drawScene(null, g ? 0.85 : 1, 0, 0, g ? 0.02 : baseJit);
    bctx.globalCompositeOperation = 'source-over';

    ctx.clearRect(0, 0, W, H);
    if (g) {
      let y = 0;
      while (y < H) {
        const h = Math.min(H - y, Math.max(2, Math.floor((4 + Math.random() * 30) * dpr)));
        const shift = Math.random() < 0.35 ? (Math.random() - .5) * 40 * dpr * glitchStrength : 0;
        ctx.drawImage(buf, 0, y, W, h, shift, y, W, h);
        y += h;
      }
      const blocks = Math.floor(Math.random() * 4);
      for (let i = 0; i < blocks; i++) {
        ctx.fillStyle = [WHITE, GHOST_A, GHOST_B][i % 3];
        ctx.globalAlpha = 0.18 + Math.random() * 0.3;
        ctx.fillRect(Math.random() * W, Math.random() * H, (10 + Math.random() * 60) * dpr, (1 + Math.random() * 3) * dpr);
      }
      ctx.globalAlpha = 1;
    } else {
      ctx.drawImage(buf, 0, 0);
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>