← Mike Higgins Home
Custom Code Experiment •

Music: Red Sash

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>Red Sash — Visualiser</title>
<!--
  Open this file directly in your browser (double-click the .html file).
  It will NOT work inside Claude's preview pane — Claude's Content Security
  Policy blocks external media URLs inside iframes.
-->
<style>
  @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Lato:wght@300;400&display=swap');

  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --red:   #c0392b;
    --gold:  #e8b86d;
    --cream: #f5ede0;
    --dark:  #0a0604;
  }

  html, body {
    width: 100%; height: 100%;
    background: var(--dark);
    overflow: hidden;
    font-family: 'Lato', sans-serif;
    color: var(--cream);
  }

  canvas#c {
    position: fixed; inset: 0;
    width: 100%; height: 100%;
    display: block;
  }

  .player {
    position: fixed;
    bottom: 32px; left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    display: flex; flex-direction: column;
    align-items: center; gap: 12px;
    width: min(480px, 92vw);
  }

  .track-info { text-align: center; letter-spacing: 0.18em; }
  .title {
    font-family: 'Cinzel', serif;
    font-size: clamp(0.9rem, 2.5vw, 1.3rem);
    color: var(--gold);
    text-shadow: 0 0 18px rgba(232,184,109,0.5);
  }
  .artist {
    font-size: 0.65rem; font-weight: 300;
    color: rgba(245,237,224,0.4);
    letter-spacing: 0.3em; text-transform: uppercase; margin-top: 3px;
  }

  .progress-wrap { width: 100%; display: flex; align-items: center; gap: 8px; }
  .time { font-size: 0.63rem; color: rgba(245,237,224,0.4); min-width: 30px; }
  .time.right { text-align: right; }

  .progress-bar {
    flex: 1; height: 4px;
    background: rgba(255,255,255,0.1);
    border-radius: 2px; cursor: pointer;
    position: relative;
    transition: height 0.12s;
  }
  .progress-bar:hover { height: 6px; }
  .progress-fill {
    height: 100%;
    background: linear-gradient(90deg, var(--red), var(--gold));
    border-radius: 2px; width: 0%;
    pointer-events: none;
  }
  .progress-thumb {
    position: absolute;
    top: 50%; transform: translate(-50%, -50%);
    width: 14px; height: 14px;
    background: var(--gold);
    border-radius: 50%;
    box-shadow: 0 0 8px rgba(232,184,109,0.85);
    left: 0%;
    opacity: 0;
    transition: opacity 0.15s;
    pointer-events: none;
  }
  .progress-bar:hover .progress-thumb,
  .progress-bar.dragging .progress-thumb { opacity: 1; }
  .progress-bar.dragging .progress-thumb { transform: translate(-50%,-50%) scale(1.3); }
  .scrub-tooltip {
    position: absolute;
    bottom: 16px;
    transform: translateX(-50%);
    background: rgba(15,8,4,0.92);
    border: 1px solid rgba(232,184,109,0.3);
    color: var(--gold);
    font-size: 0.66rem;
    letter-spacing: 0.1em;
    padding: 3px 8px;
    border-radius: 3px;
    pointer-events: none;
    white-space: nowrap;
    opacity: 0;
    transition: opacity 0.1s;
  }
  .progress-bar:hover .scrub-tooltip,
  .progress-bar.dragging .scrub-tooltip { opacity: 1; }

  .controls { display: flex; align-items: center; gap: 20px; }
  .btn {
    background: none; border: none; cursor: pointer;
    color: var(--cream); opacity: 0.65;
    transition: opacity 0.2s, transform 0.15s;
    display: flex; align-items: center; justify-content: center; padding: 4px;
  }
  .btn:hover { opacity: 1; transform: scale(1.12); }
  .btn-play {
    width: 46px; height: 46px; border-radius: 50%;
    background: linear-gradient(135deg, var(--red), #7a1510);
    box-shadow: 0 0 22px rgba(192,57,43,0.45);
    opacity: 1;
    border: 1px solid rgba(232,184,109,0.25);
  }
  .btn-play:hover { box-shadow: 0 0 36px rgba(192,57,43,0.75); transform: scale(1.07); }
  .btn-play svg { margin-left: 2px; }

  .vol-wrap { display: flex; align-items: center; gap: 6px; color: rgba(245,237,224,0.4); }
  input[type=range].vol {
    -webkit-appearance: none; width: 64px; height: 2px;
    background: rgba(255,255,255,0.1); border-radius: 2px; cursor: pointer;
  }
  input[type=range].vol::-webkit-slider-thumb {
    -webkit-appearance: none; width: 9px; height: 9px;
    background: var(--gold); border-radius: 50%;
  }

  #startOverlay {
    position: fixed; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    z-index: 20;
    background: rgba(10,6,4,0.88);
    cursor: pointer; gap: 14px;
    transition: opacity 0.5s;
  }
  #startOverlay.hidden { opacity: 0; pointer-events: none; }
  #startOverlay .big { font-family:'Cinzel',serif; font-size:clamp(2rem,7vw,3.5rem); color:var(--gold); text-shadow:0 0 40px rgba(232,184,109,0.45); letter-spacing:0.12em; }
  #startOverlay .sub { font-size:0.72rem; letter-spacing:0.35em; text-transform:uppercase; color:rgba(245,237,224,0.4); }
  #startOverlay .hint { width:58px;height:58px;border-radius:50%;background:var(--red);display:flex;align-items:center;justify-content:center;box-shadow:0 0 34px rgba(192,57,43,0.55);animation:pulse 2s ease-in-out infinite;margin-top:6px; }
  @keyframes pulse{0%,100%{box-shadow:0 0 24px rgba(192,57,43,0.45);transform:scale(1);}50%{box-shadow:0 0 50px rgba(192,57,43,0.85);transform:scale(1.06);}}
