← Mike Higgins Home
Custom Code Experiment •

Music: Iwà Oru Eléṣè

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">
<title>Iwà Oru Eléṣè</title>
<style>
  *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
  canvas#main { display: block; position: fixed; inset: 0; width: 100%; height: 100%; }

  #overlay {
    position: fixed; inset: 0;
    display: flex; flex-direction: column; justify-content: space-between;
    pointer-events: none;
    font-family: 'Space Mono', 'Courier New', monospace;
  }
  #header { padding: 28px 36px; }
  #track-title {
    font-family: Georgia, 'Times New Roman', serif;
    font-style: italic; font-size: clamp(15px, 2.2vw, 21px);
    color: rgba(255,255,255,0.85); letter-spacing: 0.02em;
  }
  #track-sub { font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: 0.18em; text-transform: uppercase; margin-top: 5px; }

  #controls { padding: 20px 36px 30px; pointer-events: all; display: flex; flex-direction: column; gap: 12px; }
  #wv-canvas { width: 100%; height: 28px; opacity: 0.45; display: block; }
  #transport { display: flex; align-items: center; gap: 14px; }

  #play-btn {
    width: 42px; height: 42px; border-radius: 50%;
    border: 1px solid rgba(255,255,255,0.35); background: rgba(255,255,255,0.07);
    color: #fff; font-size: 13px; cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    transition: background 0.2s, border-color 0.2s; flex-shrink: 0;
  }
  #play-btn:hover { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.6); }
  #play-btn:disabled { opacity: 0.35; cursor: default; }

  #progress-track {
    flex: 1; height: 20px; cursor: pointer; position: relative;
  }
  #progress-track::before {
    content: ''; position: absolute;
    left: 0; right: 0; top: 50%; height: 2px;
    transform: translateY(-50%);
    background: rgba(255,255,255,0.15); border-radius: 1px;
  }
  #progress-fill {
    position: absolute; left: 0; top: 50%; height: 2px;
    transform: translateY(-50%);
    background: rgba(255,255,255,0.75); border-radius: 1px;
    width: 0%; pointer-events: none;
  }
  #progress-thumb {
    position: absolute; top: 50%; width: 11px; height: 11px;
    border-radius: 50%; background: #fff;
    box-shadow: 0 0 6px rgba(255,200,80,0.7), 0 0 0 2px rgba(255,200,80,0.35);
    transform: translate(-50%, -50%);
    pointer-events: none; left: 0%;
    transition: box-shadow 0.15s;
  }
  #progress-track.dragging #progress-thumb {
    box-shadow: 0 0 10px rgba(255,200,80,1), 0 0 0 3px rgba(255,200,80,0.6);
  }
  #time-lbl { font-size: 10px; color: rgba(255,255,255,0.4); letter-spacing: 0.08em; min-width: 90px; text-align: right; }

  #start-screen {
    position: fixed; inset: 0; display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 18px;
    cursor: pointer; z-index: 10; transition: opacity 0.5s;
    background: rgba(0,0,0,0.55);
    font-family: 'Space Mono', 'Courier New', monospace;
  }
  #start-screen.hidden { opacity: 0; pointer-events: none; }

  #play-circle {
    width: 78px; height: 78px; border-radius: 50%;
    border: 1px solid rgba(255,200,80,0.5); background: rgba(255,200,80,0.07);
    display: flex; align-items: center; justify-content: center; position: relative;
  }
  .pulse-ring {
    position: absolute; width: 78px; height: 78px;
    border: 1px solid rgba(255,200,80,0.35); border-radius: 50%;
    animation: pulse-out 2.4s ease-out infinite;
  }
  .pulse-ring:nth-child(2) { animation-delay: 0.8s; }
  .pulse-ring:nth-child(3) { animation-delay: 1.6s; }
  @keyframes pulse-out { 0%{transform:scale(1);opacity:.7} 100%{transform:scale(3.8);opacity:0} }
  #play-tri { width:0;height:0;border-top:11px solid transparent;border-bottom:11px solid transparent;border-left:18px solid rgba(255,200,80,.9);margin-left:4px; }
  #start-label { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: rgba(255,255,255,0.35); }
  #status-msg { font-size: 10px; letter-spacing: 0.12em; color: rgba(255,200,80,0.6); text-transform: uppercase; min-height: 14px; }
</style>
</head>
<body>

<canvas id="main"></canvas>

