← back to Sound Agentabrams
SoundLab: add first build — Outrun (Synthwave Visualizer)
6ad8b9f603026cb00f7f6518984b79fa1ee263a4 · 2026-07-25 00:01:00 -0700 · Steve
Files touched
M sound.jsonA sounds/synthwave-visualizer/Outrun.aaA sounds/synthwave-visualizer/index.html
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 →