697 lines
26 KiB
HTML
697 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>YouTube Music Visualizer</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;height:100vh;overflow:hidden}
|
|
#app{display:flex;flex-direction:column;height:100vh;padding:16px}
|
|
|
|
.top-bar{display:flex;gap:10px;align-items:center;z-index:10;flex-shrink:0}
|
|
.url-input{
|
|
flex:1;padding:11px 16px;
|
|
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
|
|
border-radius:8px;color:#fff;font-size:14px;outline:none;transition:border-color .2s;
|
|
}
|
|
.url-input:focus{border-color:rgba(138,43,226,.6)}
|
|
.url-input::placeholder{color:rgba(255,255,255,.3)}
|
|
|
|
.btn{
|
|
padding:11px 20px;border:none;border-radius:8px;
|
|
color:#fff;font-size:13px;font-weight:600;cursor:pointer;
|
|
transition:opacity .2s,transform .1s;white-space:nowrap;
|
|
}
|
|
.btn:hover{opacity:.88}
|
|
.btn:active{transform:scale(.97)}
|
|
.btn:disabled{opacity:.35;cursor:not-allowed}
|
|
.btn-primary{background:linear-gradient(135deg,#8a2be2,#4a00e0)}
|
|
|
|
.content{flex:1;position:relative;min-height:0;margin-top:12px;border-radius:12px;overflow:hidden}
|
|
canvas{position:absolute;top:0;left:0;width:100%;height:100%}
|
|
|
|
.splash{
|
|
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
|
text-align:center;z-index:2;pointer-events:none;
|
|
}
|
|
.splash h1{
|
|
font-size:2.4em;font-weight:700;
|
|
background:linear-gradient(135deg,#8a2be2,#e040fb);
|
|
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
|
|
margin-bottom:6px;
|
|
}
|
|
.splash p{color:rgba(255,255,255,.35);font-size:1em;line-height:1.6}
|
|
|
|
#player-wrap{
|
|
position:absolute;bottom:16px;right:16px;
|
|
width:320px;height:180px;
|
|
border-radius:10px;overflow:hidden;
|
|
box-shadow:0 8px 32px rgba(0,0,0,.7);
|
|
border:1px solid rgba(255,255,255,.08);
|
|
z-index:5;display:none;transition:opacity .3s;background:#000;
|
|
}
|
|
#player-wrap video{width:100%;height:100%;display:block}
|
|
#player-wrap.dragging{opacity:.7}
|
|
.drag-handle{
|
|
position:absolute;top:0;left:0;right:0;height:24px;cursor:grab;z-index:6;
|
|
background:linear-gradient(180deg,rgba(0,0,0,.5),transparent);
|
|
}
|
|
.drag-handle:active{cursor:grabbing}
|
|
.resize-handle{position:absolute;bottom:0;left:0;width:16px;height:16px;cursor:nesw-resize;z-index:6}
|
|
.resize-handle::after{
|
|
content:'';position:absolute;bottom:3px;left:3px;
|
|
width:8px;height:8px;border-left:2px solid rgba(255,255,255,.3);border-bottom:2px solid rgba(255,255,255,.3);
|
|
}
|
|
.title-bar{
|
|
position:absolute;bottom:0;left:0;right:0;
|
|
padding:4px 8px;background:rgba(0,0,0,.6);
|
|
font-size:11px;color:rgba(255,255,255,.6);
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;z-index:6;
|
|
}
|
|
|
|
.viz-modes{
|
|
position:absolute;bottom:16px;left:16px;
|
|
display:flex;flex-wrap:wrap;gap:5px;z-index:10;max-width:calc(100% - 360px);
|
|
}
|
|
.vbtn{
|
|
padding:6px 11px;background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.12);
|
|
border-radius:6px;color:rgba(255,255,255,.45);font-size:11px;
|
|
cursor:pointer;transition:all .2s;backdrop-filter:blur(4px);
|
|
}
|
|
.vbtn:hover{color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.25)}
|
|
.vbtn.active{background:rgba(138,43,226,.3);border-color:rgba(138,43,226,.5);color:#fff}
|
|
|
|
.status-pill{
|
|
position:absolute;top:12px;left:12px;padding:5px 12px;
|
|
background:rgba(0,0,0,.55);border-radius:20px;font-size:11px;
|
|
color:rgba(255,255,255,.4);z-index:10;display:none;
|
|
}
|
|
.status-pill.live{color:#4caf50}
|
|
.status-pill.live::before{
|
|
content:'';display:inline-block;width:6px;height:6px;
|
|
background:#4caf50;border-radius:50%;margin-right:6px;animation:blink 1.5s infinite;
|
|
}
|
|
.status-pill.loading{color:#ff9800}
|
|
.status-pill.loading::before{
|
|
content:'';display:inline-block;width:6px;height:6px;
|
|
background:#ff9800;border-radius:50%;margin-right:6px;animation:blink .6s infinite;
|
|
}
|
|
.status-pill.error{color:#f44336}
|
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="top-bar">
|
|
<input class="url-input" id="url-input" type="text"
|
|
placeholder="Paste a YouTube URL and press Enter..." />
|
|
<button class="btn btn-primary" id="load-btn">Load</button>
|
|
</div>
|
|
<div class="content" id="content">
|
|
<canvas id="cv"></canvas>
|
|
<div class="splash" id="splash">
|
|
<h1>Music Visualizer</h1>
|
|
<p>Paste a YouTube URL above and press Enter.</p>
|
|
</div>
|
|
<div class="status-pill" id="status"></div>
|
|
<div id="player-wrap">
|
|
<div class="drag-handle" id="drag-handle"></div>
|
|
<div class="resize-handle" id="resize-handle"></div>
|
|
<video id="vid" controls crossorigin="anonymous"></video>
|
|
<div class="title-bar" id="title-bar"></div>
|
|
</div>
|
|
<div class="viz-modes" id="viz-modes" style="display:none">
|
|
<button class="vbtn active" data-m="bars">Bars</button>
|
|
<button class="vbtn" data-m="circle">Circle</button>
|
|
<button class="vbtn" data-m="wave">Wave</button>
|
|
<button class="vbtn" data-m="particles">Particles</button>
|
|
<button class="vbtn" data-m="fractal">Fractal</button>
|
|
<button class="vbtn" data-m="horizon">Horizon</button>
|
|
<button class="vbtn" data-m="starburst">Supernova</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const TAU = Math.PI * 2;
|
|
|
|
/* ── Canvas ──────────────────────────────────────── */
|
|
const cv = $('cv'), c = cv.getContext('2d');
|
|
let W, H;
|
|
function resize() {
|
|
const dpr = devicePixelRatio || 1;
|
|
W = cv.clientWidth; H = cv.clientHeight;
|
|
cv.width = W * dpr; cv.height = H * dpr;
|
|
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
/* ── Status helper ───────────────────────────────── */
|
|
function setStatus(cls, text) {
|
|
const s = $('status');
|
|
s.style.display = text ? 'block' : 'none';
|
|
s.className = 'status-pill' + (cls ? ' ' + cls : '');
|
|
s.textContent = text || '';
|
|
}
|
|
|
|
/* ── Audio analysis ──────────────────────────────── */
|
|
let actx, analyser, freq, time, bufLen;
|
|
let vizMode = 'bars', animId, running = false;
|
|
let particles = [];
|
|
const vid = $('vid');
|
|
|
|
function connectAudio() {
|
|
if (actx) return;
|
|
actx = new AudioContext();
|
|
const src = actx.createMediaElementSource(vid);
|
|
analyser = actx.createAnalyser();
|
|
analyser.fftSize = 2048;
|
|
analyser.smoothingTimeConstant = 0.82;
|
|
src.connect(analyser);
|
|
analyser.connect(actx.destination);
|
|
bufLen = analyser.frequencyBinCount;
|
|
freq = new Uint8Array(bufLen);
|
|
time = new Uint8Array(bufLen);
|
|
}
|
|
|
|
function startViz() {
|
|
if (running) return;
|
|
connectAudio();
|
|
if (actx.state === 'suspended') actx.resume();
|
|
running = true;
|
|
particles = [];
|
|
$('viz-modes').style.display = 'flex';
|
|
setStatus('live', 'Visualizing');
|
|
resize();
|
|
draw();
|
|
}
|
|
|
|
function stopViz() {
|
|
running = false;
|
|
if (animId) cancelAnimationFrame(animId);
|
|
$('viz-modes').style.display = 'none';
|
|
setStatus('', '');
|
|
c.clearRect(0, 0, W, H);
|
|
}
|
|
|
|
/* ── Load video via backend ──────────────────────── */
|
|
async function loadVideo(youtubeUrl) {
|
|
setStatus('loading', 'Resolving stream...');
|
|
$('load-btn').disabled = true;
|
|
try {
|
|
const resp = await fetch('/music/api/resolve?url=' + encodeURIComponent(youtubeUrl));
|
|
const data = await resp.json();
|
|
if (data.error) throw new Error(data.error);
|
|
$('splash').style.display = 'none';
|
|
$('player-wrap').style.display = 'block';
|
|
$('title-bar').textContent = data.title || '';
|
|
vid.src = data.stream;
|
|
vid.load();
|
|
vid.play().catch(() => {});
|
|
vid.addEventListener('playing', function once() {
|
|
vid.removeEventListener('playing', once);
|
|
startViz();
|
|
});
|
|
setStatus('live', 'Playing');
|
|
} catch (e) {
|
|
setStatus('error', 'Error: ' + e.message.slice(0, 120));
|
|
console.error(e);
|
|
} finally {
|
|
$('load-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
/* ── UI events ───────────────────────────────────── */
|
|
$('load-btn').addEventListener('click', () => {
|
|
const url = $('url-input').value.trim();
|
|
if (!url) return;
|
|
loadVideo(url);
|
|
});
|
|
$('url-input').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') $('load-btn').click();
|
|
});
|
|
|
|
document.querySelectorAll('.vbtn').forEach(b => {
|
|
b.addEventListener('click', () => {
|
|
document.querySelectorAll('.vbtn').forEach(x => x.classList.remove('active'));
|
|
b.classList.add('active');
|
|
vizMode = b.dataset.m;
|
|
if (vizMode === 'particles') particles = [];
|
|
if (vizMode === 'starburst') { shockwaves.length = 0; novaParticles.length = 0; }
|
|
c.fillStyle = 'rgba(10,10,15,1)';
|
|
c.fillRect(0, 0, W, H);
|
|
});
|
|
});
|
|
|
|
/* ── Drag / resize player ────────────────────────── */
|
|
(function() {
|
|
const pw = $('player-wrap'), dh = $('drag-handle'), rh = $('resize-handle');
|
|
let dragging = false, resizing = false, ox, oy, ow, oh;
|
|
dh.addEventListener('mousedown', e => {
|
|
dragging = true; pw.classList.add('dragging');
|
|
ox = e.clientX - pw.offsetLeft; oy = e.clientY - pw.offsetTop; e.preventDefault();
|
|
});
|
|
rh.addEventListener('mousedown', e => {
|
|
resizing = true; ox = e.clientX; oy = e.clientY;
|
|
ow = pw.offsetWidth; oh = pw.offsetHeight; e.preventDefault();
|
|
});
|
|
window.addEventListener('mousemove', e => {
|
|
if (dragging) {
|
|
pw.style.left = (e.clientX - ox) + 'px'; pw.style.top = (e.clientY - oy) + 'px';
|
|
pw.style.right = 'auto'; pw.style.bottom = 'auto';
|
|
}
|
|
if (resizing) {
|
|
pw.style.width = Math.max(200, ow - (e.clientX - ox)) + 'px';
|
|
pw.style.height = Math.max(120, oh + (e.clientY - oy)) + 'px';
|
|
}
|
|
});
|
|
window.addEventListener('mouseup', () => { dragging = false; resizing = false; pw.classList.remove('dragging'); });
|
|
})();
|
|
|
|
/* ── Helpers ──────────────────────────────────────── */
|
|
function avg(lo, hi) {
|
|
const s = Math.floor(lo * bufLen), e = Math.floor(hi * bufLen);
|
|
let sum = 0; for (let i = s; i < e; i++) sum += freq[i];
|
|
return sum / (e - s || 1) / 255;
|
|
}
|
|
let T = 0; // global time in seconds
|
|
|
|
/* ── Fade amounts per mode ───────────────────────── */
|
|
const FADE = {
|
|
bars:.18, circle:.18, wave:.18, particles:.12,
|
|
fractal:.28, horizon:.06, starburst:.08,
|
|
};
|
|
|
|
/* ── Draw loop ───────────────────────────────────── */
|
|
function draw() {
|
|
if (!running) return;
|
|
animId = requestAnimationFrame(draw);
|
|
analyser.getByteFrequencyData(freq);
|
|
analyser.getByteTimeDomainData(time);
|
|
T = performance.now() / 1000;
|
|
|
|
const fade = FADE[vizMode] ?? .18;
|
|
c.fillStyle = `rgba(10,10,15,${fade})`;
|
|
c.fillRect(0, 0, W, H);
|
|
|
|
switch (vizMode) {
|
|
case 'bars': drawBars(); break;
|
|
case 'circle': drawCircle(); break;
|
|
case 'wave': drawWave(); break;
|
|
case 'particles': drawParticles(); break;
|
|
case 'fractal': drawFractal(); break;
|
|
case 'horizon': drawHorizon(); break;
|
|
case 'starburst': drawStarburst(); break;
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════
|
|
CLASSIC MODES
|
|
═══════════════════════════════════════════════════ */
|
|
|
|
/* ── Bars ─────────────────────────────────────────── */
|
|
function drawBars() {
|
|
const n = 80, bw = W / n - 2, step = Math.floor(bufLen / n);
|
|
for (let i = 0; i < n; i++) {
|
|
let s = 0;
|
|
for (let j = 0; j < step; j++) s += freq[i * step + j];
|
|
const v = s / step / 255, bh = v * H * .88, hue = 260 + v * 70, x = i * (bw + 2);
|
|
const g = c.createLinearGradient(x, H, x, H - bh);
|
|
g.addColorStop(0, `hsla(${hue},80%,60%,.92)`);
|
|
g.addColorStop(1, `hsla(${hue+30},70%,35%,.25)`);
|
|
c.fillStyle = g; c.fillRect(x, H - bh, bw, bh);
|
|
c.shadowColor = `hsla(${hue},80%,60%,.5)`; c.shadowBlur = 12;
|
|
c.fillRect(x, H - bh, bw, 2); c.shadowBlur = 0;
|
|
}
|
|
c.save(); c.globalAlpha = .08; c.scale(1, -1); c.translate(0, -H * 2);
|
|
for (let i = 0; i < n; i++) {
|
|
let s = 0; for (let j = 0; j < step; j++) s += freq[i * step + j];
|
|
const v = s / step / 255, bh = v * H * .88, x = i * (bw + 2);
|
|
c.fillStyle = `hsla(${260+v*70},80%,60%,.5)`; c.fillRect(x, H - bh, bw, bh);
|
|
}
|
|
c.restore();
|
|
}
|
|
|
|
/* ── Circle ───────────────────────────────────────── */
|
|
function drawCircle() {
|
|
const cx = W/2, cy = H/2, R = Math.min(W,H)*.18, n = 180, step = Math.floor(bufLen/n);
|
|
for (let i = 0; i < n; i++) {
|
|
let s = 0; for (let j = 0; j < step; j++) s += freq[i*step+j];
|
|
const v = s/step/255, a = (i/n)*TAU - Math.PI/2;
|
|
const r2 = R + v*Math.min(W,H)*.32, hue = 260+(i/n)*100;
|
|
c.beginPath();
|
|
c.moveTo(cx+Math.cos(a)*R, cy+Math.sin(a)*R);
|
|
c.lineTo(cx+Math.cos(a)*r2, cy+Math.sin(a)*r2);
|
|
c.strokeStyle = `hsla(${hue},80%,60%,${.25+v*.75})`; c.lineWidth = 1.5; c.stroke();
|
|
}
|
|
c.beginPath(); c.arc(cx,cy,R,0,TAU);
|
|
c.strokeStyle = 'rgba(138,43,226,.15)'; c.lineWidth = 1; c.stroke();
|
|
const bass = avg(0,.08);
|
|
const rg = c.createRadialGradient(cx,cy,0,cx,cy,R*(1+bass));
|
|
rg.addColorStop(0, `hsla(280,80%,60%,${bass*.35})`); rg.addColorStop(1, 'transparent');
|
|
c.fillStyle = rg; c.fillRect(0,0,W,H);
|
|
}
|
|
|
|
/* ── Wave ─────────────────────────────────────────── */
|
|
function drawWave() {
|
|
const mid = H/2;
|
|
c.beginPath(); c.moveTo(0, mid);
|
|
for (let i = 0; i < bufLen; i++) c.lineTo((i/bufLen)*W, mid - (freq[i]/255)*mid*.85);
|
|
c.lineTo(W, mid);
|
|
const fg = c.createLinearGradient(0,0,0,H);
|
|
fg.addColorStop(0, 'hsla(280,80%,55%,.30)'); fg.addColorStop(.5, 'hsla(260,80%,40%,.03)');
|
|
c.fillStyle = fg; c.fill();
|
|
c.beginPath();
|
|
for (let i = 0; i < bufLen; i++) { const x=(i/bufLen)*W, y=(time[i]/128)*mid; i===0?c.moveTo(x,y):c.lineTo(x,y); }
|
|
c.strokeStyle = 'hsla(280,90%,70%,.85)'; c.lineWidth = 2; c.stroke();
|
|
c.beginPath();
|
|
for (let i = 0; i < bufLen; i++) { const x=(i/bufLen)*W, y=H-(time[i]/128)*mid; i===0?c.moveTo(x,y):c.lineTo(x,y); }
|
|
c.strokeStyle = 'hsla(300,90%,65%,.25)'; c.lineWidth = 1; c.stroke();
|
|
}
|
|
|
|
/* ── Particles ────────────────────────────────────── */
|
|
function initParticles() {
|
|
particles = [];
|
|
for (let i = 0; i < 250; i++) particles.push({
|
|
x:Math.random()*W, y:Math.random()*H,
|
|
vx:(Math.random()-.5)*1.5, vy:(Math.random()-.5)*1.5,
|
|
s:Math.random()*2.5+.8, hue:Math.random()*80+250,
|
|
});
|
|
}
|
|
function drawParticles() {
|
|
if (!particles.length) initParticles();
|
|
const bass=avg(0,.05), mid2=avg(.05,.2), treb=avg(.2,.5), energy=(bass*2+mid2+treb)/4;
|
|
for (const p of particles) {
|
|
p.vx+=(Math.random()-.5)*bass*4; p.vy+=(Math.random()-.5)*bass*4;
|
|
p.vx*=.95; p.vy*=.95; p.x+=p.vx; p.y+=p.vy;
|
|
if(p.x<0)p.x+=W;if(p.x>W)p.x-=W;if(p.y<0)p.y+=H;if(p.y>H)p.y-=H;
|
|
const sz=p.s*(1+energy*3.5);
|
|
c.beginPath(); c.arc(p.x,p.y,sz,0,TAU);
|
|
c.fillStyle=`hsla(${p.hue+bass*50},80%,60%,${.25+energy*.7})`; c.fill();
|
|
}
|
|
if (energy > .2) {
|
|
const maxD=70+energy*80, maxD2=maxD*maxD;
|
|
for (let i=0;i<particles.length;i++) { const a=particles[i];
|
|
for (let j=i+1;j<particles.length;j++) { const b=particles[j];
|
|
const dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy;
|
|
if(d2<maxD2){c.beginPath();c.moveTo(a.x,a.y);c.lineTo(b.x,b.y);
|
|
c.strokeStyle=`hsla(280,60%,60%,${(1-Math.sqrt(d2)/maxD)*.12})`;c.lineWidth=.5;c.stroke();}
|
|
}}
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════
|
|
NEW MODES
|
|
═══════════════════════════════════════════════════ */
|
|
|
|
/* ── Fractal Tree ─────────────────────────────────── */
|
|
function drawFractal() {
|
|
const bass = avg(0,.06), mid = avg(.06,.2), treb = avg(.2,.5);
|
|
const energy = (bass + mid + treb) / 3;
|
|
const maxDepth = 7 + Math.floor(energy * 5); // 7-12 deep with energy
|
|
const baseLen = H * 0.24 * (.5 + bass * .8);
|
|
|
|
c.save();
|
|
c.globalCompositeOperation = 'lighter';
|
|
|
|
function branch(x, y, len, ang, depth) {
|
|
if (depth >= maxDepth || len < 2) return;
|
|
const ex = x + Math.cos(ang) * len;
|
|
const ey = y + Math.sin(ang) * len;
|
|
const ratio = depth / maxDepth;
|
|
const hue = 270 + ratio * 90 + Math.sin(T * 2 + depth) * 15;
|
|
const lum = 40 + energy * 30;
|
|
const alpha = (1 - ratio * .5) * (.25 + energy * .75);
|
|
|
|
c.beginPath(); c.moveTo(x, y); c.lineTo(ex, ey);
|
|
c.strokeStyle = `hsla(${hue},85%,${lum}%,${alpha})`;
|
|
c.lineWidth = Math.max(.4, (maxDepth - depth) * .9);
|
|
c.shadowColor = `hsla(${hue},90%,55%,${alpha * .7})`;
|
|
c.shadowBlur = 5 + energy * 12;
|
|
c.stroke();
|
|
|
|
// per-depth freq lookup for variation
|
|
const fi = Math.floor(ratio * bufLen * .3);
|
|
const fv = (freq[fi] || 0) / 255;
|
|
const spread = .35 + mid * .55 + fv * .25;
|
|
const sway = Math.sin(T * 1.3 + depth * .6) * .12 * (1 + bass * 2);
|
|
const shrink = .6 + treb * .12;
|
|
|
|
branch(ex, ey, len * shrink, ang - spread + sway, depth + 1);
|
|
branch(ex, ey, len * shrink, ang + spread + sway, depth + 1);
|
|
|
|
// bright tips
|
|
if (depth >= maxDepth - 2) {
|
|
c.beginPath(); c.arc(ex, ey, 1 + fv * 3, 0, TAU);
|
|
c.fillStyle = `hsla(${hue + 40},90%,70%,${fv * .8})`;
|
|
c.fill();
|
|
}
|
|
}
|
|
|
|
// two mirrored trees
|
|
branch(W / 2, H, baseLen, -Math.PI / 2, 0);
|
|
|
|
c.shadowBlur = 0;
|
|
c.restore();
|
|
|
|
// ground glow
|
|
const gg = c.createRadialGradient(W/2, H, 0, W/2, H, W * .4);
|
|
gg.addColorStop(0, `hsla(280,80%,40%,${.06 + bass * .1})`);
|
|
gg.addColorStop(1, 'transparent');
|
|
c.fillStyle = gg; c.fillRect(0, 0, W, H);
|
|
}
|
|
|
|
/* ── Horizon (Tunnel + Terrain combined, soft color washes) ── */
|
|
function drawHorizon() {
|
|
const bass = avg(0,.06), mid = avg(.06,.2), treb = avg(.2,.5);
|
|
const energy = (bass + mid + treb) / 3;
|
|
const hz = H * .40; // horizon line
|
|
const cx = W / 2;
|
|
|
|
c.save();
|
|
c.globalCompositeOperation = 'lighter';
|
|
|
|
/* ─── sky: soft aurora wash ─── */
|
|
const skyG = c.createLinearGradient(0, 0, 0, hz);
|
|
skyG.addColorStop(0, `hsla(260,50%,6%,.6)`);
|
|
skyG.addColorStop(1, `hsla(300,60%,18%,${.12 + energy * .18})`);
|
|
c.fillStyle = skyG; c.fillRect(0, 0, W, hz);
|
|
|
|
/* ─── sun ─── */
|
|
const sunR = 55 + bass * 30;
|
|
const sunY = hz - sunR * .4;
|
|
// soft filled disc
|
|
const sd = c.createRadialGradient(cx, sunY, 0, cx, sunY, sunR);
|
|
sd.addColorStop(0, `hsla(330,90%,70%,${.55 + treb * .3})`);
|
|
sd.addColorStop(.6, `hsla(310,85%,55%,${.25 + treb * .15})`);
|
|
sd.addColorStop(1, 'hsla(290,80%,40%,0)');
|
|
c.fillStyle = sd; c.fillRect(cx - sunR, sunY - sunR, sunR * 2, sunR * 2);
|
|
// wide halo
|
|
const halo = c.createRadialGradient(cx, sunY, sunR * .3, cx, sunY, sunR * 4);
|
|
halo.addColorStop(0, `hsla(310,80%,55%,${.06 + bass * .1})`);
|
|
halo.addColorStop(1, 'transparent');
|
|
c.fillStyle = halo; c.fillRect(0, 0, W, H);
|
|
|
|
/* ─── tunnel rings expanding from horizon ─── */
|
|
const rings = 22;
|
|
const maxR = Math.hypot(W, H) * .6;
|
|
for (let i = rings; i >= 0; i--) {
|
|
const phase = ((T * (.15 + bass * .35) + i / rings) % 1);
|
|
const radius = phase * maxR;
|
|
const fi = Math.floor((i / rings) * bufLen * .35);
|
|
const fv = (freq[fi] || 0) / 255;
|
|
const hue = 270 + i * 8 + T * 35;
|
|
const life = (1 - phase);
|
|
const alpha = life * (.04 + fv * .25);
|
|
const rot = T * (.12 + mid * .3) + i * .09;
|
|
|
|
c.beginPath();
|
|
// draw as ellipses squashed vertically to hug the terrain
|
|
for (let s = 0; s <= 64; s++) {
|
|
const a = (s / 64) * TAU + rot;
|
|
const rx = radius * (1 + fv * .12);
|
|
const ry = radius * .45 * (1 + fv * .12); // squashed
|
|
const x = cx + Math.cos(a) * rx;
|
|
const y = hz + Math.sin(a) * ry - radius * .15; // center drifts up as it expands
|
|
s === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
|
|
}
|
|
c.closePath();
|
|
c.strokeStyle = `hsla(${hue},75%,55%,${alpha})`;
|
|
c.lineWidth = 4 + fv * 14 + life * 6;
|
|
c.shadowColor = `hsla(${hue},85%,50%,${alpha * .8})`;
|
|
c.shadowBlur = 25 + fv * 35;
|
|
c.stroke();
|
|
}
|
|
|
|
/* ─── terrain: wide soft color bands ─── */
|
|
const numBands = 24;
|
|
const segs = 64;
|
|
const groundH = H - hz;
|
|
|
|
for (let i = 0; i < numBands; i++) {
|
|
const t = i / numBands; // 0=near, 1=far
|
|
const persp = Math.pow(t, 2.0);
|
|
const y = H - groundH * (1 - persp);
|
|
const nextPersp = Math.pow((i + 1) / numBands, 2.0);
|
|
const nextY = H - groundH * (1 - nextPersp);
|
|
const bandH = Math.abs(nextY - y);
|
|
const amplitude = (1 - persp) * 80 * (.35 + bass * 1.3);
|
|
const alpha = (1 - t * .6) * (.12 + mid * .35);
|
|
|
|
c.beginPath();
|
|
// top edge (audio-displaced)
|
|
for (let s = 0; s <= segs; s++) {
|
|
const sx = (s / segs) * W;
|
|
const fi2 = Math.min(Math.floor((s / segs) * bufLen * .35) + i * 3, bufLen - 1);
|
|
const fv = (freq[fi2] || 0) / 255;
|
|
const py = y - fv * amplitude;
|
|
s === 0 ? c.moveTo(sx, py) : c.lineTo(sx, py);
|
|
}
|
|
// bottom edge (close the band)
|
|
for (let s = segs; s >= 0; s--) {
|
|
const sx = (s / segs) * W;
|
|
const fi2 = Math.min(Math.floor((s / segs) * bufLen * .35) + (i + 1) * 3, bufLen - 1);
|
|
const fv = (freq[fi2] || 0) / 255;
|
|
const py = nextY - fv * (1 - nextPersp) * 80 * (.35 + bass * 1.3);
|
|
c.lineTo(sx, py);
|
|
}
|
|
c.closePath();
|
|
|
|
// gradient fill — near=cyan, far=magenta
|
|
const hue = 190 + t * 110;
|
|
const bandG = c.createLinearGradient(0, y - amplitude, 0, y + bandH);
|
|
bandG.addColorStop(0, `hsla(${hue},80%,55%,${alpha * 1.2})`);
|
|
bandG.addColorStop(1, `hsla(${hue + 15},70%,40%,${alpha * .3})`);
|
|
c.fillStyle = bandG;
|
|
c.shadowColor = `hsla(${hue},85%,50%,${alpha * .6})`;
|
|
c.shadowBlur = 18 + (1 - t) * 20;
|
|
c.fill();
|
|
}
|
|
|
|
/* ─── vertical perspective lines (soft) ─── */
|
|
const vLines = 16;
|
|
for (let i = 0; i <= vLines; i++) {
|
|
const xn = i / vLines;
|
|
const topX = cx + (xn - .5) * W * .12;
|
|
const botX = xn * W * 1.4 - W * .2;
|
|
const fi3 = Math.min(Math.floor(xn * bufLen * .3), bufLen - 1);
|
|
const fv = (freq[fi3] || 0) / 255;
|
|
|
|
c.beginPath();
|
|
c.moveTo(topX, hz); c.lineTo(botX, H);
|
|
c.strokeStyle = `hsla(${220 + fv * 60},60%,50%,${.03 + fv * .08})`;
|
|
c.lineWidth = 2 + fv * 5;
|
|
c.shadowColor = `hsla(${220 + fv * 60},70%,45%,${.04 + fv * .08})`;
|
|
c.shadowBlur = 15 + fv * 15;
|
|
c.stroke();
|
|
}
|
|
|
|
/* ─── horizon glow ─── */
|
|
c.beginPath(); c.moveTo(0, hz); c.lineTo(W, hz);
|
|
c.strokeStyle = `hsla(300,75%,60%,${.1 + energy * .2})`;
|
|
c.lineWidth = 3 + energy * 4;
|
|
c.shadowColor = `hsla(300,80%,55%,.35)`;
|
|
c.shadowBlur = 30 + energy * 20;
|
|
c.stroke();
|
|
|
|
c.shadowBlur = 0;
|
|
c.restore();
|
|
}
|
|
|
|
/* ── Supernova / Starburst ────────────────────────── */
|
|
let shockwaves = []; // {r, maxR, hue, birth}
|
|
let novaParticles = []; // {x,y,vx,vy,life,hue,s}
|
|
let prevBass = 0;
|
|
|
|
function drawStarburst() {
|
|
const bass = avg(0,.06), mid = avg(.06,.2), treb = avg(.2,.5);
|
|
const energy = (bass + mid + treb) / 3;
|
|
const cx = W / 2, cy = H / 2;
|
|
|
|
c.save();
|
|
c.globalCompositeOperation = 'lighter';
|
|
|
|
// detect bass hit → spawn shockwave + particles
|
|
if (bass > .45 && bass - prevBass > .08) {
|
|
shockwaves.push({ r: 10, maxR: Math.min(W,H) * (.3 + bass * .4), hue: 260 + Math.random() * 80, birth: T });
|
|
for (let i = 0; i < 30; i++) {
|
|
const a = Math.random() * TAU;
|
|
const spd = 2 + Math.random() * 6 * bass;
|
|
novaParticles.push({
|
|
x: cx, y: cy,
|
|
vx: Math.cos(a) * spd, vy: Math.sin(a) * spd,
|
|
life: 1, hue: 250 + Math.random() * 80, s: 1 + Math.random() * 3,
|
|
});
|
|
}
|
|
}
|
|
prevBass = bass;
|
|
|
|
// radiating beams
|
|
const beams = 90;
|
|
const step = Math.floor(bufLen / beams);
|
|
for (let i = 0; i < beams; i++) {
|
|
let s = 0;
|
|
for (let j = 0; j < step; j++) s += freq[i * step + j];
|
|
const v = s / step / 255;
|
|
const a = (i / beams) * TAU + T * .15;
|
|
const len = v * Math.min(W, H) * .45;
|
|
const hue = 260 + (i / beams) * 100 + T * 20;
|
|
|
|
c.beginPath();
|
|
c.moveTo(cx, cy);
|
|
c.lineTo(cx + Math.cos(a) * len, cy + Math.sin(a) * len);
|
|
c.strokeStyle = `hsla(${hue},85%,60%,${.08 + v * .5})`;
|
|
c.lineWidth = .5 + v * 2.5;
|
|
c.shadowColor = `hsla(${hue},90%,55%,${v * .5})`;
|
|
c.shadowBlur = 4 + v * 10;
|
|
c.stroke();
|
|
}
|
|
|
|
// shockwave rings
|
|
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
|
const sw = shockwaves[i];
|
|
sw.r += (3 + energy * 8);
|
|
const life = 1 - sw.r / sw.maxR;
|
|
if (life <= 0) { shockwaves.splice(i, 1); continue; }
|
|
c.beginPath(); c.arc(cx, cy, sw.r, 0, TAU);
|
|
c.strokeStyle = `hsla(${sw.hue},90%,65%,${life * .7})`;
|
|
c.lineWidth = 2 + life * 4;
|
|
c.shadowColor = `hsla(${sw.hue},90%,60%,${life * .5})`;
|
|
c.shadowBlur = 10 + life * 20;
|
|
c.stroke();
|
|
}
|
|
|
|
// flying particles
|
|
for (let i = novaParticles.length - 1; i >= 0; i--) {
|
|
const p = novaParticles[i];
|
|
p.x += p.vx; p.y += p.vy;
|
|
p.vx *= .98; p.vy *= .98;
|
|
p.life -= .012;
|
|
if (p.life <= 0) { novaParticles.splice(i, 1); continue; }
|
|
const sz = p.s * p.life;
|
|
c.beginPath(); c.arc(p.x, p.y, sz, 0, TAU);
|
|
c.fillStyle = `hsla(${p.hue},85%,65%,${p.life * .8})`;
|
|
c.shadowColor = `hsla(${p.hue},90%,55%,${p.life * .4})`;
|
|
c.shadowBlur = 6;
|
|
c.fill();
|
|
}
|
|
|
|
// core glow
|
|
const coreSize = 15 + energy * 50;
|
|
const cg = c.createRadialGradient(cx, cy, 0, cx, cy, coreSize);
|
|
cg.addColorStop(0, `hsla(280,90%,90%,${.3 + energy * .5})`);
|
|
cg.addColorStop(.3, `hsla(280,80%,60%,${.15 + energy * .25})`);
|
|
cg.addColorStop(1, 'transparent');
|
|
c.fillStyle = cg; c.fillRect(cx - coreSize, cy - coreSize, coreSize * 2, coreSize * 2);
|
|
|
|
c.shadowBlur = 0;
|
|
c.restore();
|
|
}
|
|
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|