<div id="start-screen">
  <div id="play-circle">
    <div class="pulse-ring"></div>
    <div class="pulse-ring"></div>
    <div class="pulse-ring"></div>
    <div id="play-tri"></div>
  </div>
  <div id="start-label">click to begin</div>
  <div id="status-msg"></div>
</div>

<div id="overlay">
  <div id="header">
    <div id="track-title">Iwà Oru Eléṣè</div>
    <div id="track-sub">01 &nbsp;·&nbsp; audio visualiser</div>
  </div>
  <div id="controls">
    <canvas id="wv-canvas" height="28"></canvas>
    <div id="transport">
      <button id="play-btn" disabled>▶</button>
      <div id="progress-track"><div id="progress-fill"></div><div id="progress-thumb"></div></div>
      <div id="time-lbl">–:–– / –:––</div>
    </div>
  </div>
</div>

<script>
const AUDIO_URL =
  "https://cdn.jsdelivr.net/gh/alaric-visi/music@main/" +
  "%20iw%C3%A0-oru-el%E1%BA%B9%CC%81p%E1%BA%B9%CC%80/" +
  "01-%20iw%C3%A0-oru-el%E1%BA%B9%CC%81p%E1%BA%B9%CC%80.mp3";

const mainCvs = document.getElementById('main');
const wvCvs   = document.getElementById('wv-canvas');
const ctx     = mainCvs.getContext('2d');
const wvCtx   = wvCvs.getContext('2d');

const ss        = document.getElementById('start-screen');
const playBtn   = document.getElementById('play-btn');
const progFill  = document.getElementById('progress-fill');
const progThumb = document.getElementById('progress-thumb');
const progTrack = document.getElementById('progress-track');
const timeLbl   = document.getElementById('time-lbl');
const statusMsg = document.getElementById('status-msg');

let W, H, cx, cy, dpr;
function resize() {
  dpr = devicePixelRatio || 1;
  W = mainCvs.width  = innerWidth  * dpr;
  H = mainCvs.height = innerHeight * dpr;
  cx = W / 2; cy = H / 2;
  wvCvs.width  = wvCvs.offsetWidth  * dpr;
  wvCvs.height = wvCvs.offsetHeight * dpr;
}
addEventListener('resize', resize); resize();

let audioCtx, analyser, audioBuffer, bufSrc;
let fftData, wvData, bufLen;

let loadState   = 'idle';
let playing     = false;
let pausedAt    = 0;
let startedAt   = 0;
let duration    = 0;

let time = 0, beatCooldown = 0, prevBass = 0;

function fmt(s) {
  if (!isFinite(s) || s < 0) return '–:––';
  return Math.floor(s / 60) + ':' + Math.floor(s % 60).toString().padStart(2, '0');
}

function currentTime() {
  if (!playing) return pausedAt;
  return audioCtx.currentTime - startedAt;
}

function setProgress(fraction) {
  const pct = (fraction * 100).toFixed(3) + '%';
  progFill.style.width  = pct;
  progThumb.style.left  = pct;
  timeLbl.textContent   = fmt(fraction * duration) + ' / ' + fmt(duration);
}

function updateUI() {
  if (!duration || isDragging) return;
  setProgress(Math.min(Math.max(currentTime(), 0), duration) / duration);
}

function stopSource(intentional) {
  if (!bufSrc) return;
  bufSrc.onended = null;
  try { bufSrc.stop(); } catch (_) {}
  bufSrc.disconnect();
  bufSrc = null;
  if (!intentional) {
    playing   = false;
    pausedAt  = 0;
    playBtn.textContent = '▶';
    progFill.style.width = '0%';
    progThumb.style.left = '0%';
    timeLbl.textContent = fmt(0) + ' / ' + fmt(duration);
  }
}

function createAnalyserOnce() {
  if (analyser) return;
  analyser = audioCtx.createAnalyser();
  analyser.fftSize = 2048;
  analyser.smoothingTimeConstant = 0.82;
  bufLen  = analyser.frequencyBinCount;
  fftData = new Uint8Array(bufLen);
  wvData  = new Uint8Array(bufLen);
  analyser.connect(audioCtx.destination);
}

function playFrom(offset) {
  stopSource(true);
  createAnalyserOnce();

  bufSrc = audioCtx.createBufferSource();
  bufSrc.buffer = audioBuffer;
  bufSrc.connect(analyser);

  const clampedOffset = Math.max(0, Math.min(offset, duration));
  bufSrc.start(0, clampedOffset);
  startedAt = audioCtx.currentTime - clampedOffset;

  bufSrc.onended = () => {
    stopSource(false);
  };
}