</style>
</head>
<body>

<canvas id="c"></canvas>

<div id="startOverlay">
  <div class="big">RED SASH</div>
  <div class="sub">Audio Reactive Visualiser</div>
  <div class="hint"><svg width="24" height="24" viewBox="0 0 24 24" fill="white"><polygon points="6,3 20,12 6,21"/></svg></div>
  <div class="sub" style="font-size:0.6rem;margin-top:2px;">click to begin</div>
</div>

<div class="player">
  <div class="progress-wrap">
    <span class="time left"  id="curTime">0:00</span>
    <div class="progress-bar" id="progressBar"><div class="progress-fill" id="progressFill"></div><div class="progress-thumb" id="progressThumb"></div><div class="scrub-tooltip" id="scrubTooltip">0:00</div></div>
    <span class="time right" id="durTime">0:00</span>
  </div>
  <div class="controls">
    <button class="btn" id="btnRew">
      <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg>
    </button>
    <button class="btn btn-play" id="btnPlay">
      <svg id="iconPlay"  width="20" height="20" viewBox="0 0 24 24" fill="white"><polygon points="6,3 20,12 6,21"/></svg>
      <svg id="iconPause" width="20" height="20" viewBox="0 0 24 24" fill="white" style="display:none"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg>
    </button>
    <button class="btn" id="btnFwd">
      <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>
    </button>
    <div class="vol-wrap">
      <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
      <input type="range" class="vol" id="volSlider" min="0" max="1" step="0.01" value="0.8">
    </div>
  </div>
</div>

<script>
// ─────────────────────────────────────────────────────────────────
//  Canvas 2D — Radial FFT visualiser
//
//  Three layers, all driven directly by the full FFT spectrum:
//
//  1. RADIAL BARS  — the full frequency spectrum fanned around a
//                    circle. Each bin = one spike radiating out.
//                    Mirrored so both halves show the same data
//                    (classic "audio circle" look).
//
//  2. WAVEFORM RING — the raw time-domain waveform (oscilloscope)
//                     bent into a ring just inside the bar layer.
//                     Shows the actual wave shape of the music.
//
//  3. ENERGY PULSE  — a solid circle at the centre that breathes
//                     with overall volume / bass energy.
//
//  Spectral bar strip at the bottom is unchanged from before.
// ─────────────────────────────────────────────────────────────────

const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
let W, H, cx, cy, R; // R = base radius for the visualiser ring

function resize(){
  W = canvas.width  = window.innerWidth;
  H = canvas.height = window.innerHeight;
  cx = W / 2;
  cy = H / 2 - 60;  // shift centre up slightly to leave room for EQ strip
  R  = Math.min(W, H) * 0.28;
}
resize();
window.addEventListener('resize', resize);

// ── Audio ─────────────────────────────────────────────────────────
let audioCtx, analyser, fftData, waveData;
let bass = 0, mid = 0, treble = 0, vol = 0;

