← Mike Higgins Home
Custom Code Experiment •

Music: As The Sting 1

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.0, user-scalable=no">
<title>As The Sting — Fullscreen Visualizer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%; height: 100%;
    background: #05060f;
    overflow: hidden;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  }
  #root {
    position: relative;
    width: 100%; height: 100%;
  }
  #vis-canvas {
    position: absolute;
    top: 0; left: 0;
    width: 100%; height: 100%;
    display: block;
    z-index: 1;
  }
  #controls {
    position: absolute;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    width: 90%;
    max-width: 560px;
    background: rgba(5, 6, 15, 0.6);
    backdrop-filter: blur(12px);
    border-radius: 32px;
    padding: 18px 24px;
    border: 1px solid rgba(255,255,255,0.1);
    transition: opacity 0.3s;
  }
  #track-info { text-align: center; }
  #track-title {
    font-size: 16px; font-weight: 500;
    color: rgba(255,255,255,0.95); letter-spacing: 0.04em;
  }
  #track-artist {
    font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 3px;
  }
  #progress-wrap {
    width: 100%; display: flex; align-items: center; gap: 10px;
  }
  #time-cur, #time-dur {
    font-size: 11px; color: rgba(255,255,255,0.6);
    min-width: 34px; font-variant-numeric: tabular-nums;
  }
  #time-dur { text-align: right; }
  #progress-bar {
    flex: 1; height: 4px;
    background: rgba(255,255,255,0.2);
    border-radius: 2px; position: relative;
    cursor: pointer; overflow: visible;
  }
  #progress-fill {
    height: 100%; width: 0%;
    border-radius: 2px;
    background: rgba(255,255,255,0.9);
    pointer-events: none;
    transition: width 0.05s linear;
  }
  #progress-thumb {
    position: absolute; right: -6px; top: 50%;
    transform: translateY(-50%);
    width: 12px; height: 12px;
    border-radius: 50%;
    background: #fff;
    pointer-events: none;
    opacity: 0; transition: opacity 0.2s;
  }
  #progress-bar:hover #progress-thumb { opacity: 1; }
  #play-btn {
    width: 54px; height: 54px;
    border-radius: 50%;
    border: none;
    background: rgba(255,255,255,0.15);
    cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    font-size: 26px; color: white;
    transition: transform 0.12s, background 0.15s;
    flex-shrink: 0;
    backdrop-filter: blur(4px);
  }
  #play-btn:hover { background: rgba(255,255,255,0.25); transform: scale(1.06); }
  #play-btn:active { transform: scale(0.95); }
  #btn-row {
    display: flex; align-items: center; justify-content: space-between;
    width: 100%;
    gap: 18px;
  }
  #vol-wrap { display: flex; align-items: center; gap: 8px; flex: 1; }
  #vol-icon { font-size: 18px; color: rgba(255,255,255,0.7); }
  #vol-slider {
    -webkit-appearance: none; appearance: none;
    width: 80px; height: 4px;
    background: rgba(255,255,255,0.3);
    border-radius: 2px; outline: none; cursor: pointer;
  }
  #vol-slider::-webkit-slider-thumb {
    -webkit-appearance: none; appearance: none;
    width: 12px; height: 12px;
    border-radius: 50%; background: rgba(255,255,255,0.9);
    cursor: pointer;
  }
  #vol-slider::-moz-range-thumb {
    width: 12px; height: 12px;
    border-radius: 50%; background: rgba(255,255,255,0.9);
    cursor: pointer; border: none;
  }
  #loading-msg {
    position: absolute;
    bottom: 40%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 3;
    font-size: 14px;
    color: rgba(255,255,255,0.5);
    background: rgba(0,0,0,0.4);
    padding: 8px 18px;
    border-radius: 40px;
    backdrop-filter: blur(8px);
    pointer-events: none;
    font-weight: 400;
    letter-spacing: 0.5px;
    white-space: nowrap;
  }
  @media (max-width: 600px) {
    #controls { padding: 14px 18px; bottom: 16px; width: 92%; }
    #vol-slider { width: 60px; }
    #play-btn { width: 48px; height: 48px; font-size: 22px; }
    #loading-msg { font-size: 12px; white-space: nowrap; }
  }