async function loadAudio() {
  if (loadState !== 'idle') return loadState === 'ready';
  loadState = 'loading';

  statusMsg.textContent = 'fetching audio…';
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  let ab;
  try {
    const res = await fetch(AUDIO_URL);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    statusMsg.textContent = 'downloading…';
    ab = await res.arrayBuffer();
  } catch (e) {
    loadState = 'error';
    statusMsg.textContent = 'load failed — see console';
    console.error('Audio fetch failed:', e);
    return false;
  }

  try {
    statusMsg.textContent = 'decoding…';
    audioBuffer = await audioCtx.decodeAudioData(ab);
    duration    = audioBuffer.duration;
    loadState   = 'ready';
    statusMsg.textContent = '';
    playBtn.disabled = false;
    return true;
  } catch (e) {
    loadState = 'error';
    statusMsg.textContent = 'decode error — see console';
    console.error('Decode error:', e);
    return false;
  }
}

let toggling = false;
async function togglePlay() {
  if (toggling) return;
  toggling = true;
  try {
    if (loadState === 'idle') {
      ss.classList.add('hidden');
      const ok = await loadAudio();
      if (!ok) {
        ss.classList.remove('hidden');
        return;
      }
    }

    if (loadState === 'loading') return;
    if (loadState === 'error')   return;

    if (audioCtx.state === 'suspended') await audioCtx.resume();

    if (playing) {
      pausedAt = currentTime();
      stopSource(true);
      playing = false;
      playBtn.textContent = '▶';
    } else {
      playFrom(pausedAt);
      playing = true;
      playBtn.textContent = '❚❚';
    }
  } finally {
    toggling = false;
  }
}

function seekTo(seconds) {
  if (loadState !== 'ready') return;
  const wasPlaying = playing;
  pausedAt = Math.max(0, Math.min(seconds, duration));

  if (wasPlaying) {
    stopSource(true);
    playing = false;
    const doPlay = () => { playFrom(pausedAt); playing = true; playBtn.textContent = '❚❚'; };
    if (audioCtx.state === 'suspended') { audioCtx.resume().then(doPlay); } else { doPlay(); }
  }
  setProgress(pausedAt / duration);
}

ss.addEventListener('click', () => { if (!playing && loadState !== 'loading') togglePlay(); });
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); });

let isDragging = false;

function trackFraction(clientX) {
  const r = progTrack.getBoundingClientRect();
  return Math.max(0, Math.min(1, (clientX - r.left) / r.width));
}

function startDrag(clientX) {
  if (!duration) return;
  isDragging = true;
  progTrack.classList.add('dragging');
  setProgress(trackFraction(clientX));
}

function moveDrag(clientX) {
  if (!isDragging || !duration) return;
  setProgress(trackFraction(clientX));
}

function endDrag(clientX) {
  if (!isDragging) return;
  isDragging = false;
  progTrack.classList.remove('dragging');
  if (!duration) return;
  const fraction = trackFraction(clientX);
  setProgress(fraction);
  seekTo(fraction * duration);
}

progTrack.addEventListener('mousedown',  e => { startDrag(e.clientX); e.preventDefault(); });
progTrack.addEventListener('touchstart', e => { startDrag(e.touches[0].clientX); }, { passive: true });
document.addEventListener('mousemove',   e => { moveDrag(e.clientX); });
document.addEventListener('touchmove',   e => { if (isDragging) moveDrag(e.touches[0].clientX); }, { passive: true });
document.addEventListener('mouseup',     e => { endDrag(e.clientX); });
document.addEventListener('touchend',    e => { endDrag(e.changedTouches[0].clientX); });

const PAL = [
  [255,200,50],[255,140,30],[255,80,110],[200,80,255],
  [80,200,255],[80,255,180],[255,240,100],[255,100,60]
];

