feat: Comet visualizer adopts album art colors and fades on silence

- Extract dominant hues from album artwork via canvas sampling
- Replace all hardcoded hue values with palette-derived colors
- Smooth palette transitions when track changes (lerp on hue circle)
- Add time-based mutation for continuous color evolution
- Wisps, rings, and clouds fade to near-invisible when silent
- Black hole shrinks significantly when no audio energy
This commit is contained in:
vicrodh committed 2026-03-08 23:34:45 -06:00
1 parent d6db593018
commit 21f70b958a
1 file changed
+142 -28
@@ -80,6 +80,99 @@
let previousHigh = 0;
let beatCooldown = 0;
// Album art color extraction
const DEFAULT_PALETTE = [356, 196, 280, 30]; // warm red, cyan, purple, orange
let artPalette: number[] = [...DEFAULT_PALETTE];
let paletteTarget: number[] = [...DEFAULT_PALETTE];
let currentArtwork = '';
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h * 360, s, l];
}
function extractPalette(img: HTMLImageElement) {
const size = 32;
const sampleCanvas = document.createElement('canvas');
sampleCanvas.width = size;
sampleCanvas.height = size;
const sCtx = sampleCanvas.getContext('2d');
if (!sCtx) return;
sCtx.drawImage(img, 0, 0, size, size);
const data = sCtx.getImageData(0, 0, size, size).data;
// Bucket hues into 12 sectors (30° each), weighted by saturation and away from gray
const buckets = new Float32Array(12);
const bucketHueSum = new Float32Array(12);
const bucketCount = new Float32Array(12);
for (let i = 0; i < data.length; i += 4) {
const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]);
if (s < 0.12 || l < 0.08 || l > 0.92) continue; // skip grays
const bucket = Math.floor(h / 30) % 12;
const weight = s * (0.5 + Math.abs(l - 0.5)); // prefer saturated, non-mid colors
buckets[bucket] += weight;
bucketHueSum[bucket] += h * weight;
bucketCount[bucket] += weight;
}
// Pick top 4 buckets
const indexed = Array.from(buckets).map((w, i) => ({ w, i }));
indexed.sort((a, b) => b.w - a.w);
const extracted: number[] = [];
for (let j = 0; j < Math.min(4, indexed.length); j++) {
const idx = indexed[j].i;
if (bucketCount[idx] > 0) {
extracted.push(Math.round(bucketHueSum[idx] / bucketCount[idx]));
}
}
// Fill remaining slots with defaults if needed
while (extracted.length < 4) {
extracted.push(DEFAULT_PALETTE[extracted.length]);
}
paletteTarget = extracted;
}
function loadArtworkPalette(src: string) {
if (!src || src === currentArtwork) return;
currentArtwork = src;
const img = new window.Image();
img.crossOrigin = 'anonymous';
img.onload = () => extractPalette(img);
img.onerror = () => { paletteTarget = [...DEFAULT_PALETTE]; };
img.src = src;
}
// Smoothly interpolate palette hues toward target each frame
function lerpPalette() {
for (let i = 0; i < 4; i++) {
let diff = paletteTarget[i] - artPalette[i];
// Shortest path around hue circle
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
artPalette[i] = wrapHue(artPalette[i] + diff * 0.02);
}
}
// Get palette hue with time-based mutation
function palHue(index: number, timeMs: number, drift: number = 18): number {
const base = artPalette[index % 4];
return wrapHue(base + Math.sin(timeMs * 0.0008 + index * 2.1) * drift);
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
@@ -105,15 +198,16 @@
}
function createWisp(intensity: number = 0.5): TunnelWisp {
const colorFamilies = [2, 8, 14, 20, 190, 206];
const family = colorFamilies[Math.floor(Math.random() * colorFamilies.length)];
// Pick from album-derived palette with random spread
const palIdx = Math.floor(Math.random() * 4);
const baseHue = artPalette[palIdx];
return {
depth: Math.random() * 0.22,
angle: Math.random() * Math.PI * 2,
speed: 0.002 + Math.random() * 0.004 + intensity * 0.008,
width: 2.8 + Math.random() * 4.5 + intensity * 5.0,
alpha: 0.25 + Math.random() * 0.3 + intensity * 0.35,
hue: wrapHue(family + (Math.random() - 0.5) * 16),
hue: wrapHue(baseHue + (Math.random() - 0.5) * 30),
spin: (Math.random() - 0.5) * 0.012,
curve: 0.05 + Math.random() * 0.14 + intensity * 0.09,
phase: Math.random() * Math.PI * 2,
@@ -185,14 +279,15 @@
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.filter = `hue-rotate(22deg) saturate(${160 + bass * 130}%)`;
const chromaHueShift = Math.round(artPalette[0] - artPalette[1]) % 60;
ctx.filter = `hue-rotate(${chromaHueShift}deg) saturate(${160 + bass * 130}%)`;
ctx.globalAlpha = 0.14 + bass * 0.14;
ctx.drawImage(canvasRef, chromaOffset, -chromaOffset * 0.7, drawWidth, drawHeight);
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.filter = `hue-rotate(-26deg) saturate(${150 + high * 150}%)`;
ctx.filter = `hue-rotate(${-chromaHueShift}deg) saturate(${150 + high * 150}%)`;
ctx.globalAlpha = 0.12 + high * 0.16;
ctx.drawImage(canvasRef, -chromaOffset * 0.8, chromaOffset * 0.7, drawWidth, drawHeight);
ctx.restore();
@@ -222,8 +317,8 @@
centerY,
minDim * 1.08
);
redFlood.addColorStop(0, `hsla(${wrapHue(4 + timeMs * 0.022)}, 96%, 44%, ${0.18 + bass * 0.16})`);
redFlood.addColorStop(0.45, `hsla(${wrapHue(356 + timeMs * 0.017)}, 96%, 36%, ${0.16 + mid * 0.12})`);
redFlood.addColorStop(0, `hsla(${palHue(0, timeMs, 22)}, 96%, 44%, ${0.18 + bass * 0.16})`);
redFlood.addColorStop(0.45, `hsla(${palHue(1, timeMs, 18)}, 96%, 36%, ${0.16 + mid * 0.12})`);
redFlood.addColorStop(1, 'rgba(24, 0, 8, 0)');
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = redFlood;
@@ -244,12 +339,14 @@
const radiusX = minDim * (0.17 + seed.scale * 0.26 + bass * 0.1 + travel * 0.12);
const radiusY = minDim * (0.13 + seed.scale * 0.22 + high * 0.08 + travel * 0.1);
const warmHue = wrapHue(356 + Math.sin(timeMs * 0.0013 + seed.offset * 7) * 18 + bass * 28);
const coolHue = wrapHue(196 + Math.cos(timeMs * 0.0018 + seed.offset * 10) * 20 + high * 16);
const hue = seedIdx % 5 === 0 ? coolHue : warmHue;
const warmHue = wrapHue(palHue(0, timeMs, 24) + Math.sin(timeMs * 0.0013 + seed.offset * 7) * 18 + bass * 28);
const coolHue = wrapHue(palHue(1, timeMs, 20) + Math.cos(timeMs * 0.0018 + seed.offset * 10) * 20 + high * 16);
const mutantHue = wrapHue(palHue(2, timeMs, 16) + Math.sin(timeMs * 0.001 + seed.offset * 5) * 22);
const hue = seedIdx % 5 === 0 ? coolHue : seedIdx % 7 === 0 ? mutantHue : warmHue;
const saturation = 70 + high * 20;
const lightness = 38 + travel * 18 + mid * 8;
const alpha = 0.08 + (1 - travel) * (0.16 + bass * 0.18);
const cloudEnergy = clamp01((bass + mid + high) * 2.5);
const alpha = (0.08 + (1 - travel) * (0.16 + bass * 0.18)) * (0.1 + cloudEnergy * 0.9);
const cloudGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radiusX * 1.25);
cloudGradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 8}%, ${alpha})`);
@@ -275,11 +372,14 @@
timeMs: number,
bass: number,
mid: number,
high: number
high: number,
totalEnergy: number
) {
if (!ctx) return;
primeWisps();
// Fade wisps nearly out when silent
const energyGate = clamp01(totalEnergy * 3.0);
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
@@ -320,7 +420,7 @@
const hue = wrapHue(wisp.hue + timeMs * 0.028 + Math.sin(timeMs * 0.0017 + wisp.phase) * 26);
const saturation = 74 + high * 18;
const lightness = 52 + bass * 20 + (1 - depthCurve) * 8;
const alpha = clamp01(wisp.alpha * (0.45 + bass * 0.6 + high * 0.15));
const alpha = clamp01(wisp.alpha * (0.45 + bass * 0.6 + high * 0.15) * (0.05 + energyGate * 0.95));
const strokeGradient = ctx.createLinearGradient(sx, sy, ex, ey);
strokeGradient.addColorStop(0, `hsla(${wrapHue(hue - 20)}, ${Math.max(30, saturation - 18)}%, ${lightness - 8}%, 0)`);
@@ -355,6 +455,7 @@
) {
if (!ctx) return;
const ringEnergy = clamp01((bass + mid + high) * 2.5);
const ringCount = 12;
const tunnelSpin = timeMs * 0.00042 + phase * 0.03;
ctx.globalCompositeOperation = 'screen';
@@ -372,9 +473,11 @@
const rotation = tunnelSpin + ringIdx * 0.065 + Math.sin(timeMs * 0.0013 + ringIdx) * 0.18;
const hue = ringIdx % 4 === 0
? wrapHue(198 + Math.cos(timeMs * 0.0018 + ringIdx) * 18 + high * 18)
: wrapHue(356 + Math.sin(timeMs * 0.0015 + ringIdx * 0.7) * 14 + bass * 22);
const alpha = 0.08 + fade * (0.24 + bass * 0.35);
? wrapHue(palHue(1, timeMs, 18) + Math.cos(timeMs * 0.0018 + ringIdx) * 18 + high * 18)
: ringIdx % 3 === 0
? wrapHue(palHue(2, timeMs, 14) + Math.sin(timeMs * 0.0012 + ringIdx * 0.9) * 16 + mid * 20)
: wrapHue(palHue(0, timeMs, 14) + Math.sin(timeMs * 0.0015 + ringIdx * 0.7) * 14 + bass * 22);
const alpha = (0.08 + fade * (0.24 + bass * 0.35)) * (0.05 + ringEnergy * 0.95);
const width = 1.6 + fade * (2.6 + bass * 3.5);
ctx.beginPath();
@@ -395,28 +498,31 @@
if (!ctx) return;
const veilA = ctx.createLinearGradient(0, 0, drawWidth, drawHeight);
veilA.addColorStop(0, `hsla(${wrapHue(356 + timeMs * 0.028)}, 100%, 46%, ${0.08 + bass * 0.16})`);
veilA.addColorStop(0, `hsla(${palHue(0, timeMs, 28)}, 100%, 46%, ${0.08 + bass * 0.16})`);
veilA.addColorStop(0.52, 'rgba(255, 48, 108, 0)');
veilA.addColorStop(1, `hsla(${wrapHue(204 + timeMs * 0.022)}, 100%, 50%, ${0.06 + high * 0.11})`);
veilA.addColorStop(1, `hsla(${palHue(1, timeMs, 22)}, 100%, 50%, ${0.06 + high * 0.11})`);
ctx.globalCompositeOperation = 'overlay';
ctx.fillStyle = veilA;
ctx.fillRect(0, 0, drawWidth, drawHeight);
const veilB = ctx.createLinearGradient(drawWidth, 0, 0, drawHeight);
veilB.addColorStop(0, `hsla(${wrapHue(196 + timeMs * 0.018)}, 90%, 48%, ${0.04 + high * 0.08})`);
veilB.addColorStop(0, `hsla(${palHue(2, timeMs, 18)}, 90%, 48%, ${0.04 + high * 0.08})`);
veilB.addColorStop(0.5, 'rgba(28, 8, 24, 0)');
veilB.addColorStop(1, `hsla(${wrapHue(10 + timeMs * 0.015)}, 92%, 44%, ${0.06 + bass * 0.12})`);
veilB.addColorStop(1, `hsla(${palHue(3, timeMs, 15)}, 92%, 44%, ${0.06 + bass * 0.12})`);
ctx.globalCompositeOperation = 'soft-light';
ctx.fillStyle = veilB;
ctx.fillRect(0, 0, drawWidth, drawHeight);
}
function drawAbyss(centerX: number, centerY: number, minDim: number, bass: number) {
function drawAbyss(centerX: number, centerY: number, minDim: number, bass: number, totalEnergy: number) {
if (!ctx) return;
const abyssRadius = minDim * (0.22 + bass * 0.08);
const abyss = ctx.createRadialGradient(centerX, centerY, minDim * 0.02, centerX, centerY, abyssRadius);
abyss.addColorStop(0, 'rgba(0, 0, 0, 0.98)');
abyss.addColorStop(0.45, `rgba(8, 0, 22, ${0.76 + bass * 0.1})`);
// Shrink black hole when silent, grow with bass
const energyScale = clamp01(totalEnergy * 2.5);
const abyssRadius = minDim * (0.04 + energyScale * 0.18 + bass * 0.08);
const abyssAlpha = 0.3 + energyScale * 0.68;
const abyss = ctx.createRadialGradient(centerX, centerY, minDim * 0.01, centerX, centerY, abyssRadius);
abyss.addColorStop(0, `rgba(0, 0, 0, ${abyssAlpha})`);
abyss.addColorStop(0.45, `rgba(8, 0, 22, ${(0.3 + energyScale * 0.46) + bass * 0.1})`);
abyss.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = abyss;
@@ -456,18 +562,20 @@
const mid = getMidEnergy();
const high = getHighEnergy();
phase += 0.004 + bass * 0.022 + high * 0.01;
lerpPalette();
if (beatCooldown > 0) beatCooldown -= 1;
drawFeedbackWarp(centerX, centerY, drawWidth, drawHeight, minDim, timestamp, bass, high);
ctx.fillStyle = `rgba(42, 0, 10, ${0.09 + high * 0.03})`;
ctx.fillStyle = `hsla(${wrapHue(artPalette[0] - 10)}, 60%, 6%, ${0.09 + high * 0.03})`;
ctx.fillRect(0, 0, drawWidth, drawHeight);
const totalEnergy = (bass + mid + high) / 3;
drawWatercolorClouds(centerX, centerY, minDim, timestamp, bass, mid, high);
drawWarpedTunnelRings(centerX, centerY, minDim, timestamp, bass, mid, high);
drawCurvedTunnelWisps(centerX, centerY, minDim, timestamp, bass, mid, high);
drawCurvedTunnelWisps(centerX, centerY, minDim, timestamp, bass, mid, high, totalEnergy);
drawRefractiveVeil(drawWidth, drawHeight, timestamp, bass, high);
drawAbyss(centerX, centerY, minDim, bass);
drawAbyss(centerX, centerY, minDim, bass, totalEnergy);
ctx.globalCompositeOperation = 'source-over';
animationFrame = requestAnimationFrame(render);
@@ -556,6 +664,12 @@
cleanup();
}
});
$effect(() => {
if (artwork) {
loadArtworkPalette(artwork);
}
});
</script>
<div class="comet-flow-panel" class:visible={enabled}>