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>