1276 lines
42 KiB
HTML
1276 lines
42 KiB
HTML
<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>
|