[object Object]

← back to Sound Agentabrams

SoundLab: add first build — Outrun (Synthwave Visualizer)

6ad8b9f603026cb00f7f6518984b79fa1ee263a4 · 2026-07-25 00:01:00 -0700 · Steve

Files touched

Diff

commit 6ad8b9f603026cb00f7f6518984b79fa1ee263a4
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 00:01:00 2026 -0700

    SoundLab: add first build — Outrun (Synthwave Visualizer)
---
 sound.json                             |  12 +-
 sounds/synthwave-visualizer/Outrun.aa  |   7 +
 sounds/synthwave-visualizer/index.html | 679 +++++++++++++++++++++++++++++++++
 3 files changed, 697 insertions(+), 1 deletion(-)

diff --git a/sound.json b/sound.json
index f443934..fd13000 100644
--- a/sound.json
+++ b/sound.json
@@ -1,3 +1,13 @@
 {
-  "sounds": []
+  "sounds": [
+    {
+      "id": "synthwave-visualizer",
+      "file": "Outrun.aa",
+      "icon": "🌆",
+      "title": "Synthwave Visualizer",
+      "desc": "Retro sun + perspective grid pulsing to WebAudio-generated synthwave. Play / Stop.",
+      "path": "sounds/synthwave-visualizer/",
+      "added": "2026-07-25"
+    }
+  ]
 }
