新增: 音频可视化壁纸主题(WASAPI+FFT+Canvas)

This commit is contained in:
2026-05-30 22:29:19 +08:00
parent cd7247d880
commit 1f892fc390
13 changed files with 839 additions and 15 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

262
web/themes/audio-viz.html Normal file
View 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>