function initAudio(){
  if (audioCtx) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  analyser = audioCtx.createAnalyser();
  analyser.fftSize = 2048;           // 1024 frequency bins, good resolution
  analyser.smoothingTimeConstant = 0.80;

  fftData  = new Uint8Array(analyser.frequencyBinCount);  // 1024 bins
  waveData = new Uint8Array(analyser.fftSize);            // 2048 time samples

  const src = audioCtx.createMediaElementSource(audio);
  src.connect(analyser);
  analyser.connect(audioCtx.destination);
}

function freqAvg(lo, hi){
  const nyq  = audioCtx.sampleRate / 2;
  const bins  = analyser.frequencyBinCount;
  const a = Math.floor(lo / nyq * bins);
  const b = Math.min(Math.floor(hi / nyq * bins), bins - 1);
  let s = 0;
  for (let i = a; i <= b; i++) s += fftData[i];
  return s / ((b - a + 1) * 255);
}

function updateAudio(){
  if (!analyser) return;
  analyser.getByteFrequencyData(fftData);
  analyser.getByteTimeDomainData(waveData);
  const rb = freqAvg(20,   250);
  const rm = freqAvg(250,  4000);
  const rt = freqAvg(4000, 16000);
  bass   += (rb - bass)   * 0.22;
  mid    += (rm - mid)    * 0.16;
  treble += (rt - treble) * 0.18;
  vol    += (Math.max(rb, rm, rt) - vol) * 0.14;
}

// ── Colour helpers ────────────────────────────────────────────────
function lerpC(a, b, t){
  t = Math.max(0, Math.min(1, t));
  return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
}
function rgba(c, a){ return `rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a.toFixed(3)})`; }

const C_RED   = [192,  57,  43];
const C_GOLD  = [232, 184, 109];
const C_CREAM = [245, 237, 224];
const C_DARK  = [ 10,   6,   4];

// Map a 0-1 position across the spectrum to a colour
function specCol(t, alpha){
  const c = t < 0.5
    ? lerpC(C_RED,  C_GOLD,  t * 2)
    : lerpC(C_GOLD, C_CREAM, (t - 0.5) * 2);
  return rgba(c, alpha);
}

// ── Render ────────────────────────────────────────────────────────
// How many FFT bins to map around the circle.
// We use the lower 512 bins (up to ~11 kHz), which covers all the
// musically interesting content. Mapped across 360° (mirrored: each
// half covers 180°).
const BINS_USED = 512;

// Slow rotation angle — the ring drifts gently
let drift = 0;

function frame(){
  requestAnimationFrame(frame);
  updateAudio();

  // ── background fade (trail effect) ──
  ctx.fillStyle = 'rgba(10,6,4,0.30)';
  ctx.fillRect(0, 0, W, H);

  if (!fftData){
    // before audio starts: draw a static idle ring
    drawIdleRing();
    drawSpectrum();
    return;
  }

  drift += 0.0015 + vol * 0.004;

  drawRadialBars();
  drawWaveformRing();
  drawCentrePulse();
  drawSpectrum();
}

// ── 1. Radial frequency bars ──────────────────────────────────────
function drawRadialBars(){
  const barMaxH = R * 1.1;           // max spike length
  const innerR  = R * 0.72;          // where bars start (just outside wave ring)
  const TWO_PI  = Math.PI * 2;

  // We map BINS_USED bins across the full circle, mirrored:
  // bins 0..BINS_USED map to angles 0..2π, but we mirror so
  // low freqs appear on both left and right (looks more symmetric).
  const total = BINS_USED;

  for (let i = 0; i < total; i++){
    // Mirror: first half = 0→π, second half = π→2π reversed
    const binIdx = i < total / 2 ? i : total - 1 - (i - total / 2);
    const raw    = fftData[binIdx] / 255;

    // logarithmic weighting so bass doesn't dominate visually
    const logW   = 1 - Math.log(1 + (total - i)) / Math.log(total + 1);
    const val    = raw * (0.3 + logW * 0.7);

    const angle  = (i / total) * TWO_PI + drift - Math.PI / 2;
    const barH   = val * barMaxH;

    const x1 = cx + Math.cos(angle) * innerR;
    const y1 = cy + Math.sin(angle) * innerR;
    const x2 = cx + Math.cos(angle) * (innerR + barH);
    const y2 = cy + Math.sin(angle) * (innerR + barH);

    const t   = i / total;  // 0 = bass side, 1 = treble side
    const alpha = 0.55 + val * 0.45;

    ctx.strokeStyle = specCol(t, alpha);
    ctx.lineWidth   = Math.max(0.8, (W / 900) * 1.2);
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();
  }
}