class Particle {
  constructor(x, y, angle, speed, color, life) {
    this.x = x; this.y = y;
    this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed;
    this.color = color; this.life = life; this.maxLife = life;
    this.sz = (Math.random() * 2.5 + 1) * dpr; this.trail = [];
  }
  update() {
    this.trail.push([this.x, this.y]);
    if (this.trail.length > 10) this.trail.shift();
    this.x += this.vx; this.y += this.vy;
    this.vx *= 0.963; this.vy *= 0.963; this.life--;
  }
  draw() {
    const a = this.life / this.maxLife, [r, g, b] = this.color;
    for (let i = 0; i < this.trail.length; i++) {
      ctx.beginPath(); ctx.arc(this.trail[i][0], this.trail[i][1], this.sz * .5, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${r},${g},${b},${(i / this.trail.length) * a * .35})`; ctx.fill();
    }
    ctx.beginPath(); ctx.arc(this.x, this.y, this.sz, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${r},${g},${b},${a})`; ctx.fill();
    ctx.beginPath(); ctx.arc(this.x, this.y, this.sz * .38, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(255,255,255,${a * .55})`; ctx.fill();
  }
}

class Ring {
  constructor(x, y, color, maxR) {
    this.x = x; this.y = y; this.r = 0; this.maxR = maxR;
    this.color = color; this.life = 1;
    this.thick = (0.8 + Math.random() * 1.2) * dpr;
  }
  update() { this.r += (this.maxR - this.r) * .07 + 2; this.life -= .022; }
  draw() {
    const [r, g, b] = this.color;
    ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(${r},${g},${b},${this.life * .55})`;
    ctx.lineWidth = this.thick; ctx.stroke();
  }
}

class Ambient {
  constructor() { this.reset(true); }
  reset(init) {
    this.x = Math.random() * W;
    this.y = init ? Math.random() * H : (Math.random() < .5 ? -20 : H + 20);
    this.vx = (Math.random() - .5) * .4 * dpr; this.vy = (Math.random() - .5) * .4 * dpr;
    this.r = (1 + Math.random() * 1.5) * dpr;
    this.color = PAL[Math.floor(Math.random() * PAL.length)];
    this.alpha = Math.random() * .28 + .04;
    this.life = .5 + Math.random() * .5; this.decay = .003 + Math.random() * .003;
  }
  update() { this.x += this.vx; this.y += this.vy; this.life -= this.decay; if (this.life <= 0) this.reset(false); }
  draw() {
    const [r, g, b] = this.color;
    ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${r},${g},${b},${this.alpha * this.life})`; ctx.fill();
  }
}

class Starburst {
  constructor(x, y, bands) {
    this.parts = [];
    const c1 = PAL[Math.floor(Math.random() * PAL.length)];
    const c2 = PAL[Math.floor(Math.random() * PAL.length)];
    const nRays = 8 + Math.floor(bands.energy * 22);
    const base  = Math.random() * Math.PI * 2;
    const spd   = (5 + bands.bass * 16) * dpr;
    for (let i = 0; i < nRays; i++) {
      const ang = base + (i / nRays) * Math.PI * 2 + (Math.random() - .5) * .5;
      for (let k = 0; k < 4; k++) {
        const s    = spd * (.5 + Math.random() * .9);
        const life = 45 + Math.floor(Math.random() * 40);
        this.parts.push(new Particle(x, y, ang + (Math.random() - .5) * .12, s,
          Math.random() < .4 ? c2 : c1, life + k * 6));
      }
    }
    rings.push(new Ring(x, y, c1, (60 + bands.energy * 180) * dpr));
    rings.push(new Ring(x, y, c2, (30 + bands.bass   *  80) * dpr));
    this.alive = true;
  }
  update() {
    this.parts.forEach(p => p.update());
    this.parts = this.parts.filter(p => p.life > 0);
    if (!this.parts.length) this.alive = false;
  }
  draw() { this.parts.forEach(p => p.draw()); }
}

let starbursts = [], rings = [], ambients = [];
for (let i = 0; i < 60; i++) ambients.push(new Ambient());

function getBands() {
  if (!analyser) return { bass: 0, mid: 0, high: 0, energy: 0 };
  analyser.getByteFrequencyData(fftData);
  analyser.getByteTimeDomainData(wvData);
  const bE = Math.floor(bufLen * .05), mE = Math.floor(bufLen * .25);
  let bass = 0, mid = 0, high = 0;
  for (let i = 0;  i < bE;    i++) bass += fftData[i];
  for (let i = bE; i < mE;    i++) mid  += fftData[i];
  for (let i = mE; i < bufLen; i++) high += fftData[i];
  bass /= bE * 255; mid /= (mE - bE) * 255; high /= (bufLen - mE) * 255;
  return { bass, mid, high, energy: bass * .6 + mid * .3 + high * .1 };
}

function drawSpectrum() {
  const n = 180, step = Math.floor(bufLen / n), maxBH = H * .32, bW = W / n;
  for (let i = 0; i < n; i++) {
    let s = 0;
    for (let j = 0; j < step; j++) s += fftData[i * step + j];
    const v = s / (step * 255), bh = v * maxBH;
    const [r, g, b] = PAL[Math.floor(i / n * PAL.length) % PAL.length];
    ctx.fillStyle = `rgba(${r},${g},${b},${.35 + v * .55})`;
    ctx.fillRect(i * bW, H - bh, bW - 1, bh);
    ctx.fillStyle = `rgba(${r},${g},${b},${(.35 + v * .55) * .28})`;
    ctx.fillRect(i * bW, 0, bW - 1, bh * .2);
  }
}

function drawWaveform() {
  if (!wvData) return;
  const wd = wvCvs.width, wh = wvCvs.height;
  wvCtx.clearRect(0, 0, wd, wh);
  wvCtx.beginPath();
  const sw = wd / bufLen; let x = 0;
  for (let i = 0; i < bufLen; i++) {
    const v = (wvData[i] - 128) / 128, y = wh / 2 + v * wh * .44;
    i === 0 ? wvCtx.moveTo(x, y) : wvCtx.lineTo(x, y);
    x += sw;
  }
  wvCtx.strokeStyle = 'rgba(255,200,80,.55)';
  wvCtx.lineWidth = dpr; wvCtx.stroke();
}

function drawCenter(bands) {
  const base = Math.min(W, H) * .09, pulse = base + bands.bass * base * 2;
  for (let i = 0; i < 120; i++) {
    const ang = (i / 120) * Math.PI * 2;
    const fv  = fftData ? fftData[Math.floor((i / 120) * bufLen * .5)] / 255 : 0;
    const r   = pulse + fv * base * 3;
    const px  = cx + Math.cos(ang) * r, py = cy + Math.sin(ang) * r;
    const [pr, pg, pb] = PAL[(Math.floor((i / 120) * PAL.length) + Math.floor(time * .01)) % PAL.length];
    ctx.beginPath(); ctx.arc(px, py, (1.5 + fv * 4) * dpr, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${pr},${pg},${pb},${.4 + fv * .6})`; ctx.fill();
  }
  const [cr, cg, cb] = PAL[Math.floor(time * .015) % PAL.length];
  const g2 = ctx.createRadialGradient(cx, cy, 0, cx, cy, pulse * .9);
  g2.addColorStop(0,  `rgba(${cr},${cg},${cb},${.12 + bands.bass * .22})`);
  g2.addColorStop(.5, `rgba(${cr},${cg},${cb},${.04 + bands.bass * .08})`);
  g2.addColorStop(1,  'rgba(0,0,0,0)');
  ctx.fillStyle = g2;
  ctx.beginPath(); ctx.arc(cx, cy, pulse, 0, Math.PI * 2); ctx.fill();
}

function drawIdle() {
  for (let k = 0; k < 3; k++) {
    const ph = time * .025 + k * (Math.PI * 2 / 3);
    const r  = Math.min(W, H) * .07 * (1 + .3 * Math.sin(ph));
    const [pr, pg, pb] = PAL[(k + Math.floor(time * .01)) % PAL.length];
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(${pr},${pg},${pb},.18)`;
    ctx.lineWidth = dpr; ctx.stroke();
  }
}

function loop() {
  requestAnimationFrame(loop);
  time++;
  const bands = getBands();

  ctx.fillStyle = playing ? 'rgba(0,0,0,.16)' : 'rgba(0,0,0,.28)';
  ctx.fillRect(0, 0, W, H);

  ambients.forEach(a => { a.update(); a.draw(); });

  if (playing) {
    drawSpectrum();
    updateUI();
    beatCooldown--;
    if (beatCooldown <= 0 &&
        (bands.bass - prevBass > .07 || (bands.energy > .28 && Math.random() < .04))) {
      const x = cx + (Math.random() - .5) * W * .55;
      const y = cy + (Math.random() - .5) * H * .45;
      starbursts.push(new Starburst(x, y, bands));
      beatCooldown = 10 + Math.floor(Math.random() * 12);
    }
    if (Math.random() < bands.mid * .25) {
      rings.push(new Ring(
        cx + (Math.random() - .5) * W * .7,
        cy + (Math.random() - .5) * H * .6,
        PAL[Math.floor(Math.random() * PAL.length)],
        (25 + bands.mid * 100) * dpr
      ));
    }
    prevBass = bands.bass;
  } else {
    drawIdle();
  }

  drawWaveform();
  rings.forEach(r  => { r.update(); r.draw(); });  rings      = rings.filter(r => r.life > 0);
  starbursts.forEach(s => { s.update(); s.draw(); }); starbursts = starbursts.filter(s => s.alive);
  if (analyser) drawCenter(bands);
}
loop();
</script>
</body>
</html>