diff --git a/sounds/synthwave-visualizer/Outrun.aa b/sounds/synthwave-visualizer/Outrun.aa
new file mode 100644
index 0000000..d3212ca
--- /dev/null
+++ b/sounds/synthwave-visualizer/Outrun.aa
@@ -0,0 +1,7 @@
+name: Outrun
+build: synthwave-visualizer
+title: Synthwave Visualizer
+tagline: A retro sun over an animated perspective grid pulses to WebAudio-generated synthwave — play/stop, no audio files.
+category: Games
+entry: index.html
+created: 2026-07-24
diff --git a/sounds/synthwave-visualizer/index.html b/sounds/synthwave-visualizer/index.html
new file mode 100644
index 0000000..a7718e5
--- /dev/null
+++ b/sounds/synthwave-visualizer/index.html
@@ -0,0 +1,679 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>Synthwave Visualizer</title>
+  <style>
+    :root {
+      --neon-pink: #ff2d95;
+      --neon-cyan: #05d9e8;
+      --neon-purple: #b967ff;
+      --bg-0: #05010f;
+    }
+    * { box-sizing: border-box; }
+    html, body {
+      margin: 0;
+      padding: 0;
+      height: 100%;
+      background: var(--bg-0);
+      overflow: hidden;
+      font-family: "Courier New", ui-monospace, monospace;
+      -webkit-tap-highlight-color: transparent;
+    }
+    #stage {
+      position: fixed;
+      inset: 0;
+      display: grid;
+      place-items: center;
+    }
+    canvas {
+      display: block;
+      width: 100vw;
+      height: 100vh;
+    }
+
+    /* ---- HUD / controls ---- */
+    #hud {
+      position: fixed;
+      left: 50%;
+      bottom: 26px;
+      transform: translateX(-50%);
+      display: flex;
+      gap: 14px;
+      align-items: center;
+      z-index: 10;
+      padding: 10px 14px;
+      border-radius: 14px;
+      background: rgba(10, 4, 26, 0.55);
+      border: 1px solid rgba(185, 103, 255, 0.35);
+      backdrop-filter: blur(6px);
+      box-shadow: 0 0 24px rgba(255, 45, 149, 0.25);
+    }
+    button.ctrl {
+      cursor: pointer;
+      font-family: inherit;
+      font-size: 15px;
+      letter-spacing: 1px;
+      text-transform: uppercase;
+      color: #fff;
+      background: linear-gradient(180deg, #2a0a3d, #14041f);
+      border: 1px solid var(--neon-pink);
+      border-radius: 10px;
+      padding: 10px 18px;
+      transition: transform .08s ease, box-shadow .2s ease, border-color .2s ease;
+      text-shadow: 0 0 8px var(--neon-pink);
+      box-shadow: 0 0 12px rgba(255, 45, 149, 0.4);
+    }
+    button.ctrl:hover { box-shadow: 0 0 22px rgba(255, 45, 149, 0.8); }
+    button.ctrl:active { transform: translateY(1px) scale(0.98); }
+    button.ctrl.cyan {
+      border-color: var(--neon-cyan);
+      text-shadow: 0 0 8px var(--neon-cyan);
+      box-shadow: 0 0 12px rgba(5, 217, 232, 0.4);
+    }
+    button.ctrl.cyan:hover { box-shadow: 0 0 22px rgba(5, 217, 232, 0.85); }
+    button.ctrl[disabled] { opacity: .35; cursor: not-allowed; box-shadow: none; }
+
+    .knob {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 4px;
+      color: var(--neon-cyan);
+      font-size: 10px;
+      letter-spacing: 1px;
+      text-shadow: 0 0 6px var(--neon-cyan);
+      user-select: none;
+    }
+    input[type="range"] {
+      -webkit-appearance: none;
+      appearance: none;
+      width: 96px;
+      height: 4px;
+      border-radius: 4px;
+      background: linear-gradient(90deg, var(--neon-cyan), var(--neon-pink));
+      outline: none;
+    }
+    input[type="range"]::-webkit-slider-thumb {
+      -webkit-appearance: none;
+      width: 15px; height: 15px;
+      border-radius: 50%;
+      background: #fff;
+      box-shadow: 0 0 8px var(--neon-cyan);
+      cursor: pointer;
+    }
+    input[type="range"]::-moz-range-thumb {
+      width: 15px; height: 15px; border: none; border-radius: 50%;
+      background: #fff; box-shadow: 0 0 8px var(--neon-cyan); cursor: pointer;
+    }
+
+    #title {
+      position: fixed;
+      top: 20px;
+      left: 50%;
+      transform: translateX(-50%);
+      z-index: 10;
+      text-align: center;
+      pointer-events: none;
+    }
+    #title h1 {
+      margin: 0;
+      font-size: clamp(22px, 5vw, 42px);
+      letter-spacing: 6px;
+      text-transform: uppercase;
+      color: #fff;
+      background: linear-gradient(180deg, #fff 0%, var(--neon-pink) 55%, var(--neon-purple) 100%);
+      -webkit-background-clip: text;
+      background-clip: text;
+      -webkit-text-fill-color: transparent;
+      filter: drop-shadow(0 0 10px rgba(255, 45, 149, .7));
+    }
+    #title p {
+      margin: 4px 0 0;
+      font-size: 11px;
+      letter-spacing: 3px;
+      color: var(--neon-cyan);
+      text-shadow: 0 0 8px var(--neon-cyan);
+    }
+
+    /* Big centered prompt before first play */
+    #prompt {
+      position: fixed;
+      inset: 0;
+      z-index: 20;
+      display: grid;
+      place-items: center;
+      cursor: pointer;
+      background: radial-gradient(circle at 50% 45%, rgba(255,45,149,.08), rgba(5,1,15,.75));
+    }
+    #prompt .badge {
+      text-align: center;
+      color: #fff;
+      text-shadow: 0 0 14px var(--neon-pink);
+      animation: pulse 1.6s ease-in-out infinite;
+    }
+    #prompt .badge .big { font-size: clamp(28px, 8vw, 64px); letter-spacing: 4px; }
+    #prompt .badge .sub { font-size: 13px; letter-spacing: 3px; color: var(--neon-cyan); text-shadow: 0 0 10px var(--neon-cyan); margin-top: 10px; }
+    @keyframes pulse { 0%,100%{opacity:.55;transform:scale(1)} 50%{opacity:1;transform:scale(1.04)} }
+    .hidden { display: none !important; }
+  </style>
+</head>
+<body>
+  <div id="stage">
+    <canvas id="c"></canvas>
+  </div>
+
+  <div id="title">
+    <h1>Synthwave</h1>
+    <p>OSCILLATOR-DRIVEN VISUALIZER</p>
+  </div>
+
+  <div id="prompt">
+    <div class="badge">
+      <div class="big">▶ CLICK TO START</div>
+      <div class="sub">generated synthwave · no audio files</div>
+    </div>
+  </div>
+
+  <div id="hud" class="hidden">
+    <button id="playBtn" class="ctrl">▶ Play</button>
+    <button id="stopBtn" class="ctrl cyan" disabled>■ Stop</button>
+    <div class="knob">
+      <span>VOLUME</span>
+      <input id="vol" type="range" min="0" max="100" value="65" />
+    </div>
+  </div>
+
+  <script>
+  "use strict";
+
+  /* =========================================================================
+     SYNTHWAVE VISUALIZER
+     - All sound is generated live with WebAudio oscillators/noise (no files).
+     - A lookahead scheduler sequences a synthwave loop (Am–F–C–G) with
+       four-on-the-floor kick, offbeat hats, bassline, pad, and a 16th arp.
+     - An AnalyserNode feeds real-time energy into the visuals so the retro
+       sun, perspective grid, and stars all pulse to the music.
+     ========================================================================= */
+
+  // ---------- Canvas / hi-DPI setup ----------
+  const canvas = document.getElementById("c");
+  const ctx = canvas.getContext("2d");
+  let W = 0, H = 0, DPR = 1;
+
+  function resize() {
+    DPR = Math.min(window.devicePixelRatio || 1, 2);
+    W = Math.floor(window.innerWidth);
+    H = Math.floor(window.innerHeight);
+    canvas.width = Math.floor(W * DPR);
+    canvas.height = Math.floor(H * DPR);
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    buildStars();
+  }
+  window.addEventListener("resize", resize);
+
+  // ---------- Stars ----------
+  let stars = [];
+  function buildStars() {
+    stars = [];
+    const horizon = H * 0.58;
+    const n = Math.round((W * horizon) / 5200);
+    for (let i = 0; i < n; i++) {
+      stars.push({
+        x: Math.random() * W,
+        y: Math.random() * horizon * 0.95,
+        r: Math.random() * 1.3 + 0.2,
+        tw: Math.random() * Math.PI * 2,
+        sp: Math.random() * 2 + 0.5
+      });
+    }
+  }
+
+  /* =========================================================================
+     AUDIO ENGINE
+     ========================================================================= */
+  const A = {
+    ctx: null,
+    master: null,
+    analyser: null,
+    freqData: null,
+    running: false,
+    // scheduler
+    tempo: 100,            // BPM
+    current16th: 0,        // absolute 16th-note counter
+    nextNoteTime: 0,       // when the next 16th is due (audio-clock seconds)
+    lookahead: 0.025,      // scheduler tick (s)
+    scheduleAhead: 0.12,   // how far ahead to schedule (s)
+    timer: null,
+    noiseBuf: null,
+    volume: 0.65
+  };
+
+  const midi = (n) => 440 * Math.pow(2, (n - 69) / 12);
+
+  // Progression: Am - F - C - G. Each entry = one bar.
+  const PROG = [
+    { bass: 45, pad: [57, 60, 64], arp: [69, 72, 76, 81] }, // Am
+    { bass: 41, pad: [53, 57, 60], arp: [65, 69, 72, 77] }, // F
+    { bass: 48, pad: [60, 64, 67], arp: [72, 76, 79, 84] }, // C
+    { bass: 43, pad: [55, 59, 62], arp: [67, 71, 74, 79] }  // G
+  ];
+
+  function initAudio() {
+    if (A.ctx) return;
+    const Ctor = window.AudioContext || window.webkitAudioContext;
+    A.ctx = new Ctor();
+
+    A.master = A.ctx.createGain();
+    A.master.gain.value = A.volume;
+
+    // soft ceiling so stacked voices don't clip
+    const limiter = A.ctx.createDynamicsCompressor();
+    limiter.threshold.value = -10;
+    limiter.knee.value = 20;
+    limiter.ratio.value = 12;
+    limiter.attack.value = 0.003;
+    limiter.release.value = 0.25;
+
+    A.analyser = A.ctx.createAnalyser();
+    A.analyser.fftSize = 512;
+    A.analyser.smoothingTimeConstant = 0.8;
+    A.freqData = new Uint8Array(A.analyser.frequencyBinCount);
+
+    A.master.connect(limiter);
+    limiter.connect(A.analyser);
+    A.analyser.connect(A.ctx.destination);
+
+    // white-noise buffer reused for hats/kick click
+    const len = Math.floor(A.ctx.sampleRate * 0.5);
+    A.noiseBuf = A.ctx.createBuffer(1, len, A.ctx.sampleRate);
+    const d = A.noiseBuf.getChannelData(0);
+    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
+  }
+
+  // ---- Voices ----------------------------------------------------------
+  function playBass(freq, t, dur) {
+    const o = A.ctx.createOscillator();
+    const g = A.ctx.createGain();
+    const f = A.ctx.createBiquadFilter();
+    o.type = "sawtooth";
+    o.frequency.value = freq;
+    f.type = "lowpass";
+    f.frequency.value = 520;
+    f.Q.value = 6;
+    g.gain.setValueAtTime(0.0001, t);
+    g.gain.linearRampToValueAtTime(0.32, t + 0.012);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    o.connect(f); f.connect(g); g.connect(A.master);
+    o.start(t); o.stop(t + dur + 0.02);
+  }
+
+  function playPad(freqs, t, dur) {
+    const g = A.ctx.createGain();
+    const f = A.ctx.createBiquadFilter();
+    f.type = "lowpass";
+    f.frequency.setValueAtTime(500, t);
+    f.frequency.linearRampToValueAtTime(1800, t + dur * 0.5);
+    f.frequency.linearRampToValueAtTime(600, t + dur);
+    f.Q.value = 2;
+    g.gain.setValueAtTime(0.0001, t);
+    g.gain.linearRampToValueAtTime(0.09, t + dur * 0.25);
+    g.gain.setValueAtTime(0.09, t + dur * 0.7);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    g.connect(A.master);
+    f.connect(g);
+    freqs.forEach((fr, i) => {
+      const o = A.ctx.createOscillator();
+      o.type = "sawtooth";
+      o.frequency.value = fr;
+      o.detune.value = (i - 1) * 6; // subtle spread
+      o.connect(f);
+      o.start(t); o.stop(t + dur + 0.02);
+    });
+  }
+
+  function playArp(freq, t, dur) {
+    const o = A.ctx.createOscillator();
+    const g = A.ctx.createGain();
+    const f = A.ctx.createBiquadFilter();
+    o.type = "square";
+    o.frequency.value = freq;
+    f.type = "lowpass";
+    f.frequency.value = 2600;
+    f.Q.value = 1;
+    g.gain.setValueAtTime(0.0001, t);
+    g.gain.linearRampToValueAtTime(0.10, t + 0.006);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    o.connect(f); f.connect(g); g.connect(A.master);
+    o.start(t); o.stop(t + dur + 0.02);
+  }
+
+  function playKick(t) {
+    const o = A.ctx.createOscillator();
+    const g = A.ctx.createGain();
+    o.type = "sine";
+    o.frequency.setValueAtTime(150, t);
+    o.frequency.exponentialRampToValueAtTime(45, t + 0.14);
+    g.gain.setValueAtTime(0.9, t);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
+    o.connect(g); g.connect(A.master);
+    o.start(t); o.stop(t + 0.3);
+  }
+
+  function playHat(t, open) {
+    const src = A.ctx.createBufferSource();
+    src.buffer = A.noiseBuf;
+    const f = A.ctx.createBiquadFilter();
+    f.type = "highpass";
+    f.frequency.value = 7000;
+    const g = A.ctx.createGain();
+    const dur = open ? 0.14 : 0.04;
+    g.gain.setValueAtTime(open ? 0.16 : 0.11, t);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    src.connect(f); f.connect(g); g.connect(A.master);
+    src.start(t); src.stop(t + dur + 0.02);
+  }
+
+  // ---- Scheduler -------------------------------------------------------
+  function scheduleStep(step16, t) {
+    const bar = Math.floor(step16 / 16);
+    const s = step16 % 16;            // 0..15 within the bar
+    const chord = PROG[bar % PROG.length];
+    const sixteenth = 60 / A.tempo / 4;
+
+    // Pad — once per bar, sustained
+    if (s === 0) playPad(chord.pad.map(midi), t, sixteenth * 16 * 0.98);
+
+    // Kick — four on the floor
+    if (s % 4 === 0) playKick(t);
+
+    // Hats — offbeat 8ths, open hat on the last
+    if (s % 4 === 2) playHat(t, s === 14);
+
+    // Bass — 8th notes, drop an octave on the "and" of beats for movement
+    if (s % 2 === 0) {
+      const oct = (s % 8 === 4) ? 12 : 0;
+      playBass(midi(chord.bass + oct), t, sixteenth * 2 * 0.9);
+    }
+
+    // Arp — every 16th, climbing the chord tones
+    const arpNote = chord.arp[s % chord.arp.length];
+    playArp(midi(arpNote), t, sixteenth * 0.85);
+  }
+
+  function scheduler() {
+    const sixteenth = 60 / A.tempo / 4;
+    while (A.nextNoteTime < A.ctx.currentTime + A.scheduleAhead) {
+      scheduleStep(A.current16th, A.nextNoteTime);
+      A.nextNoteTime += sixteenth;
+      A.current16th++;
+    }
+  }
+
+  function startAudio() {
+    initAudio();
+    if (A.ctx.state === "suspended") A.ctx.resume();
+    if (A.running) return;
+    A.running = true;
+    A.current16th = 0;
+    A.nextNoteTime = A.ctx.currentTime + 0.08;
+    A.master.gain.cancelScheduledValues(A.ctx.currentTime);
+    A.master.gain.setValueAtTime(0.0001, A.ctx.currentTime);
+    A.master.gain.linearRampToValueAtTime(A.volume, A.ctx.currentTime + 0.4);
+    A.timer = setInterval(scheduler, A.lookahead * 1000);
+    syncButtons();
+  }
+
+  function stopAudio() {
+    if (!A.running) return;
+    A.running = false;
+    clearInterval(A.timer);
+    A.timer = null;
+    if (A.master) {
+      const now = A.ctx.currentTime;
+      A.master.gain.cancelScheduledValues(now);
+      A.master.gain.setValueAtTime(A.master.gain.value, now);
+      A.master.gain.linearRampToValueAtTime(0.0001, now + 0.15);
+    }
+    syncButtons();
+  }
+
+  /* =========================================================================
+     AUDIO ANALYSIS → energy bands used by the visuals
+     ========================================================================= */
+  const energy = { bass: 0, mid: 0, treble: 0, level: 0, beat: 0 };
+  let beatEnv = 0;
+
+  function readAudio() {
+    if (!A.analyser || !A.running) {
+      // graceful decay when stopped
+      energy.bass *= 0.9; energy.mid *= 0.9; energy.treble *= 0.9;
+      energy.level *= 0.9; beatEnv *= 0.9; energy.beat = beatEnv;
+      return;
+    }
+    A.analyser.getByteFrequencyData(A.freqData);
+    const bins = A.freqData.length;
+    const avg = (lo, hi) => {
+      let s = 0;
+      const a = Math.floor(lo * bins), b = Math.floor(hi * bins);
+      for (let i = a; i < b; i++) s += A.freqData[i];
+      return (s / (b - a)) / 255;
+    };
+    const bass = avg(0.0, 0.08);
+    const mid = avg(0.08, 0.35);
+    const treble = avg(0.35, 0.8);
+    // smooth
+    energy.bass += (bass - energy.bass) * 0.35;
+    energy.mid += (mid - energy.mid) * 0.35;
+    energy.treble += (treble - energy.treble) * 0.35;
+    energy.level = (energy.bass + energy.mid + energy.treble) / 3;
+
+    // beat detection off bass transients
+    const target = Math.max(0, bass - 0.45) * 2.2;
+    beatEnv = Math.max(beatEnv * 0.86, target);
+    energy.beat = Math.min(1, beatEnv);
+  }
+
+  /* =========================================================================
+     RENDER
+     ========================================================================= */
+  let gridScroll = 0;
+  let lastT = performance.now();
+
+  function draw(now) {
+    const dt = Math.min(0.05, (now - lastT) / 1000);
+    lastT = now;
+    readAudio();
+
+    const horizon = H * 0.58;
+    const cx = W / 2;
+
+    // ---- Sky gradient ----
+    const sky = ctx.createLinearGradient(0, 0, 0, horizon);
+    sky.addColorStop(0, "#0a0320");
+    sky.addColorStop(0.55, "#2a0a4a");
+    sky.addColorStop(1, "#5a1560");
+    ctx.fillStyle = sky;
+    ctx.fillRect(0, 0, W, horizon);
+
+    // ground
+    const ground = ctx.createLinearGradient(0, horizon, 0, H);
+    ground.addColorStop(0, "#170826");
+    ground.addColorStop(1, "#05010f");
+    ctx.fillStyle = ground;
+    ctx.fillRect(0, horizon, W, H - horizon);
+
+    // ---- Stars ----
+    ctx.save();
+    for (const st of stars) {
+      st.tw += st.sp * dt;
+      const a = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(st.tw)) * (0.6 + energy.treble * 0.8);
+      ctx.globalAlpha = Math.min(1, a);
+      ctx.fillStyle = "#fff";
+      ctx.beginPath();
+      ctx.arc(st.x, st.y, st.r, 0, Math.PI * 2);
+      ctx.fill();
+    }
+    ctx.restore();
+
+    // ---- Retro sun ----
+    drawSun(cx, horizon);
+
+    // ---- Perspective grid ----
+    gridScroll += dt * (0.16 + energy.bass * 0.5);
+    if (gridScroll >= 1) gridScroll -= 1;
+    drawGrid(cx, horizon);
+
+    // ---- Horizon glow line (pulses with beat) ----
+    ctx.save();
+    const glow = 0.5 + energy.beat * 0.5;
+    ctx.strokeStyle = `rgba(255, 80, 200, ${0.6 + energy.beat * 0.4})`;
+    ctx.lineWidth = 2 + energy.beat * 3;
+    ctx.shadowBlur = 20 + energy.beat * 30;
+    ctx.shadowColor = "#ff2d95";
+    ctx.beginPath();
+    ctx.moveTo(0, horizon);
+    ctx.lineTo(W, horizon);
+    ctx.stroke();
+    ctx.restore();
+
+    // subtle vignette
+    const vig = ctx.createRadialGradient(cx, H * 0.5, H * 0.2, cx, H * 0.5, H * 0.9);
+    vig.addColorStop(0, "rgba(0,0,0,0)");
+    vig.addColorStop(1, "rgba(0,0,0,0.55)");
+    ctx.fillStyle = vig;
+    ctx.fillRect(0, 0, W, H);
+
+    requestAnimationFrame(draw);
+  }
+
+  function drawSun(cx, horizon) {
+    const base = Math.min(W, H) * 0.16;
+    const r = base * (1 + energy.bass * 0.16 + energy.beat * 0.05);
+    const sunY = horizon - r * 0.28; // sits partly behind the horizon
+
+    ctx.save();
+    // outer glow
+    ctx.shadowBlur = 60 + energy.level * 60;
+    ctx.shadowColor = "#ff2d95";
+
+    const grad = ctx.createLinearGradient(cx, sunY - r, cx, sunY + r);
+    grad.addColorStop(0, "#fff27a");
+    grad.addColorStop(0.4, "#ff9d3c");
+    grad.addColorStop(0.75, "#ff3d81");
+    grad.addColorStop(1, "#b02a8f");
+
+    // clip to circle so scanlines stay inside the disc
+    ctx.beginPath();
+    ctx.arc(cx, sunY, r, 0, Math.PI * 2);
+    ctx.closePath();
+    ctx.fillStyle = grad;
+    ctx.fill();
+
+    ctx.shadowBlur = 0;
+    ctx.clip();
+    // retro scanline stripes — thicker & denser toward the bottom
+    ctx.fillStyle = "rgba(20, 4, 30, 0.92)";
+    const stripes = 9;
+    for (let i = 0; i < stripes; i++) {
+      const p = i / stripes;                 // 0 top .. 1 bottom of lower half
+      const y = sunY + r * 0.12 + p * r * 0.9;
+      const th = 2 + p * 7;                  // thickness grows downward
+      ctx.fillRect(cx - r, y, r * 2, th);
+    }
+    ctx.restore();
+  }
+
+  function drawGrid(cx, horizon) {
+    const bottom = H;
+    const depth = bottom - horizon;
+    const halfSpread = W * 0.9;
+
+    ctx.save();
+    ctx.lineWidth = 1.2;
+    ctx.strokeStyle = `rgba(5, 217, 232, ${0.55 + energy.mid * 0.4})`;
+    ctx.shadowBlur = 8 + energy.beat * 14;
+    ctx.shadowColor = "#05d9e8";
+
+    // Vertical lines converge to the vanishing point (cx, horizon)
+    const cols = 14;
+    for (let i = -cols; i <= cols; i++) {
+      const bx = cx + (i / cols) * halfSpread;
+      ctx.beginPath();
+      ctx.moveTo(cx, horizon);
+      ctx.lineTo(bx, bottom);
+      ctx.stroke();
+    }
+
+    // Horizontal lines — perspective spacing, scrolling toward the viewer
+    const rows = 16;
+    for (let k = 0; k <= rows; k++) {
+      let f = (k + gridScroll) / rows;      // 0 (far) .. 1 (near)
+      if (f > 1) continue;
+      const y = horizon + depth * (f * f);  // f^2 compresses near the horizon
+      const a = (0.15 + f * 0.75) * (0.55 + energy.mid * 0.5);
+      ctx.globalAlpha = Math.min(1, a);
+      ctx.beginPath();
+      ctx.moveTo(0, y);
+      ctx.lineTo(W, y);
+      ctx.stroke();
+    }
+    ctx.restore();
+  }
+
+  /* =========================================================================
+     UI WIRING
+     ========================================================================= */
+  const promptEl = document.getElementById("prompt");
+  const hudEl = document.getElementById("hud");
+  const playBtn = document.getElementById("playBtn");
+  const stopBtn = document.getElementById("stopBtn");
+  const vol = document.getElementById("vol");
+
+  function syncButtons() {
+    playBtn.disabled = A.running;
+    stopBtn.disabled = !A.running;
+  }
+
+  function firstStart() {
+    promptEl.classList.add("hidden");
+    hudEl.classList.remove("hidden");
+    startAudio();
+  }
+
+  promptEl.addEventListener("click", firstStart);
+  playBtn.addEventListener("click", startAudio);
+  stopBtn.addEventListener("click", stopAudio);
+
+  vol.addEventListener("input", () => {
+    A.volume = (+vol.value) / 100;
+    if (A.master && A.running) {
+      A.master.gain.setTargetAtTime(A.volume, A.ctx.currentTime, 0.05);
+    }
+  });
+
+  // Spacebar toggles play/stop
+  window.addEventListener("keydown", (e) => {
+    if (e.code === "Space") {
+      e.preventDefault();
+      if (!hudEl.classList.contains("hidden")) {
+        A.running ? stopAudio() : startAudio();
+      } else {
+        firstStart();
+      }
+    }
+  });
+
+  // Pause the audio if the tab is hidden (keeps visuals but avoids drift)
+  document.addEventListener("visibilitychange", () => {
+    if (document.hidden && A.running) stopAudio();
+  });
+
+  // ---- boot ----
+  resize();
+  syncButtons();
+  requestAnimationFrame(draw);
+  </script>
+</body>
+</html>

← bd9b72d SoundLab: footer copy → sounds  ·  back to Sound Agentabrams  ·  SoundLab: sync.sh + sources.tsv scaffold for source-build sy bfb052f →