Files
u-desktop/web/themes/audio-viz.html

1276 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<canvas id="canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;"></canvas>
<script>
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
// === 状态 ===
var audioBins = new Array(128).fill(0);
var vizStyle = window.__audioVizStyle || 'bars';
var sensitivity = window.__audioVizSensitivity || 1.0;
var colorScheme = window.__audioVizColors || 'neon';
var idleTime = 0;
// === 峰值:重力模型 ===
var peaks = new Array(128).fill(0);
var peakVel = new Array(128).fill(0);
var PEAK_GRAVITY = 0.3;
var PEAK_FRICTION = 0.85;
window.setAudioVizStyle = function(s) { vizStyle = s; };
window.setAudioSensitivity = function(s) { sensitivity = s; };
window.setAudioColorScheme = function(s) { colorScheme = s; colorTable = getGradient(128); };
window.updateAudioDataFromGo = function(data) {
for (var i = 0; i < Math.min(data.length, audioBins.length); i++) {
audioBins[i] = data[i];
}
};
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// === 配色系统 ===
function getGradient(n) {
var stops = [];
switch (colorScheme) {
case 'ocean':
stops = [
{pos:0, h:185, s:85, l:60}, {pos:0.3, h:200, s:80, l:55},
{pos:0.6, h:215, s:75, l:55}, {pos:1, h:245, s:70, l:60}
]; break;
case 'fire':
stops = [
{pos:0, h:5, s:100, l:58}, {pos:0.3, h:20, s:95, l:53},
{pos:0.6, h:38, s:88, l:50}, {pos:1, h:55, s:82, l:48}
]; break;
case 'rainbow':
stops = [
{pos:0, h:340, s:85, l:60}, {pos:0.17, h:15, s:90, l:55},
{pos:0.33, h:45, s:85, l:50}, {pos:0.5, h:160, s:80, l:50},
{pos:0.67, h:200, s:85, l:55}, {pos:0.83, h:260, s:80, l:60},
{pos:1, h:310, s:85, l:60}
]; break;
case 'cyber':
stops = [
{pos:0, h:170, s:100, l:50}, {pos:0.3, h:190, s:95, l:55},
{pos:0.6, h:280, s:90, l:60}, {pos:0.85, h:310, s:95, l:55},
{pos:1, h:340, s:90, l:55}
]; break;
default: // neon
stops = [
{pos:0, h:180, s:100, l:55}, {pos:0.25, h:210, s:95, l:55},
{pos:0.5, h:260, s:90, l:60}, {pos:0.75, h:290, s:95, l:60},
{pos:1, h:320, s:90, l:60}
];
}
var result = [];
for (var i = 0; i < n; i++) {
var t = i / (n - 1);
var lo = stops[0], hi = stops[stops.length - 1];
for (var j = 0; j < stops.length - 1; j++) {
if (t >= stops[j].pos && t <= stops[j+1].pos) { lo = stops[j]; hi = stops[j+1]; break; }
}
var range = hi.pos - lo.pos;
var f = range > 0 ? (t - lo.pos) / range : 0;
result.push({ h: lo.h + (hi.h - lo.h) * f, s: lo.s + (hi.s - lo.s) * f, l: lo.l + (hi.l - lo.l) * f });
}
return result;
}
var colorTable = getGradient(128);
function hsl(i, a) { var c = colorTable[Math.abs(i) % 128]; return 'hsla(' + c.h + ',' + c.s + '%,' + c.l + '%,' + (a||1) + ')'; }
function hslBright(i, a) { var c = colorTable[Math.abs(i) % 128]; return 'hsla(' + c.h + ',' + c.s + '%,' + Math.min(95, c.l + 30) + '%,' + (a||1) + ')'; }
function hslDim(i, a) { var c = colorTable[Math.abs(i) % 128]; return 'hsla(' + c.h + ',' + (c.s * 0.6) + '%,' + (c.l * 0.5) + '%,' + (a||1) + ')'; }
// === 节拍检测 ===
var beatIntensity = 0;
var beatDecay = 0.88;
var bassHistory = [];
var BEAT_HIST_LEN = 30;
var beatTimes = [];
var BPM = 0;
var prevBeatState = false;
function detectBeat() {
var bass = 0;
for (var i = 0; i < 8; i++) bass += audioBins[i];
bass /= 8;
bassHistory.push(bass);
if (bassHistory.length > BEAT_HIST_LEN) bassHistory.shift();
var avg = 0;
for (var i = 0; i < bassHistory.length; i++) avg += bassHistory[i];
avg /= bassHistory.length;
var threshold = avg * 1.4 + 0.05;
var isBeat = bass > threshold && bass > 0.08;
if (isBeat && !prevBeatState) {
beatIntensity = Math.min(1, bass * 2);
var now = Date.now();
beatTimes.push(now);
if (beatTimes.length > 20) beatTimes.shift();
if (beatTimes.length > 2) {
var intervals = [];
for (var i = 1; i < beatTimes.length; i++) intervals.push(beatTimes[i] - beatTimes[i-1]);
var avgInterval = 0;
for (var i = 0; i < intervals.length; i++) avgInterval += intervals[i];
avgInterval /= intervals.length;
if (avgInterval > 0) {
var rawBPM = 60000 / avgInterval;
BPM = BPM * 0.7 + rawBPM * 0.3;
}
}
}
prevBeatState = isBeat;
beatIntensity *= beatDecay;
}
// === 鼠标追踪 ===
var mouse = { x: 0.5, y: 0.5, down: false };
var clickRipples = [];
canvas.addEventListener('mousemove', function(e) {
mouse.x = e.clientX / canvas.width;
mouse.y = e.clientY / canvas.height;
});
canvas.addEventListener('mousedown', function() { mouse.down = true; });
canvas.addEventListener('mouseup', function() { mouse.down = false; });
canvas.addEventListener('click', function(e) {
clickRipples.push({
x: e.clientX, y: e.clientY,
r: 0, maxR: 150 + Math.random() * 100,
alpha: 0.6, decay: 0.02 + Math.random() * 0.01
});
});
function renderClickRipples(w, h) {
for (var i = clickRipples.length - 1; i >= 0; i--) {
var cr = clickRipples[i];
cr.r += 4 + cr.maxR * 0.03;
cr.alpha -= cr.decay;
if (cr.alpha <= 0) { clickRipples.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(cr.x, cr.y, cr.r, 0, Math.PI * 2);
ctx.strokeStyle = hsl(Math.floor(cr.r * 0.5) % 128, cr.alpha);
ctx.lineWidth = 2;
ctx.stroke();
// 内环
if (cr.r > 20) {
ctx.beginPath();
ctx.arc(cr.x, cr.y, cr.r * 0.6, 0, Math.PI * 2);
ctx.strokeStyle = hslBright(Math.floor(cr.r * 0.5 + 40) % 128, cr.alpha * 0.5);
ctx.lineWidth = 1;
ctx.stroke();
}
}
if (clickRipples.length > 20) clickRipples.splice(0, clickRipples.length - 20);
}
// === 动态配色:节拍闪烁 + 情绪色调偏移 ===
var hueShift = 0;
var brightnessBoost = 0;
function applyDynamicColor(ts) {
brightnessBoost = beatIntensity * 0.15;
hueShift = Math.sin(ts / 8000) * 20;
}
// 覆盖 hsl 加入动态偏移
var _origHsl = hsl;
var _origHslBright = hslBright;
var _origHslDim = hslDim;
hsl = function(i, a) {
var c = colorTable[Math.abs(i) % 128];
return 'hsla(' + ((c.h + hueShift + 360) % 360) + ',' + c.s + '%,' + Math.min(95, c.l + brightnessBoost * 100) + '%,' + (a||1) + ')';
};
hslBright = function(i, a) {
var c = colorTable[Math.abs(i) % 128];
return 'hsla(' + ((c.h + hueShift + 360) % 360) + ',' + c.s + '%,' + Math.min(95, c.l + 30 + brightnessBoost * 100) + '%,' + (a||1) + ')';
};
hslDim = function(i, a) {
var c = colorTable[Math.abs(i) % 128];
return 'hsla(' + ((c.h + hueShift + 360) % 360) + ',' + (c.s * 0.6) + '%,' + (c.l * 0.5 + brightnessBoost * 50) + '%,' + (a||1) + ')';
};
// === 粒子系统 ===
var particles = [];
for (var i = 0; i < 50; i++) {
particles.push({
x: Math.random(), y: Math.random(),
vx: (Math.random() - 0.5) * 0.0003,
vy: -Math.random() * 0.0004 - 0.0001,
r: Math.random() * 1.8 + 0.5,
baseAlpha: Math.random() * 0.1 + 0.03,
phase: Math.random() * Math.PI * 2,
colorIdx: Math.floor(Math.random() * 128)
});
}
function renderParticles(w, h, energy, ts) {
var t = ts / 1000;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.x += p.vx + Math.sin(t * 0.5 + p.phase) * 0.0001 * (1 + energy * 3);
p.y += p.vy * (1 + energy * 0.8);
if (p.x < -0.02) p.x = 1.02;
if (p.x > 1.02) p.x = -0.02;
if (p.y < -0.02) { p.y = 1.02; p.x = Math.random(); }
var alpha = p.baseAlpha + energy * 0.25;
var radius = p.r * (1 + energy * 1.5);
ctx.beginPath();
ctx.arc(p.x * w, p.y * h, radius, 0, Math.PI * 2);
ctx.fillStyle = hsl(p.colorIdx, alpha);
ctx.fill();
}
}
// === 圆角矩形 ===
function roundRect(x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
if (w <= 0 || h <= 0) return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
// === Bars — 用全部128 bin中心对称展开 ===
function renderBars(w, h) {
var count = 128;
var totalW = w * 0.92;
var gap = Math.max(1, w * 0.0015);
var barW = (totalW - gap * (count - 1)) / count;
var maxH = h * 0.58;
var baseY = h * 0.78;
var offsetX = (w - totalW) / 2;
for (var i = 0; i < count; i++) {
var val = Math.min(1, audioBins[i] * sensitivity);
var barH = Math.max(0, val * maxH);
// 峰值重力
if (barH > peaks[i]) {
peaks[i] = barH;
peakVel[i] = 0;
} else {
peakVel[i] += PEAK_GRAVITY;
peakVel[i] *= PEAK_FRICTION;
peaks[i] -= peakVel[i];
if (peaks[i] < barH) { peaks[i] = barH; peakVel[i] = 0; }
}
if (barH < 0.3 && peaks[i] < 0.3) continue;
var x = offsetX + i * (barW + gap);
var y = baseY - barH;
var radius = Math.min(barW * 0.4, 4);
// 辉光(仅高能量 bin
if (val > 0.2) {
ctx.save();
ctx.shadowColor = hslBright(i, 0.5);
ctx.shadowBlur = 6 + val * 14;
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(x, y, barW, 1);
ctx.restore();
}
// 主体渐变
var grad = ctx.createLinearGradient(x, y, x, baseY);
grad.addColorStop(0, hslBright(i, 0.95));
grad.addColorStop(0.35, hsl(i, 0.88));
grad.addColorStop(0.8, hslDim(i, 0.65));
grad.addColorStop(1, hslDim(i, 0.15));
ctx.fillStyle = grad;
roundRect(x, y, barW, barH, radius);
ctx.fill();
// 峰值指示器
if (peaks[i] > barH + 2) {
var peakY = baseY - peaks[i];
ctx.fillStyle = hslBright(i, 0.9);
roundRect(x, peakY, barW, 2, 1);
ctx.fill();
}
// 底部反射
if (barH > 3) {
var refH = barH * 0.18;
var refGrad = ctx.createLinearGradient(x, baseY + 1, x, baseY + 1 + refH);
refGrad.addColorStop(0, hsl(i, 0.08));
refGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = refGrad;
ctx.fillRect(x, baseY + 1, barW, refH);
}
}
// 基线光
var lineGrad = ctx.createLinearGradient(offsetX, 0, offsetX + totalW, 0);
lineGrad.addColorStop(0, 'rgba(255,255,255,0)');
lineGrad.addColorStop(0.3, hsl(32, 0.1));
lineGrad.addColorStop(0.5, hsl(64, 0.15));
lineGrad.addColorStop(0.7, hsl(96, 0.1));
lineGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = lineGrad;
ctx.fillRect(offsetX, baseY - 0.5, totalW, 1);
}
// === Waveform ===
function renderWaveform(w, h) {
var count = 128;
var midY = h * 0.5;
var amp = h * 0.3 * sensitivity;
var points = [];
for (var i = 0; i < count; i++) {
points.push({ x: (i / (count - 1)) * w, y: midY - audioBins[i] * amp });
}
function drawSmooth(pts) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length - 1; i++) {
var cpx = (pts[i].x + pts[i+1].x) / 2;
var cpy = (pts[i].y + pts[i+1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, cpx, cpy);
}
ctx.lineTo(pts[pts.length-1].x, pts[pts.length-1].y);
}
// 填充
drawSmooth(points);
ctx.lineTo(w, midY);
ctx.lineTo(0, midY);
ctx.closePath();
var fillGrad = ctx.createLinearGradient(0, midY - amp, 0, midY);
fillGrad.addColorStop(0, hsl(32, 0.2));
fillGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = fillGrad;
ctx.fill();
// 主曲线
ctx.save();
drawSmooth(points);
ctx.strokeStyle = hslBright(32, 0.9);
ctx.lineWidth = 2.5;
ctx.shadowColor = hslBright(32, 0.6);
ctx.shadowBlur = 20;
ctx.stroke();
ctx.restore();
// 镜像
var mirror = points.map(function(p) { return { x: p.x, y: midY + (midY - p.y) * 0.4 }; });
drawSmooth(mirror);
ctx.lineTo(w, midY);
ctx.lineTo(0, midY);
ctx.closePath();
var mirGrad = ctx.createLinearGradient(0, midY, 0, midY + amp * 0.4);
mirGrad.addColorStop(0, hsl(32, 0.06));
mirGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = mirGrad;
ctx.fill();
ctx.save();
drawSmooth(mirror);
ctx.strokeStyle = hsl(32, 0.25);
ctx.lineWidth = 1.5;
ctx.shadowColor = hsl(32, 0.15);
ctx.shadowBlur = 10;
ctx.stroke();
ctx.restore();
}
// === Circular ===
var circularAngle = 0;
function renderCircular(w, h, ts) {
var cx = w / 2;
var cy = h * 0.48;
var count = 128;
var baseR = Math.min(w, h) * 0.1;
var maxLen = Math.min(w, h) * 0.28 * sensitivity;
circularAngle += 0.003;
var energy = 0;
for (var i = 0; i < count; i++) energy += audioBins[i];
energy /= count;
// 外环辉光
var outerR = baseR + maxLen * (1 + energy);
var outerGlow = ctx.createRadialGradient(cx, cy, baseR, cx, cy, outerR);
outerGlow.addColorStop(0, 'rgba(0,0,0,0)');
outerGlow.addColorStop(0.5, hsl(32, 0.03 + energy * 0.05));
outerGlow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = outerGlow;
ctx.fillRect(0, 0, w, h);
ctx.lineCap = 'round';
for (var i = 0; i < count; i++) {
var angle = (i / count) * Math.PI * 2 - Math.PI / 2 + circularAngle;
var val = Math.min(1, audioBins[i] * sensitivity);
var len = val * maxLen;
if (len < 1) continue;
var x1 = cx + Math.cos(angle) * baseR;
var y1 = cy + Math.sin(angle) * baseR;
var x2 = cx + Math.cos(angle) * (baseR + len);
var y2 = cy + Math.sin(angle) * (baseR + len);
var grad = ctx.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, hsl(i, 0.3));
grad.addColorStop(0.6, hsl(i, 0.8));
grad.addColorStop(1, hslBright(i, 0.95));
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = grad;
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * baseR / count) * 0.45);
ctx.stroke();
}
// 内核
var glowR = baseR * (1.5 + energy * 1.5);
var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
glow.addColorStop(0, hsl(32, 0.25 + energy * 0.2));
glow.addColorStop(0.3, hsl(32, 0.1));
glow.addColorStop(0.7, hsl(32, 0.03));
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy, baseR, 0, Math.PI * 2);
ctx.strokeStyle = hsl(32, 0.2);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// === Particles Viz — 粒子频谱 ===
var pvAngle = 0;
function renderParticlesViz(w, h, ts) {
var cx = w / 2, cy = h * 0.48;
var count = 128;
var baseR = Math.min(w, h) * 0.15;
var maxExpand = Math.min(w, h) * 0.22 * sensitivity;
pvAngle += 0.002;
var t = ts / 1000;
// 中心光晕
var coreGlow = ctx.createRadialGradient(cx, cy, 0, cx, cy, baseR * (1.5 + 0.3 * Math.sin(t * 0.8)));
coreGlow.addColorStop(0, hslBright(32, 0.12));
coreGlow.addColorStop(0.6, hsl(32, 0.04));
coreGlow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(cx, cy, baseR * 2, 0, Math.PI * 2);
ctx.fill();
ctx.lineCap = 'round';
for (var i = 0; i < count; i++) {
var val = Math.min(1, audioBins[i] * sensitivity);
var angle = (i / count) * Math.PI * 2 - Math.PI / 2 + pvAngle;
var expand = val * maxExpand;
var r = baseR + expand;
// 低频粒子更大
var sizeBoost = i < 32 ? (1 - i / 32) * 0.6 : 0;
var sz = 2 + val * 5 + sizeBoost * val * 4;
var px = cx + Math.cos(angle) * r;
var py = cy + Math.sin(angle) * r;
// 辉光拖尾
if (val > 0.3) {
var tailLen = expand * 0.6;
var tx = cx + Math.cos(angle) * (baseR + expand - tailLen);
var ty = cy + Math.sin(angle) * (baseR + expand - tailLen);
var grad = ctx.createLinearGradient(tx, ty, px, py);
grad.addColorStop(0, hslDim(i, 0));
grad.addColorStop(1, hslBright(i, val * 0.4));
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(px, py);
ctx.strokeStyle = grad;
ctx.lineWidth = sz * 0.6;
ctx.stroke();
}
// 粒子本体
ctx.beginPath();
ctx.arc(px, py, sz, 0, Math.PI * 2);
ctx.fillStyle = hslBright(i, 0.3 + val * 0.6);
ctx.fill();
// 高亮核心
if (val > 0.4) {
ctx.beginPath();
ctx.arc(px, py, sz * 0.4, 0, Math.PI * 2);
ctx.fillStyle = hslBright(i, 0.8);
ctx.fill();
}
}
// 基础环线
ctx.beginPath();
ctx.arc(cx, cy, baseR, 0, Math.PI * 2);
ctx.strokeStyle = hsl(32, 0.08);
ctx.lineWidth = 1;
ctx.stroke();
}
// === Ripples — 环形涟漪 ===
var rpAngle = 0;
function renderRipples(w, h, ts) {
var cx = w / 2, cy = h * 0.48;
var t = ts / 1000;
var ringCount = 7;
var baseR = Math.min(w, h) * 0.06;
var maxR = Math.min(w, h) * 0.38 * sensitivity;
rpAngle += 0.001;
// 频段分组:低/中低/中/中高/高
var bands = [
avgRange(0, 8), avgRange(8, 24), avgRange(24, 48),
avgRange(48, 80), avgRange(80, 128)
];
var totalEnergy = 0;
for (var b = 0; b < bands.length; b++) totalEnergy += bands[b];
totalEnergy /= bands.length;
for (var ring = 0; ring < ringCount; ring++) {
var ringT = (ring + 1) / ringCount;
var baseRingR = baseR + ringT * maxR;
var bandIdx = Math.min(Math.floor(ringT * bands.length), bands.length - 1);
var bandVal = Math.min(1, bands[bandIdx] * sensitivity);
var pulse = bandVal * maxR * 0.08;
ctx.beginPath();
var segments = 90;
for (var s = 0; s <= segments; s++) {
var angle = (s / segments) * Math.PI * 2 + rpAngle * (ring % 2 === 0 ? 1 : -1);
// 波浪扰动
var wave = 0;
for (var wi = 0; wi < 5; wi++) {
var freq = 2 + wi * 3;
var amp = bands[Math.min(wi, bands.length - 1)] * maxR * 0.03 * (1 - ringT * 0.5);
wave += Math.sin(angle * freq + t * (1 + wi * 0.5) + ring * 0.7) * amp;
}
var r2 = baseRingR + pulse + wave;
var px = cx + Math.cos(angle) * r2;
var py = cy + Math.sin(angle) * r2;
if (s === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
// 高频碎裂效果:虚线
var highVal = bands[bands.length - 1] * sensitivity;
if (highVal > 0.5 && ring > ringCount - 3) {
ctx.setLineDash([4 + (1 - highVal) * 12, 3 + highVal * 8]);
} else {
ctx.setLineDash([]);
}
var alpha = (0.15 + bandVal * 0.5) * (1 - ringT * 0.3);
var colorI = Math.floor(ringT * 127);
ctx.strokeStyle = hsl(colorI, alpha);
ctx.lineWidth = 1 + bandVal * 2.5;
ctx.stroke();
// 辉光
if (bandVal > 0.3) {
ctx.save();
ctx.shadowColor = hslBright(colorI, 0.4);
ctx.shadowBlur = 8 + bandVal * 12;
ctx.strokeStyle = hsl(colorI, alpha * 0.3);
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.restore();
}
}
ctx.setLineDash([]);
// 中心脉冲点
var pulseR = 4 + totalEnergy * 15;
var cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, pulseR);
cg.addColorStop(0, hslBright(32, 0.6));
cg.addColorStop(0.5, hsl(32, 0.2));
cg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = cg;
ctx.beginPath();
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
ctx.fill();
}
function avgRange(from, to) {
var s = 0;
for (var i = from; i < to && i < audioBins.length; i++) s += audioBins[i];
return s / (to - from);
}
// === Text Viz — 文字跳动 ===
function renderText(w, h, ts) {
var cx = w / 2, cy = h * 0.44;
var t = ts / 1000;
var count = 128;
var avgVolume = 0;
for (var i = 0; i < count; i++) avgVolume += Math.min(1, audioBins[i] * sensitivity);
avgVolume /= count;
// 频率光环 — 128 个点环绕
var haloR = Math.min(w, h) * 0.22;
for (var i = 0; i < count; i++) {
var val = Math.min(1, audioBins[i] * sensitivity);
if (val < 0.05) continue;
var angle = (i / count) * Math.PI * 2 - Math.PI / 2 + t * 0.15;
var pr = haloR + val * Math.min(w, h) * 0.08;
var px = cx + Math.cos(angle) * pr;
var py = cy + Math.sin(angle) * pr;
var sz = 1 + val * 3;
ctx.beginPath();
ctx.arc(px, py, sz, 0, Math.PI * 2);
ctx.fillStyle = hsl(i, 0.2 + val * 0.5);
ctx.fill();
}
// 光环线
ctx.beginPath();
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
ctx.strokeStyle = hsl(32, 0.06);
ctx.lineWidth = 1;
ctx.stroke();
// 主文字
var fontSize = 36 + avgVolume * 72;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(avgVolume * Math.PI / 10 * Math.sin(t * 2.5));
ctx.font = '900 ' + fontSize + 'px "Arial Black", "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 辉光层
var ci = Math.floor((t * 40) % 128);
ctx.shadowColor = hslBright(ci, 0.6 + avgVolume * 0.3);
ctx.shadowBlur = 15 + avgVolume * 30;
ctx.fillStyle = hsl(ci, 0.15 + avgVolume * 0.15);
ctx.fillText('♪ MUSIC ♪', 0, 0);
// 主描边
ctx.shadowBlur = 0;
ctx.strokeStyle = hslBright(ci, 0.7 + avgVolume * 0.2);
ctx.lineWidth = 1.5 + avgVolume;
ctx.strokeText('♪ MUSIC ♪', 0, 0);
// 主填充
var textGrad = ctx.createLinearGradient(-100, -fontSize / 2, 100, fontSize / 2);
textGrad.addColorStop(0, hslBright(ci, 0.9));
textGrad.addColorStop(0.5, hslBright(ci + 40, 0.95));
textGrad.addColorStop(1, hslBright(ci + 80, 0.9));
ctx.fillStyle = textGrad;
ctx.fillText('♪ MUSIC ♪', 0, 0);
ctx.restore();
// 底部辅助频谱条
var barCount = 64;
var barW = w * 0.5 / barCount;
var barMaxH = h * 0.08;
var barBaseY = h * 0.82;
var barOffsetX = w * 0.25;
for (var i = 0; i < barCount; i++) {
var val = Math.min(1, audioBins[i * 2] * sensitivity);
var bh = val * barMaxH;
if (bh < 1) continue;
ctx.fillStyle = hsl(i * 2, 0.3 + val * 0.4);
ctx.fillRect(barOffsetX + i * barW, barBaseY - bh, barW * 0.7, bh);
}
}
// === Stars Viz — 星空律动 ===
var starsData = null;
var shootingStars = [];
function renderStars(w, h, ts) {
var t = ts / 1000;
// 惰性初始化星星
if (!starsData || Math.abs(starsData._w - w) > 50 || Math.abs(starsData._h - h) > 50) {
starsData = { _w: w, _h: h, stars: [] };
for (var i = 0; i < 300; i++) {
starsData.stars.push({
x: Math.random(), y: Math.random(),
r: Math.random() * 2 + 0.5,
baseAlpha: Math.random() * 0.4 + 0.15,
phase: Math.random() * Math.PI * 2,
speed: Math.random() * 1.5 + 0.5,
colorIdx: Math.floor(Math.random() * 128)
});
}
}
var count = 128;
var energy = 0;
for (var i = 0; i < 64; i++) energy += audioBins[i];
energy /= 64;
var bassEnergy = 0;
for (var i = 0; i < 8; i++) bassEnergy += audioBins[i];
bassEnergy /= 8;
// 星云背景
var nebR = Math.max(w, h) * 0.6;
var neb = ctx.createRadialGradient(w * 0.3, h * 0.4, 0, w * 0.3, h * 0.4, nebR);
neb.addColorStop(0, hslDim(10, 0.02 + energy * 0.02));
neb.addColorStop(0.5, hslDim(60, 0.01));
neb.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = neb;
ctx.fillRect(0, 0, w, h);
var neb2 = ctx.createRadialGradient(w * 0.7, h * 0.6, 0, w * 0.7, h * 0.6, nebR * 0.8);
neb2.addColorStop(0, hslDim(90, 0.015 + energy * 0.015));
neb2.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = neb2;
ctx.fillRect(0, 0, w, h);
// 绘制星星
var stars = starsData.stars;
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var twinkle = Math.sin(t * s.speed + s.phase) * 0.3 + 0.7;
var alpha = s.baseAlpha * twinkle + energy * 0.35;
alpha = Math.min(1, alpha);
var sz = s.r * (1 + energy * 0.5);
ctx.beginPath();
ctx.arc(s.x * w, s.y * h, sz, 0, Math.PI * 2);
ctx.fillStyle = hslDim(s.colorIdx, alpha);
ctx.fill();
// 高亮星星加十字星芒
if (s.baseAlpha > 0.45 && alpha > 0.6) {
var spikeLen = sz * 3 * alpha;
ctx.strokeStyle = hsl(s.colorIdx, alpha * 0.3);
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(s.x * w - spikeLen, s.y * h);
ctx.lineTo(s.x * w + spikeLen, s.y * h);
ctx.moveTo(s.x * w, s.y * h - spikeLen);
ctx.lineTo(s.x * w, s.y * h + spikeLen);
ctx.stroke();
}
}
// 星座连线(高能量时)
if (energy > 0.15) {
var threshold = 0.08 * w; // 近邻距离阈值
ctx.lineWidth = 0.5;
for (var i = 0; i < Math.min(50, stars.length); i++) {
for (var j = i + 1; j < Math.min(50, stars.length); j++) {
var dx = (stars[i].x - stars[j].x) * w;
var dy = (stars[i].y - stars[j].y) * h;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < threshold) {
var lineAlpha = (1 - dist / threshold) * energy * 0.4;
ctx.beginPath();
ctx.moveTo(stars[i].x * w, stars[i].y * h);
ctx.lineTo(stars[j].x * w, stars[j].y * h);
ctx.strokeStyle = hslDim(stars[i].colorIdx, lineAlpha);
ctx.stroke();
}
}
}
}
// 流星 — 低频触发
if (bassEnergy > 0.25 && Math.random() < 0.3) {
shootingStars.push({
x: Math.random() * w * 0.8 + w * 0.1,
y: Math.random() * h * 0.3,
vx: (2 + Math.random() * 3) * (Math.random() > 0.5 ? 1 : -1),
vy: 3 + Math.random() * 4,
life: 1,
decay: 0.015 + Math.random() * 0.01,
colorIdx: Math.floor(Math.random() * 128)
});
}
// 更新绘制流星
for (var i = shootingStars.length - 1; i >= 0; i--) {
var ss = shootingStars[i];
ss.x += ss.vx;
ss.y += ss.vy;
ss.life -= ss.decay;
if (ss.life <= 0) {
shootingStars.splice(i, 1);
continue;
}
var tailX = ss.x - ss.vx * 8;
var tailY = ss.y - ss.vy * 8;
var grad = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, hslBright(ss.colorIdx, ss.life * 0.8));
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5 * ss.life;
ctx.lineCap = 'round';
ctx.stroke();
// 流星头部
ctx.beginPath();
ctx.arc(ss.x, ss.y, 2 * ss.life, 0, Math.PI * 2);
ctx.fillStyle = hslBright(ss.colorIdx, ss.life * 0.6);
ctx.fill();
}
// 限制流星数量
if (shootingStars.length > 15) shootingStars.splice(0, shootingStars.length - 15);
}
// === DNA Helix — DNA螺旋 ===
var dnaAngle = 0;
function renderDNA(w, h, ts) {
var cx = w / 2, cy = h / 2;
var t = ts / 1000;
var count = 64;
var amplitude = Math.min(w, h) * 0.2 * sensitivity;
var segH = h / count;
dnaAngle += 0.02 + beatIntensity * 0.05;
for (var i = 0; i < count; i++) {
var binIdx = Math.floor(i / count * 64);
var val = Math.min(1, audioBins[binIdx] * sensitivity);
var y = i * segH;
var phase = (i / count) * Math.PI * 4 + dnaAngle;
// 两条螺旋链
var x1 = cx + Math.sin(phase) * (amplitude * 0.6 + val * amplitude * 0.4);
var x2 = cx + Math.sin(phase + Math.PI) * (amplitude * 0.6 + val * amplitude * 0.4);
// 横档(碱基对连接线)
if (i % 2 === 0) {
var grad = ctx.createLinearGradient(x1, y, x2, y);
grad.addColorStop(0, hslBright(i * 2, 0.5 + val * 0.4));
grad.addColorStop(0.5, hsl(i * 2 + 32, 0.3 + val * 0.3));
grad.addColorStop(1, hslBright(i * 2 + 64, 0.5 + val * 0.4));
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.strokeStyle = grad;
ctx.lineWidth = 2 + val * 3;
ctx.stroke();
}
// 链节点
var sz1 = 3 + val * 4;
ctx.beginPath();
ctx.arc(x1, y, sz1, 0, Math.PI * 2);
ctx.fillStyle = hslBright(i * 2, 0.6 + val * 0.3);
ctx.fill();
ctx.beginPath();
ctx.arc(x2, y, sz1 * 0.8, 0, Math.PI * 2);
ctx.fillStyle = hslBright(i * 2 + 64, 0.6 + val * 0.3);
ctx.fill();
// 节拍时节点脉冲
if (beatIntensity > 0.3 && val > 0.5) {
ctx.beginPath();
ctx.arc(x1, y, sz1 * 2, 0, Math.PI * 2);
ctx.fillStyle = hslBright(i * 2, beatIntensity * 0.15);
ctx.fill();
}
}
// BPM 显示
if (BPM > 40) {
ctx.font = '600 12px "Arial", sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = hsl(32, 0.3 + beatIntensity * 0.3);
ctx.fillText(Math.round(BPM) + ' BPM', cx, h - 20);
}
}
// === Matrix Rain — 矩阵雨 ===
var matrixCols = null;
var matrixChars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
function renderMatrix(w, h, ts) {
var t = ts / 1000;
var fontSize = 14;
var cols = Math.floor(w / fontSize);
if (!matrixCols || matrixCols._len !== cols) {
matrixCols = { _len: cols, data: [] };
for (var i = 0; i < cols; i++) {
matrixCols.data.push({
y: Math.random() * h,
speed: 2 + Math.random() * 4,
chars: [],
trailLen: 8 + Math.floor(Math.random() * 15)
});
for (var j = 0; j < 30; j++) {
matrixCols.data[i].chars.push(matrixChars[Math.floor(Math.random() * matrixChars.length)]);
}
}
}
// 暗背景(较强拖尾)
ctx.fillStyle = 'rgba(4, 3, 12, 0.15)';
ctx.fillRect(0, 0, w, h);
var energy = 0;
for (var i = 0; i < 64; i++) energy += audioBins[i];
energy /= 64;
ctx.font = fontSize + 'px monospace';
ctx.textAlign = 'center';
for (var i = 0; i < cols; i++) {
var col = matrixCols.data[i];
var binIdx = Math.floor(i / cols * 128);
var binVal = Math.min(1, audioBins[binIdx] * sensitivity);
var speed = col.speed * (1 + binVal * 3 + beatIntensity * 2);
col.y += speed;
if (col.y > h + col.trailLen * fontSize) {
col.y = -col.trailLen * fontSize;
col.speed = 2 + Math.random() * 4;
}
// 绘制字符流
for (var j = 0; j < col.trailLen; j++) {
var charY = col.y - j * fontSize;
if (charY < -fontSize || charY > h + fontSize) continue;
var fade = 1 - j / col.trailLen;
var charI = Math.floor((i / cols) * 128);
if (j === 0) {
// 头部字符 — 最亮
ctx.fillStyle = hslBright(charI, 0.9 + binVal * 0.1);
} else if (j < 3) {
ctx.fillStyle = hsl(charI, fade * 0.7);
} else {
ctx.fillStyle = hslDim(charI, fade * 0.4);
}
// 随机切换字符
if (Math.random() < 0.02) {
col.chars[j % col.chars.length] = matrixChars[Math.floor(Math.random() * matrixChars.length)];
}
ctx.fillText(col.chars[j % col.chars.length], i * fontSize + fontSize / 2, charY);
}
}
}
// === Jellyfish — 水母漂浮 ===
var jfPhase = 0;
function renderJellyfish(w, h, ts) {
var cx = w / 2, cy = h * 0.45;
var t = ts / 1000;
var energy = 0;
for (var i = 0; i < 64; i++) energy += audioBins[i];
energy /= 64;
var bassEnergy = 0;
for (var i = 0; i < 8; i++) bassEnergy += audioBins[i];
bassEnergy /= 8;
jfPhase += 0.02 + energy * 0.03;
// 水母呼吸:低频驱动伞盖收缩
var breathe = Math.sin(jfPhase) * 0.3 + bassEnergy * 0.4;
var bodyW = Math.min(w, h) * 0.15 * (1 + breathe);
var bodyH = Math.min(w, h) * 0.1 * (1 - breathe * 0.5);
// 伞盖(椭圆弧)
ctx.save();
ctx.translate(cx, cy + Math.sin(t * 0.5) * 20);
// 伞盖辉光
var glowR = bodyW * 2;
var glow = ctx.createRadialGradient(0, -bodyH * 0.3, 0, 0, -bodyH * 0.3, glowR);
glow.addColorStop(0, hslBright(32, 0.08 + energy * 0.1));
glow.addColorStop(0.5, hsl(32, 0.03));
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, -bodyH * 0.3, glowR, 0, Math.PI * 2);
ctx.fill();
// 伞盖主体
ctx.beginPath();
ctx.ellipse(0, 0, bodyW, bodyH, 0, Math.PI, 0);
var bodyGrad = ctx.createRadialGradient(0, -bodyH * 0.5, 0, 0, 0, bodyW);
bodyGrad.addColorStop(0, hslBright(32, 0.4 + energy * 0.3));
bodyGrad.addColorStop(0.5, hsl(48, 0.25 + energy * 0.15));
bodyGrad.addColorStop(1, hslDim(64, 0.1));
ctx.fillStyle = bodyGrad;
ctx.fill();
// 伞盖描边
ctx.beginPath();
ctx.ellipse(0, 0, bodyW, bodyH, 0, Math.PI, 0);
ctx.strokeStyle = hslBright(32, 0.3 + energy * 0.3);
ctx.lineWidth = 1.5;
ctx.stroke();
// 触须
var tentCount = 12;
for (var i = 0; i < tentCount; i++) {
var tx = -bodyW + (i / (tentCount - 1)) * bodyW * 2;
var binI = Math.floor(i / tentCount * 64);
var binVal = Math.min(1, audioBins[binI] * sensitivity);
var tentLen = bodyH * (2 + binVal * 3 + beatIntensity * 1.5);
ctx.beginPath();
ctx.moveTo(tx, 0);
var wave1 = Math.sin(t * 1.5 + i * 0.8) * 15 * (1 + binVal);
var wave2 = Math.sin(t * 2 + i * 1.2) * 10 * (1 + binVal);
ctx.bezierCurveTo(
tx + wave1, tentLen * 0.3,
tx + wave2, tentLen * 0.6,
tx + wave1 * 0.5, tentLen
);
ctx.strokeStyle = hsl(binI * 2, 0.15 + binVal * 0.25);
ctx.lineWidth = 1 + binVal * 2;
ctx.stroke();
// 触须尖端小光点
if (binVal > 0.3) {
ctx.beginPath();
ctx.arc(tx + wave1 * 0.5, tentLen, 2 + binVal * 2, 0, Math.PI * 2);
ctx.fillStyle = hslBright(binI * 2, 0.4 + binVal * 0.3);
ctx.fill();
}
}
// 内部器官纹理
for (var i = 0; i < 5; i++) {
var organY = -bodyH * (0.2 + i * 0.15);
var organR = bodyW * (0.15 + Math.sin(t * 2 + i) * 0.05) * (1 + energy * 0.3);
ctx.beginPath();
ctx.arc(0, organY, organR, 0, Math.PI * 2);
ctx.fillStyle = hsl(i * 20, 0.06 + energy * 0.05);
ctx.fill();
}
ctx.restore();
}
// === Aurora Wave — 极光波浪 ===
function renderAurora(w, h, ts) {
var t = ts / 1000;
var layers = 5;
var energy = 0;
for (var i = 0; i < 64; i++) energy += audioBins[i];
energy /= 64;
// 极光幕帘 — 多层正弦叠加
for (var layer = 0; layer < layers; layer++) {
var layerT = layer / layers;
var baseY = h * (0.15 + layerT * 0.3);
var binStart = Math.floor(layerT * 80);
var binEnd = Math.min(128, binStart + 30);
ctx.beginPath();
ctx.moveTo(0, h);
var points = [];
for (var x = 0; x <= w; x += 3) {
var xFrac = x / w;
var binIdx = binStart + Math.floor(xFrac * (binEnd - binStart));
var binVal = Math.min(1, audioBins[binIdx] * sensitivity);
// 多重正弦叠加
var wave = Math.sin(xFrac * 4 + t * (0.3 + layer * 0.1) + layer * 1.5) * 30;
wave += Math.sin(xFrac * 7 + t * 0.5 + layer) * 15 * (1 + energy);
wave += Math.sin(xFrac * 2 + t * 0.2) * 20;
wave *= (1 + binVal * 2 + beatIntensity);
var y = baseY + wave - binVal * h * 0.1;
points.push({ x: x, y: y });
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
var colorI = Math.floor(layerT * 80) + 20;
var alpha = 0.04 + energy * 0.06 + layer * 0.01;
var grad = ctx.createLinearGradient(0, baseY - 60, 0, baseY + 80);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(0.3, hsl(colorI, alpha));
grad.addColorStop(0.6, hslBright(colorI + 20, alpha * 1.5));
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fill();
// 顶部亮线
if (energy > 0.1) {
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
if (i === 0) ctx.moveTo(points[i].x, points[i].y);
else ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = hslBright(colorI + 10, 0.15 + energy * 0.1);
ctx.lineWidth = 1 + energy;
ctx.stroke();
}
}
// 底部地平线微光
var horizonGrad = ctx.createLinearGradient(0, h * 0.85, 0, h);
horizonGrad.addColorStop(0, 'rgba(0,0,0,0)');
horizonGrad.addColorStop(1, hslDim(32, 0.05 + energy * 0.05));
ctx.fillStyle = horizonGrad;
ctx.fillRect(0, h * 0.85, w, h * 0.15);
}
// === Idle ===
function renderIdle(w, h, ts) {
var cx = w / 2;
var cy = h * 0.48;
var t = ts / 1000;
var breathR = Math.min(w, h) * 0.1 * (1 + 0.15 * Math.sin(t * 0.5));
var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, breathR * 2);
var ci = Math.floor((t * 8) % 128);
grad.addColorStop(0, hsl(ci, 0.08 + 0.04 * Math.sin(t * 0.4)));
grad.addColorStop(0.5, hsl(ci, 0.03));
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, breathR * 2, 0, Math.PI * 2);
ctx.fill();
for (var i = 0; i < 10; i++) {
var angle = (i / 10) * Math.PI * 2 + t * 0.15;
var dist = breathR * (2.5 + 0.8 * Math.sin(t * 0.6 + i * 1.2));
var px = cx + Math.cos(angle) * dist;
var py = cy + Math.sin(angle) * dist;
var pr = 1.2 + Math.sin(t * 0.8 + i) * 0.4;
ctx.beginPath();
ctx.arc(px, py, pr, 0, Math.PI * 2);
ctx.fillStyle = hsl(ci + i * 10, 0.12 + 0.05 * Math.sin(t + i));
ctx.fill();
}
}
// === 主渲染循环 ===
var FPS = 30, lastFrame = 0;
function render(ts) {
requestAnimationFrame(render);
if (ts - lastFrame < 1000 / FPS) return;
lastFrame = ts;
var w = canvas.width, h = canvas.height;
// 节拍检测
detectBeat();
// 动态配色偏移
applyDynamicColor(ts);
// 整体能量
var energy = 0;
for (var i = 0; i < 64; i++) energy += audioBins[i];
energy /= 64;
// 低频能量
var bassEnergy = 0;
for (var i = 0; i < 8; i++) bassEnergy += audioBins[i];
bassEnergy /= 8;
// 背景
ctx.fillStyle = 'rgba(4, 3, 12, 0.3)';
ctx.fillRect(0, 0, w, h);
// 节拍闪光
if (beatIntensity > 0.2) {
ctx.fillStyle = 'rgba(255,255,255,' + (beatIntensity * 0.04) + ')';
ctx.fillRect(0, 0, w, h);
}
// 中心氛围
var ambR = Math.max(w, h) * (0.4 + energy * 0.3);
var amb = ctx.createRadialGradient(w/2, h*0.5, 0, w/2, h*0.5, ambR);
amb.addColorStop(0, hsl(32, 0.03 + energy * 0.04));
amb.addColorStop(0.6, hsl(64, 0.01 + energy * 0.02));
amb.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = amb;
ctx.fillRect(0, 0, w, h);
// 粒子
renderParticles(w, h, energy, ts);
// 底部光晕
if (bassEnergy > 0.03) {
var btmGrad = ctx.createRadialGradient(w/2, h, 0, w/2, h, w * 0.5 * (1 + bassEnergy));
btmGrad.addColorStop(0, hsl(0, bassEnergy * 0.15));
btmGrad.addColorStop(0.5, hsl(32, bassEnergy * 0.05));
btmGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = btmGrad;
ctx.fillRect(0, h * 0.4, w, h * 0.6);
}
// 空闲检测
var binSum = 0;
for (var i = 0; i < audioBins.length; i++) binSum += audioBins[i];
if (binSum < 0.3) idleTime++;
else idleTime = 0;
if (idleTime > 60) {
renderIdle(w, h, ts);
return;
}
switch (vizStyle) {
case 'waveform': renderWaveform(w, h); break;
case 'circular': renderCircular(w, h, ts); break;
case 'particles': renderParticlesViz(w, h, ts); break;
case 'ripples': renderRipples(w, h, ts); break;
case 'text': renderText(w, h, ts); break;
case 'stars': renderStars(w, h, ts); break;
case 'dna': renderDNA(w, h, ts); break;
case 'matrix': renderMatrix(w, h, ts); break;
case 'jellyfish': renderJellyfish(w, h, ts); break;
case 'aurora': renderAurora(w, h, ts); break;
default: renderBars(w, h);
}
// 鼠标点击涟漪(所有模式共享)
renderClickRipples(w, h);
}
requestAnimationFrame(render);
</script>