新增: 音频可视化壁纸主题(WASAPI+FFT+Canvas)
This commit is contained in:
262
web/themes/audio-viz.html
Normal file
262
web/themes/audio-viz.html
Normal file
@@ -0,0 +1,262 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user