// ── 2. Waveform ring ──────────────────────────────────────────────
// Time-domain samples bent into a circle — shows the actual
// waveform of the sound, so it wiggles rhythmically with the music.
function drawWaveformRing(){
  const waveR   = R * 0.68;   // ring radius
  const waveAmp = R * 0.18;   // max displacement from ring
  const samples = 512;        // how many time samples to use
  const step    = Math.floor(waveData.length / samples);

  ctx.beginPath();
  for (let i = 0; i < samples; i++){
    const angle = (i / samples) * Math.PI * 2 - Math.PI / 2 + drift * 0.5;
    const raw   = (waveData[i * step] / 128) - 1;  // -1 to +1
    const r     = waveR + raw * waveAmp * (1 + bass * 1.2);
    const x     = cx + Math.cos(angle) * r;
    const y     = cy + Math.sin(angle) * r;
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.closePath();

  // colour shifts from red (quiet) → gold (mid) → cream (loud)
  const energy = Math.min(vol * 3, 1);
  const wCol   = lerpC(lerpC(C_RED, C_GOLD, energy), C_CREAM, Math.max(0, energy - 0.6) * 2.5);
  ctx.strokeStyle = rgba(wCol, 0.75 + vol * 0.2);
  ctx.lineWidth   = 1.2 + mid * 1.5;
  ctx.stroke();
}

// ── 3. Centre pulse ───────────────────────────────────────────────
function drawCentrePulse(){
  const pulseR = R * (0.18 + bass * 0.22 + vol * 0.08);

  // outer glow
  const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, pulseR * 2.2);
  grd.addColorStop(0,   rgba(C_GOLD,  0.18 + bass * 0.25));
  grd.addColorStop(0.4, rgba(C_RED,   0.12 + bass * 0.15));
  grd.addColorStop(1,   rgba(C_DARK,  0));
  ctx.fillStyle = grd;
  ctx.beginPath();
  ctx.arc(cx, cy, pulseR * 2.2, 0, Math.PI * 2);
  ctx.fill();

  // solid core
  const coreGrd = ctx.createRadialGradient(cx, cy, 0, cx, cy, pulseR);
  coreGrd.addColorStop(0,   rgba(C_CREAM, 0.9));
  coreGrd.addColorStop(0.5, rgba(C_GOLD,  0.75));
  coreGrd.addColorStop(1,   rgba(C_RED,   0.3));
  ctx.fillStyle = coreGrd;
  ctx.beginPath();
  ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
  ctx.fill();
}

// ── 4. Spectral bar strip (bottom) ───────────────────────────────
function drawSpectrum(){
  const BARS    = 80;
  const barW    = Math.min(4, W * 0.0038);
  const gap     = barW * 0.45;
  const totalW  = BARS * (barW + gap);
  const startX  = cx - totalW / 2;
  const baseY   = H - 96;
  const maxBarH = Math.min(72, H * 0.11);

  if (!fftData){
    // draw silent placeholder bars
    for (let i = 0; i < BARS; i++){
      const x = startX + i * (barW + gap);
      ctx.fillStyle = rgba(C_RED, 0.12);
      ctx.fillRect(x, baseY - 2, barW, 2);
    }
    return;
  }

  const step = Math.floor(fftData.length * 0.5 / BARS);
  for (let i = 0; i < BARS; i++){
    let sum = 0;
    for (let k = 0; k < step; k++) sum += fftData[i * step + k];
    const v = sum / (step * 255);
    const h = Math.max(2, v * maxBarH * (1 + bass * 0.6));

    const frac = i / BARS;
    const x    = startX + i * (barW + gap);

    ctx.fillStyle = specCol(frac, 0.80);
    ctx.fillRect(x, baseY - h, barW, h);

    ctx.fillStyle = specCol(frac, 0.16);
    ctx.fillRect(x, baseY + 1, barW, h * 0.3);
  }
}

// ── Idle ring (before playback) ───────────────────────────────────
function drawIdleRing(){
  const t = performance.now() * 0.001;
  const r = R * 0.68;
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.strokeStyle = rgba(C_RED, 0.18 + 0.08 * Math.sin(t * 1.5));
  ctx.lineWidth   = 1;
  ctx.stroke();

  // small core dot
  ctx.beginPath();
  ctx.arc(cx, cy, R * 0.06, 0, Math.PI * 2);
  ctx.fillStyle = rgba(C_GOLD, 0.35 + 0.15 * Math.sin(t * 2));
  ctx.fill();
}

frame();

