feat: music visualizer (#3)

This commit is contained in:
zacheryasc 2026-02-06 19:20:48 +00:00
parent be53e9a52e
commit e3f0276c33
4 changed files with 1683 additions and 13 deletions

818
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,3 +17,5 @@ message-tools = { path = "../message-tools" }
getrandom = "0.3.4"
lazy_static = "1.5.0"
serde = { version = "1.0.228", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
futures = "0.3"

View file

@ -1,17 +1,23 @@
use axum::{
Json, Router,
http::StatusCode,
body::Body,
extract::{Path as AxumPath, Query, State},
http::{HeaderMap, StatusCode},
response::Html,
routing::{get, post},
};
use futures::TryStreamExt;
use message_tools::BoxedMessage;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs::OpenOptions,
io::{BufRead, Read},
path::{Path, PathBuf},
sync::Arc,
};
use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock;
use tower_http::services::ServeDir;
const PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
@ -24,6 +30,8 @@ lazy_static::lazy_static! {
type Error = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Error>;
type UrlCache = Arc<RwLock<HashMap<String, String>>>;
fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
// Open the file
let f = OpenOptions::new().read(true).write(false).open(path)?;
@ -57,8 +65,174 @@ fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
Ok(public_keys)
}
#[derive(Deserialize)]
struct ResolveQuery {
url: String,
}
#[derive(Serialize)]
struct ResolveResponse {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
async fn resolve_handler(
State(cache): State<UrlCache>,
Query(params): Query<ResolveQuery>,
) -> Json<ResolveResponse> {
let output = tokio::process::Command::new("yt-dlp")
.args([
"-j",
"-f", "best[ext=mp4][protocol=https]/best[ext=mp4][protocol=http]/best[protocol=https]/best[protocol=http]/best",
"--no-playlist",
&params.url,
])
.output()
.await;
let output = match output {
Ok(o) => o,
Err(e) => {
return Json(ResolveResponse {
id: None,
title: None,
thumbnail: None,
duration: None,
stream: None,
error: Some(format!("Failed to run yt-dlp: {e}")),
});
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Json(ResolveResponse {
id: None,
title: None,
thumbnail: None,
duration: None,
stream: None,
error: Some(format!("yt-dlp failed: {stderr}")),
});
}
let info: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => {
return Json(ResolveResponse {
id: None,
title: None,
thumbnail: None,
duration: None,
stream: None,
error: Some(format!("Failed to parse yt-dlp output: {e}")),
});
}
};
let video_id = info["id"].as_str().unwrap_or("unknown").to_string();
let title = info["title"].as_str().map(|s| s.to_string());
let thumbnail = info["thumbnail"].as_str().map(|s| s.to_string());
let duration = info["duration"].as_f64();
let stream_url = info["url"].as_str().unwrap_or("").to_string();
if stream_url.is_empty() {
return Json(ResolveResponse {
id: None,
title: None,
thumbnail: None,
duration: None,
stream: None,
error: Some("No stream URL found".to_string()),
});
}
cache.write().await.insert(video_id.clone(), stream_url);
Json(ResolveResponse {
id: Some(video_id.clone()),
title,
thumbnail,
duration,
stream: Some(format!("/music/api/stream/{video_id}")),
error: None,
})
}
async fn stream_handler(
State(cache): State<UrlCache>,
AxumPath(id): AxumPath<String>,
headers: HeaderMap,
) -> axum::response::Response<Body> {
let stream_url = {
let map = cache.read().await;
map.get(&id).cloned()
};
let stream_url = match stream_url {
Some(u) => u,
None => {
return axum::response::Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Stream not found"))
.unwrap();
}
};
let client = reqwest::Client::new();
let mut req = client.get(&stream_url);
if let Some(range) = headers.get("range") {
req = req.header("Range", range);
}
let upstream = match req.send().await {
Ok(r) => r,
Err(e) => {
return axum::response::Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::from(format!("Upstream error: {e}")))
.unwrap();
}
};
let status = upstream.status();
let mut response = axum::response::Response::builder().status(status.as_u16());
for key in ["content-type", "content-length", "content-range", "accept-ranges"] {
if let Some(val) = upstream.headers().get(key) {
response = response.header(key, val);
}
}
let stream = upstream
.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
let body = Body::from_stream(stream);
response.body(body).unwrap()
}
#[tokio::main]
async fn main() -> Result<()> {
let url_cache: UrlCache = Arc::new(RwLock::new(HashMap::new()));
let music_routes = Router::new()
.route("/", get(serve_path("./routes/music/index.html")?))
.route("/api/resolve", get(resolve_handler))
.route("/api/stream/{id}", get(stream_handler))
.with_state(url_cache);
// build our application with a single route
let app = Router::new()
.route("/", get(serve_path("./routes/root/index.html")?))
@ -67,6 +241,7 @@ async fn main() -> Result<()> {
.route("/contact", get(serve_path("./routes/contact/index.html")?))
.route("/contact/message", get(serve_path("./routes/contact/message/index.html")?))
.route("/api/publish", post(publish_message))
.nest("/music", music_routes)
.nest_service("/routes", ServeDir::new("./routes"));
let addr = "0.0.0.0:3000";

697
routes/music/index.html Normal file
View file

@ -0,0 +1,697 @@
<!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>