263 lines
8.2 KiB
HTML
263 lines
8.2 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.5;
|
|
var colorScheme = window.__audioVizColors || 'neon';
|
|
var idleTime = 0;
|
|
var lastBinSum = 0;
|
|
|
|
// 暴露配置热更新接口
|
|
window.setAudioVizStyle = function(s) { vizStyle = s; };
|
|
window.setAudioSensitivity = function(s) { sensitivity = s; };
|
|
window.setAudioColorScheme = function(s) { colorScheme = s; };
|
|
|
|
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 getColor(i, total, alpha) {
|
|
var t = i / total;
|
|
switch (colorScheme) {
|
|
case 'ocean':
|
|
return 'hsla(' + (180 + t * 60) + ',70%,60%,' + alpha + ')';
|
|
case 'fire':
|
|
return 'hsla(' + (t * 60) + ',90%,55%,' + alpha + ')';
|
|
case 'rainbow':
|
|
return 'hsla(' + (t * 360) + ',80%,60%,' + alpha + ')';
|
|
default: // neon
|
|
return 'hsla(' + (220 + t * 80) + ',80%,60%,' + alpha + ')';
|
|
}
|
|
}
|
|
|
|
function getColorRGB(i, total) {
|
|
var t = i / total;
|
|
switch (colorScheme) {
|
|
case 'ocean':
|
|
return {r: 0, g: Math.floor(128 + t * 127), b: Math.floor(200 + t * 55)};
|
|
case 'fire':
|
|
return {r: Math.floor(200 + t * 55), g: Math.floor(t * 180), b: 0};
|
|
case 'rainbow':
|
|
var h = t * 360, s = 0.8, l = 0.6;
|
|
var c = (1 - Math.abs(2*l - 1)) * s;
|
|
var x = c * (1 - Math.abs((h/60)%2 - 1));
|
|
var m = l - c/2;
|
|
var r=0, g=0, b=0;
|
|
if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}
|
|
else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;}
|
|
return {r:Math.floor((r+m)*255), g:Math.floor((g+m)*255), b:Math.floor((b+m)*255)};
|
|
default:
|
|
return {r: Math.floor(80 + t * 100), g: Math.floor(50 + t * 80), b: Math.floor(180 + t * 75)};
|
|
}
|
|
}
|
|
|
|
// === Bars ===
|
|
function renderBars(w, h) {
|
|
var count = audioBins.length;
|
|
var barW = (w * 0.85) / count;
|
|
var gap = barW * 0.15;
|
|
var actualW = barW - gap;
|
|
var offsetX = w * 0.075;
|
|
|
|
for (var i = 0; i < count; i++) {
|
|
var val = Math.min(1, audioBins[i] * sensitivity);
|
|
if (val < 0.01) continue;
|
|
var barH = val * h * 0.65;
|
|
var x = offsetX + i * barW;
|
|
var y = h - barH;
|
|
|
|
var grad = ctx.createLinearGradient(x, y, x, h);
|
|
grad.addColorStop(0, getColor(i, count, 0.95));
|
|
grad.addColorStop(1, getColor(i, count, 0.15));
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(x, y, actualW, barH);
|
|
|
|
// 顶部高光
|
|
ctx.shadowColor = getColor(i, count, 0.6);
|
|
ctx.shadowBlur = 6;
|
|
ctx.fillRect(x, y, actualW, 2);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// 底部反射
|
|
var refGrad = ctx.createLinearGradient(x, h, x, h + barH * 0.3);
|
|
refGrad.addColorStop(0, getColor(i, count, 0.15));
|
|
refGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = refGrad;
|
|
ctx.fillRect(x, h, actualW, barH * 0.3);
|
|
}
|
|
}
|
|
|
|
// === Waveform ===
|
|
function renderWaveform(w, h) {
|
|
var count = audioBins.length;
|
|
var midY = h * 0.5;
|
|
var amplitude = h * 0.35 * sensitivity;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, midY);
|
|
for (var i = 0; i < count; i++) {
|
|
var x = (i / (count - 1)) * w;
|
|
var val = audioBins[i] * amplitude;
|
|
var y = midY - val;
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
var prevX = ((i - 1) / (count - 1)) * w;
|
|
var cpx = (prevX + x) / 2;
|
|
ctx.quadraticCurveTo(prevX, midY - audioBins[i-1] * amplitude, cpx, (midY - audioBins[i-1] * amplitude + y) / 2);
|
|
}
|
|
}
|
|
ctx.lineTo(w, midY);
|
|
|
|
// 镜像
|
|
ctx.moveTo(0, midY);
|
|
for (var i = 0; i < count; i++) {
|
|
var x = (i / (count - 1)) * w;
|
|
var val = audioBins[i] * amplitude * 0.6;
|
|
var y = midY + val;
|
|
if (i === 0) {
|
|
ctx.lineTo(x, y);
|
|
} else {
|
|
var prevX = ((i - 1) / (count - 1)) * w;
|
|
var cpx = (prevX + x) / 2;
|
|
ctx.quadraticCurveTo(prevX, midY + audioBins[i-1] * amplitude * 0.6, cpx, (midY + audioBins[i-1] * amplitude * 0.6 + y) / 2);
|
|
}
|
|
}
|
|
ctx.lineTo(w, midY);
|
|
|
|
ctx.strokeStyle = getColor(Math.floor(count / 2), count, 0.8);
|
|
ctx.lineWidth = 2;
|
|
ctx.shadowColor = getColor(Math.floor(count / 2), count, 0.5);
|
|
ctx.shadowBlur = 12;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// 填充区域
|
|
ctx.lineTo(w, midY);
|
|
ctx.lineTo(0, midY);
|
|
ctx.closePath();
|
|
ctx.fillStyle = getColor(Math.floor(count / 2), count, 0.08);
|
|
ctx.fill();
|
|
}
|
|
|
|
// === Circular ===
|
|
function renderCircular(w, h) {
|
|
var cx = w / 2;
|
|
var cy = h / 2;
|
|
var count = audioBins.length;
|
|
var baseR = Math.min(w, h) * 0.15;
|
|
var maxR = Math.min(w, h) * 0.4 * sensitivity;
|
|
|
|
for (var i = 0; i < count; i++) {
|
|
var angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
|
var val = Math.min(1, audioBins[i]);
|
|
var r = baseR + val * maxR;
|
|
var x1 = cx + Math.cos(angle) * baseR;
|
|
var y1 = cy + Math.sin(angle) * baseR;
|
|
var x2 = cx + Math.cos(angle) * r;
|
|
var y2 = cy + Math.sin(angle) * r;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.strokeStyle = getColor(i, count, 0.7 + val * 0.3);
|
|
ctx.lineWidth = Math.max(1, (Math.PI * 2 * baseR / count) * 0.6);
|
|
ctx.shadowColor = getColor(i, count, 0.4);
|
|
ctx.shadowBlur = val > 0.3 ? 8 : 0;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// 中心光晕
|
|
var avgBin = 0;
|
|
for (var i = 0; i < count; i++) avgBin += audioBins[i];
|
|
avgBin /= count;
|
|
if (avgBin > 0.05) {
|
|
var glowR = baseR * (0.8 + avgBin * 0.5);
|
|
var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
|
|
glow.addColorStop(0, getColor(Math.floor(count/2), count, avgBin * 0.3));
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, glowR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// === Idle 呼吸动画 ===
|
|
function renderIdle(w, h, ts) {
|
|
var cx = w / 2;
|
|
var cy = h / 2;
|
|
var t = ts / 1000;
|
|
var breathR = Math.min(w, h) * 0.15 * (1 + 0.15 * Math.sin(t * 0.8));
|
|
|
|
var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, breathR);
|
|
grad.addColorStop(0, getColor(0, 1, 0.15 + 0.05 * Math.sin(t)));
|
|
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, breathR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// 微弱粒子
|
|
for (var i = 0; i < 6; i++) {
|
|
var angle = (i / 6) * Math.PI * 2 + t * 0.3;
|
|
var dist = breathR * (1.5 + 0.3 * Math.sin(t * 1.2 + i));
|
|
var px = cx + Math.cos(angle) * dist;
|
|
var py = cy + Math.sin(angle) * dist;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = getColor(i, 6, 0.2 + 0.1 * 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;
|
|
|
|
// 半透明背景实现拖尾效果
|
|
ctx.fillStyle = 'rgba(5, 5, 15, 0.25)';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// 检测空闲
|
|
var binSum = 0;
|
|
for (var i = 0; i < audioBins.length; i++) binSum += audioBins[i];
|
|
if (binSum < 0.5) {
|
|
idleTime++;
|
|
} else {
|
|
idleTime = 0;
|
|
}
|
|
|
|
if (idleTime > 60) { // ~2 seconds at 30fps
|
|
renderIdle(w, h, ts);
|
|
return;
|
|
}
|
|
|
|
switch (vizStyle) {
|
|
case 'waveform': renderWaveform(w, h); break;
|
|
case 'circular': renderCircular(w, h); break;
|
|
default: renderBars(w, h);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(render);
|
|
</script>
|