// ═══════════════════════════════ Audio element ════════════════════
const audio       = new Audio();
audio.crossOrigin = 'anonymous';
audio.src         = 'https://raw.githubusercontent.com/alaric-visi/music/main/red-sash/red-sash.mp3';
audio.volume      = 0.8;

let isPlaying = false;

function fmt(s){
  return `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
}

function doPlay(){
  initAudio();
  if (audioCtx.state === 'suspended') audioCtx.resume();
  audio.play(); isPlaying = true; syncUI();
}
function doPause(){ audio.pause(); isPlaying = false; syncUI(); }
function syncUI(){
  document.getElementById('iconPlay').style.display  = isPlaying ? 'none'  : 'block';
  document.getElementById('iconPause').style.display = isPlaying ? 'block' : 'none';
}

document.getElementById('btnPlay').addEventListener('click', () => isPlaying ? doPause() : doPlay());
document.getElementById('btnRew').addEventListener('click',  () => { if (isFinite(audio.duration)) audio.currentTime = Math.max(0, audio.currentTime - 10); });
document.getElementById('btnFwd').addEventListener('click',  () => { if (isFinite(audio.duration)) audio.currentTime = Math.min(audio.duration, audio.currentTime + 10); });

audio.addEventListener('timeupdate', () => {
  if (!audio.duration) return;
  document.getElementById('progressFill').style.width = (audio.currentTime / audio.duration * 100) + '%';
  document.getElementById('curTime').textContent = fmt(audio.currentTime);
  document.getElementById('durTime').textContent = fmt(audio.duration);
});
audio.addEventListener('ended',           () => { isPlaying = false; syncUI(); });
audio.addEventListener('loadedmetadata',  () => { document.getElementById('durTime').textContent = fmt(audio.duration); });

// ── Draggable scrubber ────────────────────────────────────────────
const progressBar   = document.getElementById('progressBar');
const progressFill  = document.getElementById('progressFill');
const progressThumb = document.getElementById('progressThumb');
const scrubTooltip  = document.getElementById('scrubTooltip');

let isScrubbing    = false;
let wasPlaying     = false;

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

function applyPct(pct){
  progressFill.style.width  = (pct * 100) + '%';
  progressThumb.style.left  = (pct * 100) + '%';
  scrubTooltip.style.left   = (pct * 100) + '%';
  if (isFinite(audio.duration))
    scrubTooltip.textContent = fmt(pct * audio.duration);
}

function startScrub(clientX){
  if (!isFinite(audio.duration)) return;
  isScrubbing = true;
  wasPlaying  = isPlaying;
  if (isPlaying){ audio.pause(); }
  progressBar.classList.add('dragging');
  applyPct(getPct(clientX));
}

function moveScrub(clientX){
  if (!isScrubbing) return;
  applyPct(getPct(clientX));
}

function endScrub(clientX){
  if (!isScrubbing) return;
  isScrubbing = false;
  progressBar.classList.remove('dragging');
  const pct = getPct(clientX);
  applyPct(pct);
  if (isFinite(audio.duration)){
    audio.currentTime = pct * audio.duration;
  }
  if (wasPlaying){ audio.play(); }
}

// Mouse
progressBar.addEventListener('mousedown', e => { e.preventDefault(); startScrub(e.clientX); });
window.addEventListener('mousemove',      e => { if (isScrubbing) moveScrub(e.clientX); });
window.addEventListener('mouseup',        e => { if (isScrubbing) endScrub(e.clientX); });

// Touch
progressBar.addEventListener('touchstart', e => { e.preventDefault(); startScrub(e.touches[0].clientX); }, { passive: false });
window.addEventListener('touchmove',       e => { if (isScrubbing) moveScrub(e.touches[0].clientX); },    { passive: true });
window.addEventListener('touchend',        e => { if (isScrubbing) endScrub(e.changedTouches[0].clientX); });

// Hover tooltip without dragging
progressBar.addEventListener('mousemove', e => {
  if (isScrubbing || !isFinite(audio.duration)) return;
  const pct = getPct(e.clientX);
  progressThumb.style.left = (pct * 100) + '%';
  scrubTooltip.style.left  = (pct * 100) + '%';
  scrubTooltip.textContent = fmt(pct * audio.duration);
});
document.getElementById('volSlider').addEventListener('input', e => { audio.volume = parseFloat(e.target.value); });

document.getElementById('startOverlay').addEventListener('click', () => {
  document.getElementById('startOverlay').classList.add('hidden');
  doPlay();
});
</script>
</body>
</html>