</style>
</head>
<body>
<div id="root">
  <canvas id="vis-canvas"></canvas>
  <div id="loading-msg">🎧 Click play to load audio</div>
  <div id="controls">
    <div id="track-info">
      <div id="track-title">As The Sting 1</div>
      <div id="track-artist">Mike H</div>
    </div>
    <div id="progress-wrap">
      <span id="time-cur">0:00</span>
      <div id="progress-bar">
        <div id="progress-fill"></div>
        <div id="progress-thumb"></div>
      </div>
      <span id="time-dur">0:00</span>
    </div>
    <div id="btn-row">
      <div id="vol-wrap">
        <i class="ti ti-volume" id="vol-icon" aria-hidden="true"></i>
        <input type="range" id="vol-slider" min="0" max="1" step="0.01" value="0.8">
      </div>
      <button id="play-btn" aria-label="Play">
        <i class="ti ti-player-play" id="play-icon"></i>
      </button>
      <div style="width: 96px;"></div>
    </div>
  </div>
</div>

<script>
(function () {
  const canvas = document.getElementById('vis-canvas');
  const ctx = canvas.getContext('2d');
  const playBtn = document.getElementById('play-btn');
  const playIcon = document.getElementById('play-icon');
  const progressBar = document.getElementById('progress-bar');
  const progressFill = document.getElementById('progress-fill');
  const timeCur = document.getElementById('time-cur');
  const timeDur = document.getElementById('time-dur');
  const loadingMsg = document.getElementById('loading-msg');
  const volSlider = document.getElementById('vol-slider');
  const volIcon = document.getElementById('vol-icon');

  let audioCtx, analyser, source, gainNode;
  let audioBuffer = null;
  let isPlaying = false;
  let startTime = 0;
  let pauseOffset = 0;
  let freqData, waveData;
  let duration = 0;
  let initialized = false;

  const AUDIO_URL = 'https://cdn.jsdelivr.net/gh/alaric-visi/music@main/as-the-sting/as-the-sting-1.mp3';

  function resizeCanvas() {
    const rect = canvas.parentElement.getBoundingClientRect();
    const width = rect.width;
    const height = rect.height;
    const DPR = window.devicePixelRatio || 1;
    canvas.width = width * DPR;
    canvas.height = height * DPR;
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener('resize', () => { resizeCanvas(); nodes = []; });
  resizeCanvas();

  function fmt(t) {
    if (!isFinite(t) || t < 0) t = 0;
    const m = Math.floor(t / 60);
    const s = Math.floor(t % 60).toString().padStart(2, '0');
    return `${m}:${s}`;
  }

  async function initAudio() {
    if (initialized) return;
    initialized = true;
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    gainNode = audioCtx.createGain();
    gainNode.gain.value = parseFloat(volSlider.value);
    gainNode.connect(audioCtx.destination);
    analyser = audioCtx.createAnalyser();
    analyser.fftSize = 2048;
    analyser.smoothingTimeConstant = 0.82;
    analyser.connect(gainNode);
    freqData = new Uint8Array(analyser.frequencyBinCount);
    waveData = new Uint8Array(analyser.fftSize);

    loadingMsg.textContent = 'Loading audio...';
    try {
      const res = await fetch(AUDIO_URL);
      const ab = await res.arrayBuffer();
      audioBuffer = await audioCtx.decodeAudioData(ab);
      duration = audioBuffer.duration;
      timeDur.textContent = fmt(duration);
      loadingMsg.style.display = 'none';
      play();
    } catch (e) {
      loadingMsg.textContent = 'Failed to load audio.';
      initialized = false;
    }
  }

  function play() {
    if (!audioBuffer) return;
    if (audioCtx.state === 'suspended') audioCtx.resume();
    source = audioCtx.createBufferSource();
    source.buffer = audioBuffer;
    source.connect(analyser);
    source.start(0, pauseOffset);
    startTime = audioCtx.currentTime - pauseOffset;
    isPlaying = true;
    playIcon.className = 'ti ti-player-pause';
    source.onended = () => {
      if (isPlaying) { pauseOffset = 0; isPlaying = false; playIcon.className = 'ti ti-player-play'; }
    };
  }

  function pause() {
    if (source) { source.onended = null; source.stop(); }
    pauseOffset = Math.max(0, audioCtx.currentTime - startTime);
    isPlaying = false;
    playIcon.className = 'ti ti-player-play';
  }

  playBtn.addEventListener('click', async () => {
    if (!initialized) { await initAudio(); return; }
    if (!audioBuffer) return;
    if (isPlaying) { pause(); } else { play(); }
  });

  volSlider.addEventListener('input', () => {
    if (gainNode) gainNode.gain.value = parseFloat(volSlider.value);
    volIcon.className = parseFloat(volSlider.value) < 0.01 ? 'ti ti-volume-off' : 'ti ti-volume';
  });

  progressBar.addEventListener('click', (e) => {
    if (!audioBuffer) return;
    const rect = progressBar.getBoundingClientRect();
    const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    pauseOffset = frac * duration;
    if (isPlaying) { if (source) { source.onended = null; source.stop(); } play(); }
    else { timeCur.textContent = fmt(pauseOffset); progressFill.style.width = (frac * 100) + '%'; }
  });

  let nodes = [];
  let particles = [];
  let tentacles = [];
  let time = 0;

  function initScene(W, H) {
    nodes = [];
    const count = 32;
    for (let i = 0; i < count; i++) {
      const angle = (i / count) * Math.PI * 2;
      const r = Math.min(W, H) * 0.18 + Math.random() * 40;
      nodes.push({
        x: W / 2 + Math.cos(angle) * r,
        y: H / 2 + Math.sin(angle) * r,
        angle, baseR: r,
        size: 2.5 + Math.random() * 3,
        freqBin: Math.floor(Math.random() * 80 + 2),
        hue: 160 + Math.random() * 140,
        phase: Math.random() * Math.PI * 2
      });
    }
    tentacles = [];
    for (let i = 0; i < 10; i++) {
      tentacles.push({ angle: (i / 10) * Math.PI * 2, hue: 180 + i * 18 });
    }
    particles = [];
  }

  function spawnParticles(W, H, energy) {
    if (particles.length > 180) return;
    const count = Math.min(24, Math.floor(energy * 5));
    for (let i = 0; i < count; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = 1.2 + Math.random() * energy * 4;
      particles.push({
        x: W / 2, y: H / 2,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        life: 1, decay: 0.01 + Math.random() * 0.012,
        size: 1.2 + Math.random() * 2.8,
        hue: 160 + Math.random() * 140
      });
    }
  }

  function draw() {
    requestAnimationFrame(draw);
    time += 0.016;
    const W = canvas.width / (window.devicePixelRatio || 1);
    const H = canvas.height / (window.devicePixelRatio || 1);
    if (W === 0 || H === 0) return;
    ctx.clearRect(0, 0, W, H);

    if (nodes.length === 0) initScene(W, H);

    let energy = 0, bassEnergy = 0, midEnergy = 0, highEnergy = 0;
    if (analyser && isPlaying && freqData) {
      analyser.getByteFrequencyData(freqData);
      analyser.getByteTimeDomainData(waveData);
      let sum = 0;
      for (let i = 0; i < freqData.length; i++) sum += freqData[i];
      energy = sum / (freqData.length * 255);
      let bassSum = 0, midSum = 0, highSum = 0;
      for (let i = 0; i < 12; i++) bassSum += freqData[i];
      bassEnergy = bassSum / (12 * 255);
      for (let i = 12; i < 80; i++) midSum += freqData[i];
      midEnergy = midSum / (68 * 255);
      for (let i = 80; i < 200; i++) highSum += freqData[i];
      highEnergy = highSum / (120 * 255);
      energy = Math.min(1, energy * 1.2);
    } else {
      const idle = (Math.sin(time * 0.7) * 0.5 + 0.5) * 0.07;
      energy = idle; bassEnergy = idle * 0.4; midEnergy = idle * 0.3; highEnergy = idle * 0.1;
    }

    const centerX = W / 2, centerY = H / 2;
    const maxDim = Math.max(W, H);

    const bgGrad = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxDim * 0.7);
    bgGrad.addColorStop(0, `hsla(230,30%,8%,1)`);
    bgGrad.addColorStop(1, `hsla(230,20%,2%,1)`);
    ctx.fillStyle = bgGrad;
    ctx.fillRect(0, 0, W, H);

    const glowRad = 90 + bassEnergy * 180;
    const glowGrad = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, glowRad);
    glowGrad.addColorStop(0, `hsla(${190 + energy * 60},85%,60%,${0.22 + bassEnergy * 0.6})`);
    glowGrad.addColorStop(0.6, `hsla(${210 + energy * 40},70%,45%,${0.08 + midEnergy * 0.2})`);
    glowGrad.addColorStop(1, `hsla(220,60%,20%,0)`);
    ctx.fillStyle = glowGrad;
    ctx.beginPath();
    ctx.arc(centerX, centerY, glowRad, 0, Math.PI * 2);
    ctx.fill();

    for (let i = 0; i < 6; i++) {
      const pulse = bassEnergy * (1 - i * 0.12) + midEnergy * 0.2;
      const r = 45 + i * 32 + pulse * 70 + Math.sin(time * 2.2 + i) * 5;
      ctx.save(); ctx.translate(centerX, centerY);
      ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2);
      ctx.strokeStyle = `hsla(${170 + i * 20 + energy * 50},75%,68%,${0.12 + pulse * 0.55})`;
      ctx.lineWidth = 1 + pulse * 3;
      ctx.stroke(); ctx.restore();
    }

    if (freqData) {
      const bins = 140;
      const binStep = Math.floor(freqData.length / bins);
      ctx.save(); ctx.translate(centerX, centerY);
      for (let i = 0; i < bins; i++) {
        let val = 0;
        for (let j = 0; j < binStep; j++) val += freqData[(i * binStep + j) % freqData.length];
        val = (val / (binStep * 255)) * 1.2;
        const angle = (i / bins) * Math.PI * 2 - Math.PI / 2;
        const innerR = 42, len = val * 130 + 3;
        const hue = 180 + (i / bins) * 140 + energy * 50;
        ctx.beginPath();
        ctx.moveTo(Math.cos(angle) * innerR, Math.sin(angle) * innerR);
        ctx.lineTo(Math.cos(angle) * (innerR + len), Math.sin(angle) * (innerR + len));
        ctx.strokeStyle = `hsla(${hue},85%,68%,${0.35 + val * 0.65})`;
        ctx.lineWidth = 1.2 + val * 3.5;
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(Math.cos(angle) * -innerR * 0.8, Math.sin(angle) * -innerR * 0.8);
        ctx.lineTo(Math.cos(angle) * -(innerR * 0.8 + len * 0.5), Math.sin(angle) * -(innerR * 0.8 + len * 0.5));
        ctx.strokeStyle = `hsla(${hue + 40},80%,60%,${0.2 + val * 0.5})`;
        ctx.lineWidth = 0.8 + val * 1.8;
        ctx.stroke();
      }
      ctx.restore();
    }

    for (let t = 0; t < tentacles.length; t++) {
      const tent = tentacles[t];
      const baseAngle = tent.angle + time * 0.12;
      const freqIdx = Math.floor((t / tentacles.length) * 70 + 5) % (freqData?.length || 1);
      const fval = (freqData && freqData[freqIdx]) ? freqData[freqIdx] / 255 : 0.05;
      ctx.save(); ctx.translate(centerX, centerY);
      ctx.beginPath();
      let px = Math.cos(baseAngle) * 50, py = Math.sin(baseAngle) * 50;
      ctx.moveTo(px, py);
      const segs = 16;
      for (let s = 1; s <= segs; s++) {
        const frac = s / segs;
        const wobble = Math.sin(time * 3.2 + s * 0.7 + t) * fval * 42 * frac;
        const r = 50 + frac * 130 * (1 + fval * 1.2);
        const angleOff = wobble * 0.05;
        const nx = Math.cos(baseAngle + angleOff) * r;
        const ny = Math.sin(baseAngle + angleOff) * r;
        const cp1x = px + (nx - px) * 0.4 + Math.cos(baseAngle + Math.PI / 2.5) * wobble;
        const cp1y = py + (ny - py) * 0.4 + Math.sin(baseAngle + Math.PI / 2.5) * wobble;
        ctx.quadraticCurveTo(cp1x, cp1y, nx, ny);
        px = nx; py = ny;
      }
      ctx.strokeStyle = `hsla(${tent.hue + energy * 70},85%,70%,${0.18 + fval * 0.55})`;
      ctx.lineWidth = 1 + fval * 3;
      ctx.stroke(); ctx.restore();
    }

    for (let i = 0; i < nodes.length; i++) {
      const n = nodes[i];
      const fval = (freqData && freqData[Math.min(n.freqBin, freqData.length - 1)]) ? freqData[Math.min(n.freqBin, freqData.length - 1)] / 255 : 0.05;
      const pulse = Math.sin(time * 2 + n.phase) * 0.5 + 0.5;
      const targetR = n.baseR + fval * 70 + bassEnergy * 40;
      n.x += (centerX + Math.cos(n.angle) * targetR - n.x) * 0.12;
      n.y += (centerY + Math.sin(n.angle) * targetR - n.y) * 0.12;
      const s = n.size + fval * 9 + pulse * 2.2;
      const alpha = 0.55 + fval * 0.5;

      for (let j = i + 1; j < nodes.length; j++) {
        const n2 = nodes[j];
        const dx = n2.x - n.x, dy = n2.y - n.y;
        const dist = Math.hypot(dx, dy);
        if (dist < 150) {
          const lineAlpha = (1 - dist / 150) * 0.3 * (0.5 + energy * 0.6);
          ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(n2.x, n2.y);
          ctx.strokeStyle = `hsla(${(n.hue + n2.hue) / 2},80%,68%,${lineAlpha})`;
          ctx.lineWidth = 0.6 + fval * 1.5;
          ctx.stroke();
        }
      }

      const nodeGlow = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, s * 4);
      nodeGlow.addColorStop(0, `hsla(${n.hue + energy * 60},90%,75%,${alpha * 0.8})`);
      nodeGlow.addColorStop(1, `hsla(${n.hue},80%,55%,0)`);
      ctx.fillStyle = nodeGlow;
      ctx.beginPath(); ctx.arc(n.x, n.y, s * 3.8, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = `hsla(${n.hue + fval * 40},95%,85%,${alpha})`;
      ctx.beginPath(); ctx.arc(n.x, n.y, s, 0, Math.PI * 2); ctx.fill();
    }

    if (waveData) {
      const waveRad = 55;
      ctx.save(); ctx.translate(centerX, centerY);
      ctx.beginPath();
      for (let i = 0; i < waveData.length; i += 2) {
        const angle = (i / waveData.length) * Math.PI * 2 - Math.PI / 2;
        const sample = (waveData[i] - 128) / 128;
        const r = waveRad + sample * (18 + midEnergy * 55);
        const x = Math.cos(angle) * r, y = Math.sin(angle) * r;
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.closePath();
      ctx.strokeStyle = `hsla(${200 + energy * 70},85%,72%,${0.5 + midEnergy * 0.5})`;
      ctx.lineWidth = 1.4 + midEnergy * 2.5;
      ctx.stroke(); ctx.restore();
    }

    if (energy > 0.1) spawnParticles(W, H, energy);
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.x += p.vx; p.y += p.vy;
      p.vx *= 0.985; p.vy *= 0.985;
      p.life -= p.decay;
      if (p.life <= 0 || p.x < -100 || p.x > W + 100 || p.y < -100 || p.y > H + 100) {
        particles.splice(i, 1);
        continue;
      }
      ctx.fillStyle = `hsla(${p.hue},95%,78%,${p.life * 0.85})`;
      ctx.beginPath(); ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2); ctx.fill();
    }

    if (highEnergy > 0.1) {
      for (let i = 0; i < 8; i++) {
        const angle = (i / 8) * Math.PI * 2 + time * 0.6;
        const len = 70 + highEnergy * 100;
        ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(angle);
        ctx.beginPath();
        for (let x = -len; x < len; x += 3) {
          const y = Math.sin(x * 0.28 + time * 9) * highEnergy * 18;
          if (x === -len) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.strokeStyle = `hsla(${280 + i * 25},85%,72%,${highEnergy * 0.55})`;
        ctx.lineWidth = 1; ctx.stroke(); ctx.restore();
      }
    }

    if (isPlaying && audioCtx && duration) {
      const cur = Math.min(Math.max(0, audioCtx.currentTime - startTime), duration);
      const percent = (cur / duration) * 100;
      progressFill.style.width = percent + '%';
      timeCur.textContent = fmt(cur);
    }
  }

  draw();
})();
</script>
</body>
</html>